Java 服务堆内存没有明显增长,容器却不断接近内存上限,问题可能发生在堆外。本文从 RSS、线程栈、直接内存和本地内存追踪入手,给出可复现代码、诊断命令、常见误区与一套实际排查路径。

很多 Java 内存问题并不是从 Old Gen 或堆转储开始的。

我遇到过这样的服务:监控中堆使用率长期稳定,Young GC 和 Full GC 也没有异常,但进程的 RSS 却缓慢上涨。容器限制是 2 GiB,Java 堆只配置了 1 GiB,剩余内存看起来足够,服务运行一段时间后却仍然被系统或容器杀掉。

这类问题的关键在于:JVM 进程占用的内存不等于 Java 堆内存。RSS 还可能包括线程栈、直接内存、Metaspace、代码缓存、GC 使用的本地结构,以及 JNI、压缩库、TLS 库等本地分配。

本文只聚焦一个具体问题:当堆内存稳定而 Java 进程 RSS 持续上涨时,如何判断增长来自哪里?

先区分几个内存概念

Java 堆用于存放绝大多数 Java 对象,也是 GC 主要管理的区域。jstat -gc、JMX 中的堆指标,通常只能反映这部分内存。

RSS 是进程当前驻留在物理内存中的页面总量。它包括堆,但不止于堆。一个简化的关系可以表示为:

RSS ≈ Java Heap
    + Metaspace
    + Code Cache
    + Thread Stacks
    + Direct Memory
    + GC/JVM Native Structures
    + JNI/Native Library Allocations

这里的加法不是严格的账单模型,因为不同区域可能包含保留、提交、映射和实际驻留等不同口径,但它足以帮助我们建立排查方向。

因此,看到“堆使用率只有 50%”时,不能直接推断“进程还用了不到一半内存”。在容器环境中,还要特别关注 cgroup 的内存限制和进程实际 RSS。

一个容易复现的直接内存问题

ByteBuffer.allocateDirect 分配的是堆外内存。Java 代码持有的是一个 DirectByteBuffer 对象,而真正的大块内存位于堆外。这个对象被回收后,底层内存通常会由清理机制释放,但释放时机受 GC 影响,不能简单理解为离开作用域就立即归还。

下面的示例故意制造直接内存暂时无法及时回收的情况:

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

public class DirectMemoryDemo {
    public static void main(String[] args) throws Exception {
        List<ByteBuffer> buffers = new ArrayList<>();

        for (int i = 0; i < 1024; i++) {
            buffers.add(ByteBuffer.allocateDirect(1024 * 1024));
            Thread.sleep(10);
        }

        System.out.println("allocated direct buffers: " + buffers.size());
        Thread.sleep(600_000);
    }
}

运行时可以设置:

java -Xms256m -Xmx256m -XX:MaxDirectMemorySize=512m DirectMemoryDemo

这个程序的堆可能并不大,但直接内存会接近限制。当直接内存分配无法继续时,常见异常是:

java.lang.OutOfMemoryError: Cannot reserve ... bytes of direct buffer memory

需要注意,-XX:MaxDirectMemorySize 是对直接缓冲区使用量的限制,不是整个 Java 进程的内存上限。即使直接内存没有达到这个值,线程栈、Metaspace 或本地库仍然可能让进程触碰容器限制。

第一阶段:确认是堆外增长,而不是监控口径问题

排查时先在相近时间点采集三组数据:进程 RSS、堆使用情况、JVM 原生内存摘要。

ps -o pid,rss,vsz,comm -p <pid>

jstat -gcutil <pid> 5s 12

jcmd <pid> VM.native_memory summary

ps 中的 RSS 通常以 KiB 展示,具体口径要以操作系统工具为准。jstat -gcutil 可以观察各堆区利用率和 GC 次数,但不能代表全部进程内存。

如果执行 VM.native_memory 得到未启用或无法使用的提示,需要在 JVM 启动时开启 Native Memory Tracking:

java -XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions App

NMT 会增加一定开销,通常适合诊断环境或经过评估后用于生产。它必须在 JVM 启动时开启,不能依靠运行时命令补上。

开启后,可以查看摘要:

jcmd <pid> VM.native_memory summary

也可以先记录基线,过一段时间后比较差异:

jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff

如果 ThreadClassCodeGC 等分类明显增长,方向就会比单看堆曲线清晰很多。NMT 不是万能的,它无法完整统计所有第三方本地库分配,但对 JVM 自身区域非常有帮助。

线程栈为什么会让 RSS 变大

