深入理解 Java 虚拟机(二):JVM 垃圾收集器

概述

从上一篇文章中,我们知道了内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构能确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

上一篇文章讲述了 Java 内存区域与内存溢出异常,本章将介绍垃圾收集的算法,然后分析几款 JDK1.7 中提供的垃圾收集器特点以及运作原理。

说明

Java GC(Garbage Collection,垃圾收集,垃圾回收)机制对 JVM(Java Virtual Machine)中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,永不停息(Nerver Stop)的保证 JVM 中的内存空间,防止出现内存泄露和溢出问题。我们将从 4 个方面学习 Java GC 机制:

Who:哪些是不再使用要被当做 “垃圾” 回收处理的对象?也就是要确定垃圾对象。
Where:在哪里执行垃圾回收?明确要清理的内存区域。
When:什么时候执行 GC 操作?即 JVM 触发 GC 的时机。
How:怎么样进行垃圾对象处理?即 GC 的实现算法。

判断对象存活(判别算法或搜索算法)

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在堆进行垃圾回收前,第一件事就是要确定这些对象之中哪些还 “存活” 着,那些已经“死去”(即不可能再被任何途径使用的对象)。

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它是,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。

客观地说,引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软公司的 COM(Component Object Model)技术、使用 ActionScript 3 的 Flash Player、Python 语言和游戏脚本领域被广泛应用的 Squirrel 中都使用了引用计数算法进行内存管理。但是,至少主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法

在主流的商用程序语言中(Java、C#)的主流实现中,都是称通过可达性分析(Reachability Analusis)来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连,则证明此对象是不可用的。

-w467

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈 JNI(Native 方法)引用的对象

引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判断对象是否存活都与 “引用” 有关。在 JDK 1.2 以前,Java 中引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似 “Ojbect obj = new Object()” 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些还在但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。
  • 弱引用也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉之被弱引用关联的对象。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDk 1.2 之后,提供了 PhantomReference 类来实现虚引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 强引用: 被强引用关联的对象不会被回收, 使用 new 一个新对象的方式来创建强引用.
Object obj = new Object();

// 2. 软引用: 被软引用关联的对象只有在内存不够的情况下才会被回收, 使用 SoftReference 类来创建软引用.
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; // 使对象只被软引用关联

// 3. 弱引用: 被弱引用关联的对象一定会被回收, 也就是说它只能存活到下一次垃圾回收发生之前, 使用 WeakReference 类来创建弱引用.
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<>(obj);
obj = null;

// 4. 虚引用: 又称为幽灵引用或者幻影引用, 一个对象是否有虚引用的存在, 不会对其生存时间造成影响, 也无法通过虚引用得到一个对象. 为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知. 使用 PhantomReference 来创建虚引用.
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<>(obj, null);
obj = null;

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是 “非死不可” 的,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行 finalize()方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的 “执行” 是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在 finalize()方法中执行缓慢,或者发生了死循环,将很可能导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个变量或者对象的成员变量,那在第二次标记时它将被移除出 “即将回收” 的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

finalize()方法尽量避免使用它,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,所以建议完成可以忘掉 Java 语言中这个方法的存在。

回收方法区

很多人认为方法区(或者 HotSpot 虚拟机中的永久代)是没有垃圾收集的,Java 虚拟机规范中确实说可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的 “性价比” 一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分的内容:废弃常量和无用的类。回收废弃常量和回收 Java 堆中的对象非常相似。以常量池中字面量的回收为例,假如一个字符串 “abc” 已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做 “abc” 的,换句话说,就是没有任何 String 对象引用常量池中的 “abc” 常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个 “abc” 常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否是 “废弃常量” 比较简单,而要判定一个类是否是 “无用的类” 的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是“无用的类”:

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任务地方通过反射访问该类的方法。

虚拟机可以堆满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

标记 - 清除算法

最基础的收集算法是 “标记 - 清除”(Mark-Sweep)算法,分为“标记” 和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

-w673

复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清除掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

-w683

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是 “朝生夕死” 的,所以并不需要按照 1:1 的比列来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另外一块 Survivor 空间上,最后清除掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比列是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存会被“浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

标记 - 整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种 “标记 - 整理”(Mark—Compact)算法,标记过程仍然与“标记 - 清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

-w654

分代收集算法

当前商业虚拟机的垃圾收集都采用 “分代收集”(Generrational Collection)算法,根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记 - 清除” 或者 “标记 - 整理” 算法来进行回收。

垃圾收集器

-w447

如图展示了 7 种用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

Serial 收集器

Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,但它的 “单线程” 的意义并不仅仅说明它只会使用一个 CPU 或者一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

-w636

从 JDk 1.3 开始,一直到现在的 JDk 1.7,HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep(CMS)乃至 GC 收集器的最前沿成果 Garbage First(G1)收集器,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除。

Serial 收集器仍然是虚拟机运行在 Client 模式下的默认新生代收集器。它也有着优于其他收集器的地方:简答而高效,对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew 收集器

ParNew 收集器是一个新生代收集器,使用复制算法的收集器,又是并行 [1] 的多线程收集器。

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样。

-w600

ParNew 收集器是许多运行在 Server 模式下虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。ParNew 收集器也是使用 - XX:UseConcMarkSweepGC 选项后默认新生代收集器,也可以使用 - XX:+UseParNewGC 选项来强制指定它。

ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的环境下,可以使用 - XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器,使用复制算法的收集器,又是并行的多线程收集器。

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集器时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运行而不需要太多交互的任务。

由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为 “吞吐量优先” 收集器。Parallel Scavenge 收集器参数 - XX:+UseAdaptiveSizePolicy 打开后就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是单线程收集器,使用 “标记 - 整理” 算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用 [2] ,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failue 时使用。

-w681

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记 - 整理” 算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge 收集器无法与 CMS 收集器配置工作)。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果。

