Java 多线程编程之一 Java 内存模型浅析

大纲

前言

本文将介绍 Java 的内存模型(JMM),部分内容摘自《深入理解 Java 虚拟机》。

物理硬件和内存

首先,在单核电脑中处理的问题要简单得多,对内存和硬件的要求,各种方面的考虑没有在多核的情况下复杂。电脑中,CPU 的运行计算速度是非常快的,而其他硬件比如磁盘 IO,网络、内存读取等等,跟 CPU 的速度比起来是差几个数量级的。而不管任何操作,几乎是不可能都在 CPU 中完成而不借助于任何其他硬件操作。所以协调 CPU 和各个硬件之间的速度差异是非常重要的,要不然 CPU 就一直在等待,浪费资源。而在多核中,不仅面临如上问题,还有如果多个核用到了同一个数据,如何保证数据的一致性、正确性等问题,也是必须要解决的。目前基于高速缓存的存储交互很好的解决了 CPU 和内存等其他硬件之间的速度矛盾,多核情况下各个处理器(核)都要遵循一定的诸如 MSI、MESI 等协议来保证内存的各个处理器高速缓存和主内存的数据的一致性。

jmm-2

除了增加高速缓存,为了使处理器内部运算单元尽可能被充分利用,处理器还会对输入的代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在乱序执行之后的结果进行重组,保证结果的正确性,也就是保证结果与顺序执行的结果一致。但是在真正的执行过程中,代码执行的顺序并不一定按照代码的书写顺序来执行,可能和代码的书写顺序不同。

Java 的内存模型

JMM 概述

Java 内存模型(Java Memory Model,简称 JMM)是由 Java 虚拟机规范定义的,用来屏蔽掉 Java 程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现 Java 程序在各种不同的平台上都能达到内存访问的一致性。避免了像 C/C++ 等直接使用物理硬件和操作系统的内存模型,导致在不同操作系统和硬件平台下表现不同,比如有些 C/C++ 程序可能在 Windows 平台运行正常,而在 Linux 平台运行却有问题。虽然 Java 程序所有的运行都是在虚拟机中,涉及到的内存等信息都是虚拟机的一部分,但实际也是物理机的,只不过是虚拟机作为最外层的容器统一做了处理。虚拟机的内存模型,以及多线程的场景下与物理机的情况是很相似的,可以类比参考。Java 内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。需要注意的是,这里的变量跟平时写 Java 程序中的变量不是完全等同的。这里的变量是指实例字段、静态字段、构成数组对象的元素,但是不包括局部变量和方法参数(因为这是线程私有的)。这里可以简单的认为主内存是 Java 虚拟机内存区域中的堆,局部变量和方法参数是在虚拟机栈中定义的。但是在堆中的变量如果在多线程中都使用,就涉及到了堆和不同虚拟机栈中变量的值的一致性问题了。

主内存与工作内存

  • 主内存:Java 虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。可以与前面说的物理机的主内存相比,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。

  • 工作内存:Java 虚拟机中每个线程都有自己的工作内存,该内存是线程私有的。为了方便理解,可以认为是虚拟机栈,可以与前面说的高速缓存相比。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

jmm-1

特别说明

主内存、工作内存与 Java 内存区域中的 Java 堆、虚拟机栈、方法区并不是一个层次的内存划分。这两者是基本上是没有关系的,上文只是为了便于理解,做的类比。

工作内存与主内存交互

