面试官:从零开始设计个JMM吧,说说你的思路

业余草

共 11581字,需浏览 24分钟

 · 2023-10-31

你知道的越多,不知道的就越多,业余的像一棵小草!

你来,我们一起精进!你不来,我和你的竞争对手一起精进!

编辑:业余草

来源:juejin.cn/post/7278584104852455463

推荐:https://t.zsxq.com/136Bz0EvG

自律才能自由

相信大多数同学都背过JMM的八股,一听到JMM直接开始吟唱:什么重排序,线程本地内存与主存,巴拉巴拉的。但「从零开始设计个JMM吧,说说你的思路」显然不是只问「Java是怎么做的」,更加强调:「你要设计JMM,为什么需要JMM?要解决哪些问题?怎么解决的?」 这也是本篇文章要强调的,我们只有明白为什么,才能更好地理解JMM定义的规范。

为什么需要JMM

我们需要并发编程。比如看一个视频,计算机需要做:接受并读取数据包,解压数据包,播放数据包三件事。我们当然希望有三个线程分别取执行这三个子任务,从而达到边收数据,边解压,边播放,而不是先接受完整的数据包,再解压完,再播放这样的串行执行。但我们要解决这样一个问题:「如果播放的速度快于解压的速度,那么负责播放的线程如何感知到此时能播放的数据已经播放完了?解压得到了新的数据,又该如何通知播放线程可以播放了呢?」

通信与同步的概念

用更抽象的语言来表达就是:

  • 线程之间如何「通信」,更确切的说,如何将线程A的数据交付给线程B,「线程之间以何种机制来交换信息」
  • 线程之间如何「同步」,即线程A如何让线程B停下来,等线程A的工作完成到一定程度再把线程B叫醒继续工作

这就是「通信与同步的概念」。这就是并发编程需要实现的两个最基本的功能:通信与同步。

并发编程基本工作模型

那么实现并发编程的核心问题就是:如何实现「通信与同步」

一般有两种并发编程的基本工作模型:

  • 消息传递并发模型:线程A给线程B发送数据消息,从而通信,收到数据的线程B可以根据数据来决定是否阻塞(停下来)或是继续工作。即:「显式通信,隐式同步」
  • 共享内存并发模型:线程A修改共享内存中的某个数据,B可以通过某种方式,比如轮询,感知到这个数据的变化,从而改变自己的行为。即:「隐式通信,显式同步 (这也是JAVA所采用的模型)」

重新审视面试题:线程之间如何通信

不要急着开始吟唱:比如wait/notify之类的。

首先wait源码是将线程放入monitor的WaitSet中阻塞,这个过程和别的线程交换信息了吗?其实没有,但调用wait方法会「释放锁」。

而「释放锁」这个行为,对开发者来说,是同步,但会隐式地将线程本地内存的数据回写到主存中,开发者感知不到,但是实打实做了,这是synchronized的内存语义保证的,这就是「隐式通信,显式同步」。

因此,在Java世界中,是「隐式通信,显式同步」的,在吟唱之前,先和面试官说清楚这个概念,是非常有必要的,然后再继续你的吟唱。别小看这么一个简单的问题,这就是你和其他竞争者拉开差距的好机会。

下层给并发编程带来的问题

虽然上述分析已经让我们明确了实现并发编程最关键的问题所在,也确定了两种模型来实现线程间的通信与同步功能,但我们还必须考虑到下层的硬件,操作系统,编译器等向上的影响。硬件和操作系统的实现各不相同,我们需要一个模型来「屏蔽下层对上层的影响,或者说提供一些手段让程序员一定程度上能够影响到下层硬件的工作」。而一般来说,下层对上层的影响就是如下三个问题,它们也被叫做并发三要素:

可见性问题

可见性问题一般是由CPU缓存等硬件引起,每个核心的L1,L2 cache都是私有的,每个线程分别运行在CPU的两个核心,对cache的写操作,硬件不能保证彼此的可见性,这就是并发模型需要考虑的第一个问题:可见性问题。

原子性问题

