219|内存持续增长排查:堆内/堆外、缓存、线程、本地内存和泄漏定位

性能工程、可观测性与故障排查约 15 分钟阅读8,413 字符
知识库文档 220 / 251

一、引言:内存持续增长的"温水煮青蛙"效应

与 OOM(OutOfMemoryError)崩溃不同,内存持续增长更像"温水煮青蛙"。应用不会立刻崩溃,而是内存曲线缓慢爬升,GC 频率逐渐增加,最终在某一天突然不可用。这类问题之所以棘手,是因为:

  • 时间跨度长:可能从几个小时到几天才暴露
  • 触发条件模糊:往往与特定流量模式、定时任务、缓存过期策略耦合
  • 监控盲区:堆内存有 GC 日志和 Heap Dump 可查,但堆外、本地内存长期处于"灰色地带"

本文从 JVM 内存模型的全景视角出发,逐一剖析堆内、堆外、缓存、线程、本地内存等各维度的增长根因,并给出可落地的排查工具链和实战案例。


二、JVM 内存全景图:你看到的只是冰山一角

在深入排查之前,必须建立完整的内存视图。JVM 进程占用的内存远不止 -Xmx 指定的堆大小。

文章配图
文章配图

经验公式:JVM 进程总内存 ≈ -Xmx + -XX:MaxMetaspaceSize + 线程数 × (-Xss) + -XX:MaxDirectMemorySize + Code Cache + GC 内部结构 + JNI 分配 + ...

一个典型的 4 核 8G 应用:-Xmx4g -Xss1m,500 个线程的线程栈就占 500MB,加上 Metaspace 256MB、Direct Memory 256MB、Code Cache 240MB,总内存轻松超过 5.5GB。如果容器只分配了 6GB,剩余空间非常紧张。


三、堆内内存增长排查:GC 日志不会说谎

堆内内存增长是最常见的场景,排查手段相对成熟。

3.1 第一步:确认是堆内还是堆外

在动手分析之前,先确认增长的"主战场":

bash
# 查看堆内存使用趋势
jstat -gcutil <pid> 1000  # 每秒输出一次 GC 统计

# 关键指标:
# OU(Old Gen usage)持续上升,FGC 后仍不下降 → 堆内泄漏
# FGC 后 OU 显著下降,但很快又涨回来 → 可能是缓存/业务正常增长
# 堆内存曲线平稳,但进程 RSS 持续上涨 → 堆外/本地内存泄漏

判断标准:如果 Full GC 后老年代使用率能回到一个稳定的基线,说明堆内对象是可回收的,问题在堆外;如果 Full GC 后老年代使用率持续升高,说明堆内存在泄漏。

文章配图
文章配图

3.2 堆内泄漏的三大根因

根因一:被遗忘的引用

java
// 经典错误:HashMap 作为缓存但从不清理
public class OrderService {
    // 静态 Map,类加载后永不释放
    private static final Map<String, Order> ORDER_CACHE = new HashMap<>();

    public Order getOrder(String id) {
        return ORDER_CACHE.computeIfAbsent(id, this::loadFromDB);
    }
}

这个 ORDER_CACHE 会随着访问的订单越来越多而无限增长,直到 OOM。HashMap 的 key 和 value 都是强引用,只要 Map 本身不被 GC,所有条目都永远存活。

根因二:类加载器泄漏

这是最难排查的堆内泄漏类型之一。当自定义 ClassLoader 加载的类无法被卸载时,该 ClassLoader 及其加载的所有类、所有静态字段引用的对象,全部成为"僵尸对象"。

java
// 典型场景:动态代理/热部署框架
// 每次重新加载服务,都创建新的 ClassLoader
// 但旧 ClassLoader 因为某个线程的 contextClassLoader 引用而无法被 GC

排查方法:在 MAT 中搜索 ClassLoader → 查看 Retained Heap → 找到最大的 ClassLoader 实例 → 追溯 GC Root 路径。

根因三:ThreadLocal 未清理

java
// 线程池中使用 ThreadLocal 必须显式 remove()
public class UserContext {
    private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();

    public static void setUser(User user) {
        CURRENT_USER.set(user);
    }
    // 忘记调用 remove(),线程复用后 ThreadLocal 值永远存在
}

