JVM——G1 收集器

原文链接:http://www.dubby.cn/detail.html?id=9059

1. 概述

硬件和软件要求

  • 操作系统要求 Windows XP 或者更高,Mac OS X 和 Linux 都可以。请注意,这些测试操作是在 Windows 7 上完成的,尚未在所有平台上进行测试。 但是,一切都应该在 OS X 或 Linux 上正常工作。 当然,你的机器有一个以上的核心就更好了。
  • Java 7 Update 9 或者更高版本。
  • 最新的 Java 7 Demos 和示例 Zip。

准备内容

  • 安装好 Java 7u9 或者更高版本。
  • 从官网下载下来示例代码,解压后,比如放在 C:\javademos 下。

2. Java 和 JVM

Java 预览

Java 是 Sun Microsystems 在 1995 年首次发布的编程语言和计算平台。它是支持 Java 程序(包括通用工具,游戏和商业应用程序)的基础技术。 Java 运行在全世界超过 8.5 亿台个人计算机上,并在全球数十亿台设备上运行,包括移动和电视设备。 Java 由许多关键组件组成,总体而言,它们共同组成了 Java 平台。

Java 运行时版本

当你下载 Java 时,你就已经获得了 Java 运行时环境(JRE)。JRE 是由 Java 虚拟机(JVM),Java 核心类库和辅助性的 Java 类库组成。如果你想要在你的电脑上运行 Java 程序,那么这三个组成部分都是需要安装好的。使用 Java 7 的时候,你可以在操作系统上运行 Java 应用程序,或者使用 Java Web Start 从 Web 上安装然后运行 Java 应用程序,或者作为一个 Web 嵌入式应用程序运行在浏览器里(JavaFX)。

Java 编程语言

Java 是一个面向对象的编程语言,有下面这些特性。

  • 平台无关性——Java 应用被编译成字节码,存储在 class 文件里,然后被 JVM 加载。由于 Java 应用是运行在 JVM 中,而不是直接运行在操作系统上,所以他们可以运行在各个操作系统上。(译者:也就是一次编写,到处运行,JVM 帮我做了平台兼容,当然,不可能真的平台无关性)
  • 面向对象——Java 吸收了 C 和 C++ 的很多特性,并做了一些优化。
  • 自动垃圾回收——Java 会自动分配和释放内存,程序员不会有负担。(译者:但是多了了解 GC 机制的负担,不然你也不会看这篇文章了)
  • 丰富的标准库——Java 拥有很多预先设计好的类,我们可以直接用,比如:输入输出,网络,日期等等。

JDK

Java 开发工具包(JDK)是开发 Java 应用所需的一系列的工具包。有了 JDK,你可以编译你用 Java 写的程序,并且运行。除此之外,JDK 还提供了打包和分发应用程序的工具。

JDK 和 JRE 公用了 Java 应用程序接口(Java API)。Java API 是预先打包好的类库,开发者可以直接使用。Java API 让开发者的开发工作变得更简单,比如:string 的处理,时间的处理,网络,各种数据结构的集合(例如:lists, maps, stacks, and queues)。

JVM

Java 虚拟机(JVM)是一个抽象的计算机。 JVM 是一个看起来像一个计算机的程序,可以执行写入到 JVM 中的程序。 这样,Java 程序就被写入到同一组接口和库中。 针对特定操作系统的每个 JVM 实现,将 Java 编程指令转换为在本地操作系统上运行的指令和命令。 这样,Java 程序就实现了平台独立性。

Sun 公司完成的 Java 虚拟机的第一个原型实现,模拟了由类似当代个人数字助理的手持设备托管的软件中的 Java 虚拟机指令集。 Oracle 的当前虚拟机实现了移动端,桌面和服务器设备上的 Java 虚拟机。但 Java 虚拟机不承担任何特定的实现技术,主机硬件或主机操作系统。 它没有一个固有的解释,(只是一个规范),你也可以通过将其指令集编译为硅 CPU 来实现。 它也可以用微码或直接用硅来实现。

