Java 垃圾回收机制及算法

18 篇文章 0 订阅

Java 垃圾回收机制及算法

垃圾回收概述

什么是垃圾回收

说起垃圾收集(Garbage Collection, 下文简称 GC) , 有不少人把这项技术当作 Java 语言的伴生产物。 事实上,垃圾收集的历史远远比 Java 久远, 在1960年诞生于麻省理工学院的 Lisp 是第一门开始使用内存动态分配和垃圾收集技术的语言。垃圾收集需要完成的三件事情:

哪些内存需要回收?

什么时候回收?

如何回收?

Java 垃圾回收的优缺点

优点:

  • 不需要考虑内存管理
  • 可以有效的防止内存泄露,有效地利用可使用的内存
  • 由于有垃圾回收机制,Java 中的对象不再有 “作用域” 的概念,只有对象的引用才有 “作用域”

缺点:

Java 开发人员不了解自动内存管理,内存管理就像一个黑匣子,过渡依赖就会降低我们解决内存溢出、内存泄露等问题的能力。

如何判断对象是垃圾

引用计数法

引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对象被引用时,计数值 +1,引用失效时 -1,所以当计数值为 0 时表示对象已经再被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例,但是对于 Java 虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用的问题。

优点:

实现简单,执行效率高

缺点:

无法检测出循环引用

比如有 A 和 B 两个对象,他们都相互引用,除此之外都没有任何对外的引用,那么理论上 A 和 B 都可以被当做垃圾回收掉,但实际如果采用引用计数算法,则 A、B 的引用计数都是 1,并不满足被回收的条件,如果 A 和 B 之间的引用一直存在,那么就永远无法被回收了

public class Demo {
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.ref = b;
        b.ref = a;
        a = null;
        b = null;
    }
}

public class Test {
    public Test ref = null;
}

a、b 这两个对象再无任何引用,实际上这两个对象已经不可能被访问,但是因为它们互相引用这对方,导致它们的引用计数都不为 0,引用计数算法也就无法回收它们。

但是在 Java 程序中这两个对象仍然会被回收,因为 Java 中并没用引用计数算法。

可达性分析算法

在主流的编程语言如 Java、C# 等主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活。此算法的基本思路就是通过一些列的 GC Roots 的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为 引用链(Reference Chain),当一个对象到任何 GC Roots 都没有引用链时,则表明该对象不可达,即该对象是不可用的。

image-20210827114712755

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

  • 栈帧中的局部变量表中的 reference 引用所引用的对象
  • 方法区中 static 静态引用的对象
  • 方法区中 final 常量引用的对象
  • 本地方法栈中 JNI (Native 方法)引用的对象
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointException、OutOfMomoryError)等,还有系统类加载器
  • 所有被同步锁(synchronized 关键字)持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

image-20210827115202781

从上图,reference1、reference2、reference3 都是 GC Roots,可以看出:

  • reference1 -> 对象实例1
  • reference2 -> 对象实例2
  • reference3 -> 对象实例4 -> 对象实例6
  • 对象实例3 和对象实例5 没在任何引用链上,所以它们可以被回收

JVM 判定对象是否存活

finalize() 方法最终判定对象是否存活:

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

第一次标记:

如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。

没有必要执行 finalize() 方法

假如对象没有覆盖 finalize() 方法,或者 finalize() 方法以及被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”

有必要执行 finalize() 方法

如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列中,并在稍后由一条虚拟机自动简历的、低调度优先级别的 Finalizer 线程去执行它们的 finalize() 方法。finalize() 方法是对象逃脱垃圾回收的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己,只要重新与引用链上的任意一个对象简历关联即可,比如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合。

image-20210827143436927

一个对象的自我拯救:

/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次, 因为一个对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低, 暂停0.5秒, 以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
        //下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低, 暂停0.5秒, 以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

注意: Finalizer 线程去执行它们的 finalize() 方法, 这里所说的“执行”是指虚拟机会触发这个方法开始运行, 但并不承诺一定会等待它运行结束。 这样做的原因是, 如果某个对象的 finalize() 方法执行缓慢, 或者更极端地发生了死循环, 将很可能导 致 F-Queue 队列中的其他对象永久处于等待, 甚至导致整个内存回收子系统的崩溃。

并发可达性分析

当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活,可达性分析算法理论上要求全过程基于一个能保障一致性的快照中才能进行分析。

垃圾回收器的工作流程大体如下:

  1. 标记出哪些对象是存活的,哪些是垃圾的可回收的;
  2. 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用

