本节目标:把「内存一直涨」拆成可验证的三步:看增长曲线、看类直方图、看引用链。
适用版本:JDK 25(Temurin 25.0.5+8);-Xmx64m;MAT 本机未安装,只写操作步骤。
承接的问题
7.1 讲了怎么抓一份 hprof。抓之前还有一个更基础的问题:这次到底是不是泄漏。GC 日志里老年代占用高,可能是流量峰值,也可能是缓存没有上限;两者的处理方式相反,前者扩容,后者改代码。
泄漏排查最常见的错误是直接抓 dump。几十 GB 的堆,抓一次要停顿几秒到几十秒,写盘几 GB,打开分析又要几分钟。更便宜的路径是先回答三个问题:
- 堆使用量是「锯齿形」还是「单调上升」?
- 如果一直涨,是哪个类的实例数和字节数在涨?
- 这个类被谁持有,为什么 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;正常高峰应回落到稳定基线。
- 直方图要同时看实例数和字节数:本机
[B3 秒内实例只多 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 元空间与直接内存:类生成与堆外泄漏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。