使用Eclipse Memory Analyzer分析JVM堆Dump
Eclipse Memory Analyzer(MAT)是一个图形化的Java堆分析工具,速度快且特性丰富,可以用于取代JDK自带的堆Dump浏览器(jhat)。使用MAT,你可以快速分析包含上亿对象的生产环境Dump文件,快速计算某种对象导致的内存占用量,进而评估内存泄漏风险。
你可以将MAT安装为独立运行的RCP应用,也可以作为插件安装到现有的Eclipse中。
jhat不是本文的主题,这里做一个简单的介绍。
这是JVM自带的堆Dump分析工具,执行如下命令即可使用:
1 |
jhat a.map # a.map为Dump文件路径 |
jhat默认在7000端口暴露HTTP服务,你可以通过浏览器进行以下查询:
查询 | 说明 |
All classes including platform | 显示堆中所有类型(Java类)的列表,这些类被加载到JVM中 |
All Classes excluding platform | 类似上面,但是不包括平台(即JDK内部)的类 |
Show all members of the rootset |
显示所有GC Root的对外引用,按照以下几类GC Root,分段展示:
|
Show instance counts for all classes (including platform) | 显示所有类型的实例计数(包含平台类) |
Show instance counts for all classes (excluding platform) | 显示所有类型的实例计数(不包含平台类) |
Show heap histogram |
显示一个表格,统计各类型的实例数量、内存占用,按内存占用降序排列 内存占用显示为Shallow size,即对象自身的内存占用。除了数组、字符串等之外的对象Shallow size通常很小,通过引用(指针)关联的其它对象不计入Shallow size,仅仅计入固定尺寸的指针大小 |
下面是一段急剧消耗内存的示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
package com.dangdang.digital.test; import org.apache.commons.lang.math.RandomUtils; import java.util.concurrent.TimeUnit; public class BusyApplication { public static void main( String[] args ) { new MemoryPredator( Integer.parseInt( args[0] ), Integer.parseInt( args[1] ) ).start(); } } class MemoryPredator extends Thread { private final int consumeInterval; private int retainTime; public MemoryPredator( int consumeInterval, int retainTime ) { this.consumeInterval = consumeInterval; this.retainTime = retainTime; } @Override public void run() { for ( ; ; ) { consumeMemory( retainTime ); try { TimeUnit.MILLISECONDS.sleep( consumeInterval ); } catch ( InterruptedException e ) { } } } private void consumeMemory( final int retainTime ) { new MemoryPreyThread( retainTime ).start(); } } class MemoryPreyThread extends Thread { private final int retainTime; public MemoryPreyThread( int retainTime ) { this.retainTime = retainTime; } @Override public void run() { int n = ( RandomUtils.nextInt( 100 ) + 1 ); Object bigObj = createBigObject( n ); System.out.println( String.format( "%d MB consumed", n ) ); try { TimeUnit.MILLISECONDS.sleep( retainTime ); } catch ( InterruptedException e ) { } } private Object createBigObject( int n ) { Object big = new byte[n * 1024 * 1024]; return big; } } |
让上述程序执行一段时间(应用程序参数使用500 7000),然后使用下面的命令获取堆Dump:
1 2 |
# 只Dump可达对象,使用二进制格式,输出为bzapp.hprof jmap -dump:live,format=b,file=bzapp.hprof `jps | grep BusyApplication | cut -d' ' -f1` |
通过jhat开启服务后,打开浏览器,首先看到的是堆中包含的类的列表(不包含平台类):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
All Classes (excluding platform) Package com.dangdang.digital.test class com.dangdang.digital.test.BusyApplication [0xc0005048] class com.dangdang.digital.test.MemoryPredator [0xc0004ff0] class com.dangdang.digital.test.MemoryPreyThread [0xc0004f98] Package com.jarego.jayatana class com.jarego.jayatana.Agent [0xc0005200] Package org.apache.commons.lang.math class org.apache.commons.lang.math.JVMRandom [0xc0004e60] class org.apache.commons.lang.math.RandomUtils [0xc0004f00] |
点击界面下方的Show heap histogram连接,可以看到Shallow size最大的类型:
1 2 3 4 5 6 7 8 9 10 11 12 |
Class Instance Count Total Size class [B 1063 788662183 class [I 512 6789172 class [C 2552 349224 class java.lang.Class 566 54336 class [Ljava.lang.Object; 467 48496 class [S 770 44518 class java.lang.String 2494 39904 class java.net.URL 346 30448 class [Ljava.util.WeakHashMap$Entry; 156 22464 class java.util.HashMap$Entry 771 21588 |
可以看到byte[]类型,即[B,占用绝大部分内存,接近800MB。
点击class [B上的链接,进入byte[]类型的主页面“Class 0xc009a858”,后面的是类对象的内存地址,和Dump分析关系不大。留意“References to this object”这一段内容:
1 2 3 4 5 6 7 8 9 |
[B@0xbb0b6e18 (240 bytes) : ?? [B@0xc00bad20 (30 bytes) : ?? [B@0xbae503c0 (29 bytes) : ?? [B@0xbb091708 (115 bytes) : ?? [B@0xbb057350 (29 bytes) : ?? ... [B@0xc87c6368 (15728656 bytes) : ?? # 大对象 |
任何类型的示例,都会持有该类型的Class对象的引用,byte[]也不例外。所有存活的byte[]对象因此列于“References to this object”中。我们需要关心的通常(取决于应用场景)是大对象,简单的滚动页面,留意那些更长的行,就可以定位到大的byte[]了。
可以看到对象[B@0xc87c6368占据了15M的内存空间,点击其上的链接,进入[B@0xc87c6368对象的主页面。在页面的下方,可以看到Other Queries - Reference Chains from Rootset - Exclude weak refs,点击后,可以查看从GC Root到此对象的引用链页面:
1 2 3 4 5 6 7 |
References to [B@0xc87c6368 (15728656 bytes) Java Local References Java Local Reference (from com.dangdang.digital.test.MemoryPreyThread@0xc4ec6208) : # 这是GCRoot --> [B@0xc87c6368 (15728656 bytes) |
至此,我们可以大概判定,引发内存问题的代码,位于类MemoryPreyThread中。
点击菜单项 File ⇨ Open Heap Dump ...,看到如下界面:
上半部分显示当前正在分析的对象的基本信息,以上面的截图为例:
值 | 意义 |
0xcb4c69f8 |
对象的地址 |
MemoryPreyThread | 对象的类型 |
com.dangdang.digital.test | 对象的包名 |
class com.dangdang.digital.test.MemoryPreyThread @ 0xc0004f98 |
对象的类型,以及类对象的地址 |
java.lang.Thread | 对象的父类型 |
sun.misc.Launcher$AppClassLoader @ 0xc0000bd8 | 加载对象的类的ClassLoader |
112(shadlow size) | 此对象本身的大小 |
104,857,784(retained size) | 此对象本身的大小 + 此对象引用(包括间接)对象的大小 |
GC root: Native Stack,Thread | 提示此对象是否GC Root |
下半部分包括4个选项卡:
- Statics:此对象的类变量列表
- Attributes:此对象的实例变量列表
- Class Hierarchy:类层次图
- Value:对象的值
此面板使用饼图显示Retained Size最大的对象,并列出其导致的内存驻留的尺寸。这些对象往往就是问题所在。
饼图上方的按钮区、和饼图下方的Actions/Reports/Step By Step,功能是对应的。
其中Actions包括:
- Histogram:类似jhat的Histogram类似,但是支持统计Retained Size,支持排序
- Dominator Tree:显示那些导致内存驻留最严重的对象,按对象导致驻留的比例,降序排列。可以展开这些对象的引用链
- Top Consumers:以饼图、列表方式显示导致内存驻留最严重的对象、类、类加载器、包
- Duplicate Classes:显示不同类加载器重复加载的类
其中Reports包括:
- Leak Suspects:可以的内存泄漏报告,更加细粒度的、辅助人工分析的报告
使用jhat,我们就可以获取堆Dump中的全部信息了。 但是其UI非常简陋,缺乏排序、统计类功能,这些功能正是MAT能提供的。下面我们使用MAT来分析上一章的示例应用。
打开Dump后,在Overview面板的饼图上,可以立即看到所有大内存驻留都是由com.dangdang.digital.test.MemoryPreyThread类型的对象所引发。
单击任何一个饼图切片,点击菜单List Objects ⇨ With Outgoing References,可以看到对象引用的哪些对象的Shallow Size较大。这些对象就是内存驻留的根源:
很明显, MemoryPreyThread所引用的、byte[]类型的局部变量,其Shallow Size占据了全部内存份额。注意:如果上级对象的Outgoing引用特别多,则问题所在的子级对象可能看不到,这种情况下可以点击Retained Heap列排序或者手工展开。
对于像示例应用这样简单的代码,分析到这里就直接能定位到问题代码所在行号了。生产环境中的情况要复杂的多,单纯基于堆内存Dump进行静态分析不一定能奏效,需要结合线程栈Dump,甚至是剖析工具(JMC、JProfiler等)录制的内存分配历史。
概要情况如下图:
某个线程引用了接近6G的数据。从线程名字上来看,它是一个普通的工作线程,持有的6G内存应该是某种临时性对象。
分析此线程的Outgoing引用,排序Retained Heap列,将大内存条目显示出来:
可以看到,内存由一个局部变量(<Java Local>)导致。需要注意,局部变量的GC Root都是当前执行线程。出现问题的变量引用一个ArrayList对象,进一步展开可以猜测到此对象是数据库查询的结果集:
可以看到,此ArrayList的元素多达1000万。很可能是错误的SQL语句把大量行甚至整个表都读取过来,一下子把JVM内存撑爆了。
展开一个ByteArrayRow,查看行中的元素,进一步定位到所操作的表。配合JMC查看Hot Methods,定位到了存在问题的方法。
线上环境某服务,因为内存溢出、连续FGC被监控进程杀死,我们获得的堆Dump,内存占用总计1.7GB,Netty 3.x中的类NioClientSocketPipelineSink导致了绝大部分的内存驻留。
点击饼图中最大的切片NioClientSocketPipelineSink进行分析,打开菜单项List Objects ⇨ With Incoming References,展开:
可以看到NioClientSocketPipelineSink是Netty处理管线(Pipeline)的一部分。在很多框架中,管线常常和SRC、SINK等术语一起出现,SRC常常表示管线的输入、SINK则表示输出。
一个全局性的ChannelFactory、大量DefaultChannelPipeline通过sink属性引用了NioClientSocketPipelineSink。这些DefaultChannelPipeline应该是某种一次性对象,不熟悉代码的话只能猜测到这个程度,可以考虑通过JMC来录制对象分配的调用栈,来证实这种猜测。
那么,谁创建了这些DefaultChannelPipeline呢?从上面的截图中可以看到Dubbo的NettyClient间接引用了NioClientSocketPipelineSink,再随机抽取几个DefaultChannelPipeline展开:
可以看到来自Dubbo的NettyClient通过channel ⇨ pipeline ⇨ sink 引用了DefaultChannelPipeline。com.alibaba.dubbo上面引用链上唯一的非Netty包,我们有理由猜测Dubbo和内存溢出有关。
我们可以看一下相关对象的计数。对于希望统计数量的对象,在调用链上点选它,左下面板切换到Class Hierarchy,在类名上右击,选择List Object ⇨ With Incoming References:
由于类的每个对象都会引用类对象,因此展开右侧窗口的根节点,即可知晓对象的大概数量。在本例中,DefaultChannelPipeline、NioClientSocketChannel的数量大概是130万,NettyClient的数量是33。
MAT的支配树功能,可以帮助你追踪实际(Shallow)消耗内存的对象:
可以看到,Netty内部的某种链表结构(LinkedTransferQueue)的每个元素的RegisterTask对象占用了1KB的内存,但是链表长度非常大,因此这个链表消耗了大量的内存。
从名字LinkedTransferQueue可以猜测,这是Netty用来存储待处理的网络数据的。
从MAT分析的结果来看,NioClientSocketPipelineSink的内部类Boss的实例变量registerTaskQueue,占据了高达1.64GB的内存。我们来看一下这个类是做什么的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
// 代码中不相关的部分做了删减 class NioClientSocketPipelineSink extends AbstractChannelSink { final Executor bossExecutor; private final Boss[] bosses; private final NioWorker[] workers; private final AtomicInteger bossIndex = new AtomicInteger(); private final AtomicInteger workerIndex = new AtomicInteger(); // 这是外部能影响队列registerTaskQueue的唯一入口 public void eventSunk( ChannelPipeline pipeline, ChannelEvent e) throws Exception { if (e instanceof ChannelStateEvent) { ChannelState state = event.getState(); Object value = event.getValue(); switch (state) { case CONNECTED: if (value != null) { // 连接到客户端通道 connect(channel, future, (SocketAddress) value); } else { channel.worker.close(channel, future); } break; } else if (e instanceof MessageEvent) {} } private void connect( final NioClientSocketChannel channel, final ChannelFuture cf, SocketAddress remoteAddress) { try { if (channel.socket.connect(remoteAddress)) { channel.worker.register(channel, cf); } else { // 通道是非阻塞模式,因此会走到这个分支 nextBoss().register(channel); } } catch (Throwable t) {} } Boss nextBoss() { return bosses[Math.abs( bossIndex.getAndIncrement() % bosses.length)]; } private final class Boss implements Runnable { private final AtomicBoolean wakenUp = new AtomicBoolean(); private final Object startStopLock = new Object(); private final Queue<Runnable> registerTaskQueue = new LinkedTransferQueue<Runnable>(); // 这个方法直接导致registerTaskQueue增大 void register(NioClientSocketChannel channel) { Runnable registerTask = new RegisterTask(this, channel); synchronized (startStopLock) { // 如果尚未启动,则使用Executor启动Client Boss线程 // 每当有新的连接请求到达,都会尝试启动Client Boss线程 if (!started) { DeadLockProofWorker.start( bossExecutor, new ThreadRenamingRunnable( this, "New I/O client boss #" + id + '-' + subId)); } // 添加一个RegisterTask boolean offered = registerTaskQueue.offer(registerTask); } if (wakenUp.compareAndSet(false, true)) { // 唤醒选择器 selector.wakeup(); } } public void run() { Selector selector = this.selector; long lastConnectTimeoutCheckTimeNanos = System.nanoTime(); for (;;) { wakenUp.set(false); try { // 最多阻塞500ms int selectedKeyCount = selector.select(500); if (wakenUp.get()) { selector.wakeup(); } // 消费registerTaskQueue队列 processRegisterTaskQueue(); if (selector.keys().isEmpty()) { if (shutdown || bossExecutor instanceof ExecutorService && ((ExecutorService) bossExecutor).isShutdown()) { synchronized (startStopLock) { if (registerTaskQueue.isEmpty() && selector.keys().isEmpty()) { // 标记Client Boss线程为停止状态,下次再有连接请求到达,会再次启动 started = false; try { selector.close(); } catch (IOException e) { logger.warn( "Failed to close a selector.", e); } finally { this.selector = null; } // 退出条件 break; } else { shutdown = false; } } } else { shutdown = true; } } else { shutdown = false; } } catch (Throwable t) {} } } private void processRegisterTaskQueue() { for (;;) { final Runnable task = registerTaskQueue.poll(); if (task == null) { break; } task.run(); } } private static final class RegisterTask implements Runnable { RegisterTask(Boss boss, NioClientSocketChannel channel) { this.boss = boss; this.channel = channel; } public void run() { try { // 在指定的选择器上注册通道的事件 channel.socket.register( boss.selector, SelectionKey.OP_CONNECT, channel); } catch (ClosedChannelException e) { channel.worker.close(channel, succeededFuture(channel)); } int connectTimeout = channel.getConfig().getConnectTimeoutMillis(); if (connectTimeout > 0) { channel.connectDeadlineNanos = System.nanoTime() + connectTimeout * 1000000L; } } } } |
可以看到:
- 每当有新的连接请求时,都会调用Boss.register,从而唤醒Boss.run线程
- 一旦Boss.run线程线程启动,它就会消费掉所有堆积的registerTaskQueue元素
也就是说,正常情况下,registerTaskQueue队列不应该出现堆积的。由于发现问题时没有开启Dubbo或Netty的日志,准备等待下次问题重现后继续深入分析。
Leave a Reply