qinghuan
← JUC + JVM / 全部笔记

单元 115 / JUC + JVM

P1 · 重点提升L3 · 深入

性能诊断与 OOM:先定位资源,再选择证据工具

先辨认 CPU、等待或哪类内存出了问题,再用线程记录、JFR 或内存分析追到具体资源和引用关系。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能按故障现象选择低成本证据,并说明采样结论和内存转储各自的限制。

学习理由、准备要求与知识点

为什么学它

高 CPU、线程阻塞和 OOM 需要不同证据,诊断命令也可能增加服务负担。

先读这些内容

知识点清单

  • CPU 与等待
  • 线程转储
  • JFR
  • 堆直方图
  • heap dump
  • GC roots
  • 本地内存
  • 证据与影响预算

运行前先确认JDK17 HotSpot诊断工具与MemoryUsage,源码固定17.0.12+7。保留内存算法处理静止强引用模型,不读取真实堆转储;诊断命令需针对实际进程核对支持与影响。

01先弄懂它是什么

排查性能时先描述现象:CPU 很高、请求很慢、线程不断增加,还是内存一直涨?OutOfMemoryError 也不只表示 Java 堆满,还可能与类元数据、直接缓冲区、原生线程或过大数组有关。操作系统或容器甚至可能直接结束进程,来不及抛 Java 异常。先保存错误原文、退出原因、时间和资源限制,再选择检查工具。

Java 中常见的内存泄漏,是业务已不需要的数据仍被强引用保留。一个对象自己可能很小,却连着一大片数据,因此要区分对象自身大小和它独自保留的数据总量。例如某缓存节点只有几十字节,但它是大量条目的唯一入口;移除这条引用可能释放很多空间。如果其他地方也引用这些对象,就不能全部重复算在它头上。

选诊断工具前,先确定想回答什么。指标适合看一段时间的变化;线程栈看线程现在执行或等待在哪里;JFR 记录一段时间里的事件;类直方图看哪些类型的对象多、占用大;堆转储可以继续追查是谁引用着这些对象;NMT 则补充 HotSpot 内部原生内存分类。它们提供的信息和采集成本不同,应按当前怀疑的原因选择,没必要一开始就给所有线上进程做大型转储。

文中这几个词,先认一认

浅大小 Shallow Size
只计算这个对象本身占用的内存,不包括它引用的其他对象。
保留大小 Retained Size
如果这个对象不再可达,随之也失去所有根引用、能一起释放的对象总大小。
支配 Dominator
从任何根走到某对象都必须经过另一个对象,后者就是前者的支配者。
RSS
操作系统记录的进程当前驻留物理内存量,不等于 Java 堆已用量,也不等于预留地址空间。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先确认具体PID和JDK,使用 jcmd <pid> help 查看目标进程支持的命令;不要用PID 0批量作用所有进程。可从 VM.version、VM.flags、GC.heap_info 和 Thread.print 等信息建立现场,再根据CPU、分配或锁等待假设选择JFR。诊断输出绑定时间与负载,单次快照不能说明持续趋势。

如果每次 GC 后剩下的堆占用仍不断上涨,可以先用类直方图找哪些类型在增长,再用堆转储查它们被谁保留、如何一路连到 GC 根;支配树能帮助找到关键的保留入口。GC.class_histogram 和 GC.heap_dump 可能明显影响运行,后者默认还可能请求 Full GC,执行前要核对目标进程的命令帮助、磁盘空间和允许操作的时间。转储里也可能带着请求内容与凭据,应限制谁能访问以及保留多久。

如果堆较稳定但RSS增长,检查线程数量、直接内存、类元数据、代码缓存和其他原生分配。需要NMT时在启动配置启用 -XX:NativeMemoryTracking=summary,再使用 VM.native_memory baseline 与 summary.diff 比较变化。NMT不是所有第三方原生库的完整分配追踪,也不能把reserved、committed和RSS当成同一个量。

正在加载流程图…

