【JVM学习】4.垃圾回收机制及算法


1 定义

垃圾收集器(Garbage Collector,下文简称GC)。

程序计数器虚拟机栈本地方法栈随线程而生,也随线程而灭

栈帧随着方法的开始而入栈,随着方法的结束而出栈。这几个区域的内存分配和回收都具有确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了

而对于 Java 堆方法区,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的GC所关注的正是这部分内存。

2 回收对象

即哪些对象是GC的目标,也就是如何判定对象是否不可用或者说已死。

JVM说:若一个对象不被任何对象或变量引用,那么它就是无效对象,需要被回收。

常用的判定方法有:引用计数法、可达性分析法。

2.1 引用

判定对象是否存活都和引用离不开关系。

JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用

JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱

2.1.1 强引用

类似Object obj=new Object()这种引用关系。

无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

2.2.2 软引用

用来描述一些还有用但非必须的对象

只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常

JDK 1.2版之后提供了SoftReference类来实现软引用。

2.2.3 弱引用

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

JDK 1.2版之后提供了WeakReference类来实现弱引用。

2.2.4 虚引用

也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。

为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

JDK 1.2版之后提供 了PhantomReference类来实现虚引用。

2.2 引用计数法

在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。 当计数器为 0 时,就认为该对象无效了。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题

2.3 可达性分析法

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

如图3-1所示,对象object 5object 6object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

利用可达性分析算法判定对象是否可回收

GC Roots对象包括下面几种(重点是前面 4 种):

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象;java类的引用类型静态变量。
  • 方法区中常量引用的对象,比如:字符串常量池里的引用。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • JVM内部的JMXBeanJVMTI中注册的回调、本地代码缓存等。
  • JVM实现中的临时性对象,跨代引用的对象

Tips:
以上的回收都是对象,对于类的回收条件,请注意 Class 要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • 参数控制:-Xnoclassgc,禁用类的垃圾回收。

废弃的常量和静态变量的回收其实就和 Class 回收的条件差不多。比如常量池中字面量回收的例子,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。如,一个字符串 “bingo” 进入了常量池,但是当前系统没有任何一个 String 对象引用常量池中的 “bingo” 常量,也没有其它地方引用这个字面量,必要的话,”bingo”常量会被清理出常量池。

2.4 回收堆中无效的对象

可达性分析算法中判定为不可达的对象,也不是非死不可的。

它还会处于缓刑阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize),我们可以在 finalize 中去拯救。

比如:在执行 finalize() 方法时,将 this 赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。

代码演示拯救对象,“你,只有一次机会”:

public class FinalizeGC {
    public static FinalizeGC instance = null;

    public void isAlive() {
        System.out.println("I am still alive!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeGC.instance = this;
    }

    public static void main(String[] args) throws Throwable {
        instance = new FinalizeGC();
        //对象进行第1次GC
        instance = null;
        System.gc();
        Thread.sleep(1000); //Finalizer 方法优先级很低,
        if (instance != null) {
            instance.isAlive();
        } else {
            System.out.println("I am dead!");
        }
        //对系选衍第2次GC
        instance = null;
        System.gc();
        Thread.sleep(1000);
        if (instance != null) {
            instance.isAlive();
        } else {
            System.out.println("I am dead! ");
        }
    }
}

运行结果:

finalize method executed! 
I am still alive!
I am dead!

可以看到,对象可以被拯救一次(finalize 执行第一次,但是不会执行第二次)。

代码改一下,再来一次:

public class FinalizeGC {
    public static FinalizeGC instance = null;

    public void isAlive() {
        System.out.println("I am still alive!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeGC.instance = this;
    }

    public static void main(String[] args) throws Throwable {
        instance = new FinalizeGC();
        //对象进行第1次GC
        instance = null;
        System.gc();
        // 注释掉这行   Thread.sleep(1000); //Finalizer 方法优先级很低,
        if (instance != null) {
            instance.isAlive();
        } else {
            System.out.println("I am dead! ");
        }
        //对象进行第2次GC
        instance = null;
        System.gc();
        //注释掉这行 Thread. sleep (1000) ;
        if (instance != null) {
            instance.isAlive();
        } else {
            System.out.println("I am dead! ");
        }
    }
}

运行结果:

I am dead!
finalize method executed! 
I am dead!

对象没有被拯救,这个就是 finalize 方法执行缓慢,还没有完成拯救,垃圾收集器就已经回收掉了

所以建议大家尽量不要使用 finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了 finalize 方法!因为在 finalize 方法能做的工作,java 中有更好的,比如 try-finally 或者其他方式可以做得更好。

System.gc();只做测试,千万不要在实际生产中使用。不然,后果自负!

3 回收算法

学会了如何判定无效对象、无用类、废弃常量之后,剩余工作就是回收这些垃圾。常见的垃圾收集算法有以下几个:

3.1 标记-清除算法

