谈谈volatile

问题的引入

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主内存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。为了处理这个问题,在CPU里面就有了高速缓存的概念。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

 

图一(注:L1、L2、L3表示CPU核心中的高速缓存)

Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。线程工作内存一个抽象的描述,工作内存中主要包括两个部分,一个是属于该线程私有的栈,二是对主存部分变量拷贝的寄存器(包括程序计数器PC和CPU工作的高速缓存区)。

 

图二

有了以上概念,我们进一步谈谈多线程情况下产生线程不安全的原因。为了提高计算机的处理速度,CPU不会直接和内存进行通信,而是将内存中的数据拷贝到高速缓存中进行操作,当多个线程的共享数据被拷贝到高速缓存后,各个线程对应的那块高速缓存彼此不可见,而各高速缓存中的数据被CPU修改后不知道何时会被写入主内存中,这就极有可能导致别的线程读不到最新数据,从而造成数据不同步的线程安全问题。

Java代码编译后会变成字节码在JVM中运行,而字节码最终需要转换成汇编语言在CPU上执行,因此Java的并发机制必然依赖于JVM的实现和CPU对指令的执行情况。

volatile的作用及其实现原理

volatile的作用主要有两点,一是保证变量的可见性,另外一个是保证代码执行的有序性。

若有这样一行代码:private static volatile LazySingleton instance = new LazySingleton();那么其转换成汇编指令的时候大概是这样:0x0000000002931351:lock add dword ptr [rsp],0h  ;*putstatic instance; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)。加了volatile关键字的变量进行写操作时会多出含有有lock前缀汇编指令,而lock前缀会引发下面的事情:一是当前缓存行中的数据会立即写入到内存中去,二是这一写入内存的操作会导致其它高速缓存中缓存了该数据内存地址的数据无效,并且会利用缓存一致性协议来保证其他处理器中的缓存数据的一致性,从而保证变量的可见性。

实际上,当我们把代码写好之后,虚拟机不一定会按照我们写的代码的顺序来执行。例如对于这两句代码:int a = 1;int b = 2;你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。那么为什么要进行重排序呢?你想啊,假如执行 int a = 1这句代码需要100ms的时间,但执行int b = 2这句代码需要1ms的时间,并且先执行哪句代码并不会对a,b最终的值造成影响。那当然是先执行int b = 2这句代码了。所以,虚拟机在进行代码编译优化的时候,对于那些改变顺序之后不会对最终变量的值造成影响的代码,是有可能将他们进行重排序的。那么重排序之后真的不会对代码造成影响吗?实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题的。具体请看下面的代码

public class NoVisibility{

    private static boolean ready;

    private static int number;

    private static class Reader extends Thread{

        public void run(){

        while(!ready){

            Thread.yield();

        }

        System.out.println(number);

    }

}

    public static void main(String[] args){

        new Reader().start();

        number = 42;

        ready = true;

    }

}

这段代码最终打印的一定是42吗?如果没有重排序的话,打印的确实会是42,但如果number = 42和ready = true被进行了重排序,颠倒了顺序,那么就有可能打印出0了,而不是42。(因为number的初始值会是0).因此,重排序是有可能导致线程安全问题的。如果一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。例如把上面中的number声明为volatile,那么number = 42一定会比ready = true先执行。不过这里需要注意的是,虚拟机只是保证这个变量之前的代码一定比它先执行,但并没有保证这个变量之前的代码不可以重排序。之后的也一样。

缓存一致性协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。线程中的处理器会一直在总线上嗅探其内部缓存中的内存地址在其他处理器的操作情况(但是注意,当处理器读取内存中的值后进行写操作前这段时间即便内存中的值改变了,其高速缓存中的值仍不会失效,可以认为这期间线程中的处理器没有在总线上嗅探其内部缓存中的内存地址在其他处理器的操作情况,这为volatile不能保证线程的安全埋下了伏笔),一旦嗅探到某处处理器打算修改其内存地址中的值,而该内存地址刚好也在自己的内部缓存中,那么处理器就会强制让自己对该缓存地址的无效。所以当该处理器要访问该数据的时候,由于发现自己缓存的数据无效了,就会去主存中访问。

为什么说volatile不保证变量在多线程下的安全性?

     内存屏障(lock前缀指令)会把这个屏障前写入的数据刷新到内存,这样任何试图读取该数据的线程将得到最新值。但当处理器读取内存中的值后进行写操作前这段时间即便内存中的值改变了,其高速缓存中的值仍不会失效,这为volatile不能保证线程安全埋下了伏笔。这样如果有一个变量i = 0用volatile修饰,两个线程对其进行i++操作,如果线程1从内存中读取i=0进了缓存,然后把数据读入寄存器,之后时间片用完了,然后线程2也从内存中读取i进缓存,因为线程1已进行读操作还未执行写操作,然后线程2执行完毕,内存中i=1,然后线程1又开始执行,然后将数据写回缓存再写回内存,结果还是1。

参考文献

  1. 《Java并发编程的艺术》 作者:方腾飞
  2. https://zhuanlan.zhihu.com/p/42497046
  3. https://www.cnblogs.com/xrq730/p/7048693.html#undefined

猜你喜欢

转载自www.cnblogs.com/hzdzkjdxygz/p/10486769.html