解析volatile关键字
一、初识volatile
volatile,是Java提供的一种轻量级同步机制,相较于同步块/方法(Synchronized)更加轻量,不会引起线程上下文切换与调度;
二、volatile的特性
1、保证可见性
1.1、什么是可见性:
多线程环境下,当多个线程同时访问同一个共享变量的时候,一个线程对共享变量修改,其他线程能立即获取到修改后的值,即读线程及时获取到写线程修改后的值;
从JMM来看可见性
对于共享变量的修改,未同步到主内存中
JMM有以下规定:
1、所有的共享变量(实例变量和类变量,不包含局部变量)都存储于主内存。
2、 每一个线程都存在自己的工作内存,工作内存内保留了被线程使用的变量的工作副本。 线程对变量的所有的操作都必须在工作内存中完成,而不能直接读写主内存中的变量。
3、不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
1.2、如何保证可见性
当查看汇编指令的时候,会发现,在修改带有volatile修饰的成员变量时,会多出一个lock指令。lock指令是一种控制指令,在多线程环境下,lock汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的效果;
Lock前缀的指令在多核处理器下会引发两件事情:
1、将当前处理器缓存行的数据写回到系统内存。
2、这个写回内存的操作会使其他cpu里缓存了该内存地址的数据无效。
如果声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的还是旧的,在执行操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值,会引发总线风暴,所以不要大量使用volatile。
2、禁止指令重排
2.1、什么是指令重排序
指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
一般重排序可以分为如下三种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
这里还得提一个概念,as-if-serial。 不管怎么重排序,单线程下的执行结果不能被改变。 编译器、runtime和处理器都必须遵守as-if-serial语义。
2.2、如何保证不会被执行重排序
Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置)来禁止特定类型的处理器重排序。 为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
volatile重排序规则表
需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。
3、不保证原子性
所谓原子性就是:不可分割,也即某个线程在做某个具体业务时,中间不可以被加塞或者分割,需要整体完整,要么同时成功 要么同时失败
public class VolatileAtomic {
public static void main(String[] args) {
MyTest myTest= new MyTest();
for(int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myTest.addNum();
}
}, String.valueOf(i)).start();
}
while(Thread.activeCount()>2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t number= "+myTest.num);
}
}
class MyTest {
public volatile int num = 0;
public void addNum() {
num++;
}
}
从上述代码中,即使变量添加了volatile关键字,但结果也并非是2000,可知volatile关键字并没有保证原子性
总结:
- 每个Java线程都有自己的工作内存,工作内存中的数据并不会实时刷新回主内存,因此在并发情况下,有可能线程A已经修改了成员变量k的值,但是线程B并不能读取到线程A修改后的值,这是因为线程A的工作内存还没有被刷新回主内存,导致线程B无法读取到最新的值。
- 在工作内存中,每次使用volatile修饰的变量前都必须先从主内存刷新最新的值,这保证了当前线程能看见其他线程对volatile修饰的变量所做的修改后的值。
- 在工作内存中,每次修改volatile修饰的变量后都必须立刻同步回主内存中,这保证了其他线程可以看到自己对volatile修饰的变量所做的修改。
- volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
- volatile保证可见性,不保证原子性,部分保证有序性(仅保证被volatile修饰的变量)。
- 指令重排序的目的是为了提高性能,指令重排序仅保证在单线程下不会改变最终的执行结果,但无法保证在多线程下的执行结果。
- 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止重排序。