当线程池中的线程被复用时,ThreadLocalMap 中的 Entry 不会被清理。对于 Tomcat 等 Web 容器,每个请求线程如果不清理 ThreadLocal,就会导致用户对象随着请求累积而不断增长。

3.3 Heap Dump 对比分析

单次 Heap Dump 只能看到"当前状态",无法判断"增长趋势"。对于持续增长问题,需要至少两次 Heap Dump:

bash
# 间隔 30 分钟分别抓取 Heap Dump
jmap -dump:live,format=b,file=heap1.hprof <pid>
# 等待 30 分钟
jmap -dump:live,format=b,file=heap2.hprof <pid>

在 MAT 中分别打开两次 dump,对比 "Histogram" 视图:

  • 按 Retained Heap 排序
  • 找出两次 dump 之间 Retained Heap 增长最大的类
  • 对该类做 "Merge Shortest Paths to GC Roots" 分析

关键技巧:关注 byte[ ]、char[ ]、String、HashMap$Node、Object[ ] 这些"容器类"的增长。如果 byte[ ] 占据了大量 Retained Heap,通常是缓存了大量数据;如果 HashMap$Node 暴涨,可能是某个 Map 在不断膨胀。

文章配图
文章配图

四、缓存:最大的"隐形杀手"

缓存是内存持续增长的"头号嫌犯"。在排查内存问题时,先检查所有缓存配置,往往能事半功倍。

4.1 本地缓存的常见陷阱

Guava Cache / Caffeine 配置不当

java
// 危险配置:没有最大大小限制
Cache<String, Object> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 只有时间过期
    .build();
// 如果 10 分钟内写入 1000 万条 → 内存爆炸

// 正确配置:大小限制 + 时间过期双重保障
Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)                     // 硬性条目上限
    .expireAfterAccess(5, TimeUnit.MINUTES)  // 访问过期
    .expireAfterWrite(30, TimeUnit.MINUTES)  // 写入过期
    .recordStats()
    .build();

Caffeine 的 maximumSize 和 maximumWeight 是强制的:超过限制后会自动驱逐。但 expireAfterWrite 不是强制的——它只在访问时惰性检查。如果某个 key 写入后从未被访问,它会在内存中一直存在直到触发 maximumSize 驱逐。

WeakHashMap 的陷阱

java
// WeakHashMap:key 是弱引用,但 value 是强引用!
WeakHashMap<Key, BigObject> map = new WeakHashMap<>();
Key k = new Key("id");
map.put(k, new BigObject());  // 1MB 的对象
k = null;                     // key 失去引用
System.gc();                  // key 被回收
// 但 BigObject 仍然在 WeakHashMap 的内部 Entry 中!
// 需要等到下次对 map 的操作(get/put/size)才会清理 stale entry

WeakHashMap 的过期条目清理是被动触发的,只在 get、put、size 等操作时顺便清理。如果 Map 长期没有操作,内存不会被释放。

4.2 缓存排查工具

bash
# 通过 JMX 查看 Guava/Caffeine 缓存统计
# 关注:size、hitRate、evictionCount
# 如果 size 持续增长而 evictionCount 为 0 → 缓存无上限或上限过大

# Arthas 查看缓存实例
vmtool --action getInstances --className com.github.benmanes.caffeine.cache.BoundedLocalCache --limit 10

4.3 Redis 客户端缓存注意

虽然 Redis 本身是外部存储,但 Jedis/Lettuce 客户端也有内存占用:

  • 连接池中的连接对象
  • 序列化/反序列化缓冲区
  • 集群拓扑信息缓存

对于大 Value 场景,单次反序列化可能产生大量临时对象,导致 Young GC 压力增大。

文章配图
文章配图


五、线程栈内存与线程泄漏

线程栈内存经常被忽视,但在高并发应用中,它可能成为本地内存的"大户"。

5.1 线程栈默认大小

JDK 版本 平台 默认 -Xss
JDK 8 Linux x64 1024 KB
JDK 11+ Linux x64 1024 KB
JDK 8 macOS x64 1024 KB

计算公式:线程栈总内存 = 线程数 × 线程栈大小

