7.1 堆分析

第 5 章探讨的 GC 日志和工具对于理解 GC 对应用的影响很有帮助,但是要想获得更多信息,我们必须研究堆本身。本节探讨的工具可为我们理解应用中正在使用的对象提供帮助。

大多数情况下,这些工具仅对堆中的活跃对象有效——会在下一次 Full GC 周期内被回收的对象不会包含在工具的输出中。在某些情况下,这些工具会通过强制执行一次 Full GC 来实现其功能,所以在使用工具后,应用的行为会受到影响。而在其他一些情况下,这些工具会对堆进行走查,报告活跃对象数据,但是不会释放对象。不过不管是哪种情况,这些工具都需要一些时间和机器资源;因此一般不用于测量程序的执行。

7.1.1 堆直方图

减少内存使用是一个重要目标,但和大多数性能优化主题一样,它有助于我们把目标放在可用内存的最大化上。在本章后面,我会围绕一个 Calendar 对象的延迟初始化演示一个例子。这个例子会节省 640 字节的堆内存,但如果应用只初始化一个这样的对象,那么性能并不会有多大差别。我们必须通过分析来确定哪类对象消耗了大量内存。

最简单的方法是利用堆直方图。利用直方图,我们可以快速看到应用内的对象数目,同时不需要进行完整的堆转储(因为堆转储需要一段时间来分析,而且会消耗大量磁盘空间)。如果应用中的内存压力是由一些特定的对象类型引起的,利用堆直方图我们很快就能看出端倪。

