高性能编程——多线程并发编程Java基础篇之线程安全之可见性问题

Java内存模型(JMM)详解

Java代码运行时的过程:
在这里插入图片描述
靠虚拟机实现了跨平台性。而为了实现该效果,必须有一个规范,这就是《Java虚拟机规范》。

语言规范和虚拟机规范

一门语言的规范规定了如何运用对应的语言,而虚拟机规范则是将如何解释运行一段字节码的规范,具体效果如下图:
在这里插入图片描述

通俗点说,就是Java语言规范就是描述Java应该是怎么样的,而JVM规范就是描述JVM应该是怎么样的。

内存模型和JVM的关系

我们的内存模型其实只是一种规则,包含了多线程程序和单线程程序的语义,当多个线程修改了共享内存中的值的时候,应该读取到哪个值的规则。但是这部分规范又不同于硬件体系结构的内存模型,所以这些语义被称为Java编程语言内存模型。
它们没有规定如何执行多线程程序,只是定了规则,而如何实现是由JVM来实现的。
所以内存模型和JVM的关系就是规则制定者和执行者的关系。

多线程中的问题

  1. 所见非所得
  2. 无法肉眼去检测程序的准确性
  3. 不同的运行平台会有不同的表现
  4. 错误很难重现

所见非所得

首先来看一段代码:

public class Demo1Visibility {
    private boolean isRunning = true;
    private int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        new Thread(()->{
            int i = demo.i;
            System.out.println("here i am...");
            while(demo.isRunning){
                i++;
            }
            System.out.println(i);
        }).start();
        //线程睡眠3秒
        Thread.sleep(3000l);
        demo.isRunning=false;
        System.out.println("shutdown......");
    }
}

很多有点基础的朋友认为输出的结果应该是:

here i am
一个数字
shutdown…

然而看一下idea的输出:
在这里插入图片描述
可以看到thread线程陷入了死锁,也就是说isRunning一直处于true的状态(当然也有人有出现数字的情况,这都是正常的现象,后面会细说),作为共享变量的demo.isRunning在thread中一直都是true,这是怎么回事呢?

这其实与jdk的位数以及JVM的参数有关,当我们是32位jdk且JVM中设置JIT为-client(客户端)的时候就可以打印,其他的情况是不打印的。

打印情况图

可见性问题成因思考

所谓可见性问题就是明明一个共享数据已经被一个线程写入了新值,另一个线程却无法读到它的现象。

会不会是高速缓存的原因

高速缓存是在CPU和RAM内存之间的一块区域,主要存放读取率比较高的数据。那我们这里的可见性问题是不是由于高速缓存导致的呢?我们只能说理论上高速缓存是有可能导致数据可见性问题的,但是高速缓存会被很快同步,因为高速缓存协议的存在(详见:高性能编程——多线程并发编程Java基础篇之CPU缓存和内存屏障),所以不会是高速缓存导致的。

真正的罪魁祸首——CPU指令重排序

如果对这个概念有点模糊的可以先去看一下上文中的链接,里面有对Java编译器(JIT)和微处理器(CPU)重排序的详解。我们明白如果在一个线程的时候,CPU为了提高使用效率是会对一些不改变程序语义的代码执行顺序进行重排序的,但是它是不保证在多线程的情况下也保证语义不变,也就是说如果多线程环境下,程序的语义因为重排序被改变也是合法的,如下图:
在这里插入图片描述
这样就导致原先的思想并没有被正确执行。

JIT编译器(Just In Time Compiler)

Java的编译过程其实是javac先把源代码编译成.class字节码,然后JVM再解释执行class字节码,但是当方法频繁被调用或者方法体中有多次循环的时候就会升级成JIT编译执行,也就是讲这一部分代码(热点代码)编译执行:
在这里插入图片描述
上面的图片就是JIT编译的实例。

真正成因

我们已经明白了JIT和重排序,就应该明白了while中的代码其实已经被优化之后编译执行了,具体的效果如下:
在这里插入图片描述
当f读取了为true的isRunning之后(基本类型是值传递),就一直是true了,就算isRunning改为了false,也没卵用。说白了就是JIT编译很激进,当发现你的isRunning一只是true的话就别读了,这里就是true了,没有回旋的余地。

该如何解决可见性问题

文章上部分已经明确了问题的成因,那么要如何解决呢?这里就要用到一个关键字:volatile

volatile

可见性问题:让一个线程对共享变量的修改,能够及时的被其他线程看到。

Java内存模型规定:对volatile变量v的写入,与所有其他线程后续对v的读同步

在这里插入图片描述
在JVM规范中,指明了volatile不能被缓存。

要满足这些条件,所以volatile关键字就有这些功能:

  1. 禁止缓存;volatile变量的访问控制符会加个ACC_VOLATILE
  2. 对volatile变量相关的指令不做重排序;

我们在上述代码中的isRunning再加一个修饰词volatile,再打印一次结果:在这里插入图片描述
可以清晰的看到,已经实现了我们想要的效果。

Shared Variables定义

可以在线程之间共享的内存称为共享内存或堆内存。
所有实例字段(new 出来的对象)、静态字段(静态变量)和数组元素都存储在堆内存中,这些字段和数组都是标题中提到的共享变量。只有这些有必要去用volatile去约束,因为只有他们是会被线程共享的。

冲突

在多线程环境中,如果至少一个访问是写操作,那么对同一个变量的两次访问是冲突的。
这些能被多个线程访问的共享变量是内存模型规范的对象。

线程间操作的定义

  1. 线程间操作指:一个程序执行的操作可以被其他线程感知或被其他线程直接影响。
  2. Java内存模型只描述线程间操作,不描述线程内操作,线程内操作按照线程内语义执行。

常见的线程间操作有:read操作、write操作、volatile read操作、volatile write操作、Lock.(锁monitor)、Unlock、线程的第一个和最后一个操作、外部操作

所有线程间的操作,都存在可见性问题,JMM需要对其进行规范。

对于同步的规则定义

  • 对volatile变量v的写入,与所有其他线程后续对v的读同步(也就是说v的值变了之后,其他线程能够同步的读取,不会存在读取缓存的数据的情况)。
  • 对于监视器m的解锁与所有后续操作对于m的加锁同步:即当一个线程获得m锁之后,其间他所有对共享变量的操作都会同步通知给其他取得m锁的线程,就如下图:
    在这里插入图片描述
    线程2在加锁其间对共享内存进行了操作,之后解锁了再由线程1取得了锁,而线程2对共享内存进行的操作都会同步到线程1的变量中。

Word Tearing字节处理

有些处理器没有提供写单个字节的功能。在这样的处理器上需要更新byte数组,若只是简单地读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。这个问题被称为“字分裂(Word Tearing)”,更新单个字节有难度的处理器,就要用其他方式来解决问题。因此,编程的时候尽量不要对byte[]中的元素进行重新赋值,更不要在多线程 程序中这样做。

发布了37 篇原创文章 · 获赞 10 · 访问量 721

猜你喜欢

转载自blog.csdn.net/weixin_41746577/article/details/103851391