Java 进程中有哪些组件会占用内存?

Java 进程中有哪些组件会占用内存?

楼主发现 Java 进程占用内存远超过堆内存设置的大小,于是提出了下面的问题:

有谁能解释为什么 Java 进程占用内存远超过堆内存大小?如何正确计算 Docker 内存限制?有没有办法减少 Java 进程的堆外内存(off-heap memeory)占用?

"下面是热心网友的答复"

Java 进程使用的虚拟内存远远超过 Java 堆大小。要知道 JVM 包括许多子系统,垃圾回收器、类装载器、JIT 编译器等等。所有这些子系统运行都需要占用内存。

JVM 不是内存唯一的消费者,Java Class Library 在内的所有 Native Library 也会占用内存。对于内存跟踪工具来说这些开销甚至无法跟踪。Java 应用程序本身还可以通过直接 ByteBuffers 使用堆外内存。

一、究竟 Java 进程中有哪些组件会占用内存?

通过 Native Memory Tracking 可以观察到有以下 JVM 组件。

1.1 Java 堆

最显而易见的就是 Java 堆,它是 Java 对象存在的地方。它会占用 -Xmx 参数指定大小的内存。

1.2 垃圾回收器

GC 需要额外的内存进行堆管理,主要用于 GC 自身的结构与算法。这些结构包括 Mark Bitmap、Mark Stack(遍历对象关系图)、Remembered Set(记录 region 之间引用)等等。其中一些可以直接调优,例如 -XX: MarkStackSizeMax 选项,另一些依赖于堆布局。其中 G1 region (-XX:G1HeapRegionSize)占用内存较大,Remembered Set 占用内存较小。

GC 的内存开销因算法而异,其中 -XX:+UseSerialGC-XX:+UseShenandoahGC 的开销最小,而 G1 或 CMS 则会轻松占用大约10%的堆内存。

1.3 代码缓存
代码缓存包含动态生成的代码,JIT 编译生成的方法、解释器以及运行时 stub 代码。代码大小受 -XX:ReservedCodeCacheSize选项限制(默认为240M)。关闭 -XX:-TieredCompilation 可以减少已编译代码的数量,从而减小代码缓存。

1.4 编译器

JIT 编译器本身工作时也需要内存。可以通过关闭 Tiered Compilation 或者 -XX:CICompilerCount 减少编译使用的线程数。

1.5 类加载

类的元数据存储在 Metaspace 堆外区域中,包括方法字节码、符号、常量池、注解等。加载的类越多,使用的元数据就越多。可以通过 -XX:MaxMetaspaceSize(默认无上限)和 -XX:CompressedClassSpaceSize(默认1G)选项控制元数据总大小。

1.6 符号表

JVM 有两个主要的 hashtable:符号表包含名称、签名、标识符等,String 表包含对 interned String 引用。如果 Native Memory Tracking 显示 String 表使用了大量内存,这可能意味着应用程序调用 String.intern 过于频繁。

1.7 线程

线程堆栈也会申请内存。堆栈大小由 -Xss 选项指定,默认每个线程1M,幸运的是情况并非那么糟糕。操作系统会以延迟分配的方式分配内存页面,比如在第一次使用时分配,因此实际使用的内存要低得多,通常每个线程堆栈占用80至200KB。

还有其他 JVM 部件会占用本地内存,但它们在总内存消耗中通常比例不大。

二、Direct Buffer

应用程序可以通过 ByteBuffer.allocateDirect 调用直接请求非堆内存。默认的非堆内存大小限制由 -Xmx 选项指定,但也可以使用 -XX:MaxDirectMemorySize 覆盖配置。Direct ByteBuffer 包含在 Native Memory Tracking 输出的 Other 区域,在 JDK 11 之前包含在 Internal 区域。

通过 JMX 可以在 JConsole 或 Java Mission Control 中直接看到 Direct Memory 的使用量:



除了 Direct ByteBuffer,还有 MappedByteBuffer 映射到进程虚拟内存中的文件。虽然 Native Memory Tracking 不对它跟踪,但是 MappedByteBuffer 也会占用物理内存,而且没有一种简单的方法限制它申请的内存大小。可以通过查看进程内存映射了解实际的内存使用情况:pmap-x

shell
Address Kbytes RSS Dirty Mode Mapping
...
00007f2b3e557000 39592 32956 0 r--s- some-file-17405-Index.db
00007f2b40c01000 39600 33092 0 r--s- some-file-17404-Index.db
^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^

三、Native Library

System.Loadlibrary 加载的 JNI 代码可以不受 JVM 控制分配堆外内存,标准 Java Class Library 也是如此。尤其是未关闭的 Java 资源可能造成本地内存泄漏。典型的例子是 ZipInputStreamDirectoryStream

JVMTI 代理,尤其是 jdwp 调试代理,也会造成内存消耗过多。

四、Allocator 问题

进程通常通过 mmap 系统调用直接从操作系统分配内存,或者使用标准的 libc allocator —— malloc 分配本机内存。反过来,malloc 会调用 mmap 向操作系统申请大块内存,然后根据自己的分配算法管理内存块。

五、总结

因为有太多的因素需要考虑,没有一种可靠的方法可以用来评估一个 Java 进程所有的内存使用量。

总内存 = 堆 + 代码缓存 + Metaspace + 符号表 +
其他 JVM 结构 + 线程堆栈 +
Direct Buffer + 映射文件 +
Native Library + Malloc 开销 + ...

虽然可以通过设置 JVM 参数缩小或限制类似代码缓存这样的区域,但是其他许多区域根本不受 JVM 控制。

设置 Docker 限制的一种可能的方法是观察进程“正常”状态下的实际内存使用情况。有一些工具和技术可以用来研究 Java 内存消耗问题

发布于 2019-05-06 16:15