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

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

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

典型回答

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

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

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

考点分析

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

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

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

知识扩展

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

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

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

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

1.新生代

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

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
-XX:SurvivorRatio=value

不知道你有没有注意到,我在年代视角的堆结构示意图也就是第一张图中,还标记出了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本地内存使用:

-XX:MaxMetaspaceSize=value

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

-XX:InitialBootClassLoaderMetaspaceSize=30720

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

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

-XX:InitialCodeCacheSize=value
-XX:ReservedCodeCacheSize=value

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

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

使用命令:

-XX:+UseSerialGC

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

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

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

一课一练

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

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

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