《Java 虚拟机原理与性能调优》7.2 内存泄漏定位:增长曲线、直方图与引用链
内存一直涨不等于泄漏。本节用 -Xmx64m 把一个 static Map 逼到真实 OutOfMemoryError,采样出 13 MB 到 67 MB 的线性增长曲线,用直方图盯住 [B 的实例与字节,并用 WeakHashMap 实验说明弱引用为什么救不了值反向持有键的缓存。
tag
内存一直涨不等于泄漏。本节用 -Xmx64m 把一个 static Map 逼到真实 OutOfMemoryError,采样出 13 MB 到 67 MB 的线性增长曲线,用直方图盯住 [B 的实例与字节,并用 WeakHashMap 实验说明弱引用为什么救不了值反向持有键的缓存。
JNI 需要写头文件、编译胶水代码、处理引用计数。本节用 JDK 25 正式的外函数与内存 API 编译 libisbn,通过 Linker、SymbolLookup 和 MemorySegment 真实调用 isbn13_check_digit,演示 Arena 生命周期与结构体布局,并记录不加 --enable-native-access 时的告警原文。
堆转储不是截一张内存快照就完事。本节用 500 个 64 KB 数组实测 jcmd GC.heap_dump 生成 37 MB hprof,讲清 shallow size、retained size 与支配树,给出 GC Root 路径的读法、MAT 操作步骤和 libraryd 的 dump 落盘策略。
classpath 上的 libraryd 能编译、能启动,却可能在运行时才暴露缺包和 split package。本节真写 module-info.java,用 jdeps 分析依赖、打成 modular jar,再用 jlink 裁出 32 MB、3 个模块的运行时,并记录缺模块、自动模块名和 classpath 绕过模块描述符的真实输出。
ZGC 的停顿为什么和堆大小基本无关?染色指针与读屏障到底做了什么?本节用本机 10 秒 G1 与 ZGC 对照实验拆开停顿、MMU、Allocation Stall 与 RSS,并说明换收集器前要算清的三笔账,读完能判断什么时候该换、换了要付什么代价。
G1 的 region 到底多大?多大的对象算 Humongous?IHOP 和 MaxGCPauseMillis 改了为什么没效果?本节用本机实测拆开 Region、Humongous、Mixed GC 与停顿目标,并给出 libraryd 导出任务避开大对象的写法。
一行 GC(9) Pause Young (Mixed) 431M->313M(512M) 0.964ms 里每个字段是什么意思?本节用本机 12 秒真实日志拆字段、讲标签、复现 Evacuation Failure,并给出解析停顿的 gc-summary.py。
五种收集器在同一台 M1 Pro、同一份负载下的真实对照:Serial 与 Parallel 的日志形状、G1 的 region、ZGC 的 0.053ms 最大停顿和 Shenandoah 的 256K region,附选型表与可复现命令。
同样一段分配负载,Serial 打印 DefNew/Tenured,Parallel 打印 PSYoungGen/ParOldGen,G1 打印的是一串 region 计数。本节用三份真实 GC 日志拆开分代假设、三种回收算法与晋升年龄,并在 -Xmx64m 下量出年龄表最高只到 age 6。
穷尽 switch 只解决了「分支是否齐全」,没有解决 null、守卫顺序和模式嵌套过深。本节用 LoanState 演示 case null 的三种写法、when 守卫的求值规则、模式支配的编译错误,并说明 JDK 25 中 primitive patterns 仍是预览特性,不能带进生产代码。
libraryd 的 LoanCache 清了引用还占着 1.2 GB。本节用 GC Root 实验、弱/软/虚引用与 Cleaner 的真实输出,量出 -Xmx64m 下软引用只剩 16/4096,并解释 WeakHashMap 为什么留不住条目。
ErrorCode 枚举加 default 分支的问题不是写法丑,而是新增错误码时编译器不会提醒任何消费方。本节用 LoanState 和 LoanResult 的 sealed 层次演示穷尽 switch,真实编译一次「新增子类型」的失败,并给出 libraryd 中罚金、下一步动作与 HTTP 映射的落地代码。
加载一个插件类,热了五分钟的接口调用突然退回解释器。本节用 -XX:+PrintCompilation 抓 marked for deoptimization 与 uncommon trap,用 JFR 的 jdk.Deoptimization 事件看 class_check 的 bci、原因与动作,再解释 OSR 为什么在循环中途换代码,以及 libraryd 插件热加载该怎么避开反优化。
借阅结果从 service 传到 HTTP 层时,instanceof 加强制转换把类型判断和取值拆成两段。本节用 record pattern 把 LoanResult、LoanState 和嵌套 record 一次解构,讨论未命名模式、守卫与泛型擦除带来的边界,并给出可编译的 libraryd 展示层代码。
同样的罚金计算,走单态调用是 0.74 ns/op,走四实现的多态调用是 3.44 ns/op。本节用 -XX:+PrintInlining 读内联与拒绝原因,用 -XX:-DoEscapeAnalysis 对照 GC 次数,再用 JMH 1.37 跑单态、双态与巨态分派,最后落到 libraryd 的 FinePolicy 策略设计。
虚拟线程让等待变便宜,但不会让连接变多。本节用 400 个任务打 10 个连接的实验说明并发度仍要显式限制,用池化线程上的 ThreadLocal 泄漏演示请求串号,再用 ScopedValue 与令牌桶给出 libraryd 的三件套落地写法。
同一个 FinePolicy.fineCents,启动时跑在解释器里,几十毫秒后已经被 C2 编译。本节用 -XX:+PrintCompilation 的真实输出拆解列含义与 tier 3/4 迁移,用 jcmd Compiler.queue、Compiler.codecache 看编译队列和代码缓存,并对照 -XX:TieredStopAtLevel=1 的峰值与启动耗时。
三个查询扇出,一个 50ms 就失败,另一个却跑满 400ms——invokeAll 不会取消兄弟任务。本节用 JDK 25 预览版的 StructuredTaskScope 把 DashboardService 的三路聚合改成「全成功或全取消」,并实测失败 44ms 返回、超时 106ms 中止。
80 万个 LoanEvent 在 1.03 秒内分配完,折合 2.5 GB/s。本节用 -Xlog:gc+tlab=debug 读 TLAB totals、用 gc+plab 看 PLAB、用 gc+region 定位 Humongous,再用 JFR 的 jdk.ObjectAllocationSample 找分配热点,最后讨论 libraryd 每请求创建 LoanEvent 的取舍。
2000 个虚拟线程只用了 10 个载体线程,同一个虚拟线程睡一觉就换了载体。本节用三个可复现实验拆开挂起、迁移与 pinning,验证 JDK 24 JEP 491 之后 synchronized 不再 pin,而本地调用仍然占住载体,并给出 Javalin 7 的落地配置。