Java性能调优11——堆内存最佳实践(下)

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

关于内存管理,要大篇幅讨论的第二个主题是对象生命周期管理。在很大程度上,Java 会尽量减轻开发者投入到对象生命周期管理上的精力:开发者在需要的时候创建对象,当不再需要这些对象时,它们会走出作用域,并由垃圾收集器释放。

有些情况下,正常的生命周期并不是最优的。有些对象创建的成本很高,而管理这些对象的生命周期可以改进应用的效率,即便以让垃圾收集器多做些工作为代价。本节将探索正常的生命周期何时应该有所改变,以及如何改变,手段可以是重用对象,或者是维护指向这些对象的特殊引用。

对象重用

对象重用通常有两种实现方式:对象池和线程局部变量。一说这个,开发 GC 的工程师就要抱怨了,因为这两种技术都会影响 GC 的效率。特别是对象池,GC 圈对其是很抵触的,而且由于其他很多原因,开发圈也不太喜欢这种技术。

从某种程度上说,对象池技术之所以不受待见,原因似乎显而易见:被重用的对象会在堆中停留很长时间。如果有大量对象存在于堆中,那用来创建新对象的空间就少了,因为 GC 操作会更为频繁。不过这只是冰山一角。

前面章节曾经介绍过,对象创建时是分配在 Eden 区的。在最终提升到老年代之前,会在 Survivor 区反复经历一些 Young GC 周期。每当处理到最近创建或者新创建的池化对象时,GC 算法必须执行一些工作,去复制这个对象,并调整指向它的引用,直到该对象最终进入老年代。

尽管看上去故事可能就此结束了,但是一旦对象被提升到老年代,可能引发的性能问题甚至会更多。执行一次 Full GC 所花的时间与老年代中仍然存活的对象数量成正比。存活对象的数量甚至比堆的大小更重要;处理一个 3 GB 大小但存活对象很少的老年代,与处理一个 1 GB 大小但存活对象占 75% 的老年代相比,速度要快一些。

GC 效率

那么堆中存活对象的数量对 GC 时间有多少影响呢?答案不一而足。

如下是在我的标准 4 核 Linux 系统上所做的一个测试的 GC 日志输出,测试中使用了 4 GB 的堆(其中 1 GB 固定为新生代):

[Full GC [PSYoungGen: 786432K->786431K(917504K)]
        [ParOldGen: 3145727K->3145727K(3145728K)]
        3932159K->3932159K(4063232K)
        [PSPermGen: 2349K->2349K(21248K)], 0.5432730 secs]
        [Times: user=1.72 sys=0.01, real=0.54 secs]
...
[Full GC [PSYoungGen: 786432K->0K(917504K)]
        [ParOldGen: 3145727K->210K(3145728K)]
        3932159K->210K(4063232K)
        [PSPermGen: 2349K->2349K(21248K)], 0.0687770 secs]
        [Times: user=0.08 sys=0.00, real=0.07 secs]
...
[Full GC [PSYoungGen: 349567K->349567K(699072K)]
        [ParOldGen: 3145727K->3145727K(3145728K)]
        3495295K->3495295K(3844800K)
        [PSPermGen: 2349K->2349K(21248K)], 0.7228880 secs]
        [Times: user=2.41 sys=0.01, real=0.73 secs]

注意中间的输出:应用清理了指向老年代中的大部分引用,所以在 GC 之后,老年代中的数据只有 210 KB 了。该操作仅用了 70 毫秒。在其他情况下,堆中的大部分数据仍然存活;Full GC 操作尽管几乎没有移除什么数据,但是花费的时间分别是 540 毫秒和 730 毫秒。这还算是幸运的,测试中有 4 个 GC 线程。在单核系统上,这个例子中耗时较短的 GC 需要 80 毫秒,耗时较长的 GC 则需要 2410 毫秒(超过 30 倍的差距)。

使用某个并发收集器避免 Full GC 并不能使情况有所好转,这是因为,并发收集器的标记阶段所花的时间也依赖于仍存活数据的数量。特别是对 CMS 而言,池中的对象很可能会在不同的时间被提升,这会增大因碎片而导致的并发故障的机会。总的来说,对象在堆中存留的时间越长,GC 的效率越差。

因此,对象重用并不好。现在我们可以讨论如何以及何时重用对象了。

JDK 提供了一些常见的对象池:线程池和软引用。软引用(本节后面会讨论)本质上是一大池可重用对象。同时 Java EE 依赖对象池来连接数据库和其他资源,而且 EJB(Enterprise Java Beans)的整个生命周期都是围绕对象池的概念构建的。

