深入Java虚拟机GC篇

引言: JVM GC部分

深入Java虚拟机GC篇

GC 概述

什么是垃圾?什么是GC?

(>_<我是垃圾)

垃圾:运行程序没有任何指针指向的对象(游离的对象)

​ GC(Garbage Collection)垃圾回收,程序运行会产生很多垃圾,所以我们需要时不时对垃圾进行回收,回收他们的内存分配给其他对象使用。

​ 相较于传统的C/C++,GC也是Java语言的优势,程序员不必再去考虑内存的释放,加快开发效率。

​ 对于JVM的运行时数据区,GC的主战场是堆和方法区,对于PC、Stack、本地方法栈都没有GC。

GC相关算法

GC可以分为两个阶段:

  1. 标记阶段:标记出垃圾
  2. 清除阶段:对1中标记的垃圾进行清除

标记阶段算法

标记阶段主要讲两个算法:

  • 引用计数算法
  • 可达性分析算法

引用计数算法

引用计数算法:对每一个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

一个对象被一个指针引用,那么就会使引用计数器+1,如果去除了这个指针,就会-1,为0时则GC就知道,这个对象可以被清理了。

优点:实现简单,垃圾对象便于分别;判定效率高,回收没有延迟性

缺点

  • 需要额外的字段存储计数器,增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,增大了时间开销
  • 最严重的问题:无法处理循环引用的情况,这个缺陷直接导致JVM没有选择这种算法

循环引用,如下

1
2
p -> A -> B -> C
↑________|

如果我们将P->null,你会发现ABC的引用计数器还是1,这样GC就永远也不会去去除他们三个。

可达性分析算法

可达性分析算法(也叫根搜索算法、追踪性垃圾收集)

