《Java并发编程的艺术》| 并发关键字与 JMM 核心规则

摘要:本篇文章围绕 Java 并发编程核心,梳理了 volatile、synchronized的实现原理与特性 ;同时详解了 JMM,需配合 volatile、synchronized等工具,才能实现多线程下共享变量的原子性、可见性和有序性保障。

第2章 Java并发机制的底层实现原理

2.1 volatile的应用

volatile 是轻量级的 synchronized,它在多处理器开发中保证了共享变量的可见性。

volatile 之所以被称为 "轻量级的 synchronized",是因为它在保证可见性 这一核心场景下,用更小的性能开销实现了 synchronized 的部分能力,但它并不能替代 synchronized,因为它不保证原子性。

如果对声明了volatile的变量进行写操作,JVM 就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。


在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。

缓存一致性 协议是如何工作的?

  • 光写回内存还不够,其他处理器的缓存里可能还是旧值。

  • 于是就有了++缓存一致性++ ++协议++:所有处理器都在监听总线。

  • 当某个处理器把数据写回内存时,其他处理器会通过 "嗅探" 发现这个动作,然后检查自己缓存里对应的数据是否已经过期。

  • 如果发现过期,就会把自己缓存里的数据标记为无效,下次再用的时候就必须重新从内存里读取最新值。

2.2 synchronized的实现原理

"Java 中的每一个对象都可以作为锁"

Java 中的每一个对象,都可以作为锁的 "载体" 或 "标记"。

因为 JVM 在每个对象的对象头 里预留了空间,用来存储锁的状态(比如是否被占用、锁的类型等),同时每个对象还绑定了一个Monitor(监视器) 。当你用 synchronized 锁定一个对象时,JVM 会通过修改对象头里的锁状态,并操作对应的 Monitor 来实现线程互斥 ------ 只有获得 Monitor 所有权的线程,才能执行被保护的代码。


三种锁的具体形式

这三种形式是上面基础规则的具体落地,也是你写代码时会用到的场景:

  1. 对于普通同步方法,锁的是当前实例对象。

    1. 当你在一个普通实例方法上加上 synchronized,比如 public synchronized void method(),这把锁就是调用这个方法的对象实例(this)。

    2. 多个线程调用同一个对象的这个方法时,会竞争这把锁;但调用不同对象的这个方法时,不会互相影响,因为它们锁的是不同的对象。

  2. 对于静态同步方法,锁的是当前类的 Class 对象。

    1. 当你在静态方法上加上 synchronized,比如 public static synchronized void staticMethod(),这把锁是类的 Class 对象(比如 YourClass.class)。

    2. 因为 Class 对象在 JVM 中是全局唯一的,所以无论多少个对象实例,调用这个静态方法时都会竞争同一把锁。

  3. 对于同步方法块,锁的是 Synchronized 括号里配置的对象。

    1. 当你写 synchronized (obj) { ... } 时,这把锁就是括号里的 obj 对象。

    2. 这种方式最灵活,你可以自己指定锁的对象,从而精细地控制同步范围,避免锁的粒度太大导致性能问题。

"JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步","任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态"

  • Monitor(监视器) :你可以把它想象成一个 "看门人",每个 Java 对象都关联着一个 Monitor。当线程想要执行被 synchronized 保护的代码时,必须先获得这个 Monitor 的 "入场许可"。

  • 进入 Monitor :线程执行到 monitorenter 指令时,会尝试获取 Monitor 的所有权。如果 Monitor 未被占用,线程就成功持有它;如果已被占用,线程就会进入等待队列,直到锁被释放。

  • 退出 Monitor :线程执行到 monitorexit 指令时,会释放 Monitor 的所有权,让等待队列里的下一个线程可以尝试获取锁。

2.2.1 Java 对象头

"synchronized 用的锁是存在 Java 对象头里的。","Java 对象头里的 Mark Word 里默认存储对象的 HashCode 、分代年龄和锁标记位。"

Mark Word 是对象头里最关键的部分,它是一个 "动态" 的数据结构,会根据对象的锁状态变化而改变存储内容。

  • 无锁状态:存储 HashCode、分代年龄、锁标记位(01)。

  • 偏向锁状态:存储偏向的线程 ID、Epoch、分代年龄、锁标记位(01)。

  • 轻量级锁状态:存储指向栈中锁记录的指针、锁标记位(00)。

  • 重量级锁状态:存储指向 Monitor 对象的指针、锁标记位(10)。

synchronized 的锁信息就存在每个 Java 对象的对象头 里,而对象头中的 Mark Word 是存储锁状态的核心区域,它会根据锁的升级(无锁→偏向锁→轻量级锁→重量级锁)动态改变自己的内容。

