Java GC 笔记

垃圾收集器(Garbage Collection,GC)

内存分配与回收的神秘面纱

​ Java的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java的自动内存管理最核心的功能是Java Heap内存中对象的分配与回收。

堆内存划分

​ 堆内存分为新生代、老年代和永久代。新生代又被进一步分为:Eden 区+Survivor1 区+Survivor2 区。值得注意的是,在 JDK8中移除永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)

栈上分配

​ 对象分配首先会尝试在栈上分配,如果分配失败,会尝试TLAB分配,如果继续失败则会在堆内的Eden区内分配,当Eden区空间无法容纳该对象时,会分配在老年代区域。

栈上分配是Java虚拟机提供的一项优化技术,它的基本思想是,对于那些线程私有对象(指不可能被其他线程访问的对象)可以将它们打散分配在栈上,而不是分配在堆上。

  • 好处:分配在栈上可以在线程结束后自行销毁,不需要垃圾回收器介入,从而提高系统的性能。

  • 局限性:栈空间小,对于大对象无法实现栈上分配。

  • 基础:栈上分配需要依赖于逃逸分析和标量替换。

栈上分配示例

设置最大堆内存为10m,为了更好的看出测试结果:

启动参数:-Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations

-Xmx10m -Xms10m 设置了最大堆和初始堆内存大小

-XX:+DoEscapeAnalysis 开启了逃逸分析

-XX:-UseTLAB 关闭了TLAB线程本地分配缓冲区的内存

-XX:+EliminateAllocations 开启了标量替换

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
/**
* JVM对象分配之栈上分配 & TLAB分配
* VM Args: -Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations
* @author Mr.zxb
* @date 2020-09-04 16:58
*/
public class CreateObjectDemo {

static class ObjectHolder {}

/**
* 栈内分配对象
*/
public static void createByStack() {
// objectHolder 在方法内部创建,所以不是逃逸对象,所以会在栈上分配对象
ObjectHolder objectHolder = new ObjectHolder();
}

private static ObjectHolder objectHolder;

/**
* 堆内分配对象
*/
public static void createByHeap() {
// objectHolder 是方法外部定义的对象,所以是逃逸对象,就需要在堆内分配对象
objectHolder = new ObjectHolder();
}

public static void main(String[] args) {
long start = System.currentTimeMillis();
int count = 100_000_000;
for (int i = 0; i < count; i++) {
createByStack();
}
System.out.println("耗时:" + (System.currentTimeMillis() - start) + " ms");
}
}

执行1亿次的结果发现,并未出现GC,而且很快执行完,说明该方法内的对象是创建在虚拟机栈中,随着栈帧的退出而对象消亡。

1
2
[GC (Allocation Failure)  2047K->488K(9728K), 0.0007411 secs]
耗时:6 ms

当我们测试有逃逸对象体的时候结果如下,可以发现对象会在堆内分配,如果开启了TLAB,则会在TLAB分配:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
long start = System.currentTimeMillis();
int count = 100_000_000;
for (int i = 0; i < count; i++) {
createByHeap();
}
System.out.println("耗时:" + (System.currentTimeMillis() - start) + " ms");
}
1
2
3
4
5
6
7
8
// 省略......
[GC (Allocation Failure) 3142K->1094K(9728K), 0.0002338 secs]
[GC (Allocation Failure) 3142K->1094K(9728K), 0.0002363 secs]
[GC (Allocation Failure) 3142K->1094K(9728K), 0.0002392 secs]
[GC (Allocation Failure) 3142K->1094K(9728K), 0.0002356 secs]
[GC (Allocation Failure) 3142K->1094K(9728K), 0.0002461 secs]
[GC (Allocation Failure) 3142K->1094K(9728K), 0.0002566 secs]
耗时:3058 ms

当使用如下参数(任意一行)运行,都会发现触大量GC

1
2
3
4
5
//不使用逃逸分析
-server -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations

//不使用标量替换
-server -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:-EliminateAllocations

可以得出结论: 栈上分配需要依赖于逃逸分析和标量替换。

从结果上来看,栈内分配对象的效率远远高于堆内分配对象的效率,栈内分配的对象随着栈帧的弹出而消亡,不需要GC的参与,大大提高了分配性能。

逃逸分析

栈上分配的一个技术基础是进行逃逸分析。目的是判断对象的作用域是否有可能逃逸出逃逸体。