物理机高速缓存和主内存之间有交互协议,同样的,Java 内存中线程的工作内存和主内存的交互是由 Java 虚拟机定义了如下的八种操作来完成的,每种操作必须是原子性的(double 和 long 类型在某些平台有例外)。Java 虚拟机中主内存和工作内存交互,本质就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。

  • lock(锁定):作用于主内存的变量,一个变量在同一时间只能被一个线程锁定,该操作表示这条线成独占这个变量
  • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
  • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的 load 操作使用
  • load(载入):作用于线程的工作内存的变量,表示把 read 操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
  • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
  • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的 write 操作使用
  • write(写入):作用于主内存的变量,把 store 操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存传输到工作内存,那就要顺序的执行 read 和 load 操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行 store 和 write 操作。对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。例如两个线程分别从主内存中读取变量 a 和 b 的值,即执行 read a; load a; read b; load b;,此时可能也会出现如下执行顺序 read a; read b; load b; load a;这八种操作必须是原子的,不可分割的。针对于 volatile 修饰的变量,会有一些特殊规则,后边会详细列出。对于上述八种操作,虚拟机也规定了一系列规则,在执行这八种操作的时候必须遵循如下的规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
  • 不允许一个线程丢弃最近的 assign 操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步 / 回写到主内存
  • 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何 assign 操作,是不允许将该变量的值回写到主内存
  • 变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行 load 或者 assign 操作,即执行 use、store 之前必须对相同的变量执行了 load、assign 操作
  • 一个变量在同一时刻只能被一个线程对其进行 lock 操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同
  • 对变量执行 lock 操作,就会清空工作内存中该变量的值,执行引擎使用这个变量之前,需要重新 load 或者 assign 操作初始化变量的值
  • 不允许对没有 lock 的变量执行 unlock 操作,如果一个变量没有被 lock 操作,那也不能对其执行 unlock 操作,当然一个线程也不能对被其他线程 lock 的变量执行 unlock 操作
  • 对一个变量执行 unlock 之前,必须先把变量同步回主内存中,也就是执行 store 和 write 操作

volatile 关键字

volatile 的特殊规则

关键字 volatile 可以说是 Java 虚拟机中提供的最轻量级的同步机制,Java 内存模型对 volatile 专门定义了一些特殊的访问规则。假定 T 表示一个线程,V 和 W 分别表示两个 volatile 修饰的变量,那么在进行 read、load、use、assign、store 和 write 操作的时候需要满足如下规则:

  • 只有当线程 T 对变量 V 执行的前一个动作是 load,线程 T 对变量 V 才能执行 use 动作;同时只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 对变量 V 才能执行 load 操作。所以,线程 T 对变量 V 的 use 动作和线程 T 对变量 V 的 read、load 动作相关联,必须是连续一起出现。也就是在线程 T 的工作内存中,每次使用变量 V 之前必须从主内存去重新获取最新的值,用于保证线程 T 能看得见其他线程对变量 V 的最新的修改后的值。

  • 只有当线程 T 对变量 V 执行的前一个动作是 assign 的时候,线程 T 对变量 V 才能执行 store 动作;同时只有当线程 T 对变量 V 执行的后一个动作是 store 的时候,线程 T 对变量 V 才能执行 assign 动作。所以,线程 T 对变量 V 的 assign 操作和线程 T 对变量 V 的 store、write 动作相关联,必须一起连续出现。也即是在线程 T 的工作内存中,每次修改变量 V 之后必须立刻同步回主内存,用于保证线程 T 对变量 V 的修改能立刻被其他线程看到。

  • 假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,动作 F 是和动作 A 相关联的 load 或 store 动作,动作 P 是和动作 F 相对应的对变量 V 的 read 或 write 动作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,动作 G 是和动作 B 相关联的 load 或 store 动作,动作 Q 是和动作 G 相对应的对变量 W 的 read 或 write 动作。如果动作 A 先于 B,那么 P 先于 Q。也就是说在同一个线程内部,被 volatile 修饰的变量不会被指令重排序,保证代码的执行顺序和程序的顺序相同。

总结

在上面三条规则中,前面两条可以概括为:volatile 类型的变量保证对所有线程的可见性,第三条可以概括为:volatile 类型的变量禁止了指令重排优化。

volatile 禁止指令重排

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

zhi-ling-chong-pai

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重排序时必须考虑指令之间的数据依赖性。多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。


普通的变量仅仅会保证在该方法执行的过程中,所有依赖赋值结果的地方都能获取到正确的结果,但不能保证变量赋值的操作顺序和程序代码的顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是 Java 内存模型中描述的所谓的 线程内部表现为串行的语义。也就是在单线程内部,我们看到的或者感知到的结果和代码顺序是一致的;即使代码的执行顺序和代码顺序不一致,但是在需要赋值的时候结果也是正确的,所以看起来就是串行的。但实际结果有可能代码的执行顺序和代码顺序是不一致的,这在多线程代码中就会出现问题。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Map configOptions;
char[] configText;
//volatile类型变量
volatile boolean initialized = false;

