Java使用的内存比堆大小(或正确设置的Docker内存限制)要多得多。

202

我的应用程序使用的Java进程内存比堆大小要多得多。

容器运行的系统因为容器占用的内存比堆大小多很多而开始出现内存问题。

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

您能解释一下为什么Java进程使用的内存比堆多吗?如何正确调整Docker内存限制?有没有办法减少Java进程的离堆内存占用?


我使用JVM本地内存跟踪命令收集了有关该问题的一些详细信息。

从主机系统中,我获取了容器使用的内存情况。

$ docker stats --no-stream 9afcb62a26c8
CONTAINER ID        NAME                                                                                        CPU %               MEM USAGE / LIMIT   MEM %               NET I/O             BLOCK I/O           PIDS
9afcb62a26c8        xx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.0acbb46bb6fe3ae1b1c99aff3a6073bb7b7ecf85   0.93%               461MiB / 9.744GiB   4.62%               286MB / 7.92MB      157MB / 2.66GB      57

从容器内部,我可以获取进程使用的内存。

$ ps -p 71 -o pcpu,rss,size,vsize
%CPU   RSS  SIZE    VSZ
11.2 486040 580860 3814600

$ jcmd 71 VM.native_memory
71:

Native Memory Tracking:

Total: reserved=1631932KB, committed=367400KB
-                 Java Heap (reserved=131072KB, committed=131072KB)
                            (mmap: reserved=131072KB, committed=131072KB) 

