Java 8 垃圾收集调优指南2-分代(译)

如何分代与分代收集

Java SE 平台的一个强大之处是它保护开发人员避开内存分配和垃圾收集的复杂性。然而,当垃圾收集是主要瓶颈的时候,理解这个被隐藏的实现的某些方面还是很有用的。关于应用使用对象的方式,垃圾收集器做出了几个假设,它们被反映在可调参数中,可以调整这些参数以获得更好的性能同时又不牺牲这层抽象的强大。

正在运行的程序中,当一个对象通过任何指针都不再可到达时,它就被认为是垃圾。最简单的垃圾收集算法是遍历每一个可到达的对象,剩下的对象都被当作垃圾。这种方式花费的时间与活对象的数量成正比,这对于维护着大量活数据的大型应用来说是难以承受的。

虚拟机包含多个不同的垃圾收集算法,并使用分代收集将它们结合在一起。简单的垃圾收集检查堆中每一个活对象,而分代收集则利用在大部分应用上通过实证都被观察到的几个属性来最小化回收不再使用的对象所需的工作。这些被观察到的属性中最重要的是弱分代假说,这个假说称大部分对象只存活很短的一段时间。

下图中的蓝色区域是对象生命周期的一个典型分布。x 轴表示对象生命周期(用被分配的字节来度量)。y 轴上的字节数就是具有对应生命周期的对象的总字节数。左边的那个尖峰代表分配后不久就可以回收的对象(换句话说,已死的对象)。例如,Iterator 对象经常只存活于单个循环期间。

Figure 3-1 Typical Distribution for Lifetimes of Objects

Description of “Figure 3-1 Typical Distribution for Lifetimes of Objects”

有些对象存活得更久些,所以蓝色区域一直延伸到右端。举例来说,典型地有一些对象在初始化时就被分配,且一直存活直到所在进程退出。在这两个极端之间是存活于中间计算过程中的对象,上图中它们就是 初始的尖峰 右边的那个块。尽管一些应用有着看起来很不一样的分布,但非常非常多的应用都拥有这个大体上的形状。通过聚焦于大多数对象都“英年早逝”这个事实使得高效的解决方案成为可能。

为了针对这种情况进行优化,内存就被分代(保存不同年龄的对象的内存池)管理。当分代装满时,垃圾收集就会在分代中发生。绝大多数对象被分配在年轻对象专用的内存池(新生代)中,大多数对象都在那儿死去。当新生代装满时,就会导致一次 minor 收集。minor 收集只会对新生代进行回收,其他分代的垃圾不会被回收。minor 收集可以被优化,假设弱分代假说成立,且新生代中的大部分对象都是垃圾且可被回收。首要的是(to the first order),这种收集的成本与被收集的活对象(指收集过程中被扫描的对象)的数量成正比;充满死对象的新生代收集起来非常快。典型地,在每一次 minor 收集期间新生代中幸存对象的某一小部分会被移动至老生代。最终,老生代会装满且必须被收集,这就会导致一次 major 收集,在这个过程中,整个堆都会被收集。major 收集通常持续时间比 minor 收集长得多,因为涉及相当多数量的对象。

正如在 Ergonomics 一节指出的那样,ergonomics 动态选择垃圾收集器,为了给各种各样的应用提供好的性能。串行垃圾收集器是为具有小数据集的应用设计的,为它选择的默认参数对大部分小型应用来说都是有效的。并行或吞吐量垃圾收集器的目的是用于具有中到大型数据集的应用。ergonomics 选择的堆大小参数加上大小自适应策略的特性目的是为服务器应用提供更好的性能。大部分情形下,这些选择都工作得很好,但并非所有情形,那些例外情形通向这个文档的核心信条:

注意:如果垃圾收集成为了瓶颈,很可能你既需要自定义总的堆大小,还要自定义各个分代的大小。检查垃圾收集器的冗长输出,探索你的个性化性能指标对垃圾收集器参数的敏感度。

图 3-2 展现了分代的默认布局(适用于所有收集器,除了 Parallel 和 G1 收集器):

Figure 3-2 Default Arrangement of Generations, Except for Parallel Collector and G1

Description of “Figure 3-2 Default Arrangement of Generations, Except for Parallel Collector and G1”

初始化时,会虚拟地保留一个最大的地址空间,但并未将它分配给物理内存,除非它被需要。为对象内存保留的整个地址空间可被划分为新生代(young)、老生代(tenured)。
新生代由伊甸园(eden) 和两个幸存者(survivor)空间组成。大部分对象最初被分配在 eden 区。任何时刻都有一个 survivor 区是空着的,用作 eden 区中活对象的目的地;另一个 survivor 在接下来的复制收集(the next copying collection)期间用作目的地。像这样对象在两个 survivor 之间来回复制,直到它们变得足够老而被拷贝至老生代。

性能考虑因素(Performance Considerations)

垃圾收集的性能有两个主要的的量度:

1. 吞吐量是指未花在垃圾收集上的总时间所占的百分比。吞吐量包括为对象分配内存所花费的时间(但一般不需要调优分配速度)。
2. 暂停是指因正在进行垃圾收集而使应用表现得无响应的时间。

用户有不同的垃圾收集需求。举例老说,一些用户认为对于 web server 来说正确的指标是吞吐量,因为垃圾收集期间的暂停是可容忍的或者可以简单地被网络延迟掩盖。然而,在交互式图形应用中,即使很短的暂停也会负面地影响用户体验。

而一些用户对其他考虑因素比较敏感。footprint 是一个进程的工作集,使用页(pages)和缓存行(cache line)来度量。在物理内存有限或有许多进程的系统上,footprint 可能会影响可扩展性/可伸缩性(scalability)。垃圾收集的敏捷度(promptness)就是对象变成死的和其内存变得可用之间的间隔时间,对于分布式系统(包括远程方法调用 RMI)来说,这是一个重要的考虑因素。

总之,为具体的分代选择大小就是在这些考虑因素之间进行平衡。例如,非常大的新生代可以最大化吞吐量,但这样做是以增加 footprint、降低垃圾收集的敏捷度(promptness)和暂停时间变长为代价的。新生代很小可以最小化新生代的暂停时间,代价是减小了吞吐量。调整一个分代的大小不会影响另一个分代的垃圾收集频率和暂停次数。

没有一个为分代选择大小的正确方法。最好的选择就是根据用户需求和应用使用内存的方式来决定。因此虚拟机对垃圾收集器的选择并不总是最佳的,使用 Sizing the Generations 一节描述的命令行选项可以覆盖默认选择。

度量(Measurement)

吞吐量和 footprint 最好使用特定于应用的指标来度量。例如,web server 的吞吐量可以使用客户端负载生成器(client load generator)来测试,而它的 footprint 在 Solaris 上可以使用 pmap 命令来测量。而垃圾收集导致的暂停通过查看虚拟机自身的诊断输出就可以轻松估算出来。

命令行选项 -verbose:gc 可使 JVM 在每次收集时打印关于堆和垃圾收集的信息。例如,这就是来自于一个大型服务器应用的输出:

1
2
3
[GC 325407K->83000K(776768K), 0.2300771 secs]
[GC 325816K->83372K(776768K), 0.2454258 secs]
[Full GC 267628K->83769K(776768K), 1.8479984 secs]

输出展示了两次 minor 收集,接着的是一次 major 收集。箭头前后的数字(例如,第一行中的 325407K->83000K)分别表示垃圾收集前、后新生代和老生代中活对象加在一起的大小。minor 收集之后,活对象的总大小仍然包含一些已经是垃圾(不再是活的)但 minor 收集没法回收的对象。这些对象要么就在老生代中,要么被老生代中的死对象引用。

紧接着的圆括号中的数字(例如,(776768K) 仍来自第一行)是堆的 committed size:不用向操作系统请求更多内存,直接可用于 Java 对象的内存空间的数量。注意,这个数字只包含两个 survivor 中的一个。除在垃圾收集期间之外,都只有一个 survivor 用于在任何给定的时间存储对象。

每行的最后一项(例如,0.2300771 secs)表示垃圾收集所花费的时间,在这个实例中大约是 1/4 秒。

第三行中有关 major 收集的输出格式是类似的。

注意:-verbose:gc 选项产生的输出的格式在将来的发布中可能会改变。

命令行选项 -XX:+PrintGCDetails 可使 JVM 打印更多有关垃圾收集的信息。下面是一个使用串行垃圾收集器的 JVM 中该选项的输出:

1
[GC [DefNew: 64575K->959K(64576K), 0.0457646 secs] 196016K->133633K(261184K), 0.0459067 secs]

这表明 minor 收集回收了新生代大约 98% 的空间,DefNew: 64575K->959K(64576K),花了 0.0457646 secs (大约 45 毫秒)。

整个堆的使用减少至大约 51%(196016K->133633K(261184K)),最终的总时间表明收集操作还有一些微小的额外开销(除新生代的收集之外)。

注意:-XX:+PrintGCDetails 选项产生的输出的格式在将来的发布中可能改变。

-XX:+PrintGCTimeStamps 选项会在垃圾收集开始时添加一个时间戳。要查看垃圾收集发生的频率,这个选项很有用。

1
111.042: [GC 111.042: [DefNew: 8128K->8128K(8128K), 0.0000505 secs]111.042: [Tenured: 18154K->2311K(24576K), 0.1290354 secs] 26282K->2311K(32704K), 0.1293306 secs]

这次收集开始于进入应用执行期大约 111 秒(about 111 seconds into the execution of the application)。此外,还显示了使用老生代来描绘 major 收集的信息。minor 收集大约同时开始执行。老生代的使用减少至大约 10%(18154K->2311K(24576K)),花了 0.1290354 secs(大约 130 毫秒)。

参考资源

原文 Java 8 Garbage Collection Tuning Guide: Generations

0%