如何监控和诊断JVM堆内和堆外内存使用?

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

今天我想要大家的问题是:如何监控和诊断JVM堆内和堆外内存使用?

概述

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

  • 可以使用综合性的图形化工具,如JConsole、 VisualVM(注意,从Oracle JDK 9开始, VisualVM已经不再包含在JDK安装包中)等。这些工具具体使用起来相对比较直观,直接连接到Java进程,然后就可以在图形化界面里掌握内存使用情况。
  • 也可以使用命令行工具进行运行时查询,如jstat和jmap等工具都提供了一些选项,可以查看堆、方法区等使用数据。
  • 或者,也可以使用jmap等提供的命令,生成堆转储( Heap Dump)文件,然后利用jhat或Eclipse MAT等堆转储分析工具进行详细分析。
  • 如果你使用的是Tomcat、 Weblogic等Java EE服务器,这些服务器同样提供了内存管理相关的功能。
  • 另外,从某种程度上来说, GC日志等输出,同样包含着丰富的信息。

这里有一个相对特殊的部分,就是是堆外内存中的直接内存,前面的工具基本不适用,可以使用JDK自带的Native Memory Tracking( NMT)特性,它会从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 Bufer( TLAB),据我所知所有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(默认情况下,这个数值是3,意味着老年代是新生代的3倍大;换句话说,新生代是堆大小的1/4)
  • 当然,也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大小数值 -XX:NewSize=value
  • Eden和Survivor的大小是按照比例设置的,如果SurvivorRatio是8,那么Survivor区域就是Eden的1/8大小,也就是新生代的1/10,因为YoungGen=Eden +2*Survivor,JVM参数格式是 -XX:SurvivorRatio=value
  • TLAB当然也可以调整, JVM实现了复杂的适应策略

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

再来看看JVM堆外内存包括什么?
在JMC或JConsole的内存管理界面,会统计部分非堆内存,但提供的信息相对有限,下图就是JMC活动内存池的截图。
在这里插入图片描述
接下来会依赖NMT特性对JVM进行分析:
首先来做些准备工作,开启NMT并选择summary模式

-XX:NativeMemoryTracking=summary

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

-XX:+UnlockDiagnosicVMOptions -XX:+PrintNMTStatisics

然后,执行一个简单的在标准输出打印HelloWorld的程序,就可以得到下面的输出
在这里插入图片描述
简单来分析一下 NMT所表征的JVM本地内存使用:

  • 第一部分非常明显是Java堆,我已经分析过使用什么参数调整,不再赘述。
  • 第二部分是Class内存占用,它所统计的就是Java类元数据所占用的空间, JVM可以通过类似下面的参数调整其大小: -XX:MaxMetaspaceSize=value
  • 下面是Thread,这里既包括Java线程,如程序主线程、 Cleaner线程等,也包括GC等本地线程。你有没有注意到,即使是一个HelloWorld程序,这个线程数量竟然还有25。似乎有很多浪费,设想我们要用Java作为Serverless运行时,每个function是非常短暂的,如何降低线程数量呢?

DK 9的默认GC是G1,虽然它在较大堆场景表现良好,但本身就会比传统的Parallel GC或者Serial GC之类复杂太多,所以要么降低其并行线程数目,要么直接切换GC类型;JIT编译默认是开启了TieredCompilation的,将其关闭,那么JIT也会变得简单,相应本地线程也会减少。
我们来对比一下,这是默认参数情况的输出
在这里插入图片描述

  • 接下来是Code统计信息,显然这是CodeCache相关内存,也就是JIT compiler存储编译热点方法等信息的地方, JVM提供了一系列参数可以限制其初始值和最大值等,例如:
-XX:InitialCodeCacheSize=value
-XX:ReservedCodeCacheSize=value
  • 下面就是GC部分了,就像我前面介绍的, G1等垃圾收集器其本身的设施和数据结构就非常复杂和庞大,例如Remembered Set通常都会占用20%~30%的堆空间。如果我把GC明确修改为相对简单的Serial GC,会有什么效果呢?
    使用命令:
		-XX:+UseSerialGC

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

  • Compiler部分,就是JIT的开销,显然关闭TieredCompilation会降低内存使用。
  • 其他一些部分占比都非常低,通常也不会出现内存使用问题。唯一的例外就是Internal( JDK 11以后在Other部分)部分,其统计信息包含着Direct Bufer的直接内存,这其实是堆外内存中比较敏感的部分,很多堆外内存OOM就发生在这里,原则上Direct Bufer是不推荐频繁创建或销毁的,如果你怀疑直接内存区域有问题,通常可以通过类似instrument构造函数等手段,排查可能的问题。
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值