Java多线程内存模型

Java内存模型是多线程开发必须掌握的一个知识点。如果不想出现“知识不够,玄学来凑”的情况,想要在程序出现诡异问题的时候能够根据知识和经验快速定位并处理问题,就需要彻底搞懂Java内存模型。必须要知道什么是内存模型,为什么需要内存模型,以及引入内存模型能解决什么问题,在Java中是如何定义内存模型的。

Java内存模型(Java Memory Model)简称JMM,在JSR-133规范中定义了Java内存模型与线程的规范

基础知识

什么是内存模型

内存模型是为了保证多核心处理器的各级缓存与主内存数据的一致性而引入的数据操作模型。

在硬件层面:为了达到更高的性能引入了多核心处理器,每个处理器核心都有自己的高速缓存,并且每个核心都共享同一个主内存,从而引发了高速缓存与主内存的缓存一致性问题。

为了解决缓存一致性问题,需要各个处理器访问数据时都遵循“缓存一致性协议”。
处理器在读写数据时需要根据协议进行操作,常见的缓存一致性协议有:MSI、MESI、MOSI、Synapse等。
内存模型可以定义为:在特定的操作协议下,对特定的内存进行读写访问的抽象模型。
不同的硬件有不同的内存模型,为了屏蔽不同平台硬件的差异,Java虚拟机也定义了自己的内存模型。

Java内存模型的定义

Java内存模型屏蔽了不同平台硬件的差异,定义了变量的底层访问规则细节。

为什么需要Java内存模型?
不同的CPU处理器有不同的内存屏障实现,Java虚拟机提供了内存屏障类似的实现,保证了缓存一致性。
Java内存模型在语言层面屏蔽了底层硬件的差异,降低开发者多编写多线程程序的难度。
下图为Java多线程开发模型中的内存关系简化:

每个线程共享同一个主内存,且每个线程都有自己的工作内存,此处的工作内存可以对应理解为CPU的高速缓存。

每个线程都不能直接操作主内存,只能修改线程自己的工作内存,再同步回主内存。

Java内存模型规定了如下5条规范:

  1. Java内存模型规定所有的变量都存储在主内存中。
  2. 每条线程都有自己的工作内存
  3. 线程的工作内存中保存了该线程中使用到的变量的主内存副本拷贝
  4. 线程对变量的读写都必须在工作内存中进行,不能直接读写主内存。
  5. 线程间的变量传递必须通过主内存,不能直接访问其他线程的工作内存。

主内存与工作内存的交互协议

Java内存模型定义了8种操作来完成。

  1. 作用于主内存:
    lock 锁定:把变量标识为锁定状态,锁定后线程独占,其他线程无法访问。
    unlock 解锁:解锁变量的锁定状态,解锁后其他线程才能访问。
    read读取:把主内存的变量传输到一个线程的工作内存中,待load操作使用。
    write写入: 把store操作从工作内存中得到的变量存储到主内存中。

  2. 作用于工作内存:
    load加载:把read操作的变量加载到线程的工作内存的变量副本中。
    use 使用: 把工作内存中的变量传递给执行引擎。
    assign 赋值:把执行引擎的变量赋值给工作内存中的变量。
    store 存储:把工作内存中的变量传递到主内存中,待write操作使用。

8种操作规则

  1. readloadstorewrite 必须成对出现。不允许出现read完不load的情况,store完不write的情况。
  2. assign完成必须同步回主内存,不允许丢弃。
  3. 不允许没有assign操作的数据从工作内存同步回主内存。
  4. 变量必须在主内存中诞生,在usestore之前必须经过assignload操作。
  5. 一个线程可以对一个变量执行多次lock,执行多次lock后需要执行相同次数unlock才能释放锁,在锁定期间其他线程不允许lock
  6. 如果一个变量被lock,将会清空工作内存中此变量副本的值,执行引擎执行前需要重新执行loadassign完成初始化变量值的操作。
  7. 如果变量没有lock,不允许执行unlock操作。
  8. 对变量执行lock之前,必须把此变量同步回主内存中,执行storewrite操作。

多线程共享变量的不可见性

不使用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
/**
* 多线程共享变量不可见测试
* @author 蓝士钦
*/
public class MultiThreadSharedVarTest {

public static boolean initDataStatus = false;

public static void main(String[] args) throws InterruptedException {

Thread readThread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("[readThread]: waiting data...");
while (!initDataStatus){
// do something
}
System.out.println("[readThread]: data read success done.");
}
});
readThread.start();