Java 虚拟机对 Java 编程语言一无所知,只知道特定的二进制格式,即类文件格式 class。 类文件包含 Java 虚拟机指令(或字节码)和符号表以及其他辅助信息。

为了安全起见,Java 虚拟机对类文件中的代码施加了强烈的语法和结构限制。 但是,Java 虚拟机可以托管任何具有可以用有效的类文件表示的功能的语言。正因如此,很多其他语言的实现者,为了享受 JVM 带来的遍历,他们可以把自己的语言编译成 class 文件交给 JVM 来执行。

探索 JVM 架构

Hotspot 的架构

HotSpot JVM 拥有支持强大功能和功能的基础架构,并支持实现高性能和大规模可扩展性的能力。 例如,HotSpot JVM JIT 编译器会生成动态优化。 换句话说,他们在 Java 应用程序运行时做出优化决策,并生成针对底层系统体系结构的高性能本地机器指令。 此外,通过其运行时环境和多线程垃圾收集器的成熟发展和持续工程,HotSpot JVM 即使在最大的可用计算机系统上也具有很高的可扩展性。

JVM 的主要组件包括类加载器,运行时数据区和执行引擎。

Hotspot 的关键组件

下图高亮显示了与性能相关的 JVM 的关键组件。

在调整性能时,JVM 有三个重点关注的组件。 堆是你的对象数据存储的地方。 这个区域由启动时选择的垃圾收集器管理。 大多数调优选项都是针对堆的大小以及为您的情况选择最合适的垃圾收集器。 JIT 编译器对性能也有很大的影响,但很少需要使用较新版本的 JVM 进行调优。

性能基础

通常,在调整 Java 应用程序时,重点是两个主要目标之一:响应性或吞吐量。 随着教程的进展,我们将回顾这些概念。

响应性

响应性指的是一个应用程序或者一个系统可以多快的响应一个请求。举个例子:

  • 桌面应用响应 UI 事件(点击,滑动等)的速度。
  • 一个网站返回页面的速度。
  • 数据库查询结果返回的速度。

对于一个关注响应性的应用,是不能接受长时间停顿的。优化的目标一般是加快响应速度。

吞吐量

吞吐量关注的是在一定时间内,应用程序或系统可以完成的工作量。举个例子:

  • 给定时间,完成的事物数量。
  • 一个小时内,一个批处理可以完成的 job 数量。
  • 一个小时内,数据库可以完成的查询量。

长时间的停顿对于关注吞吐量的应用来说,是可以接受的。因为关注的是一个更长时间的的工作效率,而不是尽快结束一个请求。

3. G1 收集器

G1 收集器(Garbage-First Collector)是一个适合服务端,多处理器,大内存的场景。G1 收集器可以很大概率的满足预期的停顿时间,同时实现高吞吐。G1 收集器在 JDK 7 update 4 之后就已经支持了。G1 收集器设计主要用于以下应用:

  • 可以与 CMS 收集器等应用程序线程同时运行。
  • 在较短的停顿时间内,完成空闲内存碎片的整理。
  • 需要更可预测的 GC 暂停持续时间。
  • 不想牺牲过多的吞吐量。
  • 不需要更大的 Java 堆(译者:可参考复制算法)。

G1 计划作为并发商标扫描收集器(CMS)的长期替代品。 比较 G1 和 CMS,有一些差异使得 G1 成为更好的解决方案。 一个区别是 G1 是一个压缩算法的实现。 G1 充分压缩空间以避免使用细粒度的自由列表进行分配,而是依赖于区域。 这大大简化了收集器的实现,并且大部分消除了潜在的碎片问题。 此外,G1 提供比 CMS 收集器更多的可预测的垃圾收集暂停,并允许用户指定所需的暂停目标。

G1 概述

之前的垃圾收集器(serial, parallel, CMS)都会把堆构造成三个区域:新生代,老年代,永久代。

所有的对象都在在其中一个块里死亡。

而 G1 收集器采用一个不一样的方式来划分堆内存。

堆被分割成一组相等大小的堆区域,每个区域都是连续的虚拟内存范围。 每个区域被分配成 eden, survivor 或者 old,但是他们没有固定的大小。 这提供了更大的内存使用灵活性。

