【JUC】volatile

JMM

JMM(Java内存模型:Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式,它从Java层面定义了主存工作内存抽象概念。JMM不是Java内存布局,不是所谓的栈、堆、方法区。

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间,其实是每个线程缓存在CPU中的高速缓存数据),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

img

主存就是内存。工作内存指每个线程缓存在CPU中的数据(高速缓存),每个线程都在自己的CPU核心中缓存代码数据,通过总线嗅探得知主存中自己之前缓存的数据值已经过期,从而从主存中获取更新自己缓存中的数据

每个Java线程都有自己的工作内存。操作数据时,首先从主内存中读,得到一份拷贝,操作完毕后再写回到主内存。

JMM可能带来可见性原子性有序性问题。所谓可见性,就是某个线程对主内存内容的更改,应该立刻通知到其它线程。原子性是指一个操作是不可分割的,不能执行到一半,就不执行了。所谓有序性,就是指令是有序的,不会被重排

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受 cpu 缓存的影响
  • 有序性:保证指令不会受 cpu 指令并行优化(指令重排序)的影响

CPU 缓存结构

CPU 缓存结构示意图:

image-20220212152612808

因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。 而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)

缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效(通过MESI 协议察觉)。

一旦某个缓存行内的数据不是最新的了(通过 MESI 缓存一致性协议,总线嗅探察觉到数据被其他核心锁修改,自己缓存的不再是最新值),就会将该行的缓存数据失效,重新从内存中更新值保存到缓存。

案例

下图中,两个线程分别占用两个 CPU 核心,并且都将内存中的两个数据 Cell[0]Cell[1] 缓存到自己的一个缓存行里(二者保存保存到同一个缓存行里):

image-20220212153306905

图中每一行为一个缓存行结构,一般是 64 byte(8 个 long)

因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行可以存下 2 个的 Cell 对象。这样问题来了:

  • Core-0 要修改 Cell[0]
  • Core-1 要修改 Cell[1]

无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中Cell[0]=6000, Cell[1]=8000 要累加 Cell[0]=6001, Cell[1]=8000,这时会让 Core-1 的缓存行失效

@sun.misc.Contended 注解用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的 padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行。这样,不会造成对方缓存行的失效:

image-20220212154151927

代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 防止缓存行伪共享
@sun.misc.Contended
static final class Cell {
volatile long value;
Cell(long x) { value = x; }

// 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值
final boolean cas(long prev, long next) {
return UNSAFE.compareAndSwapLong(this, valueOffset, prev, next);
}
// 省略不重要代码
}

volatile

volatile:易变的

volatile 是 JVM 提供的轻量级的同步机制synchronized锁是重量级的同步机制)。它可以用来修饰成员变量静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的最新值。其特点:

  • 保证可见性(保证共享变量在多个线程操作下的可见性)
  • 保证有序性(禁止指令重排)
  • 不保证原子性(指令交错导致的原子性问题需要加锁来保证)

volatile 关键字常与 CAS 配合使用:自旋的过程中保证 volatile 修饰的共享变量的值永远是最新的(而非会用高速缓存值)。

原理

volatile 的底层实现原理是内存屏障:Memory Barrier(Memory Fence)

  • volatile 修饰的变量的写指令后会加入写屏障
  • volatile 修饰的变量的读指令前会加入读屏障

volatile 保证可见性

  • 写屏障(sfence)保证:在该屏障之前对共享变量的改动,结果都立即同步到主存当中
  • 读屏障(lfence)保证:在该屏障之后对共享变量的读取,加载的都是主存中的最新数据

通过读写屏障,在每次需要用到共享变量时都会保证和主存中的值一致。

其他非 volatile 修饰的共享变量仍然可以使用自己的高速缓存。有 volatile 修饰的共享变量会通过读写屏障保证可见性

volatile 保证有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

通过读写屏障,保证读写屏障前后的代码不会被重拍到屏障前后

volatile 无法保证原子性

volatile 无法保证原子性。无法解决高并发下的指令交错问题。仍需要加锁来解决。

synchronized

使用 synchronized 关键字能同时满足上面三个特性。在JMM中,synchronized 规定,线程在加锁时:

  • 清空自己工作内存的缓存
  • 然后在主内存中拷贝最新变量的副本到自己的工作内存告诉缓存
  • 执行再完代码将更改后的共享变量的值刷新到主内存
  • 释放 Monitor 锁
  • 其他工作线程通过总线嗅探发现自己之前缓存的值发生变化,就会重新从主内存拉取变量的最新值

总结:添加了 synchronized 关键字后,其所在线程就会强制从主存中更新当前线程所需的成员变量值到自己的高速缓存中,从而保证了可见性

