《Java 虚拟机原理与性能调优》7.2 内存泄漏定位:增长曲线、直方图与引用链

内存一直涨不等于泄漏。本节用 -Xmx64m 把一个 static Map 逼到真实 OutOfMemoryError,采样出 13 MB 到 67 MB 的线性增长曲线,用直方图盯住 [B 的实例与字节,并用 WeakHashMap 实验说明弱引用为什么救不了值反向持有键的缓存。

本节目标:把「内存一直涨」拆成可验证的三步:看增长曲线、看类直方图、看引用链。
适用版本:JDK 25(Temurin 25.0.5+8);-Xmx64m;MAT 本机未安装,只写操作步骤。

承接的问题

7.1 讲了怎么抓一份 hprof。抓之前还有一个更基础的问题:这次到底是不是泄漏。GC 日志里老年代占用高,可能是流量峰值,也可能是缓存没有上限;两者的处理方式相反,前者扩容,后者改代码。

泄漏排查最常见的错误是直接抓 dump。几十 GB 的堆,抓一次要停顿几秒到几十秒,写盘几 GB,打开分析又要几分钟。更便宜的路径是先回答三个问题:

  1. 堆使用量是「锯齿形」还是「单调上升」?
  2. 如果一直涨,是哪个类的实例数和字节数在涨?
  3. 这个类被谁持有,为什么 GC 回收不掉?

这一节用一个 64 MB 堆的可控进程把三步跑完:先采样增长曲线,再采样直方图,最后触发一次真实 OOM 并留下 dump。实验代码故意写成反例,方便对照修法。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1。

增长曲线:先分清高水位与单调增长

反例是一个没有上限的静态缓存:

import java.util.HashMap;
import java.util.Map;

public class LeakDemo {
    // 反例:无界静态缓存,只增不减
    static final Map<String, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) throws Exception {
        System.out.println("pid=" + ProcessHandle.current().pid()
                + " maxHeap=" + Runtime.getRuntime().maxMemory());
        for (int i = 0; ; i++) {
            CACHE.put("loan-" + i, new byte[256 * 1024]);
            if (i % 5 == 0) {
                System.out.println("cached=" + CACHE.size() * 256 / 1024 + "MB");
            }
            Thread.sleep(40);
        }
    }
}

每 40 ms 放入 256 KB,理论速率约 6.4 MB/s。用 -Xmx128m 启动,刻意给足空间,然后每秒采一次 GC.heap_info:

java -Xmx128m -cp out LeakDemo
for i in 1 2 3 4 5 6; do
  sleep 1.2
  jcmd <pid> GC.heap_info | sed -n '2p'
done

本机输出(单次运行,未做统计;省略地址区间):

t=1s garbage-first heap   total reserved 131072K, committed 131072K, used 13694K
t=2s garbage-first heap   total reserved 131072K, committed 131072K, used 24702K
t=3s garbage-first heap   total reserved 131072K, committed 131072K, used 35198K
t=4s garbage-first heap   total reserved 131072K, committed 131072K, used 46462K
t=5s garbage-first heap   total reserved 131072K, committed 131072K, used 56446K
t=6s garbage-first heap   total reserved 131072K, committed 131072K, used 67710K

六个点几乎在一条直线上,斜率约 11 MB/s,而且 committed 一直是 131072K,说明 G1 没有因为压力提前扩容或频繁回收。这是典型的泄漏形状:每次 GC 后残留的活对象在增加。

正常的流量高峰是另一种形状:used 上去,GC 后落回一个稳定水位;即使峰值很高,基线不抬升。判断时要看「多次 GC 之后的低点」,不是看某一个瞬时值。Runtime.totalMemory() - freeMemory() 也不可靠,它把未回收的垃圾也算进去,只能作为粗粒度参考。

GC.heap_info 的 used 同样包含垃圾。要确认「GC 后还剩下多少」,可以配合 jcmd GC.class_histogram(会触发一次 Full GC)或 jmap -histo:live。这两个命令影响都不小,生产环境不要每秒跑。

