Java内存模型(JMM)

什么是JMM?

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

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

  JMM是围绕原子性,有序性、可见性展开。在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。

线程、工作内存、主内存交互方式如下:

image-20211117213435409

主内存

  主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该**实例对象是成员变量还是方法中的本地变量(也称局部变量)**,当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多个线程对同一个变量进行访问可能会发生线程安全问题。

工作内存

  主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

Java 内存模型的规定:

  • 所有变量存储在主内存中。
  • 每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行。
  • 不同线程之间无法直接访问彼此工作内存中的变量,要想访问只能通过主内存来传递。

JAVA内存模型八大原子操作

  • **lock(锁定)**:作用于主内存的变量,把一个变量标记为一条线程独占状态

  • **unlock(解锁)**:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

  • **read(读取)**:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

  • **load(载入)**:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

  • **use(使用)**:作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎

  • **assign(赋值)**:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量

  • **store(存储)**:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作

  • **write(写入)**:作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中

​ 如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

image-20211117214248949

同步规则分析

  1. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  2. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
  3. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  4. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
  5. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  6. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

并发编程三大重要特性

介绍

  1. 原⼦性 : ⼀个的操作或者多次操作,要么所有的操作全部都得到执⾏并且不会收到任何因素的⼲扰⽽中断,要么所有的操作都执⾏,要么都不执⾏。

  2. 可⻅性 :可见性是指当⼀个变量对共享变量进⾏了修改,那么另外的线程都是⽴即可以看到修改后的最新值。

    在多线程环境中,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题;另外指令重排以及编译器优化也可能导致可见性问题

  3. 有序性 :有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。 volatile 关键字可以禁⽌指令进⾏重排序优化

  事实上,输出了乱序的结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出。但是,指令重排序也是导致乱序的原因之一。总之,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

拓展:

  早期32位系统,long类型数据和double类型数据它们的读写并非原子性的(对于基本数据类型,byte、short、int、float、boolean、char读写是原子操作),也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但是此问题比较少见,而且目前的商用虚拟机几乎都把64位的数据的读写操作作为原子操作来执行,所以了解即可,不用太过担心。

如何解决

原子性问题

  除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized、Lock以及一些JDK自带的原子类来实现原子性。

可见性问题

  volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。

有序性问题

  在Java里,volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性(保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性)。

Java内存模型

  每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

指令重排序

  java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义在于JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

从源码到最终执行的指令序列示意图如下:

image-20211117220238654

为什么 Java 局部变量、方法参数不存在内存可见性问题?

  在 Java 中,所有的局部变量、方法定义参数不会在线程之间共享,所以也就不会有内存可见性的问题。所有的 Object 实例、Class 实例和数组元素都存储在 JVM 堆内存中,堆内存在线程之间共享,所以存在可见性问题。

as-if-serial语义

  as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

  为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

happens-before 原则

  只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下(主要是前6个):

  1. 程序顺序原则(as-if-serial 规则):在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  2. 监视锁规则(Monitor Lock Rule):对一个监视锁的解锁操作,先行于(happen-before)后续对这个监视锁的加锁操作。
  3. volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  4. start()规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
  5. 传递性:如果 A 操作先行于(happen-before)B 操作,而 B 操作又先行于(happen-before)C 操作, 那么 A 操作先行于(happen-before)C 操作。
  6. join 规则:如果线程 A 执行了 B.join()操作并成功返回,那么线程 B 中的任意操作先行于(happen-before) 线程 A 所执行的 ThreadB.join()操作。
  7. 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  8. 对象终结规则:对象的构造函数执行,结束先于finalize()方法

volatile

  volatile是Java虚拟机提供的轻量级的同步机制。被volatile 关键字修饰的共享变量中的汇编指令中,会在此变量之前之前多出一个 lock 前缀指令 lock addl,该 lock 前缀指令有三个功能:

  • 将当前处理器缓存行的数据立即写回系统内存,对所有线程总数可见的

  在对 volatile 修饰的共享变量进行写操作时,其汇编指令前用 Lock 前缀修饰。Lock 前缀指令导致在执行指令期间,CPU 可以独占共享内存。对共享内存的独占,老的 CPU(如 Intel486)通过总线锁方式实现。由于总线锁开销的比较大,所以新版 CPU(如 IA-32、Intel 64)通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个 CPU 同时修改共享内存的数据。

  • Lock 前缀指令会引起在其他 CPU 里缓存了该内存地址的数据无效

  写回操作时要经过总线传播数据,而每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器要对这个值进行修改的时候,会强制重新从系统内存里把数据读到处理器缓存;

  • Lock 前缀指令禁止指令重排

  Lock 前缀指令的最后一个作用是作为内存屏障(Memory Barrier)使用,可以禁止指令重排序, 从而避免多线程环境下程序出现乱序执行的现象。

