应用重启元凶之JVM内存分配与默认值

Posted 内文的后花园

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了应用重启元凶之JVM内存分配与默认值相关的知识,希望对你有一定的参考价值。

在上一篇文章中提到跑在K8S上的应用实例被频繁重启的问题,其中一个原因是K8S的内存单位与JVM的单位在表示方式上有差异,JVM的内存单位GB在K8S的yaml配置里面应该写成GiB。但是,应用程序的JVM堆Xmx值为3G,离K8S分配给应用的3.72G还有一定距离,那么究竟是谁懂了我的内存呢?


在容器世界里,一个容器推荐只跑一个进程,团队严格遵循该最佳实践,所以我们首先可以排除其它进程占用了内存。那么剩下唯一的可能就只能是应用本身占了很多内存,而且是堆之外的地方占用了内存。工欲善其事,必先利其器,要找出根源,我们有必要先了解一下JVM是如何分配和管理内存的。


JVM内存管理

JVM将它所管理的内存分成两大块,包括Heap(堆)和Non-Heap(非堆)。程序代码中创建的类实例和数组所需要的内存空间都在堆分配,除了堆以外JVM所使用的内存都属于非堆的范畴。堆这块的内存空间比较好理解,它主要管理着程序中new出来的对象实例,它的大小受到程序启动时指定的Xms和Xmx影响,Xms决定了堆空间的下限,Xmx则决定了它的上限。相比起堆,非堆就显得比较模糊,堆以外还包含了哪些内容?从官方文档上看,常见的方法区(method area)毫无疑问是属于非堆的一部分,此外还有线程栈、GC所需要的额外空间、JIT优化所需要的空间(包括记录哪些代码是热点代码以及缓存了热点代码的native code)等。下面本文将以Hotspot JVM为例,着重分析下非堆这块的内存空间以及程序通过什么方式影响非堆内存空间的大小。


MemoryUsage

通过java.lang.management.MemoryMXBean暴露的方法我们可以查看堆和非堆的内存大小。从代码上看,它们都返回MemoryUsage。


而MemoryUsage主要包含四个属性:

  • init - 表示程序在启动时尝试从堆/非堆申请的内存大小,对于堆来说它等于Xms指定的大小。这里用了“尝试”一词,是因为虽然郎有情但妾不一定有意,分配内存空间大小的最终决定权在JVM,程序只是表达了它的意向。

  • committed - 表示JVM真正从操作系统申请到的内存大小,从在windows和linux的测试结果来看,它都是比init的值稍微小一点。

  • used - 顾名思义,这里反映的是程序真正使用的内存大小。

  • max - 表示程序能从操作系统申请到的最大内存,对于堆来说它总是小于等于Xmx指定的大小。


例如,下面这段Heap的MemoryUsage输出表示的是,程序启动的时候程序尝试申请128M内存,最终JVM向操作系统申请了123M给它,同时程序已经使用了3.25M内存。程序最多可以使用的内存是228M,当操作系统内存不足的情况下不一定能保证分配到228M。


Init = 134217728 bytes (128.0 M)

Committed = 128974848 bytes (123.0 M)

Used = 3408944 bytes (3.2510223388671875 M)

Max = 239075328 bytes (228.0 M)


JVM并不是在启动后一下子就从操作系统申请所有内存,而是在Init值的基础上循序渐进(Committed的值会不断增加),直到达到max值。在达到max值后,JVM即使在后面做了GC,也只是在已申请的内存空间里面把无效数据剔除,但JVM并不会将释放出来的内存空间返回给操作系统。


Native Memory Tracking

MemoryUsage只能告诉我们堆和非堆各用了多少内存,但并没有告诉我们哪些块各用了多少内存。欲知JVM内存分配的详细信息,我们需要借助于Native Memory Tracking(NMT)。Hotspot JVM提供了NMT来追踪Hotspot JVM的内存使用情况,该功能默认是关闭的,我们需要在程序启动的时候加上-XX:NativeMemoryTracking=[summary | detail]把它打开。参数值summary和detail的区别只是追踪的粒度不一样,summary只包含汇总后的结果,而detail还包括内存使用的一些细节信息。根据官方说法,目前的NMT不能监控到JDK类库以及第三方可库分配的本地内存,打开NMT会给程序带来5%-10%的性能损失。


下面通过一段NMT的输出,看看它都包含哪些信息


Native Memory Tracking:

Total: reserved=2048MB, committed=890MB

-                 Java Heap (reserved=256MB, committed=240MB)

                            (mmap: reserved=256MB, committed=240MB)

