对于我的应用程序,Java进程使用的内存远远大于堆大小。

运行容器的系统开始出现内存问题,因为容器占用的内存远远超过堆大小。

堆大小设置为128MB(-Xmx128m -Xms128m),而容器最多占用1GB内存。正常情况下需要500MB。如果docker容器的限制低于(例如mem_limit=mem_limit=400MB),则进程将被操作系统的内存不足杀手杀死。

那么为什么Java进程比堆占用更多的内存吗?如何正确调整Docker内存限制?有没有一种方法可以减少Java进程的堆外内存占用?

Java进程使用的虚拟内存远远超出了Java堆。你知道,JVM包括许多子系统:垃圾收集器、类加载、JIT编译器等等,所有这些子系统都需要一定数量的RAM才能正常工作。

JVM并不是RAM的唯一使用者。本机库(包括标准Java类库)也可以分配本机内存。这甚至对本机内存跟踪都不可见。Java应用程序本身也可以通过直接ByteBuffers使用堆外内存。

那么在Java进程中什么需要内存呢?

JVM部件(主要通过本机内存跟踪显示)

Java堆 Heap

最明显的部分。这就是Java对象的所在。堆占用最多-Xmx的内存量。

垃圾收集器 Garbage Collector

GC结构和算法需要额外的内存来进行堆管理。这些结构是标记位图、标记堆栈(用于遍历对象图)、记忆集(用于记录区域间引用)和其他结构。其中一些是直接可调的,例如-XX:MarkStackSizeMax,其他则取决于堆布局,例如,较大的是G1区域(-XX:g1heapegionsize),较小的是记忆集。

GC内存开销因GC算法而异。-XX:+UseSerialGC和-XX:+UseShenandoahGC的开销最小。G1或CMS可以轻松地使用大约10%的堆大小。

代码缓存 Code Cache

包含动态生成的代码:JIT编译的方法、解释器和运行时存根。其大小受-XX:ReservedCodeCacheSize限制(默认为240M)。关闭-XX:-tieredcomilation以减少编译代码的数量,从而减少代码缓存的使用。

编译程序 Compiler

JIT编译器本身也需要内存来完成它的工作。可以通过关闭分层编译或减少编译器线程数来再次减少这一点:-XX:CICompilerCount

类加载 Class loading

类元数据(方法字节码、符号、常量池、注释等)存储在称为Metaspace的堆外区域中。加载的类越多,使用的元空间就越多。总使用量可以受-XX:MaxMetaspaceSize(默认无限制)和-XX:CompressedClassSpaceSize(默认为1G)的限制。

关于metaspace内存溢出排查细节也可以参考之前的文章:https://javakk.com/160.html

符号表 Symbol tables

JVM的两个主要哈希表:Symbol表包含名称、签名、标识符等,String表包含对内部字符串的引用。如果本机内存跟踪指示字符串表占用大量内存,则可能意味着应用程序过度调用字符串.实习生.

线程 Thread

线程堆栈还负责获取RAM。堆栈大小由-Xss控制。默认值是每个线程1米,但幸运的是情况不是那么糟糕。操作系统延迟地分配内存页,即在第一次使用时,因此实际的内存使用量将低得多(通常每个线程堆栈80-200KB)。我编写了一个脚本来估计有多少RSS属于Java线程栈。

还有其他JVM部分分配本机内存,但它们通常不会在总内存消耗中扮演重要角色。

直接缓冲器 Direct buffers

应用程序可以通过调用ByteBuffer.allocateDirect. 默认堆外限制等于-Xmx,但可以用-XX:MaxDirectMemorySize重写它。直接bytebuffer包含在NMT输出的其他部分(或jdk11之前的内部)。

使用的直接内存量通过JMX可见,例如在JConsole或Java任务控制中:

你知道吗?Java使用的内存远远超过堆大小插图

除了直接的ByteBuffers之外,还有mappedbytebuffer——映射到进程的虚拟内存的文件。NMT不跟踪它们,但是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
                           ^^^^^               ^^^^^^^^^^^^^^^^^^^^^^^^

本地库 Native libraries

JNI代码加载者系统加载库System.loadLibrary可以分配任意多的堆外内存,而无需JVM端的控制。这也涉及到标准Java类库。特别是,未关闭的Java资源可能成为本机内存泄漏的源。典型的例子是ZipInputStreamDirectoryStream

JVMTI代理,特别是jdwp调试代理-也会导致内存消耗过多。

此答案描述了如何使用异步探查器评测本机内存分配。

分配器问题 Allocator issues

进程通常直接从操作系统(通过mmap系统调用)或使用malloc标准libc分配器请求本机内存。反过来,malloc使用mmap从操作系统请求大块内存,然后根据自己的分配算法管理这些内存块。问题是-这个算法会导致碎片化和虚拟内存的过度使用。

jemalloc是一种替代的分配器,通常看起来比普通libc malloc更聪明,因此切换到jemalloc可能会导致免费占用的空间更小。

结论

没有一种方法可以保证java进程的完整内存使用,因为有太多的因素需要考虑。

Total memory = Heap + Code Cache + Metaspace + Symbol tables +
               Other JVM structures + Thread stacks +
               Direct buffers + Mapped files +
               Native Libraries + Malloc overhead + ...

通过JVM标志可以缩小或限制某些内存区域(如代码缓存),但许多其他内存区域根本不受JVM控制。