You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

第26讲 | 如何监控和诊断JVM堆内和堆外内存使用

上一讲我介绍了JVM内存区域的划分总结了相关的一些概念今天我将结合JVM参数、工具等方面进一步分析JVM内存结构包括外部资料相对较少的堆外部分。

今天我要问你的问题是如何监控和诊断JVM堆内和堆外内存使用

典型回答

了解JVM内存的方法有很多具体能力范围也有区别简单总结如下

  • 可以使用综合性的图形化工具如JConsole、VisualVM注意从Oracle JDK 9开始VisualVM已经不再包含在JDK安装包中等。这些工具具体使用起来相对比较直观直接连接到Java进程然后就可以在图形化界面里掌握内存使用情况。

以JConsole为例其内存页面可以显示常见的堆内存各种堆外部分使用状态。

  • 也可以使用命令行工具进行运行时查询如jstat和jmap等工具都提供了一些选项可以查看堆、方法区等使用数据。

  • 或者也可以使用jmap等提供的命令生成堆转储Heap Dump文件然后利用jhat或Eclipse MAT等堆转储分析工具进行详细分析。

  • 如果你使用的是Tomcat、Weblogic等Java EE服务器这些服务器同样提供了内存管理相关的功能。

  • 另外从某种程度上来说GC日志等输出同样包含着丰富的信息。

这里有一个相对特殊的部分就是是堆外内存中的直接内存前面的工具基本不适用可以使用JDK自带的Native Memory TrackingNMT特性它会从JVM本地内存分配的角度进行解读。

考点分析

今天选取的问题是Java内存管理相关的基础实践对于普通的内存问题掌握上面我给出的典型工具和方法就足够了。这个问题也可以理解为考察两个基本方面能力第一你是否真的理解了JVM的内部结构第二具体到特定内存区域应该使用什么工具或者特性去定位可以用什么参数调整。

对于JConsole等工具的使用细节我在专栏里不再赘述如果你还没有接触过你可以参考JConsole官方教程。我这里特别推荐Java Mission ControlJMC这是一个非常强大的工具不仅仅能够使用JMX进行普通的管理、监控任务,还可以配合Java Flight RecorderJFR技术以非常低的开销收集和分析JVM底层的Profiling和事件等信息。目前 Oracle已经将其开源如果你有兴趣请可以查看OpenJDK的Mission Control项目。

关于内存监控与诊断我会在知识扩展部分结合JVM参数和特性尽量从庞杂的概念和JVM参数选项中梳理出相对清晰的框架

  • 细化对各部分内存区域的理解,堆内结构是怎样的?如何通过参数调整?

  • 堆外内存到底包括哪些部分?具体大小受哪些因素影响?

知识扩展

今天的分析我会结合相关JVM参数和工具进行对比以加深你对内存区域更细粒度的理解。

首先,堆内部是什么结构?

对于堆内存我在上一讲介绍了最常见的新生代和老年代的划分其内部结构随着JVM的发展和新GC方式的引入可以有不同角度的理解下图就是年代视角的堆结构示意图。

你可以看到按照通常的GC年代方式划分Java堆内分为

1.新生代

新生代是大部分对象创建和销毁的区域在通常的Java应用中绝大部分对象生命周期都是很短暂的。其内部又分为Eden区域作为对象初始分配的区域两个Survivor有时候也叫from、to区域被用来放置从Minor GC中保留下来的对象。

  • JVM会随意选取一个Survivor区域作为“to”然后会在GC过程中进行区域间拷贝也就是将Eden中存活下来的对象和from区域的对象拷贝到这个“to”区域。这种设计主要是为了防止内存的碎片化并进一步清理无用对象。

  • 从内存模型而不是垃圾收集的角度对Eden区域继续进行划分Hotspot JVM还有一个概念叫做Thread Local Allocation BufferTLAB据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。这是JVM为每个线程分配的一个私有缓存区域否则多线程同时分配内存时为避免操作同一地址可能需要使用加锁等机制进而影响分配速度你可以参考下面的示意图。从图中可以看出TLAB仍然在堆上它是分配在Eden区域内的。其内部结构比较直观易懂start、end就是起始地址top指针则表示已经分配到哪里了。所以我们分配新对象JVM就会移动top当top和end相遇时即表示该缓存已满JVM会试图再从Eden里分配一块儿。

2.老年代

放置长生命周期的对象通常都是从Survivor区域拷贝过来的对象。当然也有特殊情况我们知道普通的对象会被分配在TLAB上如果对象较大JVM会试图直接分配在Eden其他位置上如果对象太大完全无法在新生代找到足够长的连续空闲空间JVM就会直接分配到老年代。

3.永久代

这部分就是早期Hotspot JVM的方法区实现方式了储存Java类元数据、常量池、Intern字符串缓存在JDK 8之后就不存在永久代这块儿了。

那么我们如何利用JVM参数直接影响堆和内部区域的大小呢我来简单总结一下

  • 最大堆体积
-Xmx value

  • 初始的最小堆体积
-Xms value

  • 老年代和新生代的比例
-XX:NewRatio=value

默认情况下这个数值是2意味着老年代是新生代的2倍大换句话说新生代是堆大小的1/3。

  • 当然,也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大小数值。
-XX:NewSize=value

  • Eden和Survivor的大小是按照比例设置的如果SurvivorRatio是8那么Survivor区域就是Eden的1/8大小也就是新生代的1/10因为YoungGen=Eden + 2*SurvivorJVM参数格式是