TimeUnit.SECONDS.sleep(2);

Thread initThread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("[initThread]: init data");
initData();
System.out.println("[initThread]: init done.");
}
});
initThread.start();
}

public static void initData(){
initDataStatus = true;
}
}

不使用volatile的执行结果:

1
2
3
[readThread]: waiting data...
[initThread]: init data
[initThread]: init done.

针对共享变量,两个线程的工作流程简化如下:

  1. 在主内存中定义了共享变量 initDataStatus = false
  2. 在readThread线程中读取共享变量initDataStatus并拷贝到自己的工作内存中,得到initDataStatus变量副本, CPU读取副本变量进入while循环后空转。
  3. 在initThread线程中读取共享变量initDataStatus并拷贝到自己的工作内存中,并将工作内存中的initDataStatus变量副本设置为true。
  4. 图中省略了initThread将工作内存同步写回主内存的操作,同步写回后readThread线程依旧无法感知,会继续使用工作内存中的值。两个线程在各自的工作内存中读写initDataStatus变量副本。此时readThread永远不会读取到initThread对变量修改后的值,readThread线程将会一直处于while循环中

volatile的变量可见性

java内存模型提供了volatile关键字来保证变量的可见性和有序性

将第7行代码加上volatile关键字修饰 initDataStatus 变量。

1
2
3
// ... 
public static volatile boolean initDataStatus = false;
// 略...

使用volatile的执行结果:

1
2
3
4
[readThread]: waiting data...
[initThread]: init data
[initThread]: init done.
[readThread]: data read success done.

使用volatile关键字可以开启CPU嗅探机制,实现缓存一致性协议的功能。
CPU核心会监听BUS总线上有关volatile修饰的变量的值变动。
执行过程如下:

  1. 通过readload指令将主内存中的initDataStatus = false拷贝到readThread线程的工作内存中。
  2. readThread线程使用use指令,将工作内存中的initDataStatus变量副本交给执行引擎执行指令!initDataStatus,进入while空转。
  3. 通过readload指令将主内存中的initDataStatus = false拷贝到initThread线程的工作内存中。
  4. initThread线程使用use指令,将工作内存中的initDataStatus变量副本交给执行引擎执行指令initDataStatus=true,通过assign指令将initDataStatus=true赋值给工作内存。
  5. volatile变量变化后立即通过 storewrite指令将工作内存中的initDataStatus=true写回主内存。
  6. 第五步写回主内存时经过BUS总线,CPU为volatile修饰的变量开启了嗅探机制。initThread修改变量后经过总线时将会被Core 0核心监听到,Core 0核心中的readThread会将工作内存中的initDataStatus变量失效,重新从主内存中加载。
    CPU嗅探机制会监听volatile修饰的变量变化,使工作内存中的变量失效,重新从主内存读取。

指令重排问题

在不影响单线程程序执行结果的前提下,计算机为了最大限度发挥机器的性能,会对上下文无关的指令进行重排序优化。

编译器和CPU都有可能会对程序进行指令重排优化,重排序会遵循as-if-serialhappens-before原则。

不能重排的程序:

1
2
3
4
5
6
7
8
9
public class WillNotSortFrom {

static int x = 0;

public static void main(String[] args) {
x = 5;
System.out.println("x="+x);
}
}

程序输出结果:

1
x=5

上面的代码不允许进行指令重排,假设第6行和第7行代码重排了顺序,先执行输出语句System.out.println("x="+x),将会打印x=0,再执行赋值操作x = 5。这将会改变单线程内执行结果的一致性。

在单个线程内,第7行的变量x,依赖第6行对x的赋值操作,上下文相关的指令不会进行重排

对于多线程间的变量操作,就无法保证上下文无关的指令进行重排序了,代码如下:

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
/**
* 指令重排测试
* @author 蓝士钦
*/
public class ReorderTest {

static int x = 0;
static int y = 0;
static int a = 0;
static int b = 0;

public static void main(String[] args) throws InterruptedException {

Set<String> resultSet = new HashSet<>();

for (int i=0; i< 100000000; i++){
x = 0;
y = 0;
a = 0;
b = 0;

Thread oneThread = new Thread(() -> {
a = 1;
x = b;
});

Thread twoThread = new Thread(() -> {
b = 1;
y = a;
});

oneThread.start();
twoThread.start();

oneThread.join();
twoThread.join();

resultSet.add("x="+x+",y="+y+"");
System.out.println(resultSet);
}
}
}