线程局部变量的情况类似;JDK 中到处是使用线程局部变量的类,以避免重新分配特定种类的对象。

显然,甚至 Java 专家都理解在某些情况下需要对象重用。

之所以要重用对象,原因是很多对象初始化的成本很高,与增加的 GC 时间这一点相权衡,重用更为高效。对于像 JDBC 连接池这样的东西,肯定如此:创建网络连接,以及可能还要进行的登录和建立数据库会话,成本非常高。这种情况下,对象池有很大的性能优势。线程也可以池化,以节省创建线程的时间;随机数生成器是作为线程局部变量提供的,以节省生成随机数的时间;诸如此类。

这些例子有一个共同的特性,即初始化对象需要的时间较长。在 Java 中,对象分配非常快,成本也不高(反对对象重用的观点往往就是关注的这一点)。对象初始化的性能取决于对象本身。应该只考虑重用初始化成本非常高的对象,而且是只有当初始化这些对象的代价在程序中是主导性操作之一时。

这些例子还有一个共性,那就是所共享对象的数目往往很小,以便最小化对 GC 的影响:即它们的数量较小,还不足以降低 GC 周期。池中有少量对象,对 GC 效率不会影响太大;如果堆中满是池化对象,就会严重影响 GC 了。

下面是 JDK 和 Java EE 中重用对象的一些例子,以及重用的原因:

线程池:
  线程初始化的成本很高。


JDBC 池:
  数据库连接初始化的成本很高。


EJB 池:
  EJB 初始化的成本很高(参见后面章节)。


大数组
  Java 要求,一个数组在分配的时候,其中的每个元素都必须初始化为某个默认值(null、0 或者 false,根据具体情况而定)。对于很大的数组,这是非常耗时的。


原生 NIO 缓冲区
  不管缓冲区多大,分配一个直接的 java.nio.Buffer(即调用 allocateDirect() 方法返回的缓冲区),这个操作都非常昂贵。最好是创建一个很大的缓冲区,然后通过按需切割的方式来管理,以便将其重用于以后的操作。


安全相关类
  MessageDigest、Signature 以及其他安全算法的实例,初始化的成本都很高。基于 Apache 的 XML 代码就是使用线程局部变量保存这些实例的。


字符串编解码器对象
  JDK 中的很多类都会创建和重用这些对象。在大多数情况下,这些还是软引用,下一节将介绍。


StringBuilder 协助者
  BigDecimal 类在计算中间结果时会重用一个 StringBuilder 对象。


随机数生成器
  Random 类和(特别是)SecureRandom 类,生成它们的实例的代价是很高的。


从 DNS 查询到的名字
  网络查询代价很高。


ZIP 编解码器
  有一种有趣的变化,初始化的开销不是特别高,但是释放的成本很高,因为这些对象要依赖对象终结操作(finalization)来确保释放掉所用的原生内存。更多细节,参见 7.3.2 节的“终结器和最终引用”。

此处讨论的对象池和线程局部变量两种方式,在性能上有些差别。下面详细看一下。

1. 对象池

对象池不受人喜欢,原因有多个方面,只有部分原因和性能有关。线程池的大小可能很难正确地设置,它们将对象管理的负担又抛给程序员了:程序员不能简单地将对象丢出作用域,而必须记得将其返还到对象池中。

不过这里的焦点是对象池的性能,它受如下几个因素的影响。

1. GC 影响
  如我们所见,保存大量对象会降低 GC 的效率(有时非常显著)。

2. 同步
  对象池必然是同步的,如果对象要频繁地移除和替换,对象池上可能会存在大量竞争。其结果是,访问对象池可能比初始化新对象还慢。

3. 限流(Throttling)
  对象池对性能也有正面的影响:对于对稀缺资源的访问,线程池可以起到限流作用。如果想增加的负载超出系统的处理能力,性能将下降。这是线程池之所以很重要的一个原因。如果有太多线程同时运行,CPU 将不堪重负,而且性能会下降。

  这一原则也适用于远程系统的访问,而且在 JDBC 连接中会经常见到。如果 JDBC 连接数超出数据库的处理能力,数据库的性能就会下降。在这些情况下,通过确定池的上限来限制资源数(如 JDBC 连接数)更好,即便这意味着应用中的线程必须等待一个空闲资源。

2. 线程局部变量

在通过将对象保存为线程局部变量这种技术实现对象重用时,有不同的性能权衡,如下所列。

