Java并发专题(三)深入理解volatile关键字
前言
上一章节简单介绍了线程安全以及最基础的保证线程安全的方法,建议大家手敲代码去体会。这一章会提到volatile关键字,虽然看起来很简单,但是想彻底搞清楚需要具备JMM、CPU缓存模型的知识。不要小看这个关键字,它在整个并发包(concurrent包)使用的非常广泛,掌握volatile关键字是非常重要的。
如果你是一个急性子,请看下面3点就行:
- 保证了多线程读取变量的可见性,一个线程修改volatile修饰的变量,另外一个线程会立即读取到新的值
- 禁止指令重排序
- volatile关键字不会像synchronized关键字一样造成线程阻塞,也就是说无锁
1.1 初识volatile关键字
我先写一个例子,在主线程启动2个线程,一个线程负责写,一个线程负责读,读写的该变量就是共享变量,那么结果是你想的那样吗?
/** * volatile第一个演示Demo类。 * * @author GrimMjx */ public class VolatileDemo1 { //i的初始值为0 public static int i; //i的最大值为3 public static int MAX = 3; public static void main(String[] args) { //读线程 new Thread(() -> { int index = i; while (index < MAX) { if (i != index) { System.out.println("i = " + i); index = i; } } }).start(); //写线程 new Thread(() -> { int index = i; while (index < MAX) { System.out.println("new i = " + ++i); index = i; try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } }
我贴上一份我执行的结果:
new i = 1 i = 1 new i = 2 new i = 3
程序不会停,需要手动停。那么问题来了,为什么明明写入了i了,读线程还是无法读到新的i的值呢?读线程压根没有感知到i的变化!只要我们把变量i的定义改变一下,那么就可以解决这个问题。
public volatile static int i;
改好之后再试一下,确实如我们预料的执行了且读线程也不会死循环。只是一个关键字的差别,会发生很大的不同。那接下来请带着疑问去学习。
new i = 1 i = 1 new i = 2 i = 2 new i = 3 i = 3
1.2 机器CPU
所有的指令都是CPU寄存器完成的,CPU指令的过程中涉及到数据的写入和读取。CPU能访问的所有数据只能是RAM(计算机内存)。虽然CPU频率不断提升,但是RAM访问速度没有很大突破,因此CPU处理速度和内存的访问速度差距巨大,一次主内存的访问通常在几十到几百个甚至上千个时钟周期,一次L1高速缓存的读写需2个左右时钟周期,一次L2高速缓存的读写需要几十个时钟周期。
1.2.1 CPU Cache模型
可以直观看到两边的速度严重不对等,于是有了在CPU和主内存之间增加缓存,最靠近CPU的缓存成为L1高速缓存,其次是L2,L3和主内存。我们先看一张各级缓存之间响应时间差距,以及内存到底有多慢。
接下来我们看一下CPU Cache模型:
1.2.2 CPU缓存一致性
缓存大大提高了访问速度,但是同时也引入了缓存不一致的问题,比如i++;这个操作。具体的过程如下:
- 读取主内存的i到CPU Cache中
- 将i+1
- 将结果写回CPU Cache
- 将数据刷新回主内存
i++在单线程完全不会有问题,但是多线程的时候就会有问题,每个线程都有自己的工作内存(本地内存),如果在2个线程都执行i++;操作,A线程和B线程此时的工作内存中的i都是0,加1之后都变成1。最后经过计算再写入主内存可能结果还是1。这就是缓存不一致问题。如果想要解决这个问题,主流方法是通过缓存一致性协议(MESI协议)。这个协议的大致思想就是如果当CPU在操作Cache中的数据时,其他Cache也存在一份副本,那么会进行如下操作:
- 读取操作,不做任何处理,只是将Cache中的数据读取到寄存器
- 写入操作,发出信号通知其他CPU将其变量中的Cache line置为无效状态,其他CPU在进行该变量的读取时候不得不到主内存中再次获取
1.3 Java内存模型
JMM指定了JVM和计算机RAM如何进行工作的,同时也决定了一个线程对共享变量的写入何时对其他线程可见,有以下几个要点:
- 每个线程都有自己的工作内存,也成为本地内存
- 工作线程只存储线程对共享变量的副本
- 线程不能直接操作主内存,只能操作工作内存
- 工作内存和JMM都是一个抽象的概念,实际并不存在,覆盖了寄存器,编译器优化等等
主内存和工作内存的关系和CPU与CPUCache之间的关系是非常类似的,所以通过图示和讲解,我们发现理解volatile关键字会比synchronized关键字困难很多,需要了解机器CPU还有JMM。volatile在JDK5以后的concurrent包运用非常广泛,所以掌握volatile关键字很重要。
1.4 深入理解volatile关键字
说到并发,有三大特性,原子性,有序性和可见性,那我们从三个方面来介绍
1.4.1 原子性
volatile不具备原子性
原子性的意思就是在一次操作中,所有的操作全部执行或者都不执行,就像名字一样是不可分割的。Java中,对变量的读取和赋值操作都是源自的,但是多个原子性的操作在一起,不一定是个原子操作。JMM只保证了基本的读取和赋值的原子性,其他的均不保证。说回volatile,如果在上一章节的UnsafeAdd的例子,用volatile修饰变量i,是否可以解决多线程并发问题呢,结果是不可以的,可以自己去试试。
就像是i++操作,他其实包含了3步骤
1.从主内存获取i,缓存到工作内存
2.在工作内存中进行+1
3.刷回主内存。
这也就是刚刚说的多个原子性的操作在一起,不一定是个原子操作
Java中想要保证原子性,需要使用synchronized关键字,concurrent包的lock,原子封装类和循环CAS的方式(原子变量是一种更好的volatile,后面会讲到)
1.4.2 可见性
volatile具备可见性
读取:当一个变量被volatile关键字修饰时,当其他线程对此变量进行了修改,则会迫使其他线程的工作内存中的该变量失效,所以必须从主内存重新获取。(使用的是机器指令lock)
写入:当然是先修改工作内存,修改后立即将其刷新到主内存中。
Java中volatile,synchronized关键字和显式锁lock都保证可见性
1.4.3 有序性
volatile具备有序性
首先volatile遵循happens-before原则:对一个变量的写操作要早于这个变量之后的读操作。也就是说,如果一个变量使用volatile关键字修饰,一个线程对这个变量进行写操作,另外一个线程对这个变量进行读操作。那么写操作肯定要先发生于读操作。
volatile对顺序性非常霸道,直接禁止JVM和处理器进行指令重排序,但是对于volatile前后无依赖关系的执行可以随便排序。
Java中volatile,synchronized关键字和显式锁lock都保证有序性
1.5 volatile的正确打开姿势
- 确保它们所引用状态的可见性
- 标识一些重要的程序生命周期事件发生(init,destroy)
- 确保只有一个线程更新变量的值
- 不会用就不要用:)