讲到Java并发,多线程编程,一定避免不了对关键字volatile的了解,那么如何来认识volatile,从哪些方面来了解它会比较合适呢?
个人认为,既然是多线程编程,那我们在平常的学习中,工作中,大部分都接触到的就是线程安全的概念。
而线程安全就会涉及到共享变量的概念,所以首先,我们得弄清楚共享变量是什么,且处理器和内存间的数据交互机制是如何导致共享变量变得不安全。
共享变量
能够在多个线程间被多个线程都访问到的变量,我们称之为共享变量。共享变量包括所有的实例变量,静态变量和数组元素。他们都被存放在堆内存中。
处理器与内存的通信机制
大家都知道处理器是用来做计算的,且速度是非常快的,而内存是用来存储数据的,且其访问速度相比处理器来说,是慢了好几个级别的。那么当处理器需要处理数据时,如果每次都直接从内存拿数据的话,就会导致效率非常低,因此在现代计算机系统中,处理器是不直接跟内存通信的,而是在处理器和内存之间设置了多个缓存,也就是我们常常听到的L1, L2, L3等高速缓存。
具体架构如下所示:
memory_processor_communication.png
处理器都是将数据从内存读到自己内部的缓存中,然后在缓存中对数据进行修改等操作,结束后再由缓存写到回主存中去。
如果一个共享变量 X,在多线程的情况下,同时被多个处理器读到各自的缓存中去,当其中一个处理器修改了X的值,改成Y了,先写回了内存,而此时另外一个处理器,又将X改成Z,再写回内存,那么之前的Y就会被覆盖掉了。
这种情况下,数据就已经有问题了,这种因为多线程操作而导致的异常问题,通常我们就叫做线程不安全。
memory_processor_communication_core1.png
memory_processor_communication_core2.png
如上述两图所示,X的变量同时被不同的处理器修改成各自的Y和Z,那么如何避免这种情况呢?
这就涉及到了Java内存模型中的可见性的概念。
Java内存模型之可见性
可见性,意思就是说,在多线程编程中,某个共享变量在其中一个线程被修改了,其修改结果要马上能够被其他线程看到,拿上面的例子来说,也就是当X在其中一个处理器的缓存中被修改成Y了, 另一个处理器必须能够马上知道自己缓存中的X已经被修改成Y了,当此处理器要拿此变量去参与计算的时候,必须重新去内存中将此变量的值Y读到缓存中。
而一个变量,如果被声明成violate,那么其就能保证这种可见性,这就是volatile变量的作用了。
volatile
那么 volatile 变量能够保证可见性的实现原理是什么?
声明成volatile的变量,在编译成汇编指令的时候,会多出以下一行:
0x0bca13ae:lock addl $0x0,(%esp) ;
这一句指令的意思是在寄存器上做一个+0的空操作,但这条指令有个Lock前缀。
而处理器在处理Lock前缀指令时,其实是声言了处理器的Lock#信号。
在之前的处理器中,Lock#信号会导致传输数据的总线被锁定,其他处理器都不能访问总线,从而保证处理Lock指令的处理器能够独享操作数据所在的内存区域。
但由于总线被锁住,其他的处理器都被堵住了,影响多处理器执行的效率。在后来的处理器中,声言Lock#信号的处理器,不会再锁住总线,而是检查到数据所在的内存区域,如果是在处理器的内部缓存中,则会锁定此缓存区域,将缓存写回到内存当中,并利用缓存一致性的原则来保证其他处理器中的缓存区域数据的一致性。
缓存一致性
缓存一致性原则会保证一个在缓存中的数据被修改了,会保证其他缓存了此数据的处理器中的缓存失效,从而让处理器重新去内存中读取最新修改后的数据。
在实际的处理器操作中,各个处理器会一直在总线上嗅探其内部缓存区域中的内存地址在其它处理器的操作情况,一旦嗅探到某处理器打算修改某内存地址,而此内存地址刚好也在自己内部的缓存中,则会强制让自己的缓存无效。当下次访问此内存地址的时候,则重新从内存当中读取新数据。
volatile不仅保证了共享变量在多线程间的可见性,其还保证了一定的有序性。
有序性
何谓有序性呢?
事实上,java程序代码在编译器阶段和处理器执行阶段,为了优化执行的效率,有可能会对指令进行重排序。
如果一些指令彼此之间互相不影响,那么就有可能不按照代码顺序执行,比如后面的代码先执行,而之前的代码则慢执行,但处理器会保证结束时的输出结果是一致的。
以上的这种情况就说明指令有可能不是有序的。
volatile变量,上面我们看过其汇编指令,会多出一条Lock前缀的指令,这条指令能够 保证,在这条指令之前的所有指令全部执行完毕,而在这条指令之后的所有指令全部未执行,也相于在这里立起了一道栅栏,称之为内存栅栏,而更通俗的说法,则是内存屏障。
那么有了这道屏障,volatile变量就禁止了指令的重排序,从而保证了指令执行的有序性。
所有对volatile变量的读操作一定发生在对volatile变量的写操作之后。这同时也说明了volatile变量在多个线程之间能够实现可见性的原理。所以各种规定和操作,其实之间互有关联,彼此依赖,才能更好地保证指令执行的准确和效率。
内存屏障
在上面我们也引出了内存屏障的概念,也知道了,其实它就是一组处理器的操作指令。
插入一个内存屏障,则相当于告诉处理器和编译器先于这个指令的必须先执行,后于这个指令的必须后执行。
image
内存屏障另一个作用是强制更新一次不同CPU的缓存。
例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。
这再仔细一想,不就是上面所说的volatile的作用吗?
所以,内存屏障,可见性,有序性,缓存一致性原则,在java并发中各种各样的名词,本质上可能就只是同一种现象或者同一种设计,从不同的角度观察和探讨所得出的不同的解释。
下一篇文章
Java并发系列之synchronized
参考资料
作者:苟诞
链接:https://www.jianshu.com/p/ababc4b1ee97