1. 生命周期管理

  线程局部变量要比在池中管理对象更容易,成本更低。这两种技术都邀请开发者去获取初始对象:或者是从对象池中检出,或者是在线程局部对象上调用 get() 方法。但是对象池还要求开发者在使用完毕后归还对象(否则其他人就不能使用了);线程局部对象在线程内总是可用的,不需要显式地归还。

2. 基数性(Cardinality)

  线程局部变量通常会伴生线程数与保存的可重用对象数之间的一一对应关系。不过并非严格如此。线程的变量副本,直到该线程第一次访问它时,才会创建,因此保存的对象数有可能小于线程数。但是保存的对象数不可能会超过线程数,大部分时间两者是相同的。

  另一方面,对象池的大小则有些随意。如果一个 Servlet 有时需要一个 JDBC 连接,有时需要两个,则 JDBC 池的大小可以相应设定(比如说,对于 8 个线程,设定 12 个连接)。线程局部变量做不到这一点,也不能减少对资源的访问(除非线程数本身可以减少)。

3. 同步

  线程局部变量不需要同步,因为它们只能用于一个线程之内;而且线程局部的 get() 方法相当快。(情况并非一直如此,在早期的 Java 版本中,获得一个线程局部变量的开销很大。如果过去因为差劲的性能而远离了线程局部变量,在当前的 Java 版本中,可以重新考虑一下。)

  同步还带来了一个有趣的问题,因为线程局部对象的性能优势通常会用节省了同步的代价来表达(而不说这是重用对象的好处)。比如,Java 7 引入了一个 ThreadLocalRandom 类;这个类(而不是一个 Random 实例)也用到了示例股票应用中。此外,本书中的很多例子在 Random 对象的 next() 方法上都会遇到一个同步瓶颈。使用线程局部对象是避免同步瓶颈的好方法,因为只有一个线程能使用这个对象。

  然而,只要让这个例子每次需要时,就简单地创建一个新的 Random 实例,同步问题也能轻松解决。不过,这样解决同步问题对整体性能没什么帮助:初始化一个 Random 对象的开销非常大,而且持续创建这个类的实例,与在多个线程间共享一个类实例的同步瓶颈相比,性能可能更差。

  使用 ThreadLocalRandom 类性能会更好,如下表所示。这个例子使用了 batching stock 应用,对于每支股票,有创建新的 Random 实例和重用 ThreadLocalRandom 两种方案。

股票数量分配新的Random(秒)重用ThreadLocalRandom(秒)
10.1740.175
100.2580.236
1000.5640.49
10002.3081.916
10 00017.3213.55

对于一般的对象重用,这里的经验是,在初始化对象需要很长时间时,不用畏惧探索用对象池或线程局部变量技术来重用那些创建开销高昂的对象。不过还是要找到一个平衡点:对于一般的类,较大的对象池所带来的性能问题很可能比解决的问题还要多。所以应该将这些技术应用于初始化成本高昂,以及重用对象的数目比较小时。

对象重用小结

  1. 对象重用通常是一种通用操作,我们并不鼓励使用它。但是这种技术可能适合初始化成本高昂,而且数量比较少的一组对象。

  2. 在使用对象池还是使用线程局部变量这两种技术之间,应该有所取舍。一般而言,建设线程和可重用对象直接存在一一对应关系,则线程局部变量更容易使用。

弱引用、软引用与其他引用

在 Java 中,弱引用和软引用也支持对象重用,不过作为开发者,我们并不会经常从重用的角度看待它们。我会一般性地将其称作非确定引用。这些类引用更多用于缓存一个较长的计算或者一个数据库查询的结果,而非用于重用对象。比如,在股票 Servlet 中,可以用一个非确定引用来缓存 getHistory() 方法(该方法需要很长的计算,或者需要很长的数据库调用)的结果。这个结果只是一个对象,当通过非确定引用来缓存它时,我们只是简单地重用了该对象,不然的话,初始化开销会很高。

术语说明

讨论弱引用和软应用可能会令人困惑,因为很多术语使用了类似的词汇。下面是这些术语的一个简单入门介绍。

引用(Reference)
  引用(或者说对象引用)可以是任何类型的引用:强引用、弱引用、软引用等。指向一个对象的普通引用实例变量就是一个强引用。

非确定引用(Indefinite reference)
  这里使用这个术语来区分强引用和其他特殊引用(比如软引用或弱引用)。一个非确定应用其实是一个对象实例(比如,SoftReference 类的一个实例)。