-XX:SurvivorRatio=value

  • TLAB当然也可以调整JVM实现了复杂的适应策略如果你有兴趣可以参考这篇说明

不知道你有没有注意到我在年代视角的堆结构示意图也就是第一张图中还标记出了Virtual区域这是块儿什么区域呢

在JVM内部如果Xms小于Xmx堆的大小并不会直接扩展到其上限也就是说保留的空间reserved大于实际能够使用的空间committed。当内存需求不断增长的时候JVM会逐渐扩展新生代等区域的大小所以Virtual区域代表的就是暂时不可用uncommitted的空间。

第二分析完堆内空间我们一起来看看JVM堆外内存到底包括什么

在JMC或JConsole的内存管理界面会统计部分非堆内存但提供的信息相对有限下图就是JMC活动内存池的截图。

接下来我会依赖NMT特性对JVM进行分析它所提供的详细分类信息非常有助于理解JVM内部实现。

首先来做些准备工作开启NMT并选择summary模式

-XX:NativeMemoryTracking=summary

为了方便获取和对比NMT输出选择在应用退出时打印NMT统计信息

-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics

然后执行一个简单的在标准输出打印HelloWorld的程序就可以得到下面的输出

我来仔细分析一下NMT所表征的JVM本地内存使用

  • 第一部分非常明显是Java堆我已经分析过使用什么参数调整不再赘述。

  • 第二部分是Class内存占用它所统计的就是Java类元数据所占用的空间JVM可以通过类似下面的参数调整其大小

-XX:MaxMetaspaceSize=value

对于本例因为HelloWorld没有什么用户类库所以其内存占用主要是启动类加载器Bootstrap加载的核心类库。你可以使用下面的小技巧调整启动类加载器元数据区这主要是为了对比以加深理解也许只有在hack JDK时才有实际意义。

-XX:InitialBootClassLoaderMetaspaceSize=30720

  • 下面是Thread这里既包括Java线程如程序主线程、Cleaner线程等也包括GC等本地线程。你有没有注意到即使是一个HelloWorld程序这个线程数量竟然还有25。似乎有很多浪费设想我们要用Java作为Serverless运行时每个function是非常短暂的如何降低线程数量呢
    如果你充分理解了专栏讲解的内容对JVM内部有了充分理解思路就很清晰了
    JDK 9的默认GC是G1虽然它在较大堆场景表现良好但本身就会比传统的Parallel GC或者Serial GC之类复杂太多所以要么降低其并行线程数目要么直接切换GC类型
    JIT编译默认是开启了TieredCompilation的将其关闭那么JIT也会变得简单相应本地线程也会减少。
    我们来对比一下,这是默认参数情况的输出:

下面是替换了默认GC并关闭TieredCompilation的命令行

得到的统计信息如下线程数目从25降到了17消耗的内存也下降了大概1/3。

  • 接下来是Code统计信息显然这是CodeCache相关内存也就是JIT compiler存储编译热点方法等信息的地方JVM提供了一系列参数可以限制其初始值和最大值等例如
-XX:InitialCodeCacheSize=value

-XX:ReservedCodeCacheSize=value

你可以设置下列JVM参数也可以只设置其中一个进一步判断不同参数对CodeCache大小的影响。

很明显CodeCache空间下降非常大这是因为我们关闭了复杂的TieredCompilation而且还限制了其初始大小。

  • 下面就是GC部分了就像我前面介绍的G1等垃圾收集器其本身的设施和数据结构就非常复杂和庞大例如Remembered Set通常都会占用20%~30%的堆空间。如果我把GC明确修改为相对简单的Serial GC会有什么效果呢

使用命令:

-XX:+UseSerialGC

可见不仅总线程数大大降低25 → 13而且GC设施本身的内存开销就少了非常多。据我所知AWS Lambda中Java运行时就是使用的Serial GC可以大大降低单个function的启动和运行开销。

  • Compiler部分就是JIT的开销显然关闭TieredCompilation会降低内存使用。

  • 其他一些部分占比都非常低,通常也不会出现内存使用问题,请参考官方文档。唯一的例外就是InternalJDK 11以后在Other部分部分其统计信息包含着Direct Buffer的直接内存这其实是堆外内存中比较敏感的部分很多堆外内存OOM就发生在这里请参考专栏第12讲的处理步骤。原则上Direct Buffer是不推荐频繁创建或销毁的如果你怀疑直接内存区域有问题通常可以通过类似instrument构造函数等手段排查可能的问题。

JVM内部结构就介绍到这里主要目的是为了加深理解很多方面只有在定制或调优JVM运行时才能真正涉及随着微服务和Serverless等技术的兴起JDK确实存在着为新特征的工作负载进行定制的需求。

今天我结合JVM参数和特性系统地分析了JVM堆内和堆外内存结构相信你一定对JVM内存结构有了比较深入的了解在定制Java运行时或者处理OOM等问题的时候思路也会更加清晰。JVM问题千奇百怪如果你能快速将问题缩小大致就能清楚问题可能出在哪里例如如果定位到问题可能是堆内存泄漏往往就已经有非常清晰的思路和工具可以去解决了。

一课一练

关于今天我们讨论的题目你做到心中有数了吗今天的思考题是如果用程序的方式而不是工具对Java内存使用进行监控有哪些技术可以做到?

请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。

你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。