JVM GC,收集器,垃圾收集算法实现细节

GC垃圾回收

1、对象已死?

1.1、引用计数法

只要一个对象被其他变量所引用,就让这个对象的一个引用计数加一,如果这个对象不再被某一变量引用,则减一,这样非常直观也很好实现,不过存在以下弊端:

image-20211211103631150

上面这种情况下,一个A引用B,B引用A,但是A,B缺不被外部任何变量所引用,这样他们的引用计数就永远不能减为零,早期的Python虚拟机便是采用这种算法,至少主流的Java虚拟机没有采用过这个算法

1.2、可达性分析算法

Java便是采用的这个算法,它首先要确定一系列根对象GC Root,在执行垃圾回收的时候,虚拟机会扫描所有堆中的对象,沿着根对象为起点的引用链找到该对象,当一个对象被根对象间接或直接的引用,则它不能被回收,反之则会被回收

1.2.1、GC Roots

所谓根对象,可以是线程栈的本地变量,静态变量,本地方法栈的变量等等,也可以理解为肯定不会被垃圾回收的对象,在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepitonOutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBeanJVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

  • 譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性

1.3、四种引用

  • 强引用
    • 只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
  • 软引用
    • 仅有软引用引用该对象时,在垃圾回收的时候,只有在内存不足的时候,才会触发回收软引用对象可以配合引用队列来释放软引用自身,非常适合做缓存
  • 弱引用
    • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象可以配合引用队列来释放弱引用自身
  • 虚引用
    • 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
1.3.1、强引用

即最普通的引用

/**
 * @author PengHuanZhi
 * @date 2021年12月20日 20:43
 */
public class NormalReferenceTest {
    public static void main(String[] args) throws IOException {
        M m = new M();
        m = null;
        System.gc();
        System.out.println(m);
        System.in.read();
    }

    static class M {
        @Override
        protected void finalize() {
            System.out.println("finalize");
        }
    }
}

image-20211220204840703

其中nullfinalize顺序是不确定的,因为GC线程和Main线程是两个不同的线程,GC何时回收M,由他自己决定

1.3.2、软引用
/**
 * @author PengHuanZhi
 * @date 2021年12月20日 20:54
 */
public class SoftReferenceTest {
    public static void main(String[] args) {
        SoftReference<byte[]> s = new SoftReference<>(new byte[1024 * 1024 * 10]);
        System.out.println(s.get());
        System.gc();
        try {
            Thread.sleep(500);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(s.get());
        byte[] b = new byte[1024 * 1024 * 15];
        System.out.println(s.get());
    }
}

现在堆内存中创建10M大小的byte数组,然后再将其指给一个SoftReference,这里SoftReference对象软引用于这个字节数组,而s则是强引用于这个SoftReference

image-20211220210507253

现在添加一个系统变量,设置堆内存大小为15M

-Xmx25M

再重启

image-20211220210610210

结论就是,如果GC发现当前内存不足了,相比较于强引用,他永远不会回收一个强引用对象,但会去尝试回收软引用的对象

1.3.3、弱引用
/**
 * @author PengHuanZhi
 * @date 2021年12月20日 21:25
 */
public class WeakReferenceTest {
    public static void main(String[] args) {
        WeakReference<M> w = new WeakReference<>(new M());
        System.out.println(w.get());
        System.gc();
        System.out.println(w.get());
    }

    static class M {
        @Override
        protected void finalize() {
            System.out.println("finalize");
        }
    }
}

image-20211220212729436

可以发现GC二话不说直接就给他回收了,不管是否内存不足

1.3.4、虚引用

用处最多的地方就是管理直接内存,因为直接内存不受GC管理,当JVM中的一个引用直接内存的对象,被回收了,但它所指向的直接内存不能被回收,所以就需要一种机制,当使用了直接内存的对象被回收后,需要同步通知JVM去清理GC管不着的堆外内存

/**
 * @author PengHuanZhi
 * @date 2021年12月21日 9:25
 */
public class PhantomReferenceTest {
    private static final List<Object> LIST = new LinkedList<>();
    private static final ReferenceQueue<M> REFERENCE_QUEUE = new ReferenceQueue<>();

