1. volatile关键字的用法
在java中,我们可以用volatile修饰实例变量或类变量(但不能是final的,也不能是局部变量)。
public class Hello { |
2. volatile的作用
volatile主要作用有两个,可见性 和 有序性,还有一个额外的作用,保证单个变量的读写操作原子性。
2.1. 可见性
什么是可见性?
在多核CPU环境下,并发执行的线程之间,共享主存,共享变量保存在主存,一个线程修改了共享变量,其他线程可以读到修改后的共享变量的值。
这里共享变量包含类的实例变量,类的静态变量。为什么有可见性问题?
现代的CPU处理速度比内存读写速度快很多,然后为了提供计算机处理速度,在CPU和内存之间添加了高速缓存(例如:一级缓存、二级缓存、三级缓存等)。
多核CPU中每个处理器都有自己的缓存,然后共享主存。这样就会有缓存一致性问题,缓存中的值跟主存中的值可能不一致,这也就是可见性问题。
- 怎么保证的可见性?
通过lock前缀指令,这个指令会做两件事:
a. 将当前CPU缓存行的数据写回主存
b. 写回主存的操作使得其他CPU缓存了该内存地址的数据无效 【这里用到了MESI缓存一致性协议】
2.2. 有序性
什么是有序性问题?
CPU在执行java的代码时,最底层的指令经过了编译器,处理器以及内存系统优化后,它的顺序发生了变化,以加快指令的执行。
这种优化在单线程下,没有问题。但是在多线程环境下,可能会造成空指针,数据异常等情况。举例说明有序性问题
单例的一种写法是通过synchronized同步块,加上两次null检查来实现,这时候需要对静态变量加上volatile修饰,原因是new对象有三步操作:
a. 申请内存空间
b. 调用构造函数
c. 返回内存地址赋值给引用变量
处理器可能对这三步执行顺序做优化,比如申请内存地址后,直接返回内存地址赋值给引用,最后再调用构造函数进行初始化,这样其他的线程可能拿到了未初始化的单例对象。
// 单例对象的double check写法 |
- 怎么解决的?
对volatile变量的读写操作前后插入内存屏障来解决。
3. volatile的底层实现
3.1. Java内存模型 (JMM)
针对主存,CPU缓存,Java语言规范提出语言级别的内存模型 (JMM),它能屏蔽不同的编译器和不同处理器平台的差异,通过插入内存屏障指令,
禁止编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
3.2. 内存屏障 — java语言角度
内存屏障的两个作用
a) 在有内存屏障的地方,会禁止指令重排序,也就是屏障下面的代码不能跟屏障上面的代码交换执行顺序
b) 在有内存屏障的地方,线程修改完共享变量后,会马上将该变量从本地内存写会主存,并且让其他线程的本地内存中该变量副本失效。它与Lock前缀指令的关系?
Lock前缀指令不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock前缀指令会对CPU总线和高速缓存加锁,可以理解为CPU指令级别的一种锁。
内存屏障不一定就是用Lock前缀指令来实现的,也可以用其他指令(例如:fences)来实现。
3.3. 缓存一致性协议 — 硬件的角度
Intel处理器使用的是MESI协议,即修改(modify), 独占(eclusive),共享(share), 无效(invalid)。
处理器使用嗅探技术保证内部缓存、系统内存和其他处理器的的缓存数据在总线上保持一致。
该协议会组织同时修改两个以上处理器缓存的内存区域,具体实现是,处理器执行指令期间,需要独占任何共享内存,以前是锁总线,现在是锁缓存。