Java 直接内存
Java 直接内存用于在堆外保存 I/O 缓冲内容。本文先介绍直接缓冲区的用途、上限和释放时机,再说明文件映射与其他本地内存的来源。最后用 NMT 和系统指标定位进程内存增长,并估算容器内存预算。
1. 面试核心
先记住这几条,再读正文- 分配与访问。 HotSpot 的
ByteBuffer.allocateDirect在堆外分配缓冲内容,在堆内保留管理对象,供原生 I/O 直接访问数据。卡 · 直接缓冲区 - 容量限制。
-XX:MaxDirectMemorySize限制 NIO 直接缓冲区的容量总和,JNI 和文件映射需要另外计入进程预算。卡 · 直接内存上限与池统计 - 释放时机。 普通直接缓冲区的管理对象不可达后,底层内存由清理机制释放,释放时机受 GC 和清理进度影响。
- 增长归因。 NMT 的 baseline 与 diff 按 Class、Thread、Code、GC 等类别显示 HotSpot 本地内存的增长。卡 · HotSpot 本地内存的来源
2. 直接缓冲区
直接缓冲区把数据内容放到 Java 堆之外,让原生 I/O 有机会直接使用这块区域。原生 I/O 指操作系统提供的底层输入输出操作。下面在 HotSpot 中分配一块 1 MiB 的直接缓冲区:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
Java 堆上仍有一个 ByteBuffer 管理对象,保存堆外区域的地址和访问状态。JVM 会尽力让原生 I/O 直接使用这块区域,减少调用前后在堆与本地缓冲区之间的复制。直接内存不等于整个 I/O 路径都实现了零拷贝。
直接缓冲区的分配与清理成本高于普通堆缓冲区,所以更适合反复参与 I/O 的大块、长寿命数据。短小且用完即丢的数据,仍应比较 byte[] 或堆缓冲区的成本。它的内容位于堆外,单看 Java 堆占用也容易低估实际内存。
复用缓冲区可以摊薄分配成本,但池需要有容量边界。缓冲区何时释放还取决于池和管理对象的生命周期,下面会进一步说明。
2.1 直接内存上限与池统计
排查直接内存不足,需要比较缓冲区容量与配置上限。在 HotSpot JDK 21 中,MaxDirectMemorySize 限制 NIO 直接缓冲区的容量总和。未显式设置时,上限取 Runtime.maxMemory(),也就是 JVM 可用的最大堆容量。
容量计数与实际 native 分配字节数可以不同,native 在这里指堆外的本地内存。该参数并非整个进程堆外内存的总上限。JNI 分配和文件映射有各自的生命周期,需要另留预算。
BufferPoolMXBean 是读取缓冲池指标的管理接口,可以查看 direct 与 mapped 池统计:
for (BufferPoolMXBean pool :
ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
System.out.printf("%s count=%d capacity=%d used=%d%n",
pool.getName(), pool.getCount(),
pool.getTotalCapacity(), pool.getMemoryUsed());
}
输出中,count 表示池中的缓冲区数量,capacity 表示可容纳的字节总数。used 估计底层实际占用,还会受对齐和分配器影响,所以它可以与容量不同。
slice() 和 duplicate() 创建的视图共享底层内容。即使视图只暴露很小一段数据,长期保留它也会延长整块区域的生命周期。
3. 直接缓冲区的生命周期
判断缓冲区是否释放,要看谁还持有它,而非只看业务是否已经处理完数据。Java 17、21 的 ByteBuffer 没有公开的释放方法。普通直接缓冲区的管理对象不可达后,HotSpot 才能通过清理机制释放底层内存。
GC 发现不可达对象、清理机制执行释放,都需要时间,所以本地内存释放会晚于业务使用结束。共享视图还会持有底层缓冲区,只要视图仍可达,整块区域就需要继续保留。
池化则是另一种保留方式:业务归还缓冲区后,池将它留下供后续请求复用。因此,应分别检查缓冲区是否已归还、池是否仍保留它,以及底层内存是否已释放。
4. 文件映射
文件映射把文件的一段内容关联到进程虚拟地址,程序可以通过内存访问读取文件。操作系统在页面被访问时按需调入数据,可写映射中的修改页则按映射模式和系统策略回写。
因此,映射一个大文件只是先建立地址关联,并未立即把整个文件读入 Java 堆。映射会消耗虚拟地址区间和页表,访问过的页面还会占用物理内存。映射长度、RSS 和容器的文件缓存统计需要分别观察。
Java 17、21 可以使用 MappedByteBuffer 访问映射:
try (FileChannel channel = FileChannel.open(path, READ)) {
MappedByteBuffer mapped = channel.map(READ_ONLY, 0, channel.size());
// 读取 mapped,单段大小最多为 Integer.MAX_VALUE 字节。
}
本例的单段映射最多为 Integer.MAX_VALUE 字节。退出代码块会关闭 FileChannel,但映射本身仍有效,释放依赖映射缓冲区的可达性。也就是说,关闭通道与解除映射是两个不同的生命周期动作。
5. HotSpot 本地内存的来源
堆使用率平稳而进程内存上涨时,还要检查缓冲区之外的区域。HotSpot 与应用会使用以下本地内存:
- Metaspace 与压缩类空间保存类元数据。动态生成类和加载器持续保留会增加这部分占用。
- 平台线程栈保存线程调用状态,平台线程越多,总占用越大。
- JIT Code Cache 保存即时编译生成的机器码。
- GC 的卡表、记忆集和标记位图记录引用或存活信息,供回收器使用。
- 类加载、符号表、模块和其他 JVM 内部结构也需要内存。
- JNI 和第三方 native 库可以通过自己的分配器申请内存。
-Xmx 只限制 Java 堆。因此,线程增长、动态类生成或 native 泄漏,都可以在堆平稳时耗尽容器内存。
6. Native Memory Tracking
NMT(Native Memory Tracking)按类别跟踪 HotSpot 的本地内存分配。它适合回答“这段时间哪一类内存增长了”,需要在启动时开启:
java -XX:NativeMemoryTracking=summary -jar app.jar
进程运行后先建立基线,过一段时间再查看相对基线的变化:
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff
summary.diff 按 Class、Thread、Code、GC 等类别显示相对基线的增减。启动时选择 detail 还能记录更多分配调用点,代价是更高的跟踪开销。启动时未开启 NMT,就无法在运行中通过 jcmd 开启跟踪。
解释结果时,要区分虚拟地址、已承诺内存和驻留内存:
reserved是 JVM 保留的虚拟地址区间。它为后续使用留出地址范围。committed是 JVM 已承诺可用的内存,表示这部分区域已经可以使用。- RSS 是实际驻留在物理内存中的页面总量,还受页面访问和共享映射影响。
因此,NMT 数值与 RSS 可以不同。NMT 也未覆盖所有第三方 native 分配,需要结合系统工具补充。Linux 上可以对照 /proc/<pid>/smaps、pmap、native profiler 和容器指标,判断增长来自哪些映射或分配。
7. Direct buffer OOM
直接缓冲区分配失败时,要区分配置容量不足和本地内存不足。HotSpot JDK 21 在容量计数无法容纳新缓冲区时,会等待清理并尝试重试。重试失败后抛出 OutOfMemoryError,提示无法保留所需的直接缓冲区内存。
排查时,需要检查以下容量与持有来源:
- 比较 direct 池的数量、容量、已用内存和
MaxDirectMemorySize。 - 检查池是否无界,以及单次请求大小是否失控。
- 检查队列、缓存和未完成 I/O 是否仍持有缓冲区。
- 检查长期存活的小视图,以及它们持有的原始区域。
- 检查进程可用本地内存和容器上限是否满足工作负载。
即使配置上限充足,native 分配仍受进程和系统资源约束。扩大上限只适合已确认的容量不足;若释放跟不上分配,或引用一直未清理,加大容量只会延迟故障。
8. 容器内存预算
容器限制的是整体内存使用,因此设置堆上限时需要给其他区域留出空间。可以先按下面的关系估算进程峰值:
进程峰值 ≈ 堆 + 类元数据 + Code Cache + GC 结构
+ 线程栈 + direct / JNI
+ 映射与动态库 + 余量
先测量稳定负载和故障期间的峰值,再决定 -Xmx 和直接内存上限。若堆已接近容器上限,非堆区域的正常增长也会触发 OOM Kill,也就是系统因内存不足终止进程。此时 JVM 可能来不及输出 Java OOM,所以还需要查看容器和系统的退出记录。
9. 常见问题
9.1 堆外区域的释放方式
普通直接缓冲区通过堆上管理对象的可达性间接控制释放,因此 GC 会影响堆外区域的生命周期。native 库则按自身 API 管理资源,需要遵循它提供的关闭或释放规则。
9.2 reserved 与泄漏
较大的 reserved 表示保留了较多虚拟地址,单凭这个值难以判断泄漏。应在相近负载下观察 committed 或 RSS 是否持续增长,再定位增长类别及持有原因。
9.3 堆转储的证据范围
堆转储能显示缓冲区管理对象及其持有链,所以适合回答谁让缓冲区继续存活。缓冲内容、线程栈、类元数据和 Code Cache 位于堆外,需要 NMT、线程信息和系统工具补充。第三方本地库的分配还要结合其自身的统计或 native profiler。
10 · 面试题卡片
10.1 什么是 Java 直接内存,什么时候使用
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释直接缓冲区如何减少复制,以及分配成本、复用方式和持有引用怎样影响释放。
相关内容:直接缓冲区和Direct buffer OOM。
参考回答
直接 ByteBuffer 在堆外保存数据,适合反复参与原生 I/O 的大块、长寿命缓冲区。
JVM 会尽力让原生 I/O 直接访问它,减少中间复制。它的分配和清理成本较高,因此应通过复用摊薄成本,并限制池容量。
普通直接缓冲区仍依赖堆上管理对象的可达性触发清理。池和共享视图会延长底层区域的生命周期,所以业务使用结束后内存仍可继续保留。遇到直接缓冲区 OOM 时,要同时检查分配速率、池上限、未完成 I/O 和可用本地内存。
10.2 堆使用率正常但进程内存持续增长,怎样排查
出现公司:阿里巴巴、腾讯
考察重点:区分堆、线程栈、类元数据与本地库分配,并结合 NMT、缓冲池和系统映射定位增长来源。
相关内容:HotSpot 本地内存的来源至容器内存预算。
参考回答
先把 RSS 与堆 used、committed 的变化放在同一时间轴,确认增长主要发生在哪里。
之后按内存来源继续排查:
- 统计平台线程数和 BufferPool 的数量、容量与已用内存。
- 已开启 NMT 时,用
VM.native_memory baseline建立基线,再用VM.native_memory summary.diff比较增长。 - Class 增长时查类加载器,Thread 增长时查平台线程,并继续观察 Code、GC 等类别。
- 对 NMT 未覆盖的增长,结合
smaps、pmap或 native profiler 定位映射与分配来源。
最后把结果与发布、流量、线程和类加载事件关联,检查对应资源是否按预期释放。
10.3 MaxDirectMemorySize 能限制全部堆外内存吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明直接内存上限统计什么,以及 JNI 和文件映射为何需要单独计入预算。
相关内容:直接内存上限与池统计。
参考回答
MaxDirectMemorySize 只限制 NIO 直接缓冲区的容量计数,覆盖不了全部堆外内存。
HotSpot JDK 21 未显式设置时,上限取 Runtime.maxMemory()。
JNI 分配和文件映射有各自的生命周期,需要单独计入进程预算。direct 池中的 capacity 也可以与 used 不同,因为后者还受对齐和分配器影响。