从粗放 JVM 运维 CMS 大促高峰 Full GC 十几秒 STW 把交易服务冻死大面积超时 + 默认参数裸跑从不按负载调堆和分代 + 静态 Map 缓存无上限只进不出缓慢漏到 OOM 周期性崩溃重启 + 性能问题靠几个人围一起凭印象猜哪段慢挑可疑处改改上线试盲人摸象白忙几天 + 热点路径循环里反复 new 加字符串 + 拼接加装箱海量临时对象涌入新生代 Young GC 频繁晋升加重 Full GC + 线程池核心数最大数队列长全靠拍脑袋甚至用 Executors 无界队列任务积压到 OOM + 共享数据无脑 synchronized 锁整个方法粒度极粗高并发大批线程挤一把锁排队吞吐被锁死 + 堆外 DirectByteBuffer 泄漏堆内监控一切正常进程却被容器 OOM Killer 反复杀查大半天没头绪 + 不懂 JIT 新实例冷启动全解释执行刚接流量就大量超时误判成网络问题 + 线上 JVM 黑盒平时不看出事才 SSH 上去 jmap 导几十G大堆把垂危服务彻底压垮 → 2026 现代 JVM 性能工程 G1/ZGC 低延迟回收设停顿目标 + 按负载调堆 Xms=Xmx 分代 + HeapDumpOnOutOfMemoryError 加 MAT 引用链定位泄漏加 Caffeine 缓存上限 + JFR 加 async-profiler 火焰图数据驱动定位热点 + StringBuilder 加预分配容量加避免装箱加逃逸分析减分配 + ThreadPoolExecutor 精细化有界队列加拒绝策略加命名 + ConcurrentHashMap 加 LongAdder 替代重锁 + NMT 原生内存追踪加 MaxDirectMemorySize 治堆外 + 分层编译加预热 warm-up 解决冷启动 + JFR 持续黑匣子加 Micrometer 加 Prometheus/Grafana 可观测 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

9 人的 Java 平台与性能工程团队 87 天把一套跑了六年、日交易量从每天几百万笔涨到几亿笔后 JVM 性能问题集中爆雷的核心交易系统——大促高峰期老问题 CMS 因老年代碎片触发 Full GC 一停就是十几秒 STW 把整个交易服务冻死导致大面积超时、JVM 参数基本是默认裸跑从来没有按照服务的负载特征去认真调过堆大小和分代比例、一个静态 Map 当缓存用却没设任何上限只进不出运行十几天就缓慢泄漏到 OOM 周期性崩溃重启、遇到性能问题就是几个人围在一起凭着对代码的印象猜哪段慢挑个看着可疑的地方改改上线看看这种盲人摸象式优化经常白忙好几天还优化错地方、热点路径上循环里反复 new 对象用 + 号拼字符串加自动装箱产生海量临时对象疯狂涌入新生代逼得 Young GC 极其频繁还把存活对象一批批晋升到老年代加重 Full GC、线程池的核心线程数最大线程数队列长度全靠拍脑袋填甚至直接用 Executors 工厂方法背后的无界队列让任务积压到 OOM、并发控制无脑用 synchronized 锁住整个方法粒度极粗高并发下大批线程全挤在同一把锁上排队干等吞吐量被死死锁住、堆外 DirectByteBuffer 泄漏导致堆内存监控一切正常进程却被容器的 OOM Killer 反复杀掉查了大半天毫无头绪、完全不懂 JIT 导致新扩容的实例冷启动时全在解释执行刚接流量就大量超时还被误判成网络问题白白排查、线上 JVM 平时是个完全的黑盒不出事不看一出事才慌忙 SSH 上去 jmap 导出几十 G 的大堆结果这个重量级操作直接把本已垂危的服务彻底压垮——系统性地重构成 2026 年现代 JVM 性能工程体系:用 G1 设停顿目标和 ZGC 把高峰停顿从十几秒压到毫秒级、按服务负载把堆设成 Xms=Xmx 并调好分代、开 HeapDumpOnOutOfMemoryError 自动抓堆用 MAT 沿引用链定位泄漏再用 Caffeine 给所有缓存设上限、性能问题一律先用 JFR 加 async-profiler 火焰图拿数据定位热点再动手、热点路径用 StringBuilder 加预分配容量加避免装箱加写逃逸分析友好的代码从源头减分配、线程池一律用 ThreadPoolExecutor 显式精细化配置加有界队列加拒绝策略加命名、并发优先用 ConcurrentHashMap 加 LongAdder 等无锁低竞争方案替代粗暴的 synchronized、开 NMT 原生内存追踪加 MaxDirectMemorySize 把失控的堆外内存管起来、理解分层编译加给新实例上线做预热解决冷启动慢、用 JFR 常态化做持续黑匣子加 Micrometer 加 Prometheus/Grafana 把黑盒变玻璃箱,高峰再没因为 Full GC 卡死过、半夜不再被 OOM 告警叫醒、同样的机器扛住了几倍的交易量、性能问题分钟级就能定位根因,沉淀 47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学。

我们是一支 9 人的 Java 平台与性能工程团队,维护着一套用了六年、跑在 JVM 上支撑公司核心交易的 Java 服务集群。JVM 是一台精密而强大的机器——它有自动垃圾回收、有即时编译、有成熟的内存模型,可正是这份"自动",让我们早年写下了大量看似没问题、实则在性能上千疮百孔的代码和配置,随着业务量从每天几百万笔涨到每天几亿笔,这些粗放做法集中爆雷:JVM 几乎是用默认参数裸跑的、堆大小、GC 算法、各种关键参数全凭出厂默认、从没根据实际负载调过;用的还是老旧的 CMS 垃圾回收器、一到业务高峰就触发漫长的 Full GC、动辄几秒甚至十几秒的 STW 停顿、整个服务在那期间完全卡死、交易大面积超时;有几处缓慢的内存泄漏、堆内存像漏水的桶一样缓慢但持续地上涨、Full GC 也救不回来、最终周期性地 OOM 崩溃重启、而我们却始终定位不到到底是哪里在泄漏;大量使用 Netty 和 DirectByteBuffer 的堆外内存、却完全没有监控、堆外内存悄悄失控涨上去、明明堆内看着很正常进程却被系统 OOM Killer 杀掉;线程池的核心数、最大数、队列长度全是拍脑袋随便填的、要么线程太少高峰期请求大量排队、要么线程爆炸把内存和调度拖垮;热点路径上大量用 synchronized 加重量级锁、高并发下锁竞争激烈、大批线程阻塞在锁上干等、吞吐被锁死;完全不了解 JIT 即时编译的工作方式、服务冷启动时所有代码都在慢速解释执行、刚上线那几分钟性能极差、却以为是别的问题;线上 JVM 就是个黑盒、出了性能问题只能登上服务器手忙脚乱地 jstack 抓线程、jmap 导堆、还经常因为 jmap 导堆把本就垂危的服务彻底压垮;每一次性能故障复盘、根因都指向同一句话:不是 JVM 不行、而是我们把"JVM 会自动帮我管好一切"误当成了"我可以完全不用懂 JVM"。

我们花了 87 天,把这套粗放的 JVM 调优与诊断体系,系统性地重构成了 2026 年的现代 JVM 性能工程体系。这不是简单地加内存换机器,而是一次从"默认参数裸跑、CMS Full GC 长停顿、内存泄漏定位不到 OOM、堆外失控被系统杀、线程池拍脑袋、synchronized 重锁竞争、不懂 JIT 冷启动慢、热点盲目分配、线上黑盒靠 jstack/jmap 手撸压垮服务"到"按负载调优 JVM 参数、G1/ZGC 低延迟分代 GC、堆转储 + MAT 定位泄漏引用链、NMT 追踪堆外内存、精细化线程池、并发容器减少锁竞争、理解 JIT 分层编译与预热、减少分配与逃逸、JFR + async-profiler 火焰图 + Micrometer 持续可观测"的范式跃迁。下面这张表,是我们这次 JVM 性能现代化战役里十个关键战场的"重构前 → 重构后"全景对比。

维度 重构前(粗放 JVM) 重构后(现代 JVM 性能工程)
GC 算法 老旧 CMS,高峰 Full GC 长 STW 卡死 G1/ZGC 低延迟分代,停顿毫秒级
JVM 参数 默认出厂参数裸跑,从不按负载调 按负载调堆大小/分代/GC 参数
内存泄漏 堆缓慢上涨 OOM,定位不到泄漏点 堆转储 + MAT 分析 GC roots 引用链
性能剖析 哪里慢全靠猜,盲目优化 JFR + async-profiler 火焰图定位热点
对象分配 热点频繁分配大对象,GC 压力大 减少分配 + 逃逸分析 + 复用
线程与锁 线程池拍脑袋,synchronized 重锁竞争 精细化线程池 + 并发容器减锁
堆外内存 DirectBuffer 失控,堆内正常却被杀 NMT 原生内存追踪 + 监控
JIT 编译 不懂 JIT,冷启动全解释执行极慢 分层编译 + 预热,理解编译日志
可观测 线上黑盒,jmap 导堆压垮服务 JFR 持续剖析 + Micrometer metrics
故障定位 登服务器手忙脚乱手撸命令 持续剖析数据 + 监控告警分钟定位

这套体系不是一蹴而就的,而是 9 个人在 87 天里、在一套天天扛着几亿笔交易的线上服务上,一个参数一个参数地调、一次 Full GC 一次 Full GC 地啃、一处泄漏一处泄漏地定位,啃下来的。最终我们沉淀了 47 套工程修法、7 个 P0 事故复盘和 6 条工程哲学。下面从十个战场逐一复盘。

一、GC 选型:从 CMS Full GC 长停顿到 G1/ZGC 低延迟分代

第一仗,也是整场战役里收益最直接的一仗,就是把垃圾回收器从老旧的 CMS 换成现代的 G1 乃至 ZGC。古早时代我们用的是 CMS(Concurrent Mark Sweep)垃圾回收器,它在设计上虽然力图减少停顿、但有两个致命弱点:一是它不整理内存、长期运行后老年代会产生大量内存碎片、碎片累积到一定程度就会触发一次极其昂贵的、需要完全暂停所有应用线程(STW)的 Full GC 来做整理、这种 Full GC 在我们几十 G 的大堆上动辄要停顿几秒甚至十几秒、整个服务在这期间彻底冻结、交易大面积超时;二是 CMS 的并发回收一旦跟不上对象分配的速度就会退化成 Full GC,业务高峰恰恰是分配最快的时候、于是高峰必卡。现代做法是换用 G1(Garbage First)——它把堆划分成许多大小相等的 region、能够有预测性地、增量地回收垃圾、把停顿控制在一个可设定的目标内(比如 200ms),并且自带内存整理、不会有 CMS 那种碎片导致的灾难性 Full GC;而对停顿要求极致苛刻的服务、我们更进一步换用 ZGC——它是一款几乎全程并发的低延迟回收器、能把 STW 停顿稳定地压在几毫秒以内、且停顿时间几乎不随堆的增大而增长、哪怕几百 G 的大堆停顿依然是毫秒级。下面是 GC 选型的对比:

# 重构前:老旧 CMS,大堆上 Full GC 长 STW 停顿几秒到十几秒,高峰服务卡死
# java -Xms32g -Xmx32g \
#   -XX:+UseConcMarkSweepGC \      # CMS:不整理内存,碎片累积触发昂贵 Full GC
#   -XX:+UseParNewGC \             # 高峰并发回收跟不上分配速度就退化成 Full GC
#   -jar app.jar                   # 几十G大堆 Full GC 一停就是几秒十几秒,交易超时

# 重构后:G1 可预测增量回收 + 停顿目标,大堆也把停顿控制在毫秒到百毫秒级
java -Xms32g -Xmx32g \
  -XX:+UseG1GC \                   # G1:region 化堆,增量回收,自带整理无碎片 Full GC
  -XX:MaxGCPauseMillis=100 \       # 设定停顿目标,G1 努力把每次停顿控制在 100ms 内
  -XX:InitiatingHeapOccupancyPercent=45 \  # 老年代占比 45% 就启动并发标记,避免来不及
  -jar app.jar

# 对停顿极致苛刻的服务用 ZGC:几乎全程并发,STW 稳定在几毫秒,且与堆大小无关
java -Xms64g -Xmx64g \
  -XX:+UseZGC \                    # ZGC:亚毫秒级停顿,几百G大堆停顿依然是毫秒级
  -XX:+ZGenerational \             # 分代 ZGC(JDK 21+),进一步提升吞吐
  -jar app.jar
# ↑ 从 CMS 几秒级 Full GC 卡死,到 G1 百毫秒、ZGC 毫秒级停顿,高峰不再卡顿超时

GC 选型让我们从"用老旧的 CMS 垃圾回收器它有两个致命弱点:一是不整理内存长期运行后老年代产生大量碎片累积到一定程度触发极其昂贵的需要完全 STW 的 Full GC 在几十G大堆上动辄停顿几秒甚至十几秒整个服务彻底冻结交易大面积超时、二是 CMS 并发回收一旦跟不上对象分配速度就退化成 Full GC 而业务高峰恰恰分配最快于是高峰必卡"进化到了"换用 G1 它把堆划分成许多大小相等的 region 能有预测性地增量地回收垃圾把停顿控制在可设定目标内并自带内存整理不会有碎片导致的灾难性 Full GC、对停顿要求极致苛刻的服务更进一步换用 ZGC 它几乎全程并发能把 STW 停顿稳定压在几毫秒以内且停顿时间几乎不随堆增大而增长":过去我们的 JVM 一直在用 CMS 这款在它那个年代算是先进、但放到今天已经明显落伍的垃圾回收器,而 CMS 身上背着两个会要命的结构性缺陷:第一个缺陷是它采用的是"标记-清除"算法、回收完垃圾之后并不会把存活的对象整理压缩到一起、于是随着服务长时间运行、老年代里被回收的空间和存活对象交错分布、逐渐产生大量无法被利用的内存碎片、当这些碎片累积到没有足够大的连续空间来容纳新晋升上来的对象时、CMS 就不得不退而求其次、触发一次彻头彻尾的、会完全暂停所有应用线程的 Full GC 来对整个老年代做一次彻底的整理压缩,而这种 Full GC 在我们动辄几十 GB 的大堆上、其停顿时间动辄就是好几秒、严重时甚至十几秒、在这漫长的几秒十几秒里整个 Java 服务的所有线程全部被冻结、什么都干不了、所有正在处理的交易请求统统超时失败;第二个缺陷是 CMS 的核心卖点——它的垃圾回收主要阶段是和应用线程并发进行的、本意是不停顿、可这个并发回收是有速度的、一旦应用分配新对象的速度超过了 CMS 并发回收垃圾的速度、CMS 就会因为来不及回收而被迫降级、同样退化成一次 STW 的 Full GC、而最讽刺的是、应用分配对象最快的时刻、恰恰就是业务量最大的高峰期、于是就形成了"越是高峰、越要卡顿"的恶性局面;现在我们果断地把垃圾回收器升级到了现代的方案:对于绝大多数服务、我们换用了 G1 垃圾回收器——它彻底革新了堆的组织方式、把整个堆划分成了许许多多个大小相等的小区域(region)、回收时不再是要么不收要么全收、而是可以有选择地、增量地优先回收那些垃圾最多、收益最高的 region(这正是 Garbage First 名字的由来)、从而能够把每一次回收的停顿时间都精细地控制在我们通过 MaxGCPauseMillis 参数设定的目标范围之内(比如 100ms)、而且 G1 在回收的过程中本身就会做内存整理、从根上杜绝了 CMS 那种因碎片累积而触发灾难性 Full GC 的问题;而对于那些对延迟有着极致苛刻要求的核心服务、我们更进了一步、换用了 ZGC 这款专为超低延迟而生的回收器——它的垃圾回收工作几乎是全程与应用线程并发进行的、需要 STW 的阶段被压缩到了极致、能够把停顿时间稳定地控制在几毫秒以内、更关键的是 ZGC 的停顿时间几乎不随堆的大小而增长、这意味着哪怕我们用到几百 GB 的超大堆、它的停顿依然能保持在毫秒级、彻底告别了"堆越大、停顿越长"的魔咒。我们的纪律是"绝不再用 CMS 这类已被淘汰的回收器、大堆通用场景用 G1 并设合理的 MaxGCPauseMillis 停顿目标、对延迟极致苛刻的服务用 ZGC(JDK 21+ 用分代 ZGC)、GC 参数必须配合 GC 日志和实际停顿数据反复调而非照抄、关注的核心指标是停顿时间和停顿频率而非单纯吞吐、任何 GC 调整都要在压测和灰度下验证停顿是否真的改善"。GC 选型的本质认知是:垃圾回收器的选择、本质上是在"吞吐量"和"停顿时间"这两个目标之间做权衡——没有任何一款 GC 能同时把两者做到极致、CMS 是上个时代在这个权衡上的产物、而 G1 和 ZGC 则代表了现代 GC 在"用可控的、可预测的、极短的停顿换取服务平稳响应"这个方向上的巨大进步;选对 GC 的智慧、在于认清自己服务真正的痛点是什么——如果服务的命脉是低延迟、是绝不能容忍长停顿、那么就该毫不犹豫地拥抱 G1 乃至 ZGC 这种把停顿控制力放在第一位的回收器、用一点点吞吐的代价、换来高峰期再也不会因为一次 Full GC 而整个卡死几秒钟的稳定,这是 JVM 调优里投入产出比最高、也最该最先打的一仗——一次正确的 GC 选型、往往比加几倍机器更能解决高峰期的卡顿超时。

二、堆内存与泄漏定位:从默认裸跑 OOM 到堆转储 + MAT 引用链

第二仗,是把堆内存从"默认裸跑、泄漏了也找不到"扭转成"按负载配置、泄漏了能精准定位"。古早时代我们的 JVM 堆几乎是用默认参数裸跑的——堆的初始大小和最大大小、新生代和老年代的比例、统统没有根据服务的实际负载和对象生命周期特征去调整过,结果要么堆设小了导致 GC 过于频繁、要么新生代设得不合理导致对象过早晋升到老年代加重 Full GC;更要命的是我们有几处缓慢的内存泄漏——某些对象本该被回收却因为还被某个长生命周期的容器(比如一个静态的 Map 缓存、一个没移除的监听器列表)持有着引用而一直无法释放、于是堆内存像一只底部漏水的桶一样、只升不降地缓慢上涨、连 Full GC 都救不回来、最终周期性地撞上堆上限触发 OutOfMemoryError 崩溃,而每次面对 OOM 我们却束手无策、根本不知道到底是哪个对象、被哪条引用链吊住了不放。现代做法是:第一,根据服务实际的负载、对象分配速率和存活特征、合理地配置堆大小和分代;第二,也是定位泄漏的关键——在 OOM 发生时自动 dump 出堆快照(配置 HeapDumpOnOutOfMemoryError),然后用 MAT(Memory Analyzer Tool)这类专业工具去分析这个堆转储、它能算出哪些对象占用了最多内存(Dominator Tree)、并能沿着 GC roots 把那条让对象无法被回收的引用链精确地揪出来、让泄漏的元凶无所遁形。下面是堆配置与泄漏定位的对比:

// 重构前:静态 Map 当缓存无上限,对象被它一直引用永不释放,堆缓慢上涨直到 OOM
// public class Cache {
//     static final Map<String, byte[]> CACHE = new HashMap<>(); // 静态,生命周期=进程
//     static void put(String k, byte[] v) { CACHE.put(k, v); }    // 只进不出,无限膨胀
// }   // ↑ 放进去的 byte[] 被静态 Map 永久持有,GC 判定可达永不回收,堆漏到 OOM

// 重构后:配置 OOM 自动 dump 堆 + 用有上限可淘汰的缓存,并用 MAT 分析引用链定位泄漏
// JVM 参数:OOM 时自动转储堆快照,供 MAT 事后分析(关键!不留现场就无法定位)
//   -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdump.hprof
//   -Xms16g -Xmx16g                  // 初始=最大,避免运行时堆伸缩抖动
//   -XX:+UseG1GC -Xlog:gc*:file=/var/log/gc.log  // 开 GC 日志观察分代与晋升

public class Cache {
    // 用带容量上限和淘汰策略的缓存替代无限膨胀的静态 Map(如 Caffeine)
    private static final Cache<String, byte[]> CACHE = Caffeine.newBuilder()
        .maximumSize(10_000)            // 容量封顶,满了自动淘汰最久未用,绝不无限膨胀
        .expireAfterWrite(Duration.ofMinutes(10))  // 写入 10 分钟后过期,主动释放引用
        .build();
}
// 泄漏定位流程:拿到 heapdump.hprof → MAT 打开 → 看 Dominator Tree 找最大对象
//   → 对元凶对象 "Path to GC Roots" → 揪出那条让它无法被回收的引用链 → 定位代码