如果有 1000 个线程,每个线程栈 1MB,仅线程栈就占用 1GB 内存,且这部分内存不在堆内,由操作系统直接分配。

5.2 线程泄漏的常见场景

java
// 场景一:每次请求创建新线程(极端错误)
@GetMapping("/api")
public String handle() {
    new Thread(() -> {
        // 处理业务
    }).start();  // 线程执行完就死了,但高并发下瞬时创建大量线程
    return "ok";
}

// 场景二:线程池无限增长
ExecutorService executor = Executors.newCachedThreadPool();
// CachedThreadPool 的最大线程数是 Integer.MAX_VALUE!
// 当任务提交速度 > 处理速度时,线程数无限增长

// 场景三:线程池拒绝策略不当
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    10, 20, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>()  // 无界队列!
);
// 核心线程忙时,任务进入无界队列,队列无限增长导致堆内存 OOM

5.3 线程泄漏排查

bash
# 查看当前线程数
jstack <pid> | grep "^Thread" | wc -l

# 或者
jcmd <pid> Thread.print | grep "^Thread" | wc -l

# 查看线程状态分布
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c

# 通过 NMT 查看线程栈内存
jcmd <pid> VM.native_memory summary
# 关注 Thread 部分的 committed 值

关键判断:如果线程数随时间线性增长,且线程状态多为 WAITING 或 TIMED_WAITING,说明线程池在无限制创建线程但任务处理不过来。

文章配图
文章配图


六、堆外内存(Direct Memory):Netty 的"双刃剑"

堆外内存(Direct Memory)通过 ByteBuffer.allocateDirect() 分配,不受 JVM 堆大小限制,GC 不直接管理,但受 -XX:MaxDirectMemorySize 限制。

6.1 Direct Memory 的回收机制

Direct Memory 的回收依赖 Cleaner 机制:DirectByteBuffer 对象在堆内有一个"影分身",当堆内的 DirectByteBuffer 被 GC 回收时,其 Cleaner 会触发 unsafe.freeMemory() 释放堆外内存。

问题所在:如果堆内存足够大,Young GC 很少触发,堆内的 DirectByteBuffer 对象一直存活在 Old Gen,堆外内存就一直不被释放。这就是为什么 Direct Memory 泄漏往往伴随着"堆内存正常,但进程 RSS 持续上涨"的现象。

bash
# 查看 Direct Memory 使用情况
jcmd <pid> VM.native_memory summary
# 或使用 JMX:
# java.nio:type=BufferPool,name=direct

6.2 Netty 内存池与泄漏检测

Netty 使用自己的内存池(PooledByteBufAllocator)管理堆外内存,以提高性能。但使用不当容易泄漏:

java
// 危险:ByteBuf 未释放
public void handle(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    // 处理业务...

    byte[ ] data = new byte[buf.readableBytes()];

    buf.readBytes(data);
    // 忘记调用 ReferenceCountUtil.release(buf) 或 buf.release()
    // 引用计数永远不会归零,内存永远不会释放
}

// 正确做法
public void handle(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    try {

        byte[ ] data = new byte[buf.readableBytes()];

        buf.readBytes(data);
        // 处理业务...
    } finally {
        ReferenceCountUtil.release(buf);
    }
}

Netty 提供了内存泄漏检测级别:

bash
# 开启高级泄漏检测(开发/测试环境)

- Dio.netty.leakDetection.level=PARANOID

# 级别:DISABLED < SIMPLE < ADVANCED < PARANOID
# PARANOID 会采样所有 ByteBuf,开销较大

6.3 堆外内存泄漏排查工具

bash
# 使用 jcmd 查看 Direct ByteBuffer 相关统计
jcmd <pid> GC.run              # 先触发一次 Full GC
jcmd <pid> VM.native_memory summary.diff  # 对比 GC 前后的差异

# 使用 pmap 查看进程内存映射
pmap -x <pid> | sort -k3 -nr | head -20

# 使用 gperftools(Google Performance Tools)
# 适用于排查本地内存分配热点
LD_PRELOAD=/usr/lib/libtcmalloc.so HEAPPROFILE=/tmp/heap.prof java -jar app.jar

