深入理解Java虚拟机(二)-垃圾收集

垃圾收集

垃圾收集主要是针对堆和方法区进行。

深入理解Java虚拟机(一)-Java内存区域中知道,在运行时数据区域中,程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,与线程生命周期相同,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。

对象存活判断

引用计数算法

为每个对象添加一个引用计数器,新增一个引用时计数器加1,引用释放时计数器减1,计数器为0时该对象可以被回收。

引用计数法实现简单且高效,但无法解决对象之间相互循环引用的问题,正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。例如:

1
2
3
4
5
6
7
8
9
10
11
public class Test {

public Object instance = null;

public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.instance = b;
b.instance = a;
}
}

可达性分析算法

这个算法的基本思路就是通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

可作为GC Roots的对象包括下面几种:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI引用的对象。

finalize

类似 C++ 的析构函数,用于关闭外部资源。

对象自救

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选的条件时此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”————即死亡,被回收。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程中执行它。而finalize()方法是对象逃脱死亡命运的最后一个机会,稍后GC将对F-Queuezhongde对象进行第二次小规模的标记,如果对象要在finalize()方法中成功拯救自己————只要重新与引用链上的任何一个对象建立关联即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class FinalizeEscapeGC {
// 用于自救的类变量
private static FinalizeEscapeGC SAVA_HOOK;

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("当前对象: " + this + " 在 " + Thread.currentThread() + " 线程执行 finalize() 方法");
// 把当前对象( this )赋值给某个类变量, 重新与引用链建立引用
FinalizeEscapeGC.SAVA_HOOK = this;
}

public static void main(String[] args) throws Throwable {
// 创建一个对象 FinalizeEscapeGC@3654919e, SAVA_HOOK 引用该对象
SAVA_HOOK = new FinalizeEscapeGC();

System.out.println("第一次自救");
SAVA_HOOK = null; // 失去引用
System.gc(); // 运行垃圾回收器
Thread.sleep(100); // 让 GC 相关线程先走
if (SAVA_HOOK != null) {
System.out.println(SAVA_HOOK + " 对象自救成功");
} else {
System.out.println("对象已被回收");
}

System.out.println("\n第二次自救");
SAVA_HOOK = null; // 失去引用
System.gc(); // 运行垃圾回收器
Thread.sleep(100); // 让 GC 相关线程先走
if (SAVA_HOOK != null) {
System.out.println(SAVA_HOOK + " 对象自救成功");
} else {
System.out.println("对象已被回收");
}
}
}

代码中有两段完全一样的代码片段,执行结果却是一次逃脱一次失败,因为任何一个对象的finalize()方法都会被系统自动调用一次,在对象面临下一次回收,finalize()方法不会被再次执行,因此第二次自救失败了。

注意
finalize()方法能做的所有工作,使用try-finally活着其它方式可以做得更好、更及时,并且finalize()方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

1.强引用

类似 Object obj = new Object();这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

2.软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。

使用 SoftReference 类来创建软引用。

1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联

3.弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

4.虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

回收方法区

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

主要是对常量池的回收和对类的卸载。

为了避免内存溢出,在大量使用反射、动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要满足以下三个条件,但是满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

垃圾收集算法

标记-清除算法

首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足之处有两个:一个是效率问题,标记和清除两个过程效率都不高;另一个是空间问题,标记清除之后会产生大量的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发一次垃圾收集动作。

复制算法

它将可用的内存按容量划分为大小相等的两块,每次只是用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都对整个半区进行内存回收,也不用考虑碎片等复杂问题。

主要不足是只使用了内存的一半。

现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

标记-整理算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,,那就选用复制算法,只需要付出少量存货对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

Serial 收集器

Serial收集器是最基本、发展历史最悠久的收集器。通过名字可以知道这个收集器是一个单线程收集器,单线程的意义不仅仅说明它只会使用一个CPU或一条手机线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束————Stop the world。

Serial/Serial Old收集器运行示意图

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本。

Parallel Scavenge 收集器

Parallel是一个多线程收集器。其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。

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

GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,收集时间缩短,但同时垃圾回收也变得频繁,导致吞吐量下降。

可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Serial Old收集器

Seriol Old是Serial收集器的老年代版本,同样是一个单线程收集器。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,同样是一个多线程收集器。

CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。

分为以下七个流程:

  • initial Mark:这个是CMS两次stop-the-wolrd事件的其中一次,这个阶段的目标是标记那些直接被GC root引用或者被年轻代存活对象所引用的所有对象。
  • Concurrent Mark:在这个阶段收集器会根据上个阶段找到的GC Roots遍历查找,然后标记所有存活的对象,也就是进行GC Roots Tracing的过程。这个阶段会与用户的应用程序并发运行,因此在标记期间用户的程序可能会改变一些引用,并不是老年代所有的存活对象都会被标记。
  • Concurrent Preclean:这也是一个并发阶段,与应用的线程并发运行。在并发运行的过程中,一些对象的引用可能会发生变化,但当种情况发生时,JVM会将包含这个对象的区域(Card)标记为Dirty,这也就是Card Marking。在这个阶段,能够从Dirty对象到达的对象也会被标记,这个标记做完之后,dirty card标记就会被清除了。
  • Concurrent Abortable Preclean:这也是一个并发阶段,这个阶段是为了尽量承担stop-the-world中最终标记阶段的工作。
  • Final Remark:这是第二个STW阶段,也是CMS中的最后一个,这个阶段的目标是标记老年代所有的存活对象,由于之前的阶段是并发执行的,gc线程可能跟不上应用程序的变化,为了完成标记老年代所有存活对象的目标,STW就非常有必要了。
  • Concurrent Sweep:这个阶段清除那些不再使用的对象,回收它们的占用空间为将来使用。
  • Concurrent Reset:这个阶段会重设CMS内部的数据结构,为下次的GC做准备。

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

CMS 收集器有以下三点缺点:

  • CMS 收集器对CPU资源特别敏感,在并发阶段,它虽然不会导致用户线程停顿,但是因为占用了一部分线程而导致应用程序变慢,总吞吐量降低
  • 无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 而导致另一次Full GC的产生。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,是当今收集器技术发展的最前沿成果之一。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

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

G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

参考资料

  • 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.