-                     Class (reserved=1106MB, committed=89MB)

                            (classes #10925)

                            (malloc=4MB #2044)

                            (mmap: reserved=1102MB, committed=85MB)

-                    Thread (reserved=422MB, committed=422MB)

                            (thread #214)

                            (stack: reserved=421MB, committed=421MB)

                            (malloc=1MB #1069)

-                      Code (reserved=131MB, committed=7MB)

                            (malloc=1MB #1395)

                            (mmap: reserved=130MB, committed=6MB)

-                        GC (reserved=13MB, committed=13MB)

                            (malloc=3MB #115)

                            (mmap: reserved=9MB, committed=9MB)

-                  Internal (reserved=105MB, committed=105MB)

                            (malloc=105MB #14526)

-                    Symbol (reserved=14MB, committed=14MB)

                            (malloc=13MB #34371)

                            (arena=1MB #1)

-    Native Memory Tracking (reserved=1MB, committed=1MB)

                            (tracking overhead=1MB)


  • Java Heap - 毫无疑问,内存使用的一员中必然有Java堆,而且一般情况下它占用了内存空间的大头,程序中实例化的对象都在堆上分配,我们可以通过参数Xms和Xms来控制堆的大小。

  • Class - 用于存储类元数据信息,JVM通过jar包加载的class或者程序通过ASM、CGLIB等生成的class都会分配到这里。我们可以通过参数MetaspaceSize和MaxMetaspaceSize来控制它的大小。JDK8在64位机器上默认启用了指针压缩功能,并划分出一个专门的区域用来存放压缩后的指针,这个区域所使用的空间也属于Class统计的范畴,我们可以通过参数CompressedClassSpaceSize来控制它的大小。

  • Thread - 创建线程所分配的空间,我们可以通过参数Xss来控制它的大小。经测试,当程序把Xss设置为2M并创建100个线程后,从NMT的输出看到Thread的committed值已经增加了200M,但从操作系统以及Non-heap的MemoryUsage看只有些许的增加。这其中的原因可能是Thread在OS的内存请求也不是一步到位的,也跟heap一样是一点一点拿的,只有Thread的栈帧(Frame)很多的情况下才会增加Thread的真实内存使用。

  • Code - JIT对热点代码native code的缓存,若Code Cache空间不足则JIT无法继续编译,编译执行改为解释执行,性能会因此受到影响。我们可以通过XX:ReservedCodeCacheSize来控制它的大小。

  • GC - GC过程中所需要使用到的一些内存。

  • Symbol - 经测试,Interned String所占用的内存会反映在Symbol这里。

  • Internal - JVM本身运行所需要使用的内存。经测试,在Direct Memory上分配的内存会在Internal上反映出来,所以我们可以通过MaxDirectMemorySize来控制Direct Memory大小,从而间接影响Internal的大小。


更多关于Native Memory Tracking参数的解释和说明请参考文末文档。


Spring Actuator

Spring Actuator基于前面提到的Memory Usage暴露了JVM的内存使用情况,但需要注意的两点是:

1. mem反映的内存大小会比真实的大小小,原因是mem在计算内存大小的时候只统计了non-heap的used,而不是committed。

mem = java.lang.Runtime.totalMemory() + ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getUsed()

2. Direct Memory的内存并不包含在non-heap所统计的大小里面。


问题剖析

通过前面介绍,我们知道JVM的内存使用是怎样子的,它提供了哪些参数供开发人员调整内存的大小。回到文章一开头提到的问题,我们看看应用程序是如何设置JVM参数的:

-Xmx3072m -Xms3072m -XX:MaxPermSize=400m -Xss256k


参数设置十分简单,只设置了堆的大小为3G,线程大小256K,以及PermSize为400M。由于程序使用的是JDK8,JDK8已经没有Perm,取而代之的是Metaspace,所以等于PermSize是一个无效设置参数。对于没有显式指定的参数,JVM提供了默认值。

  • Meta Space - 理论上是无限大,操作系统有多少内存它就能用多少内存。对于容器来说,由于它看到的是宿主机的内存,所以这个值会比cgroup指定的内存大很多。

  • Direct Memory - Direct Memory的最大值默认等于堆的最大值,即MaxDirectMemorySize = Xmx,在我们这个例子里面是3G。

  • Reserved Code Cache - 对于JDK8,MaxReservedCodeCacheSize = 240M。

  • CompressedClassSpaceSize - 由于JDK8在64位机器上默认打开了指针压缩功能-XX:+UseCompressedClassPointers将指针压缩为32位,随之而来的是-XX:CompressedClassSpaceSize被打开,它的默认大小是1G。


从上面各个参数的默认值我们可以看到好几个参数的默认值都远远大于700M的内存大小,更不用说多个参数值的和。虽然这只是理论上的最大值,实际使用不一定这么多,但这也为OOMKilled埋下了隐患。我们需要为他们指定一个合适的值以防止它们不受控制的增长,这样才能避免OOMKilled。下面的数值128M只是个例子,需要按实际情况进行调整。

-Xms128m -Xmx128m -XX:MaxMetaspaceSize=128m -XX:CompressedClassSpaceSize=128m -Xss256k -XX:ReservedCodeCacheSize=128m -XX:MaxDirectMemorySize=128m


插个题外话,前面提到JVM看到的内存是宿主机的,而非Docker所分配的内存,CPU也是如此。在JDK8u131后,JDK提供了对Docker cpu和memory的支持,它能自动感知运行环境是否是docker并只使用docker分配的cpu limit。对于memory,则要额外加多两个参数-XX:+UnlockExperimentalVMOptions和-XX:+UseCGroupMemoryLimitForHeap才可以。JDK 11开始,参数改成了-XX:-UseContainerSupport,并且默认是打开的。


总结

本文主要回顾了JVM内存是如何分配的,并结合应用程序的JVM参数设置分析了OOMKilled的另一个原因——默认大小。我们需要对参数设定合理的值来保证内存使用的可控。


参考资料:

  1. JVM Considerations - https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/considerations.html

  2. Native Memory Tracking - 

    https://docs.oracle.com/javase/8/docs/technotes/guides/vm/nmt-8.html

  3. Native Memory Tracking - 

    https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html

  4. JVM内存模型 - https://www.cnblogs.com/sidesky

  5. Java SE support for Docker CPU and memory limits - https://blogs.oracle.com/java-platform-group/java-se-support-for-docker-cpu-and-memory-limits

  6. 聊聊新版JDK对docker容器的支持 - https://segmentfault.com/a/1190000014142950

以上是关于应用重启元凶之JVM内存分配与默认值的主要内容,如果未能解决你的问题,请参考以下文章

深入理解JVM之JVM内存区域与内存分配

JVM之垃圾收集器 (GC) 与内存分配策略

深入理解JVM之JVM内存区域与内存分配

深入理解JVM之JVM内存区域与内存分配

深入JVM系列之内存模型与内存分配

JVM-内存分配与垃圾回收