直方图:实例数和字节数要一起看

在同一个进程里,第 2 秒和第 5 秒各抓一次直方图:

jcmd <pid> GC.class_histogram | head -6

本机输出:

t=2s
   1:          8672       13758720  [B (java.base@25.0.5)
   2:          1720         227552  java.lang.Class (java.base@25.0.5)

t=5s
   1:          8822       33422520  [B (java.base@25.0.5)
   2:          1718         227312  java.lang.Class (java.base@25.0.5)

3 秒内 [B 的实例从 8672 涨到 8822,只多了 150 个;字节数从 13.7 MB 涨到 33.4 MB,多了约 19.6 MB。3 秒按每 40 ms 一个 256 KB 算,大约放入 75 个数组,合计 19.2 MB,与字节增量 19.6 MB 基本对得上;实例增量 150 里还包含 HashMap 扩容产生的辅助数组。这说明涨的是我们放入的大数组,不是大量小对象。

这也是看直方图的基本方法:实例数和字节数一起看。

  • 实例数涨、字节数不涨:可能是小对象或字符串,先看是不是短生命周期对象堆积。
  • 实例数不涨、字节数涨:单个对象在变大,常见于缓冲区、数组、StringBuilder。
  • 两者都涨:真的在累积对象,回到代码找容器。

jmap -histo:live 会先触发一次 Full GC,只统计存活对象。在 128 MB 堆、缓存约 25 MB 时执行:

jmap -histo:live <pid> | head -8

本机输出(节选):

 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:          8764       25819184  [B (java.base@25.0.5)
   2:          1720         227552  java.lang.Class (java.base@25.0.5)
   3:          8589         206136  java.lang.String (java.base@25.0.5)
   4:          1097         136768  [Ljava.lang.Object; (java.base@25.0.5)
   5:          1381          44800  [I (java.base@25.0.5)
   6:          1119          35808  java.util.HashMap$Node (java.base@25.0.5)

[B 25,819,184 字节,与当时缓存约 25 MB 对得上。HashMap$Node 也在涨,但只有 35808 字节,因为节点本身很小,大头是它引用的数组。泄漏对象往往不是「类排行第一」的那个业务类,而是它背后的 byte[]、char[]、Object[]。看到 [B 排第一,要继续问「谁持有这些数组」。

真实 OOM:64 MB 堆下的失败现场

把堆压到 64 MB,并打开 OOM 自动落盘:

java -Xmx64m \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/tmp/vol4-d/leak/leak.hprof \
     "-Xlog:gc+heap=info:file=/tmp/vol4-d/leak/gc.log" \
     -cp out LeakDemo

本机输出(节选):

pid=72274 maxHeap=67108864
cached=44MB
cached=45MB
cached=46MB
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/vol4-d/leak/leak.hprof ...
Heap dump file created [53560211 bytes in 0.041 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.util.HashMap.newNode(HashMap.java:1910)
	at java.base/java.util.HashMap.putVal(HashMap.java:649)
	at java.base/java.util.HashMap.put(HashMap.java:619)
	at LeakDemo.main(LeakDemo.java:12)

有几个数字值得解释。

第一,OOM 发生在缓存约 46 MB 时,离 -Xmx64m 还有 18 MB。看 GC 日志的最后几条:

[8.319s][info][gc,heap] GC(16) Old regions: 64->64
[8.319s][info][gc,heap] GC(17) Eden regions: 0->0(1)
[8.319s][info][gc,heap] GC(17) Old regions: 64->64
[8.320s][info][gc,heap] GC(18) Eden regions: 0->0(1)
[8.320s][info][gc,heap] GC(18) Old regions: 64->64

64 MB 堆下 G1 Region 是 1 MB(前面 jmap 运行时输出的 region size 1024K),老年代 64 个 Region 全部占满。46 MB 是 byte[] 载荷,另外还有 HashMap 节点、键字符串、类元数据、线程栈、G1 自身结构,以及 Region 内部的碎片。used < Xmx 不代表还能分配,判断容量要看 Region 占用和分配失败,不要做简单减法。

第二,dump 文件 53,560,211 字节,比 46 MB 载荷大,因为 hprof 要记录对象头、类信息、引用边和字符串。一个 64 MB 的堆能写出 51 MB 的 dump,容量规划要按这个量级留磁盘。

第三,异常栈顶是 HashMap.newNode,不是业务代码里的 LoanCache.put。如果只看栈,容易以为是 HashMap 的 bug;实际是调用方无限制地放。OOM 的栈只说明「最后一次分配失败在哪里」,不说明「谁该负责」。

有了 dump 之后,引用链的读法和 7.1 一样:在 MAT 里按 retained heap 排序,找 [B 或 HashMap,再 Path to GC Roots。本机没有 MAT,这里不编造它的输出。

引用链:为什么 WeakHashMap 不是万能

看到 static Map,很多人的第一反应是换成 WeakHashMap 或 SoftReference。它们解决的问题比想象中窄。

WeakHashMap 的键是弱引用:键对象没有其他强引用时,条目会被移除。但如果 value 反过来强引用 key,key 就仍然可达,条目不会被清掉。用一个实验验证:

import java.util.Map;
import java.util.WeakHashMap;

public class WeakDemo {
    record Key(String id) {}
    static final class Value {
        final Key key;
        final byte[] payload = new byte[1024 * 1024];
        Value(Key key) { this.key = key; }
    }

    static void gc() throws Exception {
        for (int i = 0; i < 4; i++) {
            System.gc();
            Thread.sleep(100);
        }
    }

    public static void main(String[] args) throws Exception {
        Map<Key, byte[]> first = new WeakHashMap<>();
        Key k1 = new Key("M-1");
        first.put(k1, new byte[1024 * 1024]);
        k1 = null;
        gc();
        System.out.println("weakKeyOnly size=" + first.size());

        Map<Key, Value> second = new WeakHashMap<>();
        Key k2 = new Key("M-2");
        second.put(k2, new Value(k2));   // value 反向强引用 key
        k2 = null;
        gc();
        System.out.println("valueHoldsKey size=" + second.size());
    }
}

本机输出:

weakKeyOnly size=0
valueHoldsKey size=1

第一种情况条目被清掉了,第二种没有。业务缓存里 value 持有 key(比如 Loan 里带 memberId,缓存 key 也是 memberId)非常常见,这时 WeakHashMap 帮不上忙。

SoftReference 的问题是另一面:它只在内存不足时被清除,清除时机不可控。缓存被清空后,下一次请求又要回源,容易出现「内存没爆,但数据库被打满」的抖动。它适合「可有可无、重建便宜」的缓存,不适合当容量策略。

真正可控的做法是 有界缓存 + 明确的淘汰策略 + 指标。容量是配置项,淘汰规则写在代码里,命中率和大小暴露到监控。引用类型只能改变「什么时候能被回收」,不能改变「最多放多少」。

在 libraryd 里落地

LoanCache 一开始的反例是这样写的:

// 反例:没有容量上限,也没有淘汰
private static final Map<Long, Loan> CACHE = new HashMap<>();

public Loan find(long id) {
    return CACHE.computeIfAbsent(id, this::loadFromDb);
}

两个问题叠加:static 让缓存活到进程结束,computeIfAbsent 只增不减。借阅记录越多,缓存越大;即使 Loan 很小,HashMap 的负载因子和扩容也会让内存持续抬升。更隐蔽的是,Loan 是不可变 record,但借阅状态会变,缓存里的旧 Loan 可能已经过期。

改成有界 LRU,容量由配置传入:

package com.plume.library.persistence;

import com.plume.library.domain.Loan;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;

public final class LoanCache {

    private final Map<Long, Loan> entries;

    public LoanCache(int maxEntries) {
        this.entries = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Long, Loan> eldest) {
                return size() > maxEntries;
            }
        };
    }

    public synchronized Optional<Loan> get(long id) {
        return Optional.ofNullable(entries.get(id));
    }

    public synchronized void put(Loan loan) {
        entries.put(loan.id(), loan);
    }

    public synchronized int size() {
        return entries.size();
    }
}

LinkedHashMap 的第三个构造参数 accessOrder=true 让 get 也更新顺序,removeEldestEntry 在超过容量时淘汰最久未访问的条目。synchronized 是必要的,LinkedHashMap 不是线程安全的;如果并发量高,可以换成 ConcurrentHashMap 加显式淘汰,但不要在没有测量的情况下引入第三方缓存库。

三个配套动作:

  • maxEntries 从环境变量或配置文件读,默认值按「单条 Loan 的 shallow size × 预期条目数」估算,而不是拍脑袋。
  • 暴露 cache.size()、命中率、淘汰次数三个指标,命中率骤降或淘汰次数飙升时告警。
  • 借阅状态变更时主动 put 新值或失效旧值,不要让缓存返回过期的 Loan。

如果确实需要「缓存大对象、内存紧张时自动让路」,可以用 SoftReference 包一层,但仍然要有上限和指标。软引用不是容量规划,只是最后一道缓冲。

常见错误与边界

错误一:用一次 Runtime 采样判断泄漏。 totalMemory() - freeMemory() 包含垃圾,单次采样可能刚好在 GC 前,数字很难看但不代表泄漏。至少看多次 GC 后的低点,或者用 jmap -histo:live 看存活对象。

错误二:只调大 -Xmx。 泄漏的斜率不变,调大堆只是把 OOM 推迟,同时让每次 Full GC 更久、dump 更大。先确认是不是泄漏,再决定扩容。

错误三:用 WeakHashMap 缓存 value 持有 key 的对象。 本机实验里 valueHoldsKey size=1,条目不会被回收。弱引用只对「键没有其他强引用」的情况有效。

错误四:在生产环境反复执行 jmap -histo:live。 每次都会触发 Full GC,堆大时停顿明显。用 jcmd GC.class_histogram 也要谨慎,先看 Impact: High 的提示。

边界:SoftReference 的清空时机由 GC 决定。 不要依赖它在某个时间点一定被清掉,也不要用它做精确的容量控制。

边界:OOM 的栈顶不是根因。 HashMap.newNode 只是最后一次分配失败的位置,真正的根因在调用方和缓存策略里。

小结

  • 先看增长曲线:本机泄漏在 6 秒内从 13.7 MB 线性涨到 67.7 MB,斜率约 11 MB/s;正常高峰应回落到稳定基线。
  • 直方图要同时看实例数和字节数:本机 [B 3 秒内实例只多 150 个,字节多了约 19.6 MB,说明是大数组累积。
  • -Xmx64m 下真实 OOM 发生在缓存约 46 MB 时;老年代 64 个 1 MB Region 全满,used < Xmx 不代表还能分配。
  • -XX:+HeapDumpOnOutOfMemoryError 生成了 53,560,211 字节的 dump;OOM 栈顶是 HashMap.newNode,不是根因。
  • WeakHashMap 只在键没有其他强引用时有效;本机 value 反向持有 key 时条目仍然保留。
  • libraryd 的修法是有界 LRU、容量可配、指标可观测,而不是换一种引用类型。

泄漏对象找到了,下一节换一条战线:堆本身没满,但进程 RSS 一直涨。元空间和直接内存不归 -Xmx 管,需要另外两把尺子。

阅读导航:上一节:7.1 堆转储与支配树:从 hprof 到保留大小 · 下一节:7.3 元空间与直接内存:类生成与堆外泄漏 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发