堆内存与泄漏定位让我们从"JVM 堆几乎用默认参数裸跑堆的初始和最大大小新生代老年代比例统统没根据服务实际负载和对象生命周期特征调整过、结果要么堆设小导致 GC 过于频繁要么新生代不合理导致对象过早晋升老年代加重 Full GC、更要命的是有几处缓慢内存泄漏某些对象本该回收却因还被某个长生命周期容器持有引用而一直无法释放堆内存像漏水的桶只升不降缓慢上涨连 Full GC 都救不回最终周期性 OOM 崩溃、而每次面对 OOM 束手无策根本不知道是哪个对象被哪条引用链吊住"进化到了"根据服务实际负载对象分配速率和存活特征合理配置堆大小和分代、在 OOM 发生时自动 dump 堆快照然后用 MAT 分析它能算出哪些对象占用最多内存并能沿 GC roots 把那条让对象无法回收的引用链精确揪出来":过去我们对 JVM 堆内存的态度是"开多大算多大、能跑就行",堆的各项关键参数全是默认值在裸跑——初始堆大小和最大堆大小没有设成相等(导致 JVM 运行时频繁地伸缩堆带来额外抖动)、堆的总大小也没有依据服务实际的内存需求来设定、新生代和老年代之间的比例更是从没调过,这种"听天由命"式的配置带来了一系列问题:堆如果给小了、对象很快填满、GC 就会非常频繁地触发、CPU 大量耗在回收上;新生代如果设得不合理、很多本该在新生代就被快速回收掉的短命对象、却因为新生代空间不足而被过早地晋升到了老年代、白白加重了本就昂贵的老年代回收(Full GC)的负担;而比配置不当更让我们头疼的、是潜伏的内存泄漏——在 Java 这种带垃圾回收的语言里、所谓内存泄漏并不是忘了释放、而是"某个对象逻辑上早就不需要了、却因为意外地还被某条从 GC roots 出发可达的引用链牵着、导致垃圾回收器判定它仍然存活、从而永远不会去回收它",我们的代码里就有这样的隐患:比如用一个静态的 Map 来做缓存、却从不给它设上限和淘汰、于是放进去的对象被这个与进程同生命周期的静态容器永久地持有着、即便业务上早已用不到了也永远无法被回收、缓存只进不出地无限膨胀,这类泄漏的表现就是堆内存呈现一条只升不降的缓慢上涨曲线、每一次 GC 都只能回收掉那一点点真正的垃圾、而泄漏的对象岿然不动、堆水位线一点点逼近上限、最终撞顶触发 OutOfMemoryError、进程崩溃重启、然后开始新一轮的缓慢上涨,而过去最让我们绝望的是、OOM 发生后我们手里什么有用的线索都没有、根本无从知晓究竟是哪类对象、因为什么原因、被哪一条引用链吊着不放;现在我们把堆内存管理和泄漏定位都做扎实了:在配置上、我们根据每个服务实际的负载压测数据、对象的分配速率和存活时间分布、来合理地设定堆的大小(并把初始堆和最大堆设成相等以消除堆伸缩抖动)、调整新生代的比例让短命对象尽量在新生代就被高效回收掉、避免无谓的过早晋升;而在泄漏定位这个老大难问题上、我们建立起了一套可靠的流程——首先、给所有服务的 JVM 都加上了 HeapDumpOnOutOfMemoryError 参数、这样一旦真的发生了 OOM、JVM 会在崩溃之前自动地把当时整个堆的完整快照转储成一个 .hprof 文件保存下来(这一步至关重要、因为没有保留崩溃现场、事后就根本无从分析)、然后、我们用 MAT 这款专业的内存分析工具去打开和剖析这个堆转储文件、MAT 能够通过 Dominator Tree 视图清晰地算出究竟是哪些对象(或哪类对象的集合)占据了堆里最多的内存、找到这个占用大户(也就是泄漏的元凶)之后、再用 MAT 的 "Path to GC Roots" 功能、它就能沿着引用关系一路回溯、把那条从某个 GC root 出发、一直牵连到这个本该被回收的对象、导致它无法被释放的完整引用链精确地展示出来、让我们一眼就能看出到底是哪个静态容器、哪个监听器、哪个缓存在作祟、从而精准地定位到出问题的代码。我们的纪律是"堆的初始和最大大小设成相等消除伸缩抖动、堆大小和分代比例按压测和实际负载调而非默认裸跑、所有服务必开 HeapDumpOnOutOfMemoryError 保留 OOM 现场、所有缓存必须有容量上限和淘汰策略(用 Caffeine 等)严禁无限膨胀的静态 Map、定期观察堆水位曲线发现只升不降立刻 dump 堆用 MAT 分析、警惕静态容器/监听器/ThreadLocal 等长生命周期引用"。堆内存与泄漏定位的本质认知是:JVM 的自动垃圾回收解决了"手动释放内存"的麻烦和危险、但它并没有、也无法解决"判断一个对象是否还'应该'被持有"这个属于业务逻辑层面的问题——GC 的回收依据冷冰冰地只有一条"可达性":只要一个对象还能从 GC roots 经由引用链被触达、GC 就认定它有用、绝不回收,这意味着内存泄漏在 Java 里以一种隐蔽的形式存在:不是你忘了释放、而是你在某个不经意的地方、让一条引用链一直牵着一个本该放手的对象;定位泄漏的智慧、则在于认识到"泄漏的现场(堆快照)是定位的唯一可靠依据、而引用链是定位的唯一钥匙"——与其在 OOM 之后对着日志干瞪眼瞎猜、不如用 HeapDumpOnOutOfMemoryError 把崩溃那一刻的完整堆现场固化下来、再用 MAT 沿着引用链这把钥匙、把"谁占了内存"和"谁吊着它不放"这两个问题确定性地回答清楚,这是在自动 GC 的语言里依然必须懂内存、依然要会查泄漏的根本原因——自动回收管的是"清理不可达的对象"、而防泄漏和查泄漏、管的是"找出那些本该不可达、却被引用链意外吊住的对象",后者永远是工程师的责任。

这十个战场不是孤立的,它们彼此咬合、层层递进,共同构成了从粗放 JVM 裸跑到现代 JVM 性能工程的完整跃迁。下面这张图,勾勒出我们这套服务体系里 JVM 从对象在堆中分配、经分代 GC 回收、被 JIT 编译加速、到全程被 JFR 持续剖析与监控的全景脉络:

三、性能剖析:从靠猜哪里慢到 JFR + async-profiler 火焰图

第三仗,是把性能优化从"凭经验拍脑袋猜"扭转成"用数据精准定位"。古早时代我们一旦遇到服务变慢、CPU 飙高、吞吐上不去这类性能问题,排查方式就是几个人围在一起、凭着对代码的印象和经验去猜测"会不会是那段循环慢""会不会是那个序列化重",然后挑一个看着可疑的地方改一改、上线看看有没有好转、没好转再换一个地方猜——这种盲人摸象式的优化不仅效率极低、经常南辕北辙白忙一场、还常常因为优化了根本不是瓶颈的地方而徒增复杂度。现代做法是用专业的剖析工具拿数据说话:一是 JFR(Java Flight Recorder)——它是内置在 JVM 里的、开销极低(低到可以在生产环境长期开启)的事件记录器、能持续地记录下 GC、线程、锁竞争、对象分配、IO 等海量运行时事件、事后用 JMC(JDK Mission Control)打开分析、能清晰地看出 CPU 时间和内存分配到底花在了哪;二是 async-profiler——它能以极低开销采样、生成直观的火焰图(flame graph)、火焰图上每个函数占据的横向宽度就代表它消耗 CPU 的比例、一眼就能看出最宽的那根"火苗"就是真正的性能热点。下面是性能剖析的对比:

# 重构前:靠猜哪里慢,挑个可疑地方改改上线看看,盲人摸象,经常优化错地方白忙一场
# (没有任何数据支撑,纯凭经验和印象猜测瓶颈在哪)

# 重构后:用 JFR + async-profiler 拿数据说话,火焰图一眼看出真正的 CPU 热点

# 1) JFR:JVM 内置低开销飞行记录器,可在生产长期开启,持续记录运行时事件
java -XX:+FlightRecorder   -XX:StartFlightRecording=duration=60s,filename=rec.jfr,settings=profile   -jar app.jar
# 或对运行中的进程动态开启:
jcmd  JFR.start duration=60s filename=rec.jfr settings=profile
jcmd  JFR.dump filename=rec.jfr      # 导出后用 JMC 打开看 CPU/分配/锁/GC

# 2) async-profiler:极低开销采样,直接生成火焰图,最宽的火苗就是热点
./asprof -d 30 -e cpu -f cpu_flame.html      # 采样 30s CPU,输出火焰图 HTML
./asprof -d 30 -e alloc -f alloc_flame.html  # 按对象分配采样,定位分配热点
./asprof -d 30 -e lock -f lock_flame.html    # 按锁竞争采样,定位锁瓶颈
# ↑ 火焰图横向越宽=占 CPU/分配/锁越多,一眼锁定真正瓶颈,而非靠猜

性能剖析让我们从"一旦遇到服务变慢 CPU 飙高吞吐上不去这类性能问题排查方式就是几个人围在一起凭着对代码的印象和经验去猜测会不会是那段循环慢会不会是那个序列化重然后挑一个看着可疑的地方改一改上线看看有没有好转没好转再换一个地方猜、这种盲人摸象式的优化不仅效率极低经常南辕北辙白忙一场还常常因为优化了根本不是瓶颈的地方而徒增复杂度"进化到了"用专业剖析工具拿数据说话:JFR 是内置在 JVM 里开销极低低到可以在生产环境长期开启的事件记录器能持续记录下 GC 线程锁竞争对象分配 IO 等海量运行时事件事后用 JMC 打开分析能清晰看出 CPU 时间和内存分配到底花在了哪、async-profiler 能以极低开销采样生成直观的火焰图火焰图上每个函数占据的横向宽度就代表它消耗 CPU 的比例一眼就能看出最宽的那根火苗就是真正的性能热点":过去我们面对性能问题时的排查手段、说出来很难为情、基本上就是"猜"——当监控告警说某个服务变慢了、或者 CPU 使用率异常飙高了、或者吞吐量怎么也压不上去的时候、我们几个工程师就会凑到一起、开始基于各自对这段代码的记忆和直觉、展开一场"猜谜大会":有人说"我记得那里有个嵌套循环、会不会是它慢"、有人说"那个接口的 JSON 序列化数据量挺大的、会不会是序列化重"、然后我们就从这些猜测里挑一个看起来最可疑的、动手去改它、改完发布上线、盯着监控看指标有没有改善、要是没改善、就再回到猜谜大会、换下一个怀疑对象继续猜、继续改、继续试——这种完全没有数据支撑、纯靠经验和直觉的盲人摸象式优化、问题太多了:首先是效率极其低下、一轮轮地猜测、修改、发布、验证、往往要折腾好几天才能撞对、其次是经常南辕北辙、我们以为的瓶颈和真正的瓶颈常常根本不是一回事、花了大力气优化的那个地方实际上对整体性能毫无影响、纯属白忙、更糟的是、为了优化一个其实并非瓶颈的地方、我们还往往会引入额外的缓存、额外的复杂逻辑、平白给代码增加了维护负担、却没换来任何性能收益;现在我们彻底抛弃了"猜",改用专业的性能剖析工具、一切让数据说话:第一件利器是 JFR(Java Flight Recorder)——这是一个内置在 JVM 内部的、专门用于记录 JVM 和应用运行时各类事件的强大工具、它最大的优点是开销极低、低到我们完全可以放心地在生产环境里长期开启它、让它持续不断地、事无巨细地记录下服务运行过程中发生的海量事件:每一次 GC 的细节、线程的状态变迁、锁的竞争情况、对象的分配行为、IO 操作等等、然后当我们需要分析时、就把它记录下来的数据文件用 JMC(JDK Mission Control)这个配套的图形化工具打开、就能非常清晰、非常全面地看出这段时间里 CPU 的时间都消耗在了哪些方法上、内存的分配主要来自哪里、有没有激烈的锁竞争、GC 的表现如何——一切都基于真实记录的数据、再不用猜;第二件利器是 async-profiler——它是一款采样式的性能剖析器、同样以极低的开销工作、它最强大的地方在于能够直接生成火焰图(flame graph)这种极其直观的可视化结果:在火焰图里、每一个函数调用都用一个矩形条来表示、而这个矩形条的横向宽度、就精确地代表了这个函数(及其调用的子函数)所消耗的 CPU 时间(或内存分配、或锁等待)在整体中所占的比例、于是我们只需要扫一眼这张图、找到那根最宽的"火苗"、就能一眼锁定到底是哪个函数、哪段代码才是真正吞噬性能的那个热点、定位精准到方法级、再也不用在一堆可疑对象里大海捞针式地猜测了。我们的纪律是"性能问题一律先用 JFR/async-profiler 拿到数据定位热点再动手优化、严禁不做剖析凭经验猜测就改代码、生产环境常态化开启低开销的 JFR 留存性能数据、CPU 问题看 CPU 火焰图、内存分配问题看 alloc 火焰图、卡顿看锁和 GC 事件、优化前后都要用同样的剖析手段对比验证收益是否真实"。性能剖析的本质认知是:性能优化领域有一条铁律——"过早优化是万恶之源"、而它的另一面是"没有度量就没有优化",这两句话共同指向同一个道理:在没有用工具实际测量出瓶颈到底在哪之前、任何基于直觉的优化都是一场胜率极低的赌博、因为程序的实际性能瓶颈、常常出现在那些违反我们直觉的、意想不到的地方;剖析工具的智慧、就在于它把"程序运行时的资源消耗"这个原本完全不可见、只能靠猜的东西、变成了可被精确测量、可被直观呈现的客观数据——JFR 用低开销的持续记录让我们能回溯生产现场、async-profiler 用一张火焰图让最深的热点一目了然,它们共同把性能优化从一门依赖经验和运气的玄学、变成了一门"先测量、定位热点、再针对性优化、最后用数据验证收益"的严谨工程,会做 JVM 性能优化的工程师、动手改代码之前永远先打开剖析器、因为他们深知:优化错地方、比不优化更浪费。