三色标记

三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过
  • 黑色:该对象已经访问过,而且该对象引用到的其他对象也全部访问过了
  • 灰色:该对象已经访问过,但是该对象引用到的其他对象尚未全部访问完,全部访问完后,会转换成黑色

image-20210831110240393

假设现在有白、灰、黑三个集合,其遍历访问过程为:

  1. 初始时,所有对象都在白色集合里;
  2. 将GC Roots 直接引用到的对象挪到灰色集合里;
  3. 从灰色集合中获取对象:
    1. 将本对象引用到的其他对象全部挪到灰色集合中
    2. 将该对象挪到黑色集合中
  4. 重复步骤3,直至灰色集合为空时结束
  5. 结束后,仍在白色集合中的对象为 GC Roots 不可达对象,可以进行回收

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

当 Stop The World 时,对象间的引用是不会发生变化的,可以轻松完成标记。 而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

  • 多标

    假设已经遍历到 E (变成灰色了),此时应用执行了 objD.fieldE = null;

    image-20210831112521036

    此刻之后,对象 E/F/G 应该是要被回收的。然而因为 E 已经变成灰色的了,其仍然会被当做存活对象继续遍历下去。最终的结果是:E/F/G 都会标记为存活,本轮 GC 不会回收这部分内存

    这部分本应该回收,但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

  • 漏标

    假设 GC 线程已经遍历到 E (变成灰色了),此时应用线程先执行了:

    G g = objE.fieldG;
    objE.fieldG = null; // 灰色E断开引用白色G
    objD.fieldG = g;  // 黑色D引用白色G
    

    image-20210831113050290

    此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会把 G 放到灰色集合;尽管 D 重新引用了 G,但是因为 D 已经是黑色了,不会再遍历处理。最终导致的结果是:G 会一直停留在白色集合中,最终被当做垃圾清除掉。这直接影响到了程序的正确性,是不可接受的。

    漏标只有同时满足一下两个条件时才会发生:

    • 条件一:

      灰色对象断开了白色对象的引用,即灰色对象原来的成员变量的成员变量的引用发生了变化

    • 条件二:

      黑色对象重新引用了该白色对象,即黑色对象成员变量增加了新的引用

    从代码的角度看:

    G g = objE.fieldG;	// 1.读
    objE.fieldG = null; // 2.写
    objD.fieldG = g;  // 3.写
    
    1. 读取对象 E 的成员变量 fieldG 的引用值 G
    2. 往对象 E 的成员变量 fieldG 写入 null 值
    3. 往对象 D 的成员变量 fieldG 写入 g 值

    我们只要在上面这三步中的任意一步中做一些“手脚”,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots遍历完(并发标记),该集合的对象遍历即可(重新标记)。

    重新标记是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记 STW 的时间,这个是优化问题了。

再谈引用

在 JDK1.2 以前,Java 中引用的定义很传统:如果引用类型中的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种顶一下只有被应用或者没有被引用两种状态。

我们希望能描述这一类对象:

  • 当内存空间还足够时,则能保存在内存中;

  • 如果内存空间在进行垃圾回收后还是很紧张,则可以抛弃这些对象。

很多系统中的缓存对象都符合这样的场景。在 JDK1.2 之后,Java 对引用的概念做了扩充,将引用分为:

  • 强引用(Strong Reference)

    强引用是实用最普遍的引用。如果一个对象具有强引用,那么垃圾回收器不会回收它。当内存空间不足时,JVM 选择抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

    ps:强引用也就是我们平时常用的 A a = new A();

  • 软引用(Soft Reference)

    如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;

    如果内存空间不足了,就会回收这些对象的内存。

    只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java 虚拟机就会把这个软引用加入到与之关联的引用队列中。

  • 弱引用(Weak Reference)

    用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 之后提供了 WeakReference 类来实现弱引用。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  • 虚引用(Phantom Reference)

    “虚引用” 顾名思义,它是最弱的一种引用关系。如果一个对象仅持有虚引用,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

弱引用和软引用的区别:

  1. 更短暂的生命周期;
  2. 一旦发现了只具有软引用的对象,不管当前内存空间足够与否,都会回收它的内存。

虚引用与软引用和弱引用的一个区别:

  1. 虚引用必须和引用队列 (ReferenceQueue)联合使用。
  2. 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

垃圾收集算法

分代收集理论

思想也很简单,就是根据对象的生命周期将内存划分,然后进行分区管理。当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集(Generational Collection)的理论进行设计,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:

垃圾收集器应该将 Java堆划分出不同的区域,然后将回收对象依据其年龄(即对象熬过垃圾收集的次数)分配到不同的区域之中存储。

显而易见,如果一个区域中大多数对象都是朝生夕死,难以熬过垃圾收集过程的话,那么把它们集中放到一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要回收的对象,就能以较低的代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么把它们集中放在一起,虚拟机便可以以较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间的有效利用。

在 Java堆划分出不同的区域之后,垃圾收集器才可以每次回收其中一个或者某些部分区域——因而才有了 Minor GC、Major GC、Full GC 这样的回收类型的划分;也才能够针对不同的区域设置与其存储对象生命周期相匹配的垃圾收集算法——因而发展出了 “标记-清除算法”、“标记-整理算法” 等针对性的垃圾收集算法

针对不同分代的,分为:

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

标记-清除算法

什么是 标记-清除算法?

最早出现也是最基础的垃圾收集算法是 标记-清除(Mark-Sweep)算法,在 1960 年由 Lisp 之父 John McCarthy 所提出。如它的名字一样,算法分为标记和清除两个阶段:

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

也可以反过来,标记存活的对象,统一回收未被标记的对象。

image-20210830142710553

标记-清除算法有两个不足之处:

  • 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致当以 后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

什么是标记-复制算法

标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时效率低的问题,1969 年 Fenichel 提出了一种称为 “半区复制”(Semispace Copying)的垃圾收集算法。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间清理掉。

如果内存中的多数对象都是存活的,这种算法将产生大量的内存间复制的开销,但对于多数都是可回收的情况下,存活对象是占少数的,而且每次都是针对半区进行内存回收,分配内存也就不用考虑有空间碎片的复杂情况,只要一动堆顶指针,按顺序分配即可。

image-20210830143935981

这种算法有以下缺点:

  • 需要提前预留一半的内存空间来操作,这样导致可用的对象区域减少一半,总体的 GC 更加频繁了
  • 如果出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低。如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM 公司曾有一项专门研究对新生代“朝生夕死”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。 因此并不需要按照1∶1的比例来划分新生代的内存空间。

Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor空间, 每次分配内存只使用 Eden 和其中一块 Survivor。 发生垃圾收集时, 将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上, 然后直接清理掉 Eden 和已用过的那块 Survivor 空 间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是8∶1:1, 也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的80%加上一个 Survivor 的10%) , 只有一个 Survivor 空间, 即10%的新生代是会 被“浪费”的。

标记-整理算法

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

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

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。 是否移动回收后的存活对象是一项优缺点并存的风险决策:

image-20210830151118840

是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看, 移动对象会更划算。

垃圾收集器

垃圾收集器与垃圾回收算法

垃圾回收算法分为两类:

  • 判断对象生死算法,如 引用计数法、可达性算法等
  • 收集死亡对象方法,如 标记-清除算法、标记-复制算法、标记-整理算法。

一般的实现采用分代回收算法,根据不同代的特点应用不同的算法。垃圾回收算法是内存回收的方法论。垃圾收集器是算法的落地实现。和回收算法一样,目前还没有出现完美的收集器,而是要根据具体的应用场景选择最合适的收集器,进行分代收集。

垃圾收集器分类

image-20210830151926034

串行垃圾回收(Serial)

串行垃圾回收时为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境

image-20210830153130754

并行垃圾回收(Parallel)

多个垃圾收集器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景。

image-20210830153140899

并发垃圾回收(CMS)

用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程,不需要停顿用户线程,互联网应用程序中经常使用,适用于对响应时间有要求的场景。

image-20210830153215873

G1垃圾回收

G1垃圾回收器将堆内存分割成不同的区域然后并发地对其进行垃圾回收。

七种垃圾收集器及其组合关系

根据分代思想,我们有 7 种主流的垃圾回收器:

在这里插入图片描述

新生代垃圾收集器: Serial、ParNew、Parallel Scavenge

老年代垃圾收集器:Serial Old、Parallel Old、CMS

通用收集器: G1

垃圾收集器的组合关系

image-20210830154416085

JDK8 中默认使用的组合是:Parallel Scavenge GC + Parallel Old GC

JDK9默认是用G1为垃圾收集器

JDK14 弃用了: Parallel Scavenge GC 、Parallel Old GC

JDK14 移除了 CMS GC

GC 性能指标

吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码的时间 / (运行用户代码时间 + 垃圾回收时间) )。例如,虚拟机共运行 100 分钟,垃圾回收花了 1 分钟,吞吐量 = 100 / 101 ≈ 99%。

暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间

内存占用:Java 堆所占内存的大小

收集频率:垃圾收集的频次

新生代收集器

Serial 收集器

单线程收集器,,“单线程”的意义不仅仅说明它只会使用一个CPU或一个收集线程去完成垃圾收集工作; 更重要的是它在垃圾收集的时候,必须暂停其他工作线程,直到垃圾收集完毕。

“Stop The World” 这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。

Serial 和 Serial Old 收集器的运行过程:

image-20210830161511193

Serial 收集器也并不是只有缺点。Serial 收集器由于简单并且高效,对于单 CPU 环境来说,由于 Serial 收集器没有线程间的交互,专心做垃圾收集自然可以做获得最高的垃圾收集效率。使用方式:-XX:+UseSerialGC

ParNew 收集器

ParNew 收集器实际上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略都与 Serial 收集器完全一致,在实现上这两种收集器也共用了很多代码。

ParNew 收集器的工作过程:

image-20210830163722291

ParNew 收集器在单核服务器上的效率不会比 Serial 收集器高,但是在多核 CPU 服务器上,效果会明显比 Serial 好。

使用方式:-XX:+UseParNewGC ,设置线程数 :XX:ParllGCThreads

Parallel Scavenge 收集器

Parallel Scavenge 又称为吞吐量优先收集器,和 ParNew 收集器类似,是一个新生代收集器。使用复制算法的并行多线程收集器。Parallel Scavenge 是 JDK8 默认的收集器,以吞吐量优先。

特点:

  • Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(Throughput)
  • 自适应调解策略,自动指定年轻代、Eden、Suvivor

适合后台运算,交互不多的任务,如批量处理、订单处理、科学计算等。

参数:

  • 使用方式:-XX:+UseParalleGC

  • 最大垃圾收集停顿时间:-XX:MaxGCPauseMillis

    -XX:MaxGCPauseMillis 参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。

    不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的: 系统把新生代调得小一些, 收集 300MB 新生代肯定比收集 500MB 快, 但这也直接导致垃圾收集发生得 更频繁, 原来10秒收集一次、 每次停顿100毫秒, 现在变成5秒收集一次、 每次停顿70毫秒。 停顿时间的确在下降, 但吞吐量也降下来了。

  • 吞吐量大小:-XX:GCTimeRatio

    -XX: GCTimeRatio参数的值则应当是一个大于0小于100的整数, 也就是垃圾收集时间占总时间的 比率, 相当于吞吐量的倒数。 假设GCTimeRatio的值为n,那么系统将花费不超过1/(1+n)的时间用于垃圾收集。譬如把此 参数设置为19, 那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)) , 默认值为99, 即允许最大1%(即 1/(1+99)) 的垃圾收集时间

  • 设置年轻代线程数:-XX:ParllGCThreads

    当 CPU 核数小于等于8,默认与 CPU 核心数相同;当 CPU 核数超过 8 ,ParllGCThreads 设置为 3 + ( 5 * CPU_COUNT) / 8

  • 与 Parallel Scavenge 收集器有关的还有一个参数:-XX:UseAdaptiveSizePolicy

    有了这个参数后,就不用手动指定年轻代、Eden、Suvivor 区的的比例,晋升老年代的对象年龄等,因为虚拟机会根据系统运行情况进行自适应调节。

老年代收集器

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是提供客户端模式下的 HotSpot 虚拟机使用。

特点:

  • 针对老年代
  • 采用 “标记-整理” 算法
  • 单线程收集

执行流程:

image-20210830195137613

应用场景:

主要用于 Client 模式

  1. 在 JDK1.5 及之前,Parallel Scavenge 收集器搭配使用(JDK1.6 有 Parallel Old 收集器可搭配)
  2. 作为 CMS 收集器的后背预案,在并发收集发生 Concurrent Mode Failure 时使用

参数设置:-XX:UseSerialGC

注意事项:

需要说明一下,Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接调用 Serial Old收集器, 但是这个 PS MarkSweep 收集器与 Serial Old 的实现几乎是一样的,所以在官方的许多资料中都是 直接以 Serial Old 代替 PS MarkSweep 进行讲解

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是知道 JDK1.6 才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于尴尬的状态,原因是如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择,其他表现良好的老年代收集器,如 CMS 无法与他配合工作。

工作过程:

image-20210830200435400

应用场景:

JDK1.6 及以后用来代替老年代 Serial Old 收集器,特别是在 Server 模式,多 CPU 的情况下。

