Java性能调优10——堆内存最佳实践(上)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

前面详细探讨了如何调优垃圾收集器,以使其尽可能不影响程序。调优垃圾收集器非常重要,但是应用更好的编程实践往往可以获得更好的性能。本章就探讨一些在 Java 中使用堆内存的最佳实践方法。

这里有两个相互冲突的目标。第一个一般规则是,有节制地创建对象并尽快丢弃。使用更少的内存,这是提升垃圾收集器效率的最好方法。相反,频繁地重建某类对象会导致整体性能变得更糟(即便 GC 的性能有所改进)。然而,如果重用那些对象,程序的性能则有可能会得到改善。对象的重用方式有很多种,包括线程局部变量、特殊的对象引用以及对象池。重用对象意味着这些对象会长期存活,而且会影响垃圾收集器,但如果能合理利用,整体性能就会得到改进。

本章会探讨这两种方法以及在它们之间的权衡。不过,我们首先要看一下可以帮助理解堆内正在发生什么的工具。

堆分析

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

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

堆直方图

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

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

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

% jcmd 8998 GC.class_histogram
8898:

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

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

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

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

% jmap -histo process_id

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

% jmap -histo:live process_id

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

堆转储

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

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

或:

% 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 的查询。

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

保留内存的对象图

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

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

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

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

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

在 Memory Analyzer 中查看保留内存

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

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

直方图用在第二步很有用(如下图)。

在 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,它保存着全局缓存。

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

在 Memory Analyzer 中追溯对象引用

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

堆转存小结

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

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

  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 消息中提到了原生内存的分配,那对堆进行调优解决不了问题:你需要看一下错误中提到的是何种原生内存问题。例如,下面的消息说明线程栈的原生内存耗尽了:

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

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

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

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

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

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

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

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

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

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

3. 堆内存不足

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

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

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

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

自动堆转储

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

-XX:+HeapDumpOnOutOfMemoryError

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

-XX:HeapDumpPath=

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

-XX:+HeapDumpAfterFullGC

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

-XX:+HeapDumpBeforeFullGC

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

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

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

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

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

直方图对比

4. 达到GC的开销限制

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

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% 的堆内存(假设该应用使用了软引用)。

内存溢出小结

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

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

减少内存使用

在 Java 中,第一种更高效使用内存的方式是减少堆内存的使用。这句话不难理解:堆内存用的越少,堆被填满的几率就越低,需要的 GC 周期也越少。而且有倍乘效应:新生代回收的次数更少,对象的晋升年龄也就不会很频繁地增加,这意味着对象被提升到老年代的可能性也降低了。因此,Full GC 周期(或者是并发 GC 周期)也会减少。而且,如果这些 Full GC 周期能够清理更多内存,它们发生的频率也会降低。

本节将研究三种减少内存使用的方式:减少对象大小、对象的延迟初始化以及使用规范化对象。

减少对象大小

对象会占用一定数量的堆内存,所以要减少内存使用,最简单的方式就是让对象小一些。考虑运行程序的机器的内存限制,增加 10% 的堆有可能是无法做到的,但是堆中一半对象的大小减少 20%,能够实现同样的目标。

减少对象大小有两种方式:减少实例变量的个数(效果很明显),或者减少实例变量的大小(效果没那么明星)。下表列出了 Java 中不同类型实例变量的大小。

类型大小
byte1
char2
short2
int4
float4
long8
double8
reference在 32 位 JVM 以及堆小于 32 GB 的 64 位 JVM 上是 4;在启用大堆的 64 位 JVM 上是 8

这里的引用类型指的是指向任何类型 Java 对象(包括类或数组的实例)的引用。这个空间存储的只是参数本身。如果对象中包含指向其他对象的引用,其大小会因我们想考虑 Shallow Size、Deep Size 还是 Retained size(保留大小)而有所不同,不过其中都会包含一些隐藏的对象头字段。对于普通对象,对象头字段在 32 位 JVM 上占 8 字节,在 64 位 JVM 上占 16 字节(跟堆大小无关)。对于数组,对象头字段在 32 位 JVM 以及堆小于 32 GB 的 64 位 JVM 上占 16 字节,其他情况下是 64 字节。