思想:

  • 根集合对象(GC Roots)为起始点,从上至下的方式搜索被根对象集合所连接的目标对象是否可达

    根集合对象GC Roots:一组必须活跃的引用

  • 存活的对象都会被根节点直接或间接的连接(这条连接的路径叫引用链

  • 如果对象没有被任何引用链连接,则不可达;不可达,则可以回收

可达性分析算法图示

目前,此算法是Java、C#语言所选择的GC标记算法


GC Roots可以是哪些元素?

  • JVM Stack中引用的对象:方法中使用的参数、局部变量等
  • JNI(本地方法栈)引用的对象
  • 方法区中类静态属性引用的对象:静态变量
  • 方法区中常量引用的对象:字符串常量池的引用
  • 所有被同步锁synchronized持有的对象
  • JVM内部的引用:基本数据类型对应的Class对象、常驻的异常对象(NullPointExceptionOutOfMemoryError)、系统类加载器
  • 反应JVM内部情况的JMXBean,JVMTI中注册的回调、本地代码缓存等
  • 除了这些外,根据用户选用的GC不同、当前回收的内存区域不同,还可能有其他对象“临时性”的加入,共同构成完整的GC Roots集合。比如分代收集和局部回收(Partial GC
    • 即:如果是对新生代的回收,那么老年代中有的对象也可以成为GCRoots的元素

技巧:

​ 如果一个指针,指向堆内的对象,但是自己又不在堆内,那么这就是一个GC Root的元素

注意:

  • 使用可达性分析算法,分析工作必须在一个可以保障一致性的快照中进行
  • 这点也是GC时必须STW的原因(即使是CMS收集器(号称不会发生停顿的收集器),在枚举根节点时也是要STW的)

标记阶段的补充:finalization机制

finalize()方法

Java给对象提供了finalization来让开发人员可以进行对象销毁之前的处理

当GC回收器发现一个对象可以被回收时,会先去调用fianlize()方法

1
2
3
4
public class Object {
//....
protected void finalize() throws Throwable { }
}

此方法可以被重写,用于在对象被回收前进行资源释放

但是注意永远不要主动调用finalize()方法:

  • finalize()方法有可能导致对象复活
  • finalize()方法执行时间没有保障,完全由GC线程决定,极端情况下如果不发生GC,那么finalize()方法就没有执行的机会
  • 一个糟糕的finalize()方法会严重影响GC性能(没错,就是说你写的finalize方法)

finalize()可能与C++析构函数相似,但是他们有本质的区别,finalize()方法是基于垃圾回收器的自动内存管理机制,不需要主动调用

对象的三种状态

由于finalize()方法的存在,导致对象有三种状态:

  • 可触及的:从根节点开始,这个对象可达,就是可触及的
  • 可复活的:对象已经不可达,但是有可能在finalize()中复活
  • 不可触及的:对象的finalize()被调用并且没有复活,就进入此状态;此状态的对象不可能复活,因为finalize()只会调用一次

注意只有不可触及状态的对象,才会被回收!

对象是否可以回收

因为存在三种状态,所以我们要判断一个对象是否可回收,要经历两次标记过程

  1. 是否可达,第一次标记
  2. 是否有必要执行finalize()方法
    • 没有重写此方法或此方法已调用过:判定为不可触及
    • 重写了此方法且还未执行:此对象会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法
    • finalize()方法是最后逃离被回收的机会。稍后GC会对F-Queue的对象进行第二次标记。如果finalize()方法中,此对象建立了引用,那么在第二次标记时,这个对象会被移除出即将回收的集合;如果这个对象之后再次出现不可达的情况,那么会直接变为不可触及状态;(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
public class RebackObject {
public static RebackObject object; // 类变量,属于GCroot

@Override
// 只会调用一次
protected void finalize() throws Throwable{
object = this; // 重新建立引用链
}
public static void main(String[] args) {
try{
object = new RebackObject(); // 建立引用链
object = null; // 失去引用链
System.gc(); // 调用GC
System.out.println("第一次 GC");
Thread.sleep(2000); // 等待 finalizer 线程调用finalize方法
if(object == null){
System.out.println("object is dead");
}else {
System.out.println("object still alive");
}
// --------------------------------------------
System.out.println("第二次GC");
object = null; // 再次失去引用链
System.gc();
Thread.sleep(2000);
if(object == null){
System.out.println("object is dead");
}else {
System.out.println("object still alive");
}
}catch (Exception e){
e.printStackTrace();
}
}
}

运行结果如下:

1
2
3
4
第一次 GC
object still alive
第二次GC
object is dead

清除阶段算法

JVM常见的三种垃圾回收算法:

  • 标记-清除算法(Mark-Sweep)
  • 复制算法(Copying)
  • 标记-压缩算法(Mark-Compact)

还有一些其他的算法:

  • 增量收集算法
  • 分区算法

标记-清除算法

一种基础常见的垃圾收集算法。

提出者——J.McCarthy,应用于Lisp语言

执行过程:

当有效内存空间(available memory)被耗尽的时候,就会STW,然后进行两项工作:

  1. 标记:回收器从根节点开始遍历,标记所有被引用的对象(注意:标记的是可达对象
  2. 清除:回收器对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header(对象头)中没有标记为可达对象,则将其回收。

缺点

  • 效率不高
  • GC时需要STW,用户体验差
  • 清理出的空间内存不连续,产生内存碎片,需要维护一个空闲列表

注意:

清除不需要置空,只需要标记为空闲即可。下次使用直接覆盖。

复制算法

算法思想:

将活着的内存空间分为两块:每次只使用一块,在GC时将正在使用的内存中存活对象复制到未被使用的内存块中,之后清除正在使用内存块中的所有对象,交换两个内存的角色,最后完成回收。

优点

  • 没有标记和清除的过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现碎片问题

缺点

  • 需要两倍的内存空间
  • 对于G1这种分拆成为大量rigion的GC回收器,复制而不是移动,意味着GC需要维护region之间对象引用关系,内存占用大,时间开销也大
  • 不利于非垃圾对象多的情况

应用场景:

新生代中,有from区和to区,就应用了这种算法;

新生代大部分都是垃圾对象,而且新生代也不大,完美的契合这种算法。

标记-压缩算法

复制算法利于新生代,但对于老年代这种大内存,需要使用其他算法。

但是标记清除算法效率低下还会产生内存碎片,所以对其进行了改进。

过程:

  1. 标记(同标记-清除算法)
  2. 将所有存活的对象,压缩到内存的一端并按顺序排放,之后清理边界外所有的空间。

也可以称为标记-清除-压缩算法,他与标记-清除算法的区别是:是否对对象进行了移动。

优点

  • 内存连续(对比标记-清除算法)
  • 内存不需要加倍(对比复制算法)

缺点:

  • 效率低于复制算法
  • 移动对象时,如果对象被其他对象引用,还需要调整引用的地址
  • 移动过程需要STW

三种清除算法的对比

Mark-Sweep Mark-Compact Copying
速度 中等 最慢 最快
空间开销 少(有内存碎片) 两倍大小
移动对象

分代收集算法

所谓分代收集算法,就是按照不同的代,使用不同的算法。

目前几乎所有的GC都使用了分代收集算法

  • 年轻代
    • 较小、回收频繁、垃圾多
    • 适用复制算法
  • 老年代
    • 较大、回收次数少、垃圾少
    • 一般由标记-清除算法和标记-压缩算法混合实现

在Hotspot Vm中,CMS回收器采用Mark-Sweep算法,回收效率高。

对于内存碎片问题,CMS采用Mark-Compact算法的Serial Old回收器作为补偿:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old 执行Full GC以达到对老年代内存的整理

增量收集算法

上述所有的清除算法,都会出现STW状态,而长时间的STW严重影响用户体验。

增量收集算法就是为解决STW而提出的一种算法。

基本思想

​ 一次性清理所有垃圾,需要长时间的STW,那么可以让垃圾收集线程和应用程序线程交替执行

​ 每次,垃圾收集线程只收集一小片区域的内存空间,接着就切换到应用程序线程,依次反复,直到垃圾收集完成。

本质上此算法的基础依旧是标记清除算法和复制算法,但是此算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点

  • 线程切换和上下文转换的消耗大,导致垃圾回收的总成本上升,造成系统吞吐量下降。

分区算法

​ 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。

​ 为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
​ 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间
​ 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

三色标记算法

分为白、黑、灰三色,分别标记不可达对象、可达对象、尚未遍历完成的对象

  1. 初始时,将所有对象标记为白色
  2. 将所有GC roots直接关联的对象标记为灰色
  3. 遍历这些直接关联的对象,如果一个对象没有子引用对象,就标为黑色;如果有子引用对象,标为灰色
  4. 重复第3步

可能会有 浮动垃圾漏标的问题:

浮动垃圾:标记前不是垃圾,标记后变为垃圾,导致没有GC掉

漏标:标记前是垃圾,但是标记后不是垃圾了,导致被错误的GC掉

GC相关概念

System.gc()方法

  • 程序员可以使用System.gc()来显示的触发Full GC
  • 这个方法相当于调用Runtime.getRuntime().gc(),实际上System.gc()底层也是这么写的
  • 只是提醒,无法保证对垃圾回收器的调用

demo如下:

1
2
3
4
5
6
7
8
9
10
11
public class GCTest {
public static void main(String[] args) {
new GCTest();
System.gc();
}

@Override
protected void finalize(){
System.out.println("调用了finalize方法!");
}
}

执行结束,也不会打出“调用了finalize方法!”

因为重写finalize会被另一个低优先度的线程,以此证明System.gc()方法只是提醒JVM进行GC,但是GC回收器的选择还是由JVM来操作。

另外JVM有方法System.runFinalization()强制调用失去引用的finalize()方法


还有一些关于GC的demo,便于深入理解GC的时刻

先来两个简单的demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void localVarGC1(){
byte[] buffer = new byte[1024 * 1024 * 5];
//5MB; 注意这里设置太大,有可能直接分配到老年代,然后会被GC掉
System.gc();
//[GC (System.gc()) [PSYoungGen: 10375K->5864K(76288K)]
//[Full GC (System.gc()) [PSYoungGen: 5864K->0K(76288K)] ParOldGen: 8K->5727K(175104K)] 5872K->5727K(251392K)
//可以看出: young GC 没有回收掉buffer,而是Full GC时,将数据放到了老年代(老年代8K->5727K,变大了)
}
public void localVarGC2(){
byte[] buffer = new byte[1024 * 1024 * 5];
buffer = null;
System.gc();
//[GC (System.gc()) [PSYoungGen: 7741K->128K(76288K)]
//[Full GC (System.gc()) [PSYoungGen: 128K->0K(76288K)] [ParOldGen: 5727K->629K(175104K)]
// 可以看出 不可达对象 直接就被YGC了
}

可见,正常情况下,有引用链的对象,在Full GC后,会被转移到老年代;而没有引用链的对象,就直接被回收了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void localVarGC3() {
{
byte[] buffer = new byte[1024 * 1024 * 5];
}
System.gc();
//[GC (System.gc()) [PSYoungGen: 10375K->5896K(76288K)]
//[Full GC (System.gc()) [PSYoungGen: 5896K->0K(76288K)] [ParOldGen: 8K->5728K(175104K)]
// 注意:没有被回收,放在了老年代!是因为buffer还占用着局部变量表的slot槽
}

private void localVarGC4() {
{
byte[] buffer = new byte[1024 * 1024 * 5];
}
int a = 0; // 把槽占用掉
System.gc();
//[GC (System.gc()) [PSYoungGen: 7741K->224K(76288K)]
//[Full GC (System.gc()) [PSYoungGen: 224K->0K(76288K)] [ParOldGen: 5728K->629K(175104K)]
// 这里就被YGC回收掉了!因为槽被占用
}

其中localVarGC3()方法中的对象,在{}中,按理来说应该被回收,但其实没有,因为buffer变量还占用着局部变量表的slot槽;下面的例子将槽复用后,这个对象就被回收掉了

内存溢出与内存泄露

内存溢出OOM:

没有足够的内存使用(即使GC后内存也不够)

造成OOM的原因有两种:

  • JVM堆内存设置大小不够
    • 可能存在内存泄露;也可能堆设置的太小了
  • 创建了大量大对象,并且GC收集不了

报OOM之前,通常会执行GC

  • 如果内存不够,会自动触发一次GC,如果还不够则会报出OOM异常;

  • 如果想创建一个超大的对象,这个对象的内存大小直接超过了堆大小,那么JVM不会去触发GC,而是直接OOM


内存泄露(Memory Leak):

严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露。

​ 实际情况中,一些不太好的实践导致对象的生命周期变长甚至OOM,也算宽泛意义上的“内存泄漏”。

​ 内存泄露不会立即引起程序崩溃,但是一旦发生内存泄露,程序中的可用内存就会被进一步蚕食,直至耗尽所有内存,最终出现OOM异常(内存泄露有可能导致OOM,但并不一定)

内存泄露的例子

  1. 单例模式:单例的生命周期和应用程序一样长,所以单例程序中,如果用一个单例的对象关联外部对象的引用的话,那么这个外部对象是不能被回收的,会导致内存泄漏的产生。
  2. 一些提供close的资源未关闭导致内存泄露:数据库连接、网络连接、io连接等必须手动close,否则是不能被回收的。

可能的内存泄漏的原因

  1. 静态的集合类
1
static List<String> list = new ArrayList<>();

类的变量的生命周期很长,类信息被回收的条件比较苛刻,需要满足三个条件:1、没有任何实例。2、子类也没有任何实例。3、加载该类的类加载器已被回收

注意:类的静态常量一般属于常量池,其实际的位置在堆中,是可以方便回收的。

  1. 单例对象

单例对象一般是static修饰的,如果其含有外部引用,那么也不会被回收。

  1. 数据库连接、IO、Socket没有关闭

连接如果没有调用close方法关闭,是不会被回收的。

  1. hash值发生变化

使用HashMap等容器,如果存入之后,对象的hash值发生变化,那么也就找不到对应的对象了,也就无法回收。

(这也是为什么String被设计为不可变类型的原因)

  1. ThreadLocal使用不当

ThreadLocalMap的Entry是弱引用的,弱引用会在每一次GC时被回收,但是如果创建该线程仍然存活,value还是处于被引用的状态,就不会被回收

Stop The World

STW:指GC事件中,整个应用线程被暂停,没有响应的状态。

  • 所有的GC回收器都有STW事件
  • STW由JVM在后台自动发起和自动完成,用户不可见。

垃圾回收的并行与并发

操作系统的并发并行:

  • 并发:同一个时间段发生;互相抢占资源
  • 并行:同一个时间点发生;不互相抢占资源

垃圾回收的并行和并发:

  • 并行:多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态
    • 如:ParNew、Parallel、Scavenge、Parallel Old
  • 并发:用户线程和垃圾收集线程同时进行(注意是一个时间段内,这个时间段内,可能用户线程和垃圾收集线程是来回交替的)
    • 如:CMS、G1

安全点与安全区域

程序运行过程,不是所有的时间点都可以停下进行GC

安全点(Safe Point):可以停顿下来进行GC的位置

安全点的选择很重要:

  • 太少,GC等待时间太长
  • 太多,运行时太卡

通常会根据 是否具有让程序长时间执行的特征 为标准选择安全点。

例如:选择一些执行时间较长的指令,方法调用、循环跳转、异常跳转等

如何确保GC时所有线程都进入安全点了呢?

有两种方法:

  • 抢先式中断:(目前没有虚拟机采用)
    • 首先中断所有线程,如果线程不再安全点,恢复其线程,让其运行至安全点
  • 主动式中断
    • 设置一个中断标志,各个线程运行到safe point主动轮询这个标志,如果中断标志位真,就将自己进行中断挂起

如果线程处于Sleep状态或是Blocked状态,也就无法进入到安全区域

安全区域:

​ 一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。

我们可以把安全区域看作是被扩展了的安全点

  1. 当线程运行到safe Region的代码时,首先**标识已经进入了Safe Region**,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
  2. 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止

深入引用

JDK1.2后,引用进行了扩展,分为了四种,其强度依次递减

  • 强引用(StrongReference):强引用下,不会被GC回收;会造成内存泄漏
  • 软引用(SoftReference):系统将要发生OOM溢出之前,会将软引用回收,如果回收后依然没有足够的内存,才会抛出OOM
  • 弱引用(WeakReference):弱引用关联的对象只能生存到下一次GC之前,无论内存是否充足都会回收弱引用
  • 虚引用(PhantomReference):一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。虚引用的唯一目的是为了在这个对象被GC时收到一个系统通知

只有强可触及对象不会被GC,软可触及、弱可触及、虚可触及都可以被回收掉

java.lang.ref包下,有四个引用类对象,还有一个是终结器引用

1
2
3
4
5
6
7
java:
lang:
ref:
SoftReference
WeakReference
PhantomReference
FinalReference(终结器引用)

而且终结器引用是包内可见,其他全为public

下面具体介绍这几个引用:


  1. 强引用——永不回收

特点:

  • 强引用可以直接访问对象
  • 强引用指向的对象永远不会被GC,JVM即使抛出OOM,也不会回收强引用对象
  • 强引用很可能导致内存泄漏
  1. 软引用——不足才回收

特点:

  • 用来描述一些还有用但是非必须的对象
  • 只有在即将OOM之前,JVM才会回收这些对象(即使主动调用GC,如果内存还有空闲,就不会清除)
  • 清理软引用时,可以将引用存放到一个引用队列(可选)

应用:

  • 通常来实现内存敏感的缓存,高速缓存就用到了软引用

可以使用java.lang.ref.SoftReference来实现软引用

例如:

1
2
3
4
5
6
7
8
9
// 强引用
String str = new String("nihao1");
// 软引用
SoftReference<String> soft = new SoftReference<>(str);
// 销毁强引用
str = null;

//或者直接一行完成
SoftReference<String> soft = new SoftReference<>(new String("nihao1"));
  1. 弱引用——发现即回收

特点:

  • 只被弱引用关联的对象只能生存到下一次GC发生为止(无论内存是否足够)
  • 由于垃圾回收线程的优先级很低,所以不一定很快被回收掉;这种情况可以存活较长时间

demo:

1
2
3
4
5
6
// 强引用
String str = new String("nihao");
// 弱引用
WeakReference<String> weak = new WeakReference<>(str);
// 销毁强引用
str = null;
  1. 虚引用——对象回收跟踪

特点:

  • 也称为幽灵引用、幻影引用
  • 设置虚引用的唯一目的跟踪垃圾回收进程,在被回收前收到一个系统通知
  • 虚引用对对象的生命周期没有丝毫影响:一个对象如果只有虚引用,那么和没有引用一样
  • 虚引用get()方法得不到对象,返回的是一个null
  • 使用时必须和引用队列一起使用,GC工作时,如果发现对象还有虚引用,那么他会在回收对象后,将这个虚引用加入引用队列,以通知对象回收情况
  • 虚引用可以跟踪对象回收时间,因此可以将一些资源释放操作放置在虚引用中执行和记录
1
2
3
4
5
6
7
// 强引用
String str = new String("nihao");
// 虚引用
ReferenceQueue<String> queue = new ReferenceQueue<>(); // 必须配合引用队列
PhantomReference<String> pf = new PhantomReference<>(str, queue);
// 销毁强引用
str = null;
  1. 终结器引用
  • 用以实现对象的finalize()方法
  • 无需手动编码,内部配合引用队列使用
  • GC时,终结器引用入队。由Finalize线程通过终结器引用找到被引用的对象并调用他的finalize()方法,第二次GC时才回收该对象。

GC回收器

垃圾收集器没有进行太多规范,不同的厂商有不同的定制

垃圾回收器基本概念

垃圾回收器的分类:

  • 按照线程数分

    • 串行垃圾回收器
    • 并行垃圾回收器
  • 按工作模式分

    • 并发式垃圾回收器
    • 独占式垃圾回收器
  • 按碎片处理方式分

    • 压缩式垃圾回收器
    • 非压缩式垃圾回收器
  • 按工作内存区间分

    • 年轻代垃圾回收器
    • 老年代垃圾回收器

性能评价指标:(加粗为重点参考指标)

  • 吞吐量:运行用户代码的时间与总运行时间的比例
    • T总运行 = T程序 + T内存回收
  • 垃圾收集开销:吞吐量的补数,垃圾回收时间与总运行时间的比例
  • 暂停时间:执行GC时,工作线程被暂停的时间
  • 收集频率:相对于应用程序的执行,收集操作发生的频率
  • 内存占用:Java堆区占用的内存大小
  • 快速:一个对象从诞生到被回收所经历的时间

吞吐量vs暂停时间

吞吐量大,程序更多时间处于生产状态;暂停时间短,会让用户感觉交互性好,延迟低。

但是吞吐量和暂停时间是负相关的,如果以低延迟为优先,那么就要暂停时间短,就要减少GC的时间,但是GC次数更加频繁,从而吞吐量下降;如果以高吞吐量为优先,那么一次GC的时间就会变长,暂停时间就会变大。

现在的标准:在保证最大吞吐量优先的情况下,降低停顿时间

不同的垃圾回收器概述

经典款:

  • 串行回收器:Serial(第一款GC)、Serial Old
  • 并行回收器:ParNew(Serial的并行版本)、Parallel Scavenge(JDK8)、Parallel Old(JDK8)
  • 并发回收器:CMS、G1(JDK9默认GC)

最新款:

  • ZGC、shenandoah GC

本节主要对经典款GC做介绍:

  • 新生代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:Serial Old、Parallel Old、CMS
  • 整堆收集器:G1

垃圾回收器的配合情况:

垃圾回收器的配合

关于图的解释:

  • 为什么CMS与Serial Old有连接?
    • CMS收集时 可能失败,需要Serial Old做后备方案
  • 虚线代表最新版本(截止JDK14)取消的组合;
    • 红线代表JDK9取消的组合;
    • 绿线代表JDK14取消的组合
  • CMS用虚线框,代表最新版本JDK14中删除了

查看默认的GC:

  • -XX:PrintCommandLineFlags查看命令行相关参数,包括使用的GC是什么
1
-XX:InitialHeapSize=266429440 -XX:MaxHeapSize=4262871040 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC <- JDK8运行:这里写着使用ParallelGC
  • jinfo -flag 相关垃圾回收器参数 进程ID

Serial GC——串行回收

Serial 与 Serial Old

第一款GC收集器,JDK1.3之前新生代的唯一选择

Serial特点:

  • 是Hotspot中client模式下的默认新生代GC
  • 采用复制算法、串行回收、STW机制
  • 与Serial Old搭配使用

Serial Old特点:

  • Serial Old同样是client模式下的默认老年代GC
  • 使用标记压缩算法、串行回收、STW机制
  • 应用有两个:
    • 与Serial搭配使用
    • 作为CMS的后备垃圾收集方案

Serial与Serial Old

优势

  • 简单而高效:单线程的GC王
  • 适用于低配机器:单核CPU机器

ParNew——并行回收

Serial的多线程版本,Par代表Parallel,New代表只能处理新生代

特点:

  • 复制算法、并行回收、STW机制
  • 能与CMS、Serial Old进行组合

与Serial Old搭配:

ParNew与Serial Old

新生代GC频繁,使用并发;老年代GC次数少,用串行节省资源

ParNew一定比Serial性能高吗?

如果CPU多核、那么是一定的;如果CPU只有一个核,Serial反而发挥更好。

Parallel——吞吐量优先

Parallel Scavenge与Parallel Old:JDK 8 默认GC

Parallel Scavenge:

  • 复制算法、并行回收、STW机制
  • 可以与Serial Old、Parallel Old搭配使用
  • 吞吐量优先
  • 自适应策略
    • 运行过程会对年轻代大小、Eden与Survivor的比例、晋升老年代对象年龄进行调整

Parallel Old:

  • 标记压缩算法、并行回收、STW机制

与ParNew机制一样?是否多此一举?

  • 与ParNew不同,Parallel Scavenge目标是为了达到一个可控制的吞吐量,被称为吞吐量优先的垃圾回收器。

  • 而且区别与ParNew,Parallel Scavenge还有自适应调节策略

Parallel与ParNew对比:

GC ParNew Parallel
内存占用 较大
吞吐量 较小

因为高吞吐量的优势,所以Parallel Scavenge适合执行交互不多的后台计算任务

Parallel Scavenge与Parallel Old

CMS——低延迟

CMS特点

CMS:Concurrent Mark Sweep JDK1.5推出的强交互GC,也是第一款并发GC

  • 并发GC:用户线程与垃圾收集线程并发执行
  • 标记清除算法STW机制
  • 目的是减少暂停时间,提高交互性
  • 运行在老年代
  • 可以与Serial、ParNew进行搭配
  • Serial Old是CMS的备选方案

为什么不能与Parallel进行搭配?

由于使用的框架不同,导致不能搭配使用


CMS工作原理

CMS工作原理

CMS的工作主要分为四个阶段:

  • 初始标记(Initial Mark):短暂的STW,仅仅只标记出GC Roots能直接关联到的对象
  • 并发标记(Concurrent-Mark):从第一步找到的直接关联的对象开始,遍历整个对象图,是整个过程中耗时时间最长的阶段,但是不需要STW
  • 重新标记(Remark):确认并发标记期间不能确定是否是垃圾的对象,这部分也需要STW
  • 并发清除(Concurrent-Sweep):清除标记阶段判断的依据死亡的对象,释放内存空间
  • 提前GC:达到阈值就开始GC(JDK6+默认阈值为92%)

注意:

  1. 即使是CMS,也有STW,STW发生在初始标记和重新标记两个阶段

  2. 另外,由于CMS回收过程中,用户进程依旧在运行,所以要保证有足够的空间使用,因此CMS不能等到老年代满才进行手机,而是达到一定阈值就开始进行回收;

    如果CMS运行期间预留的内存无法满足程序需要,就会出现Concurrent Mode Failure,此时JVM会采用后备方案临时启用Serial Old收集器来进行老年代的收集

  3. 因为CMS为了低延迟与并发,采用标记清除算法,所以会产生内零头(内存碎片),所以CMS只能使用空闲列表的方式进行内存分配,不能使用指针碰撞

为什么CMS采用标记清除算法而不是标记压缩算法?

CMS主打低延迟与并发,所以如果使用标记压缩算法,除了运行时间会稍长外,最致命的是不能进行并发操作;如果要进行压缩,势必要让对象进行移动,那用户线程就必然不能执行了

CMS优与劣

优势:并发收集、低延迟

弊端:

  1. 产生内存碎片,无法分配大对象时只能进行Full GC

  2. 对CPU资源敏感,会因为用户线程占用CPU资源而导致吞吐量降低

  3. CMS无法处理浮动垃圾,而且有可能回收失败

    浮动垃圾:在并发标记阶段,用户线程产生的新的垃圾对象

    注意:

    并发标记阶段,有不能确认的对象(这里成为怀疑对象),重新标记阶段就是最终确认怀疑对象是否是垃圾

    浮动垃圾是并发标记阶段前不是垃圾,而后变为垃圾的垃圾

G1——区域化分代式

产生背景:

为了进一步的提高吞吐量、降低暂停时间;

G1基本了解

G1 (Garbage First):JDK7引入,JDK9作为默认GC

Region:

在G1中,把堆分为一个一个Region,而Region进一步的去组成Eden、Survivor、老年代

G1可以跟踪每个Region内垃圾堆积的价值(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Regin(G1因此得名)

Region构成新生代、老年代

可见新生代、老年代不再是连续的了

G1的特点

  • 采用分区算法,分为一个一个Region,回收的单位是Region
    • Region级别是复制算法
    • 整体可以看做标记压缩算法
  • 兼顾并行与并发
    • 并行:G1回收期间可以和多个GC线程同时工作(此时STW)
    • 并发:G1也可以与用户线程交替执行,部分工作可以和应用线程同时执行
  • 兼顾老年代与年轻代
  • 可预测的停顿时间模型
    • 相较于CMS的一大优势:除了追求低停顿,还能明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
    • 不需要全局停顿,只需要回收价值最高的Region
  • 对比CMS,G1还不具有压倒性优势
    • 有更大的内存占用和额外的负载
  • 提供三种垃圾回收模式:YoungGC、Mixed GC、Full GC

Region:化整为零

G1将Java堆划分成越2048个大小相同的独立Region块;每个Region的大小由实际情况而定,整体控制在1MB-32MB之间,且为2的幂次

所有Region的大小是相同的,并且在JVM生命周期内不变

新生代和老年代由Region组成,但不再是物理隔离的了

G1中有四种角色,一个Region只能属于一个角色角色是可以变化的

Region四种角色

对于H角色我们要详细说明:H其实是Old区的一种,如果要存入的对象大于一个Region的一半,就视为一个H区,JVM对待H区等同于Old

图中Humongous代表矩形对象,是G1新增的内存区域,用来存储大对象,如果超过1.5个Region就存放到H中,如果一个H不够,会去找连续的H进行存储(如果没有连续的H,会触发Full GC

原因:在原有的JVM设计下,对于堆中超过新生代的大对象,会直接存放到老年代,但是如果是一个短期的大对象,这样处理显然是不好的,因此划分了H区,H的很多行为都被当做老年代来看待。

G1的垃圾回收机制

垃圾回收过程主要包括三个环节(最后一个环节作为保护方案):

  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • Full GC(GC评估失败后的保护机制)

G1的垃圾回收过程

在开始细细阐述每一步之前,先来了解一个概念:

每一个Region不是孤立的,一个Region中的对象很可能被其他任意类型的Region引用。

那么问题来了:如果一个新生代Region只被一个老年代Region引用,我们是不是得花大力气去遍历所有老年代Region?

这样太麻烦了,STW时间会很长很长,因此提出了记忆集的概念

Remembered Set(Rset):记忆集

  • 每一个Region都有一个Remembered Set
  • 每次Reference类型数据写操作时,都会产生一个Write Barrier写屏障暂时中断操作,检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region
    • 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中(CardTable是记忆集的具体实现)
  • G1垃圾回收器的记忆集的实现实际上是基于哈希表的key代表的是其他region的起始地址,value是一集合里面存放了对应区域的卡表的索引,因此G1的region能够通过记忆集知道,当前是哪个region有引用指向了它,并且能知道是哪块区域存在指针指向。(其他GC的收集器就是一个byte数组,只能知道一个区域是否有指针指向,而不能知道是谁指向)

因此当进行垃圾收集时,加入Remember Set就可以保证不进行全局扫描也不会有遗漏


垃圾回收机制,有四种:

一、年轻代GC

当Eden区空间耗尽时,G1会启动一次YGC

首先STW,G1创建回收集(Collection Set)

回收集是指需要被回收的内存分段的集合

年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段

回收过程:

  1. 扫描根

    • 根引用连同RSet记录的外部引用作为扫描存活节点的入口
  2. 更新RSet

    • 处理dirty card queue中的card,更新RSet

    • 更新完成后,RSet可以准确反映老年代对当前所在内存分段中对象的引用

      脏卡表队列:

      对于引用赋值语句,类似于Object obj = object,JVM会在执行这条语句的之前和之后再脏卡表队列中入队一个保存了对象引用信息的Card

      在G1 YGC时,G1会对脏卡表队列中所有的card进行处理,更新RSet

      为什么不在引用赋值语句直接更新Rset?

      如果着这样做,RSet处理需要考虑线程同步,复杂而且开销大

  3. 处理RSet

    • 识别其中指向Eden的对象
  4. 复制对象

    • 使用复制算法,将存活对象复制到Survivor区的空的内存分段
    • 如果达到年龄阈值,复制到Old
    • 如果Survivor空间不够,直接晋升至老年代
  5. 处理引用

    • 处理软、虚、最终引用、JNI Weak等各种引用
    • 清空Eden空间
二、 并发标记+YGC
  1. 初始标记阶段
    • STW,标记从根直接的可达对象
    • 并触发一次YGC
  2. 根区域扫描
    • 标记Survivor区直接可达的老年代区域对象,并标记
    • 必须在YGC之前完成
  3. 并发标记
    • 可以与应用线程同时执行
    • 可能被YGC打断
    • 如果发现一个Region全是垃圾,那这个Region会被立即回收
    • 计算每个Region的对象活性(存活对象的比例)
  4. 再次标记
    • STW:修正并发标记期间的标记结果(同CMS)
    • 采用比CMS更快的初始快照算法(snapshot-at-the-beginning SATB)
  5. 独占清理
    • STW;计算每个区域的存活对象和GC的回收比例,进行排序,识别可以混合回收的区域
    • 并不会实际去做垃圾的收集
  6. 并发清理
    • 识别并清理完全空闲的区域
三、混合回收

​ 当越来越多的对象晋升到old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器即Mixed GC(该算法并不是old Gc)

​ 除了回收整个Young Region,还会回收一部分的old Region

注意:回收是一部分老年代,而不是全部老年代

​ 由于前一个阶段,对老年代的垃圾回收价值进行了排序,所以排序越靠前,越会被先回收

​ 回收分默认分8次进行(不一定要完全8次,允许一个Region对内存浪费10%),并且只有垃圾占65%时才会对一个Region进行回收

四、 Full GC
  • Full GC是一个可能会被触发的过程,并不是一定会有的过程

  • 是一个单线程过程,运行很慢,STW时间很长

导致G1 进行Full GC的原因可能有三个:

  1. Evacuation时没有足够的to区存放晋升的对象
  2. 并发处理过程完成之前空间耗尽
  3. 最大GC停顿时间太短,导致在规定的时间间隔内无法完成垃圾回收,也会导致Full GC

CMS与G1的跨代引用问题

跨代引用问题:加入有老年代执行新生代的引用,GC时是否需要遍历所有的老年代对象呢?这就是跨代引用问题

首先需要了解几个结构:卡表、写屏障。

卡表:一个字节数组,每一个字节代表一个卡页,卡页是一段内存区域(HotSpot实现中一个卡页代表512字节的内存范围),如果卡的范围内存在至少一个外部引用,那么就会置为为1,表示脏页

记忆集:记忆集是专门为了解决跨代引用问题提出的一种理想化的结构,卡表是记忆集的一种具体实现(类似于HashMap与Map的关系)

写屏障:是对象引用在被修改时执行的一段代码。(可以理解为一个机器码级别的AOP,会在对象引用发生变化的时候执行,更改卡表为脏)

  • CMS中的卡表是一个字节数组,每一个字节代表一段512字节的区域,如果该区域至少存在一个对象存在外部的引用(CMS只会记录老年代指向新生代),就将该位置的卡表置位1,card_table[this_addr >> 9] = 1(当前地址右移9位,代表地址除以512,就是对应卡表的索引位置)
  • G1的卡表是一个hashMap,key存储了外部region的起始地址,value是一个记录卡表索引号的集合,因此G1的这种结构可以找到哪个Region指向了我。

G1的卡表

问题:假设没有记忆集这个结构,该如何进行一次YGC呢?

为了防止年轻代的对象含有老年代的引用,我们需要遍历老年代的所有对象。(成本很大)

G1的清理步骤

  • 初始标记(STW):标记GCRoots可以直接关联的对象
  • 并发标记:GC线程可以与用户线程同时执行,使用SATB(原始快照算法)扫描的更少
  • 最终标记(STW):标记遗留的少部分STAB记录
  • 筛选回收(STW):将各个region按可回收价值排序,选择任意个Region组成回收集合,将依然可以存活的对象复制到新的Region,然后清空对应的Region。

与CMS不同的是,G1筛选回收的阶段也需要STW

问题:并发标记阶段,如何保证用户线程和GC线程互不干扰?

CMS使用增量更新的方式,G1使用STAB算法

G1每一个Region有两个指针TAMS(Top at Mark Start),两个指针之间的空间用来担任回收期间的新对象分配

CMS对比G1

  • 回收算法:
    • CMS是标记清除算法;
    • G1宏观上是标记压缩算法,微观上是标记复制算法
  • 卡表的设计:
    • CMS是一个简单的字节数组(为什么不是bit数组?操作系统最小的取值单位是字节,bit还需要移位操作,会慢一点),只管老年代指向新生代的跨代引用,对于新生代对老年代的引用,在极端情况下需要遍历所有新生代的对象。
    • G1是一个hashmap,key是其他region的起始地址,value是对应卡表的索引,因此G1可以实现双向的指向
  • 卡表的大小:
    • CMS的卡表全局唯一,且只负责老年代指向新生代的引用
    • G1每一个Region有一个卡表
  • 内存、CPU占用:
    • CMS相较于G1要少一些,G1需要额外10%20%的内存来维持GC,这大约需要68G的堆大小才能安全工作,小内存下,CMS的效果更好
    • CMS的额外负载要少于G1
  • STW:
    • CMS只有初始标记、重新标记需要STW
    • G1在初始标记、最终标记、筛选回收都需要STW
  • 其他特性:
    • G1可以实现指定时间内的GC
    • G1可以同时完成新生代与老年代的回收,CMS只可以完成老年代的回收。
    • G1使用的STAB算法比CMS更快,扫描的内容更少

文章相关链接

  1. 尚硅谷JVM教程:强推,最强JVM视频教程
  2. 《深入理解Java虚拟机》阅读笔记:省下看书的时间
  3. 《深入理解Java虚拟机》:书还是要看的