Skip to content

JVM 常用排查工具

JVM 排查不是背几个命令,而是把线上现象一步一步还原成证据:进程还在不在、线程卡在哪里、GC 是否异常、对象为什么还活着、内存到底花在堆里还是堆外。

本页按生产排查方式讲清楚 JVM 工具,重点补齐 OOM 排查。零基础先记住一句话:

OOM 不是一句“内存不够”,而是一类结果。先分类,再保留证据,再定位引用链或资源上限,最后用代码、参数和容量设计一起修。

学习目标

学完本页要能回答这些问题:

  1. 线上 JVM 出问题时先保留哪些证据。
  2. Java heap spaceMetaspaceDirect buffer memoryunable to create native thread 分别是什么原因。
  3. 为什么不能一看到 OOM 就直接调大 -Xmx
  4. heap dump、线程栈、GC 日志、NMT 分别解决什么问题。
  5. 医疗采集、订单导入、MQ 消费、Netty 网关等商业场景中如何排查 OOM。

为什么要先用工具看证据

线上问题不能靠猜。CPU 高可能是死循环,也可能是频繁 GC;接口慢可能是锁等待,也可能是数据库慢;内存涨可能是缓存,也可能是线程堆积;容器重启可能是 Java 自己抛了 OOM,也可能是 Kubernetes 发现进程超过 cgroup 限制后直接杀掉。

如果不采集线程栈、GC 指标、堆信息和容器事件,直接改代码或调参数,很可能修错方向。

如果你要按面试场景题回答“线上 OOM 怎么定位”,并且需要分别给出 Java 原生命令和 Arthas 两套解决思路,优先看:线上 OOM 怎么定位:Java 原生命令与 Arthas 两套思路

常见错误做法:

错误做法为什么危险
一看到 OOM 就把 -Xmx 调大可能只是延缓爆炸,真正的泄漏还在;堆越大,Full GC 和 dump 成本越高
没保留 dump 就重启重启后现场消失,无法知道哪些对象占内存
只看平均内存OOM 常常发生在峰值、批处理、导入、消费堆积时,平均值掩盖问题
只看 Java 堆容器内存还包括直接内存、线程栈、元空间、代码缓存、本地库等
只抓一次线程栈单次快照可能刚好处在正常等待,连续抓多次才有趋势

JVM排查总流程

mermaid
flowchart TD
    A["线上服务异常"] --> B["确认现象: 慢、卡、CPU高、内存涨、重启"]
    B --> C["保留证据: 日志、GC日志、线程栈、dump、容器事件"]
    C --> D{"是否 OOM 或内存异常"}
    D -- "是" --> E["按错误文本分类 OOM"]
    D -- "否" --> F["按 CPU、线程、锁、IO、下游继续排查"]
    E --> G["定位对象、类、直接内存、线程或容器限制"]
    G --> H["修代码、限容量、调参数、加监控"]
    H --> I["压测验证并复盘预防"]

这张图的关键是“先分类”。不同 OOM 的根因完全不同,堆 OOM 查对象引用链,线程 OOM 查线程数量和 -Xss,直接内存 OOM 查 NIO/Netty/ByteBuffer,Metaspace OOM 查类加载器和动态生成类。

证据清单

线上出现 JVM 问题时,优先保留这些证据。

证据解决什么问题常用方式
应用错误日志看异常类型、业务链路、traceIdapp.log、日志平台
GC 日志看 GC 频率、停顿、回收效果-Xloggc、JDK 9+ -Xlog:gc*
JVM 参数看堆、元空间、GC、直接内存、容器识别jcmd pid VM.flags
线程栈看线程卡在 CPU、锁、IO、连接池还是等待队列jstackjcmd Thread.print
对象直方图看哪些类对象数量和内存大jmap -histo:live
heap dump看对象引用链和为什么不能回收jcmd GC.heap_dump、MAT
Native Memory看堆外、线程栈、类元数据、本地库NMT、jcmd VM.native_memory
容器事件判断是否 OOMKilled 或资源限制kubectl describe pod

