当前位置 : 主页 > 编程语言 > java >

JVM垃圾回收机制与常见的垃圾回收器

来源:互联网 收集:自由互联 发布时间:2023-09-06
一张图看懂 JVM 之垃圾回收机制 (qq.com) JVM垃圾回收详解(重点) 有这样一个梗,说在食堂里吃饭,吃完把餐盘端走清理的,是 C++ 程序员,吃完直接就走的,是 Java 程序员。 确实,在

一张图看懂 JVM 之垃圾回收机制 (qq.com)

JVM垃圾回收详解(重点)

有这样一个梗,说在食堂里吃饭,吃完把餐盘端走清理的,是 C++ 程序员,吃完直接就走的,是 Java 程序员。

确实,在 Java 的世界里,似乎我们不用对垃圾回收那么的专注,很多初学者不懂 GC,也依然能写出一个能用甚至还不错的程序或系统。但其实这并不代表 Java 的 GC 就不重要。相反,它是那么的重要和复杂,以至于出了问题,那些初学者除了打开 GC 日志,看着一堆 0101 的天文,啥也做不了。

今天我们就从头到尾完整地聊一聊 Java 的垃圾回收。

我们通过一张图的方式,从总体上对 JVM 的结构特别是内存结构有一个比较清晰的认识。

图片

虽然在 JDK1.8+ 的版本中,JVM 内存管理结构有了一定的优化调整:主要是方法区(持久代)取消变成了直接使用元数据区的方式,但是整体上 JVM 的结构并没有大改,特别是我们最为关心的堆内存管理方式并没有在 JDK1.8+ 的版本中有什么变化。

在上面的图中,我们也大致对整个垃圾回收系统进行了标注,这里主要涉及回收策略、回收算法、垃圾回收器这几个部分。

形象一点表述,就是 JVM 需要知道哪些内存可以被回收,要有一套识别机制,在知道哪些内存可以回收以后具体采用什么样的回收方式,这就需要涉及一些回收算法,而具体的垃圾回收器就是根据不同内存区域的使用特点,采用相应地回收策略和算法的具体实现了。

下面我们就从这几个方面给大家介绍,JVM的垃圾回收相关的知识点。

1 什么是垃圾回收?

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

2 哪些内存需要回收?

我们知道,根据 《Java虚拟机规范》,Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。

图片

而程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要过多考虑如何回收的问题。

而 Java 堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

3 如何判断对象已成垃圾?

既然是垃圾收集,我们得先判断哪些对象是垃圾,然后再看看何时清理,如何清理。

常见的垃圾回收策略分为两种:

  • 一种是直接回收,即引用计数;
  • 另一种是间接回收,即追踪式回收(可达性分析)。

3.1 引用计数法

给对象中添加一个引用计数器:

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

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

对象之间循环引用

除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。

但是它们因为互相引用对方,导致它们的引用计数器都不为 0,通过引用计数算法,也就永远无法通知 GC 收集器回收它们。

3.2 可达性分析算法

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

下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

可达性分析算法

哪些对象可以作为 GC Roots 呢?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象(正在运行的方法使用到的变量、参数等)
  • 方法区中类静态属性引用的对象(static 关键字声明的字段)
  • 方法区中常量引用的对象,(也就是 final 关键字声明的字段)
  • 本地方法栈中引用的对象(native 方法)
  • 所有被同步锁(synchronized 关键字)持有的对象
  • Java虚拟机内部的引用(系统内部的东西当然能作为根了)

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

Object 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!

4 四种引用方式

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

JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

image-20230905221437179

4.1 强引用

正常情况下我们平时基本上我们只用到强引用类型,例如 Object obj = new Object();

无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。且当内存空间不足抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠回收具有强引用的对象,来解决内存不足的问题。

4.2 软引用

软引用是种相对强引用弱化一些的引用,用来描述一些还有用,但非必须的对象。

软引用是通过 SoftReference 类实现的。

Object obj = new Object();
SoftReference softObj = new SoftReference(obj);
obj = null;

被软引用关联着的对象,在即将 OOM 之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

4.3 弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

弱引用是通过 WeakReference 类实现的。

Object obj = new Object();
WeakReference<Object> weakObj = new WeakReference<Object>(obj);
obj = null;

弱引用与软引用的区别:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

ThreadLocal 中的 key 就用到了弱引用。

4.4 虚引用

虚引用,也称幻象引用。是通过 PhantomReference 类实现的。它是最弱的一种引用关系,定义完成之后,无法通过虚引用来取得一个对象实例。

无法通过虚引用访问对象的任何属性或者函数。那就要问了要它有什么用?

虚引用仅仅只是提供了一种确保对象被 finalize 以后来做某些事情的机制。

