0%

JMM(一):初识Java内存模型

在并发编程中,线程之间的通信是一个很关键的问题,而该问题解决方案主要可分为两大类:消息传递、共享内存。前者有以Erlang语言为代表的Actor模型,而后者中典型的则是Java语言。对于消息传递机制而言,线程之间必须通过发送消息以进行显式地通信。而同步过程则是隐式地,因为消息的发送必须在消息的接收之前;而对于共享内存机制来说,线程之间可以通过读、写内存中的公共状态来实现隐式地通信,但同步操作则需通过开发者显式地进行指定。可以看到由于Java的并发采用是共享内存机制,所以在谈多线程并发编程之前,需要对JMM(Java Memory Model)Java内存模型有一定的了解

abstract.png

CPU内存模型与缓存一致性问题

CPU内存模型

在谈论Java内存模型之前,我们先来了解下现代CPU内存模型。下面是一个双核CPU的组成示意图,每个CPU都包含一个独有的一级缓存,同时还有一个可被所有CPU共享的二级缓存。多级Cache的作用就是为了缓冲现代CPU与主内存Ram之间严重不匹配的速度

figure 1.jpeg

Cache Coherency 缓存一致性问题

双核甚至多核CPU的出现,使得多个线程可以在不同的CPU中执行,可以大大减少单核CPU由于频繁切换线程而引起的上下文切换开销。目前看来好像一切都是美妙的。但是很快人们发现,这会引发一个新的问题——Cache Coherency 缓存一致性问题

假设现在我们有两个线程A、B分别使用CPU #1、#2执行,其中在主内存Ram有一个共享变量a,其初始值为1

  • Step 1 : 线程A将变量a的值修改为2

线程A首先获取共享变量a值,由于两级缓存L1 Cache、L2 Cache均未命中,故只能从主内存Ram中加载;然后将a=1缓存到两级缓存中;最后线程A修改了变量a的值为2,并将其写入两级缓存、主内存中

figure 2.jpeg

  • Step 2 : 线程B对变量a进行自增

线程B首先获取共享变量a值,二级缓存L2 Cache被命中,其值为2;然后对变量a自增变为3,并将其写入两级缓存、主内存中

figure 3.jpeg

目前为止一切都是正常的,经过Step1、2两步操作后,主内存中变量a的值变为3,符合我们的预期

  • Step 3 : 线程A对变量a进行自增

线程A首先获取共享变量a值,一级缓存L1 Cache被命中,其值为2;然后对变量a自增变为3,并将其写入两级缓存、主内存中

figure 4.jpeg

等等,好像哪里不对啊!在Step2后共享变量在主内存中已经是3了,那么Step3中线程A如果再次对其自增后,主内存中的变量a的值应该更新为4才对啊。但实际上执行完Step3后,主内存变量a的却依然是3

相信聪明的朋友可能已经看出来原因所在了,在Step2后,虽然主内存中变量a的值已经更新为3了,但是在CPU #1独有的L1 Cache中,变量a的值却还是2未被更新。换言之,由于各CPU内部Cache之间的不可见性,CPU无法感知到其他CPU Cache对数据所做的更新、修改,从而引发 Cache Coherency 缓存一致性问题

总线加锁