四、对象分配与逃逸:从热点频繁分配大对象到减少分配 + 复用

第四仗,是给 GC 从源头减负——砍掉热点路径上那些没必要的对象分配。古早时代我们在每秒被调用成千上万次的热点路径上,毫无节制地创建大量临时对象:循环里反复地 new 对象、字符串用 + 拼接(每次拼接都产生新的中间字符串对象)、用 stream 处理时产生大量中间的装箱对象、动不动就分配一个很大的临时数组或集合,这些对象绝大多数都是短命的、用完就成垃圾、可它们的量太大了——在如此高频的热点上、海量的临时对象疯狂地涌入新生代、把新生代迅速填满、迫使 Young GC 极其频繁地触发、而那些恰好在 GC 时还存活着的对象、又会被一批批地晋升到老年代、最终加重了昂贵的 Full GC;此外有些大对象甚至直接被分配进了老年代、进一步加剧了老年代的回收压力。现代做法是有意识地减少分配:循环里能复用的对象就提到循环外复用、大量字符串拼接用 StringBuilder 替代 + 号、能用基本类型就别用包装类型避免装箱、预先分配好容量足够的集合避免扩容时反复分配、对那些创建成本高又频繁使用的对象考虑池化复用、并理解 JVM 的逃逸分析(对那些不会逃逸出方法的小对象、JIT 可能做标量替换、直接在栈上分配甚至完全消除分配)从而写出对逃逸分析友好的代码。下面是对象分配优化的对比:

// 重构前:热点循环里反复 new + 字符串 + 拼接 + 装箱,海量临时对象疯狂涌入新生代
// String buildReport(List<Order> orders) {
//     String result = "";                       // 每次 += 都 new 一个新 String!
//     for (Order o : orders) {                   // 几万订单的热点循环
//         result += o.getId() + ":" + o.getAmount() + "\n"; // 每轮产生多个临时 String
//         List<Integer> tmp = new ArrayList<>(); // 每轮都 new 一个临时集合
//         tmp.add(o.getQty());                   // Integer 装箱,又一个临时对象
//     }
//     return result;   // ↑ 几万轮累积出海量短命对象,Young GC 频繁,晋升加重 Full GC
// }

// 重构后:StringBuilder 复用 + 预分配容量 + 避免装箱 + 对象提到循环外复用,大幅减分配
String buildReport(List<Order> orders) {
    // 预估容量一次性分配好 StringBuilder 内部缓冲,避免反复扩容重分配
    StringBuilder sb = new StringBuilder(orders.size() * 32);
    for (Order o : orders) {
        sb.append(o.getId()).append(':')         // append 复用同一个缓冲,不产生中间 String
          .append(o.getAmount()).append('\n');
    }
    return sb.toString();                         // 整个循环只在最后产生一个 String
}
// 其他减分配手段:
//   - 能用 int/long 就不用 Integer/Long,避免自动装箱产生包装对象
//   - new ArrayList<>(expectedSize) 预分配容量,避免 add 时反复扩容重分配数组
//   - 高频创建且贵的对象考虑池化复用;循环不变的对象提到循环外只创建一次
//   - 理解逃逸分析:不逃逸出方法的小对象,JIT 可标量替换/栈上分配,几乎零成本

对象分配与逃逸优化让我们从"在每秒被调用成千上万次的热点路径上毫无节制地创建大量临时对象:循环里反复 new 对象字符串用 + 拼接每次拼接都产生新的中间字符串对象用 stream 处理时产生大量中间装箱对象动不动分配一个很大的临时数组或集合、这些对象绝大多数都是短命的用完就成垃圾可量太大了在如此高频热点上海量临时对象疯狂涌入新生代把新生代迅速填满迫使 Young GC 极其频繁触发而那些恰好在 GC 时还存活的对象又被一批批晋升到老年代最终加重昂贵的 Full GC"进化到了"有意识地减少分配:循环里能复用的对象提到循环外复用、大量字符串拼接用 StringBuilder 替代 + 号、能用基本类型就别用包装类型避免装箱、预先分配好容量足够的集合避免扩容时反复分配、对创建成本高又频繁使用的对象考虑池化复用、并理解 JVM 的逃逸分析对不逃逸出方法的小对象 JIT 可能做标量替换直接在栈上分配甚至完全消除分配":过去我们写热点路径代码时、对"创建一个对象的成本"几乎是无感的、需要一个临时变量就随手 new 一个、需要拼字符串就直接用 + 号、用 Stream API 时也丝毫不在意中间产生了多少装箱拆箱的开销、这些写法在低频代码里完全没问题、可一旦它们出现在了每秒被调用成千上万次的热点路径上、问题就被调用频率无情地放大了:循环里用 + 号拼接字符串、由于 String 的不可变性、每一次 += 实际上都会创建一个全新的 String 对象来容纳拼接后的结果、把旧的丢弃、于是一个几万次的循环就产生了几万乃至几十万个转瞬即逝的中间字符串对象;循环里反复 new 临时集合、把基本类型 int 放进集合时自动装箱成 Integer 对象、用 Stream 时产生的各种中间对象——所有这些短命的临时对象、汇聚成一股海量的洪流疯狂地涌入 JVM 堆的新生代区域、把新生代以极快的速度填满、从而迫使负责回收新生代的 Young GC 极其频繁地被触发、CPU 被大量地消耗在了永无止境的垃圾回收上、而更糟的是、在某一次 Young GC 发生的那一刻、总有一部分临时对象恰好还在被使用、按照分代回收的规则、这些"幸存"下来的对象在经历几次 Young GC 后就会被晋升到老年代、于是大量本该速生速死的对象因为分配实在太快、被"裹挟"着晋升到了老年代、慢慢地把老年代也填满、最终触发那个我们最不愿见到的、停顿漫长的 Full GC;此外、有些一次性分配的大对象(比如一个很大的临时数组)甚至会绕过新生代直接被分配到老年代、更是直接给老年代施压;现在我们建立起了对"对象分配成本"的敏感、有意识地、有针对性地在热点路径上削减不必要的分配:对于字符串拼接、我们一律用 StringBuilder 来替代 + 号、StringBuilder 内部维护一个可变的字符缓冲区、append 操作都是在这同一个缓冲区上追加、整个拼接过程只在最后 toString 时产生一个最终的字符串、把原来几万个中间对象削减成了一个;对于集合、我们在创建时就根据预估的元素数量、用带初始容量的构造器一次性把底层数组分配到位、从而避免了往集合里不断添加元素时、因为容量不够而反复地扩容(每次扩容都要分配一个更大的新数组再把旧数据拷贝过去);对于数值、我们在能用基本类型 int、long 的地方就坚决不用 Integer、Long 这些包装类型、从根上避免自动装箱产生的对象;对于那些创建成本高、又会被频繁用到的对象、我们会考虑用对象池来池化复用;对于循环、我们会把那些在循环里其实并不变化的对象创建挪到循环外面、只创建一次;而在更深的层次上、我们还理解并利用了 JVM 的逃逸分析这一优化——JIT 编译器在编译时会分析一个对象的作用域、如果发现某个对象自始至终都不会"逃逸"出它被创建的那个方法(既不会被作为返回值传出去、也不会被赋值给外部可见的字段)、那么 JIT 就可以施展一系列激进的优化、比如标量替换(把对象拆解成几个独立的局部变量、压根就不在堆上创建这个对象了)、甚至直接把它分配在栈上(随方法返回自动销毁、零 GC 成本)、于是我们也有意识地把对象的作用域尽量收窄、写出对逃逸分析友好的代码、让 JIT 能帮我们消除掉更多的分配。我们的纪律是"热点路径上对每一次对象分配都保持敏感、字符串拼接用 StringBuilder 严禁循环里 + 号拼接、能用基本类型就不用包装类型避免装箱、集合 Map 创建时预估容量预分配避免反复扩容、循环不变的对象创建提到循环外、高频且创建贵的对象考虑池化、写对逃逸分析友好的代码尽量收窄对象作用域、但只在 profile 证实的真热点上做这些优化别在冷代码上过度优化牺牲可读性"。对象分配与逃逸的本质认知是:在带 GC 的 Java 里、对象分配看起来是免费的(一个 new 而已)、但这份"免费"是一种错觉——分配的成本并没有消失、而是以"GC 回收"的形式被延迟和转移了、在低频场景下这点成本微不足道、但在高频热点上、它会被调用次数乘成实实在在的、足以拖垮服务的 GC 压力;减少分配的智慧、和 GC 调优是一枚硬币的两面——GC 调优是在优化"如何更高效地回收垃圾"、而减少分配则是在从源头上"让程序少产生垃圾"、后者往往比前者更治本,因为最高效的垃圾回收、永远是那些根本不需要发生的回收;而"只在真热点上优化"这条边界、则守住了性能与可读性的平衡——这些减分配的技巧多少都会牺牲一点代码的简洁直观、所以它们只配用在那些被剖析工具实锤了的、真正每秒被调用成千上万次的关键热点上、而绝不该污染那些一天也跑不了几次的冷代码,会做 JVM 性能优化的工程师、对热点路径上的每一次分配锱铢必较、对冷路径则保持代码的自然简洁、把减分配的功夫精准地下在 GC 压力真正来自的那几个地方。

