首先要了解的是,volatile 可以保证可见性和顺序性,这些都很好理解,那么它为什么不能保证原子性呢?
可见性
可见性与 Java 的内存模型有关,模型采用缓存与主存的方式对变量进行操作,也就是说,每个线程都有自己的缓存空间,对变量的操作都是在缓存中进行的,之后再将修改后的值返回到主存中,这就带来了问题,有可能一个线程在将共享变量修改后,还没有来的及将缓存中的变量返回给主存中,另外一个线程就对共享变量进行修改,那么这个线程拿到的值是主存中未被修改的值,这就是可见性的问题。
volatile 很好的保证了变量的可见性,变量经过 volatile 修饰后,对此变量进行写操作时,汇编指令中会有一个 LOCK 前缀指令,这个不需要过多了解,但是加了这个指令后,会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使得在其他处理器缓存了该内存地址无效
什么意思呢?意思就是说当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值,这就保证了可见性。
原子性
问题来了,既然它可以保证修改的值立即能更新到主存,其他线程也会捕捉到被修改后的值,那么为什么不能保证原子性呢?
首先需要了解的是,Java 中只有对基本类型变量的赋值和读取是原子操作,如 i = 1 的赋值操作,但是像 j = i 或者 i++ 这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取 i 的值,再将 i 的值赋值给 j,两个原子操作加起来就不是原子操作了。
所以,如果一个变量被 volatile 修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。
举个栗子
一个变量 i 被 volatile 修饰,两个线程想对这个变量修改,都对其进行自增操作也就是 i++,i++ 的过程可以分为三步,首先获取 i 的值,其次对 i 的值进行加 1,最后将得到的新值写会到缓存中。
线程 A 首先得到了 i 的初始值 100,但是还没来得及修改,就阻塞了,这时线程 B 开始了,它也得到了 i 的值,由于 i 的值未被修改,即使是被 volatile 修饰,主存的变量还没变化,那么线程 B 得到的值也是 100,之后对其进行加 1 操作,得到 101 后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。
问题来了,线程 A 已经读取到了 i 的值为 100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程 A 阻塞结束后,继续将 100 这个值加 1,得到 101,再将值写到缓存,最后刷入主存,所以即便是 volatile 具有可见性,也不能保证对它修饰的变量具有原子性。
评论区:
-
两个原子操作加起来就不是原子操作了,你确定原子性是这样的意思吗
-
volatile 关键字会在编译成汇编的时候前缀加上 lock,就是强制刷到内存的意思
-
自增操作在字节码的确是三条指令,但周志明老师的<深入理解jvm虚拟机>里的原话: “实事求是地说,笔者使用字节码来分析并发问题仍然是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。此处使用-XX:+PrintAssembly 参数输出反汇编来分析才会更加严谨一些,但是考虑到读者阅读的方便性,并且字节码已经能很好地说明问题,所以此处使用字节码来解释。” 字节码角度的确是三条指令,但这每个操作未必是原子性的。
-
周老师的<深入理解java虚拟机>写的不一样, 周老师写的意思是, volatile 关键字修饰的变量, 当其被修改时, 会立即将修改的值从缓存中刷新到主内存中, 其它线程如果有这个变量的缓存, 会监听主内存的这个变量, 如果发现有变化, 则会重新拉取主内存的数据到缓存中, 追踪源码到汇编指令会发现有一条 lock addl 指令, 这个指令的作用就是将当前缓存刷新到主内存中, 并使其它地方的缓存失效, 重新从主内存拉取最新内容存到当前的缓存
重点:
1、线程读取 i
2、temp = i + 1
3、i = temp
当 i=5 的时候A,B两个线程同时读入了 i 的值, 然后A线程执行了 temp = i + 1的操作, 要注意,此时的 i 的值还没有变化,然后B线程也执行了 temp = i + 1的操作,注意,此时A,B两个线程保存的 i 的值都是5,temp 的值都是6, 然后A线程执行了 i = temp (6)的操作,此时i的值会立即刷新到主存并通知其他线程保存的 i 值失效, 此时B线程需要重新读取 i 的值那么此时B线程保存的 i 就是6,同时B线程保存的 temp 还仍然是6, 然后B线程执行 i=temp (6),所以导致了计算结果比预期少了1
吊吊吊 终于搞明白了!很有道理,说白了volatile只会保证“读”这一步的原子性,而i++这个动作实际分为“读、加、赋值”三步。即便MESI保证了B线程会去重新读取最新的i值,但是已经执行过的“加操作”并不会再次重新执行,所以导致计算结果比预期少了1
其实说白了就是因为 volatile 没有加锁,所以多个线程可以同时操作同一个变量,同时读取,同时写入就会有问题
-
原子性是读+写是一次操作,volatile 只能保证读最新,但写的话是另外一次操作了
-
多线程并发导致的问题有三个:CPU 缓存引起的可见性问题,CPU 指令重排序引起的顺序性问题,线程中断引起的原子性问题。前两个可以用 volatile 修饰词解决,因为 volatile 的原理就是禁用指令重排序和禁用 CPU 缓存。但 volatile 无法阻止 CPU 中断。