所引对象(Referent)
  非确定引用的工作方式是,在非确定引用类的实例内,嵌入另一个引用(几乎总是嵌入一个强引用)。被封装的对象称作“所引对象”。

不过,很多程序员仍然会有不同的感觉。实际上,该术语也反映出这样一点:没有人说“缓存”一个线程用于重用,但是我们将在缓存数据库操作结果方面探索非确定引用的重用。

与对象池或线程局部变量相比,非确定引用的优势在于,它们最终会被垃圾收集器回收。如果对象池中包含了已经执行的最后 10 000 个股票查询,堆的运行就会变慢,应用也会受牵连:去掉那 10 000 个元素所占据的堆,剩下的就是应用可以使用的其余堆了。如果这些查询是通过非确定引用保存的,JVM 就可以释放一些空间(取决于引用的类型),从而获得更好的 GC 吞吐量。

非确定引用的缺点是对垃圾收集器的效率会有轻微影响。如图对比了不使用与使用非确定引用时内存的使用情况(这里用的是弱引用)。

非确定引用的内存分配

被缓存的对象占了 512 字节。在左侧的就是消耗的所有内存(没有指向对象的实例变量占据的内存)。在右侧,对象被缓存在一个 SoftReference 内,额外增加了 40 字节的内存消耗。非确定引用和其他任何对象一样:它们也消耗内存,而且其他变量(图中右侧的 cachedValue 变量)也是通过强引用引用它们。

所以对垃圾收集器的第一个影响是,非确定引用会导致应用使用更多内存。对垃圾收集器的更大的影响体现为,垃圾收集器要回收非确定引用,至少需要两个 GC 周期。

下图说明了当一个所引对象不再被强引用时(即 lastViewed 被设置为 null),会发生什么。如果没有对 StockHistory 对象的引用,在下一次 GC 期间,该对象会被释放。所以图的左侧现在消耗的内存为 0 字节。

在 GC 周期间非确定引用保留的内存

在图的右侧,仍然有内存消耗。所引对象被释放的精确时机,会随非确定引用类型的不同而有所不同,暂时只考虑软引用的情况。所引对象将仍然逗留在内存中,直到 JVM 确定近期不会再使用它。当这个条件出现时,第一次 GC 会释放所引对象,但不是非确定引用本身。应用最终的内存状态如下图所示。

非确定引用不会立即清理

现在,对于非确定引用对象本身,(至少)有两个强引用指向它:由应用创建的原始的强引用,再就是由 JVM 创建的、在所引对象队列上的一个新的强引用。在非确定引用对象本身被垃圾收集器回收之前,必须先清理掉所有这些强引用。

这种代码通常是由处理引用队列的代码处理的。如果在队列上有新对象创建,代码会得到通知,并立即移除指向该对象的所有强引用。之后,在下一个 GC 期间,非确定引用对象会被释放。最糟糕的情况是,引用队列没有立即被处理,有可能要经过多个 GC 周期,才能将一切清理干净。然而即便在最好的情况下,非确定引用在释放之前也必须经历两个 GC 周期。

依赖于非确定引用的类型,处理算法也有较大差异,但是所有的非确定引用某种程度上都有这类性能损失。

GC 日志与引用处理

当运行一个使用了大量非确定引用的对象时,可以考虑添加 -XX:+PrintReferenceGC标志(默认为 false)。这样就能看到处理这些引用花了多少时间:

[GC[SoftReference, 0 refs, 0.0000060 secs]
        [WeakReference, 238425 refs, 0.0236510 secs]
        [FinalReference, 4 refs, 0.0000160 secs]
        [PhantomReference, 0 refs, 0.0000010 secs]
        [JNI Weak Reference, 0.0000020 secs]
        [PSYoungGen: 271630K->17566K(305856K)]
        271630K->17566K(1004928K), 0.0797140 secs]
        [Times: user=0.16 sys=0.01, real=0.08 secs]

在这个例子中,238 425 个弱引用的使用使得 Young GC 的时间增加了 23 毫秒。

1.软引用

如果问题中的对象以后有很大的机会重用,可以使用软引用,但是如果该对象近期一直没有使用到(计算时也会考虑堆还有多少内存可用),垃圾收集器会回收它。软引用本质上是一个比较大的、最近最久未用(LRU)的对象池。获得较好性能的关键是确保它们会被及时清理。

来看一个例子。股票 Servlet 可以设置一个股票历史的全局缓存,以股票代码(或者代码与日期)为键。比如有请求要获取 TPKS 从 2013 年 6 月 1 日到 2013 年 8 月 31 日之间的股价历史,可以先看看缓存,其中是不是有以前类似请求的结果。