//假设以下代码在线程A中执行
//模拟读取配置信息,读取完成后认为是初始化完成
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

//假设以下代码在线程B中执行
//等待initialized为true后,读取配置信息进行操作
while ( !initialized) {
sleep();
}

doSomethingWithConfig();

在上述代码中,如果 initialiezd 是普通变量,没有被 volatile 修饰,那么线程 A 执行的代码的修改初始化完成的结果 initialized = true 就有可能先于之前的三行代码执行,而此时线程 B 发现 initialized 为 true 了,就执行 doSomethingWithConfig() 方法,但是里面的配置信息都是 Null 的,就会出现问题了。如果 initialized 是 volatile 类型变量,保证禁止代码重排序优化,那么就可以保证 initialized = true 执行的时候,前边的三行代码一定执行完成了,那么线程 B 读取的配置文件信息就是正确的。跟其他保证并发安全的工具相比,volatile 的性能确实会好一些。在某些情况下,volatile 的同步机制性能要优于锁(使用 synchronized 关键字或者 Java.util.concurrent 包中的锁)。但是现在由于虚拟机对锁的不断优化和实行的许多消除动作,很难有一个量化的比较;但与自身比较可以确定一个原则:volatile 变量的读操作和普通变量的读操作几乎没有差异,但是写操作会性能差一些,因为要在本地代码中插入许多内存屏障指令来禁止指令重排序,保证处理器不发生代码乱序执行行为。

volatile 保证可见性

可见性是指当一个线程修改了某个变量的值,新值(修改后的值)对于其他线程来说是立即可以得知的。正如上面的前两条规则规定,volatile 类型的变量每次值被修改了就立即同步回主内存,每次使用时就需要从主内存重新读取值。返回到前面 JMM 对普通变量的规则中,并没有要求这一点,所以 普通变量的值是不会立即对所有线程可见的,即普通变量不具备可见性。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
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 1 验证volatile的可见性
* <p> 加入int number=0,number变量之前没有添加volatile关键字修饰,不具备可见性
* <p> 添加了volatile关键字,可以解决可见性问题
*/
public class VolatileTest
{
public static void main(String[] args)
{
MyData data = new MyData();

new Thread(() - >
{
System.out.println(Thread.currentThread().getName() + " thread come in");

try
{
TimeUnit.SECONDS.sleep(3);
}
catch(InterruptedException e)
{
e.printStackTrace();
}

data.setNumber();
System.out.println(Thread.currentThread().getName() + " thread set number is " + data.number);
}, "AAA").start();

while(data.number == 0)
{
// main线程一直在这里循环等待,直到number的值不再等于零
}

System.out.println(Thread.currentThread().getName() + " thread is over, the number is " + data.number);
}
}

class MyData
{
// int number = 0;

// volatile可以保证可见性,即可以及时通知其他线程,主内存中的变量值已经被修改
volatile int number = 0;

public void setNumber()
{
this.number = 60;
}
}

程序执行后的输出结果:

1
2
3
AAA thread come in
AAA thread set number is 60
main thread is over, the number is 60

volatile 不保证原子性