五、线程与锁:从线程池拍脑袋 synchronized 重锁到精细化 + 并发容器

第五仗,是把线程和锁从"凭感觉乱配、用重锁硬扛"治理成"精细化配置、用合适的并发工具减少竞争"。古早时代我们在两件事上很粗放:其一是线程池的参数全靠拍脑袋——核心线程数、最大线程数、任务队列的长度,统统是随手填一个看着顺眼的数字、从没根据任务是 CPU 密集型还是 IO 密集型、根据实际的负载去测算过,结果要么线程开太少、高峰期大量任务在队列里排长队、响应延迟飙升,要么线程开太多、成百上千个线程的栈内存和频繁的上下文切换把机器拖垮,甚至有的地方还在用无界队列、任务积压时内存无限上涨直到 OOM;其二是并发控制清一色用 synchronized 加重量级锁——凡是涉及共享数据就无脑 synchronized 整个方法或整个大代码块、锁的粒度极粗、高并发下大批线程全都阻塞在同一把锁上排队干等、锁竞争激烈到吞吐量被死死锁住。现代做法是双管齐下:线程池上、根据任务类型精细化配置(CPU 密集型线程数约等于核心数、IO 密集型可适当多些)、用有界队列加合理的拒绝策略防止任务无限积压、给线程池起有意义的名字便于排查;锁上、能不用锁就不用锁(用 ConcurrentHashMap 等并发容器、用 atomic 原子类、用不可变对象)、必须用锁时也尽量缩小锁的粒度、用更轻量的 java.util.concurrent 工具(如 ReentrantLock 的读写锁、StampedLock)替代粗暴的 synchronized。下面是线程与锁治理的对比:

// 重构前:线程池参数拍脑袋 + 无界队列 + synchronized 锁整个方法,锁竞争激烈吞吐被锁死
// ExecutorService pool = Executors.newFixedThreadPool(200); // 200 拍脑袋,栈内存+切换重
//   // 或 newCachedThreadPool 线程无上限爆炸 / 无界队列任务积压到 OOM
// private final Map<String, Long> counts = new HashMap<>();
// public synchronized void incr(String k) {                 // 锁整个方法,粒度极粗
//     counts.merge(k, 1L, Long::sum);                       // 所有线程挤在一把锁上排队
// }

// 重构后:按任务类型精细配线程池 + 有界队列 + 拒绝策略 + 并发容器/原子类替代重锁
int cpuCores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    cpuCores,                    // 核心线程数:CPU 密集约等于核数(IO 密集可适当多)
    cpuCores * 2,                // 最大线程数:按负载测算,不拍脑袋
    60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),          // 有界队列:防止任务无限积压到 OOM
    new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(), // 有名字便于排查
    new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略:满了让调用线程自己跑,反压

// 锁:能不用就不用——并发容器 + 原子类,无锁且高并发友好
private final ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
public void incr(String k) {
    counts.computeIfAbsent(k, x -> new LongAdder()).increment(); // 分段无锁,高并发不互相阻塞
}   // ↑ LongAdder 高并发计数远胜 synchronized,ConcurrentHashMap 分段锁竞争极小

线程与锁治理让我们从"线程池的参数全靠拍脑袋核心线程数最大线程数任务队列长度统统随手填一个看着顺眼的数字从没根据任务是 CPU 密集型还是 IO 密集型根据实际负载测算过、结果要么线程开太少高峰期大量任务在队列排长队响应延迟飙升要么线程开太多成百上千个线程的栈内存和频繁上下文切换把机器拖垮甚至有的地方还在用无界队列任务积压时内存无限上涨直到 OOM、并发控制清一色用 synchronized 加重量级锁凡是涉及共享数据就无脑 synchronized 整个方法或整个大代码块锁粒度极粗高并发下大批线程全阻塞在同一把锁上排队干等锁竞争激烈到吞吐量被死死锁住"进化到了"根据任务类型精细化配置线程池 CPU 密集型线程数约等于核心数 IO 密集型可适当多些用有界队列加合理拒绝策略防止任务无限积压给线程池起有意义的名字便于排查、锁上能不用锁就不用锁用 ConcurrentHashMap 等并发容器用 atomic 原子类用不可变对象必须用锁时也尽量缩小锁粒度用更轻量的 juc 工具替代粗暴的 synchronized":过去我们在使用线程和处理并发这两件事上都相当粗放,第一件粗放的事是线程池的配置全凭感觉——我们在创建线程池时、对于核心线程数该设多少、最大线程数该设多少、缓冲任务的队列该用什么类型、该设多长、这些至关重要的参数、从来没有认真地去分析过我们的任务到底是 CPU 密集型(主要在做计算、瓶颈在 CPU)还是 IO 密集型(主要在等待网络或磁盘、瓶颈在等待)、也从来没有结合实际的并发负载去测算过、而是随手就填一个看上去差不多的数字了事、甚至有不少地方图省事直接用 Executors 提供的那几个工厂方法(如 newFixedThreadPool、newCachedThreadPool)、而这些方法背后藏着陷阱——newFixedThreadPool 用的是无界的任务队列、一旦任务的产生速度超过处理速度、任务就会在这个没有上限的队列里无限地堆积、慢慢吃光内存直到 OOM、而 newCachedThreadPool 则是线程数没有上限、任务一多就疯狂创建新线程、直到把系统的线程资源耗尽、这种拍脑袋的配置带来的后果是两极分化的:线程配少了、高峰期海量的任务就在队列里排起长队、迟迟得不到处理、用户感受到的响应延迟急剧飙升、配多了、成百上千个线程光是它们各自占用的栈内存就是一笔可观的开销、而且如此多的线程争抢有限的 CPU 核心、操作系统在它们之间频繁地做上下文切换、这种切换本身的开销就能把机器的性能严重拖垮;第二件粗放的事是并发控制手段的简单粗暴——我们处理多线程共享数据时、几乎清一色地祭出 synchronized 这一种武器、而且用得极其粗放、动不动就把 synchronized 加在整个方法上、或者锁住一大段代码块、锁的粒度粗得不能再粗、这就导致在高并发的场景下、明明很多线程要操作的是共享数据里互不相干的不同部分、却因为它们都要去争抢那同一把粗粒度的大锁、而被迫排成一队、一个接一个地串行执行、绝大多数线程在绝大多数时间里都只是阻塞在这把锁上、白白地干等着、CPU 闲着、吞吐量被这把锁死死地扼住了喉咙;现在我们对线程和锁都做了精细化的治理:在线程池方面、我们彻底告别了拍脑袋和危险的工厂方法、改为直接用 ThreadPoolExecutor 的完整构造器来显式地、精心地配置每一个参数——根据任务的性质来定线程数(对于 CPU 密集型的任务、线程数设成约等于 CPU 核心数就足够了、再多也只是徒增切换开销、对于 IO 密集型的任务、由于线程大量时间在等待、可以适当地多配一些线程来提高 CPU 的利用率)、一律使用有界的任务队列、给队列设一个合理的上限、并配上恰当的拒绝策略(比如 CallerRunsPolicy、让提交任务的线程自己来执行被拒绝的任务、从而形成一种自然的反压、让任务的生产速度慢下来)、从而彻底杜绝了任务无限积压导致 OOM 的风险、同时我们还给每个线程池都起了一个有意义的、能看出它是干什么的名字、这样将来用 jstack 或 JFR 排查问题时、一眼就能看出某个出问题的线程是属于哪个业务线程池的;在锁方面、我们遵循的首要原则是"能不用锁、就坚决不用锁"——对于并发的 Map、我们改用专为并发设计的 ConcurrentHashMap(它内部采用了更精细的分段/桶级别的锁、不同的线程操作不同的桶时根本不会互相阻塞)、对于计数器这类、我们改用 AtomicLong、尤其是在高并发下用 LongAdder(它把一个计数拆分到多个独立的单元上分散累加、极大地降低了并发冲突)、对于那些不会改变的数据、我们就用不可变对象(天生线程安全、根本无需加锁)、而当确实绕不开、必须要用锁来保护一段临界区时、我们也力求把锁的粒度缩到最小、只锁那些真正需要互斥保护的最小范围、并且优先选用 java.util.concurrent 包里那些比 synchronized 更轻量、更灵活的锁工具(比如支持读写分离的 ReentrantReadWriteLock、以及更进一步的 StampedLock)。我们的纪律是"线程池一律用 ThreadPoolExecutor 显式配置严禁用 Executors 工厂方法(无界队列/无限线程的坑)、线程数按 CPU 密集或 IO 密集和实测负载定、队列必须有界并配拒绝策略防积压 OOM、线程池必须命名便于排查、并发优先用 ConcurrentHashMap/atomic/LongAdder/不可变对象等无锁或低竞争方案、必须加锁时缩小粒度并优先用 juc 的轻量锁、绝不无脑 synchronized 整个方法"。线程与锁的本质认知是:线程是宝贵且有成本的资源(每个线程都要占栈内存、都要被调度切换)、而锁是并发性能的天敌(锁把并行强行变回了串行)——这两者都不是越多越好、而恰恰是需要被严格约束和精打细算的;精细化线程池的智慧、在于认清"并发度不是越高越好、而是要匹配系统真实的处理能力"——线程数应该由任务的性质(CPU 还是 IO 密集)和系统的承受力共同决定、用有界队列和拒绝策略给任务的涌入设一道闸门、而不是任由线程和任务无节制地膨胀;减少锁竞争的智慧、则在于认识到"锁是不得已的最后手段、而非处理并发的默认选项"——并发编程的高手、追求的是如何通过无锁的数据结构、原子操作、不可变性、以及缩小临界区、把对锁的依赖降到最低、因为每一把锁、都是在并发的高速公路上设下的一个收费站、设得越多越粗、车流就越堵,会做 Java 高并发的工程师、既懂得像精算师一样去配置线程池的每一个参数、也懂得像躲避瘟疫一样去躲避不必要的、粗粒度的锁、用合适的并发工具让线程们尽可能地并行飞奔、而不是挤在一把大锁前排长队。

六、堆外内存:从 DirectBuffer 失控被系统杀到 NMT 原生内存追踪