虚拟机会进行逃逸分析,判断线程内私有对象是否有可能被其他线程访问,导致逃逸,然后虚拟机就会根据是否可能会逃逸将其分配在栈上,或者堆中。

只有在server模式下,才能开启逃逸分析。参数: -XX:+DoEscapeAnalysis 是开启逃逸分析。 -XX:+EliminateAllocations 是开启标杆替换,允许将对象打散分配到栈上,默认状态是开启的。

逃逸分析大概分为以下类型:

  • 全部变量赋值逃逸
  • 方法返回值逃逸
  • 实例引用发生逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
标量替换

​ 标量替换,scalar replacement。Java中的原始类型无法再分解,可以看作标量(scalar);指向对象的引用也是标量;而对象本身则是聚合量(aggregate),可以包含任意个数的标量。如果把一个Java对象拆散,将其成员变量恢复为分散的变量,这就叫做标量替换。拆散后的变量便可以被单独分析与优化,可以各自分别在活动记录(栈帧或寄存器)上分配空间;原本的对象就无需整体分配空间了。

什么是TLAB

​ TLAB,全称Thread Local Allocation Buffer,即:线程本地分配缓存。这是一块线程专用的内存分配区域。TLAB占用的是Eden区的空间。在TLAB启用的情况下(默认开启),JVM会为每一个线程分配一块TLAB私有区域

局限性:TLAB空间一般不会太大(占用eden区),所以大对象无法进行TLAB分配,只能直接分配到堆上。

为什么需要TLAB

  这是为了加速对象的分配。由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步,会使分配的效率下降。考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。

对象在Edge区分配

​ 目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

  • 新生代GC(Minor GC): 指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • 老年代GC(Major GC/Full GC): 指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC(并非绝对),Major GC的速度一般会比Minor GC的慢10倍以上。
大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

为什么要这样呢?

为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1,对象在 Survivor 中每熬过一次 MinorGC, 年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

动态对象年龄判定

为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。

空间分配担保

在发生Minor GC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果条件成立,那么Minor GC可以确保是安全的。

如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。

如果允许,那么会检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均大小,如果大于,将尝试一次Minor GC,尽管这次Minor GC是有风险的。

如果小于,或者HandlePromotionFailure设置不允许冒险,那么这时就要执行一次Full GC。

如何判断对象活着?

引用计数算法
  • 是在对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不能再被使用
  • 主流Java虚拟机并没有选用引用计数算法来管理内存
  • 原因是该算法很难解决对象之间互相循环引用的问题
可达性分析算法
什么是可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走的路径称为“引用链(Reference Chain)”,如果某个对象到GC Roots间没有任何引用链相连或从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的

什么是 GC Roots对象
  • 虚拟机栈中引用的对象,比如各线程调用方法堆栈中使用的参数、局部变量、临时变量等
  • 方法区中类静态属性引用的对象,比如字符串常量池里的引用和Java类的引用类型静态变量
  • 本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,比如:基本数据类型对应的Class对象,以及一些异常对象(NPE、OOM)等,还有系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

再谈引用

无论通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用“有关

强引用(Strongly Reference)
  • 不会被垃圾收集器回收掉的引用
软引用(Soft Reference)
  • 当JVM将要发生内存溢出前,会回收这部分引用,若内存还是不够,则OOM
弱引用(Weak Reference)
  • 被弱引用关联的对象只能生存到下一次垃圾收集发生为止
虚引用(Phantom Reference)
  • 最弱的引用关系,无法造成任何影响,也无法通过虚引用获取一个对象实例
  • 虚引用关联的唯一目的只是为了能在这个对象被垃圾收集器回收时候收到一个通知

引用计数式垃圾收集

直接垃圾收集
  • 主流Java虚拟机中均未涉及此算法
  • Objective-C采用此方式作为内存管理

追踪式垃圾收集(间接垃圾收集)

分代收集算法
弱分代假说
  • 大多数对象朝生夕死
强分代假说
  • 熬过多次垃圾收集过程的对象越难以消亡

  • 根据以上假说多款收集器一致的设计原则:

    • 收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储
    • 根据分代设计理论,一般至少会把Java堆划分为新生代和老年代,在新生代中,每次垃圾收集会有大量的对象死亡,而每次存活的少量对象晋升到老年代存放

    跨代引用假说

    • 跨代引用相对于同代引用来说仅占少数
    • 存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的
    • 依据该假说,就不用为了少量的跨代引用扫描整个老年代,也不必浪费空间专门记录每个对象是否存在或存在哪些跨代引用,只需要在新生代建立一个全局的数据结构(记忆集),这个结构把老年代划分若干个小块,标识出老年代的哪一块内存会存在跨代引用
    • 在Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描,这样就不用扫描整个老年代了

