ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

synchronized 的重量级锁到底卡在哪:一次 8 万 QPS 跌到 2 万的排查,和 Mark Word 的 4 种状态

synchronized 的重量级锁到底卡在哪:一次 8 万 QPS 跌到 2 万的排查,和 Mark Word 的 4 种状态 引子去年做网关限流组件压测的时候我们遇到一个很诡异的现象单机在 8 万 QPS 的时候 CPU 才用到 40%一切都正常但把并发线程数从 32 提到 64 之后QPS 不升反降直接掉到 2 万CPU 反倒跑满了。线程 dump 一拉满屏都是BLOCKED (on object monitor)。那一刻我才意识到自己一直把synchronized当成语法糖级别的小锁在用却从没认真看过它底层到底在争什么。这篇文章就把这次排查里弄明白的东西写下来对象头里的 Mark Word 长什么样、synchronized的锁是怎么从无锁一路升级到重量级的、以及什么时候它真的会拖垮你。先搞清楚锁信息存在哪很多人以为synchronized(obj)这把锁是挂在obj对象身上的某个字段里其实不是。JVM 里每个 Java 对象在堆内存中都有一个对象头Object Header锁状态就编码在对象头最前面的Mark Word64 位 JVM 下占 8 字节里。也就是说锁是借用了对象头的空间来记录的。下面用 JOLOpenJDK 的 Object Layout 工具把对象头打出来看一眼import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class MarkWordDemo { public static void main(String[] args) throws Exception { // 一个普通对象还没上锁 Object lock new Object(); // ClassLayout 会读取对象在内存中的真实布局 System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 进入同步块后再次打印对比 Mark Word 的变化 synchronized (lock) { System.out.println(--- 持有锁时 ---); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } } }逐行解释第 1 行import org.openjdk.jol.info.ClassLayout引入 JOL 的核心类它是分析对象内存布局的标准工具Maven 坐标org.openjdk.jol:jol-core。第 6 行Object lock new Object()创建一个最朴素的对象8 字节 Mark Word 4 字节压缩指针类型指针没有其他字段。第 8 行ClassLayout.parseInstance(lock).toPrintable()会直接读取这个对象在堆里的真实字节而不是凭空算。前两行输出的十六进制就是 Mark Word 的 64 位注意它是小端显示的。第 11 行synchronized (lock)进入同步块后Mark Word 的低位最后 2~3 个 bit会从无锁的标记变成偏向锁或轻量级锁的标记肉眼就能看出那几个 bit 变了。那 64 位的 Mark Word 到底怎么划分在 64 位 JVM、开启指针压缩的情况下它的布局大致是无锁状态25 位未用 31 位哈希码identity hash code 1 位未用 4 位分代年龄 1 位偏向锁标志 2 位锁标志位01。偏向锁54 位持有偏向锁的线程 ID 2 位 Epoch 1 位未用 4 位分代年龄 1 位偏向标志 2 位锁标志01。轻量级锁62 位指向栈上锁记录Lock Record的指针 2 位锁标志00。重量级锁62 位指向操作系统互斥量ObjectMonitor的指针 2 位锁标志10。关键点锁标志位只有最后 2 个 bit靠这 2 个 bit 的 01/00/10 组合来区分无锁、轻量级、重量级偏向锁复用的是 01 这个组合再靠前面 1 个偏向标志位区分。这就是很多人看 Mark Word 会绕晕的原因——它是在复用 bit做状态机。锁升级的链路不是一上来就重早期的synchronized确实慢因为不管三七二十一直接上操作系统的互斥量mutex每次加锁都是一次用户态到内核态的切换。JDK 6 之后 HotSpot 引入了锁升级机制目的是能不惊动操作系统就别惊动它。升级路径是偏向锁第一个线程来加锁直接把线程 ID 记到 Mark Word 里以后这个线程再进来连 CAS 都不用做纯比对一下线程 ID 就放行。相当于这把锁认人了。轻量级锁第二个线程来竞争偏向锁被撤销两个线程开始用 CAS 自旋去抢一把锁记录谁抢到谁进临界区抢不到就自旋一会儿。重量级锁自旋超过一定次数或者竞争激烈到一定程度JVM 不再让你空转而是把竞争线程挂起交给操作系统的ObjectMonitor没抢到的线程进入BLOCKED状态排队。这里有个容易踩的坑偏向锁在 JDK 15 默认关闭JDK 17 直接移除。原因是多核时代下维护偏向本身的开销在大量线程竞争的场景里比它省下的那点 CAS 还贵。所以如果你现在用的是 JDK 17对象头里已经没有偏向锁这个状态了上来就是无锁 → 轻量级 → 重量级。从字节码看 synchronized 的边界synchronized对 JVM 来说是两个指令monitorenter和monitorexit。看下面这段代码编译后的表现public class Counter { private int count 0; // 方法级 synchronized锁对象是 this public synchronized void inc() { count; } // 代码块 synchronized锁对象是指定的 this public void incBlock() { synchronized (this) { count; } } }用javap -c -p Counter.class反编译incBlock方法的字节码大概长这样public void incBlock(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter // ① 进入监视器拿到 this 的锁 4: aload_0 5: dup 6: getfield #2 // 读 count 9: iconst_1 10: iadd 11: putfield #2 // count 14: aload_1 15: monitorexit // ② 正常路径释放锁 16: goto 24 19: astore_2 20: aload_1 21: monitorexit // ③ 异常路径也要释放锁 22: aload_2 23: athrow 24: return逐行解释第 3 行monitorenter是加锁入口JVM 会去查this对象头的 Mark Word决定走偏向、轻量还是重量级路径。第 15 行monitorexit是正常返回前的释放。注意编译器在字节码层面保证无论方法怎么退出锁都要释放。第 19~23 行是一段异常保护astore_2把异常对象存起来再执行一次monitorexit释放锁最后athrow把异常抛出去。这解释了为什么synchronized不会像ReentrantLock那样需要你手写finally { unlock() }——JVM 在字节码层面替你把异常路径也兜住了。这也带来一个实战提醒方法级 synchronized 锁的是 this。如果你的类是个 Spring 单例那synchronized方法锁的就是这个全局单例等于把整个实例变成串行的这是非常隐蔽的性能坑。复现那次 QPS 暴跌回到开头的问题。我们用一个最小复现来还原高竞争下 synchronized 的代价public class ContentionDemo { private static final Object lock new Object(); private static long counter 0; public static void main(String[] args) throws InterruptedException { int threads 64; Thread[] ts new Thread[threads]; for (int i 0; i threads; i) { ts[i] new Thread(() - { for (int k 0; k 1_000_000; k) { synchronized (lock) { // 64 个线程抢同一把锁 counter; } } }); } long start System.nanoTime(); for (Thread t : ts) t.start(); for (Thread t : ts) t.join(); long ms (System.nanoTime() - start) / 1_000_000; System.out.println(counter counter 耗时 ms ms); } }逐行解释第 2 行private static final Object lock是一把全局共享的锁64 个线程都在抢它这就是高竞争场景。第 8 行起每个线程循环 100 万次每次都synchronized (lock)包住counter。counter本身只要几个纳秒但加锁的争用成本被放大了 6400 万倍。第 14 行t.join()等所有线程结束。你会观察到线程越多单线程平均拿锁的等待时间越长因为没抢到的线程在重量级锁下被挂起、唤醒上下文切换成本极高。你再抓一份jstack会看到大部分线程卡在BLOCKED (on object monitor)这正是我们网关当时压测的现场。我们当时的真实代码是这样的脱敏后// 网关限流每个统计窗口重置时大量请求线程同时自增同一个计数器 private final Object qpsLock new Object(); private volatile long windowQps 0; public void onRequest() { synchronized (qpsLock) { // 问题就在这所有请求线程挤一把锁 windowQps; } // ... 限流判断逻辑 }每次请求都进synchronized自增并发一高线程全堵在qpsLock上限流判断逻辑被拖住QPS 自然崩了。改成LongAdder之后内部用分段 Cell CAS避免一把锁private final LongAdder qpsAdder new LongAdder(); public void onRequest() { qpsAdder.increment(); // 无锁高并发下靠分片把竞争打散 // ... 限流判断逻辑读取时用 qpsAdder.sum() }压测立刻回到 8 万 QPSCPU 也下来了。这事儿给我的教训很具体凡是每个请求都自增/自减一个共享计数器的地方先别急着用 synchronizedLongAdder / AtomicLong 往往更合适。我的取舍判断我不建议把synchronized一棍子打死也不建议无脑用。我的判断是低竞争、代码块小、不需要超时/可中断/公平锁/条件变量用synchronized最省心。JVM 能对它做锁消除escape analysis 发现锁对象没逃出方法就直接去掉和锁粗化连续多次加锁合并成一次这是ReentrantLock享受不到的编译器级优化。高竞争、或者需要tryLock(timeout)、可中断、多条件变量Condition上ReentrantLock。它把抢不到锁怎么办的选择权交还给你而不是像synchronized那样要么阻塞要么自旋。纯计数场景直接LongAdder/AtomicLong别碰锁。另外提醒一句偏向锁在 JDK 17 已经没了所以synchronized 因为偏向锁很快这个老黄历在新项目里不成立。评估性能时请以你实际运行的 JDK 版本为准。总结synchronized的慢曾经是真的但 JDK 6 之后的锁升级让它脱胎换骨无锁 → 轻量级CAS 自旋→ 重量级操作系统互斥这条链路本质是能用用户态解决就绝不下沉内核态。真正让它崩的是我们把一把全局锁用在了高频竞争的共享状态上——比如给每个请求都加的自增计数器。定位这类问题最快的办法就是jstack抓BLOCKED (on object monitor)再决定是换无锁结构还是换ReentrantLock。思考题你现在的项目里有没有每个请求都 synchronized 一个共享变量的代码把它换成LongAdder跑一遍压测看看 QPS 和 CPU 是不是有惊喜。如果方便把前后对比数据发在评论区我们一起看哪些场景适合 LongAdder、哪些还是该用锁。
返回列表