第六仗,是把那些游离在 Java 堆之外、却同样会吃光机器内存的"暗物质"管起来。古早时代我们大量使用 Netty 做网络通信、用 NIO 做 IO,而这些框架为了性能会大量使用 DirectByteBuffer 这类堆外内存(off-heap memory)——这部分内存直接向操作系统申请、不在 JVM 堆的管辖范围内、也就不受我们设定的 -Xmx 堆上限的约束、更不被常规的堆内存监控所覆盖,问题就出在这里:我们的监控盯着的全是堆内存、堆内看着一切正常、水位平稳,可堆外的 DirectByteBuffer 却因为某些地方申请了却没及时释放、或者用量本就估算不足、在悄无声息地持续上涨、而我们对此一无所知、直到某一刻整个 Java 进程的总内存(堆内 + 堆外 + 元空间 + 线程栈等)超过了机器或容器的物理内存上限、被操作系统的 OOM Killer 毫不留情地直接杀掉——而我们看着堆内存监控曲线一脸懵:堆明明好好的、进程怎么就没了?现代做法是用 JVM 自带的 NMT(Native Memory Tracking)——开启它之后、JVM 会详细地追踪和分类统计自己所申请的所有原生内存的去向:堆、元空间、线程栈、代码缓存、GC 数据结构、以及最关键的 Internal/Other 里包含的 DirectByteBuffer 等堆外分配,通过 jcmd 命令就能看到一份清晰的原生内存使用明细报告、让堆外内存的增长无所遁形;同时我们也用 -XX:MaxDirectMemorySize 给堆外内存设一个明确的上限、并把它纳入监控。堆外内存治理让我们从"大量使用 Netty 做网络通信用 NIO 做 IO 而这些框架为了性能会大量使用 DirectByteBuffer 这类堆外内存这部分内存直接向操作系统申请不在 JVM 堆的管辖范围内也不受我们设定的 -Xmx 堆上限约束更不被常规堆内存监控覆盖、我们的监控盯着的全是堆内存堆内看着一切正常水位平稳可堆外的 DirectByteBuffer 却因为某些地方申请了却没及时释放或用量本就估算不足在悄无声息地持续上涨而我们对此一无所知、直到某一刻整个 Java 进程的总内存超过机器或容器物理内存上限被操作系统 OOM Killer 直接杀掉而我们看着堆内存监控曲线一脸懵堆明明好好的进程怎么就没了"进化到了"用 JVM 自带的 NMT 开启后 JVM 会详细追踪和分类统计自己申请的所有原生内存去向堆元空间线程栈代码缓存 GC 数据结构以及最关键的包含 DirectByteBuffer 等堆外分配通过 jcmd 就能看到清晰的原生内存使用明细报告让堆外内存增长无所遁形、同时用 MaxDirectMemorySize 给堆外内存设明确上限并纳入监控":过去我们对 Java 进程的内存认知有一个巨大的盲区——我们以为一个 Java 进程占用的内存就等于它的堆内存、所以我们的内存监控、内存告警、容量规划、全都只围绕着堆(-Xmx 设定的那部分)来做,可实际上、一个 Java 进程占用的总内存远不止堆这一块、它还包括元空间(存类的元数据)、每个线程的栈、JIT 编译后的代码缓存、GC 自身的数据结构、以及一大块我们长期忽视的堆外内存——而我们的服务大量使用了 Netty 这个高性能网络框架和 Java NIO、它们为了避免数据在 JVM 堆和操作系统之间反复拷贝、提升 IO 性能、会大量地使用 DirectByteBuffer、也就是直接堆外内存:这部分内存是 JVM 通过 Unsafe 等机制直接向操作系统申请的、它压根就不在 JVM 堆里、自然也就完全不受我们辛辛苦苦设定的 -Xmx 堆大小上限的约束和管辖、更要命的是它也完全不在我们那套只盯着堆的监控体系的视野之内,于是一个极其隐蔽的故障模式就形成了:某些使用 DirectByteBuffer 的地方、可能因为代码缺陷申请了之后没有及时地释放归还、或者仅仅是我们对它的用量估计不足、导致堆外内存在持续地、悄无声息地往上涨、而我们的监控大盘上、堆内存的曲线却始终平稳健康、看不出任何异常、我们对正在堆外发生的内存泄漏浑然不觉、直到某一个时刻、这个 Java 进程占用的物理内存总量(堆内的 + 堆外的 + 其他的)终于超过了它所在的物理机或容器被分配到的内存上限、操作系统的 OOM Killer 出手了、它不管你 JVM 内部是什么情况、直接一刀把整个 Java 进程给杀掉了、而我们事后去翻监控、看到的是堆内存一切正常、进程却凭空消失了、百思不得其解、因为凶手藏在我们从来没看过的堆外;现在我们用 JVM 自带的 NMT(Native Memory Tracking)这个功能、给原本不可见的原生内存装上了透视眼——我们在启动参数里开启 NMT(-XX:NativeMemoryTracking=detail)之后、JVM 就会在内部详细地追踪并按类别统计它所申请的每一块原生内存的用途和大小:有多少用在了 Java 堆上、多少用在了元空间、多少用在了线程栈(以及有多少个线程)、多少用在了 JIT 的代码缓存、多少用在了 GC 的内部数据结构、以及最关键的——有多少属于 Internal 或 Other 这些类别(DirectByteBuffer 的分配就体现在这里)、然后我们只需要用 jcmd VM.native_memory 这个命令、就能随时拉出一份分门别类、清清楚楚的原生内存使用明细报告、哪一类内存在涨、涨了多少、一目了然、堆外内存的异常增长再也无处藏身;与此同时、我们还用 -XX:MaxDirectMemorySize 这个参数给堆外的直接内存设定了一个明确的上限、这样一旦堆外内存的使用触及了这个上限、JVM 会抛出一个明确的 OutOfMemoryError(Direct buffer memory)、让问题以一种我们能捕获、能告警的方式暴露出来、而不是悄无声息地涨到被系统杀掉、并且我们也把堆外内存的用量正式纳入了监控体系。我们的纪律是"凡是大量用 Netty/NIO/DirectByteBuffer 的服务必须开 NMT 追踪原生内存、必须用 MaxDirectMemorySize 给堆外内存设上限别让它无约束增长、内存监控不能只看堆要把进程的总物理内存占用(RSS)和堆外用量都纳入监控、进程被 OOM Killer 杀而堆内正常时第一时间怀疑堆外内存、容量规划时给堆外预留出足够空间(-Xmx 不能设到接近物理内存上限要给堆外和其他留余地)"。堆外内存的本质认知是:一个 Java 进程的内存版图、远比我们直觉中的"就是那个堆"要广阔和复杂——堆只是 JVM 内存的冰山一角、水面之下还有元空间、线程栈、代码缓存、以及那块为了性能而存在、却游离于堆的管辖和监控之外的堆外内存,只盯着堆来理解和监控 Java 进程的内存、就像只看冰山露出水面的部分来判断它的大小一样危险;NMT 的智慧、就在于它把 JVM 那些原本对我们不可见的、水面之下的原生内存分配、变成了可被精确追踪、可被分类查看的明细数据、让我们对一个 Java 进程的完整内存版图有了全局的、而非只见堆的认知,这是在容器化、内存配额严格的现代部署环境下、避免"堆内一切正常、进程却被系统无情杀掉"这类诡异故障的关键——理解 Java 进程的内存、不能止步于堆,真正吃光内存、招来 OOM Killer 的、往往恰恰是那些我们从未正眼看过的、堆之外的暗物质。

七、JIT 编译:从不懂冷启动全解释执行到分层编译 + 预热

第七仗,是搞懂 JVM 的即时编译(JIT)、并解决服务冷启动那几分钟性能极差的老大难问题。古早时代我们对 JIT 几乎一无所知、只是模糊地知道"Java 是先编译成字节码再运行的",却完全不了解一个关键事实:JVM 在运行字节码时、一开始走的是速度很慢的解释执行(逐条解释字节码)、只有当它通过运行时的统计发现某段代码被反复执行(成为了"热点")之后、才会由 JIT 编译器把这段热点代码即时编译成高度优化的本地机器码、之后这段代码的执行速度才会有质的飞跃,这个机制的副作用是:每当服务刚刚启动、或者刚刚重启、或者刚刚扩容上线一个新实例时、所有的代码都还没有被 JIT 编译过、全都在以最慢的解释模式执行、于是服务在刚上线的最初那几分钟里、性能极差、响应缓慢、吞吐量很低,而我们过去对此毫无认知、每次都把这刚上线时的性能抖动误判成是别的什么问题(网络、依赖、数据库)、白白排查了无数次。现代做法是真正理解并善用 JIT:理解 JVM 的分层编译(Tiered Compilation)机制(它分多个层级、从解释执行、到快速但优化程度低的 C1 编译、再到慢速但优化程度极高的 C2 编译、逐步升级);针对冷启动慢的问题、在服务正式接收生产流量之前、用预热(warm-up)流量先把核心代码路径跑上一段时间、促使 JIT 把这些关键路径都提前编译成机器码、等真正的流量进来时服务已经处于"热"的最佳状态;并通过打印 JIT 编译日志来观察哪些方法被编译了、有没有发生反优化(deoptimization)。JIT 编译让我们从"对 JIT 几乎一无所知只是模糊知道 Java 是先编译成字节码再运行的却完全不了解一个关键事实:JVM 运行字节码时一开始走速度很慢的解释执行只有当它通过运行时统计发现某段代码被反复执行成为热点之后才会由 JIT 编译器把这段热点代码即时编译成高度优化的本地机器码、这个机制的副作用是每当服务刚启动刚重启刚扩容上线新实例时所有代码都还没被 JIT 编译过全在以最慢的解释模式执行于是服务刚上线的最初那几分钟性能极差响应缓慢吞吐量很低而我们对此毫无认知每次都把刚上线时的性能抖动误判成别的问题白白排查无数次"进化到了"真正理解并善用 JIT:理解 JVM 的分层编译机制它分多个层级从解释执行到快速但优化程度低的 C1 编译再到慢速但优化程度极高的 C2 编译逐步升级、针对冷启动慢的问题在服务正式接收生产流量之前用预热流量先把核心代码路径跑上一段时间促使 JIT 把这些关键路径都提前编译成机器码等真正流量进来时服务已处于热的最佳状态、并通过打印 JIT 编译日志观察哪些方法被编译了有没有发生反优化":过去 JIT 对我们来说是一个完全的黑盒、我们只有一个模糊到几乎错误的认知——"Java 程序是先把源码编译成字节码、然后 JVM 执行字节码"、却对这个"执行字节码"的过程内部究竟发生了什么一无所知,而我们恰恰漏掉了 JVM 性能模型里最关键的一环:JVM 执行字节码并不是一上来就全速运行的、它有一个动态的、渐进的优化过程——在最开始、JVM 是用解释器逐条地解释执行字节码的、这种方式启动快、但运行速度很慢、然后 JVM 会在运行的过程中持续地统计每段代码的执行频率、当它发现某个方法或某段循环被反复地、大量地执行、构成了程序的"热点"时、它就会启动后台的 JIT(Just-In-Time)编译器、把这段热点字节码即时地编译成针对当前 CPU 高度优化过的本地机器码、并在之后的执行中直接运行这段飞快的机器码而不再解释、于是热点代码的性能会在运行一段时间后产生质的飞跃,理解了这个机制、我们才恍然大悟过去那个百思不得其解的现象的根源:每当一个服务实例是全新启动的(无论是发布新版本、还是故障重启、还是高峰扩容拉起新实例)、它内部所有的代码都还处于"冷"的状态、一行都没有被 JIT 编译过、全都在用最慢的解释模式执行、所以这个新实例在刚刚启动、刚刚开始接收流量的最初几分钟里、它的处理性能是极差的、响应慢、吞吐低、而随着流量的持续、热点代码被逐渐识别和编译、它的性能才会慢慢爬升到正常水平、可过去我们由于完全不懂这个机制、每次遇到新实例上线初期的这种性能抖动、都本能地往外部因素上去想、怀疑是不是网络抖动了、是不是依赖服务慢了、是不是数据库连接没建好、为此一次次地白白排查、却从没想到罪魁祸首其实是 JIT 还没来得及给代码"加热";现在我们真正地去理解并主动地驾驭 JIT 了:我们搞清楚了 JVM 的分层编译(Tiered Compilation)机制——它把代码的执行优化分成了好几个层级、从最底层的解释执行(Level 0)、到由 C1 编译器产出的、编译速度快但优化程度较低的机器码(Level 1-3)、再到由 C2 编译器产出的、编译速度慢但优化程度极高的机器码(Level 4)、JVM 会根据代码的热度让它在这些层级之间逐步地、平滑地升级、兼顾了启动速度和峰值性能;而针对最让我们头疼的冷启动性能差的问题、我们引入了预热(warm-up)机制——在一个新的服务实例正式被注册进负载均衡、开始接收真实的生产流量之前、我们先用一批预热流量(可以是回放的真实请求、也可以是专门构造的覆盖核心路径的请求)在这个实例上跑上一段时间、主动地、提前地把那些核心业务路径上的代码都执行个成百上千遍、从而促使 JIT 把这些关键代码都提前编译成优化后的机器码、等到这个实例真正开始承接生产流量的那一刻、它内部的核心代码早已是被编译过的"热"状态、一上来就能火力全开、再也没有那段尴尬的冷启动低性能期;此外、我们还会通过打印 JIT 的编译日志(-XX:+PrintCompilation 等)来观察到底哪些方法被编译了、被编译到了哪个层级、有没有发生反优化(deoptimization、即 JIT 基于的某个假设在运行时被打破、不得不把已编译的代码退回解释执行、这往往是性能问题的一个信号)。我们的纪律是"理解 JVM 分层编译机制不要把 JIT 当黑盒、新实例上线前必须经过预热再接生产流量避免冷启动低性能期直接面对用户、压测时必须先充分预热再测峰值性能否则测到的是没编译的解释执行性能毫无意义、关注 JIT 编译日志里的反优化信号、对启动速度极致敏感的场景(如 Serverless)考虑 GraalVM Native Image 把 JIT 换成 AOT 提前编译"。JIT 编译的本质认知是:JVM 的高性能、是一种需要"时间"和"运行"来逐步兑现的、动态的高性能、而非一启动就到位的静态高性能——它用启动初期的解释执行换来了快速启动、再用运行时的统计和即时编译、把真正的热点代码优化到极致、这是一种极其聪明的"为最该优化的地方花最大力气"的动态优化哲学、但它的代价就是那段"由冷到热"的预热期;理解 JIT 的智慧、首先在于消除认知盲区——一个不懂 JIT 的工程师、会反复地被冷启动性能差、被压测没预热数据失真这些现象所欺骗、把 JVM 的正常行为误判成故障、而懂 JIT 的工程师、则能坦然地接纳并主动地管理这个预热过程:用预热把冷启动的低性能期挡在生产流量之前、用充分预热保证压测数据的真实有效、用编译日志洞察 JIT 的行为,这是把 Java"运行越久越快"这个独特的性能特性、从一个会偷袭你的陷阱、变成一个被你掌控的、可以主动利用的优势——会做 JVM 性能工程的工程师、既敬畏 JIT 带来的预热期、也善用 JIT 带来的峰值性能,绝不把 Java 当成一启动就该满血的静态语言去对待。