垃圾收集分类

部分收集(Partial GC)
  • 目标不是完整收集整个Java堆的垃圾收集
  • 新生代收集(Minor GC/Young GC
    • 目标只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC
    • 目标只是老年代的垃圾收集
    • 目前只有CMS收集器会有单独收集老年代的行为
  • 混合收集(Mixed GC
    • 目标是收集整个新生代以及部分老年代的垃圾收集
    • 目前只有G1收集器有这种行为
整堆收集(Full GC)
  • 目标收集整个Java堆和方法区的垃圾收集

Full GC触发条件

  • System.gc()方法的调用
  • 方法区空间不足
  • Metaspace区内存达到阈值
  • 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间
  • 堆中产生大对象超过阈值
  • 老年代连续空间不足
  • CMS GC时出现promotion failed和concurrent mode failure

垃圾收集器算法

标记-清除算法(Mark-Sweep)
  • 分为“标记”和“清除”两个阶段

    • 首先标记所有要回收的对象,在标记完成后,统一回收掉所有被标记的对象
    • 也可以反过来,标记存活的对象,统一回收所有未被标记的对象
    • 标记的过程就是对象是否属于垃圾的判断过程
  • 缺点

    • 执行效率不稳定

      • 如果堆中有大量的对象,其中大部分是要被回收堆,这时就需要大量标记和清除的动作,导致标记和清除过程的执行效率都随着对象的数量增长而降低
    • 内存空间碎片化问题

      • 标记/清除后产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程需要分配较大的对象时无法找到足够的连续内存而提前触发一次GC动作
  • 在HotSpot虚拟机中

    • 关注低延迟的CMS收集器是基于标记-清除算法的,当内存空间碎片过多时CMS收集器就会采用标记-整理算法
    标记-复制算法
  • 简称复制算法,解决标记-清除算法面临大量对象执行效率低的问题

    • 就是将内存按容量划分为大小相等的两块,每次只是使用其中一块
    • 当这一块内存快使用完,就将存活的对象复制到另一块内存上面,然后把使用的这一块内存空间一次清理掉
  • 好处

    • 不用考虑空间碎片化问题,只需移动堆顶指针,按顺序分配即可,这样实现简单高效
  • 缺点

    • 如果内存中多数对象都是存活的,就会存在大量内存复制的开销
    • 将内存缩小为原来的一半,浪费了大量的空间
  • 根据以上特点提出更优化的半区复制分代策略(Appel式回收)

    • Appel式回收的做法

      • 将新生代分为

        • 一块较大的Eden空间
        • 两块较小的Survivor空间
      • 每次分配内存只使用Eden空间和其中一块Survivor空间,然后直接清理掉Eden空间和用过的那块Survivor空间

      • 发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用的Survivor空间

      • HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是新生代可用内存空间为整个新生代容量的90%(Eden80%+Survivor10%)

      • 但无法保证每次回收都有不多于10%对象存活,所以Appel式回收设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就要依赖其他内存区域(实际大多是老年代)进行分配担保(Handle Promotion)

    • HotSpot的Serial/ParNew等新生代收集器均采用这种策略来设计新生代内存布局

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

  • 标记-清除和标记-整理本质区别:前者是非移动式的回收算法,后者是移动式的

  • 缺点

    • 移动存活对象,在老年代中每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是极为繁重的操作

    • 对象移动操作必须全程暂停用户程序才能进行

    • 若不移动和整理存活对象,将会导致空间碎片化问题

      • 就只能依赖复杂的内存分配器和内存访问器来解决
  • 在HotSpot虚拟机中

    • 关注吞吐量的收集器Parallel Scavenge基于标记-整理算法

经典垃圾收集器

Serial收集器
  • 在JDK1.3以前是HotSpot虚拟机新生代收集器的唯一选择

  • 客户端模式下默认新生代收集器

  • 单线程工作的收集器

  • 在收集过程中会停顿用户线程,直到它收集介绍

  • 优势

    • 和其他单线程收集器相比,简单而高效
    • 对于内存资源受限的环境,是所有收集器里额外内存消耗最小的
    • 对于单核处理器或处理器核心较少的环境来说,Serial收集器由于没有线程交互的开销,可以获得最高的单线程收集效率

ParNew收集器
  • 是Serial收集器的多线程并行版本
  • ParNew收集器是激活CMS收集器后的默认新生代收集器
  • 从JDK9 G1收集器的出现,CMS+ParNew收集器的组合就不再是官方推荐的服务器模式下的默认收集器
  • ParNew收集器默认开启的收集线程数与处理器核心数量相同,也可以使用-XX:ParallelGCThreads参数来限制垃圾收集器的线程数

Parallel Scavenge收集器
  • 也是新生代收集器

  • 基于标记-复制算法实现的收集器

  • 也是并行收集的多线程收集器

  • 特点

    • 与其他收集器关注点不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间
    • Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量
    • 所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
    • 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 运行垃圾收集器时间)
    • 所以Parallel Scavenge收集器又被称作“吞吐量优先收集器”
  • Parallel Scavenge收集器提供了两个参数用来精确控制吞吐量

    • 控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数
    • 直接设置吞吐量大小的-XX:GCTimeRatio参数

Serial Old收集器
  • Serial Old收集器是Serial收集器的老年代版本

  • 也是一个单线程收集器

  • 使用标记-整理算法实现的收集器

  • 主要也是提供客户端模式下HotSpot虚拟机使用

  • 若是用于服务端模式下,则可能有两种用途

    • 在JDK5及以前的版本中与Parallel Scavenge收集器搭配使用
    • 是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

Parallel Old收集器
  • Parallel Old收集器是Parallel Scavenge收集器的老年代版本
  • 支持多线程并发收集,基于标记-整理算法实现
  • 从JDK6开始提供,为了解决Parallel Scavenge收集器的尴尬状态,原因在于Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,无法与其他表现良好的老年代收集器配合工作,如CMS收集器
  • 吞吐量优先收集器的组合,在注重吞吐量或者处理器资源较为稀缺的场合,可以考虑Parallel Scavenge加Parallel Old收集器这个组合

CMS(Concurrent Mark Sweep)收集器
什么是CMS收集器
  • 以获取最短回收停顿时间为目标的收集器
  • 从名字来看CMS收集器是基于标记-清除算法实现的
  • 在JDK9以后不官方不推荐使用CMS收集器
CMS 工作过程
  • 初始标记(CMS initial mark)

    • 标记一下GC Roots能直接关联到到对象,速度很快,需要停顿用户线程
  • 并发标记(CMS concurrent mark)

    • 从GC Roots直接关联对象开始遍历整个对象图的过程,该过程耗时长但不需要停顿用户线程
  • 重新标记(CMS remark)

    • 修正并发标记期间,因用户线程继续运作而导致标记产生变动但那一部分对象的标记记录,该阶段停顿的时间比初始标记要长一些,但也远比并发标记的时间要短
  • 并发清除(CMS concurrent sweep)

    • 清理删掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以该阶段也是可以和用户线程同时并发

CMS 优点
  • 并发收集
  • 低停顿
CMS 缺点
  • 对处理器资源非常敏感,在并发阶段,会导致应用程序变慢,降低了总吞吐量

  • CMS收集器默认启动的回收线程数是(处理器核心数量+3)/4

    • 如果处理器核心数在4个以上,并发回收时垃圾收集器只占用不超过25%的处理器运算资源,并且会随着处理器核心数增加而下降
    • 当处理器核心数不足4个时,CMS收集器对用户程序的影响就可能变得很大
  • 由于CMS收集器无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”失败进而导致一次完全“Stop The World”的Full GC的产生

    • 浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集处理掉它们,只好留到下一次清理,这一部分垃圾就是浮动垃圾
  • 由于CMS是一款基于“标记-清除”算法实现的收集器,就会造成大量空间碎片产生,如果空间碎片过多时,当需要足够大大连续空间来分配大对象大时候,会不得不提前触发Full GC的情况

    • 解决方案

      • CMS收集器提供一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启,JDK9开始弃用),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的
      • 空间碎片问题虽然解决了,但停顿时间又会变长,因此虚拟机提供了参数-XX:CMSFullGCsBeforeCompaction(JDK9开始弃用),用于要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)