之所以要缓存数据,原因是对某类数据的请求往往会比其他数据更多。如果对 TPKS 这支股票的请求最多,就可以考虑将其保存到软引用缓存中。另一方面,查询一次 KENG 这支股票,其结果也会在缓存中停留一段时间,但最终会被回收。对于随时间变化的请求,也是如此:对 DNLD 的一群请求,可以利用第一次请求的结果。如果用户意识到 DNLD 是笔糟糕的投资,那些缓存的条目最终会从堆中去掉。

精确地讲,一个软引用何时会被释放呢?首先,所引对象一定不能有其他的强引用。如果软引用是指向其所引对象的唯一引用,而且该软引用最近没有被访问过,则所引对象会在下一次 GC 周期释放。具体而言,其关系可以用如下伪代码表示:

long ms = SoftRefLRUPolicyMSPerMB * AmountOfFreeMemoryInMB;
if (now - last_access_to_reference > ms)
   free the reference

这里有两个关键值。第一个是由 -XX:SoftReflRUPolicyMSPerMB=N 标志设置的,默认值为 1000。

第二个是堆中空闲内存的数量(在一个 GC 周期完成之后)。因为堆的大小是动态变化的,在计算堆中有多少内存可用时,JVM 有两个选择:堆中目前的空闲内存数量,或者堆扩展到最大容量后的空闲内存数量。这些值的选择是由所用的编译器确定的。client 编译器是基于当前堆中的可用值,而 server 编译器堆的最大可能值。

那这都是怎么工作的呢?以使用 server 编译器、堆空间为 4 GB 的 JVM 为例,在一次 Full GC(或一个并发周期)之后,堆可能被占用了 50%,因此空闲堆是 2 GB。SoftReflRUPolicyMSPerMB 的默认值(1000)意味着在过去的 2048 秒(2 048 000 毫秒)内没有访问到的任何软引用都会被清理:空闲堆是 2048(MB),再乘以 1000:

long ms = 2048000; // 1000 * 2048
if (System.currentTimeMillis() - last_access_to_reference_in_ms > ms)
    free the reference

如果 4 GB 的堆占用了 75%,则过去的 1024 秒内没有访问到的对象会被回收,以此类推。

要更频繁地回收软引用,可以降低 SoftReflRUPolicyMSPerMB 标志的值。将该值设置为 500,意味着堆大小为 4 GB 的 JVM 如果占用了 75%,则会回收过去 512 秒没有访问到的对象。

如果堆很快就会被软引用填满,则调优该标志往往是必要的。假设堆有 2 GB 空闲,应用开始创建软引用。如果它在不到 2048 秒(大概是 34 分钟)创建了 1.7 GB 的软引用,则这些软引用都不满足回收条件。这样,堆中留给其他对象的空间就只有 300 MB 了;这会导致 GC 频繁进行(对整体性能影响很坏)。

如果 JVM 完全耗尽了内存,则会出现非常严重的颠簸(thrashing),它会清理掉所有的软引用,否则会抛出 OutOfMemoryError。不抛出错误当然好,但是不分青红皂白地丢掉所有缓存的结果,可能也不理想。因此,另一个降低 SoftReflRUPolicyMSPerMB 的值的时机是,当引用处理日志说明有大量软引用意外被清理时。这种情况在 4 次连续的 Full GC 周期之后才会发生(而且仅当其他因素都已满足时)。

另一方面,对于长期运行的应用,如果满足如下两个条件,可以考虑增大 SoftReflRUPolicyMSPerMB 的值:

  1. 有很多空闲堆可用;

  2. 软引用会频繁访问。

这种情况非常罕见。这与设置 GC 策略所讨论的情况类似:可以想象,如果增加了软引用策略的值,就是告诉 JVM,不到万不得已不要释放软引用。确实如此,但是这同时也告诉 JVM,堆中不要给正常操作留任何空间,结果很可能会导致把很多时间花在 GC 上。

应该注意的是,不要使用太多软引用,因为它们很容易填满整个堆。与提防创建包含太多实例的对象池相比,这一点更应该注意:如果对象的数目不是特别大,软引用就会工作得很好。否则,就要考虑用更传统的、固定大小的对象池来实现一个 LRU 缓存。

2.弱引用

当问题中的所引对象会同时被几个线程使用时,应该考虑弱引用。否则,弱引用很可能会被垃圾收集器回收:只有弱引用的对象在每个 GC 周期都可以回收。

