【Java 8 GC 调优】“代”(Generation)
Jave SE 平台的一个优点是,它可以保护开发人员不受 内存分配 和 垃圾收集 复杂性的影响。但是当垃圾收集成为主要瓶颈时,了解这个隐藏实现的某些方面是很有用的。GC对应用程序使用对象的方式进行了假设。这些假设可以反映在可调参数中。可以在不牺牲抽象能力的情况下调整这些参数,以提高应用程序的性能。
当一个对象不能被程序中的任何指针访问时,会被认为是垃圾。最简单的GC算法会遍历每个可到达的对象。任何遗留的对象都被视为垃圾。这种方法所用的时间与活动对象的数量成正比。这使得大型应用程序无法维护大量活动数据。
JVM 融合了许多不同的GC算法。这些算法通过“代际收集”(Generational Collection)结合在一起。与“检查堆中每个活动对象”的GC不同,“代际收集”利用了大多数应用程序中几个根据经验观察到的属性,以最小化回收垃圾对象所需的工作。这些观察到的属性中最重要的是“弱世代假设”(Weak Generational Hypothesis),即 大多数对象只能存活很短的一段时间。
下图中的蓝色区域就是对象寿命的典型分布。
- X轴 是 以分配的字节为单位 测量的对象寿命。
- Y轴 上的字节数 是 具有相应寿命的对象的总字节数。
- 左边的尖峰代表分配后不久可以回收的对象(也就是说,这些对象“死了”)。
例如,迭代器对象通常存活于单个循环期间。 - 有些对象会存活更长时间,因此分布向右延伸。
例如,在初始化时通常会分配一些对象,这些对象一直存活到进程退出。 - 在这两个极端之间,存在一些中间计算的对象。此图初始峰值右侧的肿块表示这些对象。
有些应用程序的对象寿命分布非常不同,但拥有这种常规分布的应用数量大得惊人。通过关注“大多数对象‘早死’”这一事实,高效的垃圾收集成为可能。
为了对此场景进行优化,内存 按代管理(不同的“代”存放不同年龄的对象)。“代”被填满时会发生GC。
绝大多数对象都被分配到专为年轻对象设计的池中(新生代),其大多数对象会在这里死亡。
当新生代被填满时,会引发一次只收集年轻对象的 “Minor GC”,其它“代”中的垃圾不会被回收。
如果“弱世代假设”成立 且 新生代中的大多数对象是可被回收的垃圾,那么就可以优化 Minor GC。这种回收方式的成本与被检测的存活对象数量成正比,全都是死对象的 “新生代” 回收非常快。
在每次 Minor GC 中通常会有部分存活的对象从新生代被移到 “老年代”(Tenured Generation)。
最终老年代也会被填满并被回收,即 Major GC,整个堆都会被回收。
Major GC 的持续时间通常比 Minor GC 要长得多,因为涉及的对象数量要多得多。
正如《Ergonomics》一节所述,JVM 会动态选择GC 为不同的应用程序提供良好的性能。串行GC 是为小数据量的应用程序设计的,它的默认参数是为大多数小型应用设计的。并行GC 或 吞吐量GC 用于中大型数据量的应用程序。Ergonomics 选择的 堆大小 与 自适应(堆)大小策略特性 旨在为服务器应用提供良好的性能。这些选择在大多数情况下都很有效,但并不是所有情况。这也引出了本文的中心原则:
下图展示了“代”的默认排布(除了 并行GC 和 G1)
在初始化时,实际上保留了最大地址空间。但除非需要,否则不会分配物理内存。整个(堆)地址空间被分为 新生代 和 老年代 (young - tenured)。
- 新生代由 Eden 和 2个Survivor 组成。
- 大多数对象最初被分配到 Eden。
- 任何时候都有一个 Survivor 是空的(除了GC期间),它是 Eden 中存活对象的目的地。
- 另一个 Survivor 是下一次复制收集操作的目的地。
- (存活)对象在两个 Survivor 之间被复制,直到它们足以被划为老年代(被复制到老年代)。
性能关注点
有 2个主要指标 来衡量GC性能:
-
吞吐量。它是较长一段时间内未被用于GC的时间占比。(即,应用程序业务执行时间占总时间的百分比。)
吞吐量包含了分配对象所花费的时间。但通常不需要分配速度。
- 暂停时间。它指应用程序因垃圾收集而没有响应的时间。
用户对垃圾收集有不同的要求。如:
有些人认为Web服务的正确指标是 吞吐量,因为GC过程中的暂停可能是可以容忍的,也可能会被网络延迟掩盖。
但是在交互式图形程序中,即使是短暂的停顿也可能对用户体验产生负面影响。
有些用户对其它考虑因素很敏感。
-
内存占用量(Footprint)是进程的工作集。
可以用 页(page)和 缓存线(cache line)来衡量它。
在物理内存很有限 或 进程很多 的系统上,内存占用量 可能会决定 伸缩性。
-
及时性(Promptness)是指 从对象死亡 到 内存可用 的时间。
它是分布式系统 与 远程方法调用(RMI) 的一个重要考虑因素。
一般来说,为特定的“代”选择大小 是在这些考虑因素之间进行权衡。如,
规模非常大的 新生代 可以最大化吞吐量,但这样的代价是 内存占用高(Footprint)、及时性差(Promptness)、停顿时间长。
新生代 的停顿 可以通过缩小其规模来减少,代价就是吞吐量下降。
一“代”的大小 不影响 另一“代”的 收集频率 和 暂停时间。
“代”的大小选择没有一种固定的正确方法。最佳选择取决于应用程序使用内存的方式 及 用户需求。因此 JVM 对 GC 的选择并不总是最优的。我们可以通过命令行选项的来改变这些选择(《确定“代”的大小》)。
衡量
吞吐量 和 内存占用量 的最佳衡量方式是使用应用程序特有的一些指标。
通常:
- Web 服务的吞吐量可以通过客户端负载生成器进行测试;
- 服务的内存占用量则可以使用操作系统的命令来获得(如,Solaris 中的 pmap)。
- GC 导致的暂停时间 则可以通过检查 JVM 的诊断输出进行估计。
通过命令行选项获得 GC 活动的输出,并评估:
-verbose:gc
此选项可以让 JVM 输出每次GC时 堆与收集操作 的信息。
如下就是某大型服务端应用的输出:
[GC 325407K->83000K(776768K), 0.2300771 secs] [GC 325816K->83372K(776768K), 0.2454258 secs] [Full GC 267628K->83769K(776768K), 1.8479984 secs]
这些输出显示了 两次 Minor GC 及 后续的一次 Major GC。
$ 箭头前后的数字,如第一行的 “325407K->83000K”,分别表示 GC 前后 存活对象 的总大小。
(但是)Minor GC 之后的 大小 包括一些已经是垃圾但暂时无法回收的对象。这些对象要么包含在 老年代中,要么被老年代中的(死)对象所引用。
$ 后面括号中的数字,如第一行的“(776768K)”,是堆的大小。
它是Java对象可用的空间量,不需要从操作系统申请更多内存。
注意,这个数字只是一个 Survivor 的空间。除了GC期间,其它任何时刻都只有一个 Survivor 会被用于存放对象。
$ 最后一项,如“0.2300771 secs”,表示此次GC 所花费的时间。
第三行中 Major GC 的格式也一样。
注意:-verbose:gc 输出信息的格式在未来版本中可能会发生更改。
-XX:+PrintGCDetails
此选项可以让 JVM 输出GC的其它详细信息。如下就是使用 串行GC 时输出的样例:
[GC [DefNew: 64575K->959K(64576K), 0.0457646 secs] 196016K->133633K(261184K), 0.0459067 secs]
这段输出表明:
- 此次 Minor GC 恢复了 新生代 大约98% 的空间(从 64575K 到 959K),花费了 0.0457646 秒。
- 整个堆的使用率降到了大约 51%(从 196016K 到 133633K),且在收集新生代之外有一些轻微的额外开销(最终总耗时是 0.0459067 秒)。
注意:-XX:+PrintGCDetails 输出信息的格式在未来版本中可能会发生更改。
-XX:+PrintGCTimeStamps
此选项会让 JVM 在每次收集开始处添加一个时间戳。这有助于了解 GC 发生的频率。
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]
这段输出表明:
- 此次 GC 在应用程序运行大约111秒后开始。Minor GC 大约在同一时刻开始。
- 还有一次 Major GC (由 Tenured 表示)。老年代的使用率降低到大约10%(从 18154K 到 2311K),花费了 0.1290354 秒。