从线程安全到 JMM(Java Memory Model)

Posted by He Zongjiang on 2020-03-09

一、什么是线程安全

多个线程不管以何种方式访问共享变量,并且不需要进行同步,都能表现正确的行为。这就是线程安全。

呃,这和 JMM(Java Memory Model)有什么联系呢?

这里就需要知道为什么会产生线程不安全。

发生线程不安全的本质实际上是主内存和工作内存中的数据不一致,或者发生了重排序所导致

什么是主内存,什么是工作内存,什么又是重排序呢?这就牵涉到 JMM 了。

二、Java 内存模型——JMM

Java 内存模型(Java Memory Model,JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例变量、静态变量和构成数组对象的元素)的访问方式。

需要注意的是别把 JMM 和 JVM 搞混了,JVM 是 Java 虚拟机(Java Virtual Machine)。

JMM

JMM 定义了每个线程和主内存之间的抽象关系:线程之间的共享变量存储在“主内存”中,而每个线程都有一个私有的“工作内存”,工作内存中存储了该线程以读/写共享变量的副本。

上图描述了一个多线程执行场景。

线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。

JMM 规定:线程不能直接读写主内存的共享变量,每个线程都有自己私有的工作内存,线程需要读写主内存的共享变量时,首先需要将该变量拷贝一份副本到自己工作内存,然后在自己的工作内存中对该变量进行所有操作,完成操作之后再将结果同步至主内存。

这么说起来有点抽象,下面举一个例子。

例如 2 条线程,循环 1000 次,分别给 x 值加 1,若不加任何处理,其最终结果很可能会小于 2000。

A、B 线程分别读取主内存中 x 的值,并把 x 的值复制到自己的工作内存中,此时,A、B 线程的工作内存的值都是 0。

然后,每条线程对自己工作内存中的值进行自加操作,操作完成之后再写回主内存,这时候就出问题了。站在线程 A、B 的角度看是没问题的,但是站在主内存的角度看,结果就不对了。A、B 线程只把自己的值写回主内存,而没有考虑到其他线程在该期间内也对主内存中的值做了修改。这就是线程不安全的原因之一。

三、volatile

为了解决上述问题,volatile 关键字就闪亮登场了。

被 volatile 关键字描述变量的操作具有可见性有序性(禁止指令重排)。

1、可见性

针对 volatile 修饰的变量 Java 虚拟机有特殊的约定:

  • 当一条线程读取被 volatile 修饰的变量时,JMM 会把该线程的工作内存中对应的变量值置为无效,必须从主内存中读取;

  • 当一条线程写入被 volatile 修饰的变量时,JMM 会把该线程的工作内存中对应的值立即刷新到主内存;

从而避免出现数据脏读的现象,保证数据的“可见性”。

2、有序性

在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。那么什么情况下,不能进行重排序了?下面就来说说数据依赖性。有如下代码:

1
2
3
double pi = 3.14             // A
double r = 1.0 // B
double area = pi * r * r // C

这是一个计算圆面积的代码,由于 A、B 两句之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。

因此可以执行顺序可以是 A->B->C 或者 B->A->C 执行最终结果都是3.14,即 A 和 B 之间没有数据依赖性。但是 C 一定在 A、B 执行完之后才能执行。因此,重排序有以下两个特点:

  1. 重排序操作不会对存在数据依赖关系的操作进行重排序。

  2. 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。

例如下面的代码,A 线程执行changeStatus(),B 线程执行run(),能保证输出一定等于 3 吗?

不一定,因为 1 和 2 之间不存在数据依赖关系,因此编译器和处理器可能会对指令进行重排序,若 A 线程先执行了 2,还未执行 1,此时 B 线程执行了 3 和 4,这就会时输出的值等于 2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestVolatile {
int a = 1;
boolean status = false;

public void changeStatus() {
a = 2; // 1
status = true; // 2
}

public void run() {
while (true) {
if (status) { // 3
int b = a + 1; // 4
System.out.println(b);
break;
}
}
}
}

使用 volatile 关键字修饰共享变量便可以禁止这种重排序。若用 volatile 修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

3、不保证原子性

在访问 volatile 变量时不会执行加锁操作,也就不会使执行线程阻塞,因此 volatile 是一种比 sychronized 关键字更轻量级的同步机制,可以保证可见性,保证不被重排序,但是,不能保证线程安全,也不保证原子性。

为什么 volatile 不能保证原子性?

以 i++ 为例,其包括读取、操作、赋值三个操作,下面是两个线程的操作顺序。

假如说线程 A 在做了 i+1,但未赋值的时候,线程B就开始读取 i,那么当线程 A 赋值 i=1,并回写到主内存,而此时线程 B 已经不再需要 i 的值了,而是直接交给处理器去做 +1 的操作,于是当线程 B 执行完并回写到主内存,i 的值仍然是 1,而不是预期的 2。也就是说,volatile 缩短了普通变量在不同线程之间执行的时间差,但仍然存有漏洞,依然不能保证原子性。

四、总结

所以,结论是 volatile 是一种轻量级的同步机制,可以保证共享变量对所有线程的可见性,禁止指令重排序优化,但不保证原子性,像 num++ 这种复合操作,volatile 无法保证其原子性

当然,像 num++ 这种操作可以通过 CAS 的方式来保证原子性。

如想保证线程安全和原子性,还是需要使用锁。锁可以保证可见性、有序性、原子性