    public static void main(String[] args) {
        PhantomReference<M> phantomReference = new PhantomReference<>(new M(), REFERENCE_QUEUE);
        System.out.println(phantomReference.get());
    }

    static class M {
        @Override
        protected void finalize() {
            System.out.println("finalize");
        }
    }
}

那么我们在创建一个虚引用对象的时候,就应该为其分配一个虚引用队列,每当垃圾回收线程执行的时候,会去判断虚引用队列中的对象是否有被回收,如果被回收,再判断其是否有直接内存引用,如果有,它所指向的直接内存地址还能被拿出来,然后被JVM所回收

1.4、生存还是死亡?

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

  • 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记
  • 随后进行一次筛选,筛选的条件是此对象是否有必要执行**finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()**方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

1.5、finalize方法

  • 对象在被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法,但并不承诺一定会等待它运行结束,因为如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃

  • finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象在此方法中重新与引用链上的任何一个对象建立关联,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。每当要回收的时候都调用**finalize()方法,岂不是死不掉了?当然不是,任何一个对象的finalize()**方法都只会被执行一次

建议大家尽量避免使用它,因为它并不能等同于CC++语言中的析构函数,而是Java刚诞生时为了使传统CC++程序员更容易接受Java所做出的一 项妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。有些教材中描述它适合做“关闭外部资源”之类的清理性工作,这完全是对finalize() 方法用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、 更及时,所以笔者建议大家完全可以忘掉Java语言里面的这个方法。

2、垃圾回收算法

2.1、分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在三个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数,因为存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set

几个GC术语

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

2.2、标记清除

扫描整个堆内存中的对象,当一个对象没有被GC Root对象直接间接引用,则将其标记为垃圾

image-20211221112452682

然后第二步就是将这些垃圾进行清除操作即可

image-20211221112952130

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

2.3、标记复制

需要占用双倍内存空间,将原始FROM空间的可用对象,全部相邻复制到TO空间中,然后FROM中的垃圾全部清空,双方角色互换,这样操作也不会产生内存碎片

image-20211221113801629

  • 缺点:这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

2.4、标记整理

第一步与标记清除算法一致

第二步在清除的基础上,会把可用的对象向前移动,让内存更加紧凑

image-20211221113308030

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World” 。

3、分代垃圾回收

image-20211221114130967

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from(第一次GCfrom还没有对象) 存活的对象使用 copy 复制到 to 中,存活的对象年龄加1并且交换 from to
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时(认为这个对象经常会被使用,不太可能被回收),会晋升至老年代,最大寿命是154bit
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gcstop the world的时间更长

3.1、相关VM参数

含义参数
堆初始大小-Xms
堆最大大小-Xmx 或 -XX:MaxHeapSize=size
新生代大小-Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )即初始和最大
幸存区比例(动态)-XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例-XX:SurvivorRatio=ratio默认是0.8(伊甸园)
晋升阈值-XX:MaxTenuringThreshold=threshold
晋升详情-XX:+PrintTenuringDistribution
GC详情-XX:+PrintGCDetails -verbose:gc
FullGC前MinorGC-XX:+ScavengeBeforeFullGC

新生代和老年代默认占用比例为1:3,即如果给堆内存分配3G,那么新生代占用1G,老年代占用2G

image-20211224194526431

3.2、案例演示1

运行下面Demo前,添加如下VM参数

  • -Xms20M:堆初始大小为20M
  • -Xmx20M:堆最大大小为20M
  • -Xmn10M:新生代初始和最大都大小为10M
  • -XX:+UseSerialGC:不使用默认的GC垃圾回收器,JDK8默认的GC幸存区会动态调整,这里修改的SerialGC为串行垃圾回收器,后面会说,它不会动态调整幸存区
  • -XX:+PrintGCDetails -verbose:gc:打印GC详情
  • -XX:-ScavengeBeforeFullGC:打印GC详情
/**
 * @author PengHuanZhi
 * @date 2021年12月09日 21:19
 */
public class Demo {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;