Garbage First(G1)收集器
什么是G1收集器
  • Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式
  • 从JDK7确立项目目标,G1收集器就被视为JDK7中HotSpot虚拟机的一项重要进化特征,直到JDK8 Update 40之后,这个版本以后的G1收集器才被Oracle官方称为“全功能的垃圾收集器”
  • 面向服务端应用的垃圾收集器,HotSpot期望它代替CMS收集器,在JDK9中G1就代替Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则标记为不推荐使用的收集器,在未来CMS可能会被废弃
G1收集器设计模式
  • G1收集器以前的其他收集器,垃圾收集的目标范围要么是整个新生代(Minor GC),要么是老年代(Major GC),要么是整个Java堆(Full GC);而G1跳出了这个樊笼,它可以面对堆内存任何部分来组成回收集(Collection Set,简称CSet)进行回收,衡量标准不在是属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的混合收集(Mixed GC)模式

  • G1开创基于Region的堆内存布局是它能够实现这个目标的关键

  • 虽然G1也遵循分代收集理论设计的,但在堆内存的布局与其他收集器有很大差异

    • G1不再坚持固定大小以及固定数量但分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Edge空间、Survivor空间或者老年代空间
    • G1收集器能根据不同的Region采用不同的策略去处理
    • Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象
    • 每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB-32MB,且为2的n次幂
    • 对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待
  • 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合

    • G1收集器之所以能够建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集
    • 处理思路是让G1收集器跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(-XX:MaxGCPauseMillis指定,默认是200ms),优先处理回收价值受益最大的那些Region,这也就是“Garbage First”名字的由来
    • 这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率