这意味着,弱引用绝对不会进入上图(在 GC 周期间非确定引用保留的内存)所示的软引用的状态。当强引用被移除时,弱引用会立即释放。

这里有个有趣的现象,弱引用会在堆中终结。引用对象就和其他 Java 对象一样:在年轻代中创建,最终会被提升到老年代。如果弱引用本身仍然在年轻代中,而弱引用的所引对象被释放了,则弱引用可以快速释放(下一次 Minor GC 时)。(假定问题中对象的引用队列会被快速处理。)如果弱引用的所引对象存在了足够长的时间,被提升到了老年代中,则弱引用在下一次并发或 Full GC 周期内才会释放。

以股票 Servlet 的缓存为例,假设我们知道某个特定用户会在其会话期间访问 TPKS,他几乎总会再次访问。在该用户的 HTTP 会话中,用一个强引用来保存股票的值是有意义的:它会一直存在,一旦用户登出,HTTP 会话就会被清理,而内存也会被回收。

如果另一个用户来了,而且也需要 TPKS 的数据,那如何找到数据呢?因为对象在内存中的某个地方,我们不想重新去查找,但是 Servlet 代码不能搜索其他用户的会话数据,因此,除了在第一个用户的 HTTP 会话中保存一个指向 TPKS 数据的强引用外,在一个全局缓存中保存一个弱引用,指向那个数据,也是有意义的。现在第二个用户就能查找 TPKS 数据了,当然这是以第一个用户没有登出并清理会话为前提的。(这就是本章的堆分析一节中所用的场景,其中的数据有两个引用,通过在最大的保留内存中查看对象,并不容易查找。)

这就是所谓的同时访问。这就好比是告诉 JVM:“嘿,只要有其他人对这个对象感兴趣,就让我知道它在哪儿,但是如果他们不再需要它了,就把它丢弃,我自己会重新创建。”比较弱引用与软引用,软引用基本像是在说:“嘿,只要有足够的内存,而且看上去有人会偶尔访问它,那就保留着它。”

如果不理解这种区别,在使用弱引用时就经常会出现性能问题。不要认为除了释放更快,弱引用和软引用就是一样的,别犯这种错误:软引用的对象通常可以存活几分钟甚至几小时,但是只要所引对象仍然存在,弱引用对象就一直存活。(下一个 GC 周期会清理。)

非确定引用与集合

在 Java 中,集合类经常是内存泄漏的根源:比如,某个应用将对象放入了一个 HashMap 对象中,却从不移除。随着时间的推移,这个 HashMap 对象就会越来越大,而且消耗堆。

为处理这种情况,开发者喜欢的一种方式是使用保存非确定引用的集合类。JDK 提供了两个这样的类:WeakHashMap 和 WeakIdentityMap。很多第三方库中都有基于软引用(即其他引用)定制集合类的用法(包括 JSR 166 的示例实现,比如用在如何创建和保存 canonical 对象的例子中使用的那个)。

使用这些类很方便,但是注意,它们有两大开销。其一,如本节所讨论的,非确定引用对垃圾收集器有不利影响。其二,类本身必须周期性地执行一个操作,清理集合中所有的未引用数据(也就是说,这个类负责处理它所保存的非确定引用的引用队列)。

例如,WeakHashMap 类的键使用了弱引用。当弱引用的键不再可用时,WeakHashMap 代码必须清理掉其中与该键关联的值。每次引用到这个映射时,都会执行该操作:处理弱键的引用队列,从映射中移除与引用队列中的任何键关联的值。

性能方面,有两点意义。第一,弱引用及其关联值,当这个映射再一次被用到时,才会实际释放。因此,如果这个映射使用不是很频繁,则意味着与映射关联的内存不会如预期般释放得那么快。

第二,它意味着该映射上的操作的性能是难以预测的。正常而言,hashmap 上的操作非常快;这也是 hashmap 得以流行的原因所在。紧接在一次 GC 之后的 WeakHashMap 上的操作,必须处理引用队列;该操作的时间不再固定,而且可能会比较长。因此,即便键释放不是很频繁,性能还是很难预测。更糟的是,如果映射中的键会频繁释放,WeakHashMap 的性能可能会非常差。

基于非确定引用的集合可能很有用,但是应该谨慎使用。如果可能,让应用自己管理集合。

3.终结器(Finalizer)和最终引用(Final Reference)

