这是我参与8月更文挑战的第15天,活动详情查看:8月更文挑战
往期推荐
一、volatile简介
volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。
被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
二、volatile作用
2.1 保证共享变量可见性
可见性:是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的。
而使用了volatile关键字修改的共享变量,当线程修改了共享变量之后,会立马刷新到主内存中,并且会使其他线程缓存了该地址的数据失效,这就保证了线程之间共享变量的可见性。
public class VolatileDemo1 {
//加了volatile关键字,当main线程将共享变量num修改后
//会通知持有共享变量num的A线程,保证共享变量的可见性
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
//main线程修改了共享变量num的值,
//如果不将共享变量加volatile关键字,则此时的A线程不知道共享变量的值已经被改变了,此时的A线程会进入死循环
new Thread(() -> {
while (num == 0) {
//
}
},"A").start();
TimeUnit.SECONDS.sleep(2);
//main线程修改共享变量num的值,并写回了主存中
num = 1;
System.out.println(num);
}
}
2.2 禁止指令重排
重排序:是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。
Java提供volatile来保证一定的有序性,重排序操作不会对存在数据依赖关系的操作进行重排序,这是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。
单例实现:双重检查加锁(DCL)
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
由于操作系统可以对指令进行重排序,所以对象的构造过程,实例化一个对象可能会变成如下过程:
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
2.3 保证原子性:单次读/写
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。
2.3.1 i++为什么不能保证原子性?
- 对
volatile变量的单次读/写操作可以保证原子性的; - 对
long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。
public class VolatileTest01 {
volatile int i;
public void addI(){
i++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}
从运行结果可以看出volatile是无法保证原子性的
i++操作可以分为三步:
- 读取i的值,装载进工作内存
- 对i加1操作
- 将i的值写回工作内存,刷新到主存中
我们知道线程的执行具有随机性,假设a线程和b线程中的工作内存中都是num=0,a线程先抢了cpu的执行权,在工作内存进行了加1操作,还没刷新到主存中;b线程这时候拿到了cpu的执行权,也加1;接着a线程刷新到主存num=1,而b线程刷新到主存,同样是num=1,但是两次操作后num应该等于2。
2.3.2 共享的long和double变量的为什么要用volatile?
因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。
三、硬件系统架构
计算机在运行程序时,每条指令都是在CPU中执行的,在程序执行过程中必然会涉及数据的读写操作。CPU中程序运行的数据是存储在主内存中的,这时就可能出现以下问题:CPU中执行指令的速度比读取主内存中数据的速度要快得多,如果CPU中程序运行所用到的数据都需要与主内存打交道,那么CPU就会因为读取主内存数据的低效率而不能发挥其高效的执行效率。
- 为了解决以上所述问题,就有了
CPU高速缓存,每个高速缓存为每个CPU所独有,每个高速缓存存放的是对应CPU执行的指令和所需数据,这样CPU避免了直接与主内存打交道,而是与速度比主内存高得多的高速缓存打交道,从而充分发挥CPU的执行效率。 CPU为了提高访问数据的效率,在每个CPU核心上都会有多级小容量但速度极快的缓存。缓存中是以缓存行为单位存储的,当CPU执行一条读内存指令时,内存地址所在缓存行中的内容都会加载到CPU缓存中(一次加载整个缓存行中数据)- 高速缓存使的
CPU的读取速度得到了极大的提高。由于高速缓存分布在不同的CPU中,因此必须保障某个CPU写入后的数据在各个CPU的高速缓存区存储的数据是保持一致的。
有以下两种方式可以保证写入数据后的数据一致性:
(1)直写。透过本级缓存直接把数据写到下一级缓存或者主内存中。如果对应的数据被缓存了,则直接更新缓存中的内容,或者直接丢弃缓存中的内容。
(2)回写。缓存不会立即把写操作写到下一级缓存中,而是仅修改本级缓存中的数据,并把对应的缓存数据标记为“脏”数据。“脏”数据会触发回写,把里面的内容写到下一级缓存或者对应的主内存中,回写后就可以保证数据的一致性了。
处理器、高速缓存、主内存间的交互关系图:
四、Java内存模型
Java内存模型(Java Memory Model),简称JMM。JMM是一种概念、约定,并不存在于现实中。
工作内存和主内存的8种交互: 1. lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态。
2. unlock(解锁):作用于主内存的变量,释放一个处于锁定状态的变量,释放后的变量才可以被其他线程锁定。
3. read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。
4. load(载入):作用于工作内存的变量,它把read操作从主内存中获取的变量放入工作内存中。
5. use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到的变量的值,就会使用到这个指令。
6. assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中。
7. store(存储):作用于主内存中的变量,它把工作内存中一个变量的值传送到主内存中,以便后续的write使用。
8. write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
Java内存模型定义了线程和主内存之间的抽象关系,具体如下:
- 共享变量存储于主内存之中,每个线程都可以访问。
- 每个线程都有私有的工作内存和本地内存。
- 工作内存值存储该线程对共享变量的副本。
- 线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存。
- 工作内存和Java内存模型一样也是一个抽象的概念,它其实并不存在,它涵盖了缓存、寄存器、编译优化以及硬件等。
交互操作图
五、volatile 的实现原理
5.1 volatile 可见性实现
volatile变量的内存可见性是基于内存屏障(Memory Barrier)实现:
-
内存屏障,又称内存栅栏,是一个
CPU指令。 -
在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和
CPU上有相同的结果,通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}
通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......
lock前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他
CPU里缓存了该内存地址的额数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
5.2 volatile 有序性实现
volatile 禁止重排序
JMM内存屏障分为四类见下图
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
"NO"表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:
- 在每个
volatile写操作的前面插入一个StoreStore屏障; - 在每个
volatile写操作的后面插入一个StoreLoad屏障; - 在每个
volatile读操作的后面插入一个LoadLoad屏障; - 在每个
volatile读操作的后面插入一个LoadStore屏障。
需要注意的是:
volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障
StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序
六、volatile 的应用场景
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用
volatile。 模式1:开销较低的读-写锁策略
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
模式2:双重检查(double-checked)
class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
syschronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
参考文献
《java并发编程的艺术》
《Java面试一战到底-基础卷》
《深入理解Java虚拟机》