为了解决Cache Coherency 缓存一致性问题,早期是通过直接对主内存与共享Cache(即这里的L2 Cache)之间的总线加锁来解决的。在我们上面的例子中,线程A的工作就是将变量a修改为2,然后再对其自增;而B线程的工作是将变量a的值自增一次。现在假设依然是Step 1先执行,即线程A将主存中变量a的值修改为2了。由于总线加锁机制的存在,在线程A第一次从主存中加载值为1的变量a时,总线即会主存中变量a进行加锁,使得其他CPU(即这里的CPU #2的线程B)无法读、写该变量只等进行等待,直到线程A完成了对该变量的全部操作Step1、Step3——即先将变量a修改为2,再自增为3。当线程A将变量a的值3最终写入主内存后,总线才会将该锁释放。此时线程B才可以从主内存中加载变量a执行自增操作,并最终将a=4写入到主内存中(当然总线在此期间同样会再次对总线进行加锁,以保证CPU #2的线程B对其进行独占)。即在总线加锁的机制下,如果线程A先拿到总线锁,则线程A、B的任务执行顺序是Step1、Step3、Step2。虽然通过总线加锁的方式可以解决我们上面提到的缓存一致性问题,但是弊端同样显而易见,总线加锁会导致其他线程完全无法操作该变量,只能进行等待。换句话说,总线加锁的效率太低、开销太大,严重浪费了多核CPU的性能

MESI协议-缓存锁

为了解决总线加锁的弊端,现代CPU在访问Cache的过程中,可通过遵循一些协议来解决缓存一致性问题。典型地有Intel的MESI缓存一致性协议。在MESI协议中,当多个CPU从主内存加载同一个共享变量的数据并缓存到各自Cache后,一旦某个CPU修改了该变量在其缓存中的数据后,立刻将修改后的数据同步到主内存中。通过对Cache中该数据所在的缓存行加锁来阻止其他CPU同时修改主内存中该变量的数据,当主内存数据被修改完毕后即释放锁。与此同时,其他CPU可通过总线嗅探机制感知到该变量的数据变化从而将自己CPU内部相应的缓存数据失效。可以看到,MESI协议,一方面让CPU可以感知其他CPU中缓存数据的修改、变化来及时将自己Cache中的数据失效;另一方面只对缓存数据在回写到主内存的过程进行加锁,即使用缓存锁的方式,大大减小了锁的粒度,提高了多核CPU的利用率

Java Memory Model

有了前面CPU内存模型的引子,现在让我们回到正题来了解下什么是Java Memory Model(Java内存模型)。Java试图定义一种内存模型,其能够屏蔽各种硬件底层、操作系统的内存访问差异,以保证Java程序在各种平台下的一致的内存访问效果。而在JDK 1.5版本中通过实现JSR-133,Java内存模型才被真正地完善地成熟地建立起来了。所以本文所谈论的Java内存模型均是基于JSR-133而言的

Java内存模型规定共享变量存储在主内存中,当Java线程使用该共享变量时,需要先将其拷贝到该线程所属的工作内存中。换句话说,线程对共享变量的操作只能在该线程的工作内存中进行,而不能直接读写主内存中的变量。当然线程之间也无法直接访问对方工作内存中的变量,所以线程之间共享变量值的传递均需通过主内存来完成。其示意图如下所示,可以看到JMM在设计上与我们之前介绍的CPU内存模型有很大相似之处

figure 5.jpeg

上面所说的共享变量,具体则是指实例变量、静态变量以及数组中的元素,但不包括局部变量、方法参数。因为后者(局部变量、方法参数)是线程私有的,自然不会被共享。需要注意的是,Java内存模型只是是一个抽象的概念,其中主内存一般对应于计算机硬件的Ram,而工作内存则不真实存在,其可能会对应于CPU寄存器、缓存或Ram。同时这里希望大家不要将JMM中的主内存、工作内存与JVM Java虚拟机中的堆、栈、方法区等Java内存区域相混淆,因为二者不是在一个层次上的内存划分,这里JMM是为Java并发而服务的

并发编程的三个特性

Java内存模型就是围绕着在并发过程中如何处理原子性、可见性、有序性这三个特性来建立的,其通过相关的规范、规则来避免并发编程可能出现的线程安全问题

Atomicity 原子性

原子性是指一个或多个操作是不可中断的,要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。在JMM中定义了以下八种操作来完成共享变量在主内存与工作内存之间的具体交互。与此同时,下面提及的每一种操作均由JMM来直接保证其具备原子性,所以Java虚拟机实现时必须要满足下列操作的原子性

  1. lock(锁定) :作用于主内存的变量,它把一个变量标示为一条线程独占的状态
  2. unlock(解锁) :作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取) :作用于主内存的变量,它把一个变量的值从主内存传输到线程内的工作内存中,以便随后的load操作使用
  4. load(载入) :作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use(使用) :作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行此操作
  6. assign(赋值) :作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行此操作
  7. store(存储) :作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存中,以便随后的write操作使用
  8. write(写入) :作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中