CPU如何调度线程?CPU会为每个线程分配一个时间片,执行完这个时间片,该线程还没执行完,会先去执行其他的线程。那这会导致什么问题呢?假设我们此时有两个线程A,B都执行同一行代码:i++,起初i为0,我们预期执行结果为i = 2,而这行代码实际需要三条 CPU 指令:

  • 读取i的值到寄存器
  • +1,此时结果存储在寄存器
  • 写回,可能写入的是CPU Cache,也可能是主存

假设线程A读取i后阻塞,轮到线程B执行,线程B正常执行完i++,并且顺利写回主存,此时主存的i为1,轮到线程A执行,但此时A「不会重新去读取主存中的i的值」,继续执行+1,并且顺利写回主存,此时主存的i的值仍为1,这就和我们所预想的i = 2 不太相同。这就是第二个问题:原子性问题

可以看到,我们的假设已经保证了每条线程都从主存中读取数据,并且写完立刻写回主存,即我们的假设是「已经解决了可见性问题」的,但因为CPU调度导致的「原子性问题」没有解决,才导致了程序运行结果与预期不同

具有原子性的一个或多个操作叫做原子操作,「原子操作是不能操作系统被中断的」

有序性问题

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

重排序的概念:计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

举个例子:

 int a = 1;
 int b = 2;
 int c = a + b;

上面1 2行代码,它们的执行的先后顺序,对第三行代码而言,根本不重要,即1 2行即使交换执行顺序,也不会影响程序的运行结果,因此在这里就有一个可重排序的空间,如果重排序能带来性能的提高,那也许就会发生重排序

那么在一个线程内,以及在多个线程并发执行的情况,硬件,编译器具体如何重排序都可能不一样,因此指令的执行顺序对程序员而言是个黑盒,我们不得而知。为了使程序的执行结果符合预期,我们必须对这些重排序做一些限制,从而使得多线程并发的场景下,程序的执行结果仍然与预期相符,这就是第三个问题:有序性问题。

「可见性和有序性的区别」

还是举个例子来帮助你理解:

仍然是两个线程A和B,A执行代码:i++,起初i为0;B仅仅是读取 i 的值。按照预期,A先执行i++,并刷新到主存,B读取 i 应该是1。但实际上,尽管你先启动A线程,也可能被重排序,CPU先执行线程B,读到i为0,再执行线程A做i++。这个过程没有发生可见性问题,因为在B读取的时候,主存,以及A的本地内存 i 都是0,但是运行结果与预期不相符。

并发编程基础小结

学到这里,我们已经知道实现并发编程的基本理论,知道通信与同步的概念以及并发必须解决的三大问题:可见性,原子性,有序性。那接下来就是:如何实现通信与同步的功能,以及如何解决三大问题了。虽然说是「从零开始设计JMM」,但最终肯定还是要说到JMM的实现啊,问就是我和JMM设计者想的一样。

接下来我们就正式来看一看Java是如何做的。

JMM:Java内存模型🚩

JMM :Java Memory Model,中文叫 Java内存模型,JMM描述的是一组规范

下面依次介绍JMM规范

解决可见性:内存的抽象划分

  • JMM决定了一个线程对共享变量的写入何时对另一个线程可见
  • JMM定义了线程和主内存之间的抽象关系(线程本地内存和主存)
  • JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

每个线程创建时,JVM都会为其分配「线程本地内存」,而用「主存」代表线程共享的所有资源。「线程对共享变量的所有操作都必须在自己的「线程本地内存」中进行,不能直接从「主存」中读取。「线程本地内存」是线程私有的,别的线程无法访问,注意:这不代表「线程本地内存」内的变量别的线程无法拥有。两个「线程本地内存」可以获得来自「主存」的一模一样的变量的副本」。见下图:

线程本地内存的本质

线程本地内存是JMM提出的一个「抽象的概念,并不真实存在」。它涵盖了「缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。」 因为硬件这么多,实现各不相同,依赖于硬件是不可靠的,因此需要这一个抽象的概念来表示:「所有由于某种原因导致线程操作的数据不会立即写回到内存,或是通知其它CPU核心,从而造成一致性问题的潜在因素的集合」