证据采集顺序要结合风险:heap dump 可能很大,生产上可能造成暂停,先确认磁盘空间和业务影响。线程栈相对轻量,可以连续抓多次。

常用命令速查

命令作用适合场景
jps -l查看 Java 进程找 PID
jcmd pid VM.flags查看 JVM 参数确认堆、GC、元空间配置
jcmd pid VM.system_properties查看系统属性确认环境、编码、配置来源
jstat -gcutil pid 1000每秒查看一次 GC 概况判断 Young GC、Full GC 是否频繁
jmap -histo:live pid查看存活对象直方图快速判断大对象类型
jcmd pid GC.class_histogram查看类实例数量JDK 自带对象统计
jcmd pid GC.heap_dump /tmp/app.hprof导出堆 dump分析引用链和泄漏
jstack pid导出线程栈卡死、CPU 高、锁等待
jcmd pid Thread.print打印线程栈jstack 替代方式
jcmd pid VM.native_memory summary查看本地内存直接内存、线程栈、元空间排查

Linux 和容器中常配合:

bash
top -Hp 进程ID
pmap -x 进程ID
ulimit -u
kubectl describe pod pod名
kubectl logs pod名 --previous

kubectl logs --previous 很重要:如果容器已经重启,当前日志可能看不到上一次崩溃前的 OOM 或错误栈。

OOM类型总览

OOM 必须先看错误文本。文本不同,排查方向不同。

错误或现象含义优先排查
OutOfMemoryError: Java heap spaceJava 堆放不下对象heap dump、对象直方图、GC Roots 引用链
OutOfMemoryError: GC overhead limit exceededGC 花大量时间却回收很少堆中大量对象仍存活,查泄漏或容量不足
OutOfMemoryError: Metaspace类元数据所在本地内存不足动态代理、类加载器泄漏、热部署
OutOfMemoryError: Direct buffer memory直接内存不足NIO、Netty、ByteBuffer.allocateDirect
OutOfMemoryError: unable to create native thread创建线程失败线程数、-Xss、OS 限制、容器限制
Requested array size exceeds VM limit数组太大超过 JVM 限制大文件、一次性加载、分页/分块设计
容器 OOMKilled、退出码 137进程被 cgroup 杀掉JVM 总内存和容器 limit,不一定有 Java OOM 栈

注意:容器 OOMKilled 不一定会留下 Java OutOfMemoryError。Kubernetes 看到进程超过内存限制后可能直接发信号杀掉进程,Java 还没来得及打印堆栈。

OOM排查完整流程

mermaid
flowchart TD
    A["发现 OOM 或容器重启"] --> B["读取错误文本和容器事件"]
    B --> C{"是哪类内存"}
    C -- "Java heap space" --> D["导出 heap dump 和对象直方图"]
    C -- "Metaspace" --> E["查看类数量、ClassLoader、动态生成类"]
    C -- "Direct buffer memory" --> F["查看直接内存、Netty、ByteBuffer 使用"]
    C -- "unable to create native thread" --> G["查看线程数、Xss、系统 ulimit"]
    C -- "OOMKilled" --> H["核算进程总内存和容器 limit"]
    D --> I["用 MAT 看 Dominator Tree 和 GC Roots"]
    E --> J["限制动态类、修 ClassLoader 泄漏、设置 MaxMetaspaceSize"]
    F --> K["释放 DirectByteBuffer、检查池化和 Netty 泄漏"]
    G --> L["收敛线程池、降低 Xss、修无限创建线程"]
    H --> M["调整 limit/request 与 JVM 内存预算"]
    I --> N["修复后压测和加监控"]
    J --> N
    K --> N
    L --> N
    M --> N

不要只停在“哪个类占内存最大”。真正的关键是对象为什么还活着。MAT 的 Dominator Tree 解决“谁支配了最多内存”,Path To GC Roots 解决“它为什么没有被回收”。

Heap OOM:Java heap space

堆 OOM 表示 Java 堆里对象太多或对象太大,GC 后仍然放不下新对象。

典型根因:

根因例子正确处理
无界集合static List 不断 add 采集数据设置容量、分批处理、处理完清理
缓存无淘汰本地 Map 缓存所有用户权限使用 Caffeine/Redis,设置最大容量和过期
大文件一次性读入Files.readAllBytes 读 2GB 文件流式读取、分块解析、分批入库
MQ 消费内存堆积拉取很多消息放内存再慢慢处理控制拉取批次、消费并发、背压
ThreadLocal 未清理线程池线程保存大对象finally remove()
查询无分页一次查出百万行分页、游标、按时间窗口处理

Heap OOM排查步骤

  1. 看 OOM 时间点是否对应导入、批处理、MQ 堆积、活动流量峰值。
  2. 看 GC 日志,确认 Full GC 后堆是否仍然很高。
  3. jmap -histo:live pid 快速看存活对象类型。
  4. 导出 heap dump,用 MAT 看 Dominator Tree。
  5. 对最大对象看 Path To GC Roots,确认引用链来自静态变量、线程、队列、缓存还是 ThreadLocal。
  6. 回到代码修复容量边界、分页、清理、缓存淘汰或引用释放。

Heap OOM Demo

下面 Demo 用来理解堆 OOM,不要在生产执行。

java
import java.util.ArrayList;
import java.util.List;

public class HeapOomDemo {
    static class Record {
        private final byte[] payload = new byte[1024 * 1024];
    }

    public static void main(String[] args) {
        List<Record> records = new ArrayList<>();
        while (true) {
            records.add(new Record());
            System.out.println("records = " + records.size());
        }
    }
}

运行示例:

bash
java -Xms64m -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp HeapOomDemo

为什么会 OOM:records 一直引用每个 Record,这些对象从线程栈局部变量 records 可以到达,所以 GC 不能回收。不是 GC 不努力,而是对象仍然“活着”。

GC overhead limit exceeded

这个错误表示 JVM 大部分时间都在 GC,但每次只能回收很少内存。它通常不是新类型的根因,而是堆压力的另一种表现。

你可以这样理解:

mermaid
flowchart TD
    A["堆快满"] --> B["频繁 Full GC"]
    B --> C{"每次能回收多少"}
    C -- "很少" --> D["对象仍被引用"]
    D --> E["GC overhead limit exceeded"]

常见原因仍然是集合、缓存、队列、ThreadLocal、查询无分页、大对象等。排查方式和 heap OOM 类似,重点看存活对象和引用链。

Metaspace OOM

JDK 8 以后,HotSpot 移除了永久代,类元数据主要放在 Metaspace。Metaspace 使用本地内存,不在 Java 堆里。

JDK 7 和 JDK 8 的高频区别:

版本类元数据区域常见错误
JDK 7永久代 PermGenOutOfMemoryError: PermGen space
JDK 8元空间 Metaspace,本地内存OutOfMemoryError: Metaspace

Metaspace 里放的是类元数据,不是普通业务对象。普通 new User() 多了通常是堆 OOM;动态生成类、类加载器不释放才更容易导致 Metaspace OOM。

典型根因:

  1. 动态代理、CGLIB、ByteBuddy、Groovy 脚本持续生成新类。
  2. 热部署或插件系统反复创建 ClassLoader,但旧 ClassLoader 被线程、缓存、静态变量引用。
  3. 应用容器中重复部署,旧应用类加载器没有释放。
  4. 没有限制 -XX:MaxMetaspaceSize,容器内本地内存被元空间吃掉。

排查思路:

bash
jcmd 进程ID VM.classloader_stats
jcmd 进程ID GC.class_histogram
jcmd 进程ID VM.native_memory summary

VM.classloader_stats 能帮助观察 ClassLoader 数量和类元数据占用。NMT 需要启动参数开启,例如:

bash
-XX:NativeMemoryTracking=summary

Direct buffer memory

直接内存是堆外内存,常见于 NIO、Netty、文件传输、网络通信。

java
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);

它和普通堆对象的区别:

类型内存位置常见用途回收特点
ByteBuffer.allocateJava 堆普通字节数组受堆 GC 管理
ByteBuffer.allocateDirect堆外本地内存NIO、网络、零拷贝场景依赖 DirectByteBuffer 对象和 Cleaner 触发释放