在开发中通常需要保证多个操作的原子性,所以在JMM中提供了lock、unlock操作,尽管虚拟机未将这两个操作开放提供给用户使用。但是却提供了更高层次的字节码指令monitorenter、monitorexit来隐式地使用了这两个操作,而这两个字节码指令反映到Java代码层面就是同步块——synchronized关键字。所以synchronized块之间的一系列操作同样具备原子性

Note

值得一提的是,对于long、double类型变量而言,JMM并不强制要求虚拟机在实现时保证read、load、store、write操作的原子性,即所谓的long、double的非原子性协定。不过就实际开发而言,我们也无需过多担心这点。因为目前大多数商用虚拟机几乎都会选择实现long、double数据读写操作的原子性

Visibility 可见性

可见性则是当一个线程修改了共享变量的值,其他线程能够立即感知到这个修改。前面我们已经提到JMM中各线程的工作内存相互是不可见的,即不可以直接访问其他线程的工作内存。所以在JMM中可见性是通过主内存作为传递媒介来实现的,即线程在修改了共享变量的值后需要同步回主内存,在线程读取时从主内存拷贝副本

Ordering 有序性

重排序

一般大家会认为程序的执行是按我们程序编码时的顺序关系顺序执行,但实际上并不是这样。现代CPU会利用一些诸如多级流水线(多条指令可重叠执行)等并行技术来提高执行效率。CPU可以将多条指令打乱来重新组织执行顺序,而不按程序编码时的顺序进行执行,即CPU的乱序执行(out-of-order execution,OOE)。与此同时,编译器在很多情况下(例如优化等)也会对指令执行顺序进行调整。基于此,不论是编译器还是处理器CPU都会对程序指令进行重排序。通过下面这个示例即可观察到重排序这一现象

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
public class Ordering {
public static int x = 0;
public static int y = 0;

public static void test1() throws Exception {

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

for( long i=0; i< (500000*100); i++ ) {
x = 0;
y = 0;
Map<String, Integer> map = new ConcurrentHashMap<>();
Thread threadA = new Thread(() ->
{
int a = y; // (1)
x = 1; // (2)
map.put("a", a);
});

Thread threadB = new Thread(() ->
{
int b = x; // (3)
y = 1; // (4)
map.put("b", b);
});

threadA.start();
threadB.start();

threadA.join();
threadB.join();

String result = " { a=" + map.get("a") + ", b=" + map.get("b") + " } ";
resultSet.add(result);
}
System.out.println(resultSet);
}
}

从下面执行结果中红框部分,我们可以看到竟然出现a=1,b=1的执行结果,其可以说明在程序的执行顺序中 ②比③先执行、④比①先执行。而要满足上述的执行顺序,要么是因为②比①先执行,要么是因为④比③先执行。即程序指令发生了重排序

figure 6.png

As-If Serial 语义

As-If Serial 语义,是指无论做怎样的重排序单线程的执行结果都不应被改变。即在本线程内进行观察,程序的执行是有序的而没有乱序执行,看上去是串行的。考虑下面的例子,下面3行代码的顺序是 ①->②->③,但是由于①与②之间不存在任何数据依赖关系,所以编译器、处理器可以对①、②操作进行重排序。即实际的执行顺序可能是 ②->①->③,虽然对指令进行了重排序,但并不影响最终的结果

1
2
3
double pi = 3.14159;   // (1)
double r = 2.0; // (2)
double s = pi * r * r; // (3)

所以说,遵守As-If Serial语义的编译器、Runtime和硬件(CPU等)共同把单线程程序保护了起来,即在程序中不应能够观察到重排序的效果。其为开发单线程程序的开发者创建了一个幻觉,即单线程程序是按程序编码的顺序来执行的。Java内存模型在单线程中的有序性可由As-If Serial语义提供保证

Happens-Before 先行发生原则

