金三银四面试:通过汇编指令,学习Java内存模型(JMM)

前言

时隔五年,再次学习JMM,希望怀揣曾经的热情,有着不一样的收获。

缓存一致性

CPU都有自己的L1、L2、L3缓存,CPU会将常用的数据,从主内存同步到缓存中,以此来提高数据的访问速度。如果CPU修改了缓存中的数据,就会从缓存更新到主内存中。

如今,我们使用的电脑都是多个CPU,当多个CPU同时修改了一个数据时,那么主内存中数据要以谁的为准,这就容易造成缓存不一致的情况。

假如主存中的i为0,每个CPU缓存中的i也为0。3个CPU都对i进行了+1操作,按理说最后主存的i应该为3,但是实际i为1、2、3都有可能。所以,系统为了保证多CPU之间的缓存一致性,针对内存和缓存之间的数据读写,就制定了一些协议。

缓存一致性协议保证了多CPU下的数据一致性,最后在主存中i=3。

刚开始是通过在总线加LOCK#锁的方式实现缓存一致性,但会阻塞其他cpu访问内存,所以intel提出了MESI协议:

  1. 在多处理器下,为保证各个处理器的缓存是一致的,每个处理器都会通过嗅探在总线上传播的数据来检查自己缓存的值是否过期
  2. 当处理器发现自己缓存行对应的地址被修改,就会将当前处理器的缓存行设置为无效状态
  3. 当处理器对这个数据进行读写的时候,会重新把数据从内存中读取到处理器缓存中

所以缓存一致性协议还要保障数据的可见性。

内存模型

那什么是内存模型呢?

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的抽象过程。

--《深入理解Java虚拟机》

可以理解为内存模型就是缓存一致性协议中,对于多CPU缓存和主存之间实现读写一致性的规则定义。

既然系统自带内存模型,为什么Java还有自己的内存模型?

不同处理器架构和系统架构可能会使用不同的缓存一致性协议,例如常见的x86架构使用的MESI协议,ARM架构使用MESIF或者MOESI协议,IBM Power架构的MECI。Java为了屏蔽不同架构之间对内存访问的差异性,保证Java的平台无关性,所以就制定Java内存模型(Java Memory Model, JMM)。

Java内存模型

上面讲到缓存一致性协议是为了保证多CPU下的缓存与主存数据一致性,那么JMM就是为了保证Java多线程下的线程工作内存和主存之间的数据一致性。

这里的Java线程就相当于CPU,工作内存就相当于CPU的缓存,通过JMM来实现与缓存与主内存之间的数据一致性。其中包括:可见性、原子性、有序性

原子性

在学习jvm内存结构 的时候,我们知道每个线程都有自己的虚拟机栈,而线程的工作内存就是存放在该线程的虚拟机栈中,用于存储线程私有的数据。而主内存对应的就是多线程共享的Heap存放对象的数据部分。线程在执行过程中会将共享变量从主内存加载到自己的工作内存中进行操作,然后再将结果写回主内存。

为了保证多线程之间的数据一致性,JMM定义了8种原子操作来实现一致性。

JMM提供了read、 load、 assign、 use、 store和write六个原子操作,除此之外,lock和unlock提供了大范围的原子性,例如synchronized。在执行这些原子操作时要满足以下规则:

  1. read/load、store/write必须成对出现,且顺序执行
  2. 不允许线程丢弃最近的assign操作,即工作内存中变量修改之后必须同步到主内存
  3. 不允许线程无原因地(没有assign操作)将变量工作内存同步到主内存
  4. 变量只能在主内存中诞生,并且必须在工作内存中初始化才能使用。即use、store之前必须经过load和assign
  5. 一个变量在同一时刻只能被一个线程lock。但一个线程可以lock多次。几次lock,只有对应次数的unlock变量才能解锁
  6. 一个变量被lock后,会清空所有工作内存中此变量的值。再次使用需要重新load、assign来初始化
  7. 一个变量未被lock,则不允许对它执行unlock,也不允许unlock其他线程lock的变量
  8. 一个变量unlock前,必须先把此变量同步到主内存中(store、write)

可见性

确保一个线程对共享变量的修改对其他线程可见。这意味着一个线程在工作内存中修改了数据后,必须将最新的数据刷新到主内存,以便其他线程可以读取到更新后的数据。

有序性

在执行程序时为了提高性能,即时编译器(Just In Time)和处理器常常会对指令做重排序。例如a=1,b=1,c=a+b三条指令,只要a、b在c之前,a、b谁先执行对c的结果没有影响。

对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM 的处理器重排序规则则会通过内存屏障指令来禁止特定类型的处理器重排序。

JMM通过以上规则和语义,提供了工作内存和主存之间数据的访问方式,可保证多线程之间变量的一致性。 基于JMM,Java提供了volatile、synchronized、Lock等关键字和类,来实现变量的一致性。

volatile

Java中的关键字volatile实现了可见性禁止指令重排序。定义一个volatile变量,通过汇编来看看volatile是如何保证可见性的。

java 复制代码
public class VolatileTest {
    volatile static int a;
    public static void main(String[] args) {
        a = 2;
    }
}

输出并查看汇编:

movabs 将class加载到rsi中,mov $0x2,%edi 是将2移动到edi中,mov %edi,0x68(%rsi) 是将edi中的值(2)存放在rsi指定的内存地址(对应的是变量a),这里可以理解为a=2的赋值操作。

addl $0x00,(%rsp) 表示将栈顶的数据加0,即2 + 0。前面的lock前缀指令,是表示将最新的a写到主存中。

在x86架构中,lock前缀指令会引发缓存行的写入主存。当一个处理器执行一个带有lock前缀的指令时,它会将修改后的数据写回到内存中,这样其他线程就能立马看到修改的数据,这就保证了数据的可见性。