直接内存 OOM 常见原因:

  1. 不断创建 direct buffer,释放不及时。
  2. Netty ByteBuf 没有 release,引用计数泄漏。
  3. -XX:MaxDirectMemorySize 设置过小。
  4. 容器 limit 太小,堆、直接内存、线程栈加起来超过限制。

Direct OOM Demo

java
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class DirectMemoryOomDemo {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        while (true) {
            buffers.add(ByteBuffer.allocateDirect(10 * 1024 * 1024));
            System.out.println("allocated direct buffers = " + buffers.size());
        }
    }
}

运行示例:

bash
java -Xmx128m -XX:MaxDirectMemorySize=64m DirectMemoryOomDemo

为什么 -Xmx 不大也可能 OOM:直接内存不属于 Java 堆,-Xmx 限制的是堆,直接内存还要单独看 MaxDirectMemorySize 和容器总内存。

Netty 项目排查要开启泄漏检测时,可以临时使用:

bash
-Dio.netty.leakDetection.level=advanced

生产不要长期最高级别开启,否则性能开销较大。

unable to create native thread

这个错误表示 JVM 想创建新线程,但操作系统或容器拒绝了。

常见原因:

原因解释
线程数太多无限 new Thread,或线程池最大线程数设置过大
-Xss 太大每个线程栈占用更多本地内存,能创建的线程数减少
OS 用户线程数限制ulimit -u 限制
容器内存不足线程栈也是进程内存的一部分
线程泄漏任务结束后线程没有退出,比如自建调度线程、连接线程

排查命令:

bash
jstack 进程ID > /tmp/app.jstack
ps -eLf | grep java | wc -l
ulimit -u
jcmd 进程ID VM.flags

生产修复方式:

  1. 禁止接口请求里无限 new Thread
  2. 统一使用有界线程池。
  3. 合理设置线程池最大线程数和队列。
  4. 对线程命名,方便从线程栈识别来源。
  5. 必要时调小 -Xss,但要防止递归或深调用导致栈溢出。

容器 OOMKilled 和 Java OOM 的区别

在 Docker/Kubernetes 中,服务经常不是 Java 自己抛 OOM,而是被容器运行时杀掉。

mermaid
flowchart TD
    A["Java进程总内存上涨"] --> B{"是否超过容器 limit"}
    B -- "否" --> C["继续运行或 Java 内部报 OOM"]
    B -- "是" --> D["内核触发 OOM Killer"]
    D --> E["容器退出码 137"]
    E --> F["Kubernetes 显示 OOMKilled"]

Java 进程总内存不等于 -Xmx

text
进程总内存 ~= Java堆 + 元空间 + 直接内存 + 线程栈 + Code Cache + GC本地结构 + JNI/本地库 + JVM自身开销

举例:容器 limit 是 1GB,如果你设置 -Xmx900m,再加上 Metaspace、Direct Memory、线程栈和 JVM 自身,很容易超过 1GB 被杀。

生产建议:

  1. 容器内不要把 -Xmx 贴着 limit 设置。
  2. 预留堆外、线程栈、元空间和 JVM 本身开销。
  3. JDK 8 旧版本要注意容器内存识别能力,企业老项目尤其要确认。
  4. 查看 kubectl describe pod 的 Last State 和 Events。
  5. 查看 kubectl logs --previous 保留上一次崩溃日志。

CPU飙高排查

CPU 高不一定是业务代码死循环,也可能是 Full GC 太频繁。先分辨 CPU 花在哪。

mermaid
flowchart TD
    A["CPU 飙高"] --> B["top 找进程"]
    B --> C["top -Hp 找线程"]
    C --> D["线程ID转十六进制"]
    D --> E["jstack 匹配 nid"]
    E --> F{"线程在做什么"}
    F -- "业务代码循环" --> G["检查死循环和算法复杂度"]
    F -- "GC线程活跃" --> H["检查 GC 日志和堆对象"]
    F -- "锁竞争" --> I["检查 BLOCKED 和锁持有者"]
    F -- "Native/IO" --> J["结合系统和下游指标"]

