ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

站在 JVM 角度彻底搞懂 Java 锁:对象布局、锁升级与并发优化深度解析

2026/9/26 20:10:48 拓冰建站 浏览量
站在 JVM 角度彻底搞懂 Java 锁:对象布局、锁升级与并发优化深度解析 1. 引言为什么讲锁要从 JVM 视角切入Java 开发者每天都在写 synchronized、使用 ReentrantLock但大多数人理解的“锁”只停留在 API 层面加锁互斥、可重入、公平与非公平。真正让 Java 锁区别于教科书互斥锁的地方并不在 API而在虚拟机内部。JVM 为了让锁“大多数时候不真正阻塞线程”把一把看似简单的锁拆成了对象头状态、字节码指令、线程栈记录、内核互斥量等多个层面并且让这些状态在运行期动态迁移。如果只背结论很容易得出“synchronized 现在比 Lock 慢不了多少”这种似是而非的判断一旦理解了 JVM 在对象头、线程栈、对象监视器和即时编译器四个环节上分别做了什么就能真正判断什么场景该用哪种锁、为什么会发生“锁升级”、为什么偏向锁在 JDK 15 被废弃。本文不会把锁讲成孤立的关键字而是沿着“对象在内存中如何携带锁信息 → 字节码如何进出锁 → 竞争如何逐步升级 → 即时编译器如何消除锁 → 用户态锁如何与 JVM 协同”这条主线把 Java 锁的底层机制完整串起来。2. 基础共识Java 内存模型与锁的语义2.1 可见性、原子性与有序性锁在 Java 内存模型JMM中承担两个职责一是互斥保证临界区同一时刻只有一个线程进入二是内存屏障保证锁获取之前的写入对后续获得锁的线程可见。synchronized 的语义可以简单概括为加锁动作等价于一次 LoadLoad、LoadStore 屏障之后的读取动作解锁动作等价于 StoreStore、StoreLoad 屏障之前的写入动作。这决定了共享变量只要读写都处于同一把锁的保护之下就不必再额外加 volatile。很多并发 Bug 的根源不是“没加锁”而是“有的地方加了锁、有的地方没加”。JMM 并不保护没有同步关系的访问所以在一个线程加锁写入、另一个线程不加锁读取的场景下读线程依然可能看到脏数据。理解锁首先要把“临界区内的可见性保障”和“临界区外的任意读写”严格区分开。2.2 锁的对象到底是什么Java 的锁附着在对象上而不是代码块上。任意new Object()出来的实例都可能成为一把锁。与之配套的关键机制是每个 Java 对象在堆内存里都有一段固定的元信息称为对象头JVM 正是把锁状态编码进这段元信息中。因此要谈锁必须先谈对象在内存中的布局。3. 对象内存布局锁信息的物理载体3.1 一个 Java 对象由哪几部分组成在 64 位 HotSpot 虚拟机中一个普通 Java 对象在堆中大致由三部分组成对象头Object Header又分为 Mark Word 和类型指针Klass Pointer两部分锁状态记录在 Mark Word 中。实例数据Instance Data字段真正占用的内存。对齐填充PaddingJVM 要求对象大小是 8 字节的整数倍不足部分补齐。开启指针压缩默认开启时类型指针占 4 字节Mark Word 占 8 字节。也就是说一个空 Object 对象在堆中大约占用 16 字节其中前 8 字节就是用来存放锁信息的 Mark Word。3.2 Mark Word一物多用的核心结构Mark Word 是理解锁升级的关键。它在不同锁状态下存放的内容完全不同用低位的标志位区分到底处于哪种状态。以 64 位无压缩指针场景为例Mark Word 的可能形态包括锁状态标志位Mark Word 中存储的内容无锁01对象的 hashCode、分代年龄、是否偏向偏向锁01偏向线程 ID、偏向时间戳、分代年龄轻量级锁00指向线程栈中 Lock Record 的指针重量级锁10指向 ObjectMonitor 的指针GC 标记11空供垃圾回收器使用可以看到同一段 8 字节内存因为最低几个二进制的取值不同被解释成了完全不同的信息。正是这种复用让 JVM 可以在不加额外字段、不额外分配内存的前提下让每个对象天然具备成为锁载体的能力。然而复用是有代价的一旦 Mark Word 被用来存偏向线程 ID原先存在这里的 hashCode 就无处安放。这正是“偏向锁状态与 hashCode 冲突”的根本原因后文会详细展开。3.3 用 JOL 观察对象头通过 JOLJava Object Layout工具可以直观看到对象头布局。下面的代码打印一个无锁对象的布局信息import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class ObjectLayoutDemo { public static void main(String[] args) { System.out.println(VM.current().details()); Object obj new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }在 JDK 8 的默认参数下空对象布局通常显示为一行OFFSET 0 SIZE 4、OFFSET 4 SIZE 4或者开启压缩后的对应值。读者可以自己修改 JVM 参数如关闭偏向锁、关闭指针压缩观察对象头的变化这比任何文字描述都更直观。4. 无锁状态偏向锁出现之前的默认状态4.1 无锁不等于没有并发控制JVM 中“无锁”状态指的是对象没有进入偏向、轻量级或重量级锁状态Mark Word 的最低标志位为 01 且偏向标志为 0。此时对象头里存放的是 hashCode 和分代年龄。无锁并不意味着程序没有并发控制它只是代表“这个对象目前没有被当作同步监视器使用或者使用者采取了无锁化的编程方式”。例如AtomicInteger基于 CAS 实现底层没有传统意义的锁但其内部依赖Unsafe.compareAndSwapInt这类原子指令。CAS 在 CPU 层面仍然需要保证总线或缓存的独占访问因此“无锁”更准确的说法是“无阻塞”不一定“无竞争成本”。4.2 hashCode 在无锁对象头中的延迟计算一个重要细节是无锁对象的 hashCode 并非对象一创建就计算好而是按需计算。一旦某线程首次调用hashCode()JVM 就把生成的值写入 Mark Word 并保持不变。由于对象头的空间有限一旦对象进入偏向锁状态这 8 字节中存 hashCode 的位置已经被偏向线程 ID 占用因此只要调用 hashCodeJVM 往往立即撤销偏向锁并回到无锁状态保存 hashCode这一点在后文的偏向锁小节会二次强调。5. synchronized 的字节码层实现5.1 同步方法ACC_SYNCHRONIZED 标志对于同步实例方法JVM 并不在方法体内插入额外的指令而是在方法表的访问标志中设置ACC_SYNCHRONIZED。方法调用时JVM 会先尝试获取声明这个方法的对象或 Class 对象上的监视器方法执行完成或异常退出时再释放。同步方法的加锁位置天然是整个方法体因此无法做到代码块级别的细粒度。5.2 同步代码块monitorenter 与 monitorexit同步代码块则通过一对字节码指令实现进入时执行monitorenter正常退出或异常退出时分别执行monitorexit。编译一个简单的 synchronized 代码块就能看到这对指令成对出现。示例代码public class SyncDemo { private int count; public void add() { synchronized (this) { count; } } }用javap -v反编译后可以看到如下关键部分示意public void add(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: aload_0 5: dup 6: getfield #2 9: iconst_1 10: iadd 11: putfield #2 14: aload_1 15: monitorexit 16: goto 24 19: astore_2 20: aload_1 21: monitorexit 22: aload_2 23: athrow 24: return注意两点其一异常的编译处理里也安排了一次monitorexit保证临界区抛异常时锁依然能被释放否则一个异常就能让其他线程永久阻塞其二monitorenter需要从操作数栈取出对象引用作为锁因此加锁的对象是运行时对象而不是代码里写的变量名。5.3 从字节码到真正的锁解释器与即时编译器的分叉monitorenter并不是直接对应一条 CPU 加锁指令。它在解释执行和 JIT 编译后的道路上会有不同实现解释器通常走锁升级的通用逻辑JIT 编译器则可能根据逃逸分析直接消除锁或者把轻量级锁的 CAS 操作内联展开。也就是说同样的字节码在不同执行路径上可能产生完全不同的底层行为这也是“synchronized 已经很快”这一结论不能一概而论的原因。6. 偏向锁为“只有一个线程反复加锁”而生6.1 偏向锁要解决什么问题HotSpot 团队做过大量调研后发现很多同步代码块在实际运行中始终只有一个线程进入。比如Vector、Hashtable、StringBuffer这类早期线程安全集合在线程封闭的局部场景里每次加锁解锁的对象其实只有同一个线程在访问。若每次都执行 CAS 修改对象头虽然比重量级锁轻但依然有不小的开销。偏向锁的思路是第一次加锁时就把这个对象“偏向”给当前线程之后同一线程再进出临界区几乎不需要任何真正意义上的同步操作。6.2 偏向锁的获取过程偏向锁的核心是把 Mark Word 中标志位保持为 01并设置偏向标志为 1同时在原本存放 hashCode 的位置写入持有该锁的线程 ID。同一线程下次进入时只需判断“Mark Word 中的线程 ID 是否还是我自己”。如果是直接进入临界区不做 CAS不写内存开销极小。偏向锁只会在对象第一次被同步访问时通过一次 CAS 记录线程 ID之后的每次进入都是纯粹的判断与读取。因此偏向锁的开销比轻量级锁更低代价是它“假设”不会有第二个线程来竞争一旦假设被打破就要付出撤销的代价。6.3 偏向锁的撤销代价集中爆发当第二个线程尝试获取已经偏向给其他线程的对象锁时JVM 必须执行偏向撤销。撤销要到达安全点SafePoint暂停持有偏向锁的线程Stop The World检查原偏向线程的状态如果原偏向线程已经退出同步块则把对象恢复到无锁状态并让当前线程重新尝试获取偏向锁或轻量级锁如果原偏向线程仍在同步块内则升级到轻量级锁如果竞争进一步加剧则升级到重量级锁。偏向撤销需要一次全局暂停和逐对象检查当程序里大量对象频繁发生偏向撤销时整体暂停时间反而可能超过锁本身的收益。这正是 JDK 15 默认禁用偏向锁、并在后续版本逐步移除的主要原因现代业务系统里线程竞争更加常见偏向锁“一次性获益、撤销成本集中”的设计在真实负载下经常得不偿失。6.4 hashCode 与偏向锁的冲突前面提到无锁对象的 Mark Word 存的是 hashCode偏向锁状态则把这段空间用来存线程 ID。因此一旦对象进入偏向锁状态就没有地方保存 hashCode。实际规则是如果某个对象已经偏向且尚未计算 hashCode此时调用hashCode()JVM 会撤销偏向并恢复到无锁状态把 hashCode 写回 Mark Word若对象已经存了 hashCode则它不会进入偏向锁状态直接从轻量级锁开始。理解这个限制就能解释为什么某些看似单线程的程序里调用 hashCode 会额外触发锁开销。6.5 批量重偏向与批量撤销为了降低单个对象撤销偏向的开销HotSpot 引入了类级别的批量机制。当某个类的对象撤销偏向达到阈值默认 20 次时JVM 认为这个类的对象可能存在“多个线程交替访问”的特征触发批量重偏向Bulk Rebias之后该类的对象偏向锁可以更容易地重新偏向其他线程。当撤销次数继续达到更高阈值默认 40 次时JVM 认为该类的对象不适合偏向锁触发批量撤销Bulk Revoke此后该类的全部新对象都不再进入偏向锁状态。批量机制的存在说明偏向锁的决策不是纯对象级的还夹杂着类级别的经验统计。这与我们平时从 API 层面看到的“一个对象一把锁”的直觉有很大差别。7. 轻量级锁用 CAS 与线程栈避免内核阻塞7.1 轻量级锁的设计动机当两个线程对同一对象加锁但真正同时进入临界区的时间很短、大多数时候错开执行时直接把线程挂起、交给操作系统互斥量是昂贵且不必要的。轻量级锁的思路是用线程自己栈上的 Lock Record 和对象头的 CAS 操作模拟加锁与解锁尽量不让线程进入内核态阻塞。7.2 Lock Record 的结构与加锁流程每个线程在进入同步块时会在自己的栈帧中创建一块空间称为 Lock Record。这块空间包含一个Displaced Mark Word字段和一个指向锁对象的引用。轻量级锁的获取大致如下在线程栈中分配 Lock Record把对象的 Mark Word 复制到 Lock Record 的 Displaced Mark Word 中通过 CAS 操作尝试把对象头的 Mark Word 替换为指向该 Lock Record 的指针如果 CAS 成功说明当前线程竞争成功对象头标志位变为 00进入轻量级锁状态如果 CAS 失败说明已有其他线程持有锁此时进入“自旋 竞争”逻辑并可能在达到条件后膨胀为重量级锁。这里的 CAS 完成后对象头不再直接保存“谁持有锁”的线程 ID而是保存指向线程栈 Lock Record 的指针。这个指针同时解决了“锁由谁持有”和“如何解锁”两个问题。7.3 解锁过程轻量级锁解锁时线程会比较 Lock Record 中的 Displaced Mark Word 与对象头的当前内容如果两者一致说明没有发生竞争把 Displaced Mark Word 通过 CAS 写回对象头恢复无锁状态如果对象头已经被膨胀为重量级锁指针则说明有竞争发生需要走重量级锁的释放流程并唤醒等待线程。这个“解锁前先检查”的机制保证了轻量级锁在无竞争时几乎是一条 CAS 的开销而在竞争发生时又能无缝过渡到重量级锁。7.4 自旋轻量级锁竞争时的缓冲当 CAS 失败时线程并不会立刻挂起。它可能知道锁马上会释放于是短暂地自旋等待反复读取锁状态或再做 CAS 尝试。自旋的本质是用 CPU 空转换取线程挂起与恢复的开销。如果锁确实在短时间内释放自旋就能避免一次操作系统线程调度如果自旋了若干次仍等不到锁线程就必须进入重量级锁的阻塞流程否则会白白烧掉 CPU。HotSpot 的自旋策略经历过多次演变。早期版本使用固定次数自旋后来在 JDK 1.6 中引入自适应自旋JVM 会记录同一个锁上一次自旋成功还是失败。如果上一次自旋成功就允许这次多自旋一会儿如果上一次自旋失败就可能直接跳过自旋进入挂起。自适应自旋的决策依赖运行时统计不再是一个写死的常量这与 JVM 在很多地方采用的“经验优化”思路是一致的。8. 重量级锁从 JVM 到操作系统的临界区8.1 为什么需要重量级锁轻量级锁和自旋都在解决同一个问题如果锁很快被释放就不值得让线程挂起。但现实里不是所有锁都“马上释放”。长事务、磁盘 IO、网络请求、大对象拷贝这类临界区可能持续几十毫秒甚至更久。此时让多个线程持续自旋只会空耗 CPU正确做法是把等待线程交给操作系统让它释放 CPU、进入等待队列。这一步就是锁膨胀为重量级锁的动机。重量级锁不再依赖对象头和线程栈里的 Lock Record 模拟互斥而是真正使用操作系统提供的互斥原语在 HotSpot 内部对应一个ObjectMonitor对象。对象头里的 Mark Word 会从指向 Lock Record 的指针变成指向 ObjectMonitor 的指针。8.2 ObjectMonitor 的内部结构每个重量级锁都对应一个 ObjectMonitor它是锁在 JVM 内部的具象化。ObjectMonitor 主要的组成部分包括_owner当前持有锁的线程。_EntryList等待获取锁的线程队列通常被称作锁的竞争队列。_WaitSet调用了wait()后进入等待集合的线程需要被notify()或notifyAll()唤醒。_recursions可重入计数器同一个线程重复进入锁多少次就要退出多少层。_cxq竞争锁失败后线程先进入的同步队列入口之后会被转移或合并到 EntryList。虽然不同 HotSpot 版本对这些队列的实现略有差异但模型始终没有离开“入口等待、拥有者、条件等待”三块。理解这些字段才能真正分清synchronized里的等待和Object.wait()里的等待不是同一件事前者发生在还未获得锁的阶段后者发生在已经获得锁但暂时主动放弃锁的阶段。8.3 获取与释放过程线程进入重量级锁的典型路径如下线程检查 ObjectMonitor 的 _owner 是否为自己。如果是直接令 _recursions 加一完成可重入进入。如果不是则通过 CAS 尝试把 _owner 从 null 设置为自己。CAS 成功即成为持有者。CAS 失败说明有线程已经持有锁当前线程进入 _cxq 或 EntryList 排队并调用操作系统的 park 操作让自己挂起。持有锁的线程执行完临界区并完成退出后根据策略唤醒等待队列中的线程继续竞争。重量级锁的释放逻辑也比较复杂因为要同时处理“可重入计数未归零”“等待线程唤醒”“Condition 线程转移”等状态。这里体现出一个重要观点重量级锁的慢不只是慢在内核态切换还在于维护这套队列与状态带来了更多的内存写和分支判断。所以即使现代操作系统互斥量已经优化得很好JVM 仍然希望尽量别走到这一步。8.4 线程状态切换从 RUNNABLE 到 BLOCKED 与 WAITING从 Java 线程状态的角度看重量级锁对应两种不同的挂起状态BLOCKED线程刚进入synchronized时锁被别的线程持有线程尚未获得锁。线程状态显示为 BLOCKED等锁释放后会被唤醒继续竞争。WAITING线程已经获得锁但执行了Object.wait()主动释放锁进入等待。只有被notify()或notifyAll()唤醒才会离开 WaitSet并且还要重新竞争锁。这个区分在排查线程池卡死、死锁时非常有用如果大量线程处于 BLOCKED说明它们都在抢同一把锁如果大量线程处于 WAITING说明它们在条件队列中等待某个信号。前者往往是临界区太长或锁粒度太大后者往往是 notify 条件丢失导致无法醒来。9. 锁升级全景状态迁移与设计权衡9.1 一条主线串起四个状态到这里可以把 JVM 锁升级的整体路径归纳为无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这不是一条必须走完的单行道某些状态可能被跳过某些状态也可能因为 hashCode 等原因提前失去资格。无锁状态对象头保存 hashCode 与分代年龄。首次同步且某线程独占访问优先进入偏向锁对象头保存线程 ID。第二个线程开始竞争偏向锁被撤销升级为轻量级锁对象头保存线程栈 Lock Record 指针。竞争持续激烈轻量级锁膨胀为重量级锁对象头保存 ObjectMonitor 指针。每一次升级都意味着“准备工作”更重但目的是在竞争真正到来之前尽量用更轻的方案把问题解决掉。可以把这理解成保险无竞争时几乎没有成本竞争刚刚出现时用极小成本缓冲竞争确凿存在时才支付真正昂贵的内核互斥成本。9.2 锁为什么只升级、不降级HotSpot 中的锁状态通常一旦升级就不会自动降级。偏向锁撤销后不会再回到偏向轻量级锁膨胀为重量级锁之后即使之后没有竞争对象头里仍然保存 ObjectMonitor 指针。这看起来浪费却换来了实现的简单性JVM 不需要在每一个竞争平息点都去判断“现在可以降级吗”避免为了省一点元信息而增加复杂的机会性决策。锁的优化重点因此放在“如何尽量避免升级”上而不是“如何降级”。9.3 hashCode 与状态迁移的边界再次强调 hashCode 这条暗线它把 Mark Word 的空间占用和锁状态绑在一起。对象的hashCode()一旦被调用偏向锁就可能直接失去资格。因此在实际代码里如果对象既要被用来做同步锁又经常被放进 HashMap 或 HashSet会更容易观察到偏向锁无法生效。这是一个非直觉但很经典的调优点。10. JVM 锁优化编译器与运行期互相配合10.1 锁消除从字节码到汇编码的裁剪锁消除是即时编译器在编译期“证明某段同步没有必要”时的优化。如果 JIT 分析后发现锁对象不可能被多个线程同时访问那么编译后的代码里连monitorenter/monitorexit都没有加锁成本直接归零。一个典型场景是方法内部的局部锁public String concat(ListString values) { StringBuilder sb new StringBuilder(); for (String v : values) { appendWithLock(sb, v); } return sb.toString(); } private void appendWithLock(StringBuilder sb, String v) { synchronized (sb) { sb.append(v); } }当编译器经过逃逸分析后发现sb只在本线程内可见、没有逃逸到其他线程就会把synchronized (sb)全部消除。JDK 5 以前StringBuffer与StringBuilder的性能差异经常被归因于“前者有锁”现代 JVM 则可能把两者优化到几乎一样这就是锁消除的功劳。10.2 逃逸分析锁消除的前提锁消除依赖逃逸分析。逃逸分析的目的是判断一个对象会不会“逃离”当前线程的方法作用域。只有当对象被证明是线程私有、不可能被其他线程访问时编译器才敢删除同步。逃逸分析同样作用于栈上分配、标量替换等优化是 JVM 在高版本中持续投入的重要技术。不过需要理性看待的是逃逸分析的判断并不总是成功。一旦对象通过返回值、外部引用、反射等方式逃逸编译器就会保守地保留锁。所以不能认为写了一个局部 synchronized 就“一定会被消除”它只是给了编译器一个优化机会。10.3 锁粗化把高频细粒度锁合并为低频粗粒度锁锁粗化与锁消除走向相反锁消除是去掉可以去掉的锁锁粗化是把本来可以合并的多次加解锁合并成一次。下面这段代码在循环里反复加同一个对象的锁for (int i 0; i 1000; i) { synchronized (lock) { count i; } }如果 JVM 不做任何处理这段代码会进出锁一千次。JIT 可能把整个循环体包进一次synchronized (lock)里减少加解锁控制带来的开销。锁粗化适用于临界区很短、加解锁本身相对昂贵的场景。当然如果临界区里包含耗时 IO粗化会拉长锁持有时间加剧竞争所以 JIT 会结合执行数据做权衡。锁消除和锁粗化的存在提醒我们源码层面的锁边界并不等于最终执行的锁边界。讨论并发性能时不能只凭 Java 代码推断有多少次加锁还要看即时编译器最终生成了什么。11. synchronized 与 ReentrantLockJVM 视角的横向比较11.1 它们共享同一套底层锁逻辑很多开发者把 synchronized 和 ReentrantLock 当成两个完全独立的世界。实际上从 HotSpot 运行期的角度看两者最终都可能落在同一个重量级锁机制附近。synchronized 由 JVM 直接管理对象监视器偏向锁、轻量级锁这些概念都是 JVM 为 synchronized 设计的而 ReentrantLock 依赖 AQS通过 CAS 修改同步状态底层再使用LockSupport.park挂起线程同样会进入操作系统的线程等待。它们差异主要在“状态组织方式”和“控制能力”而不是谁天然快或慢。11.2 能力差异产生使用差异ReentrantLock 相比 synchronized 多了几项编程层面的能力可中断获取lockInterruptibly()允许等待锁的线程响应中断。超时获取tryLock(long, TimeUnit)避免无限阻塞。公平锁支持按照等待先后顺序获取锁而 synchronized 没有公平性保证。多条件队列一个锁可以绑定多个 Condition比wait()/notify()只有一个隐式条件更灵活。这些能力并不改变 JVM 中锁升级等底层机制而是在 API 层提供了更精细的调度控制。如果业务只需要简单的互斥加可重入synchronized 完全够用且代码更不容易出错如果确实需要中断、超时、公平或多条件再引入 ReentrantLock这才是从 JVM 视角给出的务实判断。11.3 从排错角度对比监控友好度synchronized 在对象内存布局和锁升级上有清晰的可观测性JOL 能直接打印锁状态ReentrantLock 的状态则需要通过线程 dump 查看 AQS 队列和 owner 信息。两者在排查死锁时思路不同synchronized 更容易看到“哪个对象上的锁是谁持有”ReentrantLock 更容易看到“等待队列里有哪些线程、排队是否公平”。没有绝对的好坏取决于排查思路。12. 常见误区与 JVM 锁实践建议12.1 误区一synchronized 一定很慢这个印象来自早期 JDK。JDK 1.6 之后得益于偏向锁、轻量级锁、自旋、锁消除和锁粗化synchronized 在非高竞争场景下已经相当快。真正拖慢性能的是激烈竞争导致的重量级锁而不是 synchronized 关键字本身。把一句“synchronized 已优化”当成结论没有问题但要知道它优化的前提是竞争不激烈。12.2 误区二加了锁就保证所有数据一致锁只保护“都用同一把锁访问的数据”。如果一个变量在临界区内加锁写但在临界区外无锁读那么读到的值仍然没有可见性保证。锁的语义是对称的写方加锁、读方不加锁几乎等于没有锁。建立并发约束时必须把读写路径都纳入同一套同步协议。12.3 误区三偏向锁适合所有单线程应用偏向锁的确是为“单线程反复进入”设计的但它的收益来自避免后续 CAS。如果一个对象的锁只被使用一次偏向锁不会带来多少好处如果对象频繁调用 hashCode偏向锁还可能被立即撤销。再加上 JDK 15 之后偏向锁默认废弃很多基于老资料的优化建议已经过时。理解版本差异非常重要。12.4 误区四自旋等待越久越好自旋是拿 CPU 时间换调度延迟。如果临界区很长自旋只会让多个线程一起烧 CPU最后还是要挂起。自适应自旋的出现恰恰说明自旋次数应该由“上一次等没等到”这种运行时信号决定而不是人为拍一个很大的值。对高竞争、长临界区场景缩短自旋或直接接受重量级锁往往更稳。12.5 实践建议清单缩小临界区只把真正需要互斥的最小操作放进 synchronized减少锁持有时间。降低锁粒度例如 HashMap 拆成多个分段锁、对象拆分成多把细粒度锁避免所有线程挤在同一把锁上。避免在锁内做 IO 或远程调用这些操作时间长且不可控会把普通互斥直接拉成激烈竞争。重视逃逸分析友好的写法尽量让只在方法内部使用的对象不要返回或赋给共享变量给 JIT 创造锁消除机会。善用线程 dump遇到 BLOCKED 大量出现时先找到是哪一把锁、哪个持锁线程再决定是优化锁粒度还是剥离无关操作。13. 总结一把简单锁背后的一整套系统设计把这些问题串起来看Java 锁从来不只是“保证互斥”这一件事。JVM 在解决并发互斥时同时面对三个相互冲突的目标无竞争时几乎零开销、短暂竞争时避免线程调度、真实竞争时保证正确性和稳定性。这才演化出对象头复用、偏向锁、轻量级锁、自旋、锁消除、锁粗化、ObjectMonitor 这一系列机制。理解这些机制后再回头看日常开发synchronized 与 ReentrantLock 的选择、局部变量与共享变量的边界、临界区范围、hashCode 与锁状态的隐性冲突都不再是孤立知识点而是同一条 JVM 并发执行链上的不同切面。真正的调优方向也不是“换一把更快的锁”而是减少竞争、缩小临界区、让 JVM 有更多机会用轻量方案化解同步。这正是站在 JVM 视角看锁时得到的最重要结论。