WRITING / 2026.09.25

理解 synchronized:从互斥语义到 HotSpot 锁实现

通过库存计数、实例锁与类锁实验理解 synchronized,再用固定环境的 JOL 结果解释对象头和 HotSpot 实现的版本边界。

比价服务有一份共享报价缓存,每次更新需要同时修改价格、来源和版本号。把三个字段分别改成原子变量,可以让单次字段写入安全,却不能保证读者拿到同一个版本的三项数据。此时,用同一把锁保护完整更新和读取,通常比拼接多个原子操作更直观。

这是“Java 并发工程”的第三篇。先理解 synchronized 对程序承诺什么,再讨论 HotSpot 如何实现这些承诺。锁对象、可见性与异常退出属于语言语义;对象头布局、竞争优化与膨胀属于具体虚拟机实现,需要版本和运行参数才能解释。

加锁首先要确定对象身份

实例 synchronized 方法使用接收者,也就是 this 对应对象的监视器。static synchronized 方法使用声明该方法的类所对应的 Class 对象。同步代码块则使用括号表达式求得的那个对象。锁在哪里,取决于实际对象,不取决于方法名是否相同。

final class QuoteCache {
    private final Object mutex = new Object();
    private int price;
    private long version;

    void update(int next) {
        synchronized (mutex) {
            price = next;
            version++;
        }
    }
}

代码块把价格和版本号的更新放在一个临界区。读取时也必须遵守相同保护协议,例如持有 mutex 后复制这两个字段,返回一个不可变快照。只给更新方法加锁,却让读取方法随意直接访问字段,并不能建立完整的一致性保证。

锁对象最好有清晰、稳定的所有权。公开对象上的锁可能被外部代码持有,字符串常量还可能被无关代码共享。为类内部状态设置私有 final 锁对象,可以减少外部参与协议的机会。也不要在更新过程中替换锁引用,否则不同线程可能进入不同对象的临界区。

互斥与可见性同时存在

对同一监视器,某一时刻只有一个线程持有锁。后续成功获取同一监视器的线程,可以通过解锁与加锁之间的 happens-before 关系观察到此前受保护的写入。这两个性质一起支持“更新一组字段,再交给下一位访问者”的编程模型。

同一个线程可以再次进入自己持有的监视器,这就是可重入。实验中的同步 add 方法调用另一个同步 nested 方法,两者属于同一个实例,不会因为重复获取同一监视器而自行死锁。每层退出释放相应的持有层次,外层尚未退出时,其他线程仍不能进入。

可重入并不解决不同锁的顺序问题。线程甲先持有商家锁再请求缓存锁,线程乙反向操作,仍可能构成死锁。减少嵌套加锁、规定一致顺序、避免在锁内回调未知代码,比只记住“可重入”这个标签更有工程意义。

synchronized 的三层模型:共享不变量由同一监视器保护,HotSpot 对象头与监视器负责实现,观察结果受环境影响

查看完整 SVG 机制图

两个实例的方法可以同时执行

实验创建两个 Shop 对象,各自进入自己的同步方法。两个方法都在临界区内报告已经进入,再等待同一个释放信号。主线程确认两者都已进入后才释放它们,说明它们并没有争用同一把实例锁。

这个实验还提醒我们:同步方法并不会给某种业务类型加上一把全局锁。若希望多个商家对象共享一份全局状态,就要设计共同的保护对象,或者把共享状态放到负责统一管理的组件内。只在每个商家的实例方法上增加 synchronized,可能根本没有保护到全局不变量。

类锁实验则由主线程持有 Shop.class,同时让工作线程调用静态同步方法。在锁释放前,工作线程进入 BLOCKED;释放后方法完成。测试通过带截止时间的状态观察确认竞争,没有依靠“睡一秒后大概已经阻塞”这样的假设。

异常退出也会释放监视器

同步块正常结束或因异常退出时,监视器都会按语言语义释放。实验在同步块内抛出异常,捕获后让另一个线程进入同一监视器,确认异常路径没有留下占用。手工 Lock 的写法需要由程序员用 finally 保证 unlock,这一点与 synchronized 的结构化释放不同。

但释放锁不代表业务状态已经回滚。如果临界区先修改价格,再执行一个可能失败的动作,异常之后可能留下只更新了一半的数据。可以先在局部变量中完成验证和计算,再在锁内一次提交新状态,避免把互斥误认为事务恢复。

同样,线程被中断并不会自动结束一个正在等待进入的 synchronized 块。需要可中断或带超时的获取操作时,应考虑具备对应接口的显式锁。如何停止任务与如何保护状态,是两个需要分别设计的协议。

对象布局不是固定的十六字节公式

在常见的 HotSpot 对象布局中,可以区分对象头、实例字段与对齐填充。对象头承载与对象状态及类型识别有关的信息,字段布局还受继承、字段类型和虚拟机策略影响。数组对象另外需要长度等信息,不能把普通空对象的结果直接套给数组。

对象头中的 Mark Word 会根据实现状态复用部分信息,但它不是一个稳定开放给业务程序读写的接口。类型指针是否压缩、对象对齐设置、平台位数和虚拟机版本,都会影响最终布局。所谓“对象头固定多少字节”,必须先给出这些前提。

本机实验明确使用六十四位 HotSpot、压缩对象指针、压缩类指针和八字节对齐。JOL 输出显示空 Object 的 Mark Word 为八字节、类型信息部分为四字节,加四字节对齐填充,总大小十六字节。这是本次观测结果,测试只断言布局尺寸为正,不把十六写成跨平台必然值。

