本文将通过 Java 内存模型,CPU 缓存模型入手讲解 volatile 关键字的使用和原理
初识 volatile 关键字
public class VolatileFoo {
final static int MAX = 5;
static volatile int init_value = 0;
public static void main(String[] args) {
new Thread(()->{
int localValue = init_value;
while (localValue < MAX){
if(init_value != localValue) {
System.out.printf("The init_value is update to [%d]\n", init_value);
localValue = init_value;
}
}
}, "Reader").start();
new Thread(()->{
int localValue = init_value;
while (localValue < MAX){
System.out.printf("The init_value will to change to [%d]\n", ++localValue);
init_value = localValue;
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Updater").start();
}
}
运行上述代码,我们发现,Reader 线程压根就没有感知到 init_vale 的变化而进入了死循环,当我们进行如下调整
static volatile int init_value = 0;
Reader 线程会感知到 init_value 变量的变化
volatile 关键字只能修饰类变量和实例变量,对于方法参数,局部变量以及实例常量,类常量都不能进行修饰
机器硬件 CPU
计算机中,所有的运算都是由 CPU 的寄存器来完成的,CPU 指令的执行过程需要涉及数据的读取和写入操作,CPU 所能访问的所有数据只能在计算机主存中,对于主存的访问往往是运算的瓶颈,因为 CPU 的处理速度和内存的访问速度之间的差距可能达到上千倍
CPU Cache 模型
由于速度的不对称,通过传统的直接内存访问很明显导致 CPU 资源受到限制,降低 CPU 的吞吐量,于是通过 CPU 和 主存之间添加缓存来解决速度不匹配的问题,现在缓存数量已经增加到 3 级
程序运行中,会将运算所需要的数据从主存复制一份到 CPU Cache 中,这样 CPU 进行计算时就可以直接对 CPU Cache 中的数据进行读写,当运算结束之后,再将 CPU Cache 中最新数据刷新到主内存中
CPU 缓存一致性问题
缓存的出现极大提高了 CPU 的吞吐能力,但同时也引入了缓存不一致的问题,比如 i++ 操作的过程
- 读取主内存中 i 到 CPU Cache中
- 对 i 进行 +1 操作
- 将结果写回到 CPU Cache 中
- 将数据刷新到主内存中
在单线程情况下不会出现任何问题,但是在多线程的情况下很可能出现脏读的情况,为了解决缓存不一致的问题,通常的解决方法有以下两种
- 通过总线加锁的方式
- 通过缓存一致性协议
第一种方式是一种悲观的实现, CPU 和其他组件的通信都是通过总线进行,如果采用总线加锁的方式,则会阻塞其他 CPU 对其组件的访问,从而使只有一个 CPU 能够访问某个变量的内存,效率低效
缓存一致性通过制度一系列协议保证每一个缓存中使用的共享变量副本都是一致的,如果发现一个变量是共享变量,则进行如下操作
- 读取操作,不做任何处理
- 写入操作,发出信号通知其他 CPU 将该变量的 Cache line 置为无效状态,其他 CPU 在进行该变量的读取的时候不得不从主存中读取
Java 内存模型—JMM
Java 的内存模型指定了 Java 虚拟机如何与计算机的主存进行工作,Java 内存模型决定了一个线程对共享变量的写入何时对其他线程是可见,Java 内存模型定义了线程和主内存之间的抽象关系,具体如下:
-
共享变量存储于主内存之中,每个线程都可以访问
-
每个线程都有私有的工作内存或称为本地内存
-
工作内存只存储该共享变量的副本
-
线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存
-
工作内存和 Java 内存模型一样也是一个抽象概念,它其实并不存在,它涵盖了缓存,寄存器,编译器优化以及硬件等
Java 的内存模型是一个抽象概念,与计算机硬件的结构并不完全一样
并发编程的三个重要特性
并发编程有三个重要的特性,分别是原子性,有序性和可见性
原子性
所谓原子性就是指一次操作或者多次操作中,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。
两个原子性的操作结合一起未必还是原子性,如 i++,volatile 关键字不保证数据的原子性,synchronized 关键字保证
可见性
可见性是指当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改的最新值
有序性
所谓的有序性是指代码在执行过程中的先后顺序,由于 Java 在编译器以及运行期的优化,导致代码的执行顺序未必和编写的代码的顺序一致,如
int x = 10;
int y = 0;
x++;
y = 20;
JMM 如何保证三大特性
JVM 采用内存模型的机制来屏蔽各个平台和操作系统之间内存访问的差异,Java 的内存模型规定所有的变量都存在主内存中,而每个线程由自己的工作线程,线程对变量的操作都必须在自己工作线程中进行,而不能直接对内存进行操作
JMM 与 原子性
在 Java 语言中,对基本数据类型的变量读取赋值操作都是原子性的,对引用类型的变量读取和赋值也是原子性的,但 y = x 不能保证其原子性,因为他包含两个步骤:首先 从主内存中读取 x 的值,将其存入工作线程中,然后在工作线程中修改 y 的值为 x ,然后将 y 写回主内存中,volatile不能保证其原子性
JMM 与 可见性
在多线程中,如果某个线程首次读取共享变量,首先从主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可,同样如果该变量执行了修改操作,则先将新值写入工作内存中,然后刷新到主内存中,但什么时候值会输入主内存中不太确定
Java 提供了以下三种方式保证可见性
-
使用关键字 volatile,当一个变量使用 volatile 修饰时,对于共享资源读操作会重新刷新变量值,从主内存再次获取,对于共享资源的写操作会先修改工作内存,但是修改结束后立刻刷新到主内存中
-
通过 synchronized 关键字,synchronized 能保证同一时刻只有一个线程获得锁,并且还会确保锁释放之前会将变量刷新到主内存中
-
同理 Lock也能保证其可见性
-
final 修饰的共享变量,因为其不可变性质
JMM 与 有序性
在 Java 内存模型中,允许编译器 和 处理器对指令进行重排序,在单线程的情况下,并不会引发什么问题,但是在多线程的情况下,重排序会影响到程序的正确执行,Java 提供了三种保证有序性的方式,具体如下
- 使用 volatile关键字
- 使用 synchronize的关键字
- 使用显示锁 Lock 关键字
后两者采用同步的机制,而 volatile 采用的是内存屏障
此外, Java 的内存模型具备一些天生的有序性规则,不需要任何同步手段就能保证有序性,这个规则称为 happen-before 原则
- 程序次序规则:在一个线程内,代码按照编写次序执行,编写后面的操作发生在编写前面的操作之后,需要注意的是虚拟机仍然可能会对程序进行指令的重排序,但可以保证结果和代码顺序执行一致
- 锁定规则:一个 unlock 操作要先行发生于同一个锁的 lock 操作,无论在单线程还是多线程环境下,如果一个锁是锁定状态下,那么必须先对其执行释放操作之后才能进行 lock 操作
- volatile 变量规则:对一个变量的写操作要早于对这个变量之后的读操作,如果一个线程对它进行读操作,一个线程对它进行写操作,那么写入操作肯定要先行发生读操作
- 传递规则:如果操作 A 先于操作 B,而操作 B 又先于操作 C,则操作 A肯定先于操作 C
- 线程启动规则:Thread 对象的 start() 方法先行发生对该线程的任何动作
- 线程中断规则:对线程执行 interrupt 方法肯定优先于捕获中断信号,说明线程如果收到中断信号,那么肯定之前有 interrupt
- 线程终结规则:线程中所有的操作先于发生于线程终止检测
- 对象的终结规则:一个对象初始化的完成先于 finalize 方法之前
volatile 深入解析
volatile 语义
- 保证不同线程之间对共享变量操作时可见性,也就是说当一个线程修改 volatile 修饰的变量,另一个线程会看到最新的值
- 禁止对指令进行重排序
理解 volatile 保证可见性
关于 Happen—before 的第三天 volatile 变量规则具体步骤如下:
- 读线程从主内存中获取 init_vaule 的值为 0,并且将其缓存在本地工作内存中
- 修改线程将 init_value 的值在本地内存中修改为1,然后立即刷新到主内存
- 读线程在本地内存的 init_value 失效,表现为 CPU 的 L1 或 L2 的 Cache line 失效
- 由于 init_value 失效,需要从主内存中重新读取 init_value
理解 volatile 保证顺序性
通过直接禁止 JVM 和处理器对 volatile 关键字修饰的指令重排序,但是对于 volatile 前后无依赖关系的指令可以随意排序
int x = 0;
int y = 1;
volatile int z = 20;
x ++;
y --;
在语句 volatile int z = 20 之前,先执行 x 还是先执行 y,我们并不关系,只要能保证在执行 z = 20 的时候,x = 0,y = 1,同理 x 的自增和 y 的自减都必须在 z = 20 之后
volatile 的原理和实现机制
在OpenJDK 下通过阅读发现,volatile 修饰的变量存在于一个 lock:的前缀
实际上相当于一个内存屏障,该内存屏障会为指令执行提供如下几个保障
- 确保指令重排序时后面的代码不会排到内存屏障之前
- 确保指令重排序时前面的代码不会排到内存屏障之后
- 确保在执行到内存屏障修饰的指令前面的代码全部执行完成
- 强制将线程工作内存中值刷新到主内存中
- 如果是写操作,会导致其他线程工作内存中缓存数据失效
volatile 的使用场景
开关控制,利用可见性
状态标记,利用顺序性
volatile 和 Synchronized 区别
使用上的区别
- volatile 关键字只能修饰实例变量或者类变量,不能用来修饰方法已经方法参数和局部变量,常量等
- synchronized 不能对于变量的修饰,只能用于修饰方法或者语句块
- volatile 修饰的变量可以为 null,synchronized关键字同步语句的 monitor对象不能为 null
对原子性的保证
- volatile 无法保证原子性
- 由于 synchronized 是只用排他机制,因此被 synchronized 修饰的同步代码无法中途被打断,能保证其原子性
对可见性的保证
- 两者均可以保证共享资源在多个线程的可见性,但实现机制不一样
- synchronized 借助于JVM 指令 monitor enter 和 monitor exit 对通过排他的方式是代码串行话,在 monitor exit 时将所有共享资源刷新到主内存中
- volatile 使用机器指令(lock)的方式迫使其他线程工作内存中的数据失效,不得到主内存中进行再次加载
对有序性的保证
- volatile 禁止 JVM 编译器 以及处理器进行重排序,所有能保证有序性
- synchronized 通过串型化的方式保证其有序性
其他
- volatile 不会是线程陷入阻塞
- synchronized 会是线程进入阻塞