    public static void main(String[] args) throws InterruptedException {

    }
}

运行一下观察控制台的信息

Heap
 def new generation   total 9216K, used 2325K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  28% used [0x00000000fec00000, 0x00000000fee45738, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 3438K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 380K, capacity 388K, committed 512K, reserved 1048576K
  • 其中new generation即是新生代信息,总共有9M左右,因为我们默认的幸存区比例为0.8,这里我们分配了10M,所以伊甸园总8Meden space 8192K),还有一个FROMTO分别1Mfrom space 1024Kto space 1024K),因为我们默认TO是必须保持为空的,所以这一块就没有计算,所以总共8M+1M=9M
  • tenured generation即是老年代信息,总量10M左右
  • 元空间本不在堆中,但是这里也还是打印出来了

现在代码中创建一个集合,分配7M的内存空间

ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);

观察控制台

image-20211221154600329

再加入512KB,仍然是一次垃圾回收,不过几乎占满了

list.add(new byte[_512KB]);

image-20211221154831631

再加入512KB,这时候肯定已经存不下了,肯定就又会触发一次垃圾回收

image-20211221154936543

此时可以发现,有7M左右的内存空间被放到了老年代中,这应该就是我们第一次分配的7M字节数组了

现在我们从头再来,我们直接放入一个8M的对象,这时候新生代肯定已经放不下了

list.add(new byte[_8MB]);

image-20211221155159400

可以看见,对于大对象而言,JVM是可以直接将其放入空间足够的老年代中的,那如果再放一个8M的对象,新生代老年代都放不下呢,就一定会内存溢出了

image-20211221155519256

3.3、案例演示2

运行如下Demo,不用添加VM参数

/**
 * @author PengHuanZhi
 * @date 2021年12月09日 21:19
 */
public class Demo {
    byte[] arr = new byte[1024 * 1024];

    public static void main(String[] args) throws InterruptedException {
        List<Demo> demos = new ArrayList<>();
        while (true) {
            demos.add(new Demo());
            Thread.sleep(20);
        }
    }
}

运行起来后,打开VisualVM工具,提前安装好Visual GC插件(网上有教程),然后观察当前Demo进程的Visual GC页面

GIF 2021-12-25 12-53-14

4、HotSpot算法实现细节

4.1、准确式内存管理

准确式内存管理(Exact Memory Management,也可以叫Non-Con servative/Accurate Memory Management)而得名。准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如内存中有一个32bit的整数123456,虚拟机将有能力分辨出它到底是一个指向了123456的内存地址的引用类型还是一个数值为123456的整数,准确分辨出哪些内存是引用类 型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。

4.2、根节点枚举

我们以可达性分析算法中从了解到。固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找它们的过程要做到高效并非一件容易的事情,现在Java应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间

  • 可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发
  • 根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行,也就是说不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMSG1ZGC等收集器,枚举根节点时也是必须要停顿的。
  • 目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。
4.2.1、OopMap

对于枚举根节点时须要的停顿,在HotSpot 的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的 ,一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

4.3、安全点

对于OopMap来讲,有一个很现实的问题,程序的执行过程中,可能会出现引用关系变化,而每一个引用变化的指令都会产生一个OopMap,那样会占用大量的额外存储空间,但是实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准 进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

对于如何在垃圾收集发生时让所有线程(这里其实不包括 执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:

  • 抢先式中断Preemptive Suspension):不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC事件。
  • 主动式中断Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

4.4、安全区域

对于安全点的设计似乎完美解决了如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但是对于非运行时期的程序呢?也就是说程序没有分配处理器时间,比如线程Sleep了或者Blocked了,这个时候线程就无法响应虚拟机的中断请求,不能再走到安全的地方去终端挂起自己,更不能重新去激活它,对于这种情况,就必须引入安全区域来解决

  • 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,比如上文提到的线程Sleep等操作,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止

4.5、记忆集和卡表

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1ZGCShenandoah收集器,都会面临相同的问题

这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

  • 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构
