死锁

二编:这算是对前面没说到的内容的简单补充

锁的可重入性

前面说过,Java的线程锁是可重入,自动释放,非公平的锁,后面两个都比较好理解,但是什么是可重入的锁?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Counter {
private int count = 0;

public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}

public synchronized void dec(int n) {
count += n;
}
}

观察synchronized修饰的add()方法,一旦线程执行到add()方法内部,说明它已经获取了当前实例的this锁。如果传入的n < 0,将在add()方法内部调用dec()方法。由于dec()方法也需要获取this锁,现在问题来了:

对同一个线程,能否在获取到锁以后继续获取同一个锁?

答案是肯定的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁

由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

死锁

根据学术定义,死锁是指两个或更多事务(或进程、线程)在执行过程中,因争夺资源而造成的一种相互等待的现象,若无外部干预,它们都将无法推进下去

更形式化的描述是:在一个进程集合中,每个进程都在等待只能由该集合中其他进程才能引发的事件,那么这个进程集合就处于死锁状态

一个线程可以获取一个锁后,再继续获取另一个锁

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
public class DeadLockDemo {
private static final Object LOCK1 = new Object();
private static final Object LOCK2 = new Object();

public static void main(String[] args) {
new Thread(() -> {
synchronized (LOCK1) {
System.out.println("线程1:已获取LOCK1,等待LOCK2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (LOCK2) {
System.out.println("线程1:已获取LOCK2");
}
}
}, "线程1").start();

new Thread(() -> {
synchronized (LOCK2) {
System.out.println("线程2:已获取LOCK2,等待LOCK1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (LOCK1) {
System.out.println("线程2:已获取LOCK1");
}
}
}, "线程2").start();
}
}
image-20260209110635079

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lock1,再获取lock2的顺序,改写后,就会正常运行

image-20260209111003561

不仅多线程,数据库的事务如果持有锁循环了,也会死锁

死锁四个必要条件

死锁的发生必须同时满足以下四个条件,缺一不可。这四个条件由 Edward G. Coffman Jr. 于 1971 年提出,是分析和解决死锁问题的理论基础 :

  1. 互斥

    资源具有排他性,同一时间只能被一个进程/线程占用。这个通常没法避免,因为通常是资源的属性决定的

  2. 持有并等待

    进程已持有至少一个资源,同时又请求其他被占用的资源,且在等待期间不释放已持有的资源。线程 A 持有锁1,请求锁2;线程 B 持有锁2,请求锁1 。

  3. 不可剥夺

    已分配给进程的资源不能被强制剥夺,只能由持有者主动释放。就好像,Java 中获得 synchronized 锁的线程,除非退出同步块,否则其他线程无法抢夺该锁 。

  4. 循环等待

    存在一个进程等待链,形成闭环,最常见的情形是两个线程互相等待对方持有的锁 。

只要破坏上述任意一个条件,死锁就不会发生

死锁预防通过设计破坏四个必要条件之一:

  • 破坏循环等待:对所有资源编号,强制按统一顺序申请(如先申请 lockA 再 lockB)。
  • 破坏持有并等待:要求线程一次性申请所有所需资源 。
  • 破坏不可剥夺:使用带超时的锁(如 tryLock(timeout)),超时则释放已有资源 。
  • 破坏互斥:对只读数据使用无锁结构(如 std::atomic)。

实践中最常用的是资源有序分配法,即统一加锁顺序

死锁避免就是使用银行家算法等动态策略,在资源分配前判断是否会导致死锁 。适用于资源类型和数量固定的系统,但在现代高并发系统中较少使用。

死锁检测与恢复,就是系统定期构建资源分配图等待图,检测是否存在环路。一旦发现死锁,选择一个或多个进程回滚终止以解除死锁。MySQL 就是以这种方式解决事务死锁的

乐观锁和悲观锁

如果将悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)对应到现实生活中来。

  • 悲观锁有点像是一位比较悲观的人,总是会假设最坏的情况,做好最坏的打算,避免出现问题。
    • 认为并发冲突必然会发生,因此在访问共享资源前必须先加锁,确保操作期间的独占性。
  • 乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题来避免问题
    • 认为并发冲突很少发生,因此读取时不加锁,允许多线程并发访问;仅在提交更新时检查数据是否被他人修改。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,因为悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

悲观锁

防患于未然

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候都会出现问题,所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。

也就是说,共享资源每次只给一个线程使用,其它想要的线程被阻塞,用完后再把资源转让给其它线程

它的行为是这样的:先加锁 → 再操作 → 最后释放锁

