Java 使用比堆大小更多的内存(或者正确的 Docker 内存限制)

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

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

堆大小设置为128MB (-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

该应用程序是一个 Web 服务器,使用 Jetty/Jersey/CDI 绑定在36MB 的大容量内。

使用了下面的 OS 和 Java 版本(在容器内部)。

$ 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

69387 次浏览

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

从 java 9开始,你有了一个叫做 竖锯计划的东西,它可能会减少你启动 java 应用程序时使用的内存(以及启动时间)。项目拼图和一个新的模块系统不一定创建减少必要的内存,但如果它很重要,你可以尝试一下。

您可以看一下这个例子: 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

如果你不想花时间在这上面,你可以简单地分配更多的内存,有时候这是最好的。

如何正确调整 Docker 内存限制的大小? 通过监视应用程序一段时间来检查它。若要限制容器的内存,请尝试使用-m,—— memory bytes 选项 for docker run command-或者其他类似的选项,如果您正在运行它的话 喜欢

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

不能回答其他问题。

DR

内存的详细使用情况由本机内存跟踪(NMT)详细信息(主要是代码元数据和垃圾收集器)提供。除此之外,Java 编译器和优化器 C1/C2使用摘要中未报告的内存。

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

Docker 容器调整必须通过测试应用程序的预期负载来完成。


每个组件的详细信息

可以在容器内禁用 共享的课堂空间,因为这些类不会被另一个 JVM 进程共享。可以使用以下标志。它将删除共享类空间(17MB)。

-Xshare:off

垃圾收集器序列具有最小的内存占用,代价是在垃圾收集处理过程中需要较长的暂停时间(参见 Aleksey Shipilëv 在一张图片中对 GC 的比较)。可以使用以下标志启用它。它最多可以节省使用的 GC 空间(48 MB)。

-XX:+UseSerialGC

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

-XX:+TieredCompilation -XX:TieredStopAtLevel=1

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

可以使用以下标志禁用 C1和 C2编译器

-Xint

JVM 之外的内存现在低于总提交空间。代码空间减少了43 MB。请注意,这对应用程序的性能有重大影响。禁用 C1和 C2编译器可以减少170MB 的内存使用量。

使用 Graal VM 编译器 (代替 C2)可以减少一点内存占用。它增加了20MB 的代码内存空间,减少了来自 JVM 内存外部的60MB。

文章 面向 JVM 的 Java 内存管理提供了不同内存空间的一些相关信息。 Oracle 在 本机内存跟踪文档中提供了一些详细信息。关于 高级编译政策高级编译政策禁用 C2将代码缓存大小减少5倍中编译级别的更多细节。禁用两个编译器时 为什么 JVM 报告的提交内存比 Linux 进程驻留集大小更多?上的一些详细信息。

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

JVM 不是 RAM 的唯一使用者。本机库(包括标准的 Java 类库)也可以分配本机内存。而这对于本地记忆追踪来说是不可见的。Java 应用程序本身也可以通过直接的 ByteBuffers 使用离堆内存。

那么 Java 进程中的内存消耗是多少呢?

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

1. Java 堆

最明显的部分。这是 Java 对象存在的地方。堆占用了大量的 -Xmx内存。

2. 垃圾收集者

GC 结构和算法需要额外的内存来进行堆管理。这些结构包括 Mark Bitmap、 Mark Stack (用于遍历对象图)、 Remembers Set (用于记录区域间引用)等。其中一些是直接可调的,例如 -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 表。如果“本机内存跟踪”指示 String 表显著占用内存,则可能意味着应用程序过度调用 String.intern

7. 线

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

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

直接缓冲

应用程序可以通过调用 ByteBuffer.allocateDirect显式请求离堆内存。默认的离堆限制等于 -Xmx,但是可以用 -XX:MaxDirectMemorySize覆盖它。NMT 输出的 Other部分包括 Direct ByteBuffers (或 JDK 11之前的 Internal)。

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

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调试代理——也可能导致过度的内存消耗。

这个答案 描述了如何使用 异步剖析器分析本机内存分配。

分配器问题

进程通常直接从 OS (通过 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 控制。

设置 Docker 限制的一种可能方法是在进程的“正常”状态下观察实际内存使用情况。有调查 Java 内存消耗问题的工具和技术: 本机内存跟踪地图Jemalloc异步剖析器

更新

这是我演讲的录音。

在这个视频中,我将讨论 Java 进程中可能消耗的内存,如何监视和限制某些内存区域的大小,以及如何分析 Java 应用程序中的本机内存泄漏。