按症状选择证据,再从保留路径或执行事件定位原因;不要只根据异常类名调堆。
查看流程图源码(Mermaid)
flowchart TD
 S["记录症状、时间、PID与退出原因"] --> H{"堆回收后基线增长"}
 H -->|是| D["直方图与堆引用链"]
 D --> R["支配树、GC根、缓存与队列生命周期"]
 H -->|否但RSS增长| N["线程、直接内存、元数据与NMT"]
 H -->|主要是延迟或CPU| F["JFR、线程栈、锁与IO事件"]
 R --> X["形成可证伪的根因假设"]
 N --> X
 F --> X
 X --> V["修改后以相同负载验证趋势与业务结果"]

按顺序看,每一步做了什么

  1. 保存错误原文、进程版本、时间与容器或系统限制。
  2. 确认目标PID并查看诊断命令帮助和影响说明。
  3. 从低成本趋势定位到堆、原生内存、线程或执行热点。
  4. 必要时采集有界JFR或受控转储,分析引用与等待关系。
  5. 修复保留或容量原因,以同负载观察回收后趋势和SLO。

03对照实现代码

MemoryUsage区分used、committed与max (新标签页)↗

OpenJDK17.0.12+7 · MemoryUsage#MemoryUsage / getUsed / getCommitted / getMax

原创Java指标边界模型,映射固定源码的基本数值约束。它不是内存采样器,max为-1表示未定义,不可解释为负容量或零使用量。

核心机制代码 · java
final class UsageModel {
    record Usage(long used,long committed,long max) {
        Usage {
            if(used<0 || committed<used || max < -1)
                throw new IllegalArgumentException("inconsistent memory usage");
            // used不能超过已提交容量,未知max用-1表达。
            if(max>=0 && committed>max) throw new IllegalArgumentException("above max");
            // 只有最大值已定义时才检查其上界。
        }
        long freeWithinCommitted() { return committed-used; } // 不是整个进程可用物理内存。
    }
}

MemoryUsage保存init、used、committed和max四个量,构造器校验数值关系。used表示相应内存池当前使用,committed表示为该池保证可用的容量,max可能未定义。监控代码若把未知最大值直接参与百分比计算,会制造负数或无意义告警。

构造器会检查 used 不超过 committed;如果 max 有定义,还会检查 committed 不超过 max。这只保证这组内存池数据之间的关系合理,不保证操作系统还有足够物理内存,也不保证该内存池还能继续扩大。比较 JVM 内存池数据和进程驻留内存时,要先写清各自统计了哪些部分,否则两个正确的数字也可能被拿来做错误比较。

MemoryUsage 是取样那一刻的记录,不会自己更新。把同一个对象缓存起来,再反复调用它的 getter,读到的仍是那次采样的值。要看变化,需要从相应 MXBean 重新取样,并记下时间。几项指标如果分别在不同时间采集,就不能直接说它们共同描述了某一个精确瞬间。

源码层面的数值检查可启发监控设计:先验证数据合法与可用,再计算利用率、增长率和告警。诊断时保留原始值和单位,比只展示一个百分比更有用。大小、速率和时间窗口混用会让本来正确的数据得到错误解释。

代码先调用谁,再调用谁

  1. 管理接口从相应内存池取得一次使用快照
  2. MemoryUsage按契约保存并校验数值
  3. 监控读取used、committed与可选max
  4. 记录采样时间与单位后计算派生指标
  5. 与GC、进程RSS及业务负载交叉解释

04动手写一个例子

通过删除可达性计算一个对象的保留大小

给定静止强引用图、根和每对象大小,计算对象x的保留大小:比较原始可达集合与移除x后的可达集合,求因此失去可达性的对象总大小。原本不可达对象不计入,共享子对象不能重复归属。

输入 1
根0;0→1、0→3、1→2、3→2;大小[1,2,10,3];移除1
输出 1
仅对象1失去可达性,保留大小2
输入 2
同图移除0
输出 2
所有原可达对象失去可达性,保留大小16

先从根遍历得到baseline,再以同样方式遍历,但跳过被移除对象以及所有指向它的边。baseline为true而第二次为false的节点,就是其可达性依赖于x的对象。这个定义自然处理共享引用:如果还有一条绕开x的路径,对象仍可达,不计入x的保留集合。

根列表也可能直接包含x,移除时应跳过该根;其他根仍然有效。若x原本不可达,移除它不会减少原始可达集合,保留大小为零。环由visited处理,不必特殊打断。该方法针对一项查询进行两次图遍历,易于理解和验证。