像 Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。它们标记的代码中不原子的执行完,它们就不会释放掉自己的锁来释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致大幅增加系统进行线程的上下文切换的开销,并且,悲观锁还可能会存在死锁问题,影响代码的正常运行,但是,它是强一致性,是始终能保证数据一致的

乐观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候都不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其它线程修改了。

它的行为是这样的:先读取(记录版本)→ 本地计算 → 提交时校验版本 → 成功则更新,失败则重试

很明显,乐观锁通过校验的形式避免了大量加锁来切换线程上下文,不存在锁竞争造成线程阻塞,也不会有死锁问题,这样性能会好很多。但是,如果冲突频繁发生(通常是写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。

在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

所以说乐观锁的实现主要有三种主流方式:

版本号

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。

当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。也就是说,更新语句必须包含 WHERE version = old_version 条件。

1
2
3
4
5
6
7
-- 读取
SELECT stock, version FROM product WHERE id = 1001; -- 假设 version=1

-- 更新
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND version = 1; -- 若此时 version 已为 2,则更新0行

举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,而数据库记录当前版本为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

这样实现的乐观锁简单可靠,但是需要修改数据库

时间戳机制

时间戳机制很类似版本号,就是用 update_time 字段替代版本号,更新时比对时间戳是否一致

因为它用起来写起来感觉都比较诡异,而且依赖系统时钟精度,且需确保时间戳自动更新。

所以,不如直接用版本号,基本没啥人用它

CAS算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。

CAS 的思想很简单,实现却很复杂,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令,用于无锁编程。

因为,在 Java 中,实现 CAS 操作的一个关键类是Unsafe

Unsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用

sun.misc包下的Unsafe类提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作

image-20260209112719305

Unsafe类中的 CAS 方法是native方法。直接调用底层的硬件指令来实现原子操作

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

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

Java 中 AtomicIntegerAtomicReference 等原子类基于 CAS 实现 。

详细的 CAS 在后面说,感觉得等到 JUC

JMM(Java 内存模型)

对于 Java 来说,你可以把 JMM(Java 内存模型) 看作是 Java 定义的并发编程相关的一组规范。

它抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的转化过程要遵守哪些并发相关的原则和规范。

JMM 必须存在,约定大于配置

所谓内存模型就是对特定的内存或者高速缓存进行读写访问的过程抽象描述和约定,不同架构下的物理机或者操作系统拥有不一样的内存模型,而 JVM 是一个实现了跨平台的虚拟系统,所以很明显,它需要有自己的内存模型来保证一致性。

首先它不是对物理内存的规范,是 JVM 在操作系统的基础上进行规范来实现统一平台,那么,JMM 主要是针对缓存一致性和指令重排序的问题诞生的约定,而且同时屏蔽不同机器下CPU架构不一致的问题,于是 Java 就定义了一种协议,这个协议就是 Java 内存模型 JMM。

同时,这个约定除了抽象了线程和主内存之间的关系之外,也是 Java 定义的并发编程相关的一组规范,例如,它抽象了 happens-before 原则来解决这个指令重排序问题。

JMM 主要定义了对于一个共享变量,当一个线程执行写操作后,该变量对其他线程的可见性

说白了,JMM 就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

官方提供的关于Java内存模型和线程规范是JSR-133规范,由JSR-133专家组开发。

规范就是规范,是你只需要实现,而不规定你如何实现,所以说不同的JVM它的实现可能不尽相同,一般讲这个都讲 Hotspot 虚拟机

CPU缓存一致性

在多核系统中,处理器一般有一层或者多层的缓存,这些的缓存通过加速数据访问和降低共享内存在总线上的通讯来提高CPU性能。而且,解决了CPU 处理速度和内存处理速度不对等的问题,但是,但是,每个处理器的缓存都是私有的,而它们又共享同一内存,当有多个处理器的操作涉及同一块内存区域的时候,他们的缓存可能会因为运算而导致不一致。在这种情况下,同步回内存的数据以谁的为准呢?这就是缓存一致性问题。

内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

缓存一致性协议

对于CPU缓存不一致的问题,CPU自己也可以制定协议,一般情况下是 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范

我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。

操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型

指令重排序

再者,编译器在编译的时候,允许重排序指令以优化运行速度。这个大家都熟悉,就是 CPU 在执行指令的时候,为了使处理器内部运算单元能被充分利用,也可以对指令进行乱序执行。

常见的指令重排序有下面 3 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统重排:主存和本地内存的内容可能不一致

但是重排序遵循的原则是保证程序单线程执行的时候,重排序之后程序的运行结果必须和重排序前程序的运行结果一致。多线程它根本没管。。。编译器:没有多线程的义务。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

对于编译器的优化重排,和处理器级别的指令重排,内存重排,处理该问题的方式不一样。

  • 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
  • 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。至于内存屏障,前面也说了,就是读的时候,强制主到缓,写的时候,强制缓到主

JMM 是如何抽象线程和主内存之间的关系?

上面题都,Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。

在 Java 中,所有实例域和数组元素存储在堆内存中,静态域存储在方法区,但其所引用的对象仍位于堆中,堆内存在线程之间共享。基本类型的局部变量本身不在线程间共享,因此不会引发可见性问题;但若局部变量引用了堆中的对象,且该对象被多线程访问,则仍受 JMM 约束。

JMM规定了所有的变量都存储在主内存中,每个线程还有自己的本地内存,线程的本地内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在本地内存中进行,而不能直接读写主内存中的变量。对于不同的线程之间,也无法直接访问对方本地内存中的变量,线程之间值的传递都需要通过主内存来完成。

  • 对于主内存,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。所有线程共享的变量(如实例字段、静态字段、数组元素等),以及它们所引用的对象,逻辑上属于主内存

    二遍:如下内容,《深入理解 Java 虚拟机》(周志明):区分 JMM 与 JVM 内存结构。

    • 要注意,JMM 中的主内存是一个抽象概念,它只是一个包含所有线程共享的数据的统称,这个称呼代表了涵盖 JVM 中所有线程共享的存储区域,包括堆中的对象、方法区中的静态变量、类元数据和运行时常量池等。

    对于局部变量,局部变量本身存储在线程私有的虚拟机栈中,不是主内存,也不受 JMM 约束。但局部变量所引用的对象通常情况下创建在主内存,也就是堆中

    如果该对象仅被一个线程访问,即逃逸分析判定为未逃逸,JVM 可能将其分配在栈上或进行标量替换,此时甚至不在堆中,这是前面JVM内存模型中提到的

  • 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程已读 / 写的共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。

    • 本地内存也是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
JMM(../../../../PersonalLearn/JavaLearn/重说JavaSE基础/多线程/详解JavaSE之并发编程 part3—Java内存模型JMM基础/jmm.png)

从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  2. 线程 2 到主存中读取对应的共享变量的值。

这也就是为什么,JMM 为共享变量提供了可见性的保障。

不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。

  1. 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
  2. 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,万一线程1就手快了改的快呢,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。你无法确信其详细过程。

所以说 JMM 的具体规定的实际映射是比较复杂的

关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中

除了这 8 种同步操作之外,还规定了下面这9条同步规则来保证这些同步操作的正确执行,这些规则确保了变量在主内存与工作内存之间传递时的原子性、可见性和有序性,防止出现“读取了但不载入”、“赋值了但不同步”等各种非法状态。

  • read 和 load 必须成对出现:如果一个变量执行了 read 操作,则必须紧接着执行 load 操作,反之也要。它禁止线程从主内存读取变量后却不将其放入工作内存,或凭空在工作内存中创建变量副本。

  • store 和 write 必须成对出现:和上述大概一致,只不过,它禁止线程将工作内存中的值传送到主内存通道后,主内存却不接收写入。

  • 不允许线程丢弃最近的 assign 操作:如果一个变量在工作内存中被 assign,即被赋值,那么该线程必须在后续某个时刻执行 storewrite,将新值同步回主内存。保证“一旦修改,终将可见”,防止线程私有修改永远不对外暴露。

    JMM 不要求立即同步,只保证“最终会同步”,除非线程终止。这也就是为什么大伙不要强制断电关机的原因)

  • 不允许无原因地同步未修改的变量:如果一个变量在工作内存中从未被 assign,那么该线程不能对其执行 storewrite 操作。这个主要是避免无效写回,减少不必要的主内存更新。

  • 工作内存中的变量必须来自主内存:在执行 useassign 之前,必须先对该变量执行 read + load,即变量必须先从主内存加载到工作内存。禁止使用未初始化或“凭空产生”的变量值。

  • lock 操作会清空工作内存中的变量副本:当一个线程对主内存中的变量执行 lock 时,必须清空该线程工作内存中此变量的副本,随后必须重新从主内存 read + load 获取最新值。确保加锁后看到的是主内存的最新状态,也是synchronized 保证可见性的底层原理之一

  • 同一线程可重复 lock,但 unlock 次数必须匹配:对可重入性的保证,一个线程可以多次对同一个变量执行 lock,但必须执行相同次数的 unlock 才能真正释放该变量的锁。

  • unlock 前必须同步变量到主内存:在对一个变量执行 unlock 之前,必须先执行 store + write,将工作内存中的最新值刷新到主内存。确保释放锁前的修改对其他线程可见

  • 只能 unlock 自己 lock 的变量:一个线程不能对未被自己 lock 的变量执行 unlock,也不能对其他线程锁定的变量执行 unlock。保证锁的所有权安全,防止非法解锁。