每个 Java 类都有一个从 Object 类继承而来的 finalize() 方法;在对象可以被垃圾收集器回收时,可以用这个方法来清理数据。这听上去是个不错的特性,而且在有些情况下是需要的。然而在实践中,结果往往很糟,应该尽量不要使用这个方法。

因为功能方面的原因,终结器不是很好,另外,它们的性能也不好。终结器实际上是非确定引用的一种特殊情况:JVM 使用了一个私有的引用类(java.lang.ref.finalizer,它又是 java.lang.ref.finalReference 的子类)来记录定义了 finalize() 方法的对象。当一个具有 finalize() 方法的对象被分配时,JVM 会分配两个对象:一个是该对象本身,另一个是一个以该对象为所引对象的 finalizer 引用。

和其他非确定引用一样,在非确定应用对象释放之前,至少需要两个 GC 周期。然而,这里的性能损失要比其他非确定引用类型大得多。当软引用或弱引用的所引对象可以被 GC 回收时,所引对象本身会立即释放;这就会出现前面图 7-9 所示的内存使用情况。弱引用或软引用放在引用队列中,但是引用对象不会再指向任何东西(也就是说,get() 方法会返回 null,而不是原来的所引对象)。在软引用和弱引用的情况下,两个周期的性能损失只是引用对象本身的(而非所引对象的)。

finalReference 就不是这样了。要调用所引对象的 finalize() 方法,finalizer 类的实现必须能够访问该对象,因此,当终结器引用被放到引用队列中时,所引对象不能释放。当某个终结器的所引对象可以回收时,程序状态如图:

终结器引用要保留更多内存

当引用队列处理终结器时,finalizer 对象照例会被从队列中移除,之后就可以回收了。直到那时,所引对象才会被释放。与其他非确定引用相比,终结器对 GC 的性能影响更大,原因就在于此:与非确定引用对象本身消耗的内存相比,所引对象消耗的内存更为显著。

这会带来一个功能性问题,finalize() 方法可能会不小心又创建了一个指向所引对象的新的强引用。而这又会引起 GC 性能损失:现在,直到不再存在强引用时,所引对象才能释放。从功能上讲,这引发了一个大问题,因为当下一次所引对象可以被回收时,其 finalize() 方法不会被调用,预期的清理工作也不会进行。这类错误就足以解释为什么要尽可能少用终结器了。

遗憾的是,某些情况下终结器是不可避免的。比如,JDK 就在其操作 ZIP 文件的类中使用了终结器,因为打开 ZIP 文件会使用一些分配原生内存的原生代码。这些内存会在 ZIP 文件关闭时释放,但是如果开发者忘记调用 close() 方法,那该怎么办呢?事实上,终结器可以确保 close() 方法被调用,即便开发者忘了。

通常,如果使用终结器是不可避免的,那么一定要确保尽量减少该对象访问的内存。

对于使用终结器,还有一种替代方案,至少可以避免部分问题。特别是,这种方案支持在正常的 GC 操作期间释放所引对象。这是通过使用另一种非确定引用实现的,而非隐式地使用 finalizer 引用。

对于这个目的,有时推荐使用另一种非确定引用类型:PhantomReference(虚引用)类。这是个不错的选择,因为一旦没有指向所引对象的强引用了,引用对象就可以相当快地清理,而且在调试的时候,该引用的意图会很清晰。当然也可以利用弱引用实现同样目标(另外,弱引用可以用在更多地方)。在特定情况下,如果软引用的缓存语义匹配应用的需求,可以使用软引用。

下面看一种替代方案。要创建一个替代的终结器,先创建非确定引用的一个子类,来保存需要在所引对象被回收后再清理的任何信息。然后在引用对象的一个方法内执行清理操作(与在所引对象内定义 finalize() 方法完全不同)。

private static class CleanupFinalizer extends WeakReference {

    private static ReferenceQueue<CleanupFinalizer> finRefQueue;
    private static HashSet<CleanupFinalizer> pendingRefs = new HashSet<>();

    private boolean closed = false;

    public CleanupFinalizer(Object o) {
        super(o, finRefQueue);
        allocateNative();
        pendingRefs.add(this);
    }

    public void setClosed() {
        closed = true;
        doNativeCleanup();
    }

    public void cleanup() {
        if (!closed) {
            doNativeCleanup();
        }
    }

    private native void allocateNative();
    private native void doNativeCleanup();
}

以上就是这样一个类的大概轮廓,它使用了一个弱引用。构造器中会分配一些原生资源。在正常使用的情况下,它会调用 setClosed() 方法,并清理原生内存。

