Java 进程中有哪些组件会占用内存?
(给ImportNew加星标,提高Java技能)
编译:唐尤华
本文来自 StackOverflow 的一个问答:Java using much more memory than heap size (or size correctly Docker memory limit)
题主发现 Java 进程占用内存远超过堆内存设置的大小,于是提出了下面的问题:
有谁能解释为什么 Java 进程占用内存远超过堆内存大小?如何正确计算 Docker 内存限制?有没有办法减少 Java 进程的堆外内存(off-heap memeory)占用?
"下面是热心网友的答复"
Java 进程使用的虚拟内存远远超过 Java 堆大小。要知道 JVM 包括许多子系统,垃圾回收器、类装载器、JIT 编译器等等。所有这些子系统运行都需要占用内存。
JVM 不是内存唯一的消费者,Java Class Library 在内的所有 Native Library 也会占用内存。对于内存跟踪工具来说这些开销甚至无法跟踪。Java 应用程序本身还可以通过直接 `ByteBuffers` 使用堆外内存。
1. 究竟 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。我编写了一个[脚本][1]评估有多少 RSS 属于 Java 线程堆栈。
[1]:https://github.com/apangin/jstackmem
还有其他 JVM 部件会占用本地内存,但它们在总内存消耗中通常比例不大。
2. 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 <pid>`。
```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
^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
```
3. Native Library
`System.Loadlibrary` 加载的 JNI 代码可以不受 JVM 控制分配堆外内存,标准 Java Class Library 也是如此。尤其是未关闭的 Java 资源可能造成本地内存泄漏。典型的例子是 `ZipInputStream` 和 `DirectoryStream`。
JVMTI 代理,尤其是 jdwp 调试代理,也会造成内存消耗过多。
[这个回答][2]描述了如何使用 [async-profiler][3] 分析本地内存分配。
[2]:https://stackoverflow.com/a/53598622/3448419
[3]:https://github.com/jvm-profiling-tools/async-profiler/
4. Allocator 问题
进程通常通过 mmap 系统调用直接从操作系统分配内存,或者使用标准的 libc allocator —— malloc 分配本机内存。反过来,malloc 会调用 mmap 向操作系统申请大块内存,然后根据自己的分配算法管理内存块。问题在于这种算法会造成碎片化以及[过度使用虚拟内存][4]。
[4]:https://www.ibm.com/developerworks/community/blogs/kevgrig/entry/linux_glibc_2_10_rhel_6_malloc_may_show_excessive_virtual_memory_usage?lang=en
[jemalloc][5] 是 libc malloc 的一个更智能的替代选项,使用 jemalloc 占用内存会变得更小。
[5]:http://jemalloc.net/
5. 总结
因为有太多的因素需要考虑,没有一种可靠的方法可以用来评估一个 Java 进程所有的内存使用量。
```
总内存 = 堆 + 代码缓存 + Metaspace + 符号表 +
其他 JVM 结构 + 线程堆栈 +
Direct Buffer + 映射文件 +
Native Library + Malloc 开销 + ...
```
虽然可以通过设置 JVM 参数缩小或限制类似代码缓存这样的区域,但是其他许多区域根本不受 JVM 控制。
设置 Docker 限制的一种可能的方法是观察进程“正常”状态下的实际内存使用情况。有一些工具和技术可以用来研究 Java 内存消耗问题,[Native Memory Tracking][6]、[pmap][7]、[jemalloc][5]、[async-profiler][3]。
[6]:https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html
[7]:http://man7.org/linux/man-pages/man1/pmap.1.html
推荐阅读
(点击标题可跳转阅读)
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
好文章,我在看❤️