命令示例:

bash
jps -l
top -Hp 进程ID
printf "%x\n" 线程ID
jstack 进程ID > /tmp/app.jstack
grep -n "十六进制线程ID" /tmp/app.jstack

连续抓三次:

bash
jstack 进程ID > /tmp/app-1.jstack
sleep 2
jstack 进程ID > /tmp/app-2.jstack
sleep 2
jstack 进程ID > /tmp/app-3.jstack

如果同一个线程一直停在同一段业务代码,就要结合日志和代码检查死循环、锁等待或慢调用。如果看到 GC 线程持续活跃,要回到 GC 日志和堆对象分析。

线程卡死和接口慢排查

接口慢常见不是 CPU 算得慢,而是线程在等。

线程栈常见状态:

状态或栈信息可能含义
BLOCKED等 synchronized 锁
WAITING等条件、队列、Future、锁
TIMED_WAITINGsleep、超时等待、连接池等待
socketRead0等下游网络响应
getConnection等数据库连接池
FutureTask.getCompletableFuture.join等异步任务结果,可能线程饥饿死锁
Unsafe.parkAQS 锁、线程池队列、条件等待

排查时不要只说“线程 WAITING”。要继续看它在等谁、为什么等、有没有超时、上游是否持续提交新任务。

商业场景:医疗采集导入 Heap OOM

场景:医疗数据采集平台每天导入病案、检验、检查、费用明细。开发为了方便,把 Excel 或 CSV 全部读进内存,再把每行转换成对象放入 List,最后统一批量入库。

错误代码:

java
List<MedicalRecord> all = new ArrayList<>();
for (String line : Files.readAllLines(path)) {
    all.add(parse(line));
}
medicalRecordMapper.batchInsert(all);

问题:

  1. Files.readAllLines 会把所有文本读到内存。
  2. all 又保存所有解析后的对象。
  3. 如果文件很大,字符串和对象会同时占用堆。
  4. 即使触发 GC,因为 all 还在引用对象,GC 也不能回收。

正确思路:

java
List<MedicalRecord> batch = new ArrayList<>(1000);

try (BufferedReader reader = Files.newBufferedReader(path)) {
    String line;
    while ((line = reader.readLine()) != null) {
        batch.add(parse(line));
        if (batch.size() == 1000) {
            medicalRecordMapper.batchInsert(batch);
            batch.clear();
        }
    }
}

if (!batch.isEmpty()) {
    medicalRecordMapper.batchInsert(batch);
    batch.clear();
}

为什么这样更稳:内存中最多保留一批数据,不会因为文件总行数变大而线性撑爆堆。批大小要结合单条记录大小、数据库能力、事务时长和失败重试成本压测确定。

商业场景:MQ消费者堆积导致 OOM

场景:订单支付成功后消息进入 MQ,消费者把消息先拉到内存队列,再由另一个线程慢慢写数据库。活动高峰时数据库慢,内存队列不断上涨,最后 OOM。

根因链路:

mermaid
flowchart TD
    A["生产者发送速度高"] --> B["消费者预拉取大量消息"]
    B --> C["内存队列缓存消息"]
    C --> D["数据库写入变慢"]
    D --> E["队列只进不出"]
    E --> F["堆内存上涨"]
    F --> G["Java heap space"]

修复方向:

  1. 控制单次拉取数量和本地队列容量。
  2. 消费速度要受数据库连接池和写入能力约束。
  3. 失败重试要退避,避免重试风暴。
  4. 消费逻辑做幂等,允许小批量提交。
  5. 监控消费 lag、队列长度、处理耗时、失败率。

商业场景:Netty网关 Direct Memory OOM

场景:采集网关用 Netty 接收医院前置机长连接。某个 Handler 保存了 ByteBuf 但没有释放,或者异步处理时引用计数没有正确 retain/release,运行一段时间后报 Direct buffer memory

排查方向:

  1. 看错误是否是 Direct buffer memory
  2. 查看 Netty leak detector 日志。
  3. 检查自定义 Handler 中是否手动保存、转发、异步处理了 ByteBuf
  4. 检查是否重复 retain 但少 release。
  5. 看 direct memory 配置和容器 limit。

