JVM学习笔记 - 04常见GC解析

699 阅读3分钟

什么是垃圾?如何定位?

没有引用指向的对象都是辣鸡!咳咳,淡定(最近真的被大妈问“你是什么垃圾”了)。 呐,C/C++都是手动回收内存,那Java是咋定位垃圾的呢?当然是你听过了无数遍的:

  • 引用计数(ReferenceCount):缺点都了解,循环引用的对象无法回收;
  • 根可达算法(RootSearching):啥是根呢?线程栈里创建的变量(包括main方法等栈帧里的对象)、静态变量访问的对象、常量池以及JNI指针。

常见的垃圾回收算法

标记清除(Mark Sweep)

找到并标记没用的对象,然后直接从给内存中清除。 需要进行两次扫描,第一次扫描进行标记操作:从GC根开始找到不可回收对象;第二次扫描进行清除操作。

**优点:**算法相对简单;并且存活对象比较多的情况效率高;

**缺点:**需要扫描两边,执行效率低;容易产生碎片,位置不连续。

拷贝算法(Copying)

需要两次扫描:第一次扫描找到不可回收对象;第二次扫描移动不可回收对象。

**优点:**没有碎片,适用于存活对象少的情况;只需要扫描一次,适合Eden区;

**缺点:**浪费空间;移动复制对象需要调整对象的引用,耗费计算资源。

标记压缩(Mark Compact)

把不可回收的对象压缩整理到这块内存的前面。 需要扫描两次:第一次找到不可回收对象;第二次移动不可回收对象,涉及到多线程和同步等问题。

**优点:**不会产生碎片,内存也不会减半; **缺点:**需要扫描两次;需要移动对象,效率低。

JVM内存分代模型(用于分代垃圾回收算法)

本文的垃圾回收器只介绍到G1,如Epsilon、ZGC和Shenandoah等不做介绍。

堆内存分代模型逻辑分区如下图所示: 分代模型中分为新生代和老年代,新生代又分为Eden和两个Survivor。一个对象产生后会尝试在Stack上进行分配(TLAB),如果分配不下则分配至Eden区。一次YGC后从Eden到Survivor1,第二次YGC从Survivor1到Survivor2,到了一定年龄后进入老年代。

Card Table

这里插一个基本概念Card Table。由于做YGC时,需要扫描整个Old区,效率非常低,所以JVM设计了Card Table。如果一个Old区的Card Table中有对象指向Young区,就将它设为Dirty。下次扫描时只需要扫描Dirty Card。在结构上,Card Table用的是BitMap来实现的。

TLAB

关于线程本地分配TLAB(Thread Local Allocation Buffer)

  • 占用Eden区内存,默认1%;
  • 多线程的时候不用竞争就可以申请空间,效率高;
  • 对象为线程私有小对象,无逃逸;
  • 支持标量替换,即对象如果不被外部引用,则不创建该对象,并且会被Java中诸如int,long等基本数据类型以及reference类型等的标量进行代替。

举个栗子:

public class Test {
    class InnerTest {
        int id;
        String str;

        public InnerTest(int id, String str) {
            this.id = id;
            this.str = str;
        }
    }
    void alloc(int i) {
        new InnerTest(i, "test" + i);
    }
    public static void main(String[] args) {
        Test t = new Test();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 1000_0000; i++) {
            t.alloc(i);
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
}

如果加上如下几个参数运行时间会变长:

-XX:-DoEscapeAnalysis 去掉逃逸分析
-XX:-EliminateAllocations 去掉标量替换
-XX:-UseTLAB 去掉TLAB

对象如何进入老年代

  1. 超过-XX:MaxTenuringThreshold指定次数(YGC),以下为不同垃圾回收器的默认年龄:
  • Parallel Scavenge: 15
  • CMS: 6
  • G1: 15
  1. 动态年龄:对象从Survivor1拷贝到Survivor2以及对象从Eden拷贝到Survivor2时,如果大小超过Survivor2的一半,则将年龄最大的对象放进老年代。

最后总结一下,下图为对象的分配过程

常见垃圾回收器

常用垃圾回收器,以及组合如下图所示:

Serial

  • 算法:Copying
  • 适用内存:几十M!
  • 线程:单线程
  • 是否会STW:是

Serial Old

  • 算法:Mark Compact或Mark Sweep
  • 线程:单线程
  • 是否会STW:是

流程与Serial相同,这里就不画图了。

Parallel Scavenge

  • 算法:Copying
  • 适用内存:几百M - 几G
  • 线程:多线程
  • 是否会STW:是

Parallel Old

  • 算法:Mark Compact
  • 线程:多线程
  • 是否会STW:是

流程图同Parallel Scavenge

ParNew

专门为CMS二生的新生代并行垃圾回收器。

  • 算法:Copying
  • 线程:多线程
  • 是否会STW:是

流程图同Parallel Scavenge

CMS(Concurrent Mark Sweep)

  • 算法:看名称就知道是Mark Sweep
  • 适用内存:20G左右
  • 线程:具体看流程图
  • 是否会STW:具体看流程图

图中展示了CMS的四个步骤:

  • 初始标记:只标记根上的对象,会发生STW,但由于根对象少,所以STW时间短;
  • 并发标记:工作线程和GC线程同时工作;
  • 重新标记:重新堆并发标记时产生的新垃圾进行标记,会发生STW;
  • 并发清理:清理标记完的垃圾,同时工作线程也在运行。该步骤产生的浮动垃圾,在下一次垃圾回收时清理。

目前没有任何一个JDK版本的默认垃圾回收器是CMS,甚至在JDK9中CMS还被废弃了。这说明CMS是存在无法彻底解决的问题的:

  • 内存碎片化:CMS为了减少STW时间使用的是Mark Sweep,在老年代会产生很多碎片内存。当新生代到老年代的数据找不到位置存放的时候,就会提前产生FGC;
  • 浮动垃圾:在并发清理步骤中,工作线程会产生新的垃圾,这部分只能在下次垃圾回收中清理。但是当预留空间不足时,就会请出Serial Old这个老奶奶使用单个垃圾回收线程通过Mark Compact来回收垃圾,可能会产生很长很长时间的STW。

针对上面的问题,有几个减少影响的方法,但是不能完全避免:

  • -XX:CMSInitiatingOccupancyFraction表示CMS FGC的阈值。可以降低该阈值,让老年代保持足够空间;
  • -XX:+UseCMSCompactAtFullCollection表示FGC时使用Mark Compact,减少内存碎片化,但是会增加并发清理的时间;
  • -XX:CMSFullGCsBeforeCompaction表示多少次FGC后进行压缩,也可以一定程度减少内存碎片化。

虽然CMS有这么多不能完全避免的缺点,但是它的出现却是一个里程碑。下面要介绍的G1就用到了CMS的思想。

G1

Garbage First Garbage Collector Tuning,是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上。它在多数情况下可以实现指定GC暂停时间,同时还能保持较高的吞吐量。

G1也使用了分代模型,与PS+PO和CMS的物理分代不同,G1是逻辑分代。G1将内存划分成多个大小相等的Region,每个Region被标记成E、S、O、H,分别表示Eden、Survivor、Old、Humongous。其中E、S属于新生代,O与H属于老年代。

CSet(Collection Set)

CSet是一组被回收的分区集合,占用堆的1%不到的大小。

RSet(Remembered Set)

每个Region都有一个HashTable,记录其他Region中的对象到本Region对象的引用。使得垃圾回收器不需要扫描整个堆栈来寻找到底是谁引用了当前分区中的对象,只需要扫描RSet即可。当然,RSet会占用一定空间,后来出现的ZGC的一个改进点就是针对该问题取消了RSet。

新老年代内存比例

原来的分代模型的新老年代内存比例是1:2,当然是可以手动指定的。G1的比例是动态的,,默认在5% - 60%。此处建议不要手动指定,因为这是G1预测停顿时间的基准。因为G1会跟踪每次STW的时间,如果这次STW时间比较长,比如这次YGC时间大于我设定的时间,下次年轻代的Region就调整少一点。

G1的三种GC

G1分YGC、MixedGC和FGC。

YGC: 和其他的垃圾回收器没有什么区别,Eden区空间不足就会触发。

MixedGC: 堆空间超过-XX:InitiatingHeapOccupacyPercent(默认45%)开始MixecGC。整个过程相当于一个CMS,也有初始标记、并发标记、重新标记和筛选回收。当然筛选回收是G1和CMS的区别。筛选回收步骤会筛选垃圾占用最多的Region,这些Region直接把存活对象复制到另一块Region,不像CMS那样会产生碎片。

FGC: 如果筛选回收没有足够空间存放存活对象,或者并发处理过程完成之前空间耗尽,就会发生FGC。JDK10之前使用的是串行回收,之后才是并行回收。

三色标记

在并发标记对象的过程中,对象引用关系正在发生改变,所以会发生漏标的情况。先介绍一下三色标记法:

  • 黑色:未标记的对象
  • 灰色:自身被标记,成员变量未被标记
  • 黑色:自身和成员变量均未标记

漏标: 需要以下两个条件同时发生: 在并发标记过程中,A指向D,并且B对D的指向没有了。此时,不会对A指向的D进行扫描,这个白色D对象就漏标了。如下图: 要解决漏标问题,只需要打破任意条件就行,即跟踪黑色A指向的增加,或跟踪灰色B指向的消失。

增量更新(Incremental Update): 即跟踪黑色指向的增加。关注引用的增加,把黑色A重新标记为灰色,下次重新扫描。(CMS使用的是增量更新)

SATB(Snapshot at The Beginning): 并发标记开始做个快照,关注引用B -> D的删除,当白色D消失时,把这个引用推到GC的对战,保证白色D还是能被GC扫描到。

G1使用的是SATB,因为增量更新需要重新扫描引用,而SATB只需要扫描堆栈里的对象。当灰色B -> 白色D的引用消失并推到堆栈后,由于RSet的存在,下次扫描时不需要扫描整个堆空间去查找指向白色的引用,直接从RSet就可以拿到是哪个Region中的对象引用了它,效率更高。

ZGC

NUMA TODO