  • 标记:遍历所有的 GC Roots,然后将所有 GC Roots可达的对象标记为存活的对象
  • 清除:遍历堆中所有的对象,将没有标记的对象全部清除掉。与此同时,清除那些被标记过的对象的标记,以便下次的垃圾回收。
    “标记-清除”算法示意图
    这种方法有两个不足:
  • 效率问题:标记和清除两个过程的效率都不高。
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.2 标记-复制算法

常被简称为复制算法

为了解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。

“标记-复制”算法示意图

这种算法有优有劣:

  • 优点:不会有内存碎片的问题。
  • 缺点:内存缩小为原来的一半,浪费空间。

为了解决空间利用率问题,可以将内存分为三块:EdenFrom SurvivorTo Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 EdenSurvivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。

但是我们无法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够,需要依赖其他内存(指老年代)进行分配担保

分配担保(Handle Promotion):为对象分配内存空间时,如果 Eden+Survivor 中空闲区域无法装下该对象,会触发 MinorGC 进行垃圾收集。但如果 Minor GC 过后依然有超过 10% 的对象存活,这样存活的对象直接通过分配担保机制进入老年代,然后再将新对象存入 Eden 区。

内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。

3.3 标记-整理算法

  • 标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历 GC Roots,然后将存活的对象标记。
  • 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段
    “标记-整理”算法示意图

    这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低。

3.4 分代收集算法

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

  • 新生代:复制算法
  • 老年代:标记-清除算法、标记-整理算法。

4 Hotspot垃圾收集器

HotSpot虚拟机的垃圾收集器

4.1 新生代

4.1.1 Serial 垃圾收集器(单线程)

最古老的,单线程独占式,成熟,适合单 CPU,一般用在客户端模式下。

  • 优点:简单而高效;适合几十兆一两百兆堆空间进行垃圾回收(可以控制停顿时间100ms 左右)。
  • 缺点:进行垃圾收集时,必须暂停其他所有工作线程Stop the world,STW),直到它收集结束。
  • 参数设置:-XX:+UseSerialGC, 新生代和老年代都用串行收集器。
    Serial/Serial Old垃圾收集器运行示意图

Tips:Stop The World(STW)(重点)
单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”,但是这种 STW 带来了恶劣的用户体验,例如,应用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVMjavaC/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。

4.1.2 ParNew 垃圾收集器(多线程)

实质上是Serial收集器多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure等)、收集算法StopTheWorld对象分配规则回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

在 JDK9 以后,把 ParNew 合并到了 CMS 了。

ParNew垃圾收集器运行示意图

4.1.3 Parallel Scavenge 垃圾收集器(多线程)

Parallel ScavengeParNew 一样,都是多线程新生代垃圾收集器。但是两者有巨大的不同点:

  • Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
  • ParNew:追求降低用户停顿时间,适合交互式应用。
    Parallel Scavenge/Parallel Old垃圾收集器运行示意图

JDK1.8 默认的新生代垃圾收集器。

  • 参数设置
    • -XX:GCTimeRadio:设置垃圾回收时间占总 CPU 时间的百分比。
    • -XX:MaxGCPauseMillis:设置垃圾处理过程最大停顿时间。
    • -XX:+UseAdaptiveSizePolicy:开启自适应策略。我们只要设置好堆的大小MaxGCPauseMillisGCTimeRadio,收集器会自动调整新生代的大小(-Xmn)、Eden 和 Survivor 的比例对象进入老年代的年龄,以最大程度上接近我们设置的 MaxGCPauseMillisGCTimeRadio
    • -XX:+UseParallelGC:新生代使用 Parallel Scavenge,老年代使用 Parallel Old

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

4.2 老年代

4.2.1 Serial Old 垃圾收集器(单线程)

Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程,都适合客户端应用。它们唯一的区别就是:Serial Old 工作在老年代,使用“标记-整理”算法;Serial 工作在新生代,使用“复制”算法。

4.2.2 Parallel Old 垃圾收集器(多线程)

Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。

4.2.3 CMS 垃圾收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

CMS 收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:

  • 初始标记:短暂,仅仅只是标记一下 GC Roots直接关联到的对象,速度很快。
  • 并发标记:和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GC Roots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾收集器线程和用户线程同时工作)。
  • 重新标记:短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清除:只使用一条 GC 线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。

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