例如,考虑这几个类定义:


public class A {
    private int i;
}

public class B {
    private int i;
    private Locale l = Locale.US;
}

public class C {
    private int i;
    private ConcurrentHashMap chm = new ConcurrentHashMap();
}

在堆小于 32 GB 的 64 位 Java 7 JVM 上,这几个类的实例实际大小如表所示。

Shallow sizeDeep sizeRetained size
A161616
B2421624
C24200200

在 B 类中,定义 Locale 应用将对象的大小增加了 8 字节,但至少在这个例子中,实际的 Locale 对象是与其他一些类共享的。如果该类实际上从来没用到这个 Locale 对象,那将这个实例包含进来,只会浪费引用所占的额外空间。当然,如果应用创建了大量 B 类的实例,还是会积少成多。

另一方面,定义并创建一个 ConcurrentHashMap,除了对象应用会消耗额外的字节,这个 HashMap 对象还会增加 200 字节。如果这个 HashMap 从来不用,C 的实例就非常浪费。

仅定义需要的实例变量,这是节省对象空间的一种方式。还有一种效果不那么明显的方案,就是使用更小的数据类型。如果某个类需要记录 8 个可能的状态之一,用一个字节就可以了,而不需要一个 int,这就可能会节省 3 字节。使用 float 代替 double,int 代替 long,诸如此类,都可以帮助节省内存,特别是在那些会频繁地实例化的类中。后面的章节将讨论,使用大小适当的集合类(或者使用简单的实例变量代替集合类)可以达到类似的节省空间的目的。

对象对齐与对象大小

上表中的类,都包含一个额外的整型字段,讨论中并没有引用到。为什么要放这么一个变量呢?

事实上,这个变量的目的是让讨论更容易理解:B 类比 A 类多 8 字节,正是我们所期望的(这样更明确)。

这掩盖了一个重要细节:为使对象大小是 8 字节的整数倍(对齐),总是会有填充操作。如果在 A 类中没有定义 i,A 的实例仍然会消耗 16 字节,其中 4 字节只是用于填充,使得对象大小是 8 的整数倍,而不是用于保存 i。如果没有定义 i,B 类的实例将仅消耗 16 字节,和 A 一样,即便 B 中还有额外的对象引用。B 中仅包含一个额外的 4 字节引用,为什么其实例会比 A 的实例多 8 字节呢,也是填充的问题。

JVM 也会填充字节数不规则的对象,这样不管底层架构最适合什么样的地址边界,对象的数组都能优雅地适应。

因此,去掉某个实例字段或者减少某个字段的大小,未必能带来好处,不过我们没有理由不这么做。

去掉对象中的实例字段,有助于减少对象的大小,不过还有一个灰色地带:有些字段会保存基于一些数据计算而来的结果,这该如何处理呢?这就是计算机科学中典型的时间空间权衡问题:是消耗内存(空间)保存这个值更好,还是在需要时花时间(CPU 周期)计算这个值更好?不过在 Java 中,权衡还会考虑 CPU 时间,因为额外的内存占用会引发 GC 消耗更多 CPU 周期。

比如,String 的哈希码值(hashcode)就是对一个涉及该字符串中每个字符的式子求和计算而来的;计算会消耗一点时间。因此,String 类会把这个值存在一个实例变量中,这样哈希码值只需要计算一次:最后,与不存储这个值而节省的内存空间相比,重用几乎总能获得更好的性能。另一方面,大部分类的 toString() 方法不会把对象的字符串表示保存在一个实例变量中,因为实例变量及其引用的字符串都会消耗内存。相反,与保存字符串引用所需的内存相比,计算一个新的字符串所花的时间通常不是很多,性能更好。(还有一个因素,String 对象的哈希码值用的较为频繁,而对象的 toString() 表示使用却很少。)

