JVM 偏向锁 4 秒延迟:从源码动机到 JEP-374 废弃全链路

JVM 偏向锁 4 秒延迟:从源码动机到 JEP-374 废弃全链路 面试高频考点偏向锁为什么要延迟启动启动期到底哪些对象在抢锁后来为什么整个偏向锁都被删了本文所有结论均附 OpenJDK 源码 / JEP 原文出处不杜撰。一、一句话结论BiasedLockingStartupDelay4000默认 4 秒不是专门针对类加载器对象的特殊逻辑而是一个全局时间开关JVM 启动后的前 4 秒内所有新分配对象的 Mark Word 直接是无锁状态不允许匿名偏向4 秒到点后才对当前已加载的所有类和未来新分配的对象开放偏向锁能力。它的唯一目的规避 JVM 启动阶段大量偏向锁撤销触发的安全点SafepointSTW避免拖慢启动速度。二、HotSpot 源码原始注释最权威证据文件hotspot/src/share/vm/runtime/biasedLocking.cppBiasedLocking::init()方法voidBiasedLocking::init(){// If biased locking is enabled, schedule a task to fire a few// seconds into the run which turns on biased locking for all// currently loaded classes as well as future ones. This is a// workaround for startup time regressions due to a large number of// safepoints being taken during VM startup for bias revocation.// Ideally we would have a lower cost for individual bias revocation// and not need a mechanism like this.if(UseBiasedLocking){if(BiasedLockingStartupDelay0){EnableBiasedLockingTask*tasknewEnableBiasedLockingTask(BiasedLockingStartupDelay);task-enroll();}else{VM_EnableBiasedLockingop(false);VMThread::execute(op);}}}逐行翻译这段注释的关键信息原文含义workaround for startup time regressions这是一个临时补丁用来解决启动时间回退问题a large number of safepoints being taken during VM startup for bias revocation根因是启动阶段大量偏向撤销触发安全点 STWIdeally we would have a lower cost for individual bias revocation and not need a mechanism like this理想情况下应该降低单次偏向撤销的开销就不需要这套机制了源码自己都承认这是 workaround不是什么优雅设计。这是面试时可以直接引用的金句。4 秒到点时具体做了什么voidVM_EnableBiasedLocking::doit(){// 1. 遍历系统字典中所有已加载的类把它们的 prototype_header 改为偏向模式SystemDictionary::classes_do(enable_biased_locking);// 2. 标记全局开关未来新分配的对象也走偏向模式_biased_locking_enabledtrue;}两个动作已加载类修改类的prototype_header类的原型对象头之后该类 new 出来的新对象会继承匿名偏向的 Mark Word。未来对象全局开关打开新分配对象默认带匿名偏向标记。注意4 秒前已经分配好的对象Mark Word 不会被回溯修改它们终身是无锁状态不会进入偏向。只有 4 秒之后 new 出来的对象才有资格走偏向锁。三、启动期到底哪些对象在被多线程抢锁3.1 核心判断标准一个对象会在启动期引发锁竞争必须同时满足对象在 JVM 启动极早期被创建落在 0~4 秒窗口内是全局单例所有线程共享同一个对象不是每个线程各 new 一个会被多条不同线程交替访问不是固定一个线程独占3.2 典型对象清单① 各类基础类的Xxx.class对象最典型以java.lang.Integer为例谁在抢主线程、JIT 编译线程、Finalizer 线程、ReferenceHandler 线程几乎同时第一次访问 Integer抢的是什么JVM 规范规定每个类 C 有一个唯一的初始化锁LCHotSpot 的实现就是用Class对象本身当锁为什么必须加锁clinit()静态初始化方法全局只能执行一次多线程同时触发类初始化时必须用锁互斥JLS 12.4.2 原文类初始化详细过程第一步“Synchronize on the initialization lock, LC, for C. This involves waiting until the current thread can acquire LC.”伪代码等价synchronized(Integer.class){// 多个线程抢同一个 Class 对象锁if(Integer已初始化完成)return;Integer.clinit();// 只跑一次初始化 IntegerCache 缓存池标记初始化完成;}关键细节每个类的clinit竞争只发生一次类初始化完成后就不再抢了但启动阶段有几十上百个 JDK 基础类Integer、Long、Boolean、Double、String、Math、ArrayList、HashMap……扎堆并发加载每个类各自发生一次瞬时竞争积少成多。同样的逻辑适用于Long.class、Boolean.class、String.class、Math.class、ArrayList.class、HashMap.class等所有启动期被多线程首次访问的类。② ClassLoadingLock 对象JDK 7ClassLoader.loadClass()不再对this加锁而是调用getClassLoadingLock(className)内部维护一张哈希表key 是类全限定名value 是new Object()锁对象。谁在抢多个线程同时加载同一个类名时拿到同一个锁对象特征对象在类加载过程中创建落在启动窗口内③ JVM 内部全局锁对象C 层映射到 Java 监视器StringTable / SymbolTable 内部锁启动期大量字符串常量 intern、符号插入多线程并发操作哈希表引用队列锁java.lang.ref包静态初始化创建的锁对象协调应用线程、Finalizer 线程、ReferenceHandler 线程SystemDictionary 内部锁类名到 Class 对象的注册表多线程并发查找插入④ 应用代码早期创建的 static 全局锁对象容易被忽略publicclassApp{// 诞生于 0~4 秒窗口哪怕是业务代码 new 的同样禁止匿名偏向publicstaticfinalObjectGLOBAL_LOCKnewObject();}机制只看对象分配时间不看对象属于 JDK 内部还是业务代码。这一点是面试常考的认知误区。3.3 这些对象的共同特征总结┌─────────────────────────────────────────────────┐ │ 启动期高竞争锁对象的共同特征 │ ├─────────────────────────────────────────────────┤ │ 1. 创建时间极早 → 落在 0~4s 偏向锁关闭窗口内 │ │ 2. 全局单例 → 所有线程共享同一个对象 │ │ 3. 多线程交替 → 不是固定一个线程长期持有 │ │ 4. 线程来源杂 → 主线程 JIT 守护线程都来碰 │ └─────────────────────────────────────────────────┘四、如果没有 4 秒延迟会发生什么完整因果链假设设置-XX:BiasedLockingStartupDelay0拿Integer.class走一遍JVM安全点Integer.class对象线程B(JIT线程)线程A(主线程)JVM安全点Integer.class对象线程B(JIT线程)线程A(主线程)对象分配Mark Word 匿名偏向(101)Mark Word 已偏向A(101线程A指针)发现锁已偏向AA已退出同步块Mark Word 退回无锁(001)抢锁CAS成功对象偏向线程A执行clinit释放锁来抢同一把锁触发全局安全点STW暂停所有Java线程遍历线程栈撤销偏向标记升级轻量级锁CAS抢轻量级锁成功每一次别的线程来抢一个已被偏向的对象都要走一次全局安全点 STW。启动期几十个类各来一次STW 次数爆炸启动时间显著拉长。这就是源码注释说的 “a large number of safepoints being taken during VM startup for bias revocation”。五、4 秒延迟的本质不是识别对象是按时间一刀切0sJVM启动新对象 Mark Word 无锁(001)禁止匿名偏向4sEnableBiasedLockingTask触发已加载类的prototype_header改为偏向模式全局开关_biased_locking_enabled true4s新对象 Mark Word 匿名偏向(101)允许走偏向锁逻辑对象分配时间 vs 偏向锁能力两个常见误区误区真相4 秒后老对象也会自动变成可偏向❌ 不会。只有 4 秒后 new 出来的对象才有匿名偏向标记老对象终身无锁4 秒延迟是专门识别、过滤 JVM 内部对象❌ 没有特殊识别逻辑纯粹按对象分配时间一刀切。业务代码在启动前 4 秒 new 的对象同样不能偏向六、后来为什么把整个偏向锁都删了JEP-3746.1 版本时间线版本事件出处JDK 6引入偏向锁默认开启—JDK 152020.09默认关闭偏向锁废弃所有相关参数JEP-374JDK 18彻底删除偏向锁代码参数不再识别obsoleteJDK-8256425原本计划 JDK 16 删除后延期到 JDK 18JDK-8256253。被废弃的参数清单JEP-374 原文列出ProductBiasedLockingStartupDelay、BiasedLockingBulkRebiasThreshold、BiasedLockingBulkRevokeThreshold、BiasedLockingDecayTime、UseOptoBiasInliningDiagnosticPrintBiasedLockingStatistics、PrintPreciseBiasedLockingStatistics6.2 废弃的四大原因JEP-374 Motivation 原文归纳① 硬件变化CAS 原子指令成本大幅下降JEP 原文“The cost of executing atomic instructions has decreased on modern processors since the introduction of biased locking into HotSpot.”偏向锁诞生的初衷是省去 CAS 原子操作。当年 CAS 很贵今天 CAS 已经很便宜偏向锁省下来的那点开销微不足道。② 受益场景萎缩只有老应用还在吃红利JEP 原文“Many applications that benefited from biased locking are older, legacy applications that use the early Java collection APIs, which synchronize on every access (e.g., Hashtable and Vector).”老应用Hashtable、Vector——每次访问都 synchronized单线程场景下偏向锁收益大现代应用HashMap、ArrayListJava 1.2 引入单线程无锁或ConcurrentHashMapJava 5 引入高并发本身就减少了不必要的 synchronized③ 线程池架构的应用关闭偏向锁反而更快JEP 原文“applications built around a thread-pool queue and worker threads generally perform better with biased locking disabled. (SPECjbb2015 was designed that way)”现代微服务基本都是线程池 Worker 线程架构。同一个锁对象在不同 Worker 线程之间频繁换手一旦竞争就触发偏向撤销 STWP99 延迟恶化。SPECjbb2015 基准测试就是关闭偏向锁后性能更好。④ 维护成本太高阻碍 JVM 演进JEP 原文“Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well. This complexity is a barrier to understanding various parts of the code and an impediment to making significant design changes.”偏向锁的状态机极其复杂匿名偏向、已偏向、批量重偏向bulk rebias、批量撤销bulk revoke、epoch 机制、线程死亡处理……代码侵入线程、GC、解释器、JIT 各个模块。Project Loom虚拟线程要改造同步子系统时偏向锁是巨大障碍。6.3 关键澄清不是启动期锁竞争被解决了误区JDK 优化了启动期不再多线程初始化基础类所以不需要 4 秒延迟了。真相类加载并发逻辑、clinit锁机制几乎没有改动。启动期Integer.class等对象依旧会被多线程争抢只是现在直接走轻量级锁 CAS不再有偏向→撤销→STW这条路径自然也就不需要 4 秒这个补丁了。七、全链路因果图面试默写版JDK6 引入偏向锁目的: 单线程反复拿锁时省去CAS问题: 别的线程来抢已偏向对象时必须触发全局安全点STW做撤销JVM启动期: 几十上百个基础类被主线程JIT守护线程并发初始化每个类的Xxx.class都是全局单例锁对象多线程争抢 → 大量偏向撤销 → 大量STW启动时间严重回退打补丁: BiasedLockingStartupDelay4000前4秒新对象禁止匿名偏向4秒后: 已加载类prototype_header改偏向模式新对象允许匿名偏向时代变化硬件: CAS变便宜了应用: 线程池架构, 锁频繁换手偏向撤销STW反而恶化P99代码: 状态机太复杂, 阻碍虚拟线程等新特性JEP-374: JDK15默认关闭JDK18: 彻底删除代码, 参数obsolete4秒延迟随偏向锁一起消失启动期竞争直接走轻量级锁CAS八、面试高频 QAQ1偏向锁为什么要延迟 4 秒启动答不是为了等什么而是为了避开 JVM 启动阶段的高竞争期。启动期大量基础类的Class对象、ClassLoadingLock、JVM 内部全局锁对象会被主线程、JIT 线程、守护线程并发争抢。如果一启动就开启偏向锁这些对象会频繁经历偏向→被别的线程抢→安全点 STW 撤销的循环大量 STW 拖慢启动。所以前 4 秒新对象直接无锁4 秒后启动期高竞争基本结束再开放偏向锁。Q24 秒延迟是专门针对类加载器对象吗答不是。它是按对象分配时间的全局一刀切开关不识别对象类型。类加载器相关锁对象确实是启动期高竞争对象的典型代表但业务代码在前 4 秒 new 的对象同样被禁止偏向。Q34 秒后之前创建的对象会变成可偏向吗答不会。4 秒到点时只做两件事修改已加载类的prototype_header、打开全局开关。已经分配好的对象 Mark Word 不会被回溯修改它们终身是无锁状态。只有 4 秒之后 new 出来的对象才有匿名偏向标记。Q4偏向锁撤销为什么需要安全点 STW答撤销偏向时需要遍历所有线程的栈找到并修改那些正在使用该偏向锁的栈帧中的锁记录确保递归锁等场景的一致性。遍历线程栈必须在所有线程都暂停的安全点才能安全进行所以是全局 STW。这也是偏向锁撤销代价高的根本原因。Q5JDK 15 之后偏向锁怎么了答JDK 15 通过 JEP-374 默认关闭偏向锁并废弃相关参数JDK 18 彻底删除偏向锁代码UseBiasedLocking、BiasedLockingStartupDelay等参数不再被识别。原因是现代 CPU 的 CAS 开销降低、线程池架构下偏向锁反而制造延迟抖动、代码复杂度阻碍虚拟线程等新特性演进。Q6启动期Integer.class的锁竞争会持续很久吗答不会。每个类的clinit只执行一次初始化完成后其他线程直接返回不再争抢。但启动期有几十上百个基础类扎堆并发初始化每个类瞬时竞争一次累积起来的偏向撤销 STW 次数就很可观了。九、参考来源OpenJDK 源码hotspot/src/share/vm/runtime/biasedLocking.cpp—BiasedLocking::init()原始注释JEP 374: Deprecate and Disable Biased Locking — http://openjdk.org/jeps/374JDK-8256425: Obsolete Biased Locking in JDK 18 — https://bugs.openjdk.org/browse/JDK-8256425JLS 12.4.2 Detailed Initialization Procedure — 类初始化锁LC规范OpenJDK Wiki: Biased-locking Synchronization —BiasedLockingStartupDelay参数说明