面试官上来就问:Java 进程中有哪些组件会占用内存?

 

本文的内容来自 StackOverflow 的一个问答:Java using much more memory than heap size (or size correctly Docker memory limit)
有网友留言,今天去参加面试,面试官上来就问:你能解释为什么 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。我编写了一个脚本评估有多少 RSS 属于 Java 线程堆栈。
还有其他 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>
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 资源可能造成本地内存泄漏。典型的例子是 ZipInputStreamDirectoryStream
JVMTI 代理,尤其是 jdwp 调试代理,也会造成内存消耗过多。
这个回答描述了如何使用 async-profiler 分析本地内存分配。

4. Allocator 问题

进程通常通过 mmap 系统调用直接从操作系统分配内存,或者使用标准的 libc allocator —— malloc 分配本机内存。反过来,malloc 会调用 mmap 向操作系统申请大块内存,然后根据自己的分配算法管理内存块。问题在于这种算法会造成碎片化以及过度使用虚拟内存。
jemalloc 是 libc malloc 的一个更智能的替代选项,使用 jemalloc 占用内存会变得更小。

5. 总结

因为有太多的因素需要考虑,没有一种可靠的方法可以用来评估一个 Java 进程所有的内存使用量。
总内存 = 堆 + 代码缓存 + Metaspace + 符号表 +
        其他 JVM 结构 + 线程堆栈 +
        Direct Buffer + 映射文件 +
        Native Library + Malloc 开销 + 
虽然可以通过设置 JVM 参数缩小或限制类似代码缓存这样的区域,但是其他许多区域根本不受 JVM 控制。
设置 Docker 限制的一种可能的方法是观察进程“正常”状态下的实际内存使用情况。有一些工具和技术可以用来研究 Java 内存消耗问题,Native Memory Tracking、pmap、jemalloc、async-profiler。
本站所有文章均由网友分享,仅用于参考学习用,请勿直接转载,如有侵权,请联系网站客服删除相关文章。若由于商用引起版权纠纷,一切责任均由使用者承担
极客文库 » 面试官上来就问:Java 进程中有哪些组件会占用内存?

Leave a Reply

欢迎加入「极客文库」,成为原创作者从这里开始!

立即加入 了解更多