常见误解:volatile 对所有线程是立即可见的,所以对 volatile 的所有修改(写操作)都立刻能反应到其他线程中。或者换句话说:volatile 在各个线程中是一致的,所以基于 volatile 的运算在并发下是线程安全的。这个观点的论据是正确的,但是根据论据得出的结论是错误的,并不能得出这样的结论。volatile 的规则,保证了 read、load、use 的顺序和连续性,同理 assign、store、write 也是顺序和连续的。也就是这几个动作是原子性的,但是对变量的修改,或者对变量的运算,却不能保证是原子性的。如果对变量的修改是分为三个步骤的,那么多个线程同时从主内存拿到的值是最新的,但是经过多步运算后回写到主内存的值是有可能存在覆盖情况发生的。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
38
39
public class VolatileTest
{
public static volatile int race = 0;

public static void increase()
{
race++;
}

private static final int THREADS_COUNT = 20;

public static void main(String[] args)
{
Thread[] threads = new Thread[THREADS_COUNT];

for(int i = 0; i < THREADS_COUNT; i++)
{
threads[i] = new Thread(new Runnable()
{
@Override
public void run()
{
for(int j = 0; j < 10000; j++)
{
increase();
}
}
});
threads[i].start();
}

while(Thread.activeCount() > 1)
{
Thread.yield();
}

System.out.println(race);
}
}

程序执行后的输出结果:

1
41078

上述代码就是对 volatile 类型的变量启动了 20 个线程,每个线程对变量执行 1w 次加 1 操作,如果 volatile 并发操作没有问题的话,那么结果应该是输出 20w,但是结果运行的时候每次都是小于 20w,这就是因为 race++ 操作不是原子性的(图解),是分多个步骤完成的。假设两个线程 a、b 同时取到了主内存的值是 0,这是没有问题的,在进行 ++ 操作的时候假设线程 a 执行到一半,线程 b 执行完了,这时线程 b 立即同步给了主内存,主内存的值为 1,而线程 a 此时也执行完了,同步给了主内存,此时的值仍然是 1,线程 b 的结果被覆盖掉了。


如果需要解决 volatile 不保证原子性的问题,直接使用 AtomicInteger 这样的原子包装类即可保证原子性。示例代码如下:

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
38
39
public class VolatileTest
{
public static AtomicInteger race = new AtomicInteger();

public static void increase()
{
race.getAndIncrement();
}

private static final int THREADS_COUNT = 20;

public static void main(String[] args)
{
Thread[] threads = new Thread[THREADS_COUNT];

for(int i = 0; i < THREADS_COUNT; i++)
{
threads[i] = new Thread(new Runnable()
{
@Override
public void run()
{
for(int j = 0; j < 10000; j++)
{
increase();
}
}
});
threads[i].start();
}

while(Thread.activeCount() > 1)
{
Thread.yield();
}

System.out.println(race);
}
}

long 和 double 变量的特殊规则

Java 内存模型要求对主内存和工作内存交互的八种操作是原子性的,正如上文所讲,对 long 和 double 有一些特殊规则。八种操作中 lock、unlock、read、load、use、assign、store、write 对待 32 位的基本数据类型都是原子操作,对待 long 和 double 这两个 64 位的数据,Java 虚拟机规范对 Java 内存模型的规定中特别定义了一条相对宽松的规则:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,也就是允许虚拟机不保证对 64 位数据的 read、load、store 和 write 这 4 个动作的操作是原子的。这也就是常说的 long 和 double 的非原子性协定(Nonautomic Treatment of double and long Variables)。

先行发生原则(happens-before)

如果 Java 内存模型中所有的有序性都要依靠 volatile 和 synchronized 来实现,那是不是非常繁琐。Java 语言中有一个 “先行发生原则”,它是判断数据是否存在竞争、线程是否安全的主要依据。

先行发生原则概述

先行发生原则是 Java 内存模型中定义的两个操作之间的偏序关系。比如说操作 A 先行发生于操作 B,那么在 B 操作发生之前,A 操作产生的 “影响” 都会被操作 B 感知到。这里的影响是指修改了内存中的共享变量、发送了消息、调用了方法等。

自带先行发生原则有哪些

