结论先行:「火焰图里
pthread_mutex_lock飙到 9%」只能证明有用户在等锁,证明不了等的是哪把锁。堆外内存毛刺的三个高频嫌疑人——NettyPooledByteBufAllocator的 arena 锁、JIT/GC 屏障(G1BarrierSet::block)、以及容器 CFS 节流的跨周期睡眠——在火焰图里的签名高度重叠。本文给一套「三件套」:perf lock record定锁的身份、NMT 差分定行为、stall 时长分布定节律,按三步走能把三个嫌疑人在数据上彻底切开。
一、现象:GC 全绿、CPU 没满,P99 却每 20~40 分钟抖一次
先交代规模和数字:
- 服务:实时行情推送,Spring Boot 3.1 + JDK 17.0.10(G1,
-Xmx6g -XX:MaxGCPauseMillis=50 -XX:MaxDirectMemorySize=2g),K8s 1.28 / cgroup v2(cpu.max = "800000 100000",即 8 核带宽),QPS 约 8k,主链路 WebSocket 推送 + Netty 4.1 编解码; - 抖动模式:每天 14:00~15:30 行情高峰,每 20~40 分钟一次,单次 50~120ms;其余时段 P99 稳定在 25ms 内,P50 长期 12ms;
- 「排除法」已做:
jstat -gcutil显示抖动期间 YGC 频率没变(约 1 次/30s)、单次 Young GC < 5ms、Old 区水位 48% 纹丝不动;HUMONGOUS 分配记录每天 < 10 次(-XX:+TraceG1HUMONGOUS_ALLOC);numactl --interleave=all绑核 + 关 THP 后毛刺几乎无变化。
GC 这一票可以投了。剩下的问题只有一个:50~120ms 的 stall 到底花在哪里? 火焰图(perf record -g -p <pid> --call-graph dwarf)显示 pthread_mutex_lock / __lll_lock_wait 占比从 1.2% 飙到 9%+——但 9% 的锁等待可能是 arena 锁,也可能是 G1BarrierSet::block,也可能是 os::allocate_commit 里 mmap 的内核态竞争。单看火焰图,三者长一个样。
二、原理:三个嫌疑人的「作案签名」各不相同
嫌疑人 A:Netty arena 锁。 PooledByteBufAllocator 按 16MB chunk 切分堆外内存,每个 arena 管自己的 chunk 列表(默认 numDirectArenas = 2 × CPU = 16)。分配走 ThreadLocal 快路径时确实不碰锁,但快路径 miss(chunk 池空、order 超限、arena 被 rebind)时会落到 Arena.allocate 上的 synchronized/mutex——锁的是 arena 自己的分配链表。特征:锁对象地址集中在少数几个 arena 实例,且伴随 chunk 的 mmap 分配。
嫌疑人 B:JIT/GC 屏障竞争。 G1BarrierSet::block 本质是 futex 等待 GC 线程完成卡表操作,JIT 侧的 __lll_lock_wait 可能来自 monitor 膨胀或 safepoint 同步。特征:没有 NMT 增量(不分配新内存),且 stall 与 GC 日志里的 Pause Young/safepoint 时间戳能对上。
嫌疑人 C:CFS 节流。 cgroup v2 的 cpu.max 是带宽预算不是算力保障——配额耗尽整个 cgroup 被强制 sleep 到下个 100ms 周期。特征:stall 时长呈 100ms 的整数倍聚集,且 cpu.stat 的 nr_throttled / throttled_usec 同步上涨。
三者的关键区别:A 有 NMT 增量 + 用户态 mutex;B 无 NMT 增量 + 能对齐 GC/safepoint 日志;C 有 100ms 节律 + cpu.stat 计数。这就是切开它们的抓手。
三、具体做法:三件套 + 判定阈值
第一步:perf lock 定锁身份(30 秒内判定是不是 arena 锁)
# 抖动窗口内抓 20s(提前挂好,抖动来了自动有数据)
perf lock record -a -g -- sleep 20
perf lock report --sort lock,caller --percent-limit 1
看两点:① 锁地址——如果 top 锁是同一把地址反复出现,且 caller 栈里出现 PooledByteBufAllocator / Arena / DirectByteBuffer.<init>,直接锁定嫌疑人 A;② 锁地址是散乱的、caller 是 os::park / G1CollectedHeap 系,则是嫌疑人 B。我这次抓到的 top 锁 caller 是 PooledByteBufAllocator.newDirectBuffer → Arena.allocate,且 perf lock report 显示该锁 contention 平均等待 18.4ms / 次——一把锁平均 18ms,这已经不是「争用」,是「排队」。
第二步:NMT 差分定行为(证明是不是在反复 mmap 新 chunk)
# 需 -XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory baseline
# ——等一轮抖动(20~40min)——
jcmd <pid> VM.native_memory summary.diff
我这次 diff 里 Internal +34MB、Other +21MB——16MB 一个 chunk,34MB 正好对应 2~3 个新 chunk 的 mmap。这坐实了「arena 池耗尽 → 走慢路径 → 慢路径上锁 + mmap」的完整链条。注意:这个 diff 要在两次抖动之间取,取早了增量会被 arena 复用吃掉,看着像没分配。
第三步:stall 时长分布定节律(排除 CFS)
cat /sys/fs/cgroup/<pod-cgroup>/cpu.stat
# 关注 nr_throttled 和 throttled_usec 在抖动窗口的增量
我这次 nr_throttled 在抖动窗口内 0 增量,且 stall 时长是 50/72/85ms 的连续分布、不聚集在 100ms 整数倍——嫌疑人 C 排除。反例:如果 stall 全部落在 100~210ms 区间且 throttled_usec 同步涨,那就是 CFS,该去抬 cpu.max 而不是调 arena。
判定阈值(我的经验值,供校准):perf lock report 单锁平均等待 >5ms/次 且占比 >3%,即可认定用户态锁是主因;<2ms/次 则多半是 JIT/内核路径,别往分配器上使劲。
修复路径(按性价比排序):
- 启动预热:发布后、接流量前,用 32KB 的 dummy buffer 循环分配-释放 2 万轮,把所有 arena 的 chunk 池推到稳态。我们预热后 14:30 那次毛刺从 85ms 降到 21ms——因为稳态后分配全走 ThreadLocal 快路径,根本碰不到锁;
numDirectArenas默认 16 起步,不要拍脑袋往上加(见下);- 高频小 buffer 路径评估
-Dio.netty.allocator.type=unpooled(少锁但多系统调用,需 A/B); - 大消息路径用
CompositeByteBuf拼,避免中间态 copy 触发额外分配。
四、踩过的坑与反直觉
numDirectArenas加到 32 有效,加到 64 反而更糟:32 时毛刺 85→40ms,64 时回到 95ms。直觉上 arena 越多锁越不热,但 arena 越多、每个 arena 的 chunk 池越「凉」,回落到 mmap 慢路径的概率越高——锁竞争和 mmap 竞争是跷跷板,不是单变量。arena 数 ≈ 2×CPU 是经验最优点,再往上是在用 mmap 频率换锁等待,两头亏。MaxDirectMemorySize从 2g 抬到 4g,毛刺只从 120ms 降到 70ms:容量不是瓶颈时,加容量只是让「撞墙」变晚,慢路径本身没变。看到幅度下降就收工,是最容易犯的错。perf lock不加-g会漏掉 caller 链:没有调用栈你只能看到「某把锁热」,不知道是谁在抢——对 Netty 这种池化分配器,caller 链是唯一能把「arena 锁」和「其他锁」区分开的字段。- NMT 是采样语义:
summary粒度下小对象分配可能不可见,但它对 16MB 的 chunk 分配足够精确——用错粒度反而会把结论带偏。
五、结论与可复用清单
堆外内存毛刺的定位,本质是给「锁等待」这个模糊信号配上身份、行为、节律三个维度的指纹:
- 身份:
perf lock record -g+report --sort lock,caller——锁地址 + caller 链,30 秒定嫌疑人; - 行为:
jcmd VM.native_memory baseline / summary.diff——NMT 增量对应 chunk 数(16MB/个),证明慢路径被触发; - 节律:
cpu.stat的nr_throttled/throttled_usec+ stall 时长分布——100ms 整数倍聚集 = CFS,连续分布 = 锁/mmap; - 阈值:单锁平均等待 >5ms/次 + 占比 >3% 认定用户态锁主因;
- 修复排序:预热 > 调 arena(2×CPU 附近)> 换分配策略(unpooled/Composite);
- 反模式:加
MaxDirectMemorySize当安慰剂、arena 数无脑翻倍、看火焰图占比就下结论。
一句话:火焰图告诉你「有锁」,perf lock 告诉你「是哪把锁」,NMT 告诉你「锁背后在发生什么」,cpu.stat 告诉你「是不是根本不该往锁上想」。 四步走完,9% 的 pthread_mutex_lock 就不再是一句玄学。