枯燥的Java并发 - volatile

volatile是一个特征修饰符,它的的作用是作为指令关键字,确保本条指令不会因编译器优化而省略,且要求每次直接读取最新值。

JMM规范介绍

Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。因此,不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

线程,工作内存,主内存工作交互图(基于JMM规范):


image.png

JMM与JVM内存区域的划分是不同的概念层次,更恰当的说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。

通过上面对JMM的介绍,我们知道了各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。线程在工作内存进行操作后何时会写到主内存中?这个时机对普通变量是没有规定的,而针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证数据的“可见性”。

volatile 的特性

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
  • 禁止进行指令重排序。(实现有序性)
  • volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。

volatile 的实现原理

可见性

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了 volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

因此,经过分析我们可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

线程操作数据流程图


image.png
有序性

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。


image.png

指令重排序:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

JVM中提供了四类内存屏障来禁止指令重排优化

屏障类型 指令示例 说明
LoadLoad Load1; LoadLoad; Load2 保证load1的读取操作在load2及后续读取操作之前执行
StoreStore Store1; StoreStore; Store2 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStore Load1; LoadStore; Store2 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
StoreLoad Store1; StoreLoad; Load2 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

JMM针对编译器制定的volatile重排序规则表

第一个操作 第二个操作:普通读写 第二个操作:volatile读 第二个操作:volatile写
普通读写 可以重排 可以重排 不可以重排
volatile读 不可以重排 不可以重排 不可以重排
volatile写 可以重排 不可以重排 不可以重排

volatile写插入内存屏障后生成的指令序列示意图


image.png

volatile读插入内存屏障后生成的指令序列示意图


image.png
原子性

CPU缓存一致性协议(MESI)为了保证高效缓存的数据一致性,它将读到的每个缓存行(cache line 一般为64Bytes)使用4种状态进行标记(使用额外的两位(bit)表示),分别是修改(modify)、独占(exclusive)、共享(shared)、失效(invalid)。

image.png

虽然有MESI协议可以保证缓存一致性,但是如果有一个线程在正要进行+1的时候被挂起了,而另一个线程则正好执行完了x+=1的操作,此时回到第一个线程继续执行(该变量已经被寄存器读取了,此时并不会再次读取内存),这样就会导致一个错误的数据。因此,volatile只能保证内存的可见性,无法保证原子性的问题。
所以在多线程中的单例模式都会在获取实例的方法上加上一个synchronized关键字,以确保只会生成一个对象。

你可能感兴趣的:(枯燥的Java并发 - volatile)