虽然解决CPU缓存一致性有MESI协议,但实际上,上层语言不能完全依赖于硬件提供的某些功能,假如某CPU不支持该协议呢?像解决CPU缓存一致性的协议还有:MSI、MOSI、和 Dragon Protocol等等。那Java并发编程在这个硬件上就毫无意义。因此需要JMM来定义这样的规范,从而「屏蔽硬件的不可靠性」

关于本地内存可能的疑惑

你可能会问:Java线程持有堆对象的引用,会直接操作堆吗

虽然Java采用直接指针的方式访问对象,但实际上「并不会直接操作堆对象」,毕竟堆也属于内存,也就是JMM的主存区域,Java线程的做法是:「将堆对象拷贝到线程自己的本地内存来进行操作」(如果对象很大只会拷贝一部分)。本地内存确实是线程私有的,但请不要和「JVM运行时数据结构的那些线程私有的栈等」划上等号,本地内存是抽象的。

线程本地内存小结

一个线程无论操作什么数据,包括修改堆对象的某个字段,都是在自己的「线程本地内存」上操作的,且不能直接操作主存。并且线程的本地内存是线程私有的,其它线程访问不到。本地内存是抽象的概念。

这样规范的原因是:硬件提供缓存一致不可靠,那我们只能认为线程对数据的所有操作都是脏的(线程私有的栈等除外,因为这些内容根本不会写入主存),因为从本地内存写回到主存的时机我们不知道。

解决有序性:禁止重排序

JMM重排序的策略

JMM确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的「内存可见性」保证。

读完这句话,会有疑惑吗?为什么明明禁止重排序是解决有序性的,怎么这个作者写了可见性?实际上,「JMM对重排序的策略是:在不改变程序执行结果的前提下,你爱怎么重排序就怎么重排序。即:JMM不保证程序执行的绝对有序性。」 因此,JMM只处理那些「会导致程序执行结果改变的有序性问题」,其他的不管。那保证了部分有序有什么用?那当然是为了可见性咯。很多场景下,代码之间存在依赖关系。这就像类加载器,如果没有bootstrap,那ext啥也不是,因为ext类加载器的加载依赖于bootstrap。因此,存在依赖关系的代码段,不保证有序性根本就没办法保证可见性,为了解决可见性所做的一切努力都是白费。

那么JMM如何确定「什么样的重排序会导致程序执行结果改变」呢?实际上,这个问题作为开发者而言不需要关心,JMM贴心的提供了happens-before原则,会在后文详细介绍

有序性问题的根本:可见性

有序性问题的根本还是可见性,即:如果保证了可见性,有序无序又有什么关系呢?我们来举个例子:

int a = 1;
int b = 2;

看这两行代码,如果进行了重排序,导致行2先于行1执行,「不会对程序的执行结果造成任何影响」。这就是为啥JMM的策略不是直接放弃重排序而保证绝对有序,此时:「程序无序执行根本不是问题」

int a = 1;
System.out.print(a);

再看这两行代码,我们期望输出值“1”。如果进行了重排序,导致行2先于行1执行,但是行2仍然能够正确输出a为1,那么此时的有序性就不是问题。那如何保证行2在行1执行前能够看到a的值为1呢?「那就是:保证行1先于行2执行,即保证程序执行的有序性,这是保证可见性的前提。」

  • 「只要我们保证行1对行2的可见性,即行2能看到行1的修改,那有序无序无所谓;」
  • 「而要能够让行2看到行1的修改,我们需要让行1先于行2执行,即保证有序性。」

因此:解决有序性问题,是为了解决有序性背后的可见性问题,这也是为什么JMM提供的happens-before原则,并不会强调有序性,而是强调内存可见性。

现在你应该对有序性问题有了一个比较彻底的认识了,我们来看看JMM限制重排序的具体规则:

JMM限制重排序的具体规则

