前言
今天和大家聊聊 volatile。一个面试频考点,感觉和 synchronized 不分伯仲。
我们都知道,volatile 保证可见性与有序性,但是不保证原子性,保证原子性需要借助 synchronized 这样的锁机制。 所以我们主要围绕着这三个特点来了解 volatile。
JMM
在学习 volatile 之前,我们一定要了解 JMM。
JMM Java 内存模型,它是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。
JMM 关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而 JMM 中规定所有变量都存储在主内存,主内存是共享内存区域。所有线程都可以访问,但线程对变量的操作必须在工作内存中进行。首先要将比那辆从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存。不能直接操作主内存中的变量。各个线程中的工作内存中存储着主内存中的变量副本拷贝。因此,不同的线程间无法访问对方的工作内存,线程间的通信必须通过主内存来完成。
JMM 的八大操作
- read(读取):从主内存读取数据
- load(载入):将主内存读取到的数据写入工作内存
- store(存储):将工作内存数据写入主内存
- write(写入):将 store 过去的变量值赋值给主内存中的变量
- use(使用):从工作内存读取数据来计算
- assign(赋值):将计算好的值重新赋值到工作内存中
- lock(锁定):将主内存变量加锁,标识为线程独占状态
- unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
一致性协议不用去锁总线,只要你设置了 volatile,那么就有一个 lock 指令触发缓存失效
通过流程图我们可以大致分析一下上面代码中两个线程的执行流程。
线程1:
- 我们要读取主内存中的数据。
- 加载数据到工作内存
- cpu 对数据进行运用
线程2: 前三个步骤不变。 4. 将计算好的值重新赋值到工作内存中 5. 将工作内存数据直接写入主内存 6. 将 store 过去的变量值赋值给主内存中的变量
但我们会发现,虽然主线程中变量 initFlag 已经修改了,但是由于线程1 并没有获取到更改的信号,使其始终处于 initFlag 没改之前的状态。
那么,如果我们想实现线程间变量可见要怎么办呢?
可见性
说到可见性,我们不得不提到 MESI 缓存一致性协议。
MESI 缓存一致性协议:多个 cpu 主内存读取同一个数据到各自的高速缓存,当其中某个 cpu 修改了缓存里的数据,该数据会马上同步回主内存,其它 cpu 通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。
那这个总线嗅探机制又是什么呢?每个 cpu 都会对总线进行一个监听。如果我们一个线程修改了变量值,那它写回主内存的路径上一定会经过总线。那么如果其他线程一直对总线进行监听,当发现改值发生变化,会将自己工作内存中的变量值制空,然后重新去主内存读取该变量值。
Volatile 缓存可见性实现原理 汇编指令 lock
- 当前处理器缓存行数据立刻写回主内存
- 这个写操作,会触发总线嗅探机制(MESI 协议)
解释一下就是它的可见性的实现原理是 底层实现主要通过汇编 Lock 前缀指令(变量加了 volatile 当修改操作时,底层汇编会给该行加一个 lock 锁),他会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存。而其他线程的工作内存又时刻对总线进行监听,监听到该变量发生变化会引起其他 cpu 里缓存了该内存地址的数据无效(MESI协议)。如果要想获取该值,就要重新去主内存获取。
不保证原子性
再讲不保证原子性前,我们先运行一个段小代码。
public class Test { public static volatile int num = 0; public static void increase() { num++; } public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[10]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new Runnable() { public void run() { for (int j = 0; j < 1000; j++) { increase(); } } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(num); } }
该代码如果按照常理来想,我们一个线程给 num + 1 后,它的结果会写到主线程,然后其他线程通过 MESI 监听到该 num 已经修改了,就会重新获取该 num 在主内存中的值,然后加一,那么结果应该是 1000。可真的是这样吗?我们来看运行结果。
有的时候确实是真确的。但是多运行几次会发现,还是有异样。
为什么会少了呢?这就是 volatile 的不保证原子性。那么接下来我们分析一下原因。
首先线程1 给 num 加一;然后写回到主内存。由于 num 被 volatile 修饰,所以当 num 发生改变时其他线程会监听到。假设当线程1 给 num = 0 时加一,线程二也给 num +1。这时线程一先写入主内存,而线程2 发现 num 发生了改变,那么就将自己工作内存中的 num 给置空了。此时在重新去主内存读数据重新给 num 加一。那么问题就出现在这。
现在值是二,可你已经加了三次。本来加三次是三,而你加三次成了2。这就是结果为什么会低于 1000 的原因了。
那么想保证原子性吗,简单,给 num 前加个 sychronized。
指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,一般分为以下三种:
源代码 ——> 编译器优化的重排 ——> 指令并行的重排 ——> 内存系统的重排 ——> 最终执行的指令
其中,在单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。 而多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。处理器在进行重排序时必须要考虑指令之间的数据依赖性。
volatile 实现禁止指令重排优化,从而避免多线程环境下出现乱序执行的现象。
先了解一个概念,内存屏障(Memory Barrier),是一个 CPU 指令,它的作用有两个:
- 保持特定操作的执行顺序
- 保持某些变量的内存可见性
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。
为此,Java 内存模型采取的策略为 在每个volatile写操作的前面插入一个StoreStore屏障。 在每个volatile写操作的后面插入一个StoreLoad屏障。 在每个volatile读操作的后面插入一个LoadLoad屏障。 在每个volatile读操作的后面插入一个LoadStore屏障。
写操作
读操作
通过屏障,我们可以做到其他代码不会干扰到内存屏障内的代码。
总结
今天呢,我给大家介绍了 volatile,它主要有三个重要的点,分别是可见性,有序性和不保证原子性。而可见性是通过 JMM 模型对变量访问方式的规定及 MESI(缓存一致性协议)的嗅探机制来实现的;有序性是通过内存屏障,在汇编的层面对需要保证有序性的代码前后加屏障来保证代码的有序执行;最后又分析了 volatile 在循环类似 i++ 的情况下会出现不保证原子性的情况。