在执行垃圾收集时,G1 的运行方式类似于 CMS 收集器。 G1 执行一个并发的全局标记阶段来确定整个堆中对象的活性。 标记阶段完成后,G1 知道哪些区域大部分是空的。 它首先收集这些地区,这往往产生大量的自由空间。 这就是为什么这种垃圾收集方法称为垃圾优先。 顾名思义,G1 将其收集和压缩活动集中在可能充满可回收对象的堆的区域,即垃圾。 G1 使用暂停预测模型来满足用户定义的暂停时间目标,并基于指定的暂停时间目标选择要收集的区域的数量。

由 G1 标记的回收时机成熟的区域就是要被回收的垃圾。 G1 将对象从堆的一个或多个区域复制到堆上的单个区域,并在此过程中压缩并释放内存。 这种撤离在多处理器上并行执行,以减少暂停时间并提高吞吐量。 因此,对于每个垃圾收集,G1 不断地减少碎片,在用户定义的暂停时间内工作。 这超出了以前的两种方法的能力。 CMS(并发标记扫描)垃圾收集器不会执行压缩。 ParallelOld 垃圾收集只执行全堆压缩,导致相当多的暂停时间。

请注意,G1 不是实时收集器。 它以高概率满足设定的暂停时间目标,但不是绝对确定的。 根据以前收集的数据,G1 会估算在用户指定的目标时间内可以收集多少个区域。 因此,收集者具有相当准确的收集区域成本的模型,并且使用该模型来确定在停留时间目标内停留时收集哪些区域和收集多少区域。

注意:G1 具有并发(与应用程序线程一起运行,例如细化,标记,清除)和并行(多线程,例如 stop the world)阶段。 Full GC 仍然是单线程的,但是如果调整得当,应用程序应该可以避免 Full GC。

G1 的内存占用

如果你是从 ParallelOldGC 或者 CMS 迁移到 G1 的话,你会发现,你似乎拥有了一个更大内存。这主要与 “统计” 数据结构有关,例如 Remembered Sets 和 Collection Sets。

Remembered Sets 或者 RSets 追踪对象应用在哪里区域里。每个堆的区域都有一个 TSet。RSet 可以并行的,独立的手机一个区域的对象引用。RSets 的内存占用少于 5%。

Collection Sets 或者 CSets 将会在一个 GC 中被回收。所有活着的对象会被疏散(copied/moved)。CSets 可以是 Eden, survivor 和 old generation。CSets 对内存的占用少于 1%。

推荐使用 G1 的场景

G1 的第一个关注点就是为运行应用程序的用户提供一个解决方案,这些应用程序需要能保证有限 GC 延迟,并且是个大堆。 这意味着堆大小约 6GB 或更大,稳定可预测的暂停时间低于 0.5 秒。

现在使用 CMS 或者 ParallelOldGC 垃圾收集器运行的应用程序如果应用程序具有以下一个或多个特性,将有益于切换到 G1。

  • Full GC 持续时间太长或太频繁。
  • 对象分配率或提升率明显不同。
  • 不想要长时间 GC 停顿(超过 0.5 到 1second)

注意:如果你使用的是 CMS 或者 ParallelOldGC,并且你的应用也没有经历过长时间的 GC 停顿,你完全可以保持不变(译者:不需要为了用 G1 还来折腾自己,何必呢)。就算不使用 G1 收集器,你依然可以使用最新的 JDK。

4. 复习 CMS 收集器

回顾分代 GC 和 CMS

并发标记扫描(CMS)收集器(也称为并发低暂停收集器)收集终身代。 它试图通过与应用程序线程同时执行大部分垃圾收集工作来尽量减少由于垃圾收集造成的暂停。 通常情况下,并发的低暂停收集器不会复制或压缩活动对象。 垃圾收集完成时不移动活动对象。 如果碎片成为问题,请分配一个更大的堆。

注意:年轻一代的 CMS 收集器使用与并行收集器相同的算法。

CMS 的收集阶段

CMS 在收集老年代时,会执行下面的步骤:

