synchronized(同步锁)与 HotSpot(热点虚拟机)锁实现:从语言语义到线程转储
本章的边界是单个 JVM(Java 虚拟机)进程内的对象监视器。你学完后应能把 synchronized 的锁对象、字节码、对象头、HotSpot(热点虚拟机)竞争路径和线上证据连成一条线;也应能明确说出:本地锁保护的是本进程内的不变式,不是多实例库存或支付最终一致性。
版本口径:以 JDK(Java 开发工具包)8 的历史锁模型讲解对象头、偏向锁和锁膨胀;以 JDK(Java 开发工具包)17/21 提醒实现已经演进。Mark Word(标记字)的具体位图、对象对齐和锁实现属于 JVM(Java 虚拟机)私有实现,必须以目标版本、平台和启动参数核对,不能背成不变事实。
1. 简历关联点与面试主线
WMS(仓储管理系统)在单 JVM(Java 虚拟机)内合并同一 SKU(库存单位)的短暂扣减请求时,可以用 synchronized 保护“内存中可售数不为负”这个不变式;支付回调的本地串行化也可以减少同一订单在同一实例的重复处理。但是多实例部署后,每个进程都有不同的锁对象,最终判决仍须落在 MySQL(关系型数据库)条件更新、唯一约束、幂等流水或 Redis(远程字典服务)协调上。
面试表达按下面主线展开:先说明锁对象和互斥边界,再用 monitorenter(进入监视器)/monitorexit(退出监视器)解释异常释放和 happens-before(先行发生)规则,随后落到对象头与 ObjectMonitor(对象监视器)竞争,最后用 jstack(线程栈工具)证据和项目边界收束。
| 面试层次 | 你要给出的结论 | 容易失分的说法 |
|---|---|---|
| 语言语义 | 同一对象监视器的临界区互斥、可重入,解锁到后续加锁建立 happens-before(先行发生)规则。 | “方法加了锁,整个类就锁住了。” |
| 字节码 | 代码块有 monitorenter(进入监视器)/monitorexit(退出监视器);同步方法由 ACC_SYNCHRONIZED(同步方法标记)表示。 | “同步方法也有两条显式指令。” |
| 虚拟机实现 | 竞争可由栈上锁记录进入 ObjectMonitor(对象监视器)路径;具体细节随版本演进。 | “Mark Word(标记字)永远是某张固定的 64 位图。” |
| 工程边界 | 锁范围来自需要同时成立的一组不变式;本地锁不跨 JVM(Java 虚拟机)。 | “给订单号加本地锁即可保证支付幂等。” |
2. 语言语义:锁对象就是一致性边界
2.1 三种写法、可重入与 happens-before(先行发生)规则
实例同步方法锁住调用者 this(当前对象引用);静态同步方法锁住声明该方法的 Class(类元数据) 对象;同步代码块锁住括号中运行时实际引用的对象。同一实例的两个不同同步实例方法会互斥,不同实例不会;不同类加载器加载出的同名类也有不同的 Class(类元数据) 对象。
synchronized 同时给出四个语言层承诺:互斥、可重入、解锁可见性和异常释放。可重入是同一线程再次进入同一监视器不会把自己阻塞,退出次数必须与进入次数匹配;监视器解锁 happens-before(先行发生)随后对同一监视器的加锁,所以临界区中写入对后来获得该锁的线程可见。它不让未加同一把锁的读者自动安全,也不把多个独立对象拼成事务。
flowchart LR
A["实例方法 this(当前对象引用)"] --> B["同一订单聚合对象"]
C["静态方法 Class(类元数据)对象"] --> D["该类加载器中的全局表"]
E["代码块指定对象"] --> F["按 SKU(库存单位)分片锁"]
G["另一个 JVM(Java 虚拟机)实例"] -."不是同一对象".-> H["不能互斥"]
B --> I["保护同一组不变式"]
F --> I**图解。**正常路径是同一业务状态选择同一稳定锁对象,持锁线程完成检查和修改后解锁;失败路径是把两个相关字段分给不同锁、或多实例各自持锁,导致不变式被并发打破;面试结论是“先定义不变式,再选择能覆盖它的最小稳定对象”,不是先挑语法糖。
| 写法 | 实际锁对象 | 典型边界 | 常见误用 |
|---|---|---|---|
synchronized void reserve() | this(当前对象引用) | 单个聚合对象的字段。 | 以为所有实例共享一把锁。 |
static synchronized void refresh() | 声明类的 Class(类元数据) 对象。 | 单类加载器内的静态共享状态。 | 以为能同步不同服务实例。 |
synchronized (lock) | lock 当前指向的对象。 | 显式分片或私有守卫对象。 | 把可变、公开或可推测对象当锁。 |
**数据演绎一:可重入计数。**线程甲第一次进入 outer 获得对象 O,监视器拥有者为甲、重入计数为 1;outer 调用同对象的 inner,拥有者仍为甲、计数变 2,不会等待自己;inner 返回后计数为 1;outer 的 finally 退出后计数为 0,线程乙才可能获得 O。若少一次退出,其他线程会永久等待;正常 Java(编程语言)语法与异常表会保证编译器生成的退出配对,但手写底层接口不能假设它替你补偿。
热门面试题
- 问题(基础题):实例同步方法、静态同步方法和代码块分别锁什么?
- 考点:锁对象、类加载器、临界区边界。
- 回答思路:按运行时对象而不是按“方法名”区分,再排除跨实例与跨进程。
- 详细答案:实例方法锁当前
this(当前对象引用),静态方法锁该声明类在当前类加载器下的Class(类元数据)对象,代码块锁表达式求值得到的对象。只有竞争者请求同一个监视器才互斥;两个 WMS(仓储管理系统)实例中的同名对象和两个 JVM(Java 虚拟机)中的静态字段都不是同一把锁。 - 进阶追问:把锁对象写成可重新赋值的字段会怎样?
- 进阶回答:旧线程可能还持有旧对象,新线程已在新对象上进入,临界区被拆成两把锁。锁引用应为
private final,或使用不会暴露的稳定守卫对象。
- 问题(原理题):为什么
synchronized既有互斥又有可见性?- 考点:监视器、happens-before(先行发生)规则、安全发布。
- 回答思路:先讲同一监视器的排他进入,再讲解锁到后续加锁的内存语义。
- 详细答案:同一时刻监视器只允许一个拥有者执行受保护区,因此复合读改写不可交叉;线程甲解锁前的写入 happens-before(先行发生)线程乙随后成功锁定同一监视器,乙按该关系看到甲的结果。若读代码没有锁同一对象,或通过另一对象读取,不能借此推导可见性。
- 进阶追问:可重入会不会降低互斥?
- 进阶回答:不会。可重入只允许当前拥有者递归进入;其他线程仍因拥有者未完全退出而不能进入,直到重入次数归零。
- 问题(项目题):WMS(仓储管理系统)单 JVM(Java 虚拟机)库存临界区如何确定锁范围?
- 考点:不变式、分片、持久化兜底。
- 回答思路:先定义“可售数不为负”,把检查与内存扣减放在同锁内,再指出多实例的最终约束。
- 详细答案:我会按仓库和 SKU(库存单位)构造稳定分片守卫对象,在锁内读取本地快照、判断可售、修改暂存状态和生成待落库动作;不把远程调用放进锁内。多实例时数据库使用带版本或库存条件的更新作为最终裁决,消息和回调以业务幂等键去重,本地锁仅降低单实例竞争。
- 进阶追问:锁是否越细越好?
- 进阶回答:不是。过细会使一条业务不变式跨越多把锁,增加顺序、死锁和协调成本;粒度应恰好覆盖需要原子成立的状态集合,并依据热点和测量再分片。
2.2 锁对象陷阱与设计原则
错误锁对象最危险之处是“代码看起来同步,实际没有竞争”。new Object() 写在方法体内,每次调用都是新锁;synchronized (new String("x")) 同理。字符串字面量会被池化,synchronized ("ORDER") 可能和完全无关的代码、第三方库或同类加载器内的其他模块争抢同一公开对象;intern(手动入池方法) 会扩大这种碰撞面。包装类型、可访问集合和请求对象也不适合做公开锁。
更隐蔽的是可变引用锁:lock = new Object() 后,旧锁和新锁同时存在;以及锁住集合对象却在别处替换集合引用。优先使用私有 final 守卫对象,或在对象天然就是聚合根时锁 this(当前对象引用)。以键分片时,要让同一键稳定映射到同一守卫,并限制守卫表增长,不能无界缓存每个订单的锁对象。
flowchart TD
A["请求一拿到旧 lock"] --> B["锁对象 O1"] --> C["进入临界区"]
D["配置热更新替换 lock"] --> E["锁对象 O2"]
F["请求二拿到新 lock"] --> E --> G["也进入临界区"]
C -."两个临界区并行".-> H["不变式失效"]
I["private final 守卫对象"] --> J["引用不可替换"] --> K["同一边界同一锁"]**图解。**正常路径是锁对象在其保护的状态生命周期内保持同一身份;失败路径是热更新、设值方法或公开引用改变锁身份,两个线程各自拿到不同锁;面试结论是字符串常量锁与可变引用锁不是“小性能问题”,而是互斥语义失效。
| 陷阱 | 为什么失效或危险 | 替代方式 |
|---|---|---|
方法内 new Object() | 每次调用新建监视器,没有共同竞争点。 | 类字段 private final 守卫对象。 |
| 字符串常量 | 公开、可被池化并被外部代码意外复用。 | 私有对象,不使用 intern(手动入池方法) 结果。 |
可变 lock 引用 | 替换后请求分流到旧锁和新锁。 | final(最终关键字)引用。 |
this(当前对象引用)公开给外部 | 外部可主动占锁,扩大等待面。 | 对外暴露对象时改用私有守卫。 |
**数据演绎二:双线程竞争失败。**初始 lock=O1。时刻 1,线程甲读取 O1 并进入;时刻 2,配置线程把 lock 改为 O2;时刻 3,线程乙读取 O2 并进入;时刻 4,甲、乙分别把库存快照 10 改为 9,再落库,可能产生重复预留。这里没有“锁竞争太慢”,而是根本没有竞争。将引用固定为 final 后,乙必须等待甲退出,或者改为数据库条件更新让第二次扣减失败。
热门面试题
- 问题(基础题):为什么不建议锁字符串常量?
- 考点:字符串池、公开对象、不可预期竞争。
- 回答思路:说明它不是每次新建对象,再说明外部代码可共享该对象。
- 详细答案:字符串字面量通常由字符串池统一管理,同一内容可能拿到同一对象;该对象又对任何同类加载器内代码可见。于是无关模块可能把
"ORDER"当锁,造成偶发长等待甚至与外部锁顺序形成死锁。锁应是私有、稳定、不会被业务数据或外部代码持有的对象。 - 进阶追问:字符串不可变是否意味着可安全做锁?
- 进阶回答:不可变只避免内容被改,不保证对象私有。监视器正确性关心身份是否被同一范围的竞争者共享,公开常量恰恰共享范围过大。
- 问题(原理题):为什么锁对象应尽量是
final(最终关键字)?- 考点:对象身份、引用替换、互斥完整性。
- 回答思路:画出旧引用和新引用并行的时间窗。
- 详细答案:同步的是对象身份而非字段名。可写字段被替换时,已经进入的线程仍持旧对象,后来线程锁新对象,二者不互斥;
final(最终关键字)保证守卫引用在构造后不被重新绑定,因而使锁边界稳定。它不阻止守卫对象内部状态变化,但守卫对象通常无需业务状态。 - 进阶追问:分段锁对象表可以扩容吗?
- 进阶回答:可以,但单个业务键在一次操作期间的路由结果必须稳定;可用固定槽数组或受控条带,避免为每个短命键永久创建对象。
- 问题(项目题):支付回调为何不能只用
synchronized (orderId)?- 考点:字符串池、多实例、幂等。
- 回答思路:先否定公开字符串锁,再区分单实例串行和最终幂等。
- 详细答案:
orderId若是字符串,可能与业务外代码碰撞,且每台应用机器各有自己的对象和监视器。即使改成安全的本地按键锁,也只能减少一台机器内的重复回调。支付结果必须以回调流水唯一键、订单状态条件更新、验签和对账补偿为准;本地锁是削峰优化,不是支付正确性的唯一防线。 - 进阶追问:本地按键锁的价值还在吗?
- 进阶回答:在。它能合并同实例短时间重复通知、减少数据库冲突和下游重复调用,但应有键数量上限、超时观测,并始终保留持久化幂等。
3. 字节码:同步方法标记与异常退出
3.1 javap(字节码反汇编工具)如何证明同步语义
同步方法不会在其 Code 属性中展示成两条显式监视器指令;编译器给方法访问标志加 ACC_SYNCHRONIZED(同步方法标记),调用虚拟机在进入该方法前获取目标监视器、在方法完成时释放。实例方法目标是接收者,静态方法目标是声明类的 Class(类元数据) 对象。同步代码块则把锁对象压栈,以 monitorenter(进入监视器)获取,在正常和异常路径分别执行 monitorexit(退出监视器)。
下面示例可在本机用 javac(Java 编译器)编译、再用 javap(字节码反汇编工具)验证;代码块中的标识符属于代码豁免,不代表正文可以省略术语标注。
class LockBytecodeDemo {
synchronized void instance() { work(); }
static synchronized void statik() { workStatic(); }
void block(Object guard) {
synchronized (guard) { work(); }
}
static void work() {}
static void workStatic() {}
}$ javap -v -p LockBytecodeDemo
void instance();
flags: ACC_SYNCHRONIZED
void block(java.lang.Object);
0: aload_1
1: dup
2: astore_2
3: monitorenter
...
n: aload_2
n: monitorexit
...
Exception table:
from to target type
... ... ... any
handler: aload_2; monitorexit; athrowflowchart TD
A["进入同步代码块"] --> B["保存锁对象到局部变量"] --> C["monitorenter(进入监视器)"]
C --> D["执行临界区"]
D --> E["正常路径 monitorexit(退出监视器)"] --> F["继续执行"]
D --> G["异常表处理器"] --> H["monitorexit(退出监视器)"] --> I["重新抛出异常"]**图解。**正常路径在临界区尾部退出监视器;失败路径不是“异常自动吞掉”,而是异常表跳转到同一个锁对象的退出逻辑后重新抛出;面试结论是 synchronized 对异常释放有结构性保证,但锁内副作用仍可能只完成一半,业务仍要设计状态机和补偿。
| 形式 | javap(字节码反汇编工具)重点 | 获取时机 | 退出保证 |
|---|---|---|---|
| 同步实例方法 | ACC_SYNCHRONIZED(同步方法标记)。 | 调用方进入方法前。 | 方法正常或异常完成时由虚拟机释放。 |
| 同步静态方法 | 同样是方法标记。 | 目标为 Class(类元数据) 对象。 | 同上。 |
| 同步代码块 | monitorenter(进入监视器)/monitorexit(退出监视器)与异常表。 | 执行到代码块时。 | 正常出口和异常处理器各有退出。 |
热门面试题
- 问题(基础题):同步方法和同步代码块的字节码有什么区别?
- 考点:
ACC_SYNCHRONIZED(同步方法标记)、monitorenter(进入监视器)、编译器边界。 - 回答思路:先区分方法标志与指令,再说明两者最终都使用对象监视器。
- 详细答案:同步方法在方法访问标志中带
ACC_SYNCHRONIZED(同步方法标记),虚拟机按方法调用语义获取和释放监视器;同步代码块把锁对象显式保存在局部变量,生成monitorenter(进入监视器)和一个或多个monitorexit(退出监视器)。两种形式的语言互斥语义相同,差别主要在锁对象表达能力和字节码呈现。 - 进阶追问:为什么代码块常有两个退出点?
- 进阶回答:一个服务正常控制流,另一个位于覆盖临界区的异常处理器;处理器先释放同一监视器再抛出原异常,防止异常把锁永久遗留。
- 考点:
- 问题(原理题):异常释放锁是否意味着业务自动回滚?
- 考点:资源释放、原子性边界、补偿。
- 回答思路:把监视器释放与外部副作用拆开。
- 详细答案:不意味着。异常表只保证监视器占用被释放,不能撤销已经写出的数据库记录、发出的消息或已经调用的支付渠道。锁内逻辑应缩短为内存状态转换;涉及持久化和远程副作用时,要依赖事务、幂等键、状态机和补偿任务处理半完成状态。
- 进阶追问:
finally(最终清理)还要手动解锁吗? - 进阶回答:对
synchronized不需要也不能手动调用监视器退出;对显式Lock(锁接口)才必须在finally(最终清理)中调用unlock(解锁)。
- 问题(项目题):如何用
javap(字节码反汇编工具)排查“以为加锁却没生效”?- 考点:编译产物、代理边界、锁对象。
- 回答思路:先确认目标类字节码,再回到调用路径和实际对象身份。
- 详细答案:先对部署同版本类执行
javap -v -p,确认同步方法是否有ACC_SYNCHRONIZED(同步方法标记),代码块是否含监视器指令和异常表;再从日志、断点或线程转储确认调用的是否真是该类、锁表达式是否指向同一对象。很多问题来自代理绕过、不同实例或锁引用替换,而不是字节码丢失。 - 进阶追问:看到指令就能证明跨实例安全吗?
- 进阶回答:不能。字节码只能证明进程内监视器语义;实例数量、路由和持久化一致性仍是部署与业务协议问题。
3.2 wait(等待)、notify(通知)与许可语义
调用 wait(等待)、notify(通知)或 notifyAll(全部通知)前必须持有目标对象监视器,否则抛出 IllegalMonitorStateException(非法监视器状态异常)。原因不是语法限制,而是“检查条件—进入等待集合”必须与“改变条件—发出通知”由同一把锁原子衔接;否则通知会落在等待者真正入队之前,形成丢失通知。
wait(等待)会在把当前线程放入等待集合时完全释放该对象监视器,被通知、超时或虚假唤醒后,还要重新竞争该监视器,获得后才从 wait(等待)返回。sleep(睡眠)只让当前线程暂停,不释放已持有的锁;park(挂起)基于每线程一个许可,先 unpark(解除挂起)后 park(挂起)也能消费许可,它不要求持有对象监视器,也不替代条件变量的锁保护。等待必须用 while(循环)重查条件,不能用一次 if(条件判断)。
sequenceDiagram
participant P as "生产线程"
participant M as "对象监视器"
participant C as "消费线程"
C->>M: "获得锁,while(循环)检查队列为空"
C->>M: "wait(等待),释放锁并进入 WaitSet(等待集合)"
P->>M: "获得锁,写入元素"
P->>M: "notifyAll(全部通知)"
P->>M: "退出监视器"
C->>M: "被唤醒后重新竞争锁"
C->>C: "while(循环)再次检查条件并消费"**图解。**正常路径是修改条件、通知、退出锁,然后等待者重新获得锁并再次确认;失败路径包括虚假唤醒、多个消费者抢同一元素、或通知早于等待,都会让一次 if(条件判断)错误越过空队列;面试结论是通知不是条件本身,状态谓词才是正确性依据。
| 操作 | 是否要求持有对象监视器 | 是否释放已持有监视器 | 核心语义 |
|---|---|---|---|
wait(等待) | 是。 | 是,随后需重新获得。 | 条件等待、可超时、允许虚假唤醒。 |
notify(通知)/notifyAll(全部通知) | 是。 | 否,直到退出同步区。 | 移动等待候选,不立即交出锁。 |
sleep(睡眠) | 否。 | 否。 | 时间暂停,不协调共享条件。 |
park(挂起) | 否。 | 否。 | 一许可模型,可被中断或虚假返回。 |
**数据演绎三:空队列与虚假唤醒。**容量为 1,队列初始为空。消费者甲用 if (empty) wait() 后被虚假唤醒,若不再检查就取元素,得到空值或破坏计数;使用 while (empty) wait() 会再次进入等待。再看两个消费者:生产者放入一个元素并 notifyAll(全部通知),甲先抢到锁取走元素,乙随后获得锁时队列又为空,while(循环)令乙继续等。这里 notifyAll(全部通知)配合谓词更稳妥,notify(通知)只有在能严格证明一个等待者类型和条件时才易维护。
热门面试题
- 问题(基础题):为什么
wait(等待)和notify(通知)必须在同步块中调用?- 考点:条件谓词、原子交接、丢失通知。
- 回答思路:把“检查条件、等待”和“修改条件、通知”视为同一临界区。
- 详细答案:等待方必须在持锁时检查谓词,并原子地释放锁、加入等待集合;通知方也在同一锁内改变谓词并通知。若没有共同监视器,通知可能发生在等待者检查为空之后、真正进入等待之前,后者会无限等待。持锁规则让状态与等待集合的转换有共同的线性化边界。
- 进阶追问:为什么
notify(通知)后不能立即让对方运行? - 进阶回答:通知线程仍持有监视器;被选中的等待者只能转为可竞争状态,必须等通知线程退出且自己重新获得锁,才能读取受保护条件。
- 问题(原理题):
wait(等待)与sleep(睡眠)最关键的差别是什么?- 考点:监视器释放、条件同步、阻塞原因。
- 回答思路:先说锁是否释放,再说各自适用目的。
- 详细答案:
wait(等待)是对象监视器的条件等待,调用时释放该监视器,让能改变条件的线程进入;返回前必须重新取得锁。sleep(睡眠)只是让当前线程在指定时间不运行,若它持锁则其他线程仍被挡在门外,因此不能在持锁等待队列变非空时用它替代wait(等待)。 - 进阶追问:
park(挂起)能完全替代wait(等待)吗? - 进阶回答:不能直接替代。
park(挂起)有许可语义且不绑定对象监视器;若用它实现条件队列,仍须自己维护状态、入队竞态、取消和可见性,AQS(抽象队列同步器)正是在做这些工作。
- 问题(项目题):Runner(执行器)调度为什么不应靠
wait(等待)无限等待?- 考点:宕机恢复、持久化任务、超时与取消。
- 回答思路:说明内存等待仅适合本进程瞬态协调,任务真相要可恢复。
- 详细答案:Runner(执行器)若把任务领取和状态仅放在内存等待集合,重启会丢失唤醒关系和任务可见性。生产中可把
wait(等待)用于进程内短队列,但任务领取、租约、下一次执行时间和重试次数必须持久化;轮询或消息唤醒要有超时、幂等和补偿扫描。 - 进阶追问:为什么条件判断必须是
while(循环)? - 进阶回答:因为虚假唤醒、超时、中断及其他消费者都可能让唤醒时谓词仍不成立;循环把状态而非唤醒事件作为继续执行的唯一依据。
4. 对象头与 HotSpot(热点虚拟机)实现边界
4.1 对象布局、锁记录与版本差异
常见 HotSpot(热点虚拟机)对象由对象头、实例字段和对齐填充组成。对象头通常包含 Mark Word(标记字)及 Klass Pointer(类型指针);在 64 位环境启用压缩类指针时,Klass Pointer(类型指针)常被压缩,普通对象指针是否压缩还取决于堆大小和启动参数。32 位、64 位、压缩指针开关、对齐粒度、垃圾回收器及 JDK(Java 开发工具包)版本都会改变可观察布局。
无竞争或轻竞争时,线程栈帧可有锁记录,其中保存 Displaced Mark Word(置换标记字)或相关状态;Mark Word(标记字)可暂时指向该记录。竞争升级后对象可能关联 ObjectMonitor(对象监视器)。这是解释路径的模型,不应把某个工具输出的位图推广到全部平台,更不能依据位图在业务代码判断锁类型。
flowchart TB
O["对象 O"] --> H["对象头"]
H --> MW["Mark Word(标记字)\n状态、年龄、哈希及实现相关信息"]
H --> KP["Klass Pointer(类型指针)\n是否压缩取决于运行环境"]
O --> F["实例字段"]
O --> P["对齐填充"]
T["线程栈帧"] --> LR["Lock Record(锁记录)"]
LR --> DM["Displaced Mark Word(置换标记字)"]
MW -."竞争时可关联".-> OM["ObjectMonitor(对象监视器)"]**图解。**正常低竞争路径可由线程栈锁记录参与协调;竞争路径把协调状态转向 ObjectMonitor(对象监视器);失败路径是把“对象头有固定 12 字节”“Mark Word(标记字)低两位永远表示某状态”背成跨版本真理;面试结论是先说明语言语义不变,再有条件地描述目标 JDK(Java 开发工具包)实现。
| 观察维度 | JDK(Java 开发工具包)8 常见讲法 | JDK(Java 开发工具包)17/21 讲法 | 安全表达 |
|---|---|---|---|
| 64 位对象头 | 常见资料以 Mark Word(标记字)加压缩 Klass Pointer(类型指针)举例。 | 仍受压缩指针、对齐和实现演进影响。 | “本机参数下测得”,不背固定字节数。 |
| 偏向信息 | 历史模型可能编码线程相关偏向信息。 | 偏向锁已退出默认主路径。 | 说明历史与版本边界。 |
| 锁记录 | 栈上记录与置换标记字帮助轻竞争协调。 | 新实现可调整快速路径。 | 不依赖私有字段位置。 |
| 膨胀后状态 | 关联 ObjectMonitor(对象监视器)。 | 仍可用监视器概念解释阻塞与等待。 | 以公开语义和源码版本核对。 |
热门面试题
- 问题(基础题):Mark Word(标记字)和 Klass Pointer(类型指针)分别做什么?
- 考点:对象头、元数据指针、实现边界。
- 回答思路:先说明对象头组成,再强调字段位布局不属于 Java(编程语言)规范。
- 详细答案:Mark Word(标记字)承载与对象运行时状态相关的信息,例如哈希、年龄及锁实现所需状态;Klass Pointer(类型指针)指向对象的类元数据,使虚拟机知道字段布局和方法信息。它们是 HotSpot(热点虚拟机)常见实现概念,具体宽度和编码随平台、压缩指针和版本变化,业务代码不能依赖。
- 进阶追问:为什么不能凭对象头位图判断线上锁类型?
- 进阶回答:位定义是私有实现且会随版本、启动参数演进;线上判断竞争应看线程转储、JFR(Java 飞行记录器)和采样,而不是把调试工具的一次快照当稳定接口。
- 问题(原理题):什么是栈上锁记录与 Displaced Mark Word(置换标记字)?
- 考点:栈帧、轻竞争、对象头恢复。
- 回答思路:说明线程局部记录保存对象头原状态,再描述竞争升级。
- 详细答案:在历史轻量级锁模型中,线程进入同步块会在栈帧建立锁记录,保存 Displaced Mark Word(置换标记字)等原有状态,并尝试让对象头关联该记录;退出时恢复或按竞争状态走后续路径。它减少无竞争时直接阻塞的成本,但不是 Java(编程语言)规范承诺。
- 进阶追问:对象计算过
hashCode(哈希码方法)会不会让锁失效? - 进阶回答:不会失效。对象身份哈希与锁状态需要共存,虚拟机按其实现选择保存或迁移状态;代价和内部路线可能变化,但监视器互斥语义不变。
- 问题(项目题):排障时何时需要看对象布局?
- 考点:证据优先级、工具边界、版本核对。
- 回答思路:先把线程转储作为主证据,再把对象布局作为实现验证。
- 详细答案:接口卡死或吞吐下降时,先采集多个
jstack(线程栈工具)看线程是否持续BLOCKED(阻塞)在同一监视器、谁持有锁以及持锁栈;只有需要解释特定 JDK(Java 开发工具包)快速路径或怀疑异常对象膨胀时,才结合目标运行参数和对象布局工具验证。对象头不是首要止血依据。 - 进阶追问:32 位与 64 位能用同一张内存图吗?
- 进阶回答:不能直接用。指针宽度、压缩开关和对齐都可能不同,应在图旁标注平台与 JDK(Java 开发工具包)条件,并把结论限定为示意。
4.2 ObjectMonitor(对象监视器)竞争、膨胀与源码路径
当竞争无法由快速路径处理时,HotSpot(热点虚拟机)使用 ObjectMonitor(对象监视器)协调。理解字段时把 Owner(持有者) 看成当前拥有线程,recursions(重入次数) 记录重入层数,cxq(竞争栈) 是新到竞争者的竞争队列,EntryList(进入队列) 是待重新竞争拥有权的队列,WaitSet(等待集合) 保存执行 wait(等待)的线程。字段形态、转移细节和调度策略是实现细节,不能承诺公平顺序。
以 OpenJDK(开放 Java 开发工具包)当前主线的常见文件组织为线索:解释器/运行时进入监视器后会走监视器进入逻辑;快速路径不能获得时,ObjectMonitor::enter 进入 C++(C++ 编程语言)实现,竞争线程可经 EnterI、ReenterI 等内部路径排队或阻塞;膨胀由对象同步实现中的 ObjectSynchronizer::inflate 相关路径建立或关联 ObjectMonitor(对象监视器);退出由 ObjectMonitor::exit 处理;wait(等待)/通知逻辑在 ObjectMonitor::wait、ObjectMonitor::notify、ObjectMonitor::notifyAll 周边。源码通常分布在 src/hotspot/share/runtime/objectMonitor.*、synchronizer.* 与解释器/运行时调用点。不同 OpenJDK(开放 Java 开发工具包)分支会重命名或重构函数,阅读时先锁定标签和源码树。
flowchart LR
A["线程甲快速进入失败"] --> B["ObjectSynchronizer::inflate 相关路径"]
B --> C["ObjectMonitor(对象监视器)"]
C --> D["Owner(持有者)=线程乙"]
A --> E["cxq(竞争栈)"]
E --> F["EntryList(进入队列)"]
G["wait(等待)线程"] --> H["WaitSet(等待集合)"]
D --> I["ObjectMonitor::exit"]
I --> F
H --> J["notify(通知)后回到可竞争路径"]
F --> K["一个线程成为新 Owner(持有者)"]**图解。**正常路径是拥有者短时间完成临界区并在退出后让竞争者重新争用;失败路径是持锁线程做网络或数据库调用,使 cxq(竞争栈)、EntryList(进入队列) 增长并耗尽工作线程;面试结论是 ObjectMonitor(对象监视器)队列不是公平业务队列,不能用它承载长事务或跨服务排队。
中文伪代码(描述模型,不对应稳定源码接口)
进入监视器(对象 O):
若快速路径拿到 O 的监视器: 设置 Owner(持有者)为当前线程,返回
监视器 = 按目标 JDK(Java 开发工具包)实现执行膨胀或查找
若 Owner(持有者)是当前线程: recursions(重入次数)加一,返回
将当前线程放入 cxq(竞争栈)或 EntryList(进入队列)
阻塞,直到被允许重新竞争
成功后设置 Owner(持有者)为当前线程
退出监视器(监视器):
若 recursions(重入次数)大于零: 减一并返回
清空 Owner(持有者)
把可运行候选从竞争队列转入重新竞争流程**数据演绎四:两线程从竞争到阻塞。**线程甲先获得 O 后在锁内调用慢接口 800ms(毫秒);时刻 10ms(毫秒)线程乙竞争 O,快速获取失败,进入竞争路径;若自旋未等到甲释放,乙被挂起。时刻 800ms(毫秒)甲退出,乙只是获得“可以重新竞争”的机会,仍要被调度并抢到 Owner(持有者)才能运行。若 32 个线程池线程都执行同一锁内慢调用,32 个线程都可能堆在监视器上,吞吐不是 32 倍,而接近临界区串行速度。
| 队列或字段 | 角色 | 线程何时到达 | 不应误解为 |
|---|---|---|---|
Owner(持有者) | 当前拥有监视器的线程。 | 成功进入后。 | 永远是公平调度的“队首”。 |
cxq(竞争栈) | 新竞争者的内部竞争队列。 | 快速路径失败后。 | 应用可遍历的先进先出队列。 |
EntryList(进入队列) | 等待重新竞争监视器的候选。 | 退出或唤醒调度过程中。 | 已获得锁的线程集合。 |
WaitSet(等待集合) | 执行 wait(等待)的线程。 | 释放监视器并等待条件时。 | 被通知后立即执行的线程集合。 |
recursions(重入次数) | 同一拥有者递归进入的层数。 | 重入时增加。 | 不同线程可以共享锁的次数。 |
热门面试题
- 问题(基础题):ObjectMonitor(对象监视器)中的
EntryList(进入队列)与WaitSet(等待集合)有何区别?- 考点:竞争锁、条件等待、监视器释放。
- 回答思路:以“想进锁”和“已释放锁等待条件”区分。
- 详细答案:竞争
synchronized的线程在监视器竞争相关路径中等待拥有权,常以cxq(竞争栈)、EntryList(进入队列)等概念解释;调用wait(等待)的线程已释放监视器,是因业务条件不成立而进入WaitSet(等待集合)。通知只让等待者回到可竞争路径,它还要重新获得监视器并检查条件。 - 进阶追问:
notifyAll(全部通知)会让所有等待者并行运行吗? - 进阶回答:不会。它会使多个等待者有机会重新竞争同一监视器,最终仍一次只有一个线程进入同步区;大量无效唤醒可能带来惊群成本。
- 问题(原理题):请按 OpenJDK(开放 Java 开发工具包)源码路径说明竞争到阻塞的主线。
- 考点:膨胀、C++(C++ 编程语言)实现、调用链边界。
- 回答思路:从进入监视器、快速失败、膨胀、
ObjectMonitor::enter到退出唤醒依次说。 - 详细答案:在目标 OpenJDK(开放 Java 开发工具包)源码标签中,监视器进入先尝试快速路径;失败后同步器相关逻辑通过
ObjectSynchronizer::inflate建立或获取 ObjectMonitor(对象监视器),再由ObjectMonitor::enter及其内部竞争路径处理排队和挂起,退出由ObjectMonitor::exit释放拥有权并推动竞争者重试。核心实现主要是 HotSpot(热点虚拟机)的 C++(C++ 编程语言),不是笼统的“C 代码”。 - 进阶追问:函数名在不同版本不一致怎么办?
- 进阶回答:先固定 JDK(Java 开发工具包)版本和 OpenJDK(开放 Java 开发工具包)标签,按
objectMonitor、synchronizer、inflate、enter、exit搜索实际调用点;面试中说明这是版本相关实现,避免背错私有路径。
- 问题(项目题):锁内远程调用为何会造成线程池耗尽?
- 考点:持锁时长、监视器排队、级联阻塞。
- 回答思路:用一个热点锁和有限工作线程演绎排队放大。
- 详细答案:远程调用的延迟和故障不可控,持锁线程等待网络时仍占有 Owner(持有者),同键请求堆到监视器竞争队列;若请求由有限线程池处理,线程会陆续
BLOCKED(阻塞),新请求无法执行其他工作,最终队列、超时和重试共同放大。应在锁内只完成本地状态转移,锁外发起远程调用,并用幂等状态机处理回调。 - 进阶追问:只增加线程池大小能解决吗?
- 进阶回答:不能解决串行锁边界,更多线程只会制造更多等待者和内存占用;先消除锁内慢调用、设置超时与隔离,再依据实际吞吐配置线程池。
5. JDK(Java 开发工具包)版本模型与编译器优化
5.1 从偏向锁到新轻量级实现:哪些结论仍成立
JDK(Java 开发工具包)8 的教学模型常写成:无锁 → 偏向锁 → 轻量级锁 → 自旋 →重量级锁。偏向锁针对“几乎总是同一线程进入”的对象,轻量级锁利用栈上锁记录和 CAS(比较并交换)避免立即阻塞,自适应自旋根据历史竞争调整等待策略,竞争持续或需要等待集合时会使用重量级监视器。这个模型能解释历史资料,但不是所有版本的固定状态机。
偏向锁在 JDK(Java 开发工具包)15 被默认禁用,并在后续版本移除;较新 HotSpot(热点虚拟机)继续优化轻量级同步快速路径,不能把 JDK(Java 开发工具包)8 的偏向撤销、批量重偏向细节套到 JDK(Java 开发工具包)17/21。所谓“锁不能降级”在面试中通常指同一次对象监视器竞争历史的经典膨胀叙事:已膨胀监视器不为了当前一次退出回到旧的轻量级状态,以避免反复切换;它不是 Java(编程语言)规范,也不等于对象生命周期内永远不可能以其他快速路径被处理。
flowchart LR
A["JDK(Java 开发工具包)8 历史无锁"] --> B["偏向锁历史路径"] --> C["轻量级锁与自旋"] --> D["重量级 ObjectMonitor(对象监视器)"]
E["JDK(Java 开发工具包)15"] --> F["偏向锁默认禁用"]
G["JDK(Java 开发工具包)17/21"] --> H["实现继续演进,按目标版本核对"]
D -."经典面试语境中不回退".-> I["避免反复状态切换"]**图解。**正常路径是依据目标 JDK(Java 开发工具包)选择正确的解释模型;失败路径是把历史锁升级箭头当作语言规范,或绝对声称所有锁“永不降级”;面试结论是先报版本,再说明经典模型的适用语境。
| 机制 | JDK(Java 开发工具包)8 叙事 | JDK(Java 开发工具包)15+ 边界 | 性能含义 |
|---|---|---|---|
| 偏向锁 | 为同一线程重复进入减少原子操作。 | JDK(Java 开发工具包)15 默认禁用,后续移除。 | 对竞争少、线程固定的旧场景有利。 |
| 轻量级锁 | 栈上锁记录加 CAS(比较并交换)竞争。 | 快速路径实现可调整。 | 短临界区可避免内核阻塞。 |
| 自适应自旋 | 先忙等一小段时间。 | 策略是虚拟机私有实现。 | 核数足、持锁极短才可能划算。 |
| 重量级锁 | ObjectMonitor(对象监视器)排队、阻塞、唤醒。 | 监视器概念仍成立。 | 长竞争需要让出 CPU(中央处理器)。 |
热门面试题
- 问题(基础题):JDK(Java 开发工具包)8 的锁升级模型怎么讲?
- 考点:偏向锁、轻量级锁、自旋、重量级锁。
- 回答思路:说明它是历史实现模型,再按竞争程度解释成本变化。
- 详细答案:JDK(Java 开发工具包)8 常以偏向锁服务单线程重复进入,以轻量级锁和 CAS(比较并交换)处理短暂竞争,以自旋等待极短持锁窗口,竞争持续时膨胀到 ObjectMonitor(对象监视器)阻塞唤醒。核心目的不是制造更多锁名,而是让低竞争不付出线程切换成本,让高竞争不持续空转。
- 进阶追问:偏向锁还应作为 JDK(Java 开发工具包)21 默认能力吗?
- 进阶回答:不应。JDK(Java 开发工具包)15 已默认禁用偏向锁,后续版本又移除;讲新版本时应以轻量级快速路径和监视器竞争为主,并明确以目标版本验证。
- 问题(原理题):“锁不能降级”为什么不能说得太绝对?
- 考点:实现语境、规范边界、对象生命周期。
- 回答思路:限定为经典膨胀叙事,否定跨版本、跨生命周期的绝对化。
- 详细答案:这句话通常用于 JDK(Java 开发工具包)8 风格的对象监视器状态讨论:已膨胀的监视器不会因为一次竞争结束而频繁恢复旧状态,以减少状态来回转换。它不是 Java(编程语言)内存模型的规则,也不是所有 HotSpot(热点虚拟机)版本的可观测承诺。面试中应先讲版本,再说“不把当前膨胀监视器作为即时降级优化”。
- 进阶追问:如何验证生产版本的实现?
- 进阶回答:记录
java -version、启动参数和发行版,查对应 OpenJDK(开放 Java 开发工具包)源码标签与发行说明,并用 JFR(Java 飞行记录器)或线程转储观察竞争结果,而非只看旧博客图。
- 问题(项目题):高竞争库存热点应该依赖自旋吗?
- 考点:热点拆分、CPU(中央处理器)成本、最终一致性。
- 回答思路:先否定把自旋当业务开关,再给出分片和持久化判决。
- 详细答案:不应把自旋当成库存热点的解决方案。自旋只可能掩盖极短临界区的调度成本,库存热点通常伴随数据库、缓存和远程依赖,持锁时间一长会浪费 CPU(中央处理器)。应按仓库、SKU(库存单位)分片,缩短锁内状态变换,采用数据库条件更新或库存服务原子操作保证跨实例正确性,并用限流削峰。
- 进阶追问:什么时候自旋反而伤害吞吐?
- 进阶回答:单核或 CPU(中央处理器)饱和、持锁线程被抢占、临界区包含 I/O(输入输出)或长计算时,自旋线程不能推进释放者,只会抢占执行资源,应尽早阻塞或改变架构。
5.2 锁消除、锁粗化与逃逸分析
JIT(即时编译)在能证明对象不会逃逸出当前线程或调用范围时,可能借助逃逸分析消除无实际并发的锁;例如方法内新建且不返回、不传给其他线程的对象,其同步可能被优化。相反,循环中连续锁住同一对象的很小片段,JIT(即时编译)可能做锁粗化,减少反复进入退出的开销。两者都是优化机会,不改变程序必须满足的同步语义,也不能作为业务正确性的前提。
锁不是越细越好:锁粗化降低监视器操作次数,却可能扩大互斥时间;锁细化提升并行度,却可能拆散不变式、增加锁顺序和对象管理成本。优化前必须用压测、火焰图、JFR(Java 飞行记录器)或线程转储确认竞争热点,先缩短临界区和移出慢操作,再考虑结构调整。
flowchart TD
A["方法内新对象且不逃逸"] --> B["逃逸分析"] --> C["可能锁消除"]
D["循环反复锁同一对象"] --> E["JIT(即时编译)分析"] --> F["可能锁粗化"]
G["锁内远程调用或 I/O(输入输出)"] --> H["不能靠优化掩盖"] --> I["缩短临界区或重构状态机"]
J["多字段不变式"] --> K["选择一致性边界"] --> L["再评估锁粒度"]**图解。**正常路径是编译器在证明安全时优化机械锁操作;失败路径是开发者依赖“肯定锁消除”而删除必要同步,或为了细粒度把不可分割状态拆开;面试结论是优化只改善已正确程序,性能决策必须测量。
| 优化或策略 | 触发前提 | 可能收益 | 风险与边界 |
|---|---|---|---|
| 锁消除 | 对象与锁状态可证明不逃逸。 | 去除无竞争的进入退出。 | 不是源代码保证,跨调用或发布后不可假设。 |
| 锁粗化 | 连续小临界区锁同一对象。 | 减少反复监视器操作。 | 互斥时间变长,可能降低并行。 |
| 自适应自旋 | 预计释放者很快运行。 | 减少短等待的挂起唤醒。 | 长等待会烧 CPU(中央处理器)。 |
| 业务分片 | 不变式可按键独立。 | 降低热点互斥。 | 跨键操作需重新定义顺序与事务。 |
热门面试题
- 问题(基础题):什么是锁消除和锁粗化?
- 考点:JIT(即时编译)、逃逸分析、优化前提。
- 回答思路:一个是去掉可证明无竞争的锁,一个是合并连续同锁区间。
- 详细答案:锁消除是 JIT(即时编译)通过逃逸分析确认对象不可能被其他线程访问后,去除没有并发意义的同步;锁粗化是在连续操作同一锁对象时合并多次短进入退出,降低监视器操作成本。它们由运行时编译器决定,源代码仍应按并发正确性写,不能依赖优化一定发生。
- 进阶追问:为什么锁粗化可能更慢?
- 进阶回答:它减少进入退出次数,却把原本可交错的非关键工作也包进锁内,增加其他线程等待;是否收益取决于临界区长度、竞争和调度,应测量而非猜测。
- 问题(原理题):逃逸分析为什么能影响锁?
- 考点:对象可达性、线程封闭、编译器证明。
- 回答思路:说明对象未发布给其他线程,就没有真实竞争者。
- 详细答案:若编译器能证明一个对象只在当前线程的栈上或调用链内使用,没有被返回、写入共享字段、传给未知调用或异步任务,那么其他线程不能请求它的监视器,锁的互斥作用没有观察者,编译器可优化。任何无法证明的别名或发布都会使这项优化失去前提。
- 进阶追问:局部变量就一定不逃逸吗?
- 进阶回答:不一定。局部变量引用的对象可以被返回、存入字段、加入共享容器或传给回调;逃逸分析看对象可达性,不只看变量声明位置。
- 问题(项目题):如何优化 IoT(物联网)报警风暴中的本地锁?
- 考点:分片、聚合窗口、观测、跨实例边界。
- 回答思路:先把同设备短窗口聚合为独立不变式,再避免锁内 I/O(输入输出)。
- 详细答案:我会按租户和设备分片聚合内存窗口,在锁内只更新计数、最近时间和待发送标志,锁外批量发送告警;对热点设备设置有界窗口和降采样。跨实例的去重、窗口归属和最终告警记录放入 Redis(远程字典服务)或消息分区与数据库,不把本地锁当全局协调。
- 进阶追问:如何判断该细化是否有效?
- 进阶回答:比较改造前后的锁等待线程数、临界区耗时分位、CPU(中央处理器)使用率、告警延迟和重复率;若锁等待下降但跨分片合并错误增加,说明边界拆得过细。
6. 死锁、线程转储与项目止血
6.1 死锁必要条件、预防与 jstack(线程栈工具)证据
死锁通常同时具备互斥、占有且等待、不可抢占和循环等待四个必要条件。synchronized 的监视器天然不可由外部线程强制释放,因此工程预防主要破坏循环等待:为资源定义全局排序并按同序加锁;尽量把多对象操作改为一次数据库条件更新或单线程分片;需要可超时获取时使用 ReentrantLock(可重入锁) 的 tryLock(尝试加锁),失败后释放已持有资源、退避或转异步补偿。
线上先保留证据而不是立刻猜代码:连续采集多个 jstack(线程栈工具),搜索 Found one Java-level deadlock,读取每个线程“持有哪个监视器、等待哪个监视器、调用栈停在哪里”。线程长期 BLOCKED(阻塞)在同一锁不必然是环,也可能是长临界区;死锁诊断要能画出等待有向环。止血按影响面选择摘流、关闭非核心入口、重启单个实例或故障转移;重启只恢复可用性,根因仍须代码修复、压测和复盘。
flowchart LR
A["线程甲持有订单锁"] --> B["等待库存锁"]
C["线程乙持有库存锁"] --> D["等待订单锁"]
B --> C
D --> A
E["jstack(线程栈工具)"] --> F["持有监视器与等待监视器"] --> G["构造等待环"]
G --> H["统一资源排序或 tryLock(尝试加锁)"]**图解。**正常路径是所有调用按统一顺序拿锁,或第二把锁拿不到就释放并重试;失败路径是甲先订单后库存、乙先库存后订单,二者都无法推进;面试结论是 jstack(线程栈工具)给出的“等待/持有”文本才是修复前的证据,单看接口超时不能证明死锁。
| 排障阶段 | 做什么 | 产出 | 禁忌 |
|---|---|---|---|
| 识别 | 看接口超时、线程池活跃数、BLOCKED(阻塞)比例。 | 是否像锁竞争或死锁。 | 仅凭 CPU(中央处理器)低就排除问题。 |
| 固证 | 间隔数秒采集多份 jstack(线程栈工具)。 | 稳定调用栈、监视器地址和持有者。 | 只采一份就直接改代码。 |
| 止血 | 限流、摘流、暂停批任务,必要时滚动重启。 | 恢复核心链路。 | 在无备份情况下全量重启。 |
| 根治 | 统一排序、缩短锁、移出远程调用、补压测。 | 修复验证与复盘项。 | 把超时调大掩盖环路。 |
热门面试题
- 问题(基础题):死锁的四个必要条件是什么?
- 考点:互斥、占有等待、不可抢占、循环等待。
- 回答思路:先列条件,再说工程上优先破坏循环等待。
- 详细答案:死锁需要资源互斥、线程已占有部分资源仍等待其他资源、资源不能被外部强制夺走,以及等待关系形成环。同步监视器往往天然具有前三项,所以最实用的是为锁对象建立全局顺序;所有路径按相同顺序获取,便能消除循环等待这一必要条件。
- 进阶追问:统一顺序会完全消灭等待吗?
- 进阶回答:不会,它消灭的是循环死锁,不消灭热点锁竞争、长临界区或下游慢导致的等待;仍需分片、超时和观测。
- 问题(原理题):如何从
jstack(线程栈工具)确认死锁?- 考点:线程状态、监视器地址、等待图。
- 回答思路:找死锁摘要,再逐线程交叉验证等待与持有关系。
- 详细答案:先查找工具输出的 Java(编程语言)级死锁摘要;随后阅读线程栈中
waiting to lock与locked信息,验证线程甲持有监视器 A 等 B、线程乙持有 B 等 A,并检查调用栈确实来自业务锁路径。连续多份转储能排除瞬时竞争,形成有向环才下死锁结论。 - 进阶追问:看见大量
BLOCKED(阻塞)是否就是死锁? - 进阶回答:不是。它可能表示一个线程在长临界区、慢 I/O(输入输出)或计算中持锁,其他线程在排队;需要找回路和持锁者栈才能区分。
- 问题(项目题):支付回调与订单履约锁顺序冲突如何止血和复盘?
- 考点:核心链路保护、幂等、灰度修复。
- 回答思路:先固证和降载,再以持久化状态保证重试可恢复,最后统一顺序。
- 详细答案:先导出线程转储并保留请求标识,限制重复回调和暂停非核心履约任务;若死锁影响核心支付,按实例滚动重启释放监视器,同时让回调依据唯一流水和订单状态重试,避免重启后重复入账。修复时统一订单、库存、履约资源的排序,移出锁内远程调用,并构造反向并发测试与告警规则。
- 进阶追问:为什么不直接在
synchronized上设置超时? - 进阶回答:内置监视器没有可超时的获取接口;需要超时获取应重新设计为
ReentrantLock(可重入锁)的tryLock(尝试加锁),同时评估改造后的释放和异常路径。
6.2 项目话术:本地串行化与分布式一致性边界
WMS(仓储管理系统)库存话术。“我会先区分本地竞争和库存最终正确性。在单 JVM(Java 虚拟机)实例内,同仓同 SKU(库存单位)的短临界区可以按稳定分片对象使用 synchronized:锁内只校验内存聚合状态、登记预留意图和更新短缓存,确保这组不变式不被并发打断。锁外再执行数据库和消息操作。多实例下,本地锁不会共享,所以 MySQL(关系型数据库)的 stock >= quantity 条件更新、版本号或唯一预留流水才是最终防超卖判决;失败请求进入可重试或补偿状态。”
支付回调话术。“支付渠道可能并发、重复、乱序回调。我可以用本地按订单分片锁降低同一实例的重复工作,但不把它当幂等保证:其他实例仍可同时处理,重启也会丢失内存状态。真正的边界是回调流水唯一键、订单状态机、验签、金额校验和对账补偿。锁内不调用支付查询或消息发送,先原子落状态,再由可靠异步流程推进副作用。”
锁内远程调用话术。“曾经最危险的设计是拿到订单锁后调用物流或支付接口。下游变慢时拥有者长期不退出,线程池工作线程都堵在同一监视器,超时重试进一步放大。我会把锁内步骤收缩为状态读取、条件判断和状态预占;远程调用放到锁外,并以幂等请求号、超时、隔离线程池、状态机和补偿任务处理回调。这样本地锁只保护内存一致性,不承担外部系统的可用性风险。”
flowchart TD
A["支付回调到达多个实例"] --> B["本地按键锁仅串行一个 JVM(Java 虚拟机)"]
B --> C["锁内:验状态、登记处理意图"]
C --> D["锁外:调用外部服务或投递任务"]
E["MySQL(关系型数据库)条件更新与唯一流水"] --> F["跨实例最终判决"]
D --> G["异步补偿与对账"]
F --> G**图解。**正常路径让锁内仅完成可快速回滚或可重复的本地状态转移,持久化条件更新决定全局结果;失败路径是把网络调用包在锁内,或以本地锁替代唯一约束,导致线程池堵塞或多实例重复;面试结论是互斥保护不变式,分布式一致性依赖共享且可恢复的事实来源。
| 场景 | 本地锁能做什么 | 本地锁不能做什么 | 最终兜底 |
|---|---|---|---|
| 单实例 WMS(仓储管理系统)热 SKU(库存单位) | 合并瞬态请求、保护内存聚合。 | 跨进程扣减。 | MySQL(关系型数据库)条件更新。 |
| 支付回调 | 减少同实例重复处理。 | 抵抗多实例、重启、乱序。 | 唯一流水、状态机、对账。 |
| Runner(执行器)任务 | 保护本地队列或句柄。 | 持久任务领取与故障恢复。 | 租约、任务表、幂等执行。 |
| IoT(物联网)告警 | 短窗口聚合与节流。 | 全局去重与跨机窗口。 | Redis(远程字典服务)或消息分区。 |
热门面试题
- 问题(基础题):本地锁与分布式锁的边界是什么?
- 考点:JVM(Java 虚拟机)进程边界、共享状态、故障恢复。
- 回答思路:先给本地锁的对象身份边界,再说跨实例需要共享协议。
- 详细答案:
synchronized的监视器存在于一个 JVM(Java 虚拟机)内,只协调引用到同一对象的线程;它不能让另一台机器看到锁状态,也不能在进程崩溃后保留业务事实。跨实例协调需要共享且可恢复的机制,如数据库条件更新、Redis(远程字典服务)原子脚本或带租约的分布式锁;即使有分布式锁,最终数据仍应有幂等和约束兜底。 - 进阶追问:分布式锁是否能取代数据库约束?
- 进阶回答:不应取代。网络分区、租约过期、客户端暂停和实现缺陷都会使锁不是绝对事实;数据库唯一约束或条件更新更接近数据最终状态,应与幂等流水共同兜底。
- 问题(原理题):为什么锁范围应来自不变式?
- 考点:原子状态转换、最小一致性边界、粒度权衡。
- 回答思路:先写出必须同时成立的业务事实,再把读校验写纳入同一边界。
- 详细答案:锁的目的不是保护某个变量名,而是让一组关联状态在观察者眼中从旧合法状态原子地变为新合法状态。例如“可售库存不小于零且预留流水唯一”涉及检查、扣减和登记,若拆到不同且无协议的锁中,其他线程可看见中间态。范围过大导致排队,过小导致不变式泄漏,应从业务约束反推。
- 进阶追问:涉及数据库时还要把数据库调用放进本地锁吗?
- 进阶回答:通常不应。数据库操作慢且会扩大持锁时间;应让数据库事务或条件更新承担持久化原子性,本地锁只保护必要的本地聚合与去重,二者用状态机衔接。
- 问题(项目题):如何向面试官解释“本地串行化但多实例失效”?
- 考点:部署拓扑、正确性分层、可观测性。
- 回答思路:用支付回调举例,先承认本地优化价值,再明确全局判决位置。
- 详细答案:我会说按订单号的本地锁能让同一台机器的重复回调排队,减少本地重复数据库访问;负载均衡把两个回调路由到不同机器时,它们持有不同对象,所以仍会并发。为此订单更新使用回调流水唯一索引和状态条件更新,日志记录回调号与状态迁移,定时对账修复漏单。本地锁是性能层,持久化幂等是正确性层。
- 进阶追问:如果条件更新失败,是否直接报错?
- 进阶回答:要按当前状态区分已成功重复、乱序提前、金额异常和真实冲突;已成功重复返回幂等成功,乱序进入待处理或查询,异常记录审计并告警,不能统一抛系统错误。
6.3 综合题入口
7. 综合口述题与追问树
问题(语言语义):请完整解释
synchronized(同步锁)的互斥、可重入、可见性与异常释放。- 口述答案:
synchronized是 Java(编程语言)内置的对象监视器同步机制,核心不是“给一段代码上锁”,而是多个线程竞争同一个对象监视器。获得同一监视器的线程进入临界区,其他线程只能等待,因此可以把检查和修改共享状态做成不可交叉的原子步骤;同一线程再次进入同一监视器会增加重入层数,不会把自己阻塞,直到对应次数全部退出才真正释放。内存语义上,线程在退出同步区前的写入,happens-before(先行发生)规则作用于后来成功获得同一监视器的线程,所以受同一锁保护的读写具备可见性和有序边界。编译器和虚拟机还会保证异常路径释放监视器,避免异常把锁永久占住;但这只释放锁,不会回滚已经写出的数据库、消息或远程副作用。项目中我把它用于单 JVM(Java 虚拟机)内的短临界区,真正的跨实例正确性仍由数据库条件更新、唯一约束和幂等状态机保证。 复述时还要补足一个边界:这条可见性关系要求两个线程锁的是同一对象,不能把写方锁this(当前对象引用)、读方锁另一个守卫对象后仍期待自动可见。若操作涉及余额、库存和订单三个持久化对象,监视器只能保护本机内存步骤,数据库事务与条件更新才定义可恢复的提交边界。把这两层分开,才能既说明语言语义,也避免把本地优化夸大成分布式一致性方案。上线后还应为临界区失败与等待设置指标,防止正确性措施变成不可见的性能瓶颈。 - 详细章节:语言语义
- 追问树:为什么必须是同一对象?答:监视器按对象身份区分,不同对象没有互斥关系;可重入如何退出?答:每次进入对应一次退出,计数归零才放行;异常是否自动回滚?答:只自动释放监视器,业务副作用要补偿。
- 口述答案:
问题(锁对象):实例方法、静态方法和代码块的锁对象如何确定,哪些写法容易失效?
- 口述答案:判断
synchronized首先要看锁对象而不是看方法名。实例同步方法锁调用者的this(当前对象引用),因此同一实例的同步实例方法互斥,不同实例完全可以并行;静态同步方法锁声明类的Class(类元数据)对象,在同一类加载器内保护静态共享状态,但也不能跨 JVM(Java 虚拟机);同步代码块锁括号表达式在运行时指向的对象,最适合把锁范围收缩到稳定的业务分片。常见错误是方法内new Object(),每次调用新建锁没有竞争;锁字符串常量会把公开、可池化对象暴露给无关代码;锁可重新赋值字段会让旧请求持有旧对象、新请求进入新对象,互斥被拆开。工程上优先选择私有final守卫对象,先从不变式确定必须一起保护的状态,再评估是否按仓库和 SKU(库存单位)分片。锁越细不必然越快,跨多个锁的不变式反而会带来顺序、死锁和协调成本。 对象选择还应服务于可观测性:锁竞争日志必须能映射到仓库、SKU(库存单位)或订单聚合,而不能把请求对象、字符串内容直接当作监视器。对于大量键,使用固定数量的条带对象可以限制内存;对跨键操作,则先规定键排序或改用数据库批量条件更新。这样既避免锁对象泄漏,也避免为了追求极细粒度让同一业务约束散落在无法协调的多把锁中。发布前还要覆盖锁引用被替换和对象外泄的测试分支。 - 详细章节:锁对象陷阱
- 追问树:为什么字符串不可变仍不推荐?答:不可变不等于私有,外部仍可竞争同一对象;
Class(类元数据)对象是否跨类加载器?答:不是,同名类可有不同类对象;按键锁如何防泄漏?答:使用固定条带或受控生命周期,不能无界缓存锁对象。
- 口述答案:判断
问题(字节码):如何用
javap(字节码反汇编工具)解释同步方法和同步代码块?- 口述答案:我会把两种同步形式分开说。同步方法在字节码方法访问标志中携带
ACC_SYNCHRONIZED(同步方法标记),虚拟机在调用该方法时按实例接收者或静态Class(类元数据)对象获取监视器,方法正常或异常完成时释放;因此反汇编同步方法时通常看到的是标记,而不是代码体里两条显式指令。同步代码块则先计算锁对象、保存到局部变量,再执行monitorenter(进入监视器),正常控制流执行monitorexit(退出监视器);编译器还生成覆盖临界区的异常表,异常处理器对同一个局部锁对象执行退出后再抛出异常。这个结构解释了为什么异常不会永久占锁,也解释了为什么锁对象表达式不能在退出时重新计算。排障时javap -v -p可以确认部署类的标志、指令和异常表,但它只能证明进程内同步语义;实际是否锁住同一对象、是否经过代理或是否多实例,仍要结合调用路径、日志和线程转储判断。 我还会提醒面试官,字节码是编译后的静态证据,不等同于运行时竞争事实。某段代码即使含有监视器指令,若实际请求路由到不同实例、锁表达式拿到不同对象,或临界区根本没有热点,就未必出现阻塞;反过来,线程转储看到大量等待时,要回到具体锁对象和持锁栈验证原因。用反汇编确认“有没有同步”,用运行证据确认“同步为什么慢”,两者缺一不可。 - 详细章节:字节码
- 追问树:同步方法有
monitorenter吗?答:通常由方法标记表达,不在代码体显式展示;异常表做什么?答:保证异常出口先退出监视器;锁内远程调用能被异常表回滚吗?答:不能,外部副作用仍要幂等补偿。
- 口述答案:我会把两种同步形式分开说。同步方法在字节码方法访问标志中携带
问题(等待通知):为什么
wait(等待)必须用while(循环),它和sleep(睡眠)、park(挂起)有什么差别?- 口述答案:
wait(等待)解决的是“条件暂时不成立”的协作问题,而不是单纯暂停线程。调用前必须持有目标监视器,等待时线程会原子地释放该监视器并进入 WaitSet(等待集合),这样生产者才可以进入同步区改变条件并通知;被通知、超时或虚假唤醒后,等待线程还要重新竞争同一监视器,拿到锁后必须用while(循环)重新检查谓词。原因是唤醒不等于条件成立:可能是虚假唤醒,可能是另一个消费者先取走元素,也可能有多个不同条件的等待者。sleep(睡眠)只按时间暂停,不释放已持有监视器,持锁睡眠会阻止改变条件的线程进入;park(挂起)使用每线程许可,允许先unpark(解除挂起)后再消费许可,不要求对象锁,但不自动维护业务条件和队列竞态。生产中内存等待只适合短暂本地协作,Runner(执行器)等可恢复任务仍需任务表、租约、超时和幂等。 实现生产者消费者时,我会把“队列非空”“队列未满”写成明确谓词,在同一监视器内修改计数和元素,再用notifyAll(全部通知)唤醒可能等待不同谓词的线程;消费者醒来后仍从谓词开始判断。若任务可持久化或需要跨实例恢复,就不让线程永久睡在内存里,而是把下一次执行时间、领取人和重试次数放入任务表,唤醒只作为加速手段,不作为唯一可靠信号。中断、超时和关闭同样要回到状态机处理,不能遗漏清理。 - 详细章节:等待通知
- 追问树:
notify(通知)后谁立刻运行?答:无人立刻运行,通知者退出锁后等待者再竞争;为什么必须持锁通知?答:让改条件与入等待集合不丢事件;notifyAll(全部通知)是否更快?答:更安全但可能惊群,要按条件模型权衡。
- 口述答案:
问题(对象头):Mark Word(标记字)、Klass Pointer(类型指针)、锁记录该怎样讲,为什么不能背固定布局?
- 口述答案:对象头的讲法要先给实现边界。HotSpot(热点虚拟机)常见对象布局包括对象头、实例字段和对齐填充;对象头中的 Mark Word(标记字)承载对象运行时状态,例如身份哈希、年龄和锁实现相关信息,Klass Pointer(类型指针)关联类元数据,使虚拟机知道对象的字段布局与方法信息。在 JDK(Java 开发工具包)8 的历史轻量级锁模型里,线程栈帧可建立锁记录,保存 Displaced Mark Word(置换标记字)等原状态,并尝试让对象头关联它;竞争升级后再关联 ObjectMonitor(对象监视器)。但 32 位和 64 位、压缩对象指针和压缩类指针、堆大小、对齐、垃圾回收器以及 JDK(Java 开发工具包)版本都会影响宽度和位编码,所以不能把网络上某张位图说成所有环境固定事实。线上排障优先看线程转储的持锁关系,对象布局只用于在目标版本和参数下验证实现细节,绝不能成为业务逻辑依赖。 如果面试官继续追问,我会给出验证方法而非猜数值:记录目标发行版、
java -version输出和压缩指针参数,在相同环境用对象布局工具或对应 OpenJDK(开放 Java 开发工具包)源码核对,再把结果标成该环境的观测。这样可避免把调试结论写死在设计中,也能说明即使内部编码演进,对象监视器的互斥、重入和内存语义仍由语言层契约保持稳定。 - 详细章节:对象布局
- 追问树:对象头是否总是固定字节数?答:否,受平台、压缩和对齐影响;
hashCode(哈希码方法)会让锁失效吗?答:不会,虚拟机协调内部状态;对象头能证明死锁吗?答:不能,死锁主证据是等待环。
- 口述答案:对象头的讲法要先给实现边界。HotSpot(热点虚拟机)常见对象布局包括对象头、实例字段和对齐填充;对象头中的 Mark Word(标记字)承载对象运行时状态,例如身份哈希、年龄和锁实现相关信息,Klass Pointer(类型指针)关联类元数据,使虚拟机知道对象的字段布局与方法信息。在 JDK(Java 开发工具包)8 的历史轻量级锁模型里,线程栈帧可建立锁记录,保存 Displaced Mark Word(置换标记字)等原状态,并尝试让对象头关联它;竞争升级后再关联 ObjectMonitor(对象监视器)。但 32 位和 64 位、压缩对象指针和压缩类指针、堆大小、对齐、垃圾回收器以及 JDK(Java 开发工具包)版本都会影响宽度和位编码,所以不能把网络上某张位图说成所有环境固定事实。线上排障优先看线程转储的持锁关系,对象布局只用于在目标版本和参数下验证实现细节,绝不能成为业务逻辑依赖。 如果面试官继续追问,我会给出验证方法而非猜数值:记录目标发行版、
问题(ObjectMonitor):请从竞争、膨胀、阻塞、唤醒和退出讲 ObjectMonitor(对象监视器)主线。
- 口述答案:我会先说明这是 HotSpot(热点虚拟机)的实现模型而不是 Java(编程语言)规范。线程进入同步区先尝试快速路径;竞争无法快速解决时,对象同步逻辑经
ObjectSynchronizer::inflate等相关路径建立或关联 ObjectMonitor(对象监视器),随后ObjectMonitor::enter的 C++(C++ 编程语言)实现处理竞争。当前拥有者可用 Owner(持有者)表示,同一线程重入通过recursions(重入次数)记录;新到竞争者可能经过cxq(竞争栈),再进入EntryList(进入队列)等可重新竞争路径。调用wait(等待)的线程释放监视器后进入 WaitSet(等待集合),被notify(通知)或notifyAll(全部通知)后不是直接执行,而是回到竞争路径。拥有者执行ObjectMonitor::exit释放后,候选线程被调度并再次争用拥有权,因此队列不承诺业务公平。源码阅读应固定 OpenJDK(开放 Java 开发工具包)标签,查看objectMonitor.*、synchronizer.*和解释器调用点,不把函数名跨版本硬背。 这条主线的工程含义是,监视器队列只能调度本机线程,不能替代业务队列和流量控制。持锁者应尽快完成纯内存判断并退出;一旦把慢数据库、网络或磁盘 I/O(输入输出)包进临界区,竞争者数量会累积,挂起唤醒与线程池排队又会叠加。排障时我会从 Owner(持有者)的栈反推为什么释放慢,而不是只盯着等待者数量。 - 详细章节:监视器竞争
- 追问树:
EntryList(进入队列)与WaitSet(等待集合)?答:前者等锁,后者等条件且已释放锁;唤醒是否公平?答:不保证;底层是 C 代码吗?答:主要是 HotSpot(热点虚拟机)的 C++(C++ 编程语言)实现。
- 口述答案:我会先说明这是 HotSpot(热点虚拟机)的实现模型而不是 Java(编程语言)规范。线程进入同步区先尝试快速路径;竞争无法快速解决时,对象同步逻辑经
问题(版本差异):JDK(Java 开发工具包)8 的锁升级模型与 JDK(Java 开发工具包)17/21 应如何区分?
- 口述答案:JDK(Java 开发工具包)8 的教学模型通常是无锁、偏向锁、轻量级锁、自旋和重量级锁:偏向锁面向同一线程反复进入,轻量级锁通过栈上锁记录和 CAS(比较并交换)减少立即阻塞,自适应自旋适合预计很快释放的短临界区,竞争持续才使用 ObjectMonitor(对象监视器)阻塞与唤醒。这套模型对理解历史资料很有用,但不应说成所有版本的语言规范。偏向锁在 JDK(Java 开发工具包)15 已默认禁用,后续版本移除;JDK(Java 开发工具包)17/21 的轻量级同步快速路径仍在演进,应以当前发行版源码和参数核对。面试常说“锁只能升级不能降级”,我会限定为经典对象监视器膨胀叙事:已经膨胀的状态不会为了当前一次退出频繁回切旧状态,以避免切换成本;它不是跨版本绝对规则,更不意味着对象一生永远不可走其他实现路径。业务优化应关注临界区时长、热点和线程状态,不靠猜锁标志位。 在迁移或性能复盘中,我会把“版本事实”和“语言保证”分栏记录。前者包括偏向锁是否启用、诊断工具能否显示某类统计及源码实现位置;后者只有同一监视器的互斥、可重入和内存顺序。这样升级 JDK(Java 开发工具包)时可以重新验证实现假设,却不需要重写正确的业务同步协议,也不会因沿用旧锁升级图而做出错误的容量判断。结论应写明适用发行版与观测日期。
- 详细章节:版本模型
- 追问树:偏向锁默认何时禁用?答:JDK(Java 开发工具包)15;自旋何时有害?答:CPU(中央处理器)饱和或持锁长时空转;如何验证版本?答:记录发行版和参数,查对应源码与 JFR(Java 飞行记录器)。
问题(优化):锁消除、锁粗化和自适应自旋分别解决什么问题,为什么锁不是越细越好?
- 口述答案:这些机制都以已正确的同步语义为前提。JIT(即时编译)通过逃逸分析若能证明一个对象没有被发布给其他线程,例如它只在当前调用链内新建和使用,就可能消除该对象上没有真实竞争者的锁;若循环中连续多次锁同一对象,编译器可能锁粗化,减少反复进入退出监视器的机械开销。自适应自旋则是在预计拥有者即将释放时短暂忙等,试图避免挂起唤醒的调度成本。它们都不是源代码保证,也不解决锁内数据库、网络或长计算。锁过细虽然可能减少无关请求互相等待,却会把同一业务不变式拆到多把锁中,增加锁顺序、死锁和中间态风险;锁过粗又会让无关操作串行。我的决策顺序是先定义必须同时成立的状态,再测量线程转储、临界区耗时和热点分布,先移出远程调用、缩短临界区和按独立键分片,最后才依据压测决定是否调整结构,而不是盲目追求更多锁。 例如 IoT(物联网)告警聚合可以按设备条带分片,因为不同设备窗口互不影响;但同一设备的去重标志、计数和最近发送时间必须仍在同一一致性边界。改造后应比较锁等待比例、临界区耗时分位、CPU(中央处理器)利用率、告警重复率和端到端延迟。若只看到吞吐上升却出现跨分片重复告警,说明性能优化破坏了业务不变式,必须回退或重设边界。优化指标必须同时覆盖正确性与资源成本,并保留改造前后的同口径基线,避免把流量下降误判成锁优化收益。
- 详细章节:编译器优化
- 追问树:局部变量一定不逃逸吗?答:不一定,返回或发布到字段就逃逸;锁粗化一定提速吗?答:不一定,会扩大互斥时间;能依赖锁消除保证正确性吗?答:不能,优化不保证发生。
问题(死锁):如何预防、定位并处理
synchronized(同步锁)死锁?- 口述答案:死锁通常需要互斥、占有且等待、不可抢占和循环等待四个条件。内置监视器很难从外部强制夺取,所以工程上最有效的是破坏循环等待:给订单、库存、账户等资源定义全局排序,所有路径按同一顺序获取;能改成单一条件更新或单线程分片的,就不要在应用层拿多把锁。需要超时获取和失败回退时,用
ReentrantLock(可重入锁)的tryLock(尝试加锁)替代内置监视器,并确保失败时释放已持有资源、退避或异步补偿。线上出现卡死时,我会先连续采集多份jstack(线程栈工具),搜索死锁摘要,再交叉核对每个线程的locked与waiting to lock监视器信息,画出等待有向环;大量BLOCKED(阻塞)只说明竞争,不等于死锁。止血时先摘流、暂停非核心批任务和限制重试,必要时滚动重启实例恢复可用性;之后统一锁顺序、移出锁内远程调用、补并发回归测试和告警,不能只把超时调大。 复盘中我会把每把资源锁映射到业务对象和调用入口,找出是否存在新增的反向顺序、异常分支提前持锁或回调重入。测试不仅覆盖甲先订单后库存、乙先库存后订单的并发,还要覆盖超时、重试和线程中断。对于不能短期修改的遗留路径,先用路由隔离、单键串行队列或熔断降低并发面,但这些是风险控制,最终仍要消除等待环。修复后持续观测同类等待是否归零。 - 详细章节:死锁排障
- 追问树:
synchronized能超时获取吗?答:不能;大批BLOCKED(阻塞)一定死锁吗?答:不一定;重启算根治吗?答:不是,只恢复可用性,仍需修复和复盘。
- 口述答案:死锁通常需要互斥、占有且等待、不可抢占和循环等待四个条件。内置监视器很难从外部强制夺取,所以工程上最有效的是破坏循环等待:给订单、库存、账户等资源定义全局排序,所有路径按同一顺序获取;能改成单一条件更新或单线程分片的,就不要在应用层拿多把锁。需要超时获取和失败回退时,用
问题(线程池):为什么锁内远程调用会导致线程池耗尽,如何改造?
- 口述答案:锁内远程调用把两类不确定性叠加了:监视器互斥使同一键请求串行,而网络、数据库或第三方接口的延迟和故障又不可控。拥有者在锁里等待 800ms(毫秒)时仍占有 Owner(持有者),后续线程会在 ObjectMonitor(对象监视器)竞争路径中
BLOCKED(阻塞);如果处理这些请求的线程池只有几十个线程,它们很快都堵在同一个热点锁上,新任务不能处理其他业务,队列增长、调用超时和上游重试继续放大负载。正确改造不是只加线程,而是缩小临界区:锁内仅做读取状态、校验不变式、登记预占或生成幂等请求号;锁外调用远程服务,设置超时、隔离线程池和限流;回调通过状态机、唯一键和补偿任务推进。这样即使远程服务变慢,锁持有时间仍短,线程池也能把故障隔离在外部调用资源池。观测上同时看锁等待栈、临界区耗时、下游响应分位、线程池队列和重复请求数。 对于必须等待外部结果的业务,我会先把请求状态持久化为处理中,再释放本地锁并异步执行;结果回来后通过条件更新推进状态,重复回调只读取既有结果。线程池按支付、物流、批处理等依赖隔离,并设有界队列和拒绝后的落库策略,避免一个慢渠道占满所有工作线程。这样吞吐瓶颈会被限制在可观测、可降级的资源池,而不是隐蔽在对象监视器后面。改造前后还要用同一慢依赖模型验证尾延迟和拒绝率。 - 详细章节:锁内慢调用
- 追问树:加线程能否解决?答:不能解除同一锁串行;远程调用放锁外如何防重复?答:幂等键和状态机;如何止血?答:超时、限流、隔离与暂停非核心任务。
- 问题(项目题):请讲一个 WMS(仓储管理系统)从单 JVM(Java 虚拟机)库存临界区到多实例防超卖的完整方案。
- 口述答案:在 WMS(仓储管理系统)里,我先把库存正确性拆成两层。单 JVM(Java 虚拟机)内,同仓同 SKU(库存单位)的并发请求会争用同一短期内存状态,我按稳定条带或聚合对象加
synchronized,在锁内读取可售快照、检查数量、登记预留意图并更新本地暂存,从而保证“可售不为负、同一次预留不重复”这组内存不变式不被交叉破坏。锁内不做数据库提交、消息发送和远程调用,避免把慢 I/O(输入输出)变成所有请求的串行瓶颈。多实例部署后本地对象不共享,因此最终防超卖必须由 MySQL(关系型数据库)条件更新承担,例如库存更新要求剩余量足够并携带版本或影响行数校验;预留流水设置唯一业务键,重复请求返回既有结果。扣减成功后的消息采用可靠投递或补偿扫描,失败和超时进入可恢复状态。这样本地锁负责降低单机冲突和保护暂态,数据库负责跨实例最终裁决,消息与对账负责异常恢复,三者各自边界清晰。 容量与故障处理也要说清:同一热点 SKU(库存单位)的请求不能无限排队,应在入口按仓库和商品限流,并记录条件更新失败、预留超时和补偿积压。若数据库提交成功但消息投递失败,不能依赖仍可能已释放的本地锁重试,而是由事务外盒或扫描任务根据持久化预留重新投递。这样进程重启、实例扩缩容和重复请求都不会改变库存最终事实,并可持续对账。 - 详细章节:项目边界
- 追问树:本地锁宕机后怎样?答:内存状态丢失,持久化预留和任务表用于恢复;条件更新 0 行怎样处理?答:库存不足或版本冲突,查询状态后重试或失败;为什么不全用分布式锁?答:数据库约束仍是最终事实且锁会增加可用性风险。
- 问题(支付):支付回调本地串行化为什么在多实例下失效,正确幂等链路是什么?
- 口述答案:支付回调经常重复、并发和乱序,本地按订单号加锁确实有价值:同一实例内可以把短时间重复通知串行,减少重复查询和状态竞争。但负载均衡可能把两次回调送到不同实例,每个 JVM(Java 虚拟机)都有独立锁对象;进程重启后本地状态也不存在,所以本地锁不能作为支付幂等的正确性基础。完整链路应先验签、核对订单号与金额,再以渠道流水号建立唯一约束或幂等表;订单状态更新使用条件更新,例如只允许待支付转已支付,重复成功通知读取已有结果并返回幂等成功,乱序通知按状态机进入待核对或延迟处理。状态落库后再异步发送履约、积分或通知,消费者同样按业务键幂等;对外部调用设置超时和补偿,对账任务修复未达或异常状态。本地锁只放在前端的短暂去重层,锁内不做支付查询和消息发送,避免渠道慢导致 ObjectMonitor(对象监视器)排队和线程池耗尽。 我还会在日志与审计表中贯穿订单号、渠道流水、状态前后值和重试原因,便于对账时区分渠道未到、重复到达、金额不符和内部消费失败。出现渠道超时时,订单不能因本地锁释放而直接改成失败,而应维持可查询的处理中状态,等待主动查询、后续回调或对账任务决定终态。这个设计让同步锁只承担短暂串行,不承担支付可信事实。告警也应按渠道与订单状态聚合,避免故障时重复通知,并让人工补单能够追溯每次决策依据。
- 详细章节:支付边界
- 追问树:唯一索引冲突是否报系统错?答:通常查询已有流水并返回幂等结果;回调乱序怎么办?答:由状态机拒绝非法跳转并安排核对;锁内验签可以吗?答:短计算可以,网络查询应移锁外。
- 问题(设计思想):为什么说“互斥保护的是不变式,锁范围来自一致性边界”?
- 口述答案:这句话用来避免把并发设计做成“看到共享变量就随手加锁”。锁真正保护的是业务状态从一个合法快照到另一个合法快照的转换。例如可售库存、已预留数和预留流水之间存在约束:检查库存、扣减与登记必须让其他线程看见旧合法状态或新合法状态,不能看见中间状态,所以它们共同定义了一个一致性边界。若只锁库存数字却把流水登记放在另一把没有协议的锁中,线程可能看见库存已减但找不到预留,或两次登记同一请求;反过来把数据库、网络调用、日志上传等不属于内存不变式的慢操作一并放进锁,会扩大等待面和故障传播。确定范围的步骤是先写出不变量、列出同时读写它的状态、选择同一稳定锁或事务边界,然后把外部副作用放在状态已持久化之后,通过幂等键和补偿连接。对于多实例,内置锁只覆盖一台机器,跨机器的一致性边界要由共享数据库、消息协议或 Redis(远程字典服务)原子操作承担。 因而设计评审不应只问“有没有加锁”,而要问每把锁覆盖哪些字段、谁可以修改、异常时中间状态如何恢复、跨实例由什么事实源裁决。把这些答案写成状态转换表后,很多不必要的嵌套锁会消失,必要的数据库事务与唯一约束也会更明确。锁的范围是由一致性需求推导出来的,不是从代码结构或性能直觉倒推出来的。这个问题应进入代码评审清单,并由并发用例验证每个非法中间态都不可观察。
- 详细章节:设计边界
- 追问树:锁粒度最小就是最好吗?答:不是,要完整覆盖不变式;远程调用是否总在锁外?答:通常应在锁外,除非本身是不可拆的内存操作;多实例如何定义边界?答:以共享可恢复的存储事务或协议定义。
- 问题(排障):接口卡顿时如何区分锁竞争、死锁和线程池耗尽?
- 口述答案:我不会仅凭“接口慢”下结论,而是建立证据链。先看请求量、错误率、线程池活跃数、队列长度、拒绝数和下游响应时间,判断是否有资源饱和;再间隔数秒连续采集多份
jstack(线程栈工具)。若大量线程稳定显示BLOCKED(阻塞)在同一监视器,且有一个拥有者栈停在远程调用、数据库或长计算,通常是热点锁竞争或锁内慢调用;若线程转储的等待与持有关系形成闭环,或工具明确给出 Java(编程语言)级死锁摘要,才确认死锁;若线程主要在队列、网络读取或下游调用处阻塞,且工作线程满、队列持续增长、拒绝增加,则更像线程池耗尽,锁可能只是放大因素。止血时先限流、暂停非核心批任务、降低重试并隔离下游,必要时滚动重启受影响实例;随后根据证据缩短临界区、统一锁顺序、拆分线程池和增加超时。复盘必须记录触发流量、持锁栈、下游依赖、修复验证和告警阈值。 采集时要注意保护现场:记录采样时间、实例标识、部署版本和请求入口,使不同线程转储可以关联同一波故障;不要在未保留证据前全量重启。修复上线后用压测复现原始并发量和下游慢响应,验证BLOCKED(阻塞)比例、拒绝数、尾延迟和业务重复率同时恢复,而不是只看到接口偶然变快就宣布完成。验证记录应进入事故复盘并形成后续告警阈值,同时明确再次触发时的自动限流和升级负责人。 - 详细章节:排障证据
- 追问树:一份线程转储够吗?答:通常不够,要多份确认稳定性;CPU(中央处理器)不高能死锁吗?答:能,线程互等时常不高;何时重启?答:影响核心业务且已固证、具备幂等恢复时作为止血。
- 问题(面试收束):请用两分钟总结
synchronized(同步锁)的底层与工程边界。
- 口述答案:我会先把
synchronized定位为 Java(编程语言)内置的对象监视器机制:实例方法锁this(当前对象引用),静态方法锁Class(类元数据)对象,代码块锁显式对象;同一监视器提供互斥、可重入和解锁到后续加锁的 happens-before(先行发生)规则,异常路径也会释放锁。字节码上同步方法体现为ACC_SYNCHRONIZED(同步方法标记),代码块体现为monitorenter(进入监视器)、monitorexit(退出监视器)和异常表。实现上,HotSpot(热点虚拟机)可从对象头的 Mark Word(标记字)、线程栈的锁记录讲到竞争后的 ObjectMonitor(对象监视器),其中 Owner(持有者)、cxq(竞争栈)、EntryList(进入队列)和WaitSet(等待集合)分别解释拥有、竞争和条件等待;底层主要是 C++(C++ 编程语言)实现,且具体路径随 JDK(Java 开发工具包)版本变化。JDK(Java 开发工具包)8 的偏向锁到重量级锁是历史模型,JDK(Java 开发工具包)15 后不能当默认结论。工程上我只用本地锁保护单 JVM(Java 虚拟机)短暂不变式,避免字符串常量、可变引用和锁内远程调用;跨实例库存与支付幂等以数据库条件更新、唯一键、状态机和补偿为最终边界,线上用jstack(线程栈工具)先取证再止血和复盘。 如果时间允许,我会再补一句选型原则:短小、单进程、对象边界清楚的复合状态适合内置监视器;需要可中断、可超时或多个条件队列时再评估ReentrantLock(可重入锁);跨实例的排他和最终一致性绝不只依赖本地内存。这样回答既能落到字节码和虚拟机源码路径,也能回到库存、支付与异步任务的真实故障边界。 - 详细章节:全章
- 追问树:锁能跨 JVM(Java 虚拟机)吗?答:不能;偏向锁何时失效为默认结论?答:JDK(Java 开发工具包)15 后默认禁用;死锁如何定位?答:用多份
jstack(线程栈工具)构造等待环。
8. 复习清单
- 能准确指出三种
synchronized写法的锁对象,并解释为什么锁对象应私有且稳定。 - 能用
javap(字节码反汇编工具)区分ACC_SYNCHRONIZED(同步方法标记)与monitorenter(进入监视器)/monitorexit(退出监视器)。 - 能说明
wait(等待)释放监视器、sleep(睡眠)不释放、park(挂起)使用许可,以及while(循环)检查谓词的原因。 - 能以版本为前提说明 Mark Word(标记字)、锁记录、ObjectMonitor(对象监视器)和 JDK(Java 开发工具包)8 历史锁模型。
- 能说清
Owner(持有者)、EntryList(进入队列)、cxq(竞争栈)、WaitSet(等待集合)与recursions(重入次数)的角色。 - 能用多份
jstack(线程栈工具)区分热点锁竞争、死锁和线程池耗尽,并给出止血和根治动作。 - 能把 WMS(仓储管理系统)库存、支付回调、Runner(执行器)任务和 IoT(物联网)告警场景拆成本地锁优化与跨实例最终判决两层。