我们之前说过:限制部分重排序需要解决:「如何确定什么样的重排序会导致程序执行结果改变」这样一个问题。如果将这个问题抛给开发者,会是一件令人十分头疼的事情,因此JMM提供了happens-before规则。

JMM保障:只要你按照happens-before规则写代码,就能写出「执行结果与你预期相符」的代码,不需要关心会发生什么重排序以及如何限制重排序等等问题。

happens-before规则

happens-before规则是面向程序员的,这个规则屏蔽了对多个编译器和处理器的具体重排序规则,更强调结果,后续部分内容将happens-before简写为h-b。

happens-before关系的定义如下:

  1. 如果一个操作happens-before另一个操作,那么「第一个操作的执行结果将对第二个操作可见」
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。

可以看到,happens-before规则保证的是可见性,而不保证有序性。

尽管你也可以理解成happens-before规则保证了有序性,多了这样一个假设,不会对你的代码的执行结果有任何影响,但事实上happens-before就是没有保证绝对有序性,只通过保证部分有序性再保证可见性

「happens-before的具体规则如下:」

  • 「一个线程中的每个操作」,先执行的操作 happen-before 后执行的操作
  • 监视器锁的解锁happens-before加锁
  • 对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • thread.start方法happens-before该线程的所有操作
  • 在线程A中调用B.join(),happens-before线程A后续的所有操作
  • interrupt方法happens-before Thread.isInterrupted 之前
  • happens-before具有传递性

上述规则中,只有第一条是针对一个线程内的,其余规则都适用于多线程的场景。

happens-before既可以理解为JMM的一种规范,即上述所有规则的集合,也可以理解为两个操作之间的一种关系,即h-b规则的集合的某一条,A happens-before B就代表A的所有操作对B可见。

as if serial:单线程语义的保证

「as if serial只针对单线程」,具体是:「一个线程内的指令,虽然允许重排序,但是保证执行结果唯一,且与没有任何重排序的执行结果完全一致。」

happens-before理解实战

下面举一些例子来帮助你彻底理解happens-before原则,如果答案和你预想的不一样,说明你对happens-before原则的掌握还不够透彻。

h-b与CPU调度的混淆

看这样一个场景,请问输出的a会是0还是1?

static volatile int a;
// 线程A
new Thread(() -> {
    System.out.println(a);
}).start();
// 线程B
new Thread(() -> {
    a = 1;
}).start();

答案是并不确定。你会说:诶呀,不是规定了「对volatile域的happens-before 对volatile域的读,所以线程B的所有操作对线程A可见嘛」?难道这原则有问题?别忘了CPU的调度,CPU先给读线程分配时间片,此时就读到了0。那这个happens-before规则到底规定了啥呢?一旦CPU先给写线程分配了时间片,执行完了a = 1这条写操作以后,任何的读操作都能读到 a 为1,而不会出现可见性问题。

到这里,相信你对happens-before原则了解的差不多了,那其实对JMM解决有序性的问题就了解的比较透彻了。我们再来看一些别的问题。

禁止重排序的方法:内存屏障

JMM具体是如何限制重排序的?内存屏障:memory barriers,用于禁止某些指令重排序

JMM一共有如下四种内存屏障:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

我个人不建议普通人深入JMM限制重排序具体是如何使用内存屏障的,因为happens-before的存在就是为了帮助你屏蔽掉具体的内存屏障的。

猜测执行

了解即可

编译器和处理器会采用「猜测执行(Speculation)」 。比如:

static volatile int a = 1;
static volatile boolean flag = true;     
if(flag){
  System.out.println(a * a);
}

由于某种原因,比如上述代码,没有可能将flag修改为false,因此CPU猜测会执行输出a*a,于是:尽管if 与 {}之间存在依赖关系,但仍可以提前读取并计算a*a,然后把计算结果临时保存到一个名为「重排序缓冲(Reorder Buffer,ROB)」 的硬件缓存中。像这样的代码,是特殊的,但对于程序员是透明的,并不影响我们的程序的执行结果。

解决原子性:CAS与锁

CAS :Compare and Swap