直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加上 Parallel Old 收集器。

-w624

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类用用的需求。

CMS 收集器是基于 “标记 - 清除” 算法实现的,整个过程分为 4 个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程就是与用户线程一起并发执行的。

-w654

CMS 收集器具备并发收集、低停顿等优点,但它有以下 3 分明显的缺点:

  • CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量 + 3)/4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如 2 个)时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%。
  • CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。由于 CMS 并发清除阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为 “浮动垃圾”。也是由于垃圾收集阶段用户线程还需要运行,那也就还需要预留足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。在 JDK 1.6 中,CMS 收集器的启动阀值(-XX:CMSInitiatingOccupancyFraction)已经提升至 92%。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure” 失败,这次虚拟机将启动后备预案:临时启用 Serial Ols 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
  • CMS 是一款基于 “标记 - 清除” 算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。为了解决这个问题,CMS 收集器提供了一个 - XX:+UseCMSCompactAtFullCollection 开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但是停顿时间不得不变长。虚拟机设计者还提供了另外一个参数 - XX:CMSFullGCsBeforeCompaction,这个参数用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为 0,表示每次进行 Full GC 时都进行碎片整理)。

G1 收集器

G1(Grabage-First)收集器是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是未来可以替换掉 JDK1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点:

  • 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop—The-World 停顿时间。
  • 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。G1 收集器能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
  • 空间整合:与 CMS 的 “标记 - 清除” 算法不同,G1 从整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
  • 可预测的停顿时间:降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫米的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分 Region 的集合。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的收集收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 RememberedSer 来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在堆 Reference 类型的数据进行写操作时,会产生一个 WriteBarrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之间(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 吧相关引用信息记录到被引用对象所属的 Region 的 RemeberadSet 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Date Counting and Evacuation)

初始标记阶段仅仅是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这个阶段需要停顿线程,但耗时很短。并发标记阶段是从 GC Roots 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时比较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运行而导致标记苍生变化的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最后标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。最后在筛选阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用用户可控制的,而且停顿用户线程将大幅提高收集效率。

-w706

GC 日志

理解 GC 日志

我们通过使用下面的启动参数让 JVM 打印出详细的 GC 日志:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps

1
2
3
4
2019-01-08T23:50:22.060-0800: 8.723: [GC (Metadata GC Threshold) [PSYoungGen: 129014K->10749K(195072K)] 138630K->27347K(247296K), 0.0257816 secs] [Times: user=0.05 sys=0.01, real=0.02 secs] 
2019-01-08T23:50:22.085-0800: 8.749: [Full GC (Metadata GC Threshold) [PSYoungGen: 10749K->0K(195072K)] [ParOldGen: 16597K->22228K(91136K)] 27347K->22228K(286208K), [Metaspace: 34001K->34001K(1079296K)], 0.1026725 secs] [Times: user=0.24 sys=0.01, real=0.10 secs]
2019-01-08T23:50:24.385-0800: 11.049: [GC (Allocation Failure) [PSYoungGen: 184320K->13282K(199680K)] 206548K->35518K(290816K), 0.0257038 secs] [Times: user=0.04 sys=0.01, real=0.03 secs]
2019-01-08T23:50:27.125-0800: 13.789: [GC (Allocation Failure) [PSYoungGen: 197602K->16376K(252928K)] 219838K->38723K(344064K), 0.0423354 secs] [Times: user=0.07 sys=0.02, real=0.04 secs]

