多线程积累:JMM模型

(一)前言

学习多线程,要理解java内存模型,才能理解多线程情况下,数据的变化,指令的运行等,才能更好的了解多线程的运行情况和日常使用的注意点。

(二)JMM与硬件内存结构

java内存模型与硬件内存结构.png

如上图所示,可以看到JMM的大概结构与硬件内存结构之间的关系,每个线程只能访问自己工作内存的数据,工作内存中存储着主内存中变量复制的副本,这两个内存的数据可以存储在硬件内存中的任一地方,并没有特殊划分。
JMM只是一种抽象的概念,是一种规则,并不真实存在,对于计算机而言,并不划分工作内存和主内存,而是都存储在计算机主内存中。

(三)JMM的三种特性

1.原子性

在多线程环境下,一个操作一旦开始就不会被其他线程影响。
比如一个静态变量,被两个线程同时进行操作,无论如何运行,最后的结构必定是两个线程中的一种结果。
特例:32位的系统,如果操作long或者double,由于操作位数问题,最终的结果可能并不是两个线程中的任一结果。
其实,在上述描述中,有一点无论如何运行,在计算机执行程序中,为了提高性能,编译器和处理器会对指令进行重排。

指令重排
  • (1)编译器重排
    简单的举个例子:
    主线程:
d=3;
c=3;

线程A:

a=c;
d=1;

线程B:

b=d;
c=2;

在以上两个线程之前,对c和d进行赋值,从程序的执行顺序来说,似乎不可能存在a=2,b=1的情况,但是指令重排之后,可能存在:
线程A:

d=1;
a=c;

线程B:

c=2;
b=d;

此时,看起来就更可能存在a=2,b=1的情况,所以,多线程情况下,对变量能否保持一致是不可预知的。

  • (2)处理器重排
    简单举个例子:
a=b+c;
d=a-e

在上述代码里面,落实到指令可以理解为:

  • 1.把b的值加载到寄存器
  • 2.把c的值加载到寄存器
  • 3.将b和c相加得到a
  • 4.将a加载到寄存器
  • 5.把e的值加载到寄存器
  • 6.将a减e得到d
  • 7.将d加载到寄存器。

其实上面的指令有个优化的点,就是将步骤5提前到2之后,因为步骤3和4都需要前面数据准备好之后才能进行,所以会进行中断,此时中断,会影响5的运行,将5提前,可以提高CPU的性能。
重排保证了串行语义的执行,但是在多线程的环境下,这样是毁灭性的,导致结果的不可预知性。

如下代码:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

在单线程的场景下,先调用writer(),再次调用read(),得到的结果是i=2
在多线程的场景下,指令重排之后,read()方法在读到flagtrue的情况下,可能误读a=0,此时得到的结果为i=1

2.有序性

有序性是指在单线程的执行代码,我们可以认为代码的执行是按照顺序执行的,但是在多线程场景下,因为指令重排,导致最终的指令可能是乱序的,在本线程内,所有操作都视为有序的,但是多线程下,存在共享变量,一个线程需要观察另一个线程,所以操作都是无序的。

3.可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。这个概念仅代表在并发程序上的概念。由于每个线程会将共享变量拷贝到自己的工作线程中,由于指令重排的情况,也会存在可见性的问题,导致结果不是预期的结果。

(四)JMM提供的解决方案

针对以上的三种特性在多线程环境下的问题,JMM提供了相应的解决方案。

  • 原子性问题
    除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。
  • 可见性问题
    可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
  • 有序性问题
    对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,关于volatile稍后会进一步分析。

同时,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

happens-before 原则

  • 1.程序顺序原则
    即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  • 2.锁规则
    解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  • 3.volatile规则
    volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  • 4.线程启动规则
    线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
  • 5.传递性
    A先于B ,B先于C 那么A必然先于C
  • 6.线程终止规则
    线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 7.线程中断规则
    对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  • 8.对象终结规则
    对象的构造函数执行,结束先于finalize()方法

(五)volatile

volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用:

  • 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。
  • 禁止指令重排序优化。

volatile的可见性

关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性。

volatile禁止重排优化

禁止重排其实在单例模式中已经有提现,就是单例模式中的双重校验锁模式。
instance = new Singleton();伪代码如下:

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

如果去掉volatile,则可重排优化为:

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

以上可以发现,当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。

你可能感兴趣的:(多线程积累:JMM模型)