不如看这个

方向 操作序列 说明
主内存 → 工作内存 readloaduse 读取并使用变量
工作内存 → 主内存 assignstorewrite 修改并写回变量
加锁 lock(清空副本 → 重新 load) 获取独占权
解锁 storewriteunlock 先刷回数据,再释放

JVM内存结构和 JMM 有何区别?

JVM 中的内存结构

img

JMM

img

Java 内存区域和内存模型是完全不一样的两个东西,但是学习的时候需要互相参考着;看

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性

happens-before 原则

happens-before 原则是什么

happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文《Time,Clocks and the Ordering of Events in a Distributed System》。在这篇论文中,Leslie Lamport 提出了逻辑时钟的概念,这也成了第一个逻辑时钟算法 。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系

  • 在该模型中,若事件 A 导致事件 B 发生(如发送消息 → 接收消息),则称 A happens-before B,记作 A → B。

Java 借鉴了这一思想,但将其应用于单机多线程环境下的内存可见性问题,而非分布式事件排序 。

为什么需要 happens-before 原则?

happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。

所以,happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
JMM 设计思想

根据 JSR-133(Java 5 引入的新内存模型)

如果操作 A happens-before 操作 B,那么:

  1. A 的执行结果对 B 可见
  2. A 的执行顺序在逻辑上先于 B。