修复原则:

场景做法
当前 Handler 消费完 ByteBuf确保 release
继续传递给下一个 Handler不要提前 release
异步线程继续使用先 retain,异步结束后 release
不熟悉引用计数优先使用 Netty 提供的安全模式和框架约定

商业场景:ThreadLocal泄漏

场景:接口过滤器把当前用户、租户、traceId 放进 ThreadLocal,业务中还放了较大的上下文对象。线程池线程处理完请求后没有清理,下次请求复用同一个线程。

危害:

  1. value 挂在线程的 ThreadLocalMap 上,线程不退出就可能长期不释放。
  2. 下一个请求可能读到上一个请求的用户或租户。
  3. heap dump 里可能看到对象被 Thread -> ThreadLocalMap -> Entry -> value 引用。

标准写法:

java
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    try {
        UserContextHolder.set(parseUser(request));
        chain.doFilter(request, response);
    } finally {
        UserContextHolder.remove();
    }
}

排查结果如何落地修复

JVM 排查的终点不是“发现哪个类最多”,而是把问题变成工程改动。

根因修复方向
无界集合增加容量上限、分批处理、及时 clear
本地缓存过大设置最大容量、过期策略、监控命中率和大小
大文件一次性加载流式读取、分页、游标、分片任务
MQ 消费内存堆积限制拉取、本地队列有界、背压、幂等、降级
ThreadLocal 泄漏finally remove(),禁止保存大对象
线程过多收敛线程池、命名、监控、限流
直接内存泄漏修 release、设置直接内存上限、检查 Netty 引用计数
Metaspace 增长查动态类和 ClassLoader 泄漏,限制生成类
容器 OOMKilled重新核算堆、堆外、线程栈和 limit

常见面试题

OOM怎么完整排查

先看错误文本分类,不能一上来就调大堆。堆 OOM 用 GC 日志、对象直方图、heap dump 和 MAT 看 Dominator Tree、Path To GC Roots;Metaspace OOM 查动态生成类和 ClassLoader 泄漏;Direct buffer memory 查 NIO、Netty、直接内存上限和 ByteBuf 释放;unable to create native thread 查线程数、线程池、-Xss、OS 限制;容器 OOMKilled 要看 cgroup limit、退出码 137 和 kubectl describe pod

为什么不能只调大Xmx

如果是内存泄漏,调大 -Xmx 只是让泄漏更晚爆发;如果是容器 OOM,堆调大还可能让进程总内存更容易超过 limit;如果是 Direct Memory、Metaspace、线程栈,-Xmx 甚至不是核心限制。正确做法是先分类,再定位对象或资源来源。

heap dump主要看什么

主要看 Dominator Tree、对象数量、Retained Heap 和 Path To GC Roots。Dominator Tree 告诉你谁支配了最多内存,Path To GC Roots 告诉你对象为什么还活着。只有知道引用链,才能判断是缓存、静态集合、线程、队列还是 ThreadLocal 导致对象不能回收。

容器OOMKilled和Java OOM区别

Java OOM 是 JVM 内部抛出 OutOfMemoryError,通常有错误栈和可能的 heap dump;OOMKilled 是操作系统或容器运行时发现进程超过 cgroup 内存限制后直接杀掉进程,可能没有 Java OOM 栈。排查 OOMKilled 要看 Pod 事件、退出码 137、上一次日志和进程总内存预算。

TLAB属于堆吗

属于。TLAB 是 Eden 区里给线程预留的一小块线程本地分配缓冲区,目的是让线程分配小对象时减少竞争。对象分配在 TLAB 里仍然是堆对象,仍然受 GC 管理。它和逃逸分析的分配消除不是一回事,详见 JIT逃逸分析

本章小结

JVM 排查要从证据出发。CPU 高看线程和 GC,接口慢看线程栈和下游等待,OOM 先按错误文本分类,再用 dump、直方图、NMT、容器事件定位根因。真正的生产能力不是会背 jmap,而是能把工具结果翻译成容量边界、代码修复、参数调整和监控告警。