注意:synchronized 包裹的代码块内仍然可能出现指令重排序其保证有序性的方式是通过保证原子性实现的。只要共享变量能在代码块内被完整保护,那么因为其原子性的特点,就能保证及时指令重排序了,也能保证是有序性的。因为其他线程都进不来,并且共享变量是被完全保护住的,所以就间接保证了有序性。

volatile 使用过多可能造成的问题:由于 volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探,并且 volatile 常配合 CAS 一起使用(长时间循环),无效的交互会导致总线带宽达到峰值,也就是造成总线风暴

可见性

各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回到主内存中的。

这就可能存在一个线程A修改了共享变量X的值但还未写回主内存时,另外一个线程B又对主内存中同一个共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

可见性案例:退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test1 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
// System.out.println(2323); 如果加上这个代码就会停下来
}
});
t.start();
utils.sleep(1);
System.out.println(3434);
run = false; // 线程t不会如预想的停下来
}
}

出现这种现象的原因:

  • 初始状态, t 线程从主内存读取了 run 的值到工作内存
  • 因为 t 线程频繁地从主存中读取 run 的值,JIT 即时编译器会将 run 的值缓存至自己工作内存的高速缓存中,减少对主存中 run 的访问以提高效率
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,但 t 是从自己工作内存的高速缓存中读取这个变量的值,结果永远是旧值

这时即出现了可见性问题。主存中的数据 run 对线程 t 是不可见的

JIT 即时编译器会在运行时将热点代码编译成缓存代码,保存到线程自己工作内存中,从而提高效率。这样做的后果就是每次使用的都是缓存数据,无法保证可见性。

volatile 解决可见性问题

为解决这个问题,可以使用 volatile 关键字修饰 run 变量,使得该变量在读写前后分别加上读写屏障,从而强制每次修改 run 变量后都会立即更新到主存中,每次读取 run 变量前都从主存中更新 run 的值到工作内存

1
volatile static boolean run = true;

synchronized 保证可见性

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。

添加了synchronized关键字后,其所在线程就会强制从主存中更新当前线程所需的成员变量值到自己的高速缓存中,从而保证了可见性。所以如果在上面代码中添加synchronized关键字,就能保证可见性。

扩展:如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了。这是因为println方法里面有synchronized修饰

原子性

**volatile不保证原子性。**若想保证操作原子性,需要使用原子类AtomicXxx

原子性:不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整要么同时成功,要么同时失败。

volatile 不保证原子性案例演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class MyData2 {
/**
* volatile 修饰的关键字,是为了增加主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
*/
volatile int number = 0;
public void addPlusPlus() {
number++;
}
}

public class VolatileAtomicityDemo {

public static void main(String[] args) {
MyData2 myData = new MyData2();

// 创建10个线程,线程里面进行1000次循环
for (int i = 0; i < 20; i++) {
new Thread(() -> {
// 里面
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}

// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
// 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
while(Thread.activeCount() > 2) {
// yield表示不执行
Thread.yield();
}

// 查看最终的值
// 假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
}
}

最后的结果总是小于20000。

原因分析

number++在多线程下是非线程安全的。number++的操作,会形成3条指令:

1
2
3
4
getfield    // 读
iconst_1 // ++常量1
iadd // 加操作
putfield // 写操作

假设有3个线程,分别执行number++,都先从主内存中拿到最开始的值,number=0,然后三个线程分别进行操作。假设线程0执行完毕,number=1,也立刻通知到了其它线程,但是此时线程1、2已经拿到了number=0,切换回1、2线程后不会再去从主内存中获取一次number的值了,所以结果是线程1、2将number变成1覆盖了线程3的赋值。

解决的方式就是:

  1. addPlusPlus()方法加synchronized锁,但它是重量级的同步机制,并发效率较低。
  2. 使用java.util.concurrent.AtomicInteger类,其可以保证原子性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static void atomicDemo() {
System.out.println("原子性测试");
MyData myData = new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 0; j <1000 ; j++) {
myData.addPlusPlus();
myData.addAtomic();
}
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t int type finally number value: " + myData.number);
System.out.println(Thread.currentThread().getName()+"\t AtomicInteger type finally number value: " + myData.atomicInteger);
}

结果:

1
2
3
原子性测试
main int type finally number value: 17542
main AtomicInteger type finally number value: 20000

有序性

指令重排序

现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令 还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段:

image-20220210235222498

术语参考:

  • instruction fetch (IF)
  • instruction decode (ID)
  • execute (EX)
  • memory access (MEM)
  • register write back (WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中 叶到 90’s 中叶占据了计算架构的重要地位。

支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理 器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。

image-20220210235510735

这是因为这种并行处理,导致了会对指令进行重排序,使得一些指令的顺序颠倒,从而出现重排序问题。

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:

img

  • 单线程环境里面能够确保程序最终执行结果和代码顺序执行的结果一致(因为即使重排序也不会影响执行逻辑)。处理器在进行重排序时必须要考虑指令之间的数据依赖性
  • 但多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。例如两条指令顺序颠倒后被不同线程并发访问就可能出现问题。

volatile 保证有序性

volatile 可以保证有序性,也就是防止指令重排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ResortSeqDemo {
int a = 0;
boolean flag = false;
/*
多线程下flag=true可能先执行,还没走到a=1就被挂起。
其它线程进入method02的判断,修改a的值=5,而不是6。
*/
public void method01(){
a = 1;
flag = true;
}
public void method02(){
if (flag){
a += 5;
System.out.println("*****retValue: "+a);
}
}
}

volatile可以保证有序性,也就是防止指令重排序。所谓指令重排序,就是出于优化考虑,CPU执行指令的顺序跟程序员自己编写的顺序不一致。就好比一份试卷,题号是老师规定的,是程序员规定的,但是考生(CPU)可以先做选择,也可以先做填空。

1
2
3
4
int x = 11; // 语句1
int y = 12; // 语句2
x = x + 5; // 语句3
y = x * x; // 语句4

以上例子,可能出现的执行顺序有1234、2134、1342,这三个都没有问题,最终结果都是x = 16,y=256。但是如果是4开头,就有问题了,y=0。这个时候就不需要指令重排序。

volatile 底层是用CPU的内存屏障(Memory Barrier)指令来实现的,有两个作用,一个是保证特定操作的顺序性,二是保证变量的可见性。在指令之间插入一条Memory Barrier指令,告诉编译器和CPU,在Memory Barrier指令之间的指令不能被重排序。

理论

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 保证特定操作的执行顺序
  2. 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

对volatile变量进行写操作时,会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回到主内存。

img

对volatile变量进行读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

img

线性安全性获得保证

  • 工作内存与主内存同步延迟现象导致的可见性问题 - 可以使用synchronized或volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
  • 对于指令重排导致的可见性问题和有序性问题 - 可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。

happens-before

happens-before 原则:前面一个线程操作的结果对其他线程后续的操作是可见的。有很多方式可以保证该原则。例如:

  • 加锁
  • volatile
  • 使用 join() 方法阻塞等待前一个线程执行完再执行后续操作
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

线程安全的单例模式

不使用 volatile 时的懒汉式单例模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Bank{
private Bank() {}
private static Bank instance = null;

public static Bank getInstance(){
// 方式一:并发性较低
/*
synchronized (Bank.class) {
if(instance == null){
instance = new Bank();
}
return instance;
}*/

//方式二:效率更高,如果实例对象已经非空,说明已经造好了对象,不需要再进入同步代码块内
//DCL(Double Check Lock 双端检锁机制)
if (instance == null) {
synchronized (Bank.class) {
if(instance == null){
instance = new Bank();
}
}
}
return instance;
}
}

可能存在的问题

注意,双端检锁机制不一定线程安全,原因是指令重排序的存在,加入volatile可以禁止指令重排序。指令重排序只会保证串行语义的执行的一致性(单线程),并不会关心多线程间的语义一致性。

代码中 instance = new Bank() 语句实际的执行顺序为:

  1. memory = allocate():为对象开辟一块内存空间
  2. instance(memory):初始化该对象,为其成员属性赋值
  3. instance = memory:设置instance指向刚才分配的内存地址,此时 instance != null

正常顺序是1 -> 2 -> 3。但由于指令重排序,可能某个线程在new对象的时候,重新排序后是1 -> 3 -> 2。即先给该对象引用赋了地址,但还未为该变量进行初始化(还未实例化对象)。

这就导致了可能该对象还未初始化时,另一个线程就抢到CPU执行权并判断 if(instance == null),此时发现instance的值已经不为空了(因为已经开辟并分配了地址),其将被直接返回,那么获取到的对象就是null值了。

该情况不会导致重复new对象,只是有可能返回null值。

解决方案:

  1. 采用方式一,在整个方法上加锁。缺点是并发效率低
  2. instance对象添加volatile关键字,禁止指令重排序。

改进版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Bank{
private Bank(){}
private volatile static Bank instance = null;

public static Bank getInstance(){
// DCL(Double Check Lock 双端检锁机制)
if(instance == null){
synchronized (Bank.class) {
if(instance == null){
instance = new Bank();
}
}
}
return instance;
}
}

加了 volatile 之后,就能保证上面的顺序不可能被重拍为 1 -> 3 -> 2。因为此时会在指令 instance = memory 之后添加写屏障,使得该写屏障之前的命令不会被重排序,从而顺序就必须为 1 -> 2 -> 3

扩展:该方式虽然使用了 synchronized,但是仍然无法保证有序性是因为,共享变量 instance 没有被完全保护,其在 synchronized 外部仍然可以被其他线程访问,所以不是有序性的,需要 volatile 来保证。