并发volatile关键字如何保证可见性和有序性及底层实现原理

it2023-07-26  74

Volatile用法

首先我们先了解一下volatile关键字的用法 ,volatile被喻为轻量级的"synchronized",它只是一个变量修饰符,只能用来修饰变量不能修饰方法和代码块。 经典的用法:双重校验锁实现单例

public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }

上面这段程序使用了volatile关键字来修饰可能被多个线程同时访问到的singleton

volatile与可见性:

先说一下可见性,所谓的可见性就是指可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

缓存一致性协议

现代处理器为了提高处理速度,在处理器和内存之间增加了多级缓存,处理器不会直接去和内存通信,将数据读到内部缓存中再进行操作。由于引入了多级缓存,就存在缓存数据不一致问题。 什么是缓存一致性协议呢? 每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。 volatile是两条实现原则: 1.Lock前缀指令会引起处理器缓存会写到内存 当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中 2.一个处理器的缓存回写到内存会导致其他处理器的缓存失效 处理器使用嗅探技术保证内部缓存 系统内存和其他处理器的缓存的数据在总线上保持一致。

综合上面两条实现原则,我们了解到:如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。 为了保证内存的可见性,除了缓存一致性协议还有一个happends-before关系

注意:

数组与对象实例中的 volatile,针对的是引用,对象获数组的地址具有可见性,但是数组或对象内部的成员改变不具备可见性。

volatile写—读建立的happends-before关系

volatile的写—读与锁的释放—获取有着相同的内存效果,所以说一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,执行效果是一样的。先简单介绍一下happends-before happends-before法则 1.程序次序法则:按照代码顺序执行 2.监视器锁法则:一个unlock操作要先于同一个锁的lock操作 3.volatile变量法则:对volatile域的写入操作happends-before于每一个后续对同一域的读操作 4.线程启动法则:在一个线程里,对Thread.start()的调用会先于Thread.run(); 5.线程终结法则:线程中的任何动作都happends-before于其他线程检测到这个线程已经终结,或者从Thread.join 调用中成功返回,或者Thread.isAlive返回false 中断法则:一个线程调用另一个线程的interrupt.happens-before于被中断的线程发现中断。(通过跑出interruptedException,或者调用isInterrupted和interrupted) 6.终结法则:一个对象的构造函数的结束happends-before于这个对象finalizer的开始。 7.传递性:如果A happens-before于B, 且B happends-before 于C, 则A happens-before 于C

volatile变量法则:对volatile域的写入操作happends-before于每一个后续对同一域的读操作

当我们去写一个volatile变量的时候,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中,读一个volatile变量的时候,JMM会把该线程对应的本地内存置为无效,接下来线程从主内存中读取共享变量。两个线程,线程A写一个volatile变量,线程B随后读这个volatile变量。这个过程实际上就是线程A和线程B通过主内存进行通信(线程间通信)。

volatile与有序性

我们都知道多线程通过抢占时间片来执行自己的代码体,所以我们会感觉到线程是同时执行完的,除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如我们拿到数据要执行写库,查询,删除这三个操作,这就会可能要涉及到有序性的问题了。

volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行接下来我们就说一下为了实现volatile内存语义JMM是怎样限制重排序(包括编译器重排序和处理器重排序)的。

volatile重排序规则表(针对编译器重排序):

从这张表我们可以看出: 当第一个操作是Volatile读时,不管第二个操作是什么,都不能重排序; 当第一个操作是Volatile写时,第二个操作是Volatile读或写,不能重排序; 当第一个操作是普通读写,第二个操作是Volatile写时,不能重排序。

内存屏障(针对处理器重排序):

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。(首先保证了正确性,再去追求执行效率) 1.在每个volatile写操作前插入StoreStore屏障;对于这样的语句Store1; StoreLoad; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 2.在每个volatile写操作后插入StoreLoad屏障;对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。 3.在每个volatile读操作前插入LoadLoad屏障;对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 4.在每个volatile读操作后插入LoadStore屏障;对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。 如果编译器无法确定后面是否还会有volatile读或者写的时候,为了安全,编译器通常会在这里插入一个StoreLoad屏障 volatile与原子性 因为volatile它不是锁只是一个变量修饰符,所以无法保证原子性。 举个栗子:

public class Test { public volatile int i = 0; public void increase() { i++; } public static void main(String[] args) { final Test test = new Test(); for(int i=0;i<10;i++){ new Thread(){ public void run() { for(int j=0;j<1000;j++) test.increase(); }; }.start(); } while(Thread.activeCount()>1) //保证前面的线程都执行完 Thread.yield(); System.out.println(test.i); }

上面这段代码就是创建10个线程,然后分别执行1000次 i++操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。所以说volatile无法满足原子性。 i++操作在编译后字节码如下:

getfield #2 // Field i:I iconst_1 iadd putfield #2 // Field i:I

i++指令也包含了四个步骤,由于CPU按照时间片来进行线程调度的,只要是包含多个步骤的操作的执行,天然就是无法保证原子性的。因为这种线程执行,不像数据库一样可以回滚。如果一个线程要执行的步骤有5步,执行完3步就失去了CPU了,失去后就可能再也不会被调度,这怎么可能保证原子性呢。 所以在以下两个场景中可以使用volatile来代替synchronized: 1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。 2、变量不需要与其他状态变量共同参与不变约束。

参考资料: 《深入理解java虚拟机》周志明 《java并发编程的艺术》方腾飞 魏鹏 程晓明

最新回复(0)