堆直方图可以使用 jcmd 命令获得(这里使用了进程 ID 8898):

  1. % jcmd 8998 GC.class_histogram
  2. 8898:
  3. num #instances #bytes class name
  4. ---------------------------------------------
  5. 1: 789087 31563480 java.math.BigDecimal
  6. 2: 237997 22617192 [C
  7. 3: 137371 20696640 <constMethodKlass>
  8. 4: 137371 18695208 <methodKlass>
  9. 5: 13456 15654944 <constantPoolKlass>
  10. 6: 13456 10331560 <instanceKlassKlass>
  11. 7: 37059 9238848 [B
  12. 8: 10621 8363392 <constantPoolCacheKlass>

在堆直方图中,Klass 相关的对象往往接近顶端,它们是加载类得到的元数据对象。在接近顶端的地方,字符数组([C)和 String 对象也很常见,因为它们是最常创建的 Java 对象。字节数组([B)和 Object 数组([Ljava.lang.Object;)同样较为常见,因为类加载器会将其数据保存到这些结构中。(如果你不熟悉这里使用的语法,它来自 Java 原生接口——即 JNI——识别对象类型的方式;更多详情,请查阅 JNI 参考文档。)

在这个例子中(这里运行的是一个应用服务器中的示例股票应用的变种),其中包含的 BigDecimal 类就是我们要追查的东西:我们知道,该示例代码会产生大量短暂的 BigDecimal 对象,但是有这么多停留在堆中,这通常不是我们希望看到的情况。尽管该命令不会强制执行 Full GC,但是 GC.class_histogram 中的输出仅包含活跃对象。

运行下面的命令会得到类似输出:

  1. % jmap -histo process_id

jmap 的输出中包含会被回收的对象(死对象)。要在看到直方图之前强制执行一次 Full GC,可以转而运行下面的命令:

  1. % jmap -histo:live process_id

直方图非常小,所以在自动化系统中为每个测试收集一个会很有帮助。因为获得直方图也需要几秒钟,所以请不要在性能测量稳定的状态下获得。

7.1.2 堆转储

直方图擅长识别由分配了一两个特定类的过多实例所引发的问题,但是要进行深度分析,就需要堆转储了。有很多可以查看堆转储的工具,而且大多数都可以连接到运行的程序来生成转储文件。从命令行生成转储文件往往更容易,可以在下面两条命令中选择一个:

  1. % jcmd process_id GC.heap_dump /path/to/heap_dump.hprof

或:

  1. % jmap -dump:live,file=/path/to/heap_dump.hprof process_id

jmap 中包含 live 选项,这会在堆被转储之前强制执行一次 Full GC;jcmd 默认就会这么做。如果因为某些原因,你希望包含其他对象(即死对象),可以在 jcmd 命令的最后加上 -all

这两条命令都会在指定目录下创建一个命名为 heap_dump.hprof 的文件。生成之后,有很多工具可以打开该文件。以下是三个最常用的工具。

jhat

  这是最原始的堆分析工具。它会读取堆转储文件,并运行一个小型的 HTTP 服务器,该服务器允许你通过一系列网页链接查看堆转储信息。

jvisualvm

  jvisualvm 的监视(Monitor)选项卡可以从一个运行中的程序获得堆转储文件,也可以打开之前生成的堆转储文件。在这里,我们可以浏览堆,检查最大的保留对象,以及执行任意针对堆的查询。

mat

  开源的 EclipseLink 内存分析器工具(EclipseLink Memory Analyzer Tool,mat)可以加载一个或多个堆转储文件并执行分析。它可以生成报告,向我们建议可能存在问题的地方;也可以用于浏览堆,并对堆执行类 SQL 的查询。

对堆的第一遍分析通常涉及保留内存。一个对象的保留内存,是指回收该对象可以释放出的内存量。在图 7-1 中,String Trio 对象的保留内存包括该对象本身占用的内存,以及 Sally 和 David 两个对象占用的内存。Michael 对象占用的内存不在此列,如果 String Trio 被释放,因为 Michael 对象还有另一个指向它的引用,所以并不满足 GC 条件。

7.1 堆分析 - 图1

图 7-1:保留内存的对象图

浅对象大小、保留对象大小及深对象大小

对于内存分析,还有其他两个很有用的术语:浅(shallow)和深(deep)。一个对象的浅大小,指的是该对象本身的大小。如果该对象包含一个指向另一个对象的引用,4 字节或 8 字节的引用会计算在内,但是目标对象的大小不会包含进来。

深大小则包含那些对象的大小。深大小与保留大小的区别在于那些存在共享的对象。在图 7-1 中,Flute Duo 对象的深大小包括 Michael 对象消耗的内存空间,但是保留大小则不包括。

保留了大量堆空间的对象一般称作堆的支配者。如果堆分析工具表明有些对象支配着大部分堆,那事情就好办了:我们需要做的就是少创建一些这类对象,减少保留这类对象的时间,简化其对象图,或者将对象变小。可能说起来容易做起来难,但是至少分析起来很简单。

更普遍的情况是,因为程序可能会共享对象,所以有时必须做一些侦查性工作。就像图 7-1 中的 Michael 对象一样,那些共享的对象不会计算在其他任何对象的保留集内,因为单独释放一个对象并不会释放共享对象。此外,最大的保留大小往往是我们几乎无法控制的类加载器带来的。举一个极端的例子,我们以运行在 GlassFish 中的某个版本的股票小服务程序为例,有些条目以强引用形式缓存在用户会话中,同时以弱引用形式保存在一个全局的散列表中(所以缓存的条目存在多个指向它们的引用),图 7-2 显示了堆中位列前茅的一些保留对象。

{%}

图 7-2:在 Memory Analyzer 中查看保留内存

堆中大约有 1.4 GB 的对象(这个值没有出现在该选项卡上)。即便如此,单向引用的最大的一组对象只有 6 MB(而且不出所料,这是 GlassFish 的 OSGi 类加载框架的一部分)。看了直接保留内存最多的一些对象,这对解决内存问题并没有什么帮助。

在这个例子中,列表中有多个 StockPriceHistoryImpl 实例,而且每一个都保留了相当数量的内存。从这些对象消耗的内存量可以推断出,它们就是问题所在。不过在一般情况下,对象可能会以这样的方式被共享,所以从保留堆看不出任何很明显的东西。

直方图用在第二步很有用(参见图 7-3)。

{%}

图 7-3:在 Memory Analyzer 中查看直方图

直方图将同一类型的对象聚合到了一起,而且在这个例子中更容易看出来,这 1.4 GB 的内存被 700 万个 TreeMap$Entry 对象占据着,而这正是关键所在。即便不知道程序内部目前的运转情况,使用 Memory Analyzer 的工具来跟踪这些对象,看看它们保持了哪些东西,这也相当直观了。

堆分析工具为找到某个特定对象(或者这个例子中的一组对象)的 GC 根提供了一种方法——尽管直接跳到 GC 根未必有多大帮助。GC 根是一些系统对象,其中保存着一些(通过一个较长的由其他对象组成的链条)指向问题中对象的静态和全局引用。它们通常来自在系统或 bootstrap 类路径下加载的某个类中的静态变量,其中包括 Thread 类和所有的活跃线程;线程保留对象,或是通过其线程局部变量,或是通过目标 Runnable 对象(或者在存在 Thread 的子类的情况下,通过子类中包含的任何其他引用)来引用。

在某些情况下,知道某个目标对象的 GC 根是有用的,但是如果有多个指向该对象的引用,那么它会有多个 GC 根。这里的引用是一个倒过来的树结构。假设有两个对象指向一个特定的 TreeMap$Entry 对象。其中每个对象又可能被其他两个对象引用,而其他两个对象引用中的每一个,还有可能被另外三个对象引用,诸如此类。引用会随着追根溯源的过程爆炸性增长,这意味着任何给定的对象都可能有多个 GC 根。

相反,在对象图中,检查并找出对象被共享的最下面的一点可能更富成效。实现方法是检查对象及指向该对象的引用,然后跟踪这些引用,直到识别出重复的路径。在这个例子中,两个地方用到了保存在 Tree Map 中的 StockPriceHistoryImpl 对象:一个是 ConcurrentHashMap,它保存着会话数据;一个是 WeakHashMap,它保存着全局缓存。

在图 7-4 中,展开追溯就足以显示这两个类的一点数据了。要得出它是会话数据的结论,我们的方法是继续展开 ConcurrentHashMap 的路径,直到可以明显看出该路径是会话数据这一点。WeakHashMap 的路径也使用了类似逻辑。

{%}

图 7-4:在 Memory Analyzer 中追溯对象引用

这个例子中用到的对象类型使分析要比通常情况下更容易一些。如果这个应用中的主要数据被建模为 String 对象,而非 BigDecimal 对象,而且保存在 HashMap 对象而非 TreeMap 对象中,分析会更困难。因为堆转储中会有数十万其他字符串,成千上万的其他 HashMap 对象,找到通往我们关注的那些对象的路,着实需要一些耐心。一般来说,我们要从集合类对象(例如 HashMap)入手,而不是从记录项(例如 HashMap$Entry)入手,并且要寻找最大的集合。

7.1 堆分析 - 图5 快速小结

1. 了解哪些对象正在消耗内存,是了解要优化代码中哪些对象的第一步。

2. 对于识别由创建了太多某一特定类型对象所引发的内存问题,直方图这一方法快速且方便。

3. 堆转储分析是追踪内存使用的最强大的技术,不过要利用好,则需要一些耐心和努力。

7.1.3 内存溢出错误

在下列情况下,JVM 会抛出内存溢出错误(OutOfMemoryError):

  • JVM 没有原生内存可用;

  • 永久代(在 Java 7 和更早的版本中)或元空间(在 Java 8 中)内存不足;

  • Java 堆本身内存不足——对于给定的堆空间而言,应用中活跃对象太多;

  • JVM 执行 GC 耗时太多。

后面两种情况涉及 Java 堆本身,它们更为常见,但是不要看到 OutOfMemoryError 就自动下结论认为堆是问题所在。你必须看一下为什么会发生这种错误(原因会是异常输出的一部分)。

1. 原生内存不足

列表中的第一种情况——JVM 没有原生内存可用,其原因与堆根本无关。在 32 位的 JVM 中,一个进程的最大内存是 4 GB(在某些版本的 Windows 上是 3 GB,在某些比较老的 Linux 版本上是 3.5 GB)。指定一个非常大的堆大小,比如说 3.8 GB,使应用的大小很接近 4 GB 的限制,这很危险。即便在 64 位的 JVM 中,操作系统的虚拟内存也不是 JVM 请求多少就有多少。

第 8 章会更完整地介绍这个主题。你必须意识到,如果 OutOfMemoryError 消息中提到了原生内存的分配,那对堆进行调优解决不了问题:你需要看一下错误中提到的是何种原生内存问题。例如,下面的消息说明线程栈的原生内存耗尽了:

  1. Exception in thread "main" java.lang.OutOfMemoryError:
  2. unable to create new native thread

2. 永久代或元空间内存不足

这种内存错误与堆无关,其发生原因是永久代(在 Java 7 中)或元空间原生内存(在 Java 8 中)满了。根源可能有两种情况:第一种情况是应用使用的类太多,超出了永久代的默认容纳范围;解决方案是增加永久代的大小(参见 5.2.3 节)。(在 Java 8 中,如果设置了类的元空间的最大大小,也会出现同样的问题。)

第二种情况相对来说有点棘手:它涉及类加载器的内存泄漏。这种情况经常出现于 Java EE 应用服务器中。部署到应用服务器中的每个应用都运行在自己的类加载器中(这提供了隔离,使一个应用中的类不会和另一个应用中的类共享,也不会有干扰)。在开发中,每次修改了应用都必须重新部署,这时就会创建一个新的类加载器来加载新的类,而老的类加载器就可以退出作用域了。一旦类加载器退出了作用域,该类的元数据就可以回收了。

如果老的类加载器没有退出作用域,那么该类的元数据也就无法释放,最后永久代就会被填满,进而抛出OutOfMemoryError。在这种情况下,增加永久代的大小会有所帮助,但最终只是推迟了错误抛出的时机而已。

如果在某个应用服务器环境中出现这种情况,除了联系应用服务器厂商,让他们修复内存泄漏问题外,也别无他法。如果你正在编写的应用会创建并丢弃大量类加载器,一定要非常谨慎,确保类加载器本身能正确丢弃(尤其是,确保没有线程将其上下文加载器设置成一个临时的类加载器)。要调试这种情况,前面刚介绍的堆转储分析就非常有用:在直方图中,找到 ClassLoader 类的所有实例,然后跟踪它们的 GC 根,看一下哪些对象还保留了对它们的引用。

识别这种情况的关键仍然是 OutOfMemoryError 的输出全文。在 Java 8 中,如果元空间满了,错误消息将会是下面这样的:

  1. Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

在 Java 7 中类似,错误消息如下:

  1. Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

3. 堆内存不足

当确实是堆本身内存不足时,错误消息会是这样的:

  1. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

由缺乏堆空间触发的内存不足条件一般与永久代的情况类似。应用可能只是需要更多堆空间:活跃对象的数目在为其配置的堆空间中已经装不下了。也可能是应用存在内存泄漏:它持续分配新对象,却没有让其他对象退出作用域。对于第一种情况,增加堆大小可以解决问题;而对于第二种情况,增加堆大小只不过将错误的出现时机推迟了。

不管是哪种情况,要找出哪些对象消耗的内存最多,堆转储分析都是必要的;之后我们的注意力就可以集中到减少那些对象的数目(或大小)上。如果应用存在内存泄漏,可以间隔几分钟,获得连续的一些堆转储文件,然后加以比较。mat 内置了这一功能:如果打开了两个堆转储文件,mat 有一个选项用来计算两个堆中的直方图之间的差别。

自动堆转储

OutOfMemoryError 是不可预料的,我们很难确定应该何时获得堆转储。有几个 JVM 标志可以起到帮助。

-XX:+HeapDumpOnOutOfMemoryError

  该标志默认为 false,打开该标志,JVM 会在抛出 OutOfMemoryError 时创建堆转储。

-XX:HeapDumpPath=

  该标志指定了堆转储将被写入的位置;默认会在应用的当前工作目录下生成 java_pid<pid>.hprof 文件。这里的路径可以指定目录(这种情况下会使用默认的文件名),也可以指定要生成的实际文件的名字。

-XX:+HeapDumpAfterFullGC

  这会在运行一次 Full GC 后生成一个堆转储文件。

-XX:+HeapDumpBeforeFullGC

  这会在运行一次 Full GC 之前生成一个堆转储文件。

有的情况下(比如,因为执行了多次 Full GC)会生成多个堆转储文件,这时 JVM 会在堆转储文件的名字上附一个序号。

如果应用会因为堆空间的原因不可预测地抛出 OutOfMemoryError,而且你需要那一刻的堆转储来分析错误原因,请尝试打开这些标志。

图 7-5 演示了由集合类(这里是一个 HashMap)引发的 Java 内存泄漏这一经典案例。(集合类是导致内存泄漏的最常见原因:应用向集合中插入条目,但从不释放它们。)这是一个直方图对比视图:它显示了两个不同的堆转储中对象数目的差别。例如,与基线堆转储相比,目标堆转储中的 Integer 对象要多出 19 744 个。

要克服这种情况,最好的办法是修改应用的逻辑,主动将不再需要的条目从集合中删除。作为一种选择,可以使用弱引用或软引用的集合,当在应用中已经不存在对某些条目的任何引用时,该集合会自动丢弃它们,不过这样的集合是有代价的(本章后面会讨论)。

{%}

图 7-5:直方图对比

4. 达到GC的开销限制

JVM 抛出 OutOfMemoryError 的最后一种情况是 JVM 认为在执行 GC 上花费了太多时间:

  1. Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

当满足下列所有条件时就会抛出该错误。

(1) 花在 Full GC 上的时间超出了 -XX:GCTimeLimit=N 标志指定的值。其默认值是 98(也就是,如果 98% 的时间花在了 GC 上,则该条件满足)。

(2) 一次 Full GC 回收的内存量少于 -XX:GCHeapFreeLimit=N 标志指定的值。其默认值是 2,这意味着如果 Full GC 期间释放的内存不足堆的 2%,则该条件满足。

(3) 上面两个条件连续 5 次 Full GC 都成立(这个数值是无法调整的)。

(4) -XX:+UseGCOverhead-Limit 标志的值为 true(默认如此)。

请注意,所有四个条件必须都满足。一般来说,应用中连续执行了 5 次以上的 Full GC,不一定会抛出 OutOfMemoryError。其原因是,即便应用将 98% 的时间花费在执行 Full GC 上,但是每次 GC 期间释放的堆空间可能会超过 2%。这种情况下可以考虑增加 GCHeapFreeLimit 的值。

还请注意,如果前两个条件连续 4 次 Full GC 周期都成立,作为释放内存的最后一搏,JVM 中所有的软引用都会在第五次 Full GC 之前被释放。这往往会防止该错误,因为第五次 Full GC 很可能会释放超过 2% 的堆内存(假设该应用使用了软引用)。

7.1 堆分析 - 图7 快速小结

1. 有多种原因会导致抛出 OutOfMemoryError,因此不要假设堆空间就是问题所在。

2. 对于永久代和普通的堆,内存泄漏是出现 OutOfMemoryError 的最常见原因;堆分析工具可以帮助我们找到泄漏的根源。