volatile
1. volatile的作用
volatile:是Java修饰符 用来修饰变量。volatile 保证了变量的内存可见性、有序性(禁止指令重排序);注意:volatile 不能保证原子性。
volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。
2. 怎么保证可见性
这里我们需要了解一下Java的内存模型 JMM
2.1 JMM (Java Memory Model)
JMM:Java的内存模型,JMM 定义了线程和内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
JMM的规定:
- 所有的共享变量都存在主内存中。
- 每一个线程都有自己的工作内存,线程的工作内存保留了被线程使用的变量的工作副本。
- 线程对变量的所有操作都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 线程是私有的,不同线程之间不能直接访问对方的工作内存,线程之间传递值 也是需要通过主内存来完成的。
正因为JMM这样的规定导致线程对共享变量的修改如果没有及时更新到主内存中,或者线程没能够即使将共享变量的最心值同步到工作内存中,从而使线程在使用共享变量的值时,不是最新的值。这也就是内存可见性的问题。
比如说 “线程A” 、“线程B” 同时操作 “变量A” 这个时候 ”线程A“ 操作 “变量A” 时,“线程B” 不能立即读到“变量A” 的最新值。
2.2 解决方法 (重点)
解决方法: 使用volatile关键字 或加 锁
volatile:保证了不同线程对共享变量操作的可见性,一个线程修改了volatile 修饰的变量,当修改后的变量写回主内存时, 其他线程就能立即看到最新值。
如何做到的呢:
- 对声明了volatile关键字的变量进行写操作,JVM会向处理器发送一条 lock前缀 的指令,将这个变量所在的缓存行立即写回系统内存。
- 通过缓存一致性协议(总线嗅探机制),其他线程如果工作内存中存了该共享变量的值,就会失效,其他线程会重新从主内存中获取最新的值;
锁:锁保证了线程的 通过 lock 和 unlock 操作保证了原子性,一个变量同一时刻只允许一个线程对其进行lock操作从保证了有序性,通过对一个变量unlock 前,将变量同步到主内存中保证了可见性。
2.3 lock前缀指令
lock前缀的指令在多核处理器下会引发两件事情
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
2.4 总线嗅探机制
背景
在现代计算机中,CPU 的速度是极高的,如果 CPU 需要存取数据时都直接与内存打交道,在存取过程中,CPU 将一直空闲,这是一种极大的浪费,所以,为了提高处理速度,CPU 不直接和内存进行通信,而是在 CPU 与内存之间加入很多寄存器,多级缓存,它们比内存的存取速度高得多,这样就解决了 CPU 运算速度和内存读取速度不一致问题。
总线嗅探机制(大概知道下就可以)
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。
嗅探机制工作原理
每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
3. 如何保证有序性
3.1 禁止指令重排序
什么是重排序?
为了提高性能,在遵守 as-if-serial 语义(即不管怎么重排序,单线程下程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。
一般重排序分为如下三种类型
- 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序:由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
volatile 是如何禁止指令重排序的呢?这里我们将引出一个概念:内存屏障指令
3.2 内存屏障
内存屏障:内存屏障是一组处理器指令,它的作用是禁止指令重排序和解决内存可见性的问题。
JMM将内存屏障分为下列4类:
类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad 屏障 | Load1;LoadLoad;Load2 | 确保Load1数据的读取操作,在Load2及所有后续的读取操作之前 |
StoreStore 屏障 | Store1;StoreStore;Store2 | 确保Store1数据的写入操作对其他处理器可见(刷新到内存),在Store2及所有后续数据的写入操作之前 |
LoadStore 屏障 | Load1;LoadStore;Store2 | 确保Load1数据的读取操作,在Store2及所有后续的数据刷新到内存之前 |
StoreLoad 屏障 | Store1;StoreLoad;Load2 | 确保Store1数据的写入操作对其他处理器可见(刷新到内存),在Load2及所有后续的读取操作之前 。StoreLoad 屏障会使该屏障之前的所有内存访问指令(读/写)完成之后,才执行该屏障之后的内存访问指令. 需要注意的事 该屏障开销很大 |
volatile 的读/写 插入内存屏障规则: |
- 在每个 volatile 读操作的后面插入 LoadLoad 屏障和 LoadStore 屏障。
- 在每个 volatile 写操作的前后分别插入一个 StoreStore 屏障和一个 StoreLoad 屏障。
也就是说,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。 保证了 volatile 的有序性。
4.总结 (重点)
- volatile:是Java修饰符 用来修饰变量。volatile 保证了变量的内存可见性、有序性(禁止指令重排序);注意:volatile 不能保证原子性。
- volatile的可见性 :是通过 lock前缀 立即写道主内存中 ,通过缓存一致性协议(总线嗅探机制) 通知其他线程重新读主内存 中的变量
- volatile的有序性 :是通过内存屏障保证的,编译器和处理器常常会对指令做重排序进行提高性能, 内存屏障阻止了 编译器的重排序 保证了volatile 的有序性。