八、可观测:从黑盒 jmap 压垮服务到 JFR 持续剖析 + Micrometer

第八仗,是把线上 JVM 从一个出事才手忙脚乱去抢救的黑盒、变成一个被持续度量、随时透明的玻璃箱。古早时代我们对线上 JVM 的可观测性几乎为零、平时不看、出了事才临时抱佛脚:服务一旦出现性能问题、我们才慌慌张张地登上生产服务器、手敲一堆诊断命令——用 jstack 抓线程快照看是不是死锁或线程阻塞、用 jmap 导出堆来看内存、用 jstat 看 GC 统计,可这套临时抢救式的做法有几个大问题:其一是滞后、等我们登上去敲命令时、那个故障的瞬间现场可能早就过去了、抓到的是事后的状态;其二、也是最危险的——jmap 导出一个几十 G 的大堆这个操作本身就会让 JVM 暂停很长时间、对一个本就因为性能问题而奄奄一息的服务、一个 jmap 下去往往就成了压垮它的最后一根稻草、直接把它彻底搞挂;其三是这些命令给出的都是某一个孤立时间点的快照、缺乏连续的、可回溯的历史数据、很难看出趋势和因果。现代做法是建立持续的、低开销的可观测体系:用 JFR 在生产环境常态化地、持续地以极低开销记录 JVM 的运行数据、做到出事后能直接回溯故障前后的完整现场、而无需在故障时再去做高开销的抓取;用 Micrometer 这样的度量门面、把 JVM 的关键指标(堆各分代用量、GC 频率与停顿时长、线程数、类加载数等)和业务指标统一采集、对接到 Prometheus、Grafana 这样的监控系统、做成实时的、有历史趋势的监控大盘、并配上告警规则、让问题在指标异常的苗头阶段就被自动发现。可观测让我们从"对线上 JVM 的可观测性几乎为零平时不看出了事才临时抱佛脚:服务一旦出现性能问题才慌慌张张登上生产服务器手敲一堆诊断命令 jstack 抓线程 jmap 导堆 jstat 看 GC、可这套临时抢救式做法滞后等登上去敲命令时故障瞬间现场早过去了抓到的是事后状态、最危险的是 jmap 导出几十G大堆这个操作本身就会让 JVM 暂停很长时间对本就奄奄一息的服务一个 jmap 下去往往成了压垮它的最后一根稻草直接搞挂、这些命令给出的都是某一个孤立时间点的快照缺乏连续可回溯的历史数据很难看出趋势和因果"进化到了"建立持续的低开销可观测体系:用 JFR 在生产环境常态化地持续以极低开销记录 JVM 运行数据做到出事后能直接回溯故障前后完整现场而无需在故障时再做高开销抓取、用 Micrometer 度量门面把 JVM 关键指标和业务指标统一采集对接到 Prometheus Grafana 做成实时有历史趋势的监控大盘并配告警规则让问题在指标异常的苗头阶段就被自动发现":过去我们对待线上 JVM 的态度是典型的"平时不烧香、临时抱佛脚"——在服务正常运行的时候、我们对它内部的状态(内存怎么样、GC 频不频繁、线程多不多)基本不闻不问、没有任何持续的监测、而一旦服务出了性能问题、我们才如梦初醒、手忙脚乱地 SSH 登录到生产服务器上、凭着记忆敲出一连串 JDK 自带的命令行诊断工具:用 jstack 打印出当前所有线程的堆栈快照、试图从中找出是不是有线程死锁了、或者大量线程都阻塞在了什么地方、用 jmap 把当前的堆内存转储出来、想看看内存里到底装了些什么、是不是有什么对象异常地多、用 jstat 来观察 GC 的统计数据、看看是不是 GC 出了问题,可这套到了火烧眉毛才临时上阵的抢救式诊断、毛病实在太多:第一个毛病是严重滞后——故障的发生往往是一瞬间的事、而等我们收到告警、反应过来、登录服务器、敲出命令、这中间已经过去了好几分钟、那个最关键的故障爆发瞬间的现场早已物是人非、我们费劲抓到的、不过是故障发生之后系统已经变化了的、价值大打折扣的状态;第二个毛病、也是最凶险的一个——jmap 这个导出堆的操作、对于我们那种动辄几十 GB 的大堆来说、其本身就是一个极其消耗资源、会触发长时间 STW 停顿的重量级操作、而我们用它的时机、偏偏是服务已经因为性能问题而岌岌可危、奄奄一息的时候、这时候再来一个 jmap、就如同给一个心脏病发作的病人又来了一记重击、往往直接就把这个本来还能抢救一下的服务彻底压垮、导致它彻底崩溃、我们不止一次地在排查问题的过程中、亲手用 jmap 把服务给"救"挂了;第三个毛病是这些命令产出的全都是孤立的、某一个时间点的静态快照、它们之间没有连续性、我们既看不到指标随时间变化的趋势(到底是从什么时候开始恶化的)、也无法对比故障前后的差异、更难以建立起现象之间的因果联系;现在我们彻底扭转了这种被动抢救的局面、建立起了一套主动的、持续的、低开销的可观测体系:一方面、我们用 JFR(Java Flight Recorder)在所有生产服务上常态化地、7x24 小时持续运行着——由于 JFR 的开销极低、我们完全可以让它一直开着、像飞机的黑匣子一样、源源不断地记录下 JVM 运行的全部细节、这样一来、当故障发生后、我们要做的不再是冲上去做高开销的实时抓取(从而避免了 jmap 压垮服务的悲剧)、而是从容地把 JFR 这个黑匣子里早已记录好的、覆盖了故障发生前、发生时、发生后完整时间段的数据导出来、慢慢地、完整地回溯当时到底发生了什么;另一方面、我们用 Micrometer 这个度量门面、把 JVM 的各项关键指标(堆里各个分代的内存用量、GC 发生的频率和每次的停顿时长、活跃线程数、已加载的类数量等等)连同我们的业务指标一起、统一地、持续地采集出来、对接到 Prometheus 做存储、用 Grafana 做成一块块实时刷新、并带有完整历史趋势曲线的监控大盘、再针对这些指标配置好告警规则(比如老年代用量持续超过 80%、或者 GC 停顿时间突然变长)、这样、JVM 内部的健康状况就被持续地、可视化地呈现了出来、任何指标的异常苗头、都会在它演变成严重故障之前、就被监控曲线和告警第一时间捕捉到、让我们能够防患于未然。我们的纪律是"生产环境常态化开启低开销的 JFR 做持续黑匣子记录、严禁在垂危的大堆服务上随意 jmap(会压垮服务)、JVM 关键指标(分代内存/GC 停顿与频率/线程数/类加载)用 Micrometer 统一采集对接 Prometheus+Grafana 做大盘和告警、可观测要平时就建好而非出事才临时抓、关键指标设告警让问题在苗头阶段被发现、故障复盘优先用 JFR 回溯现场而非靠事后快照猜"。可观测的本质认知是:一个生产服务的可观测性、绝不应该是出了事才临时去建立的能力、而必须是平时就持续运行着的基础设施——因为故障是转瞬即逝的、而临时抓取既滞后、又可能(像 jmap 那样)反过来伤害本已脆弱的服务、唯有平时就持续地、低开销地记录和度量、才能在事发后拥有可回溯的完整现场、在事发前就从趋势中嗅到危险;JFR 持续记录的智慧、在于它用"黑匣子"的思路、把"故障诊断"所需的数据采集、从故障发生后的高开销实时抓取、提前到了平时的低开销持续记录、彻底化解了"既想要诊断数据、又怕抓取动作压垮服务"这个两难;而 Micrometer + 监控大盘 + 告警的智慧、则在于把 JVM 那些原本只能靠命令行临时窥探的内部状态、变成了持续可见的、有历史、有趋势、能告警的客观指标,这两者共同实现了从"被动地、滞后地、危险地抢救黑盒"到"主动地、持续地、安全地观测玻璃箱"的根本转变——会运维 JVM 的团队、从不等到服务出事才想起去看它一眼、而是让它的每一次心跳都持续地、透明地呈现在监控大盘上。