阶段 描述
1、初始化标记(Stop the World) 老一代的对象被 “标记” 为可达,包括年轻一代可能到达的对象。停顿时间一般较短。
2、并发标记 在应用程序线程执行时,并发的遍历老年代对象,生成可达对象的对象图。这个可达性分析在阶段 2,3,5 都会执行,并且被扫描到的对象都会被立即标记成活着。
3、重新标记(Stop the World) 查找并发标记阶段错过的对象,也就是在收集器完成了对对象的跟踪后,然后 Java 应用程序线程更新的对象。
4、并发清除 收集那些在标记阶段已经被标记为不可达的对象。死亡对象会被添加到 Free List 中,以供后续分配使用。在这个时候可能会对死对象进行合并。注意,不会移动活着的对象。
5、重置 清空这一次收集的统计信息,为下次收集做准备。

复习垃圾回收的步骤

1. CMS 的堆结构

堆被拆成 3 个部分。

新生代被拆成 Eden 和两个 suvivor 区域。老年代是一个连续的空间。一般情况下不会进行对象整理(译者:整理内存碎片),除非是进行一次 Full GC。

2. Young GC 怎么工作

新生代被标记成绿色,老年代是蓝色(译者:希望你不是蓝绿色盲)。如果你的应用程序已经运行了一段时间之后,你的虚拟机内存看起来应该是这个样子。在老年代,内存是很分散的。

使用 CMS 时,老年代的对象会在适当的时候被回收掉,再次强调,除非进行一次 Full GC,否则不会整理活着的对象的。

3. 新生代收集

活着的对象会从 Eden 区和 suvivor 被复制到另一个 suvivor 区。如果对象的年龄已经达到了阈值,就会晋升到老年代。

4. Young GC 之后

在一次 Young GC 之后,Eden 区和其中一个 suvivor 会被清空。

图中,深蓝色的是刚刚从新生代晋升到老年代的对象。新生代中绿色的对象是还没有达到晋升条件的对象(译者:突然感觉我们就是一个个对象,如果没有被回收,熬啊熬,就会晋升,哈哈~)。

5. CMS 老年代收集

有两个阶段会 Stop the World:初始标记,重新标记。当老年代的对象空间占用量达到一个阈值,CMS 就拉开帷幕了。

(1)初始标记会有一个短暂的停顿,用来标记可达对象。(2)并发标记阶段是在应用程序执行时,并发的标记活着的对象。然后是(3)重新标记,找到(2)阶段遗漏的活着的对象。

6. 老年代收集——并发清除

释放掉之前几个阶段都没有标记的对象,不会整理内存。

注意:未标记对象 == 死对象

7. 老年代收集——清除之后

在阶段(4)收集之后,你可以看到很多对象都被释放了。你也可以注意到内存碎片现象还是存在。(译者:我实在是受不了了,这句话已经出现几万次了)

然后 CMS 完成(5)重置工作,等待着下一次 GC 的到来。

5. 一步一步走近 G1

G1 收集器分配堆内存和以往的不一样了。

1. G1 堆结构

堆内存是一个被拆分成很多固定大小的内存区域。

每个区域的大小是 JVM 启动时决定的。JVM 通常会化成出 2000 个区域,每个区域大小是 1 ~ 32Mb。

2. G1 内存分配

每个小的区域代表 Eden,suvivor 或者 old。

图片上的颜色展现了,每个区域代表的意义。收集时,会把活的对象从一个区域转移到另一个区域。每个区域可以并行(Stop the World)或者不并行的收集。

每个小的区域可以代表 Eden,suvivor 或者 old。除此之外,还有第四种类型的区域,用来存储大对象。一般是大小超过单个区域 50% 的对象会被分配到第四种区域里。这第四种区域是连续的很多个区域的集合。第四种区域就是我们看到的未分配的区域。

注意:在写这篇文章的时候,大对象收集还没有最优化,所以,建议尽量避免这种大对象的分配。

3. G1 中的新生代

堆内存被拆分成 2000 个小区域,大小最小是 1Mb,最大是 32Mb。蓝色代表老年代,绿色代表新生代。