CMS垃圾收集器运行示意图
  • 缺点:
    • 吞吐量低
    • 无法处理浮动垃圾,导致频繁 Full GC
    • 使用“标记-清除”算法产生空间碎片

对于产生空间碎片的问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。设置参数 - XX:CMSFullGCsBeforeCompaction告诉 CMS,经过了 NFull GC 之后再进行一次内存整理

  • 参数设置:在 JDK1.8 中,配置参数-XX:UseConcMarkSweepGC,启用CMS垃圾收集器。

4.3 G1 通用垃圾收集器(JDK7出现,JDK9默认)

G1 是一款面向服务端应用的垃圾收集器,它没有新生代和老年代的概念,而是将划分为一块块独立的 Region。当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。

G1中的Region
  • G1 收集器的工作过程分为以下几个步骤:
    • 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots直接关联的对象进行标记。
    • 并发标记:使用标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
    • 最终标记:Stop The World,使用多条标记线程并发执行。
    • 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行。
G1垃圾收集器运行示意图

特点:

  • 并行与并发

  • 分代收集

  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。

  • 追求停顿时间:-XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。

  • 参数设置:

    • -XX:+UseG1GC:开启G1垃圾收集。
    • -XX:+G1HeapRegionSize:分区大小。
    • -XX:MaxGCPauseMillis:指定最大停顿时间(毫秒),默认没有最大停顿值。

4.4 Shenandoah收集器

非Oracle亲生,由RedHat贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一。

这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMSG1Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。

具体的运行过程,可参考《深入理解Java虚拟机》第三版Page 157。这里只给出示意图:

Shenandoah垃圾收集器运行示意图
  • G1的区别(管理堆内存方面):
    • 支持并发的整理算法:G1回收阶段是可以多线程并行的,但却不能用户线程并发,Shenandoah使用转发指针读屏障来实现并发整理;
    • 默认不使用分代收集,换言之,不会有专门的新生代Region或者老年代Region的存在;
    • 记忆集:改用名为连接矩阵(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

Tips:
伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题

4.5 ZGC(JDK11开始)

Z Garbage Collector,即ZGC,是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。2018年Oracle创建了 JEP 333ZGC提交给OpenJDK,推动其进入OpenJDK 11的发布清单之中。

同样是基于Region内存布局的,(暂时)不设分代的,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

Shenandoah的目标高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但是ZGCShenandoah的实现思路又是差异显著的,如果说RedHat公司开发的Shenandoah像是OracleG1收集器的实际继承者的话,那Oracle公司开发的ZGC就更像是Azul System公司独步天下的PGC(Pauseless GC)和C4(Concurrent Continuously Compacting Collector)收集器的同胞兄弟。

ShenandoahG1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGCRegion具有动态性—动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有三类容量:

  • 小型Region(SmallRegion):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)的,因为复制一个大对象的代价非常高昂。
    ZGC的堆内存布局

ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer),以下为ChatGPT关于染色指针技术的回答,仅供参考。

基本原理:

染色指针:在64位的JVM中,通常使用最后几位(如1~3位)的指针标记位作为染色指针。这些指针位用于标记对象的颜色信息。

对象颜色:每个对象都有一个颜色,可以是白色、灰色或黑色。
  白色:表示对象不可达,即尚未标记为垃圾。
  灰色:表示对象已经被标记为垃圾,但与其相关的其他对象还未进行标记。
  黑色:表示对象及其相关对象都已经标记为垃圾。

染色指针的应用:
  并发标记:在并发标记阶段,ZGC会从根对象开始标记,将灰色对象变为黑色对象,并遍历其引用的其他对象进行标记。染色指针用于标记哪些对象已经在并发标记过程中被标记为灰色。
  安全点判断:由于并发标记过程中应用程序可能持续运行,需要判断在何时进行安全点停顿以处理染色指针的变化。ZGC通过使用屏障插入点来探测染色指针的变化,并在需要时进行安全点停顿和处理。

优点:
  减少停顿时间:通过使用染色指针,ZGC可以实现并发标记,减少了垃圾收集对应用程序的停顿时间。
  高效的对象标记:染色指针使得并发标记过程更加高效,避免了不必要的重复标记操作。
  空间效率高:由于染色指针仅占用指针的几位,节省了额外的内存空间。
染色指针示意图
  • 染色指针的三大优势:

    • 可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个中所有指向该Region的引用都被修正后才能清理。
    • 可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
    • 可以作为一种可扩展的存储结构用来记录更多与对象标记重定位过程相关的数据,以便日后进一步提高性能。
  • ZGC的运作过程:

    • 并发标记(Concurrent Mark):与G1Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked0Marked1标志位。
    • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
    • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
    • 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中旧对象的所有引用
      ZGC垃圾收集器运行示意图

