对于我的应用程序,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任务控制中:
除了直接的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资源可能成为本机内存泄漏的源。典型的例子是ZipInputStream
或DirectoryStream
。
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控制。