Class RememberedSet {
    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。(too long)
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。(a little long)
  • 卡精度:每个记录(元素)精确到一块内存区域(卡页),该区域内有对象含有跨代指针。(perfect)

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式 ,HotSpot默认的卡表标记逻辑为:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是29次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如图所示。

image-20211231195601338

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

4.6、写屏障

上面解决了如何缩减GC Roots扫描范围的问题,接下来解决卡表元素如何维护的问题

4.6.1、何时变脏

其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

4.6.2、谁把它变脏

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,既然是环形通知类型,自然可以分为写前屏障,写后屏障,HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

在应用了写屏障后,虚拟机就会为所有复制操作生成相应的指令,一旦收集器在写屏障中做了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

4.6.3、伪共享问题

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

  • 假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB64×512字节),==也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。==为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。
  • JDK 7之后,HotSpot虚拟机增加了一个新的参数**-XX:+UseCondCardMark**,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

4.7、并发的可达性分析

对于当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判断对象是否存活,对于可达性分析算法,我们已经了解了第一个步骤根节点枚举的优化了(OopMap),那么对于后面的从根节点往下遍历对象图呢,这一步骤停顿的时间必然会和Java堆大小成正比,因为存储的对象越多,对象图接口越复杂,标记更多对象所花的时间也就更长,暂停用户线程时间也就更长。

对于标记这个阶段来讲,它是所有追踪式垃圾收集算法的必要步骤,如果这个阶段花费的时间能够有所下降,那么收益必然也会是巨大的。那么我们在做对象图遍历的时候为什么必须要暂停用户线程来保障一致性快照呢?接下来尝试解释清楚这一问题,我们引入三色标记作为工具辅助推导:

4.7.1、三色标记推导

接下来我们做如下定义,我们把这个过程看作对象图上一股以灰色为波峰的 波纹从黑向白推进的过程

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

对于第一种情况,在收集线程工作的时候,用户线程被冻结了,那么这种方法不会有任何问题

image-20220101144918824

对于第二种情况,收集线程和用户线程并发执行的时候,收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。

  • 把原本消亡的对象错误标记为存活, 这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好

image-20220101145548666

  • 把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误

image-20220101145731344

4.7.2、对象消失问题解决方案

针对对象消失这个危险问题,Wilson1994年在理论上证明了,当且仅当以下两个条件同时满足时,才会出现:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

因此,我们要解决并发扫描时的对象消失问题,只需要破坏这两个条件中的一个即可,由此针对两个条件分别产生了两个解决方案

  • 增量更新:破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了(即会再次扫描)
  • 原始快照:要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索(只要发现原来的黑色对象有一个原始的黑色对象还在引用,则都不会变为白色)

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新 来做并发标记的,G1Shenandoah则是用原始快照来实现

5、垃圾收集器

常用垃圾收集器

image-20211224193525691

5.1、单线程 SerialGC

JDK 1.3.1前时HotSpot虚拟机新生代收集器的唯一选择

  • 单线程:但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强 调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

  • 适合堆内存较小适合个人电脑 :收集几十兆甚至一两百兆的新生代,垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的,所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

使用如下VM命令开启串行垃圾回收器

 -XX:+UseSerialGC
  • UseSerialGC=Serial(新生代,复制)+SerialOld(老年代,标记加整理)

image-20211221162749650

5.2、多线程 ParNewGC

  • 多线程版本的SerialGC,除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,所有的控制参收集算法等都完全一致,但是它是除了Serial收集器外,目前只有它能与CMS收集器配合工作。

5.3、吞吐量优先 Parallel Scavenge GC

  • 多线程
  • 适合堆内存较大,多核 cpu(因为单核CPU也是多个线程去争抢同一个CPU的时间片,效率还不如串行)
  • 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4(两次垃圾回收),垃圾回收时间占比最低,这样就称吞吐量高