参数设置:-XX:+UseParallelOldGC

CMS 收集器

CMS(Concurrent mark sweep)是以获取最短垃圾收集停顿时间为目标的收集器,CMS 收集器的关注点在尽可能缩短垃圾收集时用户线程的停顿时间,目前很大一部分的 Java 应用在互联网的 B/S 系统服务器上,这类应用尤其注重服务器的响应速度,系统停顿时间最短,给用户带来良好的体验,CMS收集器使用的算法是标记-清除算法实现的;

工作过程:
在这里插入图片描述

CMS 整个过程比之前的收集器要复杂,整个过程分为 4 个阶段:初始标记、并发标记、重新标记、并发收集

  • 初始标记(Initial-Mark)阶段:

    这个阶段用户的所有工作线程都会暂停,这个阶段的主要任务是:标记 GC Roots 能够关联到的对象,标记完成后就恢复 STW,由于直接关联对象比较少,所以这里的操作速度非常快。

  • 并发标记(Concurrent-Mark)阶段:

    从 GC Roots 的直接关联对象开始变量整个对象图的过程,这个过程耗时比较长,但是不需要暂停用户线程,用户线程可以与垃圾回收器一起运行。

  • 重新标记(Remark)阶段:

    由于并发标记阶段,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此,为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段也是 STW 的,通常停顿时间比初始标记阶段长一点,但也远比并发标记阶段快。

  • 并发清除(Concurrent-Sweep)阶段:

    此阶段清理删除掉标记判断已经死亡的对象,并释放内存空间,由于不需要移动存活对象,所以这个阶段可以与用户线程同时并发运行

由于最消耗时间的并发标记并发清除阶段都不需要暂停工作,因为整个回收阶段是低停顿(低延迟)的。

CMS 收集器的三个缺点

CMS 收集器对 CPU 资源非常敏感

其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会减低。CMS 默认启动的回收线程数是(处理器核心数量 +3) /4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大。 如果应用本来的处理器负载就很高, 还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

CMS 收集器无法处理浮动垃圾,可能会出现 Concurrent Mode Failure 失败而导致另一次 Full GC 产生

由于 CMS 并发清理阶段用户线程还在运行着,伴随着程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法再当次收集中处理掉它们,只好留到下一次 GC 再亲历掉。这一部分垃圾就称为浮动垃圾。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够的内存空间提供给用户线程使用,因此 CMS 收集器不能像其他垃圾收集器一样等到老年代几乎被填满了再进行垃圾回收,必须预留一部分空间供并发手机是的程序使用。

在 JDK1.5 的默认设置下,CMS 收集器是当老年代使用了 68% 的空间就会被激活,可以通过参数 -XX:CMSInitiatingOccu-pancyFraction 的值来提高 CMS 的触发时机。到了 JDK1.6 时,CMS 收集器的启动阈值就已经默认提升至 92%。 但这又会更容易面临另一种风险: 要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要, 就会出现一次 “并发失败”(Concurrent Mode Failure) , 这时候虚拟机将不得不启动后备预案: 冻结用户线程的执行,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。

空间碎片:CMS 是一款基于标记-清除算法实现的收集器,所以会有空间碎片的现象

当空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,单数无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。

为了解决这个问题, CMS收集器提供了一个参数 -XX:+UseCMS-CompactAtFullCollection 开关参数(默认是开启的,此参数从 JDK 9开始废弃),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程, 由于这个内存整理必须移动存活对象,是无法并发的。 这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction(此参数从 JDK 9开始废弃),这个参数的作用是要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0, 表示每次进入 Full GC 时都进行碎片整理)。

G1 收集器

Garbage First 是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征。

G1 收集器特点

  1. G1 把内存划分为多个独立的区域 Region
  2. G1 仍然保留分代思想,保留了新生代和老年代,但它们不再有物理隔离,而是一部分 Region 的集合。
  3. G1 能充分利用多 CPU 、多核环境硬件优势,尽量缩短 STW
  4. G1 整体采用标记整理算法,局部采用复制算法,不会产生内存碎片
  5. G1 的停顿可预测,能够明确指定在一个时间段内,消耗在垃圾收集上的时间不超过设定时间
  6. G1 跟踪各个 Region 里面垃圾的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限的时间内高效的收集垃圾。

Region区域

G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor空间、老年代空间。

image-20210831154544760

