【Java并发】用 JMM 与 Happens-Before 解决多线程可见性与有序性问题

文章目录

    • [1. 问题与场景:多线程下为何出现「看不见、顺序乱」](#1. 问题与场景:多线程下为何出现「看不见、顺序乱」)
    • [2. JMM 基础:主内存与本地内存的抽象](#2. JMM 基础:主内存与本地内存的抽象)
    • [3. Happens-Before:约定「何时可见」](#3. Happens-Before:约定「何时可见」)
    • [4. volatile:可见性与禁止重排](#4. volatile:可见性与禁止重排)
    • [5. 管程与 synchronized:互斥与块内可见性](#5. 管程与 synchronized:互斥与块内可见性)
    • [6. final 域:不可变对象的安全发布](#6. final 域:不可变对象的安全发布)
    • [7. 实际开发与架构设计中的注意点](#7. 实际开发与架构设计中的注意点)
    • 总结与参考链接

库存扣减为例------多线程同时扣减同一库存时,会出现「扣完了别的线程还读到旧值」导致超卖,或「先判断库存再扣减」被重排成「先扣减再判断」导致逻辑错乱。根因是 CPU 缓存和指令重排:写操作未必立刻对其它线程可见,代码顺序也未必等于执行顺序。

JMM 用主内存 / 本地内存 抽象和 Happens-Before 规则约定「何时对另一线程可见」,再通过 volatile (如扣减完成标志)、synchronized (扣减块互斥)、final(库存上限等配置的安全发布)把规则落地。

核心就一句:先有「何时可见」的约定(Happens-Before),再用关键字和锁去实现,业务才能安全地写多线程共享变量。

下文按「现象与根因 → JMM 与 Happens-Before → volatile/synchronized/final 用法与边界 → 实际开发注意点」展开,文末总结与参考链接。

1. 问题与场景:多线程下为何出现「看不见、顺序乱」

多线程共享变量时,常出现两类现象:写完了别的线程看不见 (可见性)、代码顺序和执行顺序不一致(有序性),于是出现脏读、重复扣减、状态不一致等难以复现的 bug。

根因简要

  • 可见性:CPU 有缓存,写往往先到缓存再异步刷主存,其他线程读到的可能是旧缓存。
  • 有序性:编译器/CPU 会重排指令,在不改变单线程语义的前提下打乱顺序,多线程下可能把「先读后写」重排成「先写后读」,从而读到未初始化的值。

若没有统一规范,既无法判断「何时对另一线程可见」,也无法在优化与正确性之间做权衡。因此需要一套约定「何时可见」的规范,并让具体的关键字和锁去实现它------这正是下文第 2、3 节要讲的 JMM 与 Happens-Before,以及第 4、5、6 节的 volatile/synchronized/final;


2. JMM 基础:主内存与本地内存的抽象

要约定「何时对另一线程可见」,先要对「谁在哪儿、何时同步」有一层统一描述。

JMM 用「主内存 + 线程本地内存」的抽象来做到这一点:

  • 共享变量在主内存,每个线程有一份「本地内存」(涵盖缓存、写缓冲区、寄存器以及编译器重排等);
  • 读/写先与本地内存交互,再与主内存同步。

JMM 通过控制「主内存与各线程本地内存之间的交互时机」来提供内存可见性保证。

在这种抽象下,线程间通信被规范为两步:

① 线程 A 将本地内存中更新过的共享变量刷新到主内存

② 线程 B 从主内存读取 A 已更新过的共享变量。

也就是说,可见性由「何时刷新、何时读」的规则决定------这套规则就是下一节的 Happens-Before。


3. Happens-Before:约定「何时可见」

上一节说到,可见性由「何时刷新、何时读」的规则决定,在 JMM 里这套规则就是 Happens-Before :若 A Happens-Before B ,则 A 的结果对 B 可见。它约束的是可见性顺序,而不是执行顺序------允许不相关操作重排,但不允许破坏 Happens-Before 关系,这样既满足业务对可见性的需求,又给编译器和 CPU 留出优化空间。

常用规则与使用场景

规则 含义 典型场景
程序顺序 同一线程内,前面的修改对后面可见 单线程内逻辑顺序可推理
volatile 变量 对 volatile 的写对后续该变量的读可见 标志位、开关(如「x 已写好」用 v=true 表示)
传递性 A→B,B→C ⇒ A→C 组合多条规则推导跨线程可见性
管程中锁 解锁 Happens-Before 后续对该锁的加锁 synchronized 块内修改对下一进入该锁的线程可见
线程 start() 主线程 start 前操作对子线程可见 传给子线程的初始参数、配置
线程 join() 子线程结束对 join 返回后的主线程可见 子线程计算结果对主线程可见
线程中断 interrupt() 先于被中断线程检测到中断 中断请求一定早于中断处理

示例:volatile 做可见性桥梁

java 复制代码
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;   // 程序顺序:先于 v=true
    v = true; // volatile 写:对后续读 v 可见
  }
  public void reader() {
    if (v == true) {  // 传递性 ⇒ x=42 对当前线程可见
      // x 在 1.5+ 上等于 42
    }
  }
}

上面用 v 做「x 已写好」的信号:volatile 写与读建立 Happens-Before,再通过传递性把 x=42 的可见性带给 reader,无需锁整块代码。volatile 正是实现「对 volatile 变量的写对后续读可见」这条 Happens-Before 规则的关键字,下一节单独说它的语义与边界。


4. volatile:可见性与禁止重排

Happens-Before 里有一条是「对 volatile 的写对后续该变量的读可见」,volatile 关键字就是实现这条规则的手段。

语义

  • 对该变量的读写 与主内存直接交互,不经过线程本地缓存(可见性)。
  • 禁止对该变量相关操作的指令重排有序性)。
  • 实现上通过读/写前后插入内存屏障保证。

适用场景

  • 单变量状态、标志位,写一次、多线程尽快可见(如关闭开关、初始化完成标志)。
  • 与 Happens-Before 配合做「可见性桥梁」(如上节示例)。

边界与注意

  • 不保证原子性 :i++ 等复合操作仍需 synchronized 或原子类。
  • 仅限本变量:只保证该变量本身及其前后顺序,不保证其他共享变量的可见性,需通过传递性组合规则。

当需求不只是「单变量可见」,而是「同一时刻只有一个线程执行某段代码」或「先检查再更新」这类复合操作时,就要用到 synchronized,见下一节。


5. 管程与 synchronized:互斥与块内可见性

volatile 只保证单变量可见性,不保证互斥和原子性。 需要互斥或「先读后改」的原子性时,要用 synchronized:它对应 Happens-Before 里的「管程中锁」规则------解锁 Happens-Before 后续对该锁的加锁。

语义

  • 管程(Monitor) :通用同步原语;synchronized 是 Java 对管程的实现。
  • JMM 规则:解锁 Happens-Before 后续对该锁的加锁 → 块内对共享变量的写,在释放锁后对下一个获得同一把锁的线程可见。

适用场景

  • 需要互斥块内修改对下一进入者可见(如判断并更新库存、先检查再赋值)。
  • 需要原子性的复合操作(如 i++、check-then-act)。

示例

java 复制代码
synchronized (this) {
  if (this.x < 12) {
    this.x = 12;
  }
}
// 解锁后,下一个获得 this 锁的线程能看到 x==12

还有一种常见需求是「构造完成后不再变的配置或引用,希望无锁地安全发布给多线程」------这由 final 配合 JMM 对 final 域的禁止重排规则来保证,见下一节。


6. final 域:不可变对象的安全发布

JMM 对 final 域做了特殊规定:禁止把对 final 域的写重排到构造函数 return 之外,从而在无锁的前提下实现「构造完成即对其它线程可见」的安全发布。

语义

  • 基本类型 final:禁止把对 final 域的写重排到构造函数 return 之外(final 写之后、return 前插入 StoreStore 屏障)→ 对象发布时 final 已写好。
  • 引用类型 final:构造函数内对 final 引用所指对象的写,不能与「把该引用赋给引用变量」重排 → 避免发布半初始化对象。

适用场景

  • 构造完成后不再变的配置、常量、引用;希望构造完成后对所有线程立即可见且无需加锁。
  • 不可变对象(final 基本类型、String、枚举、Long/Double/BigInteger/BigDecimal 等)一旦正确发布,可被多线程安全使用;AtomicInteger/AtomicLong 为可变,需按原子类语义使用。

7. 实际开发与架构设计中的注意点

前面几节分别讲了 JMM 与 Happens-Before 的约定,以及 volatile、synchronized、final 的语义与场景。实际开发中需要把这些串起来:什么时候选谁、各有什么边界、出问题时怎么排查。下面按选型、边界与排查三块简要梳理。

选型标准

诉求 选型 说明
单变量状态/标志位,仅需可见性 volatile 轻量,不保证复合操作原子性
需要互斥或复合操作原子性、块内修改对下一进入者可见 synchronized 锁粒度尽量小,避免全局锁
构造后不变、安全发布 final + 正确构造 无锁发布,依赖 JMM 禁止重排

边界与局限

  • volatile:不适用于 i++、check-then-act 等复合操作;多变量可见性需依赖 Happens-Before 传递性设计。
  • synchronized:需明确锁对象(同一对象才互斥);注意锁顺序,避免死锁。
  • final:仅保证构造完成时的可见性,构造后若通过其他引用修改对象内部状态,仍需其他同步手段。

问题排查思路

  • 现象:多线程下偶尔读到旧值或未初始化值。
  • 定位:先确认是否为可见性/有序性(是否未用 volatile/synchronized/final,或跨线程可见性未满足 Happens-Before);再结合 Thread Dump 看是否阻塞、死锁。
  • 措施:按诉求补 volatile(单变量可见)、synchronized(互斥+可见)、或 final(安全发布);复核共享变量的读写是否都在同一锁或同一 Happens-Before 链上。

总结与参考链接

回到开头的库存扣减场景:要避免「扣完了别人还读到旧值」导致的超卖,以及「先判断再扣减」被重排导致的逻辑错乱,就需要先理解「何时对另一线程可见」(JMM 与 Happens-Before),再把约定落到代码里(volatile/synchronized/final)。全文要点如下。

总结

  • 现象与根因:多线程下「看不见、顺序乱」来自 CPU 缓存与指令重排;JMM 用主内存/本地内存抽象和 Happens-Before 约定「何时可见」。
  • 手段与边界:volatile 做单变量可见与禁止重排,不保证原子性;synchronized 做互斥与块内可见;final 做无锁安全发布。按诉求选型,注意各手段的边界与组合时的 Happens-Before 关系。
  • 排查:先确认是否为可见性/有序性(未用或误用 volatile/synchronized/final),再结合 Thread Dump 看阻塞与死锁;按诉求补手段并复核 Happens-Before 链。

参考链接

主题 链接
volatile 深入 java 并发关键字:volatile 深入浅出
synchronized 深入 关键字:synchronized 详解
JMM 总览 pdai - JMM
相关推荐
L_09079 小时前
【Linux】进程状态
linux·开发语言·c++
空空kkk9 小时前
SSM项目练习——hami音乐(三)
java·数据库
2401_838472519 小时前
C++异常处理最佳实践
开发语言·c++·算法
m0_736919109 小时前
C++中的类型标签分发
开发语言·c++·算法
天桥下的卖艺者9 小时前
使用R语言编写一个生成金字塔图形的函数
开发语言·数据库·r语言
爬山算法9 小时前
Hibernate(78)如何在GraphQL服务中使用Hibernate?
java·hibernate·graphql
2301_790300969 小时前
C++与微服务架构
开发语言·c++·算法
独断万古他化9 小时前
【Spring 核心:AOP】基础到深入:思想、实现方式、切点表达式与自定义注解全梳理
java·spring·spring aop·aop·切面编程
一切尽在,你来9 小时前
C++多线程教程-1.1.4 并发编程的风险(竞态条件、死锁、数据竞争、资源争用)
开发语言·c++