线程one中的代码没有上下文依赖关系,线程tow中的代码也没有上下文依赖关系。但是线程one和线程two有互相依赖的共享变量,编译器和cpu无法区分多线程间变量的依赖顺序
为了验证指令发生重排,循环了一亿次,执行一段时间后,程序输出结果:

1
[x=0,y=1, x=1,y=1, x=0,y=0, x=1,y=0]

意料之中的结果:

  1. one线程先执行完毕,在执行tow线程,x=0,y=1
  2. two线程先执行完毕,在执行one线程,x=1,y=0
  3. one线程执行a=1后被中断,再执行two线程,直到tow线程执行完毕,在恢复one线程执行,x=1,y=1

意料之外的结果:

x=0,y=0是意料之外的结果。因为发生了指令重排,指令重排可能发生在两个线程中。
假设执行one线程发生了指令重排,第23行和第24行代码,x=ba=1对换了位置。
one线程执行x=b后被中断,接着执行two的线程直到执行完成,然后恢复one线程执行,此时就发生了x=0,y=0的情况。
为了避免指令重排,java提供了volatile关键字来开启内存屏障,防止指令重排。将上面的变量声明加上volatile即可。
1
2
3
4
static volatile int x = 0;
static volatile int y = 0;
static volatile int a = 0;
static volatile int b = 0;


volatile关键字提供给开发人员手动关闭指令重排的能力。

内存屏障防止指令重排

硬件层面很难知道软件层面前后的依赖关系,所以CPU层面提供了内存屏障,使软件可以决定在适当的地方插入内存屏障来解决指令重排序问题。

CPU层面的内存屏障

  1. 写屏障(Store Memory Barrier):告诉处理器在写屏障之前的指令数据必须从存储缓存(store buffer)中写回主内存,写屏障之前的操作对写屏障之后必须可见。
  2. 读屏障(Load Memory Barrier):配合写屏障,使得写屏障之前的内存更新,对于读屏障之后的操作可见。
  3. 全屏障(Full Memory Barrier):全屏障前的内存读写操作提交到内存之后,在进行全屏障之后的内存读写操作。

JVM层面的内存屏障

Java提供了volatile关键字来使得JVM层面能够插入内存屏障功能。

JMM提供了4种内存屏障:

  1. LoadLoad Barrier,示例Load1;LoadLoad;Load2,确保Load1的指令先于Load2及后续指令的加载。
  2. StoreStore Barrier,示例Store1;StoreStore;Store2,确保Store1的数据先于Store2的数据同步到主内存。
  3. LoadStore Barrier,示例Load1;LoadStore;Store2,确保Load1的数据加载先于Store2及后续的所有指令同步到主内存。
  4. StoreLoad Barrier,示例Store1;StoreLoad;Load2,确保Store1的数据全部同步到主内存之后,先于Load2的数据加载操作。

volatile关键字修饰的变量,插入内存屏障策略:

  • 在每个volatile变量写操作前,加入StoreStore Barrier
  • 在每个volatile变量写操作后,加入StoreLoad Barrier
  • 在每个volatile变量读操作前,加入LoadLoad Barrier
  • 在每个volatile变量读操作后,加入LoadStore Barrier

happens-before规则

happens-before规则表达多线程之间的内存可见性,前一个操作对后一个操作是可见的。
如果一个操作要让另外一个操作可见,就必须满足happens-before关系,无论两个操作是在同一个线程中还是不同线程中。详细规则此处不展开。

volatile底层实现原理

JVM底层通过lock汇编前缀指令,实现内存屏障功能。

可以通过运行时指定虚拟机参数,打印机器平台对应的汇编指令,运行时参数如下:

1
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*MultiThreadSharedVarTest.initData

