你的代码一行没改,QPS 却从 800 掉到 200:Java 锁膨胀的四个阶段,以及那个不可逆的瞬间

2026-11-10 13 0

同一份代码,同一台机器,同样的流量。上游接口从 12ms 涨到 40ms 时,我们那个用了一年半没动过的 synchronized 方法,QPS 从 800 掉到 200。那段代码里,除了一个 if 分支,什么都没改。

排查到凌晨,原因不是"业务变复杂了",而是那个 synchronized 块里不小心调了一个可能阻塞的远程调用。锁膨胀了,monitor 进到内核态,后面的线程排进 AQS 队列睡觉。这事儿像常识,但没看过 Mark Word,你不知道"膨胀"有多贵。

这篇不讲"如何学习 Java 并发",直接掀开锁的头皮:它到底是怎么长在你对象头上的。


一、你以为的锁,其实是一段内存

Java 里最反直觉的一点:synchronized 不是一把"锁对象",它就是被锁对象本身:

public void transfer(Account from, Account to) {
    synchronized (from) {
        from.debit(100);
        to.credit(100);
    }
}

加锁的是 from 这个对象本身,锁信息存在它的对象头里。JVM 把对象头拆成两段(64 位 HotSpot、压缩指针开启时):Mark Word(64 位,塞着哈希码、GC 年龄、以及锁状态)和 Klass Pointer(指向类元数据)。

Mark Word 是块"公共停车位":没被锁住时放 GC 年龄和哈希码;一旦有人 monitorenter,这块 64 位就被"霸占",改写成锁状态编码。所以"加锁"在 JVM 里其实是"改写对象头的一个 bit 位"。这操作快到只有几纳秒——直到它不成立。


二、四个阶段:一把锁的四张面孔

HotSpot 的锁有四个状态,Mark Word 随竞争强度单向升级(只升不降,全文最关键的一句):

  1. 无锁:Mark Word 记录哈希码和 GC 年龄,对象就是个普通对象。
  2. 偏向锁:只有一个线程访问过,JVM 在 Mark Word 里记下它,同线程再进入连 CAS 都不用。JDK 15 起默认禁用,JDK 18 彻底移除。
  3. 轻量级锁:有竞争了。在线程栈上开空间放锁记录,用一次 CAS 把栈上记录和 Mark Word 换过来,谁抢到谁进临界区。
  4. 重量级锁:线程一多,CAS 反复失败还自旋烧 CPU,JVM 干脆申请一把 OS 互斥量(monitor),把线程挂起进内核排队。

膨胀的本质是:从"用户态自旋 + CAS"退化成"内核态阻塞 + 唤醒"。前者纳秒级,后者动辄微秒到毫秒,中间差着一到两个数量级。


三、致命之处:升级是不可逆的

private final Object lock = new Object();

public void handle(Request req) {
    synchronized (lock) {
        cache.put(req.getKey(), req.getValue());   // 偶尔很慢
    }
    synchronized (lock) {                          // 第二次加锁
        metrics.record(req);
    }
}

看起来是两次独立加锁。第一次进来时没竞争,还是轻量级锁;但只要有一次并发把锁撑到重量级,这个 lock 对象余生都保持重量级。

这解释了一个经典现场:"压测好好的,跑半小时突然变慢"。膨胀只需一次,偏偏发生在最不巧的那次。而且膨胀是按对象的:同一对象上一百个方法,加锁的只有一个,膨胀的却是整个对象。第一原则因此是:让锁对象尽可能私有、尽可能短命。别用全局 lock 保护整个类的方法。


四、别再手搓 ConcurrentHashMap 的分段锁了

JDK 7 用 Segment 分段锁,16 段各一把锁,吞吐上来了,但数量写死、扩容复杂。JDK 8 推翻重写:

// JDK 8 的核心思路
final V putVal(K key, V value, boolean onlyIfAbsent) {
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i;
        if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 空桶:CAS 插入,无需加锁
            if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
                break;
        } else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);           // 扩容中,帮着搬
        else if (...) {
            synchronized (f) {                    // 桶头加锁
                // double check 后写入
            }
        }
    }
}

三个关键设计:空桶走 CAS 不加锁,绝大多数写入不需要锁;桶头加锁,粒度从 16 段缩到单个桶;桶内链表超 8 且表长不小于 64 时转红黑树,防止哈希碰撞把查找退化成 O(n)——HashMap 被"碰撞 DoS"这事在真实世界里真发生过。

有意思的是,JDK 8 用的还是 synchronized 而不是 ReentrantLock:JVM 能对它做锁消除、锁粗化,也不会因为忘了 unlock 泄漏线程。能用语言原语就别自己造轮子。