九、7 个 P0 事故复盘

7 事故:(1) 一次大促高峰 CMS 因老年代碎片触发 Full GC、十几秒 STW 把整个交易服务冻死大面积超时,全量切 G1 并设停顿目标后高峰再没出现长停顿;(2) 一个静态 Map 缓存无上限只进不出、运行十几天后缓慢漏到 OOM 崩溃,开 HeapDumpOnOutOfMemoryError 抓堆用 MAT 沿引用链定位到那个静态 Map、全部缓存改 Caffeine 带上限淘汰;(3) 一个堆外 DirectByteBuffer 泄漏、堆内监控一切正常进程却被容器 OOM Killer 反复杀掉、查了大半天没头绪,开 NMT 后一眼看到堆外内存暴涨、定位到未释放的 buffer 并设 MaxDirectMemorySize;(4) 一次性能问题靠猜优化了三天都没找对地方,改用 async-profiler 抓火焰图五分钟锁定真正热点是一个意想不到的序列化方法;(5) 一个用 Executors.newFixedThreadPool 的无界队列在下游变慢时任务疯狂积压、内存涨到 OOM,全部换成 ThreadPoolExecutor 有界队列加拒绝策略;(6) 一处热点用 synchronized 锁整个方法、高并发下锁竞争把吞吐死死锁住,改 ConcurrentHashMap 加 LongAdder 后吞吐数倍提升;(7) 一次扩容拉起的新实例刚接流量就大量超时、排查半天以为是网络、最后发现是 JIT 没预热全在解释执行,加上线预热流程后新实例上线平稳。每个 P0 都做 5-Why 复盘,固化成 GC 选型基线、缓存上限规约、堆外监控红线或上线预热规范,确保同类问题不再复发。

十、JVM 性能工程师的 6 条工程哲学

6 哲学:(1) 自动 GC 不等于不用懂内存——GC 只回收不可达对象,而泄漏是该断的引用没断、堆外更在它管辖之外,理解 Java 进程的完整内存版图是底线;(2) 没有度量就没有优化——性能问题一律先用 JFR/async-profiler 拿数据定位热点再动手,凭经验猜测优化是胜率极低的赌博;(3) GC 调优是在吞吐与停顿间权衡——选对回收器(G1/ZGC)往往比加机器更能解决高峰卡顿,核心看停顿而非单纯吞吐;(4) 最高效的回收是不产生垃圾——热点上减少分配比优化回收更治本,但只在 profile 证实的真热点上做别过度优化;(5) Java 的高性能要靠运行兑现——JIT 让代码越跑越快但有预热期,新实例上线和压测都必须先预热否则面对的是解释执行的假象;(6) 可观测要平时建好而非出事才抓——JFR 持续黑匣子加 Micrometer 大盘告警,临时 jmap 既滞后又可能压垮垂危的服务。这 6 条哲学,是我们用 7 个 P0 事故和 87 天攻坚换来的集体共识。它们共同指向一个认知:JVM 性能的瓶颈往往不在 JVM 本身、而在我们是否愿意去理解它"自动 GC + 分代堆 + JIT 编译 + 堆外内存"的运行机理——会调 JVM 的团队,是用合理的 GC 选型、数据驱动的剖析、源头减分配、预热、堆外治理和持续可观测这套与 JVM 运行机理相契合的方法,把这台精密机器的性能压榨到极致,而不是用默认参数裸跑、靠猜优化、忽视堆外、不懂 JIT 的粗放做法去糟蹋它、再抱怨 Java 慢、JVM 难调。

十一、重构收益的量化:7 个关键数字

7 数字:(1) 高峰期 GC 停顿:CMS Full GC 长达十几秒 STW → G1/ZGC 后停顿降到百毫秒乃至毫秒级;(2) OOM 崩溃频率:内存泄漏导致周期性 OOM 重启 → 堆转储定位泄漏加缓存治理后 OOM 归零;(3) 进程被系统杀:堆外失控被 OOM Killer 反复杀 → NMT 追踪加上限后堆外可控不再被杀;(4) 性能问题定位时长:靠猜优化常耗时数天 → JFR/async-profiler 火焰图后分钟级锁定热点;(5) 热点路径 GC 压力:频繁分配 Young GC 频繁晋升加重 Full GC → 减分配后 GC 频率和压力大幅下降;(6) 高并发吞吐:synchronized 重锁竞争把吞吐锁死 → 并发容器加原子类后吞吐数倍提升;(7) 新实例上线表现:冷启动 JIT 未预热大量超时 → 上线预热后新实例一上来就满血平稳。这些数字背后,是 87 天里 9 个人一个参数一个参数地调、一处泄漏一处泄漏地定位、一个热点一个热点地优化,但每一个都实打实地转化成了稳定性、延迟、资源利用率和定位效率的提升。当我们把这份数据汇报给管理层时,最有说服力的不是任何 JVM 名词,而是"高峰再没因为 Full GC 卡死过、半夜不再被 OOM 告警叫醒、同样的机器扛住了几倍的交易量、性能问题分钟级就能定位"这几条。

十二、留给后来者的最后一句话

87 天的 JVM 性能现代化战役,我们走过的不只是一条从 CMS Full GC 长停顿到 G1/ZGC 低延迟、从默认裸跑到按负载调优、从泄漏定位不到到堆转储加 MAT 引用链、从堆外失控被杀到 NMT 追踪、从靠猜优化到 JFR 加火焰图、从线程池拍脑袋到精细化加并发容器、从不懂 JIT 冷启动慢到分层编译加预热、从黑盒 jmap 压垮服务到 JFR 持续可观测的技术升级路,更是一次从"把 JVM 会自动管好一切误当成我可以完全不用懂 JVM、用默认参数和靠猜优化去糟蹋这台精密机器再怪它慢"到"沉下心去理解它自动 GC 加分代堆加 JIT 加堆外内存的运行机理、把它的性能压榨到极致"的范式跃迁。当一个曾经一到大促高峰就因 Full GC 而冻死十几秒的交易服务,在 G1/ZGC 之后高峰停顿稳稳压在毫秒级、当一处缠绕了我们大半年的内存泄漏在 MAT 的引用链分析下原形毕露、当一个堆内一切正常却总被系统杀掉的诡异故障在 NMT 下真相大白、当一个折腾了三天的性能问题在火焰图前五分钟就锁定了热点、当高并发的吞吐在换掉重锁之后数倍跃升、当一个新扩容的实例在预热之后一上线就满血运行的那一刻,真正让我们踏实的,不是加了多少台机器,而是'服务的稳、快、省,终于从依赖默认参数和重启的运气,变成了由 GC 选型、数据驱动剖析、源头减分配、堆外治理、JIT 预热和持续可观测这套工程方法结构性保障'的笃定。JVM 性能没有银弹,关键是理解 GC、堆与泄漏、剖析、对象分配、线程与锁、堆外内存、JIT、可观测各自解决什么问题、又各自带来什么代价,然后从选对 GC 和开好堆转储与监控这些地基做起、用剖析和预热落地——尤其要克制"图省事用默认参数裸跑、图省事靠经验猜着优化、图省事只盯堆不看堆外、图省事拍脑袋配线程池、图省事 synchronized 锁整个方法、图省事出事才 jmap"的旧习惯,因为每一个没调过的默认参数、每一次没有数据支撑的盲目优化、每一块没被监控的堆外内存、每一把粗粒度的重锁,都是在亲手埋下未来某次高峰卡死、OOM 崩溃、进程被杀或查不出根因的事故。愿每一位还在和 Full GC 长停顿、内存泄漏、堆外 OOM 和冷启动慢搏斗的同行,都能早日让自己的 JVM 服务被这套性能工程方法稳稳地托住。共勉,后会有期。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

从粗放 Go 微服务裸 go 无限开 goroutine 泄漏暴涨几十万 OOM + 无 context 传递被慢下游拖垮全链路无限干等堆积雪崩 + err 被 _ 丢掉漏接出错不知哪层报的 + 滥用 panic 当错误处理没 recover 一个边角崩整个进程 + 共享变量裸读写 data race 偶发脏数据和 concurrent map writes 崩溃 + interface{} 加类型断言丢类型安全运行时动不动 panic + 手撸 WaitGroup 加 error channel 协调并发又长又易错死锁漏收 + 热点频繁分配小对象 GC 压力大 STW 停顿肉眼可见 + 线上黑盒哪慢哪漏全靠猜 + 部署直接 kill 硬断在途请求连接 → 2026 现代高并发 Go 工程 worker pool 受控并发加 context 退出 + context 全链路传递超时取消 + 显式 error 加 %w wrapping 加 errors.Is/As + Mutex/atomic/channel 保护加 -race 检测 + 泛型 type-safe 编译期保证 + errgroup 统一并发错误取消 + sync.Pool 复用减少逃逸 + pprof/trace/metrics 可观测 + signal 加 context 优雅关闭 drain 排空 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

2026-5-29 0:18:30

技术教程

从粗放 MySQL 交易库 @Transactional 包住整个大方法连远程支付调用都裹进事务里行锁被慢接口绑架数秒高峰锁等待瀑布堆积连接池占满雪崩 + 压根不懂隔离级别用默认或乱设脏读不可重复读幻读分不清还把快照读和 FOR UPDATE 当前读混用读出灵异结果 + 凡是更新就无脑 select for update 悲观锁把读多写少场景的并行更新硬串行化 + 加锁顺序五花八门死锁频发只能靠重启清场 + 更新条件不走索引 InnoDB 行锁退化成扫描路径锁住一大片甚至全表把无关更新全阻塞 + 热点大商家账户单行被每秒上万笔成交更新行锁让请求排成长龙吞吐卡死 + 库存扣减用裸 read-modify-write 并发下丢失更新导致严重超卖发不出货 + 大事务循环更新几万行跑几分钟持锁堆 undo 拖慢全库还有僵尸事务赖着不走 + 锁等待死锁长事务全是黑盒出事才 SSH 上去 show processlist 肉眼抓瞎 → 2026 现代高并发数据库工程 短事务只包必须原子的 DB 写远程调用挪到事务外 + 理解四个隔离级别权衡默认 RR 分清 MVCC 快照读当前读 + 读多写少用乐观锁版本号 CAS + 统一按主键升序加锁加死锁监控加自动重试 + 写条件必走索引 EXPLAIN 确认行锁精准只锁命中行 + 热点账户余额分桶拆成多行分散并发读时 SUM 合并 + 原子 UPDATE x=x±? 加 stock>=1 条件根治丢失更新和超卖 + 批量拆成分批小事务加长事务监控告警 + performance_schema 持续度量锁等待和长事务做大盘告警 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

2026-5-29 0:45:59

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索