-                     Class (reserved=1120142KB, committed=79830KB)
                            (classes #15267)
                            (  instance classes #14230, array classes #1037)
                            (malloc=1934KB #32977) 
                            (mmap: reserved=1118208KB, committed=77896KB) 
                            (  Metadata:   )
                            (    reserved=69632KB, committed=68272KB)
                            (    used=66725KB)
                            (    free=1547KB)
                            (    waste=0KB =0.00%)
                            (  Class space:)
                            (    reserved=1048576KB, committed=9624KB)
                            (    used=8939KB)
                            (    free=685KB)
                            (    waste=0KB =0.00%)

-                    Thread (reserved=24786KB, committed=5294KB)
                            (thread #56)
                            (stack: reserved=24500KB, committed=5008KB)
                            (malloc=198KB #293) 
                            (arena=88KB #110)

-                      Code (reserved=250635KB, committed=45907KB)
                            (malloc=2947KB #13459) 
                            (mmap: reserved=247688KB, committed=42960KB) 

-                        GC (reserved=48091KB, committed=48091KB)
                            (malloc=10439KB #18634) 
                            (mmap: reserved=37652KB, committed=37652KB) 

-                  Compiler (reserved=358KB, committed=358KB)
                            (malloc=249KB #1450) 
                            (arena=109KB #5)

-                  Internal (reserved=1165KB, committed=1165KB)
                            (malloc=1125KB #3363) 
                            (mmap: reserved=40KB, committed=40KB) 

-                     Other (reserved=16696KB, committed=16696KB)
                            (malloc=16696KB #35) 

-                    Symbol (reserved=15277KB, committed=15277KB)
                            (malloc=13543KB #180850) 
                            (arena=1734KB #1)

-    Native Memory Tracking (reserved=4436KB, committed=4436KB)
                            (malloc=378KB #5359) 
                            (tracking overhead=4058KB)

-        Shared class space (reserved=17144KB, committed=17144KB)
                            (mmap: reserved=17144KB, committed=17144KB) 

-               Arena Chunk (reserved=1850KB, committed=1850KB)
                            (malloc=1850KB) 

-                   Logging (reserved=4KB, committed=4KB)
                            (malloc=4KB #179) 

-                 Arguments (reserved=19KB, committed=19KB)
                            (malloc=19KB #512) 

-                    Module (reserved=258KB, committed=258KB)
                            (malloc=258KB #2356) 

$ cat /proc/71/smaps | grep Rss | cut -d: -f2 | tr -d " " | cut -f1 -dk | sort -n | awk '{ sum += $1 } END { print sum }'
491080

该应用程序是使用Jetty / Jersey / CDI构建的Web服务器,打包为36 MB大小的fat jar文件。

容器内使用以下版本的操作系统和Java。Docker镜像基于openjdk:11-jre-slim

$ java -version
openjdk version "11" 2018-09-25
OpenJDK Runtime Environment (build 11+28-Debian-1)
OpenJDK 64-Bit Server VM (build 11+28-Debian-1, mixed mode, sharing)
$ uname -a
Linux service1 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 x86_64 GNU/Linux

https://gist.github.com/prasanthj/48e7063cac88eb396bc9961fb3149b58


7
堆是对象分配的地方,但JVM还有许多其他内存区域,包括共享库、直接内存缓冲区、线程堆栈、GUI组件和元空间。你需要查看JVM的大小并将限制设置得足够高,以便您宁愿进程死亡也不要再使用更多内存。 - Peter Lawrey
3
看起来垃圾收集器正在使用大量内存。您可以尝试改用CMS垃圾收集器。似乎有约125 MB的空间被用于元空间和代码,但是如果不缩小代码库,很难使其变小。已经分配的空间接近您的限制,因此被终止并不令人意外。 - Peter Lawrey
我认为值得一提的是这篇文章:Java SE支持Docker CPU和内存限制 - Guillaume Husta
1
你的程序是否执行许多文件操作(例如创建几千兆字节大小的文件)?如果是这样,你应该知道 cgroups 会将磁盘缓存添加到已使用的内存中,即使它由内核处理并且对用户程序不可见。(请注意,命令 psdocker stats 不计算磁盘缓存。) - Lorinczy Zsigmond
显示剩余4条评论
5个回答

433
Java进程使用的虚拟内存远不止Java堆。JVM包括许多子系统:垃圾收集器、类加载器、JIT编译器等,所有这些子系统都需要一定数量的RAM才能正常运行。
JVM并不是RAM的唯一消耗者。本地库(包括标准Java类库)也可能分配本地内存。而且这甚至对本地内存跟踪都不可见。Java应用程序本身也可以通过直接ByteBuffer使用非堆内存。
那么在Java进程中占用内存的是什么?
JVM部分(大多数由本地内存跟踪显示)
1. Java堆
最明显的部分。这是Java对象所在的位置。堆最多占用-Xmx内存量。
2. 垃圾回收器
GC结构和算法需要额外的内存来管理堆。这些结构包括Mark Bitmap、Mark Stack(用于遍历对象图)、Remembered Sets(用于记录区域间引用)等等。其中一些可以直接调整,例如-XX:MarkStackSizeMax,另一些则取决于堆布局,例如G1区域越大(-XX:G1HeapRegionSize),记忆集就越小。
GC内存开销因GC算法而异。-XX:+UseSerialGC和-XX:+UseShenandoahGC的开销最小。G1或CMS可能会轻松使用总堆大小的10%左右。
3. 代码缓存

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

4. 编译器

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

5. 类加载

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

6. 符号表

JVM的两个主要哈希表:符号表包含名称、签名、标识符等,字符串表包含对已合并字符串的引用。如果本机内存跟踪指示字符串表占用了大量内存,则可能是应用程序过度调用String.intern造成的。

7. 线程
线程堆栈也负责占用RAM。堆栈大小由-Xss控制。默认值为每个线程1M,但幸运的是情况并不那么糟糕。操作系统懒惰地分配内存页,即在第一次使用时,因此实际内存使用量将会更低(通常每个线程堆栈为80-200 KB)。我编写了一个脚本来估算RSS中有多少属于Java线程堆栈。
JVM的其他部分也会分配本机内存,但它们通常在总内存消耗中扮演的角色不大。

直接缓冲区

应用程序可以通过调用ByteBuffer.allocateDirect显式请求非堆内存。默认的非堆限制等于-Xmx,但可以通过-XX:MaxDirectMemorySize进行覆盖。直接字节缓冲区包含在NMT输出的Other部分中(或在JDK 11之前的Internal部分中)。
正在使用的直接内存量可通过JMX可见,例如在JConsole或Java Mission Control中:

BufferPool MBean

除了直接的ByteBuffers之外,还可以有{{MappedByteBuffers}} -映射到进程虚拟内存的文件。NMT不会跟踪它们,但是{{MappedByteBuffers}}也可以占用物理内存。而且没有简单的方法来限制它们所占用的内存数量。您只能通过查看进程内存映射来查看实际使用情况: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
                           ^^^^^               ^^^^^^^^^^^^^^^^^^^^^^^^

本地库

System.loadLibrary加载的JNI代码可以自由分配堆外内存,JVM无法控制。这同样适用于标准Java类库。特别是未关闭的Java资源可能成为内存泄漏的来源。典型的例子是ZipInputStreamDirectoryStream

JVMTI代理,特别是jdwp调试代理,也可能导致过度内存消耗。

本答案描述了如何使用async-profiler对本地内存分配进行分析。

分配器问题

进程通常通过mmap系统调用直接从操作系统请求堆外内存,或者使用标准libc分配器malloc。然后,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标志来缩小或限制某些内存区域(例如Code Cache),但许多其他内存区域完全不受JVM控制。
设置Docker限制的一种可能的方法是观察进程在“正常”状态下的实际内存使用情况。有用于调查Java内存消耗问题的工具和技术:Native Memory Trackingpmapjemallocasync-profiler
更新
这里有我演示的录像Memory Footprint of a Java Process
在这个视频中,我讨论了Java进程中可能会消耗内存的内容,如何监视和限制某些内存区域的大小以及如何分析Java应用程序中的本地内存泄漏。

2
自JDK7以来,不是将interned字符串存储在堆中吗?(https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6962931)也许我错了。 - j-keck
11
字符串对象在堆中,但哈希表(包括桶和具有引用和哈希码的条目)在非堆内存中。我重新措辞了这个句子以更加精确。感谢您的指出。 - apangin
此外,即使您使用非直接的ByteBuffer,JVM也会在本地内存中分配临时直接缓冲区,而不会施加任何内存限制。参见http://www.evanjones.ca/java-bytebuffer-leak.html。 - Cpt. Senkfuss

19

https://developers.redhat.com/blog/2017/04/04/openjdk-and-containers/:

为什么我在指定-Xmx=1g时,我的JVM使用的内存比1GB还要多?
指定-Xmx=1g告诉JVM分配1GB堆。它并没有告诉JVM将整个内存使用限制在1GB以内。有card tables、code caches和各种其他的离堆数据结构。用于指定总内存使用的参数是-XX:MaxRAM。请注意,使用-XX:MaxRam=500m时,堆的大小将约为250MB。
Java会查看主机内存大小,而不知道任何容器内存限制。这不会产生内存压力,因此GC也不需要释放已使用的内存。我希望XX:MaxRAM可以帮助您减少内存占用。最终,您可以调整GC配置(-XX:MinHeapFreeRatio,-XX:MaxHeapFreeRatio,...)
有许多种内存指标。Docker似乎报告RSS内存大小,这可能与jcmd报告的“已提交”内存不同(较旧版本的Docker将RSS +缓存报告为内存使用情况)。 良好的讨论和链接:在Docker容器中运行的JVM的Resident Set Size(RSS)和Java总提交内存(NMT)之间的区别 (RSS)内存也可能被容器中的其他实用程序(如shell、进程管理器等)所占用。我们不知道容器中还运行着什么以及您如何启动容器中的进程。

确实使用-XX:MaxRam更好。我认为它仍然使用了超过最大定义的内存,但比以前更好了,谢谢! - Nicolas Henneaux
也许您的Java实例需要更多的内存。它有15267个类和56个线程。 - Jan Garaj
提供的 RSS 仅来自 Java 进程内部的容器 ps -p 71 -o pcpu,rss,size,vsize,其中 Java 进程的 pid 为 71。实际上 -XX:MaxRam 没有起到帮助作用,但您提供的链接有助于串行 GC。 - Nicolas Henneaux

15

简而言之

通过 Native Memory Tracking (NMT) 详细提供了内存的使用情况(主要是代码元数据和垃圾回收)。除此之外,Java 编译器和优化器 C1/C2 使用的内存在总结中没有报告。

可以使用 JVM 标志来减小内存占用(但会有影响)。

对于容器大小的调整必须通过测试来确定预期负载的应用程序。


每个组件的详细信息

共享类空间 可以在容器中禁用,因为类不会被其他 JVM 进程共享。可以使用以下标志。它将删除共享类空间(17MB)。

-Xshare:off

垃圾收集器串行方式在最小化内存占用的情况下代价是垃圾收集处理期间较长的暂停时间(参见Aleksey Shipilëv对比GC的图片)。您可以通过以下标志启用它。它可以节省高达GC空间使用量(48MB)。

-XX:+UseSerialGC

使用以下标志可以禁用C2编译器,从而减少用于决定是否优化方法的分析数据。

-XX:+TieredCompilation -XX:TieredStopAtLevel=1

代码空间减少了20MB。此外,JVM之外的内存减少了80MB(NMT空间和RSS空间之间的差异)。优化编译器C2需要100MB。

C1和C2编译器可以通过以下标志禁用。

-Xint

JVM外部的内存现在比总承诺空间更低。代码空间减少了43MB。请注意,这对应用程序的性能有很大影响。禁用C1和C2编译器可以减少170MB的内存使用。

使用Graal VM编译器(替代C2)可以使内存占用略微减小。它增加了20MB的代码内存空间,并从JVM外部内存中减少了60MB。

Java Memory Management for JVM文章提供了一些有关不同内存空间的相关信息。 Oracle在Native Memory Tracking documentation中提供了一些详细信息。有关编译级别的更多详细信息,请参见advanced compilation policydisable C2 reduce code cache size by a factor 5。当两个编译器都被禁用时,为什么JVM报告的承诺内存比Linux进程驻留集大小更大?提供了一些详细信息。


-1

Java需要大量的内存。JVM本身需要大量的内存来运行。堆是虚拟机内部可用于应用程序的内存。因为JVM是一个装满所有可能好东西的大捆绑,所以加载它需要很多内存。

从Java 9开始,你有一个叫做项目Jigsaw的东西,它可以在启动Java应用程序时减少使用的内存(以及启动时间)。项目Jigsaw和一个新的模块系统并不一定是为了减少必要的内存而创建的,但如果这很重要,你可以试试。

你可以看看这个例子:https://steveperkins.com/using-java-9-modularization-to-ship-zero-dependency-native-apps/。通过使用模块系统,它产生了一个21MB的CLI应用程序(带有嵌入式JRE)。JRE需要超过200MB的内存。这应该会在应用程序启动时转化为更少的分配内存(许多未使用的JRE类将不再被加载)。

这里有另一个不错的教程:https://www.baeldung.com/project-jigsaw-java-modularity

如果你不想花时间学习,可以直接分配更多的内存。有时候这是最好的选择。


使用 jlink 相当受限,因为它要求应用程序进行模块化。自动模块不受支持,因此没有简单的方法可以实现。 - Nicolas Henneaux

-5

如何正确设置Docker内存限制? 通过监控应用程序一段时间来检查它。尝试使用docker run命令的-m,--memory字节选项来限制容器的内存 - 或者如果您以其他方式运行它,则使用等效选项。

docker run -d --name my-container --memory 500m <iamge-name>

无法回答其他问题。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接