注意:不需要和以前的收集器一样,把新生代,来年代分配在连续的内存上,在 G1 下,是新生代和老年代是可以分散的。

4. G1 中的 Young GC

活着的对象会被转移(复制 / 移动)到另一个或多个 suvivor 区域。如果年龄到了阈值,就会被分配到 Old 区域。

这个过程是 Stop the World 的。这个过程会统计很多信息,比如 Eden 大小,suvivor 大小,还有这次收集的停顿时间等等,这是为了下一次收集做准备。

这种方式,可以很容易的 resize(重新定义大小) 各个区域的大小。

5. G1 的 Young GC 之后

活着的对象被转移到其他 suvivor 或者 old 区域了。

总结一下,G1 的 Young GC 的特点:

  • 堆被拆分成多个区域。
  • 新生代有一些并不连续的区域组成。这样可以很容易的扩容或收缩新生代的大小。
  • Young GC 会 Stop the World。
  • Young GC 是多线程并行的。
  • 活着的对象会被复制移动到 suvior 或者 old 区域。

G1 的老年代收集

和 CMS 一样,G1 也是被设计成一款低停顿的 GC 收集器。下面的表格描述了 G1 的老年代收集阶段。

G1 收集阶段 —— 并发标记循环阶段

G1 的老年代收集步骤如下,请注意,其中有一些步骤是 Young GC 的一部分。

阶段 描述
1、初始标记(Stop the World) 这会 Stop the World。他会搭着 Young GC 的顺风车,顺便标记那些新生代 (根区域 / root regions) 可以引用到的老年代中的对象。
2、根区域扫描 扫描新生代,找到老年代中哪些对象被新生代中的对象引用。这个阶段不会中断应用程序的执行。这个阶段必须在 Young GC 发生之前完成。
3、并发标记 找到整个堆中的活着的对象。这个和应用程序并发执行。但是,这个阶段是可能被 Young GC 中断的。
4、重新标记(Stop the World) 完成活对象的标记。使用 SATB 算法(snapshot-at-the-beginning)(这个算法比 CMS 使用的算法快很多)
5、清除(Stop the World 也是并发) 1. 统计活对象和完全空闲的区域 (Stop the World);2. 清空 RSets(Stop the World);3. 重置空闲区域,并且回收到 Free List 上(并发)
*、复制(Stop the World) Stop the World,把活着的对象复制移动到新的未使用的区域。如果只疏散了新生代,那么日志是GC pause (young),如果新生代和老年代都疏散了,日志记为GC Pause (mixed)

我们大约了解了各个阶段的定义,现在我们来仔细看看每一步究竟是干什么的。

6. 初始标记阶段

初始标记是搭着 Young GC 的顺风车一起执行的,看 GC 日志的话,是GC pause (young)(inital-mark)

7. 并发标记阶段

如果有空区域(标记为 “X”,也就是里面的对象都死了)被发现,那么就在重新标记阶段直接移除。同样的,这些信息也会被统计,用来优化下一次 GC。

8. 重新标记阶段

空区域会被直接移除回收。并且计算出所有区域的对象的活跃度(liveness)。

9. 复制 / 清除阶段

G1 收集器会选择对象活跃度最低的区域进行收集。新生代和老年代同时被回收。这种情况下,GC 日志是GC pause (mixed)。这样,新生代和老年代同事被回收了。

10. 复制 / 清除阶段之后

选中的区域被回收,并压缩之后,就是图中深蓝色的和深绿色的。

总结老年代 GC

G1 的老年代 GC 的特点是:

  • 并发标记阶段
    • 在应用程序运行时,并发的计算出各个区域的活跃度。
    • 根据活跃度判断出哪些区域是最值得回收的。
    • 没有类似 CMS 的清除阶段。
  • 重新标记阶段
    • 使用 Snapshot-at-the-Beginning (SATB) 算法,这个算法比 CMS 的算法更高效。
    • 完全空的区域会被回收。
  • 复制 / 清除阶段
    • 新生代和老年代同时被回收。
    • 老年代的选择是根据活跃度来确定的。

6. 命令行选项和最佳实践

基本的命令行

