Java 堆外内存排查

概述

Java 内存排查,一直是不容易的事情,堆内内存还好,只要有 dump 文件,放到 MAT 分析下,基本就可以了,但是堆外内存,一直是个老大难问题。我们经常会遇到以下几个问题:

  • 为什么我配置了 Xmx2G,系统告警说内存用到了 4G?
  • 为什么配置了-XX:MaxDirectMemorySize 却没有效果?
  • 为什么 jdk 提供的工具,无法检查出堆外内存?
  • Xmx 和 Xms 设置相同,理论上,物理内存是不是就没有变化了?但为什么总是在变化呢?

以上几个问题,只是冰山一角,君不见网上有数不清的堆外内存排查案例。本文尝试总结堆外内存的来龙去脉。

分析

首先 Java 内存的分类:

  • 堆内(eden,Survivor0, Survivor1, old,压缩指针(15M 左右))
  • 堆外
    • JVM 自身运行需要的堆外内存
    • 线程栈(1Mb,200个线程,通常就是 200Mb 左右)
    • GC 自身占用内存(50Mb 左右)
    • 元数据(metaspace)100M 左右
      • 类信息,方法信息
      • 字节码,Code (代码存储区,80 Mb 左右)
      • 运行时常量池
      • 类常量池
      • 符号表(sumbol,几兆左右)
  • 开发者可以使用的堆外内存(直接内存)
    • ByteBuffer.aallocateDirect() 内存分配,受 XX:MaxDirectMemorySize 限制,这个内存是会被 JVM GC 自动回收的。
    • 反射 Unsafe.allocateMemory 调用分配的内存,XX:MaxDirectMemorySize 无效。
    • JNI 使用 C 分配的内存,XX:MaxDirectMemorySize 无效。

排查思路

前提条件:如果出现了 RES 大于 Xmx,首先要排除元数据区是否没有配置 Max 参数。这个可以使用 Jmap (jmap -histo[:live] pid)查看。

如果元数据区没有问题。 通常就是堆外内存导致,Unsafe 或者 JNI

jmap -histo:live 30232

Jmap 使用:

option:

  • no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
  • heap: 显示Java堆详细信息
  • histo:live : 显示堆中对象的统计信息, 会触发 GC !
  • clstats:打印类加载器信息
  • finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
  • dump::生成堆转储快照
  • F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
  • help:打印帮助信息
  • J:指定传递给运行jmap的JVM的参数

如何判断是 unsafe 还是 JNI ? 使用 NMT(Native Memory Tracking),注意:NMT 会带来 5%~10% 的性能损耗。

NMT 是 oracle 追踪堆外内存的一个插件。

这里使用一段代码例子:

1
2
3
4
5
6
7
public static void main(String[] args) throws InterruptedException, IOException {
for (; ; ) {
Thread.sleep(1000);
// 堆外内存申请
ByteBuffer.allocateDirect(1024 * 1024 * 100);
}
}

每秒执行一次申请堆外内存操作。

使用步骤:

  • 重启 Java 应用, 加入 -XX:NativeMemoryTracking=detail 参数。最好再加上 -XX:+AlwaysPreTouch 参数,可排除内存曲线干扰。
    • 启动参数 -XX:NativeMemoryTracking=detail -Xmx1G -Xms1G -XX:+AlwaysPreTouch -XX:MaxDirectMemorySize=10G
    • 如果要关闭:jcmd VM.native_memory shutdown
  • 等待内存变化,发现变化后,使用 jcmd 8490 VM.native_memory summary scale=MB 查看内存。结果如下图:

img_2.png

如上图, 列出了相关的内存信息,堆内存 1024,class 680 个,线程数 22个 等信息。

主要关注 total committed(committed表示应用正在使用的内存大小) 结果。可以看到这边显示的 1.8G,而实际上,我们只申请了 1G。

注意:下面显示 Internal 使用了 2709M,这就是 unsafe 申请的内存

如果看见 top 中的 RES 和 这个 commited 差不多,基本上,就是使用了 unsafe 内存。

unsafe 问题怎么办?

注意:-XX:MaxDirectMemorySize=size 默认和 -Xmx 相等

NIO 和 Netty 都取 -XX:MaxDirectMemorySize 值限制大小。NIO 和 Netty 有计数器字段,计算已申请堆外内存大小,监控堆外内存使用情况,超过最大值限制,抛OOM。

  • NIO 中 是:OutOfMemoryError: Direct buffer memory。
  • Netty 中是:OutOfDirectMemoryError: failed to allocate capacity byte(s) of direct memory (used: usedMemory , max: DIRECT_MEMORY_LIMIT )。

JDK 工具: jvisualvm
img_3.png

上图左边展示了 Unsafe 堆外内存的监控曲线,右边展示了 MappedByteBuffer 的内存增长曲线。

相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws InterruptedException, IOException {
for (; ; ) {
Thread.sleep(1000);
// 堆外内存。
ByteBuffer.allocateDirect(1024);
File file = new File("/Users/cxs/Downloads/setting.xml");
if (!file.exists()) {
file.createNewFile();
}
FileChannel channel = new RandomAccessFile(file, "rw").getChannel();
// mmp 内存映射。底层 map0 方法
MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
}
}

