问题
(1)volatile 是如何保证可见性的?
(2)volatile 是如何禁止重排序的?
(3)volatile 的实现原理?
(4)volatile 的缺陷?
简介
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制了, 但是它并不容易被正确地理解, 以至于很多人不习惯使用它, 遇到多线程问题一律使用 synchronized 或其它锁来解决.
了解 volatile 的语义对理解多线程的特性具有很重要的意义, 所以彤哥专门写了一篇文章来解释 volatile 的语义到底是什么.
语义一: 可见性
前面介绍 Java 内存模型的时候, 我们说过可见性是指当一个线程修改了共享变量的值, 其它线程能立即感知到这种变化.
关于 Java 内存模型的讲解请参考 [死磕 java 同步系列之 JMM(Java Memory Model) https://mp.weixin.qq.com/s/jownTN--npu3o8B4c3sbeA ] .
而普通变量无法做到立即感知这一点, 变量的值在线程之间的传递均需要通过主内存来完成, 比如, 线程 A 修改了一个普通变量的值, 然后向主内存回写, 另外一条线程 B 只有在线程 A 的回写完成之后再从主内存中读取变量的值, 才能够读取到新变量的值, 也就是新变量才能对线程 B 可见.
在这期间可能会出现不一致的情况, 比如:
(1) 线程 A 并不是修改完成后立即回写;
(线路 A 修改了变量 x 的值为 5, 但是还没有回写, 线程 B 从主内存读取到的还旧值 0)
(2) 线程 B 还在用着自己工作内存中的值, 而并不是立即从主内存读取值;
(线程 A 回写了变量 x 的值为 5 到主内存中, 但是线程 B 还没有读取主内存的值, 依旧在使用旧值 0 在进行运算)
基于以上两种情况, 所以, 普通变量都无法做到立即感知这一点.
但是, volatile 变量可以做到立即感知这一点, 也就是 volatile 可以保证可见性.
java 内存模型规定, volatile 变量的每次修改都必须立即回写到主内存中, volatile 变量的每次使用都必须从主内存刷新最新的值.
volatile 的可见性可以通过下面的示例体现:
- public class VolatileTest {
- // public static int finished = 0;
- public static volatile int finished = 0;
- private static void checkFinished() {
- while (finished == 0) {
- // do nothing
- }
- System.out.println("finished");
- }
- private static void finish() {
- finished = 1;
- }
- public static void main(String[] args) throws InterruptedException {
- // 起一个线程检测是否结束
- new Thread(() -> checkFinished()).start();
- Thread.sleep(100);
- // 主线程将 finished 标志置为 1
- finish();
- System.out.println("main finished");
- }
- }
在上面的代码中, 针对 finished 变量, 使用 volatile 修饰时这个程序可以正常结束, 不使用 volatile 修饰时这个程序永远不会结束.
因为不使用 volatile 修饰时, checkFinished() 所在的线程每次都是读取的它自己工作内存中的变量的值, 这个值一直为 0, 所以一直都不会跳出 while 循环.
使用 volatile 修饰时, checkFinished() 所在的线程每次都是从主内存中加载最新的值, 当 finished 被主线程修改为 1 的时候, 它会立即感知到, 进而会跳出 while 循环.
语义二: 禁止重排序
前面介绍 Java 内存模型的时候, 我们说过 Java 中的有序性可以概括为一句话: 如果在本线程中观察, 所有的操作都是有序的; 如果在另一个线程中观察, 所有的操作都是无序的.
前半句是指线程内表现为串行的语义, 后半句是指 "指令重排序" 现象和 "工作内存和主内存同步延迟" 现象.
关于 Java 内存模型的讲解请参考 [死磕 java 同步系列之 JMM(Java Memory Model) https://mp.weixin.qq.com/s/jownTN--npu3o8B4c3sbeA ] .
普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果, 而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致, 因为一个线程的方法执行过程中无法感知到这点, 这就是 "线程内表现为串行的语义".
比如, 下面的代码:
- // 两个操作在一个线程
- int i = 0;
- int j = 1;
上面两句话没有依赖关系, JVM 在执行的时候为了充分利用 CPU 的处理能力, 可能会先执行 int j = 1; 这句, 也就是重排序了, 但是在线程内是无法感知的.
看似没有什么影响, 但是如果是在多线程环境下呢?
我们再看一个例子:
- public class VolatileTest3 {
- private static Config config = null;
- private static volatile boolean initialized = false;
- public static void main(String[] args) {
- // 线程 1 负责初始化配置信息
- new Thread(() -> {
- config = new Config();
- config.name = "config";
- initialized = true;
- }).start();
- // 线程 2 检测到配置初始化完成后使用配置信息
- new Thread(() -> {
- while (!initialized) {
- LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
- }
- // do sth with config
- String name = config.name;
- }).start();
- }
- }
- class Config {
- String name;
- }
这个例子很简单, 线程 1 负责初始化配置, 线程 2 检测到配置初始化完毕, 使用配置来干一些事.
在这个例子中, 如果 initialized 不使用 volatile 来修饰, 可能就会出现重排序, 比如在初始化配置之前把 initialized 的值设置为了 true, 这样线程 2 读取到这个值为 true 了, 就去使用配置了, 这时候可能就会出现错误.
(此处这个例子只是用于说明重排序, 实际运行时很难出现.)
通过这个例子, 彤哥相信大家对 "如果在本线程内观察, 所有操作都是有序的; 在另一个线程观察, 所有操作都是无序的" 有了更深刻的理解.
所以, 重排序是站在另一个线程的视角的, 因为在本线程中, 是无法感知到重排序的影响的.
而 volatile 变量是禁止重排序的, 它能保证程序实际运行是按代码顺序执行的.
实现: 内存屏障
上面讲了 volatile 可以保证可见性和禁止重排序, 那么它是怎么实现的呢?
答案就是, 内存屏障.
内存屏障有两个作用:
(1) 阻止屏障两侧的指令重排序;
(2) 强制把写缓冲区 / 高速缓存中的数据回写到主内存, 让缓存中相应的数据失效;
关于 "内存屏障" 的知识点, 各路大神的观点也不完全一致, 所以这里彤哥也就不展开讲述了, 感兴趣的可以看看下面的文章:
(注意, 公众号不允许外发链接, 所以只能辛苦复制链接到浏览器中阅读了, 而且还可能需要 ***)
(1) Doug Lea 的《The JSR-133 Cookbook for Compiler Writers》
http://g.oswego.edu/dl/jmm/cookbook.html
Doug Lea 就是 java 并发包的作者, 大牛!
(2)Martin Thompson 的《Memory Barriers/Fences》
Martin Thompson 专注于把性能提升到极致, 专注于从硬件层面思考问题, 比如如何避免伪共享等, 大牛!
它的博客地址就是上面这个地址, 里面有很多底层的知识, 有兴趣的可以去看看.
(3)Dennis Byrne 的《Memory Barriers and JVM Concurrency》
这是 InfoQ 英文站上面的一篇文章, 我觉得写的挺好的, 基本上综合了上面的两种观点, 并从汇编层面分析了内存屏障的实现.
目前国内市面上的关于内存屏障的讲解基本不会超过这三篇文章, 包括相关书籍中的介绍.
我们还是来看一个例子来理解内存屏障的影响:
- public class VolatileTest4 {
- // a 不使用 volatile 修饰
- public static long a = 0;
- // 消除缓存行的影响
- public static long p1, p2, p3, p4, p5, p6, p7;
- // b 使用 volatile 修饰
- public static volatile long b = 0;
- // 消除缓存行的影响
- public static long q1, q2, q3, q4, q5, q6, q7;
- // c 不使用 volatile 修饰
- public static long c = 0;
- public static void main(String[] args) throws InterruptedException {
- new Thread(()->{
- while (a == 0) {
- long x = b;
- }
- System.out.println("a=" + a);
- }).start();
- new Thread(()->{
- while (c == 0) {
- long x = b;
- }
- System.out.println("c=" + c);
- }).start();
- Thread.sleep(100);
- a = 1;
- b = 1;
- c = 1;
- }
- }
这段代码中, a 和 c 不使用 volatile 修饰, b 使用 volatile 修饰, 而且我们在 a/b,b/c 之间各加入 7 个 long 字段消除伪共享的影响.
关于伪共享的相关知识, 可以查看彤哥之前写的文章 [杂谈 什么是伪共享 (false sharing)? https://mp.weixin.qq.com/s/rd13SOSxhLA6TT13N9ni8Q ] .
在 a 和 c 的两个线程的 while 循环中我们获取一下 b, 你猜怎样? 如果把 long x = b; 这行去掉呢? 运行试试吧.
彤哥这里直接说结论了: volatile 变量的影响范围不仅仅只包含它自己, 它会对其上下的变量值的读写都有影响.
缺陷
上面我们介绍了 volatile 关键字的两大语义, 那么, volatile 关键字是不是就是万能的了呢?
当然不是, 忘了我们内存模型那章说的一致性包括的三大特性了么?
一致性主要包含三大特性: 原子性, 可见性, 有序性.
volatile 关键字可以保证可见性和有序性, 那么 volatile 能保证原子性么?
请看下面的例子:
- public class VolatileTest5 {
- public static volatile int counter = 0;
- public static void increment() {
- counter++;
- }
- public static void main(String[] args) throws InterruptedException {
- CountDownLatch countDownLatch = new CountDownLatch(100);
- IntStream.range(0, 100).forEach(i->
- new Thread(()-> {
- IntStream.range(0, 1000).forEach(j->increment());
- countDownLatch.countDown();
- }).start());
- countDownLatch.await();
- System.out.println(counter);
- }
- }
这段代码中, 我们起了 100 个线程分别对 counter 自增 1000 次, 一共应该是增加了 100000, 但是实际运行结果却永远不会达到 100000.
让我们来看看 increment() 方法的字节码 (IDEA 下载相关插件可以查看):
- 0 getstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
- 3 iconst_1
- 4 iadd
- 5 putstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
- 8 return
可以看到 counter++ 被分解成了四条指令:
(1)getstatic, 获取 counter 当前的值并入栈
(2)iconst_1, 入栈 int 类型的值 1
(3)iadd, 将栈顶的两个值相加
(4)putstatic, 将相加的结果写回到 counter 中
由于 counter 是 volatile 修饰的, 所以 getstatic 会从主内存刷新最新的值, putstatic 也会把修改的值立即同步到主内存.
但是中间的两步 iconst_1 和 iadd 在执行的过程中, 可能 counter 的值已经被修改了, 这时并没有重新读取主内存中的最新值, 所以 volatile 在 counter++ 这个场景中并不能保证其原子性.
volatile 关键字只能保证可见性和有序性, 不能保证原子性, 要解决原子性的问题, 还是只能通过加锁或使用原子类的方式解决.
进而, 我们得出 volatile 关键字使用的场景:
(1) 运算的结果并不依赖于变量的当前值, 或者能够确保只有单一的线程修改变量的值;
(2) 变量不需要与其他状态变量共同参与不变约束.
说白了, 就是 volatile 本身不保证原子性, 那就要增加其它的约束条件来使其所在的场景本身就是原子的.
比如:
- private volatile int a = 0;
- // 线程 A
- a = 1;
- // 线程 B
- if (a == 1) {
- // do sth
- }
a = 1; 这个赋值操作本身就是原子的, 所以可以使用 volatile 来修饰.
总结
(1)volatile 关键字可以保证可见性;
(2)volatile 关键字可以保证有序性;
(3)volatile 关键字不可以保证原子性;
(4)volatile 关键字的底层主要是通过内存屏障来实现的;
(5)volatile 关键字的使用场景必须是场景本身就是原子的;
彩蛋
关于 "内存屏障" 的三篇文章, 考虑到有的同学无法 ***, 彤哥专门把这三篇下载下来整理了一下.
关注我的公众号 "彤哥读源码", 后台回复 "volatile", 下载这三篇资料.
来源: http://www.bubuko.com/infodetail-3065305.html