当然,这种情况必定是因人而异的。就时间 / 空间的连续体而言,究竟是使用内存来存储值,还是重新计算值,都是取决于许多具体因素的。如果目标是减少 GC,则更倾向于采用重新计算。

减小对象大小小结

  1. 减小对象大小往往可以改进 GC 效率。

  2. 对象大小未必总能很明显地看出来:对象会被填充到 8 字节的边界,对象引用的大小在 32 位和 64 位 JVM 上也有所不同。

  3. 对象内部即使为 null 的实例变量也会占用空间。

延迟初始化

正如前面几节所介绍的,很多时候,决定一个特定的实例变量是否需要并不是非黑即白的问题。某个特定的类可能只有 10% 时间需要一个 Calendar 对象,但是 Calendar 对象创建成本很高,所以保留这个对象备用,而不是需要的时候再重新创建,绝对是有意义的。这种情况下,延迟初始化可以带来帮助。

到目前为止,我们所作讨论的前提是假定实例变量很早就会初始化。需要使用一个 Calendar 对象(不需要线程安全)的类看上去可能是这样的:

public class CalDateInitialization {
    private Calendar calendar = Calendar.getInstance();
    private DateFormat df = DateFormat.getDateInstance();
    private void report(Writer w) {
        w.write("On " + df.format(calendar.getTime()) +": " + this);
    }
}

要延迟初始化其字段,在计算性能上会有一点小小的损失,代码每次执行时都必须测试变量的状态:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private void report(Writer w) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
            df = DateFormat.getDateInstance();
        }
        w.write("On " + df.format(calendar.getTime()) +": " + this);
    }
}

如果问题中的这个操作使用不太频繁,那延迟初始化最适合:如果操作很常用,实际上没有节省内存(总是会分配这些实例),而常用操作又有轻微的性能损失。

延迟初始化运行时性能

检查要进行延迟初始化的变量是不是已经被初始化了,未必总会有性能损失。考虑来自 JDK 的 ArrayList 类的一个例子。这个类维护着一个所存储元素的数组,在 JDK 7u40 之前,这个类的伪代码看上去就是下面这样:

public class ArrayList {
    private Object[] elementData = new Object[16];
    int index = 0;
    public void add(Object o) {
        ensureCapacity();
        elementData[index++] = o;
    }
    private void ensureCapacity() {
        if (index == elementData.length) {
            ……重新分配数组并把老数据复制进来……
        }
    }
}

在 JDK 7u40 中, 这个类有所修改,elementData 数组被延迟初始化了。但是因为 ensureCapacity() 方法已经需要检查数组大小,这个类的常用方法就不用承受性能损失了:检查是否初始化的代码和检查数组大小是否需要增加的代码是一样的。新的代码使用了一个静态的、共享的 0 长度数组,因此性能也是一样的:

public class ArrayList {
    private static final Object[] EMPTY_ELEMENTDATA = {} ;
    private Object[] elementData = EMPTY_ELEMENTDATA;
}

这意味着 ensureCapacity() 方法基本不需要修改,因为 index 和 elementData.length 都是从 0 开始的。

当所涉及的代码需要保证线程安全时,延迟初始化会更为复杂。第一步,最简单的方式是添加传统的同步机制:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private synchronized void report(Writer w) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
            df = DateFormat.getDateInstance();
        }
        w.write("On " + df.format(calendar.getTime()) +": " + this);
    }
}

在解决方案中引入同步,会使得同步也有可能成为性能瓶颈。不过这种情况很罕见。对于问题中的对象而言,只有当初始化这些字段的几率很低时,延迟初始化才有性能方面的好处。因为,如果一般情况下都会初始化这些字段,那实际上也不会节省内存。因此对于延迟初始化的字段,当不常用的代码路径突然被大量线程同时使用时,同步就会成为瓶颈。这种情况是可以想象的,不过好在并不多见。