这段代码,既分配了堆外内存,又申请了 mmp(底层调用的是个 naive 方法);

如果是 unsafe 内存,分为 2 种情况:

  • 如果是 ByteBuffer.aallocateDirect() 分配的,可以使用 XX:MaxDirectMemorySize 控制。如何确定? java.nio.Bits#totalCapacity 属性监控。
    • 还可能是 netty ,通常不会,可能性小,监控项:io.netty.util.internal.PlatformDependent#DIRECT_MEMORY_COUNTER
  • 如果是 Unsafe 分配的,比较麻烦,需要 debug 这个 unsafe 类,找出是哪里用的,然后再进行查看,为什么泄漏。
  • 另外检查是否有System.gc

如果 RES 和 这个 commited 差很多,那通常就是 JNI 分配内存的问题。

题外话:ByteBuffer 的回收。
Direct ByteBuffer分配出去的直接内存其实也是由GC负责回收的,而不像Unsafe是完全自行管理的,Hotspot在GC时会扫描Direct ByteBuffer对象是否有引用,如没有则同时也会回收其占用的堆外内存。

DirectByteBuffer 类有一个内部的静态类 Deallocator,这个类实现了 Runnable 接口并在 run() 方法内释放了内存,源码如下

JNI 问题怎么办?

常见 java zip 工具类。

google 工具:

  • root 权限安装 gperftools,export 环境变量, 重启 Java 应用。然后静等输出 多个 heap 二进制文件。
  • 有了 heap 文件后,使用 pprof 将最后一个 heap 二进制文件转成 pdf 调用图,即可查看。

通常会有这样的图:
img_4.png

实战

  • 使用 NMT 排除 unsafe 内存泄漏。

unsafe 内存泄漏实战代码。

JVM 参数:-XX:NativeMemoryTracking=detail -Xmx1G -Xms1G -XX:+AlwaysPreTouch -XX:MaxDirectMemorySize=1M

1
2
3
4
5
6
7
8
9
10
import java.io.IOException;
import java.nio.ByteBuffer;
public class Main2 {
public static void main(String[] args) throws InterruptedException, IOException {
for (; ; ) {
Thread.sleep(1000);
ByteBuffer.allocateDirect(1024 * 1024 * 100);
}
}
}
  • 使用 google pprof 排查 JNI 内存泄漏。

工具安装:

  • yum install gperftools
1
2
3
export LD_PRELOAD="/usr/lib64/libtcmalloc.so"
export HEAPPROFILE="/usr/local/timevale/my"
export HEAP_PROFILE_ALLOCATION_INTERVAL=2147483648

JNI 内存泄漏实战代码:

JVM 参数:-XX:NativeMemoryTracking=detail -Xmx20M -Xms20M -XX:+AlwaysPreTouch -XX:MaxDirectMemorySize=1M

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import java.io.*;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

public class Main {

public static String randomString(int strLength) {
Random rnd = ThreadLocalRandom.current();
StringBuilder ret = new StringBuilder();
for (int i = 0; i < strLength; i++) {
boolean isChar = (rnd.nextInt(2) % 2 == 0);
if (isChar) {
int choice = rnd.nextInt(2) % 2 == 0 ? 65 : 97;
ret.append((char) (choice + rnd.nextInt(26)));
} else {
ret.append(rnd.nextInt(10));
}
}
return ret.toString();
}


public static int copy(InputStream input, OutputStream output) throws IOException {
long count = copyLarge(input, output);
return count > 2147483647L ? -1 : (int) count;
}


public static long copyLarge(InputStream input, OutputStream output) throws IOException {
byte[] buffer = new byte[4096];
long count = 0L;

int n;
for (; -1 != (n = input.read(buffer)); count += (long) n) {
output.write(buffer, 0, n);
}

return count;
}


public static String decompress(byte[] input) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
copy(new GZIPInputStream(new ByteArrayInputStream(input)), out);
return new String(out.toByteArray());
}

public static byte[] compress(String str) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(bos);
try {
gzip.write(str.getBytes());
gzip.finish();
byte[] b = bos.toByteArray();
return b;
} finally {
try {
gzip.close();
} catch (Exception ex) {
}
try {
bos.close();
} catch (Exception ex) {
}
}
}


public static void main(String[] args) throws Exception {


int BLOCK_SIZE = 102400;
String str = randomString(BLOCK_SIZE);

byte[] bytes = compress(str);
for (; ; ) {
decompress(bytes);
Thread.sleep(1);
}
}

其他工具

  • GDB dump查看内存空间内容
  • pmap 查看进程内存地址空间
    • pmap -x 30232 | sort -k 3 -n -r
    • 7f5a
  • strace常用来跟踪进程执行时的系统调用和所接收的信号
    • strace -o output.txt -T -tt -e trace=all -p 28979
    • strace -f -e”brk,mmap,munmap” -o output.txt -p 30232

总结

img_5.png


Java 堆外内存排查
http://thinkinjava.cn/2023/08/15/2023/memory_fix/
作者
莫那·鲁道
发布于
2023年8月15日
许可协议