同时会锁定总线,防止其他处理器同时访问该内存位置,从而确保操作的原子性。这个就相当于对缓存中的变量做了做了一次storewrite 操作。而lock指令相当于在addl操作前加了内存屏障,执行无法透过内存屏障来重排序,从而禁止了指令重排序。

如果a没有使用volatile修饰,查看汇编:

汇编指令中没有lock,movl直接将rsi寄存器中的变量a更新成了2,而没有加载到主存。

应用场景

volatile适用于两种场景:

  1. 对变量的写入操作不依赖变量的当前值,或确保只有单线程更新变量值
  2. 该变量不与其他状态变量共同参与不变性条件中

怎么理解呢?写一段代码如下:

java 复制代码
volatile int a = 0;
int b = 1;
a = 1;
a++;
int a = b + 1;

可见性就是当一个线程修改了变量的值,其他线程能立即得知修改。当a=1修改a之后,就会被其他线程获取到a最新的值,这就是可见性。

场景1就对应着a++操作,a++拆解开其实是a + 1 = 2和 a = 2是两步操作,所以++不是原子操作,且a++依赖本身a的值。

从汇编指令中可以看到,edi中的值是从a获取的。如果在执行inc自增时,其他线程修改了a的话,则edi中的值就不最新的a的值,就保证不了可见性。

场景2对应b + 1,a的值依赖于b,多线程之间无法保证b的可见性,所以无法保证在b + 1时的b是最新值。

由此可见,volatile只能实现"赋值的原子性",如果想要执行大范围的原子性,例如a++,就要使用锁,最常见的锁就是synchronized。

synchronized

JMM中提供了lock和unlock来实现范围的原子性, 虽然JVM未把lock和unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorentermonitorexit来隐式地使用这两个操作。

编译下方的代码:

java 复制代码
static Integer a;
public static void inc() {
    synchronized (a) {
        a ++;
    }
}

查看字节码:

我们知道a++不是原子操作,而放在synchronized代码块,通过使用monitorentermonitorexit,a++就变成了原子操作,以此来保证数据的可见性。以下面代码举例:

java 复制代码
public static void main(String[] args) {
    for (int i = 0; i < 100; i++) {
        new Thread(() -> inc()).start();
    }
}

public static void inc() {
    for (int i = 0; i < 100; i++) {
        a++;
    }
    System.out.println(a);
}

这里没有使用synchronized,预期结果是10000,但实际上每次运行代码都会出现不一样的结果:

第二次运行:

使用synchronized来lock变量a。

java 复制代码
public static void inc() {
    synchronized (a) {
        for (int i = 0; i < 100; i++) {
            a++;
        }
    }
    System.out.println(a);
}

运行程序则输出预期结果。

这就是JMM中说的:一个变量在同一时刻只能被一个线程lock,变量被lock后,会清空所有工作内存中此变量的值,再次使用需要重新load

Happen-Before先行原则

通过程序在执行过程中实现的可见性和有序性,JMM定义了Happens-Before原则。

  1. 程序顺序规则:程序中操作A在B前,线程中A操作也必须在B之前执行
  2. 监视器加锁规则:在监视器锁上的解锁操作必须在加锁之前执行
  3. volatile变量规则:对volatile变量的写入必须在读取之前执行
  4. 线程启动规则:Thread.start()调用必须在该线程执行任何操作之前
  5. 线程结束规则:线程中任何操作都先行发生于对此线程的终止检测
  6. 中断规则:对线程interrupt()的调用先于被中断线程检测到中断事件的发生
  7. 终结器规则:对象构造方法执行先于它的finalize()方法
  8. 传递性:如果操作A先于B,操作B先于C,那操作A必先于C

如果一个操作执行的结果需要对另一个操作可见(可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系。

例如volatile变量规则,volatile变量是通过lock指令前缀写入到主存之后,别的线程则就会读取到这个变量的最新值。只要volatile变量写入happens-before读取,这样就能保证数据的可见性。

监视器加锁规则:例如上面synchronized lock的变量a,当第一个线程lock之后,将a修改为100;然后第一个线程unlock,第二个线程lock,再执行a++,只有第一个线程的unlock Happen-Before 第二个线程的lock,第二个线程获取到的a才是第一个线程修改后的结果100。

输出结果也证明了,第二个线程lock之后获取确是100,所以同一个监视器上解锁操作和加锁是Happen-Before关系。

结语

本篇文章就是我对Java内存模型的一个总结。总结了三千字,查了不少资料,对JMM的整体内容做了一个阐述,并分析了volatile和synchronized如何实现JMM中的规则的。

除了volatile和synchronized,java.util.concurrent下的原子类和Lock也提供了原子操作,其中还涉及了CAS等等。

相关推荐
Swift社区1 小时前
从 JDK 1.8 切换到 JDK 21 时遇到 NoProviderFoundException 该如何解决?
java·开发语言
叙白冲冲2 小时前
JVM:程序计数器
jvm
DKPT2 小时前
JVM中如何调优新生代和老生代?
java·jvm·笔记·学习·spring
phltxy2 小时前
JVM——Java虚拟机学习
java·jvm·学习
心想事成的幸运大王2 小时前
JVM如何排查OOM
jvm
seabirdssss3 小时前
使用Spring Boot DevTools快速重启功能
java·spring boot·后端
喂完待续4 小时前
【序列晋升】29 Spring Cloud Task 微服务架构下的轻量级任务调度框架
java·spring·spring cloud·云原生·架构·big data·序列晋升
benben0444 小时前
ReAct模式解读
java·ai
蒹葭玉树4 小时前
【C++上岸】C++常见面试题目--算法篇(第二十期)
c++·算法·面试