2.2.2 锁的升级与对比

加锁核心:线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中(官方称为 Displaced Mark Word)。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。

解锁核心:轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

(1)轻量级锁加锁流程

  • 创建锁记录(Lock Record):线程准备进入同步块时,JVM 会在它的栈帧里开辟一块空间,叫锁记录(Lock Record),然后把对象头里的 Mark Word 完整复制一份进去,这份复制出来的内容就叫 Displaced Mark Word(可以理解为 "备份的对象头")。

  • CAS 尝试获取锁:线程会用 CAS 操作,把对象头里的 Mark Word 替换成一个指向自己栈帧里锁记录的指针。

    • 如果 CAS 成功:说明没有其他线程竞争,当前线程成功获得锁,Mark Word 里存着指向锁记录的指针。

    • 如果 CAS 失败:说明有其他线程也在竞争这把锁,当前线程会尝试用自旋(Spin)的方式,短暂地循环重试 CAS,看能不能在对方释放锁后抢到。


(2)轻量级锁解锁流程

  • CAS 尝试恢复对象头:线程退出同步块时,会用 CAS 操作,把锁记录里备份的 Displaced Mark Word 替换回对象头。

    • 如果 CAS 成功:说明在这段时间里没有其他线程竞争,锁正常释放。

    • 如果 CAS 失败:说明在解锁时有其他线程在竞争(比如自旋超时),这时轻量级锁就会直接膨胀为重量级锁,后续竞争的线程会进入 Monitor 的等待队列,不再自旋。

Mark WordMonitor 正是这样紧密协作,共同实现了 synchronized 的完整功能。

**Mark Word(对象头标记字段):**它是对象头里的一块数据区域,就像一个动态状态牌。

  • 存储着当前对象的锁状态(无锁、偏向锁、轻量级锁、重量级锁)。

  • 在偏向锁 / 轻量级锁阶段,它直接记录锁的持有者信息,不需要动用 Monitor。

  • 当锁升级到重量级锁时,它会存储一个指向 Monitor 对象的指针。

**Monitor(监视器):**它是一个独立的同步工具,就像一个 "看门人"。

  • 维护着一个等待队列,管理所有竞争这把锁的线程。

  • 只有当锁升级到重量级锁时,才会真正创建并关联 Monitor。

  • 负责线程的阻塞、唤醒和排队,保证同一时间只有一个线程能执行同步代码。

2.3 原子操作的实现原理

这句话是 Java 原子性实现的总纲,它明确了 Java 中实现原子操作的两种核心技术路径:循环 CAS


基于锁实现 原子操作

  • 核心原理 :通过互斥锁(如 synchronizedReentrantLock)保证同一时间只有一个线程执行目标代码,从而使整个操作具备原子性。

  • 适用场景:适合复杂的复合操作(如多变量更新、事务性逻辑),以及写操作频繁、竞争激烈的场景。

  • 特点:实现简单,数据安全性高,但会导致线程阻塞,存在上下文切换和性能开销。


基于循环 CAS 实现 原子操作

  • 核心原理 :利用处理器提供的 CMPXCHG 原子指令,不断尝试比较并交换内存中的值,直到成功为止。java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)就是基于此实现的。

  • 适用场景:适合简单变量的更新(如计数器、状态标记),以及读多写少、竞争较低的场景。

  • 特点:非阻塞,无上下文切换,性能更高;但仅能保证单个变量的原子性,高竞争下会因频繁重试导致 CPU 空转,还存在 ABA 问题。

" CAS 虽然很高效地解决了 原子操作 ,但是 CAS 仍然存在三大问题。ABA 问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。"


++(1)++ ++ABA++ ++问题++

  • 现象 :一个变量的值从 A 被修改为 B,然后又被改回 A。当线程用 CAS 比较时,会发现值还是 A,便误以为它从未被修改过。

  • 风险:在某些场景下会导致逻辑错误。例如,链表节点被删除后又被重新插入,CAS 会误判节点状态未变。

  • 解决方案 :使用版本号(如 AtomicStampedReference),每次更新时不仅比较值,还比较版本号,确保值的变化轨迹可追溯。


++(2)循环时间长开销大++

  • 现象:在高并发竞争场景下,CAS 会持续自旋重试,直到成功。这会导致 CPU 空转,占用大量计算资源。

  • 风险:当竞争非常激烈时,频繁的重试会使性能急剧下降,甚至比使用锁的效率更低。

  • 解决方案:限制自旋次数(如 JVM 轻量级锁的自适应自旋),或在重试失败时升级为重量级锁。