文章配图
文章配图


七、元空间(Metaspace):被忽视的增长源

元空间(Metaspace)存储类的元数据,位于堆外本地内存。JDK 8+ 默认不限制大小,但容器内存有限时,元空间的持续增长可能导致进程被 OOM Killer 杀死。

7.1 元空间增长的常见原因

动态类生成:

java
// Groovy 脚本引擎反复创建类
GroovyShell shell = new GroovyShell();
for (int i = 0; i < 100000; i++) {
    Script script = shell.parse("def foo() { return " + i + " }");
    script.run();  // 每次 parse 生成一个新类
}
// 100000 个类被加载到 Metaspace,无法卸载

Lambda 表达式:每个 Lambda 表达式在运行时生成一个匿名类。如果 Lambda 在循环中动态创建(如 Stream.map(x -> ...) 不是问题,但动态生成 Lambda 的工厂模式可能有问题)。

CGLIB / Javassist 动态代理:Spring AOP、MyBatis Mapper 等大量使用动态代理,每次代理创建一个新类。

Reflections 库扫描:org.reflections 库在扫描 classpath 时可能加载大量类。

7.2 元空间排查

bash
# 查看元空间使用
jstat -gc <pid> 1000
# 关注 MU(Metaspace Used)和 MC(Metaspace Capacity)

# 查看类加载统计
jcmd <pid> GC.class_stats | head -100

# 设置元空间上限,让问题尽早暴露

- XX:MaxMetaspaceSize=256m

- XX:MetaspaceSize=128m

# 开启类加载/卸载日志

- XX:+TraceClassLoading

- XX:+TraceClassUnloading


八、本地内存(Native Memory):最隐蔽的泄漏源

本地内存泄漏是最难排查的,因为它在 JVM 的"管辖范围"之外。

8.1 常见本地内存泄漏源

JNI 调用:如果 JNI 代码在本地分配了内存但没有释放,Java 侧完全无法感知。

c
// JNI 错误示例
JNIEXPORT void JNICALL Java_Example_process(JNIEnv *env, jclass cls, jbyteArray data) {
    jsize len = (*env)->GetArrayLength(env, data);
    char *buf = (char *)malloc(len);  // 本地分配
    (*env)->GetByteArrayRegion(env, data, 0, len, (jbyte *)buf);
    // 处理...
    // 忘记 free(buf); → 本地内存泄漏
}

ZIP 压缩/解压:java.util.zip.Inflater 和 Deflater 内部使用本地内存。如果不调用 end() 方法,本地内存不会被释放。

java
// 错误:Inflater 未释放本地内存

public byte[ ] decompress(byte[ ] data) throws Exception {

    Inflater inflater = new Inflater();
    inflater.setInput(data);

    byte[ ] result = new byte[1024];

    inflater.inflate(result);
    inflater.end();  // 必须调用!否则本地内存泄漏
    return result;
}

JIT 编译的 Code Cache:-XX:ReservedCodeCacheSize 默认 240MB。当 Code Cache 满了,JIT 会停止编译,性能下降但不会直接报错。

8.2 本地内存排查工具

bash
# NMT 是排查本地内存的首选工具
# 启动时开启 NMT

- XX:NativeMemoryTracking=summary  # 或 detail

# 查看 NMT 摘要
jcmd <pid> VM.native_memory summary

# 建立基线,运行一段时间后对比
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff

九、NMT(Native Memory Tracking)实战:从数据到结论

NMT 是 JDK 内置的本地内存追踪工具,但很多工程师不会用或看不懂输出。

9.1 NMT 输出解读

bash
$ jcmd 12345 VM.native_memory summary

Native Memory Tracking:

Total: reserved=5580267KB, committed=3123456KB

-       Java Heap (reserved=4194304KB, committed=2097152KB)
                (mmap: reserved=4194304KB, committed=2097152KB)