将整个堆空间细分为若干个小的区域

  • 使用 G1 收集器时,它将整个 Java 堆划分为 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,为 2 的 N 次幂,即 1MB、2MB 、4MB …
  • 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过 Region 的动态分配实现逻辑上的连续。
  • G1 垃圾收集器还增加了一种内存区域,Humongous 内存区域,主要用于存储大对象,如果超过 1.5 个region 就放到 Humongous,一般被视为老年代。

G1 GC 过程

G1 提供了两种 GC 模式,Young GC 和 Mixed GC,都是需要 STW 的。

  • Young GC:

    选定年轻代里的所有 Region,通过控制年轻代的 region 个数,即年轻代内存大小,来控制young GC 的时间开销。

  • Mixed GC:

    选定年轻代里所有的 Region,外加根据 global concurrent marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Region。

在G1 GC 垃圾回收的过程有四个阶段:

初始标记:和CMS一样只标记GC Roots直接关联的对象

并发标记:进行GC Roots Traceing过程

最终标记:修正并发标记期间,因程序运行导致发生变化的那一部分对象

筛选回收:根据时间来进行价值最大化收集

image-20210831161402851

G1 Young GC

Young GC 执行前

堆分为大约2000个区域,最小大小为 1 Mb,最大大小为 32 Mb。律师区域保存年轻代对象,蓝色区域保存老年代对象

image-20210831162419443

执行 YoungGC

将存活的对象(即复制或移动)到一个或多个幸存者区域。如果满足老年化阈值,则某些对象将被提升到老年代区域

在这里插入图片描述

G1 的年轻代 GC 结束

image-20210831162637240

最近升级的对象以深蓝色显示,幸存者区域为绿色。

关于 G1 的年轻代:

  • 堆是单个内存空间,分为多个区域
  • 年轻代内存由一组非连续的区域组成
  • 年轻代的垃圾收集器将停止所有应用程序线程以进行操作
  • 年轻代 GC 使用多个线程并行完成
  • 将活动对象赋值到新的幸存者区或者老年代
G1 Mixed GC

初始标记阶段(initial mark,STW)

存活的对象的初始化标记背负在年轻代的垃圾收集器上,此标记为 GC pause (young)(inital-mark)

image-20210831163854889

并发标记阶段(Concurrent Marking)

如果找到空白区域(如 × 所示),则在 Remark 阶段将其立即删除,另外,计算确定活跃度的信息

image-20210831164421883

最终标记阶段(Remark STW)

空区域将被删除并回收,现在可以计算所有区域的区域活跃度

image-20210901142130169

筛选回收阶段/复制清理阶段(Cleanup,STW)

G1 选择“活度”最低的区域,这些区域可以被最快地收集。然后与年轻的 GC 同时收集这些区域。这在日志中表示为 GC pause (mixed) 。因此,年轻代和老年代都是同时收集的。

在这里插入图片描述

**筛选回收阶段-(复制/清理)阶段之后 **

选定的区域已被收集并压缩为图中所示的深蓝色区域和深绿色区域。

image-20210901142256667

总结:

  • 并发标记阶段
    • 活动信息是在应用程序运行时同时计算的
    • 该活动信息标识在疏散暂停期间最适合回收的区域
    • 像 CMS 中没有清扫阶段
  • 最终标记阶段
    • 使用开始快照(SATB)算法,该算法比 CMS 使用的算法快得多
    • 完全回收空区域
  • 筛选回收阶段
    • 同时回收年轻代和老年代
    • 老年代地区是根据其活跃度来选择的
G1 常用参数
参数/默认值含义
-XX:+UseG1GC使用 G1垃圾收集器
-XX:MaxGCPauseMillis=200设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)
-XX:InitiatingHeapOccupancyPercent=45mixed gc 中也有一个阈值参数 ,当老年代大小占整个堆大小百分 比达到该阈值时,会触发一次mixed gc
-XX:NewRatio=2新生代与老生代(new/old generation)的大小比例(Ratio)
-XX:SurvivorRatio=8eden/survivor 空间大小的比例(Ratio)
-XX:MaxTenuringThreshold=15提升年老代的最大临界值(tenuring threshold)
-XX:ParallelGCThreads=n设置垃圾收集器在并行阶段使用的线程数,默认值随 JVM 运行的平台不同而不同
-XX:ConcGCThreads=n并发垃圾收集器使用的线程数量. 默认值随 JVM 运行的平台不同而不同
-XX:G1ReservePercent=10设置堆内存保留为假天花板的总量,以降低提升失败的可能性
-XX:G1HeapRegionSize=n使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指 定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值 为 1Mb, 最大值为 32Mb.
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值