只有延迟初始化的变量本身是线程安全的,才有可能解决同步瓶颈。DateFormat 对象不是线程安全的,所以在现在的这个例子中,锁中是否包含 Calendar 对象并不重要:如果延迟初始化的对象突然被频频使用,那无论如何,围绕 DateFormat 对象所需的同步都会成为问题。线程安全的代码应该是这样的:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private void report(Writer w) {
        unsychronizedCalendarInit();
        synchronized(df) {
            w.write("On " + df.format(calendar.getTime()) +": " + this);
        }
    }
}

涉及非线程安全的实例变量的延迟初始化,总会围绕这个变量做同步(例如,像前面所示的那样使用方法的同步版本)。

考虑一个有点不一样的例子,其中有一个比较大的 ConcurrentHashMap 对象,就采用了延迟初始化:

public class CHMInitialization {
    private ConcurrentHashMap chm;

    public void doOperation() {
        synchronized(this) {
            if (chm == null) {
                chm = new ConcurrentHashMap();
                …… 填充这个map的代码 ……
            }
        }
        ……使用chm……
    }
}

因为多个线程可以安全地访问 ConcurrentHashMap,所以这个例子中的多余的同步,就是一种不太常见的情况,因为即便是恰当地使用延迟初始化,也引入了同步瓶颈。(不过这种瓶颈应该极为少见;如果这个 HashMap 访问非常频繁,那就应该考虑延迟初始化到底有什么好处了。)该瓶颈可以使用双重检查锁这种惯用法来解决:

public class CHMInitialization {
    private volatile ConcurrentHashMap instanceChm;

    public void doOperation() {
        ConcurrentHashMap chm = instanceChm;
	 if (chm == null) {
            synchronized(this) {
                chm = instanceChm;
                if (chm == null) {
                    chm = new ConcurrentHashMap();
                    …… 填充这个map的代码
                    instanceChm = chm;
                }
            }
            ……使用chm……
        }
    }
}

这里有些比较重要的多线程相关的问题:实例变量必须用 volatile 来声明,而且将这个实例变量赋值给一个局部变量,性能会有些许改进。后面章节会介绍更多细节;在多线程代码的延迟初始化确实有意义的特殊场合,应该遵循这种设计模式。

尽早清理

从延迟初始化变量可以推出另一种行为,即通过将变量的值设置为 null,实现尽早清理,从而使问题中的对象可以更快地被垃圾收集器回收。不过这只是理论上听着不错,真正能发挥作用的场合很有限。

可以选择延迟初始化的变量,可能看上去也可以选择尽早清理:在上面的例子中,一完成 report() 方法,Calendar 和 DateFormat 对象就可以设置为 null 了。然而,如果后面再调用到这个方法(或者同一个类中的其他地方)时,并没有用到该变量,那最初就没有理由将其设计为实例变量:在方法中创建一个局部变量就可以了,而且当方法完成时,局部变量就会离开作用域,然后垃圾收集器就可以释放它了。

不需要尽早清理变量,这个规则有个很常见的例外情况,即对于类似 Java 集合类框架中的那些类:它们会在较长的时间内保存一些指向数据的引用,当问题中的数据不再需要时会通知它们。考虑 JDK 中 ArrayList 类的 remove() 方法的实现(部分代码有所简化):

public E remove(int index) {
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1,
                         elementData, index, numMoved);
    elementData[--size] = null; // 清理,让GC完成其工作
    return oldValue;
}

JDK 源代码中有一行关于 GC 的注释:像这样将某个变量的值设置为 null,这种操作并不常见,需要解释一下。在这种情况下,我们可以看看当数组的最后一个元素被移除时,会发生什么。仍然存在于数组中的条目数,也就是实例变量 size,会被减 1。比如说 size 从 5 减少到 4。现在不管 elementData[4] 中存的是什么,都不能访问了:它超出了数组的有效范围。