-           Thread (reserved=258048KB, committed=258048KB)
                (thread #252)
                (stack: reserved=256000KB, committed=256000KB)

-          Metaspace (reserved=131072KB, committed=126400KB)

-              Arena (reserved=1024KB, committed=24KB)

-       Direct Buffer (reserved=65536KB, committed=65536KB)

关键解读:

  • reserved:JVM 向操作系统"预订"的地址空间,不一定实际占用物理内存
  • committed:实际分配的物理内存(或 swap),这是真正消耗的内存
  • Thread 部分:252 个线程 × 约 1MB 栈 = 258MB,符合预期
  • Direct Buffer:如果 committed 接近 -XX:MaxDirectMemorySize,说明堆外 Buffer 使用量大

9.2 diff 模式:定位增长源

bash
# 建立基线
jcmd <pid> VM.native_memory baseline

# 等待 30 分钟(内存持续增长期间)

# 对比差异
jcmd <pid> VM.native_memory summary.diff

# 输出示例:
# + Direct Buffer (reserved=+32768KB, committed=+32768KB)
# + Arena (reserved=+512KB, committed=+256KB)

# 如果 Direct Buffer 增长了 32MB,而 Metaspace 和 Heap 变化不大
# → 堆外内存泄漏,重点排查 Netty/DirectByteBuffer

文章配图
文章配图


十、对比分析:多时间点 Heap Dump 的差值定位

这是内存持续增长排查的"杀手锏"技术。

10.1 操作步骤

  1. 在增长过程中每隔 N 分钟抓取一次 Heap Dump(至少 3 次)
  2. 使用 MAT 打开每次 dump,在 Histogram 中按 Retained Heap 排序
  3. 导出每个类的 instance count 和 total size
  4. 计算相邻两次 dump 的差值,找出持续增长的类
bash
# 使用 MAT 命令行导出 Histogram CSV
# 路径:MAT 安装目录/ParseHeapDump.sh
./ParseHeapDump.sh heap1.hprof org.eclipse.mat.api:suspects
./ParseHeapDump.sh heap1.hprof org.eclipse.mat.api:top_components

10.2 增长模式分析

增长模式 特征 可能原因
HashMap$Node 持续增长 某个 Map 的条目数在不断增加 缓存无上限

| byte[ ] 持续增长 | 大量字节数组无法释放 | 大对象缓存、Netty 缓冲区 |

| String 持续增长 | 字符串对象不断创建 | 字符串拼接、日志收集 | | 自定义类实例持续增长 | 某种业务对象不断累积 | 内存泄漏、未关闭的资源 | | 类加载器数量持续增长 | ClassLoader 实例数增加 | 动态类加载、热部署重复 |

10.3 从可疑类到 GC Root 路径

MAT 中选中可疑类 → List objects → with outgoing references → 选中一个实例 → Path to GC Roots → exclude weak/soft/phantom references。

这条路径会告诉你为什么这个对象不能被 GC。如果路径最终指向一个静态字段或线程局部变量,那就是泄漏点。


十一、实战案例:一个真实的内存持续增长排查全过程

背景

某电商订单服务,分配 8GB 容器内存,JVM 参数 -Xmx4g -Xms4g。上线后每 3 天内存使用率达到 95%,触发容器重启。监控显示堆内存使用率在 60% 左右,但进程 RSS 超过 7.5GB。

排查过程

Step 1:确认增长不在堆内

bash
jstat -gcutil <pid> 1000
# OU 稳定在 60%,FGC 后能回到 40%
# 结论:堆内不是问题,问题在堆外

Step 2:开启 NMT 定位

bash
# 重启应用,添加 NMT 参数

- XX:NativeMemoryTracking=detail

# 运行 1 小时后对比
jcmd <pid> VM.native_memory baseline
# 等待 2 小时
jcmd <pid> VM.native_memory summary.diff

# 结果:
# + Thread (reserved=+51200KB, committed=+51200KB)  ← 线程数增长了 50 个!
# + Direct Buffer (reserved=+8192KB, committed=+8192KB)

Step 3:追查线程增长

bash
# 每隔 10 分钟统计线程数
watch -n 600 "jstack <pid> | grep '^Thread' | wc -l"
# 线程数从 200 增长到 300、400...

# 查看线程名模式
jstack <pid> | grep "^Thread" | awk -F'"' '{print $2}' | sort | uniq -c | sort -rn
# 发现大量 "pool-XX-thread-YY" 线程

Step 4:定位线程池泄漏代码

bash
# 使用 Arthas 查看线程池
thread --all | grep pool

# 找到了:某个定时任务每次执行时创建新的 ScheduledThreadPoolExecutor
# 但从未调用 shutdown()

根因

java
@Scheduled(fixedDelay = 60000)
public void syncData() {
    // BUG:每次执行创建新线程池,从不关闭
    ScheduledExecutorService executor = Executors.newScheduledThreadPool(4);
    for (DataBatch batch : getBatches()) {
        executor.schedule(() -> processBatch(batch), 10, TimeUnit.SECONDS);
    }
    // 忘记 executor.shutdown()
}

修复

将线程池提取为单例,或者使用 @Scheduled 配合 @Async 共享线程池。

总结

这个案例的排查路径:堆内存正常 → 进程 RSS 上涨 → 开启 NMT → 发现 Thread 部分增长 → 线程数统计 → 定位线程池泄漏。

文章配图
文章配图


十二、总结与排查清单

排查工具箱

工具 用途 命令示例
jstat 堆内存、GC 实时监控 jstat -gcutil &amp;lt;pid&amp;gt; 1000
jmap Heap Dump 抓取 jmap -dump:live,format=b,file=heap.hprof &amp;lt;pid&amp;gt;
jstack 线程栈抓取 jstack &amp;lt;pid&amp;gt; &amp;gt; thread.dump
jcmd NMT 本地内存追踪 jcmd &amp;lt;pid&amp;gt; VM.native&amp;#95;memory summary.diff
MAT Heap Dump 分析 支配树、Histogram、GC Root 路径
Arthas 在线诊断 dashboard, thread, vmtool
pmap 进程内存映射 pmap -x &amp;lt;pid&amp;gt;
gperftools 本地内存分配热点 LD&amp;#95;PRELOAD=libtcmalloc.so

排查清单

  1. ☐ 确认问题类型:堆内还是堆外?(jstat 看 GC 后 OU 是否下降)
  2. ☐ 堆内 → 抓 Heap Dump → MAT 分析 → 两次对比找增长类 → GC Root 路径
  3. ☐ 缓存 → 检查所有 Guava/Caffeine 缓存的 maxSize 配置
  4. ☐ ThreadLocal → 检查线程池中是否有未 remove 的 ThreadLocal
  5. ☐ 线程 → 统计线程数趋势,检查线程池创建和关闭
  6. ☐ 堆外 → 开启 NMT → baseline → diff → 定位增长类别
  7. ☐ Direct Memory → 检查 Netty/ByteBuf 的 release 调用
  8. ☐ Metaspace → 检查动态类加载、Groovy、CGLIB 代理
  9. ☐ 本地内存 → 检查 JNI、Inflater/Deflater、ZIP 压缩
  10. ☐ Code Cache → 检查 -XX:ReservedCodeCacheSize 是否合理

核心原则:先分堆内/堆外,再逐层排查。80% 的内存持续增长问题来自 20% 的常见原因:缓存无上限、ThreadLocal 未清理、线程池泄漏、DirectByteBuffer 未释放。


十三、面试问答

Q1:一个 Java 应用给 8GB 容器内存,JVM 参数**-Xmx6g -Xms6g**,为什么 Pod 还是被 OOM Kill 了?JVM 占用内存包括哪些部分?

A1:-Xmx6g 只限制了堆内存大小,但 JVM 进程占用的总内存远不止这些。还包括:

  • Metaspace:存储类元数据,默认不限制,如果大量动态生成类,可能占用几百 MB
  • 线程栈:每个线程默认 1MB 栈,如果有 500 个线程就是 500MB
  • Direct Memory:ByteBuffer.allocateDirect() 分配的堆外内存,默认等于 -Xmx
  • Code Cache:JIT 编译后的代码缓存,默认 240MB
  • GC 内部结构:标记、压缩过程中的临时数据结构
  • JNI 本地内存:JNI 调用中 malloc 的内存

粗略估算:6GB(堆)+ 256MB(Metaspace)+ 500MB(线程栈)+ 256MB(Direct Memory)+ 240MB(Code Cache)+ 其他 ≈ 7.5GB,留给操作系统的 buffer 不到 500MB,内存压力大时容易被 OOM Kill。

Q2:如何判断一个内存持续增长问题是堆内泄漏还是堆外泄漏?

A2:三步判断法:

  1. 触发 Full GC(jcmd <pid> GC.run),观察老年代使用率是否回落到低位
  2. 如果 Full GC 后老年代使用率能回到稳定基线 → 堆内对象可回收,问题在堆外
  3. 如果 Full GC 后老年代仍居高不下 → 堆内存在泄漏,抓 Heap Dump 分析

对于堆外判断,开启 NMT(-XX:NativeMemoryTracking=detail),建立 baseline 后运行一段时间,用 summary.diff 对比,看哪个分类(Thread/Direct Buffer/Metaspace/Arena)的 committed 值在持续增长。

Q3:ThreadLocal 为什么会导致内存泄漏?JDK 是如何解决这个问题的?

A3:ThreadLocal 的内部实现是每个线程维护一个 ThreadLocalMap,Map 的 Entry 使用 WeakReference 包装 ThreadLocal 作为 key。当 ThreadLocal 对象失去强引用被 GC 回收后,Entry 的 key 变为 null,但 value 仍然是强引用。如果线程长期存活(如线程池中的线程),这些 key 为 null 的 Entry 永远不会被清理,value 对应的对象就泄漏了。

JDK 的改进:ThreadLocal.get()、set()、remove() 方法在操作时会顺便清理 key 为 null 的过期 Entry。但最可靠的方案还是显式调用**remove()**,尤其是在线程池场景中。

Q4:Netty 的 ByteBuf 内存泄漏如何排查?如果泄漏检测日志显示"LEAK:",但堆栈信息不完整怎么办?

A4:Netty 的内存泄漏检测依赖于采样和引用队列。当 ByteBuf 的引用计数未归零且 GC 触发了 Cleaner 时,泄漏检测器会记录"LEAK:"日志。堆栈信息不完整的原因通常是:

  • 泄漏检测级别不够高(SIMPLE 模式不记录堆栈)
  • 创建 ByteBuf 的调用栈在采样范围之外

解决方案:

  • 设置 -Dio.netty.leakDetection.level=PARANOID,对所有 ByteBuf 采样
  • 设置 -Dio.netty.leakDetection.targetRecords=N 增加记录的堆栈帧数
  • 使用 io.netty.util.ResourceLeakDetector 的 setLevel() 方法在运行时动态调整

Q5:Caffeine Cache 配置了**expireAfterWrite(10, TimeUnit.MINUTES)**但没有配置**maximumSize**,有什么风险?

A5:expireAfterWrite 只是标记条目在写入 10 分钟后"过期",但 Caffeine 的过期处理是惰性的——只在访问时检查并清理。如果缓存中的条目写入后从未被再次访问,它们会一直占用内存,直到 JVM 堆满触发 OOM。

此外,expireAfterWrite 不影响缓存的大小控制。在流量峰值时,10 分钟内可能写入数百万条记录,远超预期。正确的做法是组合使用 maximumSize() 和 expireAfterWrite(),前者是硬性限制(强制驱逐),后者是软性优化(减少不必要的数据保留)。

Q6:元空间(Metaspace)和永久代(PermGen)有什么区别?为什么 JDK 8 要做这个替换?

A6:主要区别:

  • 存储位置:PermGen 在堆内,Metaspace 在本地内存(堆外)
  • 大小限制:PermGen 通过 -XX:MaxPermSize 限制,默认很小(约 82MB);Metaspace 默认不限制,受限于系统可用内存
  • GC 行为:PermGen 在 Full GC 时回收;Metaspace 在类加载器被回收时释放,GC 不直接管理
  • 字符串常量池:JDK 7 开始字符串常量池移到堆中,不在 PermGen/Metaspace 中

JDK 8 替换的原因:PermGen 大小难以预估,固定大小容易导致 OOM: PermGen space;Metaspace 利用本地内存,更灵活,减少了 PermGen OOM 的风险。但同时也带来了新问题:如果不设置 -XX:MaxMetaspaceSize,动态类加载泄漏可能导致本地内存耗尽。