2019-01-08T23:50:24.385-0800:[1] 11.049[2]: [GC[3] (Allocation Failure)[4] [PSYoungGen[5]: 184320K->13282K(199680K)[6]] 206548K->35518K(290816K)[7], 0.0257038 secs[8]] [Times: user=0.04 sys=0.01, real=0.03 secs][9]

[1]. 2019-01-08T23:50:24.385-0800 - GC 事件(GC event)开始的时间点
[2]. 11.049 - GC 事件的开始时间,相对于 JVM 的启动时间,单位是秒(Measured in seconds)
[3]. [GC - 用来区分(distinguish)是 Minor GC 还是 Full GC 的标志(Flag)。这里的 GC 表明本次发生的是 Minor GC。“[GC” 和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代 GC 还是老年代 GC 的。如果有 “Full”,说明这次 GC 是发生了 Stop-The-World 的,假如新生代收集器 ParNew 的日志出现“[Full GC”,说明 GC 出现了分配担保失败之类的问题,所以才导致 STW。如果是调用 System.gc() 方法所触发的收集,那么将显示“[Full GC(System)”。
[4]. (Allocation Failure) - 引起垃圾回收的原因。本次 GC 是因为年轻代中没有任何合适的区域能够存放需要分配的数据结构而触发的。
[5]. PSYoungGen - 表明本次 GC 发生在年轻代并且使用的是 Parallel Scavenge 垃圾收集器。“[DefNew”、“[Tenured”、“[Perm”表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集是密切相关的,例如上面样例所使用的 Serial 收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是 ParNew 收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用 Parallel Scavenge 收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。
“ParOldGen”Parallel Scavenge 收集器配套的老年代。
[6]. 184320K->13282K(199680K) - GC 前该内存区域已使用容量 ->GC 后该内存区域已使用容量(该内存区域总容量)
[7]. 206548K->35518K(290816K) - GC 前 Java 堆已使用容量 ->GC 后 Java 堆已使用容量(Java 堆总容量)
[8]. 0.0257038 secs - 表示该内存区域 GC 所占用的时间,单位是秒
[9]. [Times: user=0.04 sys=0.01, real=0.03 secs] - 这里面的 user、sys 和 real 与 Linux 的 time 命令所输出的时间含义一致,分别代表用户态消耗的 CPU 时间、内核态消耗的 CPU 事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU 时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘 I/O、等待线程阻塞,而 CPU 时间不包括这些耗时,但当系统有多 CPU 或者多核的话,多线程操作会叠加这些 CPU 时间,所以读者看到 user 或 sys 时间超过 real 时间是完全正常的。由于串行垃圾收集器(Serial Garbage Collector)只会使用单个线程,所以 real time 等于 user 以及 system time 的总和。

20131002100114703

20131002100112187

jvm 设置滚动记录 GC 日志

1
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/var/www/logs/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=3 -XX:GCLogFileSize=512k

[1]. -XX:UseGCLogFileRotation - 打开或关闭 GC 日志滚动记录功能,要求必须设置 -Xloggc 参数
[2]. -XX:NumberOfGCLogFiles - 设置滚动日志文件的个数,必须大于等于 1
[3]. -XX:GCLogFileSize - 设置滚动日志文件的大小,必须大于 8k;当前写日志文件大小超过该参数值时,日志将写入下一个文件
[4]. -XX:+PrintGCDetails - 输出 GC 的详细日志
[5]. -XX:+PrintGCTimeStamps - 输出 GC 的时间戳(以基准时间的形式)
[6]. -XX:+PrintGCDateStamps - 输出 GC 的时间戳(以标准时间的形式)
[7]. -Xloggc:/var/www/logs/gc.log - 默认情况下 GC 日志直接输出到标准输出,不过使用 - Xloggc:filename 标志也能修改输出到某个文件。除非显式地使用 - PrintGCDetails 标志,否则使用 - Xloggc 会自动地开启基本日志模式(文件路径必须存在,否则不能产生 GC 日志)
[8]. -XX:+PrintHeapAtGC - 了解堆的更详细的信息

在运行时开启 / 关闭 GC 日志

jinfo

在 JDK bundle 中隐藏着一个精悍的小工具—jinfo。作为一个命令行工具,jinfo 用于收集正在运行的 Java 进程的配置信息。jinfo 吸引眼球的地方在于,它能通过 - flag 选项动态修改指定的 Java 进程中的某些 JVMflag 的值。虽然这样的 flag 数量有限,但它们偶尔能够帮助到你。

通过以下的命令你便能看到 JVM 中哪些 flag 可以被 jinfo 动态修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
chenxingxingdeMacBook-Pro:gc chenxingxing$ java -XX:+PrintFlagsFinal -version|grep manageable
intx CMSAbortablePrecleanWaitMillis = 100 {manageable}
intx CMSTriggerInterval = -1 {manageable}
intx CMSWaitDuration = 2000 {manageable}
bool HeapDumpAfterFullGC = false {manageable}
bool HeapDumpBeforeFullGC = false {manageable}
bool HeapDumpOnOutOfMemoryError = false {manageable}
ccstr HeapDumpPath = {manageable}
uintx MaxHeapFreeRatio = 100 {manageable}
uintx MinHeapFreeRatio = 0 {manageable}
bool PrintClassHistogram = false {manageable}
bool PrintClassHistogramAfterFullGC = false {manageable}
bool PrintClassHistogramBeforeFullGC = false {manageable}
bool PrintConcurrentLocks = false {manageable}
bool PrintGC = false {manageable}
bool PrintGCDateStamps = false {manageable}
bool PrintGCDetails = false {manageable}
bool PrintGCID = false {manageable}
bool PrintGCTimeStamps = false {manageable}
java version "1.8.0_141"
Java(TM) SE Runtime Environment (build 1.8.0_141-b15)
Java HotSpot(TM) 64-Bit Server VM (build 25.141-b15, mixed mode)

如何使用 jinfo

1
2
3
4
5
6
7
8
chenxingxingdeMacBook-Pro:gc chenxingxing$ jps
58800 Jps
58727 Launcher
58728 WorkerMicroService
1002
50155 Launcher
chenxingxingdeMacBook-Pro:gc chenxingxing$ jinfo -flag +PrintGCDetails 58728
chenxingxingdeMacBook-Pro:gc chenxingxing$ jinfo -flag +PrintGC 58728

在 jinfo 中需要打开 - XX:+PrintGC 和 - XX:+PrintGCDetails 两个选项才能开启 GC 日志,这与用命令行参数的方式实现有着细微的差别—如果你通过启动脚本(startup script)来设置参数,仅需 - XX:+PrintGCDetails 即可,因为 - XX:+PrintGC 会被自动打开。

同理,若想关闭 GC 日志功能,只需要执行 jinfo -flag -PrintGCDetails 58728 和 jinfo -flag -PrintGC 58728 命令即可。

垃圾收集器参数总结

-w708
-w705

小知识点

[1]. 查看 JVM 使用的默认的垃圾收集器:
java -XX:+PrintCommandLineFlags -version;或者使用 java -XX:+PrintGCDetails -version 查看GC 的情况。

[2]. 在 JDK7u4 开始的 JDK7u 系列,HotSpot VM 在选择使用 ParallelGC(-XX:+UseParallelGC 或者是 ergonomics 自动选择)的时候,会默认开启 -XX:+UseParallelOldGC。在这个改变之前,即便选择了 ParallelGC,默认情况下 ParallelOldGC 并不会随即开启,默认使用 PS-MarkSweep(Serial-Old) 进行老年代回收。如果需要 ParallelOldGC,要自己通过 -XX:+UseParallelOldGC 去选定。


参考博文

[1].《深入理解 Java 虚拟机:JVM 高级特效与最佳实现》,第 3 章


注脚

[1]. 并发和并行的区别(垃圾收集器上下文语境中):
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。

[2]. Serial Old 和 Ps MarkSweep 的区别:Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接使用了 Serial Old 收集器,但是这个 PS MarkSweep 收集器与 Serial Old 的实现非常接近,所以在官方的许多资料中都是直接以 Serial Old 代替 PS MarkSweep 进行讲解。


深入理解 Java 虚拟机系列


谢谢你长得那么好看,还打赏我!😘
0%