在这种情况下,elementData[4] 是一个过时的引用。elementData 数组可能仍会存活很长时间,因此对于不需要再引用的元素,应该主动将其设置为 null。

过时引用的概念是这里的关键:如果一个长期存活的类会缓存以及丢弃对象引用,那一定要仔细处理,以避免过时引用。否则,显式地将一个对象引用设置为 null 在性能方面基本没什么好处。

延迟初始化小结

  1. 只有当常用的代码路径不会初始化某个变量时,才去考虑延迟初始化该变量。

  2. 一般不会在线程安全的代码上引入延迟初始化,否则会加重现有的同步成本。

  3. 对于使用了线程安全对象的代码,如果要采用延迟初始化,应该使用双重检查锁。

不可变对象和标准化对象

在 Java 中,很多对象类型都是不可变的。这包括那些有相应的基本类型的类,如 Integer、Double 和 Boolean 等,以及其他一些基于数值的类型,如 BigDecimal。当然,最常见的 Java 对象当属不可变的 String。从程序设计的角度看,用定制类来表示不可变的对象,往往是个不错的主意。

如果这些对象会快速创建然后丢弃,它们会对 Young GC 多少有些影响;不过如我们在前面章节所介绍,影响有限。但是和任何对象一样,如果有大量的不可变对象被提升到老年代,性能就会出现问题。

因此,没有理由不设计和使用不可变对象,即使对象无法改变、必须重新创建等特性使其看上去有点事与愿违。不过处理这些对象时往往可以进行一项优化,那就是避免创建同一对象的不同冗余副本。

最好的例子就是 Boolean 类。在任何 Java 应用中,其实只需要两个 Boolean 示例,一个表示 true,一个表示 false。遗憾的是,Boolean 设计得很差。因为它有一个 public 的构造器,应用喜欢创建多少这类对象就能创建多少,即时它们和两个标准化的 Boolean 对象其中之一是完全相同的。更好的设计方案应该是,让 Boolean 类只有一个 private 的构造器,通过 static 方法根据其参数返回 Boolean.TRUE 或 Boolean.FALSE。如果自己的不可变类有这样的一个模型可以遵循,就可以防止它们占用应用中额外的堆空间。(很明显,绝对不应该创建 Boolean 对象;必要的时候应该使用 Boolean.TRUE 或 Boolean.FALSE。)

像这类不可变对象的单一化表示,就被称为对象的标准化(canonical)版本。

创建标准化对象

即便某个特定类的全体对象几乎是无限制的,使用标准化的值通常也可以节省内存。JDK 为大部分常见的不可变对象提供了实现此功能的方法:比如字符串可以调用 intern() 方法找到该字符串的一个标准化版本。下一节将介绍字符串保留(intern)的更多细节,现在我们先看一下对于定制的类如何实现同样功能。

要标准化某个对象,创建一个 Map 来保存该对象的标准化版本。为防止内存泄漏,务必保证使用弱引用处理 Map 中的对象。这样一个类的骨架看上去会是这样的:

public class ImmutableObject {
    WeakHashMap<ImmutableObject, ImmutableObject> map = new WeakHashMap();

    public ImmutableObject canonicalVersion(ImmutableObject io) {
        synchronized(map) {
            ImmutableObject canonicalVersion = map.get(io);
            if (canonicalVersion == null) {
                map.put(io, io);
                canonicalVersion = io;
            }
            return canonicalVersion;
        }
    }
}

在多线程环境中,此处的同步可能会成为瓶颈。如果想坚持使用 JDK 的类,并没有简单的解决方案,因为 JDK 没有提供支持弱引用的并发 Hashmap。不过,目前有提议向 JDK 中添加一个 CustomConcurrentHashMap 类(最初是 JSR 166 的一部分),另外还可以找一下这种类的各种第三方实现。