不过这个弱引用也被放到了一个引用队列中。当该引用被从队列中取出时,可以检查原生内存是否已经清理,如果没有,就清理掉。

对引用队列的处理在一个守护线程中进行:

static {
    finRefQueue = new ReferenceQueue<>();
    Runnable r = new Runnable() {
        public void run() {
            CleanupFinalizer fr;
            while (true) {
                try {
                    fr = (CleanupFinalizer) finRefQueue.remove();
                    fr.cleanup();
                    pendingRefs.remove(fr);
                } catch (Exception ex) {
                    Logger.getLogger(
                           CleanupFinalizer.class.getName()).
                           log(Level.SEVERE, null, ex);
                }
            }
        }
    };
    Thread t = new Thread(r);
    t.setDaemon(true);
    t.start();
}

这些都放到一个私有的静态内部类中,隐藏到开发者使用的实际类之内,最后看上去是这样的:

public class CleanupExample {
    private CleanupFinalizer cf;
    private HashMap data = new HashMap();

    public CleanupExample() {
        cf = new CleanupFinalizer(this);
    }

    ……向hashmap中填东西的方法……

    public void close() {
        data = null;
        cf.setClosed();
    }
}

开发者就和构建其他任何对象一样构建这样的对象。开发者被告知要调用 close() 方法,它会清理原生内存,但是如果开发者没调用,也没问题。弱引用仍然存在于背后,所以当内部类处理弱引用时,Cleanupfinalizer 类自有机会处理原生内存。

这个例子中有一个技巧,即用 pendingRefs 保存弱引用。如果没有它,弱引用本身在有机会进入引用队列之前就会被收集了。

这个例子克服了传统终结器的两个局限性:它性能更好,因为所引对象一回收,与其关联的内存(这个例子中是 data 这个 HashMap)就会释放(而不是在 finalizer() 方法中进行)。而且所引对象是无法在清理代码中复活的,因为它已经被回收。

当然,其他反对使用终结器的意见也可以放到这里:我们无法确保垃圾收集器能抽出时间释放所引对象,也无法确保引用队列线程会处理队列中的任何特定对象。如果有很多这样的对象,处理引用队列的成本就会很高。和所有非确定引用一样,这个例子也应该谨慎使用。

终结器队列

终结器队列是一个引用队列,用于当所引对象可以被 GC 回收时处理 Finalizer 引用。

在执行堆转储分析时,确保终结器队列中没有对象,往往会方便一些:反正这类对象即将被释放,所以在堆转储中去掉它们,分析堆中的其他状况会更方便。可以通过如下命令让 JVM 处理终结器队列:

% jcmd process_id GC.run_finalization

要监控 Finalizer 队列,看看它是否是应用中的问题,可以在 jconsole 的 VM Summary 选项卡中看看它的大小(这是实时更新的)。脚本可以通过运行如下命令收集该信息:

% jmap -finalizerinfo process_id

软、弱、非确定引用小结

  1. 非确定引用(包括软引用、弱引用、虚引用和最终引用)会改变 Java 对象正常的生命周期,与池或线程局部变量相比,它可以以对 GC 更为友好的方式实现对象重用。

  2. 当应用对某个对象感兴趣,而且该对象在应用中的其他地方有强引用时,才应该使用弱引用。

  3. 软引用保存可能长期存在的对象,提供了一个简单的、对 GC 友好的 LRU 缓存。

  4. 非确定引用自身会消耗内存,而且会长时间抓住其他对象的内存;应该谨慎使用。

总结

内存管理对 Java 程序的快慢至关重要。调优 GC 非常重要,但是要获得最好的性能,在应用内必须有效地利用内存。

目前的硬件趋势往往不鼓励开发者考虑内存:如果我的笔记本有 16 GB 内存,我为什么要关心某个对象中有一个多余的、未使用的 8 字节大的对象引用呢?我们还忘记了一点,编程中通常的时间和空间之间的取舍,有可能会变成时间和空间与时间(time/space-and-time)之间的取舍:使用太多堆空间可能会降低性能,因为需要更多 GC。在 Java 中,管理堆仍然非常重要。

多数管理问题都围绕何时以及如何使用特殊的内存技术展开:对象池、线程局部变量和非确定引用。明智地使用这些技术可以极大改进应用性能,但是过度使用也很容易引起性能下降。在限量的情况下,也就是问题中的对象数目很少,而且有个边界时,使用这些内存技术会非常高效。

更新时间:2020-03-13 11:41:39

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

评论

Your browser is out of date!

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

×