ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

synchronized 锁升级不是越快越好:对象头里那两位状态位,我曾误读过一次

synchronized 锁升级不是越快越好:对象头里那两位状态位,我曾误读过一次 title: synchronized 锁升级不是越快越好对象头里那两位状态位我曾误读过一次date: 2026-09-25tags: [Java, synchronized, 锁升级, 对象头, 并发, HotSpot]2023 年排查一个订单号生成接口的 P99 抖动问题日志显示偶尔有 100ms 以上的毛刺。我们第一反应是锁竞争但用 JMC 一看几乎没有线程 BLOCKED。后来我打开 synchronized 的详细日志发现对象在偏向锁和轻量级锁之间反复升级降级每次升级都有几十到上百微秒的停顿。更深层的问题是我们当时用的 JDK 15 之后默认已经关闭了偏向锁但代码里还在按偏向锁会加速短临界区的思路写。这篇文章我把 synchronized 的锁升级过程、对象头结构、以及 HotSpot 源码中判断锁状态的核心逻辑拆开聊清楚那两位标志位到底怎么变以及我现在的取舍判断。一、事故现场P99 毛刺找不到阻塞源当时项目里有一个自增订单号生成器Component public class OrderIdGenerator { private long seq 0; public synchronized long next() { return seq; } }压测 QPS 500 左右P99 大部分时间稳定在 2ms 以内但每隔几百次就会出现一次 80-150ms 的毛刺。线程 dump 里没有 BLOCKED 线程JMC 锁竞争页面也看不到 monitor 争用。我们怀疑是网络抖动但毛刺完全发生在 JVM 内部。后来在 JVM 参数里加了-XX:UnlockDiagnosticVMOptions -XX:PrintBiasedLockingStatistics -XX:TraceSafepoint重新压测后看到大量输出[2023-08-12T14:23:05.1230800] vmop [RevokeBias]: 0.234ms [2023-08-12T14:23:05.1240800] vmop [RevokeBias]: 0.198msRevokeBias 就是撤销偏向锁它需要在 safepoint 完成。虽然单次只有 0.2ms但高并发下频繁撤销会累积再加上 safepoint 本身的调度开销就形成了我们看到的 P99 毛刺。二、对象头锁状态存在哪几位HotSpot 中对象头叫 mark word。64 位 JVM 下占 8 字节结构如下简化| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |最末 3 位决定了锁状态-biased_lock1, lock01偏向锁101-biased_lock0, lock01无锁001-lock00轻量级锁-lock10重量级锁-lock11GC 标记源码在src/hotspot/share/oops/markOop.hppenum { locked_value 0, unlocked_value 1, monitor_value 2, marked_value 3, biased_lock_pattern 5 };源码用biased_lock_pattern 5而不是101是因为枚举值按二进制解释5 101b正好对应biased_lock1, lock01。三、锁升级过程无锁 → 偏向锁 → 轻量级锁 → 重量级锁无锁 → 偏向锁JDK 8/11 默认开启偏向锁但 JVM 启动后有 4 秒延迟-XX:BiasedLockingStartupDelay4000。对象创建后先是无锁状态第一个线程获取 synchronized 时会用 CAS 把自己的线程 ID 写到对象头把biased_lock置 1// 简化后的偏向锁获取逻辑 if (mark-is_biased_anonymous()) { // 无锁且允许偏向尝试 CAS 设置线程 ID markOop biased_value mark-set_bias_pattern(); if (obj-cas_set_mark(biased_value, mark) mark) { return; // 获取成功 } }如果 CAS 成功后续这个线程再进入 synchronized 时不需要任何原子操作只需检查对象头里的线程 ID 是否是自己。偏向锁 → 轻量级锁第二个线程尝试获取锁时发现对象头里存的是别的线程 ID。此时需要撤销偏向锁升级为轻量级锁。撤销必须在 safepoint 完成因为要确保持有偏向锁的线程已经离开临界区。// revoke bias 需要在 safepoint BiasedLocking::revoke_at_safepoint(hobj);撤销完成后对象头恢复成无锁状态然后第二个线程用 CAS 把锁记录Lock Record地址写到对象头进入轻量级锁。轻量级锁 → 重量级锁轻量级锁通过自旋 CAS 获取锁。如果竞争加剧自旋失败锁会膨胀为重量级锁底层对应一个ObjectMonitorObjectMonitor* om inflate(...); om-enter(THREAD);重量级锁会让线程进入内核态等待队列由操作系统调度。线程从 RUNNABLE 变成 WAITING 或 BLOCKED涉及到两次用户态和内核态的切换成本远高于自旋。所以重量级锁只在轻量级锁自旋失败后才升级。锁升级不是单向的重量级锁释放后对象头不会回到无锁状态等待下一次偏向而是保持在无锁状态。后续如果只有一个线程访问可能先进入轻量级锁而不是偏向锁。JDK 15 之后偏向锁被关闭所以对象创建后直接进入无锁状态第一次加锁就是轻量级锁。四、重量级锁的底层ObjectMonitor当锁膨胀为重量级锁时对象头里的 mark word 会指向一个ObjectMonitor。这是 HotSpot 实现的管程对象class ObjectMonitor : public CHeapObjmtInternal { private: volatile markOop _header; void* _object; volatile uintptr_t _owner; volatile intptr_t _recursions; ObjectWaiter* volatile _WaitSet; ObjectWaiter* volatile _EntryList; // ... };关键字段-_owner当前持有锁的线程指针。-_recursions重入次数。-_WaitSet调用wait()后进入等待集的线程。-_EntryList竞争锁失败进入阻塞队列的线程。synchronized重量级锁的获取最终调用ObjectMonitor::entervoid ObjectMonitor::enter(TRAPS) { Thread * const Self THREAD; void * cur; // CAS 尝试把 _owner 设为自己 cur Atomic::cmpxchg(Self, _owner, (void*)NULL); if (cur NULL) { // 获取成功 return; } if (cur Self) { // 重入 _recursions; return; } // 否则进入 EntryList 等待 EnterI(THREAD); }这里用 CAS 获取锁失败则进入EnterI把线程挂起。重量级锁的高成本主要就在这里线程需要进入内核态等待。五、最小复现偏向锁撤销的 safepoint 停顿下面这段代码可以复现频繁偏向锁撤销public class BiasedLockDemo { static final Object lock new Object(); public static void main(String[] args) throws Exception { // 确保 JVM 偏向锁已启动 Thread.sleep(5000); for (int i 0; i 1000; i) { final int idx i; new Thread(() - { synchronized (lock) { if (idx % 100 0) { System.out.println(Thread.currentThread().getName() got lock); } } }).start(); } } }JDK 8 下运行加参数-XX:UnlockDiagnosticVMOptions -XX:PrintBiasedLockingStatistics会看到大量撤销统计。运行一段时间后日志里会出现类似输出biased lock entries: 1000000 anonymously biased: 0 rebiased: 5000 revoked: 5000revoked就是撤销次数。如果这个数字很大说明偏向锁没有带来收益反而在消耗 safepoint 时间。JDK 17 下运行默认已经没有偏向锁对象一开始就是无锁直接升级为轻量级锁。六、JDK 15 为什么默认关闭偏向锁JEP 374 明确说明了原因1. 偏向锁的维护复杂代码路径长容易引入 bug。2. 现代 JVM 的轻量级锁性能已经足够好。3. 偏向锁撤销需要在 safepoint 完成高并发下反而成为性能瓶颈。所以从 JDK 15 开始-XX:UseBiasedLocking默认值改为 falseJDK 18 直接移除了偏向锁。七、批量重偏向HotSpot 留的最后一块补丁JDK 8 的偏向锁并不是每次竞争都立刻升级到轻量级锁。HotSpot 观察到一个规律同一个类的对象经常被同一个批次的多线程轮流访问比如线程池里的任务依次处理一批对象单个对象撤销偏向后该类的其他对象也会陆续撞上同样的问题。于是有了两个计数机制批量重偏向bulk rebias一个类的对象撤销次数超过-XX:BiasedLockingBulkRebiasThreshold默认 20JVM 会把该类所有现存对象的偏向标记作废并把类的 epoch 加 1让新分配的对象重新可偏向。批量撤销bulk revoke撤销次数继续涨到-XX:BiasedLockingBulkRevokeThreshold默认 40JVM 直接判定这个类的对象不适合偏向整个类永久禁用偏向锁。这也解释了我们订单号场景的一个现象毛刺不是均匀分布的而是越压越频繁——撤销计数在累积累积到阈值附近时 safepoint 操作变密。当年如果早点用jstat看 safepoint 统计而不是只盯业务日志可能一周就能定位而不是拖了两周。八、我的取舍判断JDK 17 项目偏向锁已经不存在了不要按旧思维写代码。短临界区直接用 synchronizedJVM 会帮你优化成轻量级锁。还在用 JDK 8 的项目如果确认是单线程反复获取锁偏向锁确实有帮助但如果对象会被多个线程访问偏向锁反而有害。计数器、序列号生成不要用synchronized long用LongAdder或AtomicLong。高竞争场景不要用 synchronized用ReentrantLock或更细粒度的并发工具。对象头分析学会用jol-core打印对象头眼见为实。import org.openjdk.jol.info.ClassLayout; public class ObjectHeader { public static void main(String[] args) { Object o new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } } }运行结果可以看到加锁前后对象头最后 3 位从001无锁变成000轻量级锁。九、复盘真实数字优化前 P99 毛刺80-150ms频率约 0.5%根因定位偏向锁撤销导致的 safepoint 停顿修复方案升级到 JDK 17移除偏向锁同时把 OrderIdGenerator 换成 LongAdder优化后 P99 毛刺消失改造耗时1 天代码替换 3 天压测验证十、思考题你的项目还在用 JDK 8 或 JDK 11 吗有没有观察过 synchronized 对象的锁状态分布欢迎在评论区分享。
返回列表