每个 Java 线程通常都有对应的本地线程栈。栈大小受 -Xss 影响,线程数量则可能来自业务线程池、定时任务、RPC 客户端、数据库驱动和第三方库。

可以用下面的代码观察线程数量带来的风险:

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

public class ThreadStackDemo {
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < 2000; i++) {
            Thread thread = new Thread(() -> {
                try {
                    Thread.sleep(600_000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }, "demo-worker-" + i);

            thread.start();
            threads.add(thread);
        }

        Thread.sleep(600_000);
    }
}

线程数量并不一定等于已经实际提交的栈内存,操作系统还会受到虚拟内存映射和页面按需提交机制影响。但线程数量过大仍然会带来栈空间、线程控制块、调度和上下文切换成本。

排查时可以使用:

jcmd <pid> Thread.print -l

ps -eLf | awk '$4 == "<pid>" {count++} END {print count}'

第二条命令在不同系统上字段可能不同,不能盲目复制到所有环境。更可靠的做法是先执行 ps -eLf 查看列定义,再按实际 PID 过滤。

常见根因包括:动态创建线程而没有统一回收、线程池没有设置合理上限、每个请求或租户创建独立执行器,以及连接池或框架配置错误导致后台线程不断产生。

直接内存问题最常见的三个误区

第一,认为 ByteBuffer 变量离开方法后,直接内存马上释放。实际上,堆对象只是失去业务引用,底层内存的清理还要等待对象被识别为不可达并执行清理流程。短时间内大量创建直接缓冲区,仍可能造成压力。

第二,只设置 -Xmx,不设置或不观察 MaxDirectMemorySize。这只能限制堆,无法覆盖所有堆外内存。使用 Netty、NIO、压缩库或某些数据库驱动时,更要把直接内存纳入容量预算。

第三,把 OutOfMemoryError: Direct buffer memory 和容器 OOM 当成同一件事。前者通常是 JVM 对直接缓冲区的限制被触发,后者可能是整个进程 RSS 超过 cgroup 限制。两者的日志、触发点和处理方式并不相同。

一条更稳妥的排查路径

可以按下面顺序执行,避免一开始就生成大堆转储:

  1. 记录进程 RSS、容器内存使用、堆已用和提交量,确认增长曲线是否一致。
  2. 查看线程数量和线程名分布,判断是否存在无界创建或线程池泄漏。
  3. 检查启动参数中的 -Xmx-Xss-XX:MaxDirectMemorySize、Metaspace 和代码缓存设置。
  4. 使用 NMT 的 baseline 和 diff,观察 JVM 原生分类的变化。
  5. 检索日志中的 Direct buffer memoryunable to create native thread、容器 OOM 和 GC 日志。
  6. 如果 JVM 分类增长不明显,再检查 JNI、压缩库、TLS 库、代理和其他本地依赖。

在容器中,还应查看实际限制。例如 Linux cgroup v2 常见位置是:

cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current

不同容器运行时、cgroup 版本和部署平台路径可能不同,不能把某一台机器上的路径当成通用接口。

实践中的内存预算

为 Java 服务做内存配置时,不要只写一个 Xmx 就认为预算完成了。至少需要把以下项目列出来:堆、Metaspace、代码缓存、线程栈、直接内存、GC 本地结构和进程运行时余量。

例如,容器限制为 2 GiB 时,堆配置为 1.5 GiB 未必合理。假设服务有几百个线程、较多直接缓冲区和动态代理类,剩余的 512 MiB 很快可能被消耗。更实际的做法是先测量稳定运行时的非堆占用,再为突发流量和本地库预留空间,而不是把堆设到容器限制附近。

线程池应有明确的最大规模和拒绝策略;直接缓冲区应尽量复用,并在 API 设计上明确所有权和释放责任;第三方网络框架的 allocator、连接池和 event loop 参数也要纳入容量测试。

总结

“堆还很空,但进程快没内存了”并不是矛盾,而是监控只展示了 JVM 内存的一部分。排查这类问题时,先用 RSS 判断进程视角,再用堆指标确认 Java 对象是否增长,最后通过线程统计、直接内存参数和 NMT 缩小范围。

真正可靠的结论不应来自某一个指标,而应来自同一时间窗口内的多组证据:RSS 的变化、堆和 GC 的变化、线程数量的变化、NMT 分类的变化,以及容器限制的实际值。只有把这些数据放在一起,才能区分是对象泄漏、直接内存压力、线程栈膨胀,还是某个本地库在悄悄分配内存。

最后修改:2026 年 08 月 31 日
如果觉得我的文章对你有用,请随意赞赏