4.6 Epsilon收集器

不能够进行垃圾收集为“卖点”的垃圾收集器(改名叫自动内存管理子系统最妥了)。由 RedHat 推出,它还要负责堆的管理与布局对象的分配与解释器的协作与编译器的协作与监控子系统协作等职责,主要用于需要剥离垃圾收集器影响的性能测试和压力测试。

4.7 垃圾收集器整理

收集器收集对象和算法收集器类型说明适用场景
Serial新生代,复制算法单线程简单高效。适用内存不大的情况
ParNew新生代,复制算法并行的多线程收集器Serial的多线程版本搭配CMS的首选
Parallel Scavenge新生代,复制算法并行的多线程收集器类似ParNew,更加关注吞吐量,达到可控的吞吐本身是server级别的多CPU机器上的首选GC,适合后台运算不需要太多交互的任务
Serial Old老年代,标记-整理算法单线程Client模式下的虚拟机使用
Parallel Old老年代,标记-整理算法并行的多线程收集器Parallel Scavenge的老年代版本,为了配合Parallel Scavenge面向吞吐量特性而开发的对应组合在注重吞吐量和CPU资源敏感的场景使用
CMS老年代,标记-清理算法并行、并发收集器尽可能的缩短垃圾收集时用户线程停止时间;缺点在于:1)内存碎片;2)需要更多 cpu 资源;3)浮动垃圾问题,需要更大的堆空间重视服务的响应速度系统停顿时间用户体验的互联网网站或者 B/S 系统。互联网后端目前CMS是主流的垃圾收集器
G1跨新生代和老年代;标记-整理 + 化整为零并行、并发收集器JDK1.7 才正式引入,采用分区回收的思维,基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势面向服务端应用的垃圾收集器,目标为取代CMS
Shenandoah按Region,无分代一说;标记-清理-回收-引用更新并发收集器G1升级款,非Oracle亲生,支持并发回收是与Hotspot垃圾收集器最大不同可替代G1
ZGC按Region,无分代一说;标记-整理算法并发收集器动态Region、染色指针技术、支持“NUMA-Aware”的内存分配以后的主流垃圾收集器,真正的低停顿

5 内存分配与回收策略

5.1 对象优先在Eden分配

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

Tips:
新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。
混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

总结一下有哪些情况可能会触发JVM进行Full GC

  • System.gc()方法的调用:此方法的调用是建议JVM进行Full GC,注意这只是建议而非一定,但在很多情况下它会触发Full GC,从而增加Full GC的频率。通常情况下我们只需要让虚拟机自己去管理内存即可,我们可以通过-XX:+DisableExplicitGC来禁止调用System.gc()
  • 老年代空间不足:老年代空间不足会触发Full GC操作,若进行该操作后空间依然不足,则会抛出如下错误:java.lang.OutOfMemoryError: Java heap space
  • 永久代空间不足:JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中也称为永久代(Permanent Generation),存放一些类信息、常量、静态变量等数据,当系统要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,会触发Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space
  • CMS GC 时出现promotion failedconcurrent mode failurepromotion failed,就是上文所说的担保失败,而concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。
  • 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间。

5.2 大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,如长字符串数量庞大的数组

一个大对象能够存入 Eden 区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及大量的复制,就会造成效率低下。

参数设置:-XX:PretenureSizeThreshold

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

还记得对象头的结构吗?对的,就是那个分代年龄。JVM给每个对象定义了一个对象年龄计数器。当新生代发生一次 Minor GC 后,存活下来的对象年龄 +1,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。

参数设置:-XX:M axTenuringThreshold,默认值15

5.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

5.5 空间分配担保

JDK 6 Update 24 之前的规则是这样的:

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间

  • 如果这个条件成立,Minor GC可以确保是安全的
  • 如果不成立,则虚拟机会查看 HandlePromotionFailure 值是否设置为允许担保失败,
    • 如果否,Full GC
    • 如果是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
      • 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
      • 如果小于,那此时也要改为进行一次Full GC

JDK 6 Update 24 之后的规则变为:

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC

通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。这个过程就是分配担保

5.6 本地线程分配缓冲(TLAB)

把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java 堆预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden 区域申请一块继续使用。

TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是可以被所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB

设置参数:-XX:+UseTLAB,允许在年轻代空间中使用TLAB。默认情况下启用此选项。要禁用TLAB,请指定-XX:-UseTLAB


文章作者: Kezade
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Kezade !
评论
  目录