JOL 能提供什么证据

JOL用于分析 JVM 中的对象布局。实验固定为 0.17,下载文件带 SHA-256 校验,通过 Java agent 启动,依赖与运行产物放在独立工作目录。文章中的数字来自运行输出,而不是从旧截图抄写。

本机同时报告 Serviceability Agent 无法附加。我们保留了该提示,没有通过提升权限消除它,也没有据此声称已验证所有内部地址与锁状态。当前证据支持展示本环境的对象布局输出;进一步诊断 VM 内部细节,需要说明工具支持与额外观测条件。

观察本身也可能影响对象。计算 identity hash code、制造竞争、调用等待方法等,都可能改变对象关联状态。若要比较对象头前后变化,应交代每个观察动作的顺序。一个打印出来的十六进制值不能单独证明完整的锁转换过程。

把旧版锁升级图放回历史环境

许多资料按无锁、偏向锁、轻量级锁、重量级锁排列一条路线。它有助于理解某些历史实现的优化动机:没有竞争时尽量降低管理成本,出现争用时再引入更多协调机制。但不能把它当作 Java 语言规定的固定状态机。

JDK 15 已默认禁用偏向锁并弃用相关选项,本系列的 JDK 21 实验不使用启用偏向锁、延迟开启等旧命令。JDK 15 发布说明可以确认这一演进节点。讲历史实现时会标注年代,不把旧参数作为当前环境的运行前提。

“轻量级”与“重量级”是理解实现成本的术语,不应机械对应参与线程数量。是否发生真实竞争、持有时间、等待操作和具体 VM 策略都可能影响路径。仅仅启动两个线程,并不能据此判断对象一定处于某一种内部状态。

实现优化与业务选择的距离

HotSpot 可以对锁进行优化,例如在证明对象不会被其他线程访问时消除部分同步,或者在合适条件下合并相邻的锁操作。优化是否发生,取决于代码形态、逃逸分析、编译状态和运行环境,不能只凭源代码外观断言。

同样,自旋与阻塞的取舍也不是固定的“循环十次”。如果持有者很快释放,短暂等待可能避免线程挂起与恢复;如果等待很长,持续消耗 CPU 就可能适得其反。本篇解释这些策略的目的,不把某个旧版内部参数推荐为通用调优方法。

对业务开发更稳定的建议是缩小临界区,避免锁内执行不受控的远程请求,先构造候选结果再提交,并确保读取与写入遵守同一协议。不要为了减少一处 synchronized,就把一个清楚的状态不变量拆成难以证明的多个原子步骤。

运行五项实验

下载完整实验包,阅读环境与工具说明,运行:

python3 run.py --work /tmp/java-concurrency-run --group MonitorLab

本机五项通过:共享实例的可重入计数最终为八万;两个不同实例可同时进入;静态方法等待 Class 监视器;异常退出后另一线程可获得锁;JOL 输出空对象布局。完整 JVM 参数和摘要见实测记录,原始布局日志由运行脚本保存在指定工作目录。

这些测试验证可观察行为,没有测量锁膨胀阈值、消除比例或竞争吞吐。源码分析、布局观察和业务行为测试各自回答不同问题,合在一起能帮助理解实现,但不能互相替代。

锁内调用外部方法会扩大协议边界

假设报价缓存持有 mutex 后调用一个可扩展的价格监听器。监听器可能重新访问缓存、等待其他线程,或者进入另一把锁。即使缓存本身的代码很短,实际临界区也已经包含所有被调用代码的行为,局部检查很难看清它的最长持有时间。

一种常见安排是在锁内复制需要通知的不可变快照,退出后再通知监听器。这样缩短了锁的保护范围,但需要承认回调处理的是某个历史快照,期间缓存可能已更新。是否允许这种延迟,是业务语义的选择;不能仅为了减少锁持有时间,就悄悄改变监听器原来依赖的同步关系。

如果确实需要跨对象原子更新,应给出一致的锁顺序,并分析异常时哪些状态已经提交。测试可以让两个方向的操作同时发起,检查有限时间内能否完成,不过通过一次测试仍不能证明所有锁顺序都安全。代码级协议、明确的状态所有者与针对性实验需要结合使用。

还有一种隐蔽问题是锁内执行输出。打印或日志组件可能自身加锁、阻塞或进行 I/O,既会拉长持有时间,也会改变观察到的线程顺序。实验把断言结果尽量放到任务结束后输出,避免把日志展示顺序当成监视器内部调度的直接证据。

面试归纳

解释 synchronized 时,先回答锁的是哪个对象、保护什么不变量、读写是否共用协议,再说明可重入、可见性与异常释放。涉及对象头和锁升级时,主动给出 JDK 与 VM 前提。这样既能解释日常代码,也能避免把历史实现细节说成语言承诺。

下一篇讨论线程等待与退出:持有锁之后怎样等待条件,什么时候应响应中断,以及 LockSupport 为什么能支持先发许可后进入等待。

参考资料

Java 并发工程

  1. 共享变量为什么会出错:JMM、happens-before 与 volatile
  2. 从 CAS 到原子类:无锁更新的能力与代价
  3. 理解 synchronized:从互斥语义到 HotSpot 锁实现 · 当前文章
  4. 线程如何等待与退出:中断、Condition 与 LockSupport
  5. 沿着 ReentrantLock 读懂 AQS:获取、排队与唤醒
  6. ThreadLocal 在线程池中的边界:上下文、串值与清理
  7. CompletableFuture 实战:并行聚合、异常与超时

按时间浏览