JDK8环境下,默认就使用这个垃圾回收器,不过也还是介绍一下开启需要使用如下VM命令

  • -XX:+UseParallelGC ~ -XX:+UseParallelOldGC(两者开启一个,另一个连带开启)
  • -XX:+UseAdaptiveSizePolicy:采用一个自适应的幸存区比例(动态)
  • -XX:GCTimeRatio=ratio:是一个动态调整幸存区比例的触发条件,如果每单位时间内,垃圾回收所使用的时间超过了ratio比例,那么就尝试调整幸存区比例,一般就会去调大,计算方式为1/(1+ratio),一般设置为19,即1/20=0.05,一百分钟有超过五分钟垃圾回收,则会调整
  • -XX:MaxGCPauseMillis=ms:最大暂停时间,默认200ms,如果上一个命令增大幸存区内存,暂停时间同步也会增大
  • -XX:ParallelGCThreads=n:控制开启垃圾回收线程数的大小

image-20211221162856646

5.4、Serial Old

  • Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法
  • 作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

5.5、Parallel Old

  • Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

5.6、响应时间优先 CMS

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

作用于老年代,可以配合ParNew使用,即新版的Parallel Scavenge,基本差不多,只作略微改良

5.6.2、适用场景
  • 多线程
  • 堆内存较大,多核 cpu
  • 尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5(五次垃圾回收)
5.6.3、四个步骤

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”
  • 并发标记CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  • 重新标记CMS remark):是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的 标记记录(可参考并发的可达性分析),这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短,需要“Stop The World”
  • 并发清除CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

image-20211221164328966

5.6.6、两个缺点
  • 产生浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
  • 空间碎片化:CMS是一款基于“标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
5.6.5、相关VM命令

相关VM命令如下

  • -XX:+UseConcMarkSweepGC:开启CMS
    • -XX:+UseParNewGC :与CMS配合,工作在新生代使用复制算法的垃圾回收,CMS有时候会发生并发失败的问题,这个时候会采取补救措施,将当前CMS并发回收器降级为一个基于标记整理且用于老年代的SerialOld单线程的垃圾回收器,这时用户线程就会停下了,让收集器安安心心去处理
  • -XX:ParallelGCThreads=n:并行的垃圾回收线程数
  • -XX:ConcGCThreads=threads:并发的垃圾回收线程数,一般设置为并行的垃圾回收线程数的四分之一,即一个线程去垃圾回收,另外三个线程用来给用户线程服务
  • -XX:CMSInitiatingOccupancyFraction=percent:执行垃圾回收的时机,比如百分之八十即老年代占用百分之八十就执行一次CMS,这样是为了预留一些空间给浮动垃圾,这个参数设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置
  • -XX:+CMSScavengeBeforeRemark:因为在每次CMS的时候,还需要去扫描新生代中可能引用老年代的对象,来分析老年代对象的可达性来标识垃圾,但是一般堆内存比较大,这样会比较耗费资源,就可以配置这个参数来使做CMS之前,先做一次ParNew新生代的垃圾回收
  • -XX:+UseCMS-CompactAtFullCollection:默认是开启的,此参数从JDK 9开始废弃,用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,这样空间碎片问题是解决了,但停顿时间又会变长
  • -XX:CMSFullGCsBefore-Compaction:此参数从JDK 9开始废弃,这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

5.7、G1

Garbage First :于2004论文发布,2009 JDK 6u14 体验 2012 JDK 7u4 官方支持 2017 JDK 9 默认

5.7.1、概述
  • G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个牢笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
  • G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
  • Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数设定,。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中G1大多数行为都把Humongous Region作为老年代的一部分来进行看待

image-20211230203549103

5.7.2、如何解决跨代引用

使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量**10%20%**的额 外内存来维持收集器工作。

5.7.3、并发分配新对象的处理

垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMSTop at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

5.7.4、写屏障的实现

在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现 为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

5.7.5、四个步骤
  • 初始标记Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记Concurrent Marking):GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 最终标记Final Marking):对用户线程做另一个短暂的暂停用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的, 换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望

回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中

5.7.5、相关参数
  • -XX:+UseG1GC:指定使用G1
  • -XX:G1HeapRegionSize=size :设置每一个Region的大小,只能是2n大小(1,2,4,8…)
  • -XX:MaxGCPauseMillis=time:同ParallelGC,最大暂停时间,默认200ms
    • “期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,它默认的停顿目标为两百毫秒,但如果我们把停顿时间调得非常低,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值