在Java的多线程程序中,如果在一个线程中观察另外一个线程,则可以发现其所有的操作都是无序的。其原因在于指令的重排序、工作内存与主内存同步延迟。为此Java提供了volatile、synchronized两个关键字来保证线程间操作的有序性。而如果在Java内存模型中所有的有序性都仅仅依靠volatile、synchronized来实现的话,那么就会导致我们在实际开发多线程程序时非常繁琐。为此Java内存模型中提出了一个 Happens-Before 先行发生原则,其定义了两项操作之间的偏序关系。当A操作先行发生于操作B,其含义是在发生B操作之前,A操作产生的影响(包括但不限于修改共享变量的值、发送消息、调用方法)能够被B操作观察到

程序顺序规则 :在一个线程内,按照分支、循环等控制流等顺序,编写在前面的操作先行发生于书写在后面的操作
监视器锁规则 :一个unlock操作先行发生于后面对同一个锁的lock操作。这里的后面是指令在执行时间上的先后顺序
volatile变量规则 :对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这里的后面是指令在执行时间上的先后顺序
线程启动规则 :Thread对象的start()方法先行发生于此线程的每一个动作
线程终止规则 :线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行
线程中断规则 :对线程interrupted()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生
对象终结规则 :一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
传递性 :如果操作A先行发生于操作B、操作B先行发生于操作C,那么可以得出操作A先行发生于操作C的结论

在JMM中,这些原则无需任何其他同步手段协助就已经存在。故在实际的开发过程中,Happens-Before 先行发生原则是我们判断数据竞争、线程安全的主要依据,可以在编码中直接应用。如果两个操作之间的关系不在上述原则之列且无法从上述原则中推导出来,那么就无法保证他们的执行顺序,即发生重排序

当然值得一提的是,Happens-Before原则实际上是对Java内存模型的一种近似性描述,不够严谨,但是可以方便我们日常开发应用参考。例如在一个线程中存在如下代码

1
2
3
double pi = 3.14159;   // (1)
double r = 2.0; // (2)
double s = pi * r * r; // (3)

根据程序顺序原则,我们可以得到下面的三个偏序关系:

  • ①先行于③发生
  • ②先行于③发生
  • ①先行于②发生

前2个偏序关系显然是必要的,但是对于第3个偏序关系则不是必要的。也就是说①、②发生重排序,②先行于①发生,并不会改变程序的执行结果。故如果Happens-Before所禁止的重排序并不会改变程序的执行结果,JMM将不会要求编译器、处理器来禁止该重排序。即在此种条件下JMM允许进行重排序。这样做的目的也是显然易见的,即最大程度减少对编译器、处理器优化的限制

Memory Barrier 内存屏障

JMM向上给开发者提供了一些规则来保证并发编程时的一定有序性,向下则是通过编译器在适当位置插入相关Memory Barrier 内存屏障指令禁止特定类型的重排序来实现相关操作的有序。Memory Barrier 内存屏障,又称作Memory Fence内存栅栏,其是对一类CPU指令的统称。其作用在于保证CPU执行相关操作时一定的有序,避免CPU对相关指令的乱序执行。具体的,JMM将内存屏障指令分为以下四种类型

  1. LoadLoad屏障 :在指令序列 Load1; LoadLoad; Load2 中,该类型屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
  2. StoreStore屏障 :在指令序列 Store1; StoreStore; Store2 中,该类型屏障确保Store1数据的内存写入(使其对其他处理器可见)先于Store2及其后所有存储指令的操作
  3. LoadStore屏障 :在指令序列 Load1; LoadStore; Store2 中,该类型屏障确保Load1数据的装载先于Store2及其后所有存储指令的操作
  4. StoreLoad屏障 :在指令序列 Store1; StoreLoad; Load2 中,该类型屏障确保Store1数据的内存写入(使其对其他处理器可见)先于Load2及其后所有装载指令的操作。在大多数处理器的实现中该类型屏障由于同时具备其他三个类型屏障的效果,所以其是一个万能屏障。当然该屏障开销也是最大的

参考文献

  1. Java并发编程之美 翟陆续、薛宾田著
  2. 深入理解Java虚拟机·第2版 周志明著
  3. JSR-133: Java Memory Model and Thread Specification
请我喝杯咖啡捏~

欢迎关注我的微信公众号:青灯抽丝