在 Java 内存模型中,自带的先行发生原则如下:

  • 程序顺序原则:在一个线程内部,按照代码的顺序,书写在前面的先行发生与后边的。或者更准确的说是在控制流顺序前面的先行发生与控制流后面的,而不是代码顺序,因为会有分支、跳转、循环等
  • 锁规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须注意的是对同一个锁,后面是指时间上的后面
  • volatile 规则:对一个 volatile 的写操作先行发生于后面对这个变量的读操作,这里的后面是指时间上的先后顺序
  • 线程启动规则:Thread 对象的 start() 方法先行发生与该线程的每个动作。当然如果错误的使用了线程,创建线程后没有执行 start 方法,而是执行 run 方法,那此句话是不成立的,但是如果这样其实也不是线程了
  • 线程终止规则:线程中的所有操作都先行发生与对此线程的终止检测,可以通过 Thread.join()Thread.isAlive() 的返回值等手段检测线程是否已经终止执行
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize 方法的执行,也就是初始化方法先行发生于 finalize 方法
  • 传递性规则:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C

在下述的代码中,如果有两个线程 A 和 B,A 先调用 setValue() 方法,然后 B 调用 getValue() 方法,那么 B 线程执行方法返回的结果是什么?

1
2
3
4
5
6
7
8
9
private int value = 0;

public void setValue(int value) {
this.value = value;
}

public int getValue() {
return this.value;
}

对照先行发生原则一个一个来对比。首先是程序次序规则,这里是多线程,不在一个线程中,不适用;然后是锁规则,这里没有 synchronized,自然不会发生 lock 和 unlock,不适用;后面对于线程启动规则、线程终止规则、线程中断规则也不适用;这里与对象终结规则、传递性规则也没有关系。线程 A 和线程 B 的启动时间虽然有先后,但上述代码没有符合 8 条原则中的任意一条,也没有使用任何同步手段,因此线程 B 执行结果是不确定的,即上述的操作不是线程安全的。如何修改呢,一种解决方案是对 get、set 方法加入 synchronized 关键字,即可以使用锁规则;另一种方案是对 value 加 volatile 修饰,可以使用 volatile 规则。


通过上面的例子可知,一个操作时间上先发生并不代表这个操作先行发生,那么一个操作先行发生是不是代表这个操作在时间上先发生?也不是,如下面的例子:

1
2
int i = 2;
int j = 1;

在同一个线程内,对 i 的赋值先行发生于对 j 赋值的操作,但是代码重排序优化,也有可能是 j 的赋值先发生,我们无法感知到这一变化。综上所述,时间先后顺序与先行发生原则之间基本没有太大关系。我们衡量并发安全的问题的时候不要受到时间先后顺序的干扰,一切以先行发生原则为准。

Java 并发内存模型的本质

Java 内存模型是围绕着并发过程中如何处理原子性、可见性和有序性这三个特征来设计的。

  • 原子性(Atomicity)

    • 由 Java 内存模型来直接保证原子性的变量操作包括 read、load、use、assign、store、write 这 6 种操作,虽然存在 long 和 double 的特例,但基本可以忽略不计,目前虚拟机基本都对其实现了原子性。如果需要更大范围的原子性控制,lock 和 unlock 也可以满足需求。lock 和 unlock 虽然没有被虚拟机直接提供给用户使用,但是提供了字节码层次的指令 monitorenter 和 monitorexit 对应这两个操作,对应到 Java 代码就是 synchronized 关键字,因此在 synchronized 块之间的代码都具有原子性。
  • 可见性(Visibility)

    • 可见性是指一个线程修改了一个共享变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说,volatile 类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。除了 volatile 之外,synchronized 和 final 也可以实现可见性。synchronized 关键字是通过 unlock 之前必须把变量同步回主内存来实现的,final 则是在初始化后就不会更改,所以只要在初始化过程中没有把 this 指针传递出去也能保证对其他线程的可见性。
  • 有序性(Ordering)

    • 有序性从不同的角度来看是不同的。单纯从单线程内部来看都是有序的,但到了多线程就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是 线程内表现为串行的语义,后半句指的是 指令重排 现象和主内存与工作内存之间同步存在延迟的现象。保证有序性的关键字有 volatile 和 synchronized,其中 volatile 禁止了指令重排序,而 synchronized 则由 一个变量在同一时刻只能被一个线程对其进行 lock 操作 来保证。

总结

synchronized 对上述三种特性都有支持,虽然简单,但是如果无控制地滥用对性能就会产生较大影响。volatile 只支持可见性和有序性(禁止指令重排),不支持原子性。