对每个对象都重复计算会较昂贵,专业堆分析工具使用支配关系和支配树等算法更高效地得到大量保留大小。教学实现提供一个可核对的小图基准,可以用来验证更复杂支配算法。真实堆转储还要正确处理特殊引用、根类型与采样条件,不能把简化结果当任意堆工具的完全等价输出。

为什么这样写是对的保留集合等于原始可达集合减去删除x后的可达集合,因此每个被计入对象的全部根路径都经过x。

时间 单对象O(V+E+R),全部对象朴素计算O(V·(V+E+R))额外空间 O(V+E)

两次BFS和一次大小求和均为线性,图保存一次;对每个候选重复遍历才产生额外V倍成本。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 47 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    static boolean[] reach(List<List<Integer>> graph,int[] roots,int removed) {
        boolean[] seen=new boolean[graph.size()]; ArrayDeque<Integer> queue=new ArrayDeque<>();
        for(int root:roots) if(root!=removed && !seen[root]) { seen[root]=true; queue.add(root); }
        while(!queue.isEmpty()) {
            int node=queue.remove();
            for(int target:graph.get(node)) if(target!=removed && !seen[target]) {
                seen[target]=true; queue.add(target); // 共享子图与环均只展开一次。
            }
        }
        return seen;
    }
    static long retained(long[] sizes,int[][] edges,int[] roots,int object) {
        Objects.requireNonNull(sizes); Objects.requireNonNull(edges); Objects.requireNonNull(roots);
        int n=sizes.length;
        if(object<0 || object>=n) throw new IllegalArgumentException("object");
        List<List<Integer>> graph=new ArrayList<>();
        for(long size:sizes) { if(size<0)throw new IllegalArgumentException("size");graph.add(new ArrayList<>()); }
        for(int[] edge:edges) {
            if(edge==null || edge.length!=2 || edge[0]<0 || edge[0]>=n || edge[1]<0 || edge[1]>=n)
                throw new IllegalArgumentException("edge");
            graph.get(edge[0]).add(edge[1]);
        }
        for(int root:roots) if(root<0 || root>=n) throw new IllegalArgumentException("root");
        boolean[] before=reach(graph,roots,-1), after=reach(graph,roots,object);
        long total=0;
        for(int i=0;i<n;i++) if(before[i] && !after[i])
            total=Math.addExact(total,sizes[i]); // 只累计因本次移除失去可达性的对象。
        return total; // 原来就不可达的对象不会误计入保留大小。
    }
    static void check(boolean ok) { if(!ok)throw new AssertionError(); }
    public static void main(String[] args) {
        long[] sizes={1,2,10,3}; int[][] graph={{0,1},{0,3},{1,2},{3,2}};
        check(retained(sizes,graph,new int[]{0},1)==2);
        check(retained(sizes,graph,new int[]{0},0)==16);
        check(retained(sizes,graph,new int[]{0,2},0)==6);
        check(retained(new long[]{2,3},new int[][]{{0,1},{1,0}},new int[]{0},0)==5);
        check(retained(new long[]{2,3},new int[0][],new int[]{0},1)==0);
        check(retained(new long[]{2},new int[0][],new int[0],0)==0);
        check(retained(new long[]{0},new int[0][],new int[]{0,0},0)==0);
        try { retained(new long[]{Long.MAX_VALUE,1},new int[][]{{0,1}},new int[]{0},0);throw new AssertionError(); }
        catch(ArithmeticException expected) { }
        System.out.println("PASS 8");
    }
}

这些特殊情况也要试一试

  • 共享子对象还有其他根路径时不算被移除对象保留。
  • 目标本来不可达时结果为0。
  • 多个根和重复根不能重复计数。
  • 对象环按可达路径整体判断,字节溢出报错。
进阶选读 · 支配树与增长证据结合定位泄漏

场景堆中有数百万对象,需同时比较所有对象的保留集合,并寻找两次同负载采样之间增长的持有者。

做法建立虚拟总根并计算支配树,按树子结构汇总保留大小;结合类直方图差异、对象年龄和到根路径判断不该持续增长的持有者。大对象或大保留集只是候选,业务生命周期才能判断是否泄漏。