volatile保证可见性

​ 被volatile修饰的变量对所有线程总数立即可见的,对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
public class VolatileVisibilityTest {
// 不加volatile 程序不能监听到initFlag的改变
// boolean initFlag = false;
volatile boolean initFlag = false;

public void save() {
this.initFlag = true;
String threadName = Thread.currentThread().getName();
System.out.println("线程:" + threadName + ":修改共享变量initFlag");
}

public void load() {
String threadName = Thread.currentThread().getName();
while (!initFlag) {
//线程在此处空跑,等待initFlag状态改变
}
System.out.println("线程:" + threadName + "当前线程检测到initFlag的状态的改变");
}

public static void main(String[] args) {
VolatileVisibilityTest sample = new VolatileVisibilityTest();
Thread threadA = new Thread(sample::save, "threadA");
Thread threadB = new Thread(sample::load, "threadB");
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}

volatile无法保证原子性

  虽然被volatile修饰的变量在发生改变以后它的值会立刻被其它线程感知到,但是在并发场景下,多个线程同时调用操作counter++的话,就会出现线程安全问题。毕竟++操作并不具备原子性,该操作是分三步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全问题,因此对于++方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AtomicCounterTest {
private volatile static int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 10000; j++) {
counter++;//分三步 - 读,自加,写回
}
});
thread.start();
}

try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
}

volatile禁止重排优化

  volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。下面我们来了解一下volatile是如何实现禁止指令重排优化的。

硬件层的内存屏障

  Intel硬件提供了一系列的内存屏障,主要有:

  1. lfence,是一种Load Barrier 读屏障

  2. sfence, 是一种Store Barrier 写屏障

  3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力

  4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

  不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:

屏障类型 指令示例 说明
LoadLoad Load1; LoadLoad; Load2 保证load1的读取操作在load2及后续读取操作之前执行
StoreStore Store1; StoreStore; Store2 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
LoadStore Load1; LoadStore; Store2 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
StoreLoad Store1; StoreLoad; Load2 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

  内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的有序性,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子–双重校验锁实现单例模式,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DoubleCheckLock {
private static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
//第一次检测
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多线程环境下可能会出现问题的地方
instance = new  DoubleCheckLock();
}
}
}
return instance;
}
}

  上述代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。

又因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)

1
2
3
memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null

  由于步骤2和步骤3间可能会重排序,如下:

1
2
3
memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象

  由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。为了解决这个问题,我们使用volatile禁止instance变量被执行指令重排优化即可。

1
2
//禁止指令重排优化
private volatile static DoubleCheckLock instance;

volatile内存语义的实现

  指令重排序可以分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。

下图是JMM针对编译器制定的volatile重排序规则表。

第一个操作 第二个操作:普通读写 第二个操作:volatile读 第二个操作:volatile写
普通读写 可以重排 可以重排 不可以重排
volatile读 不可以重排 不可以重排 不可以重排
volatile写 可以重排 不可以重排 不可以重排

  从上面的表格可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

  • 当第一个操作是volatile写,第二个操作是volatile读或写时,不能重排序。

  为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障。
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

  上述内存屏障插入策略是针对任意处理器平台的,所以非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

1 和 2 ⽤图形描述以及对应表格规则就是下⾯这个样⼦了:

image-20220214135138650

3 和 4 ⽤图形描述以及对应表格规则就是下⾯这个样⼦了:

image-20220214135218747

一段程序的读写通常不会像上面两种情况这样简单,这些屏障组合起来如何使用呢?我们只需要将这些指令带入到文章开头的表格中,然后再按照程序顺序拼接指令就好了,先看一段demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class VolatileBarrierExample {

private int a;
private volatile int v1 = 1;
private volatile int v2 = 2;

void readAndWrite(){
int i = v1; //第一个volatile读
int j = v2; //第二个volatile读
a = i + j; //普通写
v1 = i + 1; //第一个volatile写
v2 = j * 2; //第二个volatile写
}
}

屏障指令代入后:

image-20220214155254540

我们将上图分几个角度来看:

  1. 彩色是将屏障指令带入到程序中生成的全部内容,也就是编译器生成的「最稳妥」的方案
  2. 显然有很多屏障是重复多余的,右侧虚线框指向的屏障是可以被「优化」删除掉的屏障

volatile 写-读的内存语义

volatile写

image-20220214160134520

volatile 读

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

image-20220214160205384

流程可以总结为:

  1. 线程 A 写一个volatile变量, 实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所做修改的)消息
  2. 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
  3. 线程 A 写一个 volatile 变量, 随后线程 B 读这个 volatile 变量, 这个过程实质上是线程 A 通过主内存向线程B 发送消息。

参考:面试volatile关键字时,我们应该具备哪些谈资?