++(3)只能保证一个共享变量的++ ++原子操作++

  • 现象:CAS 的设计只能对单个共享变量执行原子的 "比较 - 交换" 操作。如果需要同时更新多个变量,CAS 无法保证整体操作的原子性。

  • 解决方案 :将多个变量封装成一个对象,通过 AtomicReference 对整个对象进行 CAS 操作,从而间接实现多变量的原子更新。

第3章 Java 内存模型

3.1 J ava内存模型的基础

3.1.2 Java内存模型的抽象结构

"在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章用'共享变量'这个术语代指实例域,静态域和数组元素)。"

"局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。"

"Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。"

"从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读 / 写共享变量的副本。"


(1)哪些变量是 "共享" 的?哪些是 "私有" 的?

  • 共享变量( 堆内存 ): 实例域(成员变量)、静态域(static 变量)、数组元素,都存在堆里,是线程共享的。→ 这些变量是并发问题的重灾区,必须考虑可见性和线程安全。

  • 私有变量(栈 内存 **):**局部变量、方法参数、异常处理器参数,都存在线程自己的栈里,不会被其他线程访问。→ 这些变量天然线程安全,没有可见性问题。


(2)Java 内存模型(JMM)的核心作用

JMM 是一套抽象规则,用来控制线程之间的通信,核心解决的是:一个线程修改了共享变量,什么时候能被其他线程看到。


(3)JMM 的抽象结构

  • 内存 Main Memory):所有线程共享的内存区域,存放所有共享变量。

  • 本地 内存 (Local Memory):每个线程私有的抽象内存,存放该线程读写共享变量的副本。它是一个逻辑概念,涵盖了 CPU 缓存、写缓冲区、寄存器等硬件和编译器优化。

  • 工作流程:线程要读一个共享变量时,会先从主内存把变量拷贝到自己的本地内存;线程要写一个共享变量时,会先修改本地内存里的副本,再在某个时机把它刷回主内存。这个 "拷贝 - 修改 - 刷回" 的过程,就是可见性问题的根源 ------ 如果线程修改了本地副本但没刷回主内存,其他线程就看不到最新值。


在 Java 里,只有堆里的共享变量会有线程安全和可见性问题,而 JMM 就是通过控制 "本地内存" 和 "主内存" 之间的交互,来保证这些共享变量在多线程下的可见性和有序性。

3.1.3 从源代码到指令序列的重排序

"这些重排序可能会导致多线程程序出现内存可见性问题。"

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


++(1)++ ++指令++ ++重排序的三个阶段++

从源码到最终执行,指令会经历三次重排序:

  1. 编译器优化重排序:编译器在不改变单线程语义的前提下,调整代码执行顺序以提升效率。

  2. 指令 并行 重排序:处理器将多条指令并行执行,乱序发射和提交结果。

  3. 内存 系统重排序:由于 CPU 缓存和写缓冲区的存在,内存读写操作的实际执行顺序可能与代码顺序不一致。


++(2)重排序的风险++

在单线程中,重排序不会影响执行结果;但在多线程中,它会破坏指令的有序性,导致线程间的内存可见性问题。例如:

  • 线程 A 的 "写入变量" 指令被重排到后续,线程 B 可能读到旧值。

  • 线程间依赖的操作顺序被打乱,引发逻辑错误。


++(3)++ ++JMM++ ++如何解决重排序问题++

JMM 通过两层规则来禁止不安全的重排序:

  • 针对编译器重排序:通过编译器重排序规则,禁止会破坏多线程语义的优化。

  • 针对处理器重排序:要求编译器在生成指令时插入内存屏障(Memory Barriers)指令,强制处理器按特定顺序执行读写操作,从而禁止处理器重排序。

Java 的 内存 模型( JMM )介绍一下

JMM(Java 内存模型)本质上是一套语言级别的抽象规则,用来规范多线程环境下共享变量的读写行为,解决线程间通信的可见性、有序性和原子性问题,让你的并发代码在不同编译器和处理器上都能正确运行。

第一层:定义抽象 内存 模型(基础框架)

  1. 主内存:是所有线程共享的,比如我们定义的共享变量,本质是存在主内存里的。

  2. 工作内存:每个线程自己的 "私人缓存",线程不能直接操作主内存的变量 ------ 必须先把主内存的变量 "拷贝" 到自己的工作内存,改完之后,再 "写回" 主内存。

  3. 关键问题:线程之间不会直接通信,只能通过主内存传递数据,但 "拷贝→修改→写回" 这三步没有固定规则,这就导致了可见性、原子性、有序性三大问题。而JMM就专门定义了规则,从而避免这三个问题。


第二层:定义三大特性的保证规则