复杂度朴素逐对象删除法为二次量级以上,成熟支配算法可显著降低全图分析成本;具体复杂度取决于采用的支配算法与图表示。

好处与代价更高效算法适合真实堆规模,但实现与验证复杂;小图删除法清楚直观,适合作为正确性对照。

05项目里怎么用

为服务保留合适的诊断准备:有界GC日志、可用磁盘、明确转储目录、必要的JFR采集策略和启动参数记录。HeapDumpOnOutOfMemoryError有助于保留部分内存故障现场,但不能保证操作系统直接杀进程或磁盘不足时仍留下完整文件。把“配置了转储”与“实际生成且可读取”分开验收。

CPU 很高时,先分清是在做业务计算、垃圾回收、反复检查锁,还是执行原生代码;CPU 不高却很慢时,再查锁、IO 和资源池等待。JFR 的采样和事件能提供线索,但采样频率、事件阈值会影响它能记录到什么。修复后除了平均耗时,还要检查最慢的一部分请求、错误率和资源占用,确认问题没有被转移到别处。

对内存问题优先修正无限缓存、任务积压、未关闭资源和上下文保留等生命周期错误。扩大堆可以作为容量调整或临时缓解,但若业务无界增长,仍会再次耗尽;堆扩大还可能压缩原生内存余量。结论应指出观测窗口和负载,不能把短时稳定写成永远不会泄漏。

容易踩的坑

  • 看到任何OOM都只增加-Xmx。
  • 把类直方图第一名当根因而不查持有路径。
  • 混淆used、committed、reserved与RSS。
  • 用一次快照证明长期泄漏或长期无泄漏。
  • 诊断文件未核对空间、访问范围与实际生成结果。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
指标与GC趋势成本较低,适合持续观察难直接定位具体引用持有者判断增长与容量变化
JFR时间段记录关联CPU、分配、锁与IO事件受采样与配置覆盖影响性能热点和时间关联分析
堆转储与支配分析能追踪对象保留路径采集影响、磁盘与敏感数据成本高堆保留与泄漏根因调查
NMT与系统工具补充原生分类与进程视角NMT不覆盖所有外部原生分配堆外增长与进程内存排查

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01浅大小很小的对象为何可能保留大量内存?

展开参考答案与推导

它可能是大型对象图所有根路径必经的入口,删除该入口会让整片子图不可达。

02为什么共享子对象不能算入每个持有者的保留大小?

展开参考答案与推导

还有绕开某持有者的根路径时,删除它不会让共享子对象失去可达性。

03MemoryUsage.max为-1应如何处理?

展开参考答案与推导

它表示最大值未定义,不能当负容量参与利用率计算,应显示未知并保留used与committed原值。

换个情况,还会处理吗

01容器退出但没有Java OOM堆转储,如何继续?

展开参考答案与推导

检查容器和系统终止原因、内存限制、磁盘与转储配置,进程可能被直接杀死;结合此前指标与日志,不假定必有Java异常。

02堆转储中byte数组最大,能直接删除相关代码吗?

展开参考答案与推导

先查看持有路径、业务用途和增长趋势,byte数组可能只是被缓存、队列或缓冲池保留的载体。

03增加堆后原生线程创建失败,如何解释?

展开参考答案与推导

总进程资源有限,堆可能挤占线程栈与其他原生内存余量,也需检查线程数量及系统限制。

写代码试一试

01实现单对象保留大小。

输入 / 条件
0→1、0→3、1→2、3→2;大小1,2,10,3;删除1
输出 / 预期
2

提示 比较删除前后根可达集合。

展开参考答案与推导

按正文两次BFS,共享对象2仍从3可达。

02实现内存利用率的安全计算。

输入 / 条件
used50,committed100,max-1
输出 / 预期
已提交利用率50%,最大容量利用率未知

提示 先处理未知值与零分母。

展开参考答案与推导

验证非负和上下界,明确百分比分母,保留原始单位与采样时间。

03设计泄漏修复前后对照实验。

输入 / 条件
固定请求速率与数据规模,两个相同长度窗口
输出 / 预期
比较回收后基线、对象数、错误率与尾延迟

提示 先固定负载再观察趋势。

展开参考答案与推导