为了使用 G1 收集器,我们需要使用-XX:+UseG1GC

这里我们用 demo 来演示(首先你需要进入你 demo 的目录demo/jfc/Java2D下),

1
java -Xmx50m -Xms50m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar  Java2Demo.jar

主要参数介绍

-XX:+UseG1GC——告诉 JVM 使用 G1 收集器。
-XX:MaxGCPauseMillis=200——设置一个最大停顿时间。这是个软目标,也就是 JVM 会尽最大的努力去满足你的目标(译者:实在满足不了,你也拿他没办法)。因此,有时候可能无法达到你的要求。默认值是 200ms。
XX:InitiatingHeapOccupancyPercent=45——堆总量使用比例到达这个值时,开始一趟 GC。是堆总量的占用比例,而不是某一个代的占用比例。0 代表一直循环执行 GC,默认值是 45。

最佳实现

这里有一些使用 G1 的最佳实践的建议。

不要设置新生代的容量

如果你使用-Xmn来指定新生代大小,干预 G1 的行为(译者:G1 就会很生气,后果很严重)。

  • G1 将不会遵从你预期的停顿时间,也就是说,这个选项会关闭-XX:MaxGCPauseMillis
  • G1 将不能动态的扩展和收缩你的新生代,因为已经指定了。

使用响应时间来作为标准

不要使用平均响应时间来设置XX:MaxGCPauseMillis=<N>,考虑使用你期望的响应时间的 90% 甚至更高的值来设置。也就是说 90% 的用户 (客户端 /?) 请求响应时间不会超过预设的目标值。因为,这个值只是一个目标值,并不能精确保证满足。

转移失败?

对 survivors 或 promoted objects 进行 GC 时如果 JVM 的 heap 区不足就会发生晋升失败 (promotion failure)。堆内存不能继续扩充, 因为已经达到最大值了。可以使用-XX:+PrintGCDetails,这样在转移失败时,会打印 to-space overflow。这种操作很昂贵!

  • GC 任然要继续,所以空间必须被释放。
  • 拷贝失败的对象必须放到合适的地方。
  • CSet 区域中任何更新过的 RSets 都必须重新生成。
  • 所有这些操作代价都是很大的。

如何避免转移失败?

  • 增大堆内存。
    • 增大-XX:G1ReservePercent=n,默认是 10.
    • G1 使用一个保留的内存,创建出一个假的内存上限,当内存失败时,就会使用这个保留的内存。(译者:凡事留一线,日后好相见)
  • 更早的执行 GC。
  • 使用-XX:ConcGCThreads=n来增加 GC 的执行线程。

完整的 G1 命令行选项

下面给出 G1 的完整命令行选项,使用时,请记住上面的最佳实践。

选项和默认值 描述
-XX:+UseG1GC 使用 G1 收集器
-XX:MaxGCPauseMillis=n 设置一个预期的停顿时间,记住这只是个软目的,JVM 会尽力去实现
-XX:InitiatingHeapOccupancyPercent=n 启动并发 GC 周期时的堆内存占用百分比. G1 之类的垃圾收集器用它来触发并发 GC 周期, 基于整个堆的使用率, 而不只是某一代内存的使用比. 值为 0 则表示 “一直执行 GC 循环”. 默认值为 45.
-XX:NewRatio=n 新生代和老年代的大小比例(new/old),默认是 2
-XX:SurvivorRatio=n eden/suvivor 的比例,默认是 8
-XX:MaxTenuringThreshold=n 对象晋升的年龄,默认是 15
-XX:ParallelGCThreads=n 收集器并发阶段使用的线程数。默认值是取决于 JVM 运行的平台
-XX:ConcGCThreads=n 设置收集器的线程数。默认值是取决于 JVM 运行的平台
-XX:G1ReservePercent=n 设置 G1 保留内存,防止转移失败
-XX:G1HeapRegionSize=n G1 收集器把堆内存细分成很多个大小一致的小区域。这个选项是设置每个区域的大小默认值是根据堆的总量,算出的。范围是 1 Mb ~ 32 Mb
-------------本文结束感谢您的阅读-------------
Dean Wang wechat