不可变对象和标准化对象小结

  1. 不可变对象为标准化(canonicalization)这种特殊的生命周期管理提供了可能性。

  2. 通过标准化去掉不可变对象的冗余副本,可以极大减少应用消耗的堆内存。

字符串的保留

字符串无疑是最常见的 Java 对象;应用的堆中几乎到处都是字符串。

如果有大量的字符串是相同的,那很大一部分空间都是浪费的。因为字符串是不可变的,所以对于同样的字符序列,没有理由存在多个字符串表示。不过就编程而言,很难确定是不是正在创建重复的字符串。

要知道是不是有大量重复的字符串,需要对堆进行一些分析。方式之一就是在 Eclipse Memory Analyzer 中加载堆转储文件,计算所有 String 对象的保留大小(Retained Size),并按照其最大保留大小将这些对象排序。图 7-6 就是一个这样的堆转储信息。看上去前 3 个字符串是相同的,保留它们能够节省 650 KB 内存。(可以在验证工具中检查这些字符串。)第 4 个和第 5 个,第 7 个到第 9 个,也是这样,当然也有差别,那就是列表中越小的对象,通过保留字符串能节省的内存越少。

这种情况下,保留特定的字符串有优势;仅保留一个标准化版本,可以节省掉副本对象所消耗的空间。这可以用上一节例子中标准化例子的一个变种来实现,不过 String 类提供了自己的标准化方法:intern() 方法。

和大部分优化一样,保留字符串不能随意进行;但是如果有大量重复的字符串,占据了很大一部分堆,这时就很有效果了。关于保留太多字符串,应该注意一点:保留字符串的表是保存在原生内存中的,它是一个大小固定的 Hashtable。在 Java 7u40 之前的版本中,这个表默认有 1009 个桶;平均而言,在因为链接而出现冲突之前,预计可以保存 500 个字符串。在 64 位版本的 Java 7u40 及更新的版本中,默认大小为 60 013。

String 对象保留的内存

大小固定的 Hashtable

如果尚不熟悉 Hashtable 和 Hashmap,你可能想知道到底什么是大小固定的 Hashtable(特别是,这些类的 Java 实现大小都是不固定的)。

从概念上讲,一个 Hashtable 包含一个数组,它会保存一些条目(数组中的每个元素叫作一个桶)。当要将一个对象保存到 Hashtable 中时,可以用该对象的哈希值对桶的数目取余,以此确定对象在数组中的存储位置。这种情况下,两个哈希值不同的对象很有可能被映射到同一个桶中,每个桶实际就是一个链表,其中按顺序存储了映射到该桶的条目。当两个对象映射到一个桶时,这就叫“冲突”。

随着越来越多的对象被插入到这个表中,冲突也会越来越多;进而会有更多的条目被插入到每个链表中。要找到一个条目,就变成了在一个链表中搜索。这可能会非常慢,特别是随着链表越来越长,速度会更慢。

解决方案是设置 Hashtable 的大小,以便它有更多的桶(当然,结果就是冲突会减少)。很多实现都是动态处理的;实际上,Java 的 Hashtable 和 HashMap 也是这么工作的。

其他实现,像这里讨论的 JVM 内部的这个,就不能重新设置 Hashtable 的大小;其数组的大小是在创建时就固定的。

其他实现,像这里讨论的 JVM 内部的这个,就不能重新设置 Hashtable 的大小;其数组的大小是在创建时就固定的。

从 Java 7 中开始,这个表的大小可以在 JVM 启动时使用 -XX:StringTableSize=N(如前面所介绍的,默认值为 1009 或 60 013)。如果某个应用会保留大量字符串,就应该增加这个值。如果这个值是个素数,字符串保留表的效率最高。

intern() 方法的性能是由表大小的调优程度所决定的。作为一个例子,下表列出了在不 同场景下创建和保留 1 千万个随机创建的字符串的总时间:

调优用时
字符串表大小为 10092.3 小时
字符串表大小为 1 百万30.4 秒
字符串表大小为 1 千万25.2 秒
自定义方式26.4 秒

注意,如果字符串保留表的大小设置不当,性能损失会相当严重。一旦根据预期数据设置了该表的大小,性能会极大改善。

最后一个测试用例没有使用 intern() 方法,而是使用了前面介绍的示例 canonicalVersion() 方法,它是用 CustomConcurrentHashMap 类实现的(出自 JSR 166 的一个早期版本),而且用的是非强引用的键和值。与精心优化过的字符串保留表相比,这对性能没什么帮助。不过这种方案也有一个优势,即开发者根本不需要调节其大小。CustomConcurrentHashMap 的初始大小是 1009,它会根据需要动态调整大小。与最大程度优化过的字符串表大小相比,还是有比较小的性能损失,但是运行要容易得多。(不过在那种情况下,代码必须调用定制类的 canonicalVersion() 方法,而不是简单地替换掉 intern() 方法。)

如果想看看字符串表的执行过程,可以使用 -XX:+PrintStringTableStatistics 参数(这个标志要求 JDK 7u6 或更新版本,默认为 false)运行应用。当 JVM 退出时,它会打印一个这样的列表:

StringTable statistics:
Number of buckets       :    1009
Average bucket size     :    3008
Variance of bucket size :    2870
Std. dev. of bucket size:      54
Maximum bucket size     :    3186

这个命令行也会显示符号表的信息,但是这里我们感兴趣的是字符串表。(符号表用于保存一些类信息。JDK 8 有一个调整该表大小的实验性选项,但是一般不会调整它。)在这个例子中,有 3 035 072 个保留的字符串(因为有 1009 个桶,每个桶平均有 3008 个字符串)。理想情况下,桶的平均大小应该是 0 或 1。这个大小实际上不会为 0,可能会小于 0.5,但是因为计算时用的是整型运算,所以报告中会向下取整。如果平均值大于 1,则需要增大字符串表的大小。

某个应用中已经分配的保留字符串个数(及其总大小),可以使用如下的 jmap 命令获得(这也需要 JDK 7u6 或更新版本):

% jmap -heap process_id
…… 其他输入 ……
36361 interned Strings occupying 3247040 bytes.

如果将字符串表设得特别大,其损失是非常小的:每个桶只需要 4 字节或 8 字节(取决于使用的是 32 位还是 64 位 JVM),所以比最优的情况多几千,只是一次性消耗一些原生内存(不是堆内存)。

字符串的 Intern 和 Equals

在谈到保留字符串这个主题时,因为保留的字符串可以通过 == 操作符比较,那使用 intern() 方法让程序跑得快一些怎么样呢?这种想法很常见,但是大部分情况下并非如此。String.equals() 方法是相当快的。首先要知道,长度不相等的字符串肯定不会相同;即使长度相同,还要扫描字符串,比较所有的字符(至少要找到不匹配的地方)。不可否认,通过 == 操作比较字符串确实会快一些,但是保留字符串的成本也要考虑进去。这需要(还有其他方面)计算字符串的哈希编码,这意味着要扫描整个字符串,并在每个字符上执行一个操作(就像 equals() 所做的那样)。

只有一种情况下会有好处:应用会在一组长度相同的字符串上执行大量的重复比较。如果字符串都已经保留了,那用 == 作比较更快;调用 intern() 的代价是只需要计算一次。但是一般而言,性能差不多。

字符串的保留小结

  1. 如果应用中有大量字符串是一样的,那通过保留实现字符串重用收效很大。

  2. 要保留很多字符串的应用可能需要调整字符串保留表的大小(除非是运行在 Java 7u40 及更新的 64 位服务器 JVM 上)。

更新时间:2020-03-12 13:49:33

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优10堆内存最佳实践上
最后更新:2020-03-12 13:49:33

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×