在java中多线程为了实现共享变量能够被准备的更新除了可以使用加排他锁的方式,还有一种更加简单的方式就是对共享变量进行volatile声明。
JMM模型
Java线程之间的通信主要包括信息共享和消息传递。在java中,所有的实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。
java线程之间的通信油Java内存模型JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区、寄存器以及其他的硬件和编译器优化。
关键字作用
- 保持内存可见性
- 防止指令重排序
内存可见性
volatile关键字可以保证内存的可见性,即当本地内存中的共享变量发生改变时,会立即刷新到主内存中,当其他线程读取时能够读取到最新改变之后的值。
private static boolean running=true;
// private static volatile boolean running=true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (running){
}
System.out.printf("end");
},"thread-visible").start();
Thread.sleep(1000);
running=false;
}
当共享变量running不用volatile关键字修饰时,运行改程序则一直处于while循环中,而当running被volatile关键字修饰时,运行该程序,结果则跳出while循环,running值变为false;
原因:volatile关键字保证了running变量的内存可见性。当主线程修改了running的值时,thread-visible线程会立刻读取到running修改之后的值,跳出while循环。
指令重排序
这里我们拿最常用的单例模式来举例。
public class T02_DCLSingleton {
private static volatile T02_DCLSingleton INSTANCE;
private T02_DCLSingleton(){};
public static T02_DCLSingleton getINSTANCE(){
if(INSTANCE==null){ //01
synchronized (T02_DCLSingleton.class){
if(INSTANCE==null){ //02
INSTANCE=new T02_DCLSingleton(); //03 指令重排序位置
}
}
}
return INSTANCE;
}
}
指令重排序是什么?
编译器和处理器为了优化程序性能而对指令序列重新排序的一种手段。这里会遵循as-if-serial语义即编译器和处理器不会对存在数据依赖关系的操作做重排序。
单例模式中指令重排序发生在哪里呢?
主要发生在代码 03 行
问题的根源是什么呢?
主要是因为**对象的半初始化**,INSTANCE=new T02_DCLSingleton();这一句代码在执行时是分为了三个步骤进行的。
memory=allocate(); // 1、分配对象的内存空间
ctorInstance(memory); // 2、初始化对象
INSTANCE=memory; // 3、设置INSTANCE指向刚分配的内存地址。
在代码2和3之间可能会被重排序。发生重排序之后如下:
memory=allocate(); // 1、分配对象的内存空间
INSTANCE=memory; // 3、设置INSTANCE指向刚分配的内存地址。
ctorInstance(memory); // 2、初始化对象
当并发访问单例模式时,如果在1和3执行完以后,另一个线程开始访问判断INSTANCE是否为null,此时判断不为NULL,开始访问INSTANCE对象,但是INSTANCE其实并未进行对象的初始化操作。因此导致程序不能达到预期的结果。
volatile实现原理
内存屏障+缓存一致性协议
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。主要分为Load读屏障和store写屏障。其中写屏障可以保证当本地缓存中的数据发生改变时立即刷新到主内存中,同时是其他缓存中的数据无效进而实现了内存数据的可见性。
- LoadLoad屏障(读读):对于这样的语句Load1;LoadLoad;Load2.在Load2及后续读取操作尧都区的数据被访问前,保证Load1要读取的数据读取完毕。
- StoreStore屏障(写写) :对于这样的语句Store1;StoreStore;Store2;在Store2及后续写入操作执行前,保证Store1的写入操作对其他处理器可见。
- LoadStore屏障(读写) : 对于这样的语句Load1;LoadStore;Store2。在Store2及后续写入操作被刷出前,保证Load1要读取的数据读取完毕。
- StoreLoad屏障(写读):对于这样的语句Store1;StoreLoad;Load2。在Load2及后续所有读取操作执行前,保证Store写入对所有处理器可见。
以上内存屏障可以有效保证volatile关键字的禁止指令重排序的功能。
那么接下来的问题:为什么store屏障会刷新主内存中的额数据,从而导致其他缓存数据无效呢?
这是因为如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条 LOCK前缀 的指令,将这个变量所在的缓存行的数据写回到系统内存。但是就算回写到内存,如果其他处理器缓存的值还是旧的,再执行计算就会有问题。所以在多处理器下,为了保证各个处理器缓存是一致的,就会实现缓存一致性协议(MESI)。每个处理器嗅探在总线上的传播数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中读到处理器缓存里。
所以volatile有两条实现原则:
1、Lock前缀指令会引起处理器缓存回写到内存。
2、一个处理器的缓存回写到内存会导致其他处理器缓存无效。
通过缓存一致性协议,一些场景下我们还可以对volatile声明的关键字进行优化,通常处理器的缓存行是64字节宽,对于频繁写的共享变量可以通过追加字节以让单个字节占据一整行缓存行的方式来提高并发编程的效率等。
总结:volatile关键字实现主要通过java内存屏障,建立在lock前缀指令和缓存一致性协议基础上实现的。