-XX:+PrintAssembly参数打印汇编指令代码需要相关的动态库支持,JDK8中默认不启用汇编打印,需要下载对应的支持库。
MacOS平台的库文件为:hsdis-amd64.dylib, 对应平台的库文件需要自行下载,放到对应目录,此处不展开记录。
运行程序后,输出的汇编指令如下:

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
CompilerOracle: compileonly *MultiThreadSharedVarTest.initData
Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
[readThread]: waiting data...
[initThread]: init data
Loaded disassembler from /Library/Java/JavaVirtualMachines/jdk1.8.0_202.jdk/Contents/Home/jre/lib/server/hsdis-amd64.dylib
Decoding compiled method 0x00000001094833d0:
Code:
[Disassembling for mach='i386:x86-64']
[Entry Point]
[Verified Entry Point]
[Constants]
# {method} {0x0000000108627c48} 'initData' '()V' in 'com/lanshiqin/jmm/MultiThreadSharedVarTest'
# [sp+0x40] (sp of caller)
0x0000000109483520: mov %eax,-0x14000(%rsp)
0x0000000109483527: push %rbp
0x0000000109483528: sub $0x30,%rsp
0x000000010948352c: movabs $0x108628b38,%rsi ; {metadata(method data for {method} {0x0000000108627c48} 'initData' '()V' in 'com/lanshiqin/jmm/MultiThreadSharedVarTest')}
0x0000000109483536: mov 0xdc(%rsi),%edi
0x000000010948353c: add $0x8,%edi
0x000000010948353f: mov %edi,0xdc(%rsi)
0x0000000109483545: movabs $0x108627c48,%rsi ; {metadata({method} {0x0000000108627c48} 'initData' '()V' in 'com/lanshiqin/jmm/MultiThreadSharedVarTest')}
0x000000010948354f: and $0x0,%edi
0x0000000109483552: cmp $0x0,%edi
0x0000000109483555: je 0x000000010948357f ;*iconst_1
; - com.lanshiqin.jmm.MultiThreadSharedVarTest::initData@0 (line 42)

0x000000010948355b: movabs $0x7956a1de0,%rsi ; {oop(a 'java/lang/Class' = 'com/lanshiqin/jmm/MultiThreadSharedVarTest')}
0x0000000109483565: mov $0x1,%edi
0x000000010948356a: mov %dil,0x68(%rsi)
0x000000010948356e: lock addl $0x0,(%rsp) ;*putstatic initDataStatus
; - com.lanshiqin.jmm.MultiThreadSharedVarTest::initData@1 (line 42)

0x0000000109483573: add $0x30,%rsp
0x0000000109483577: pop %rbp
0x0000000109483578: test %eax,-0x395747e(%rip) # 0x0000000105b2c100
; {poll_return}
// 略...

从汇编指令中可以看到 lock指令,对应initData方法的initDataStatus变量,lock指令实现了和内存屏障一样的功能

1
2
0x000000010948356e: lock addl $0x0,(%rsp)     ;*putstatic initDataStatus
; - com.lanshiqin.jmm.MultiThreadSharedVarTest::initData@1 (line 42)

volatile底层通过汇编lock指令,实现硬件级别的锁。不同硬件平台的汇编指令有所不同,从JVM虚拟机的C/C++源码实现中可以找到不同平台的lock指令实现,JVM为Java程序员屏蔽了底层硬件差异。

为了解决缓存一致性问题,CPU层面提供了两种锁机制:总线锁缓存锁

总线锁

处理器其中一个核心对共享内存进行操作的时候,在总线上发出一个LOCK信号,使得其他处理器无法通过总线来访问共享内存中的数据,锁定期间其他处理器无法访问其他内存地址的数据。代价太大,需要降低锁的粒度,引入了缓存锁。

缓存锁

缓存锁是基于缓存一致性协议实现的,一个处理器的缓存写回会导致其他处理器的缓存无效。不同的处理器实现的缓存一致性协议不同。

缓存一致性协议 MESI

MESI是一种比较常用的缓存一致性协议,MESI表示缓存行的四种状态。

  1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  3. S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
  4. I(Invalid) 表示缓存已经失效
    在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它CPU的读写操作。
    对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
    CPU读操作:缓存处于 M、E、S 状态都可以被读取,I 状态CPU 只能从主存中读取数据
    CPU写操作:缓存处于 M、E 状态才可以被写。对于S状态的写,需要将其他CPU中缓存行置为无效才行。

总结

并发编程三大特性:可见性、有序性、原子性。

volatile只保证了可见性和有序性,原子性需要借助原子类操作或者使用synchronized这样的锁机制来保证。

0%