五、AQS:所有 Lock 的骨架

理解了 AQS,前面那些锁就串起来了。ReentrantLock、Semaphore、CountDownLatch 全基于同一个 AbstractQueuedSynchronizer:

// 1. 一个 volatile int state 表示同步状态
//    ReentrantLock:  0=未加锁, n=重入 n 次
//    Semaphore:      剩余许可数
//    CountDownLatch: 剩余计数
// 2. 一个 CLH 变体双向队列,节点是等待的线程
// 3. 三步模板
protected boolean tryAcquire(int arg)      { ... }  // 尝试获取
protected boolean tryRelease(int arg)      { ... }  // 尝试释放
protected void tryAcquireShared(int arg)   { ... }  // 共享模式

acquire() 的流程:先 tryAcquire,成功就完事(这就是非公平锁——锁刚释放的瞬间,排队的老哥不一定抢得到,新来的反而可能插队);失败则包装成 Node 入队,在 acquireQueued 里反复尝试,抢不到就 park 挂起,被唤醒再试。全程用 LockSupport.park/unpark,比 wait/notify 那个"必须配对"的坑少得多。

公平锁就是在第一步加了个 hasQueuedPredecessors() 判断,有人在排队你就老实去排。非公平锁不加——那一下"插队"往往能让刚释放锁的 CPU 缓存保持热。公平锁是个道德模范,不是性能冠军。大多数业务场景下,非公平锁才是正确选择。


六、线上出问题,怎么查

先分清:BLOCKED 在等 monitor(即 synchronized),WAITING 在等 Condition。排查第一步永远是 jstack:

jstack -l <pid> > /tmp/jstack.log

# 看 BLOCKED 的线程卡在哪个方法的 monitor
grep -A 20 "BLOCKED" /tmp/jstack.log | head -60

# 更直接的:jcmd 打印线程信息
jcmd <pid> Thread.print -l

# 找死锁:jstack 自己会报
Found one Java-level deadlock:
...

第二步,怀疑膨胀就用 jol 看对象头锁状态位——重量级锁是 0x02,被 hash code 覆盖就是彻底膨胀。第三步最容易漏:去代码里找"膨胀的那一刻"。膨胀需要并发,只在峰值流量下才触发。凌晨三点没人的时候,你的服务健康得跟个 baby 一样。


七、几个能立刻用的结论

1. 锁对象私有化。能用方法内的局部对象就别用成员变量,局部对象每次都是新的,天然不共享。

2. 临界区里不做可能阻塞的事。不查库、不调远程、不做文件 IO、不同步打日志。这些会把一次 CPU 级操作变成 IO 级等待,直接把你推上膨胀之路。

3. 读多写少就别用 synchronized。ReentrantReadWriteLock、StampedLock 的乐观读,或不可变对象 + ConcurrentHashMap。注意乐观读可能读到撕裂数据,你得能接受这种不一致。

final StampedLock sl = new StampedLock();

double sum() {
    long stamp = sl.tryOptimisticRead();      // 先拿"版本号"
    double cur = this.value;
    if (!sl.validate(stamp)) {                 // 校验期间是否被写
        stamp = sl.readLock();                 // 失败就退化成读锁
        try { cur = this.value; } finally { sl.unlockRead(stamp); }
    }
    return cur;
}

4. 学会用 jstack,不是 top。CPU 高用 top,锁问题用 jstack,别混。

5. 别迷信"无锁"。CAS 失败会自旋烧 CPU,高竞争下未必比锁好,也没有重入语义。


八、最后

回头看那次事故,我最后改的代码是:把 synchronized 块里的远程调用挪到锁外,用局部变量接住结果再进锁写回。改动很小,延迟从 40ms 回到 9ms。

这就是并发编程最让人又爱又恨的地方:你写的每一行代码都在和一个 64 位的整数位较劲,而这个位被四种状态共享着,还会单向退化。

知识分子的浪漫是"锁是操作系统提供的原语",工程师的浪漫是终于搞清楚了自己对象头里那 64 位到底写了什么。

祝你的锁,永远不要膨胀。

相关文章

「数据过千万就得分表」——这句话害了多少人?分库分表的四个坑与正解
你的订单在 A 库,库存卡在 B 库:分布式事务的四份解药,没有一个是银弹
缓存明明命中了,为什么接口还是慢?—— HTTP 缓存里的那点破事
监控全绿,用户却喊卡:你的平均值一直在撒谎
你的服务被刷爆,可能不是流量大:限流器写错的 7 个姿势
NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷

发布评论