其中,它不是物理时间上,是逻辑时钟上,即使 B 在 CPU 时间上先执行,只要 A happens-before B,JMM 就必须保证 B 能看到 A 的结果。

所以,它是一种承诺,只要满足 happens-before 关系,无论底层如何重排序、缓存、优化,最终效果必须等价于 A 在 B 之前执行且结果可见 。如果对 A 和 B 重排序后,单线程或正确同步的多线程程序的行为不变,JMM 就允许这种重排序。所以说,并不是不允许一切重排序。

它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

例如

1
2
3
4
5
6
7
8
// 线程 A
config = readConfig(); // (1)
ready = true; // (2) volatile 写

// 线程 B
if (ready) { // (3) volatile 读
use(config); // (4)
}
    1. happens-before (2) (程序顺序)
    1. happens-before (3) (volatile 规则)
    1. happens-before (4) (程序顺序)
  • ⇒ (1) happens-before (4) (传递性)

当线程 B 看到 ready == true 时,必然能看到 config 的最新值,即使 config 不是 volatile!

JMM 定义了 8 条天然成立的 happens-before 关系,开发者通过这些规则构建线程安全:

规则 描述 示例
1. 程序顺序规则 同一线程内,前操作 happens-before 后操作 x=1; y=x; ⇒ x=1 happens-before y=x
2. 监视器锁规则 unlock happens-before 后续对同一锁的 lock synchronized 退出 → 下次进入
3. volatile 变量规则 volatile 写 happens-before 后续对该变量的读 volatile flag = true;if(flag)...
4. 线程启动规则 Thread.start() happens-before 新线程的任何操作 主线程 start() → 子线程 run()
5. 线程终止规则 线程所有操作 happens-before 其他线程检测到它终止 thread.join() 成功返回
6. 线程中断规则 interrupt() 调用 happens-before 被中断线程检测到中断 t.interrupt()Thread.interrupted() 返回 true
7. 对象终结规则 对象初始化完成 happens-before finalize() 开始 构造函数结束 → finalize()
8. 传递性规则 若 A→B 且 B→C,则 A→C 组合多个规则推导新关系

前四个最重要

happens-before 和 JMM 什么关系?

happens-before 与 JMM 的关系如下图所示:

img
  • JMM 向程序员提供了一些 “ happens-before 规则 ”,程序员不需要关心底层复杂的重排序细节,只需要按照这些规则编写代码,就能保证多线程下的内存可见性。
  • JVM 在执行时,会将 happens-before 规则映射到具体的实现上。为了在保证正确性的前提下不丧失性能,JMM 只会 “ 禁止影响执行结果的重排序 ”
  • 最底层是编译器和处理器真实的 “ 重排序规则 ”

JMM 就像是一个中间层:它向上通过 happens-before 为程序员提供简单的编程模型;向下通过禁止改变结果的重排序,利用底层硬件性能。这种设计既保证了多线程的安全性,又最大限度释放了硬件的性能。