JMM 明确规定了在什么条件下可以保证原子性、可见性、有序性,这是它的核心价值:

  • 原子性 :JMM 保证基本数据类型的读写操作是原子的,但不保证 i++ 这类复合操作的原子性。如果需要复合操作的原子性,需要通过 synchronizedAtomic 类来实现。

  • 可见性 :JMM 规定,当线程执行 volatile 变量的写操作、释放 synchronized 锁、或写入 final 变量并完成构造时,必须将修改刷回主内存;当线程执行 volatile 变量的读操作、获取 synchronized 锁时,必须从主内存重新加载变量。

  • 有序性:JMM 允许编译器和处理器进行重排序,但禁止会破坏多线程语义的重排序。具体通过:

    • 编译器重排序规则:禁止特定类型的编译器优化。

    • 内存 屏障 指令:要求编译器插入屏障,禁止特定类型的处理器重排序。

如何保证原子性,可见性和有序性呢?

JMM 是 "规则",volatilesynchronizedfinal 是 "工具"。规则本身不能保证正确性,只有当你正确使用工具去遵守规则时,才能获得原子性、可见性和有序性的保证。

++volatile 关键字:解决 "可见性 + 有序性"(不能解决原子性)++

解决可见性的原理:

  1. 线程修改volatile变量后,会立刻把工作内存中的新值 "强制写回" 主内存;

  2. 同时会让其他线程中,这个变量的 "缓存失效"------ 其他线程再读这个变量时,不能从自己的工作内存读,必须重新从主内存加载。

解决有序性的原理:volatile会禁止编译器和 CPU 对其修饰的变量进行 "重排序",具体通过 "内存屏障" 实现:

  • volatile变量的写操作之后,加一道 "写屏障":禁止后续指令重排到写操作之前;

  • volatile变量的读操作之前,加一道 "读屏障":禁止前面的指令重排到读操作之后;

++synchronized 关键字:解决 "原子性 + 可见性 + 有序性"(全能选手)++

  • 解决原子性的原理:synchronized会给代码块加 "锁",同一时间只有一个线程能进入代码块执行 ,线程 A 执行时,其他线程只能等待,直到 A 完成 "读→修改→写回" 三步,不会被打断。

  • 解决可见性的原理:线程释放synchronized锁时,会把工作内存中的变量新值写回主内存;线程获取锁时,会清空自己工作内存的缓存,从主内存重新加载变量 ------ 相当于强制同步了主内存和工作内存。

  • 解决有序性的原理:synchronized块内的代码,会被当作 "一个整体",禁止和块外的代码重排,同时块内的代码也会通过内存屏障保证顺序。

JMM 的核心思路是:

①定义主内存(大家共享的内存)和工作内存(每个线程自己的缓存),规定变量必须从主内存加载到工作内存才能操作,改完再写回主内存。

②通过使用 volatile、synchronized 这些关键字,控制加载、写回的时机,以及禁止不合理的指令重排,最终保证多线程操作共享变量时能正确交互。

讲讲 volatile 关键字

volatile 关键字:解决 "可见性 + 有序性"(不能解决原子性)

解决可见性的原理:

  1. 线程修改volatile变量后,会立刻把工作内存中的新值 "强制写回" 主内存;

  2. 同时会让其他线程中,这个变量的 "缓存失效"------ 其他线程再读这个变量时,不能从自己的工作内存读,必须重新从主内存加载。


解决有序性的原理:volatile会禁止编译器和 CPU 对其修饰的变量进行 "重排序",具体通过 "内存屏障" 实现:

  • volatile变量的写操作之后,加一道 "写屏障":禁止后续指令重排到写操作之前;

  • volatile变量的读操作之前,加一道 "读屏障":禁止前面的指令重排到读操作之后。


恭喜你学习完本节内容!✿

相关推荐
无心水3 小时前
微服务架构下Dubbo线程池选择与配置指南:提升系统性能与稳定性
java·开发语言·微服务·云原生·架构·java-ee·dubbo
小北方城市网3 小时前
MySQL 索引优化实战:从慢查询到高性能
数据库·spring boot·后端·mysql·rabbitmq·mybatis·java-rabbitmq
l1t4 小时前
DeepSeek对AliSQL 集成 DuckDB 的总结
数据库·sql·mysql·duckdb
期待のcode4 小时前
线程睡眠sleep方法
java·开发语言
gjxDaniel4 小时前
Bash编程语言入门与常见问题
开发语言·bash
汤姆yu4 小时前
基于springboot的植物花卉销售管理系统
java·spring boot·后端
zhooyu4 小时前
OpenGL 与 C++:深入理解与实现 Transform 组件
开发语言·c++
想起你的日子4 小时前
ASP.NET Core EFCore之DB First
数据库·.netcore
SeaTunnel4 小时前
Apache SeaTunnel MySQL CDC 支持按时间启动吗?
大数据·数据库·mysql·开源·apache·seatunnel