CAS:Compare and Swap,比较并交换。用于在硬件层面上提供「原子性操作」。在 Intel 处理器中,CAS通过指令cmpxchg实现。比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

CAS有如下三个变量

  • V:要更新的变量(var)
  • E:预期值(expected) 就是旧值
  • N:新值(new)

CAS操作:判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。

当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

CAS实现原理

CAS凭啥能保证原子性呢?会不会出现两个线程同时做CAS并发现Var = Eepected,然后同时修改呢?

CAS是一条CPU的原子指令,是CPU层面保证的。Linux的X86下主要是通过cmpxchgl这个指令,但在多处理器情况下必须使用lock指令加锁来完成。不同的操作系统和处理器的实现各不相同。

CAS实现原子操作三大问题

ABA问题

A->B->A,CAS检查不出变化。

ABA问题的本质是:一次CAS的旧值与另一次CAS的新值完全一样。

因此我们可以:加上「版本号或者时间戳」

JUC解决ABA问题:AtomicStampedReference,添加了时间戳

循环时间长开销大

如果自旋CAS长时间不成功,会占用大量的CPU资源。

让JVM支持处理器提供的「pause指令」

只能保证一个共享变量的原子操作

如果要保证多个变量的操作的原子性,CAS就无能为力了。我们有两种解决方案:

  • 锁机制,保证临界区的代码只有一个线程可以执行
  • 使用JDK 1.5提供的AtomicReference类保证对象的原子性,把多个变量放到一个对象里面进行CAS操作;

锁机制

JVM实现锁的方式几乎都用了循环CAS,几乎所有的Java的锁实现都依赖于CAS操作。后续分析synchronized与lock时会体现出如何用CAS来实现锁的。

Java主要提供了关键字synchronized和接口Lock用于实现锁机制。

三大问题小结

到这里JMM就分别解决了并发编程的三大问题,但还没有实现基本的功能:显式同步,隐式通信。Java提供了若干同步的方式,在本章节不希望具体到某一种同步方式,而是所有的同步方式。即对于开发者而言,我们更希望关注线程间具体如何同步,那么在正确同步的代码,JMM又提供了什么样的保证呢?

顺序一致性:线程显式同步落地

顺序一致性内存模型是一个理论参考模型,通常CPU,编程语言的内存模型的设计都会参考顺序一致性内存模型。

「JMM保证:如果程序是正确同步的,那么执行结果与该程序在顺序一致性内存模型中的执行结果相同。」 看的一脸懵?什么是顺序一致性模型?下面我们来帮助你理解这句话的含义:

顺序一致性内存模型

顺序一致性内存模型具体是如下两条:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须「原子执行且立刻对所有线程可见」

可以看到,顺序一致性模型保证了:「每个操作的原子性、可见性,以及单线程内所有操作的绝对有序性」。也就是说,只要程序正确同步,我们完全可以将多线程的执行理解成一个单一的操作执行顺序,所有操作原子,立即可见,且严格按照顺序执行。这是一个非常强力的保证。这使得开发者能够更加关注「同步的正确性」,而非总是为了「隐式通信」而担忧。

JMM规范小结

JMM是Java提出的实现并发编程的规范,提供两个基础功能:

  • 线程通信
  • 线程同步

并采用了显式同步,隐式通信的模型实现上述功能。

同时为了克服硬件,操作系统等等带来的不确定性:

  • 规定了线程本地内存与主存来解决变量的「可见性」
  • 提供happens-before规则,通过插入内存屏障从而限制重排序来解决程序执行的「有序性」
  • 通过CAS与锁机制(锁的实现也依赖于CAS)来解决程序执行的「原子性」

至此,JMM就讲解完毕了。过几天会和大家一起来看看具体Java提供了什么机制或者关键字来实现并发编程的。

预告一下:synchronized轻量级锁会自旋吗?不会。下篇文章会具体分析synchronized的原理。如果觉得写的还不错就点个赞吧。本人水平有限,如果有疑问或写错的地方请在评论区指正我,感激不尽

浏览 580
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报