记录预热、采样时刻、GC条件和外部依赖,报告窗口内证据而非宣称永久无泄漏。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能按故障现象选择低成本证据,并说明采样结论和内存转储各自的限制。

本篇重点加练

GC roots 用来解释保留关系,证据预算则决定故障现场可以安全采集到多完整的信息。

  • GC roots
  • 证据与影响预算

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01面试场景

    接口变慢时进程 CPU 分别为接近满核与接近空闲,线程数量相同。为两份现象选择初始证据,并列出线程转储中需要核对的信息。

    提交内容
    两份诊断路径
    怎样算完成
    • 区分运行、等待和阻塞
    • 避免由单份栈快照断言长期行为

    练到的知识点CPU 与等待 · 线程转储

  2. 作业 02原理问答

    只能在故障实例上采集 60 秒轻量证据,管理员建议立即导出大堆。比较 JFR 与堆直方图能回答的问题,提出采样顺序。

    提交内容
    证据选择表
    怎样算完成
    • 列出预期影响和数据范围
    • 注明工具版本及采集权限

    练到的知识点JFR · 堆直方图

  3. 作业 03原理问答

    同一对象可经静态缓存、线程局部值和监听器列表访问。为三条路径分别声明持有者生命周期,评审“删除最大的数组”是否具有足够依据。

    提交内容
    保留路径与责任表
    怎样算完成
    • 包含共享子对象
    • 区分体积与业务必要性

    练到的知识点GC roots

综合应用

  1. 作业 04代码推演

    画出两条 GC root 路径共同指向一个大对象图的例子。分别讨论删除一条路径、删除两条路径时哪些对象可能不再可达。

    提交内容
    引用图与逐步可达性分析
    怎样算完成
    • 区分对象大小与被保留规模
    • 包括共享子图

    练到的知识点heap dump · GC roots

  2. 作业 05设计评审

    服务的堆使用稳定但进程内存持续增加,磁盘也仅剩 500 MiB。制定本地内存排查与证据保存预算,说明何时不宜生成大文件。

    提交内容
    诊断计划与空间预算
    怎样算完成
    • 覆盖线程及其他本地资源
    • 为每个采集动作给成本与停止条件

    练到的知识点本地内存 · 证据与影响预算

  3. 作业 06排错实验

    在独立本地 Java 进程中设置严格堆和运行时间上限,制造可释放与持续保留两种缓存模式,采集小规模诊断证据并比较。

    提交内容
    实验代码与证据报告
    怎样算完成
    • 只终止实验子进程
    • 说明证据中的对象引用与业务行为如何对应

    练到的知识点CPU 与等待 · 线程转储 · JFR · 堆直方图 · heap dump · GC roots · 证据与影响预算

  4. 作业 07设计评审

    线上仅允许一次短暂停顿且剩余磁盘有限,需要选择线程快照、JFR 或堆转储。按问题优先级制定采集顺序和可放弃的证据。

    提交内容
    证据预算与停止规则
    怎样算完成
    • 写明未知成本的估计方式
    • 不能把工具列表当作调查目标

    练到的知识点证据与影响预算

  5. 作业 08编程实践

    在本地小堆子进程加载可控监听器集合,分别注册与解除引用,记录可用证据并整理保留假设;所有采集文件设置数量上限。

    提交内容
    实验代码与证据目录说明
    怎样算完成
    • 仅处理实验进程
    • 不从单次回收未发生推断仍被强引用

    练到的知识点GC roots · 证据与影响预算

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    值班人员只有容器终止记录和上次 JFR,没有 heap dump。提出可继续验证的假设并为下次复现补齐观测,标明暂时无法确定的结论。

    提交内容
    调查报告与观测变更单
    怎样算完成
    • 区分 JVM 错误与外部终止
    • 不把缺少转储当作没有内存问题

    练到的知识点CPU 与等待 · JFR · heap dump · 本地内存 · 证据与影响预算

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    故障现场禁止导出含用户数据的 heap dump。探究利用脱敏计数、采样与引用结构摘要还能检验哪些泄漏假设,以及哪些结论必须保持未知。

    提交内容
    替代证据研究方案
    怎样算完成
    • 说明信息损失
    • 不绕过数据导出限制

    练到的知识点GC roots · 证据与影响预算

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