G1收集器运作过程(不计算用户线程运行过程中的动作)
  • 初始标记(Initial Marking)

    • 标记GC Roots能直接关联到到对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象
    • 该阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实行并没有额外的停顿
  • 并发标记(Concurrent Marking)

    • 从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时比较长,但可与用户线程并发执行
    • 当扫描图扫描完成后,还要重新处理SATB记录下的在并发时有引用变动的对象
  • 最终标记(Final Marking)

    • 对用户线程做另一个短暂对暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
  • 筛选回收(Live Data Counting and Evacuation)

    • 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间
    • 这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的
  • G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望

G1收集器特点
  • 可由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可让G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡

    • 默认停顿时间200ms,必须保证“期望值”符合实际
    • 若是停顿时间设置很低,则可能出现由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积,最终引发Full GC而降低性能
    • 所以期望停顿时间设置为100-200ms或200-300ms是比较合理的
  • 从G1开始,先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不是追求一次把整个Java堆全部清理干净

    • 这样应用在分配,同时收集器在收集,只要收集的速度能跟上对象分配的速度,那这一切能运作得很完美
G1收集器相比CMS的优点
  • G1从整体来看是基于“标记-整理”算法实现的收集器,从局部上看(两个Region之间)又是基于“标记-复制”算法实现,无论如何,这两种算法在运行期间都不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存

    • 有利于应用程序长时间运行
    • 分配大对象时不容易造成无法找到连续内存空间而提前触发一次收集
  • 可以自定义指定最大停顿时间

  • 分Region的内存布局

  • 按收益动态确定回收集

G1收集器相比CMS的缺点
  • 在运行过程中,G1垃圾收集器内存占用(Footprint)要比CMS要高

    • G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更复杂,堆中每个Region,无论扮演新生代还是老年代角色,都必须有一份卡表,导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间
    • CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的
  • 程序运行时的额外执行负载(Overload)要比CMS高

    • 由于G1和CMS收集器各自实现特点导致用户程序运行时的负载会有不同,譬如它们都使用写屏障,CMS用写后屏障来更新维护卡表

    • G1除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况

      • 相比增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担
      • 由于G1对写屏障的复杂操作要比CMS消耗更多对运算资源,所以CMS的写屏障实现时直接的同步操作, 而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理
  • 以上优缺点仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较

    • 目前在小内存应用上CMS的表现大概率仍然会优于G1收集器
    • 而在大内存应用上G1则大多能发挥其优势
    • 这个优劣势的Java堆容量平衡点通常在6GB-8GB之间
    • 随着HotSpot对G1对不断优化,也会让对比结果继续向G1倾斜
低延迟垃圾收集器
  • 垃圾收集器三项重要指标

    • 内存占用(Footprint)
    • 吞吐量(Throughput)
    • 延迟(Latency)
  • 实验状态的低延迟垃圾收集器

    • Shenandoah收集器(存在于OpenJDK,而不存在于OracleJDK)
    • ZGC收集器
不垃圾回收的垃圾收集器
Epsilon收集器
  • JDK11推出的不能够进行垃圾收集的垃圾收集器
  • 适用于运行数分钟或者数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出