比如说这个对象被回收之后发一个系统通知啊啥的。虚引用是必须配合 ReferenceQueue 使用的,当垃圾回收时,如果存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。

重点来了,下面我们开始介绍几个重要的垃圾回收算法。

5 垃圾收集算法

5.1 标记-清除算法

标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。

标记-清除算法

关于具体是标记可回收对象还是不可回收对象,众说纷纭,两种说法其实都没问题,我个人更倾向于是前者。

如果按照前者的理解,整个标记-清除过程大致是这样的:

  1. 当一个对象被创建时,给一个标记位,假设为 0 (false);
  2. 在标记阶段,我们将所有可达对象(或用户可以引用的对象)的标记位设置为 1 (true);
  3. 扫描阶段清除的就是标记位为 0 (false)的对象。

5.2 复制算法

为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

JVM的S0和S1区使用的就是复制算法进行垃圾回收

所以每次,一个幸存者空间总是空的

复制算法

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

5.3 标记-整理算法

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

标记-整理算法

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。

5.4 分代收集算法说明

分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述 3 种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

JVM 根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 比如在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择标记-清除或标记-整理算法进行垃圾收集。

问题来了,那内存区域到底被分为哪几块,每一块又有什么特别适合什么算法呢?

5.4.1 为什么要分代

这是基于这样一个事实:不同的对象的生命周期是不一样的。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是它们依旧存在。

因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

新生代中的对象存活时间短,只需要在新生代区域中频繁进行 GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。

5.4.2 内存分代划分

Java 堆主要分为 2 个区域:新生代与老年代,其中新生代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区。

内存分代示意图如下:

图片

Eden 区

IBM 公司的专业研究表明,有将近 98% 的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 YGC

  • YGC 对应于新生代,第一次 YGC 只回收 Eden 区域,回收后大多数的对象会被回收,活着的对象通过复制算法进入 Survivor 0(后续用S0和S1代替)。
  • 再次 YGC 后,Eden + S0 中活着的对象进入 S1
  • 再次 YGC,Eden + S1 中活着的对象进入到 S0
  • 依次循环。看到这里我相信你已经明白了为什么要设置两个 Survivor 区域了。
  • YGC(Young GC)/ MinorGC:针对新生代进行的垃圾回收,新生代空间不足会触发
  • Major GC:清理老年代
  • Full GC:清理整个堆空间,包括年轻代和永久代甚至是方法区

Survivor 区

Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。

Eden 区的旁边是两个存活区(Survivor Spaces),称为 from 空间和 to 空间。需要着重强调的的是,任意时刻总有一个存活区是空的(empty)。

空的那个存活区用于在下一次年轻代 GC 时存放收集的对象。年轻代中所有的存活对象(包括 Eden 区和非空的那个 from 存活区)都会被复制到 to 存活区。GC 过程完成后,to 区有对象,而 from 区里没有对象。两者的角色进行正好切换,from 变成 toto 变成 from

为啥需要 Survivor 区?

不就是新生代到老年代么,直接 Eden 到 Old 不好了吗,为啥要这么复杂。

想想如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。

所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少Major GC 的发生。Survivor 的预筛选保证,只有经历 15 次 Minor GC 还能在新生代中存活的对象,才会被提升到老年代。

对象提升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

如果存活区空间不够存放年轻代中的存活对象,提升也可能更早地进行。

老年代(Old Gen)

老年代的 GC 实现要复杂得多。老年代内存空间通常会更大,里面的对象是垃圾的概率也更小。

老年代 GC 发生的频率比年轻代小很多。同时,因为预期老年代中的对象大部分是存活的,所以不再使用标记和复制(Mark and Copy)算法。而是采用移动对象的方式来实现最小化内存碎片。

老年代空间的清理算法通常是建立在不同的基础上的。原则上,会执行以下这些步骤:

  • 通过标志位(marked bit),标记所有通过 GC roots 可达的对象;
  • 删除所有不可达对象;
  • 整理老年代空间中的内容,方法是将所有的存活对象复制,从老年代空间开始的地方依次存放。

通过上面的描述可知,老年代 GC 必须明确地进行整理,以避免内存碎片过多。

5.4.3 堆内存常见的分配策略

  • 对象优先在 Eden 区分配

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

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

下面我们来进行实际测试一下。

public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1, allocation2;

        allocation1 = new byte[30900*1024];

        //allocation2 = new byte[900000*1024];
    }
}

idea 添加参数:

-XX:+PrintGCDetails

图片

运行结果:

Heap
 PSYoungGen      total 75776K, used 37408K [0x000000076bb00000, 0x0000000770f80000, 0x00000007c0000000)
  eden space 65024K, 57% used [0x000000076bb00000,0x000000076df88318,0x000000076fa80000)
  from space 10752K, 0% used [0x0000000770500000,0x0000000770500000,0x0000000770f80000)
  to   space 10752K, 0% used [0x000000076fa80000,0x000000076fa80000,0x0000000770500000)
 ParOldGen       total 173568K, used 0K [0x00000006c3000000, 0x00000006cd980000, 0x000000076bb00000)
  object space 173568K, 0% used [0x00000006c3000000,0x00000006c3000000,0x00000006cd980000)
 Metaspace       used 3319K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

根据上面的结果,我们可以看出 eden 区内存被分配 57%。假如我们再为 allocation2 分配内存会出现什么情况呢?

Heap
 PSYoungGen      total 75776K, used 37403K [0x000000076bb00000, 0x0000000770f80000, 0x00000007c0000000)
  eden space 65024K, 57% used [0x000000076bb00000,0x000000076df86f60,0x000000076fa80000)
  from space 10752K, 0% used [0x0000000770500000,0x0000000770500000,0x0000000770f80000)
  to   space 10752K, 0% used [0x000000076fa80000,0x000000076fa80000,0x0000000770500000)
 ParOldGen       total 1073664K, used 900000K [0x00000006c3000000, 0x0000000704880000, 0x000000076bb00000)
  object space 1073664K, 83% used [0x00000006c3000000,0x00000006f9ee8010,0x0000000704880000)
 Metaspace       used 3323K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

简单解释一下为什么会出现这种情况:因为给 allocation2 分配内存的时候 eden 区内存几乎已经被分配完了,我们刚刚讲了当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。GC 期间虚拟机又发现allocation1 无法存入 Survior 空间,所以只好通过分配担保机制把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。

执行 Minor GC 后,后面分配的对象如果能够存在 eden 区的话,还是会在 eden 区分配内存。

  • 大对象直接进入老年代

大对象就是需要大量连续内存空间的对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

直接在老年代分配内存,主要为了避免在新生代区频繁的GC时发生大量的内存复制。

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

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

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。

对象在 Survivor 中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

6 垃圾回收器

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

JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):

  • JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK20: G1

在这里我们简单介绍几个概念:

  • Minor GC:发生在年轻代的 GC。
  • Major GC:发生在老年代的 GC。
  • Full GC:全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收。

我们先说3个常见的年轻代垃圾回收器

6.11 Serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial 收集器

虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。

但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

6.12 ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

ParNew 收集器

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

6.13 Parallel Scavenge 收集器

Parallel Scavenge 收集器是新生代收集器,它也是使用复制算法的收集器,是并行的多线程收集器,Java1.8 默认的新生代垃圾收集器。

它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU用于运行用户代码的时间/CPU总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),即吞吐量优先。

高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多与用户交互的任务。

新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Old收集器运行示意图

它与 ParNew 的主要区别是:

  • Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,适合没有交互的后台计算。弱交互强计算。
  • ParNew:追求降低用户停顿时间,适合交互式应用。强交互弱计算。

6.21 Serial Old 垃圾收集器

与年轻代的 Serial 垃圾收集器对应,都是单线程版本,同样适合客户端使用。

年轻代的 Serial,使用复制算法。

老年代的 Serial Old ,使用标记-整理算法。

图片

6.22 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

图片

6.23 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 这个阶段需要「Stop the World」,即所有用户线程暂停。它只是标记一下GC Roots能直接关联到的对象,并不需要做整个引用的扫描,因此速度很快;
  • 并发标记: 这个阶段多个gc线程与用户线程并发执行,从初始标记阶段标出的对象开始进行Tracing,标记出可达对象。因为与用户线程并发执行,所以可能会产生漏标问题;
  • 重新标记: 这个阶段需要「Stop the World」,即所有用户线程暂停。重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短;
  • 并发清除: 垃圾回收线程与用户线程可以并发执行,GC线程将标记为垃圾的对象进行清除。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

长期来看,CMS 垃圾回收器,是要被 G1 垃圾回收器替换掉的。在 Java8 之后,使用它将会抛出一个警告。

Java HotSpot™ 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.

6.24 G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW。G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
  • 分代收集:从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。和之前的各类回收器不同,它同时兼顾年轻代和老年代。
  • 空间整合:G1将内存划分为一个个的Region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-整理算法。
  • 可预测的停顿:这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

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

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。

6.25 ZGC 收集器

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

在 ZGC 中出现 Stop The World 的情况会更少!

Java11 的时候 ,ZGC 还在试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java 15 已经可以正式使用了!

不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启动 ZGC:

java -XX:+UseZGC className
上一篇:面试题-分布式锁的实现
下一篇:没有了
网友评论