JVM:垃圾收集器与内存分配策略(无G1与ZGC)

程序计数器、虚拟机栈、本地方法栈这三个区域会随线程生、随线程灭,栈中的栈帧随着方法的进入和退出而有条不紊的进行着出栈和入栈操作。每一个栈帧中分配多少内存基本在类结构确定下来就是已知的(尽管在运行期会由即时编译器做出一些优化,但是在基于概念模型的讨论中,大体认为这是已知的)。因此这几个区域的内存分配和回收都具有确定性,不做过多考虑,线程结束时内存也就跟着回收了。

GC需要关注的是Java堆和方法区,这两个区域有着显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,这些信息只有在运行期间才是可知的。这部分内存的分配和回收是动态的。

如何判断对象”死亡“(不可能通过任何途径再被使用)

1.引用计数法及其缺陷

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;引用失效时就减一,任何时刻计数器为零的对象就是不可能再被使用的。该方法虽然占用了一些额外内存,但是它原理简单、效率很高。

缺陷:对象A、B都有字段instance,赋值令A.instance=B,B.instance=A。除此之外,两个对象再没有其他引用,实际上这两个对象已经不可能再被访问。但是它们又互相引用着对方,它们的引用计数器都不为零,这时引用计数器算法就无法进行回收。

/*
 * 引用计数法
 * 需要将虚拟机的GC算法设为引用计数器算法
 */
public class ReferenceCountingGC {
    public Object instance=null;
    private static final int _1Mb=1024*1024;
    private byte[] bigSize=new byte[2*_1Mb];
    //该属性用于占用内存,以便在GC日志中看清除是否被回收
    public static void testGC() {
        ReferenceCountingGC A = new ReferenceCountingGC();
        ReferenceCountingGC B = new ReferenceCountingGC();
        A.instance=B;
        B.instance=A;
        A=null;
        B=null;
        //发生GC
        System.gc();

    }
    public static void main(String[] args) {
        testGC();
    }
}

2.可达性分析算法

当前主流商用程序语言的内存管理子系统都是用过可达性分析算法判断对象是否存活。

该算法的基本思路是通过一系列称为”GC Roots“的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程中走过的路径称为引用链,如果某个对象到GC Roots之间没有任何引用链相连,则该对象不可达,即判定为可回收对象。

固定可作为GC Roots的对象:

      1. 虚拟机栈(栈帧中的本地变量表)中引用的对象,如当前正在执行的方法用到的参数、局部变量、临时变量;
      2. 方法区中类静态属性引用的对象,如Java类的引用类型静态变量;
      3. 在方法区中常量引用的对象,如字符串常量池(String Table)中的引用;
      4. 在本地方法栈中JNI(即Native方法)引用的对象;
      5. Java虚拟机内部的引用,如借本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器;
      6. 所有被同步锁(synchronized 关键字)持有的对象;
      7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

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

无论是引用计数器算法,还是可达性分析算法,它们判定对象是否还存活都与“引用”脱不开关系。
JDK1.2之前,如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称
该reference是代表某块内存、某个对象的引用。
这种定义对一个对象就只有“被引用”和“未被引用”两种状态。当我们想描述一类对象:当内存空间
足够时就留下,垃圾收集器清理过后仍内存不足就抛弃这些对象。这时这种定义就无能为力了。
JDK1.2之后,对引用的概念进行了扩充,分为以下四种:

1.强引用(Strongly Reference):这是最传统的“引用”的定义,是指在程序代码中普遍存在的
引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况,只要强引用关系
存在,垃圾收集器就永远不会回收被引用的对象。

2.软引用(Soft Reference):用于描述一些还有用、但非必须的对象。只要被软引用关联的对象,
在系统将要发生OOM内存溢出异常之前,会把这些对象列入回收范围内进行第二次回收,如果这次
回收还没有足够的内存才会产生OOM异常。

3.弱引用(Weak Reference):也是用于描述非必须的对象,但是它的强度比软引用弱,被弱引用
关联的对象只能生存到下一次垃圾回收发生为止。无论什么情况,弱引用关联的对象都会被回收。

4.虚引用(Phantom Reference):这是最弱的一种引用关系,一个对象是否有虚引用存在完全不
会对其生存时间构成影响,也无法通过虚引用获得一个对象实例,它的存在只是为了在对象被回收
时收到一个系统通知。

4.生存与死亡?

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

    1. 如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,进行第一次标记

随后进行一次筛选,条件为此对象是否有必要执行finalize()方法,对象没有覆盖finalize()方法,或者方法已经被虚拟机调用过。这两种情况都视为“没有必要执行”。

finalize是对象最后一次自救的机会,只需要把自己与引用链上的对象建立关联即可,这样在第二次标记时它将会被移除即将回收的集合。

//对象的一次自我拯救
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK=null;
    public void isAlive(){
        System.out.println("我还活着");
    }
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize方法被调用了");
        FinalizeEscapeGC.SAVE_HOOK=this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK=new FinalizeEscapeGC();

        //对象第一次拯救自己
        SAVE_HOOK=null;
        System.gc();

        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("对象已经死了");
        }

        //对象第二次拯救自己
        SAVE_HOOK=null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("对象已经死了");
        }

    }
}

但是不建议这样用,运行代价高昂、不确定性大、不能保证各个对象的调用顺序,这种用法在后面也被舍弃了、官方不建议。可以使用try-finally或其他方式。

5.回收方法区

方法区实际上也是有垃圾收集行为的,《JVM规范》中对这里不做要求,但有产品实现这里的垃圾收集(性价比不如在Java堆中收集垃圾)。

方法区收集垃圾主要回收:废弃的常量、不再使用的类型。举个常量池的例子:

假如一个字符串"java"曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是"java",也就是说没有任何字符串对象引用常量池中的"java"常量,且虚拟机中也没有其他地方引用这个字面量。如果此时发生GC,而且垃圾收集器判断有必要的话,这个"java"常量将会被系统清出常量池。常量池中的其他类、方法、字段的符号引用也类似。

判断一个类型是否废弃(不再被使用)的条件相对更加苛刻,需要满足三条:

      1. 该类的所有实例都已被回收:Java堆中不存在该类及任何派生子类的实例;
      2. 加载该类的类加载器已被回收:通常很难达成该条件(需要精心设计的可替换类加载器的场景,如OSGi、JSP的重加载);
      3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

但是满足这三个条件的无用类会被允许回收,但也只是允许而已,并不是必然。

是否要回收,HotSpot提供了-Xnoclassgc参数控制,-verbose:class、-XX:+TranceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的压力。

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法分为:

引用计数式垃圾收集(Reference Counting GC):直接垃圾收集

追踪式垃圾收集(Trancing GC):间接垃圾收集

这里仅讲解主流的追踪式垃圾收集的分代理论、算法思想和发展过程,细节参照Richard Jones的《垃圾回收算法手册》或参考相关知识《垃圾回收算法手册 自动内存管理的艺术--引言、标记清除》

1.分代收集理论

分代收集名为理论,实质上是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

    1. 弱分代假说:绝大多数对象都是朝生夕灭的;
    2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡;

这两个分代假说共同奠定了多款常用垃圾收集器一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依照年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域中存储。显然,如果一个区域中的对象都是朝生夕灭,难以熬过垃圾回收过程,把它们集中到一起,每次回收都只关注如何保留存活的对象而不是标记大量将要被回收的对象,这样性价比将大大提高;而剩下的都是难以消亡的对象,把它们集中到一起,虚拟机就可以以较低的频率来回收这个区域。这就兼顾了垃圾收集的时间开销和内存的空间有效利用。

因此最先出现的分代是将Java堆分为新生代和老年代,伴随着区域的划分,也出现了Minor GC、Major GC、Full GC等回收类型的划分,以及“标记-复制”、“标记-清除”、“标记-整理”等针对性垃圾收集算法。

由分代理论不难发现,在新生代中每次进行垃圾回收都会有大量的对象死去,每次回收之后存活的少量对象会逐步晋升到老年代中存放。但依照这个框架和理论的发展,我们先发现了一个问题:对象之间存在着跨代的引用。下面是一个假设:

现在要进行一次只局限于新生代区域中的收集(Minor GC),但在新生代中的对象完全可能被老年代所引用的,为了找出该区域中存活的对象,不得不在固定的GC Roots之外,再额外遍历老年代中的所有对象来确保可达性分析结果的正确性。这种方法虽然可行,但是会加重性能负担,所以这里又要加上第三条经验法则:

    1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

存在互相引用关系的两个对象,是应该趋向于同时生存或同时消亡的,如:某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在垃圾收集时得以存活,进而在年龄增长之后晋升到老年代,这种跨代引用就会消除。

这时,我们可以在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用。此后发生Minor时,只有被标识的块会被加入到GC Roots进行扫描(这种方法需要在对象改变引用关系(将自己或某个属性赋值)时维护记录数据的正确性),但相对于扫描整个老年代来说是划算的。

2.标记-清除算法(最早最基础的算法)

标记:识别需要回收的对象或者是存活的对象

清除:统一回收掉被标记的对象

缺点:

    1. 执行效率不稳定,如果Java堆中大量对象是需要被清除的,标记和清除的效率就会很低;
    2. 内存空间碎片化,标记-清除之后会导致大量的不连续碎片空间,就会导致需要分配较大对象时找不到足够的空间而触发另一次的GC。

为了改进标记-清除算法的缺点,产生了其他算法。

3.标记-复制算法(解决标记-清除算法面对大面积可回收对象低效率的问题)

最初是将可用内存按容量划分为等大的两块空间,每次只使用一块,当每次内存不够用时,就将存活的对象复制到另一块空间,该空间全部清除。但是这种算法会导致大量的内存间复制的开销,也会导致极大的空间浪费。

所以改进的复制算法将内存区域分为80%的Eden和两块各为10%的Survivor(不同产品会存在擦差异)。每次分配内存时只使用Eden和其中一个Survivor。发生垃圾收集时,将Eden和Survivor中存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已使用过的survivor空间。

但是我们不能保证每次的Survivor都有充足的空间容纳存活的对象,所以需要一个应对罕见情况的安全设计---“逃生门”,当不足以容纳时就需要依赖其他内存空间(大多是老年代)进行分配担保,这时就能认为设计基本没什么风险了。

4.标记-整理算法(应对老年代)

标记过程与“标记-清除算法”一致,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间中的一端移动,然后直接清理掉边界以外的内存。

“标记-整理”算法与“标记-清除”算法的本质区别在于前者是一种移动式的回收算法,而后者是非移动式的。是否移动存活对象是优缺点并存的:

    1. 如果移动,尤其是在老年代这种存在大量存活对象的区域,移动存活对象并更新所有引用这些对象的地方,这将会是负担极重的操作。而且这种对象移动操作必须暂停全部用户应用程序才能进行(标记-清除算法也会停顿用户线程来标记、清理可回收对象,只是停顿时间相对短,甚至忽略)。
    2. 如果不考虑移动、整理存活对象的话,就会造成大量的碎片空间,需要靠更加复杂的策略来解决(内存分配器、内存访问器)。内存的访问是用户程序最频繁的操作,甚至没有之一,假如在此环节增加了额外负担,势必会直接影响应用程序的吞吐量。

这里吞吐量的实质是赋值器(请求和管理程序运行时所需的内存)与收集器(垃圾收集器)的效率总和。吞吐量也可以理解为一定时间内,执行用户代码与垃圾收集的时间的比率。

在HotSpot虚拟机中关注吞吐量的Parallel Old收集器是基于“标记-整理算法”的,而关注延迟的CMS虚拟机则是基于“标记-清除算法”的。

    1. 和稀泥式解决方案,虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。CMS面对碎片过多时也是采取这种做法。

HotSpot的算法细节(介绍垃圾收集器的前置知识,可跳过)

1.根节点枚举

固定可作为GC Roots的节点主要存在于:

      1. 全局性的引用,如常量或类静态属性
      2. 执行上下文,如栈帧中的本地变量表

现在Java应用越来越大,光是方法区就有数百上千兆,里面的类、常量更是恒河沙数,若要逐个检查以这里为起源的引用耗费的时间将会很多。

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。可达性分析算法耗时最长的查找引用链的过程以及可以与用户线程并发,但是根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行,这里的“一致性”是指整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况(否则分析结果准确性无法保证)。这是导致垃圾收集过程中必须停顿用户线程的一个重要原因,CMS、G1、ZGC在这里都必须停顿。

目前主流的虚拟机使用的都是准确式垃圾收集,所以当用户线程停下来之后并不需要检查所有执行上下文和全局的引用位置,虚拟机有办法直接得到那些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到此目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息,不需要查找所有。

准确式垃圾收集:虚拟机使用准确式内存管理,由此虚拟机可以知道内存中某个位置的数据具体是什么类型。如,内存中有一个32bit的整数12345,虚拟机将有能力分辨出它到底是一个指向了12345的内存地址的引用类型还是一个数值为12345的整数,准确分辨出哪些内存是引用类型,这也是在垃圾收集时准确判断堆上的数据是否还可能被使用的前提。

HotSpot虚拟机客户端模式下生成的一段String::hashCode()方法的本地代码

1.创建一个调用String::hashCode的简单Java类
public class StringHashCodeTest {
    public static void main(String[] args) {
        String str = "example";
        int hashCode = str.hashCode();
        System.out.println("HashCode: " + hashCode);
    }
}

2.编译
javac StringHashCodeTest.java

3.输出
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=print,java/lang/String.hashCode StringHashCodeTest > output.docx

[Verified Entry Point]
0x026eb730: mov			%eax,-0x8000(%esp)
.......
;; ImplicitNullCheckStub slow case
0x026eb7a9: call	0x026e83e0		;OopMap{ebx=Oop [16]=Oop off=142}
                                    ;*caload
                                    ;- java.lang.String::hashCode@48(line 1489)
                                    ;	{runtime_call}
    0x026eb7ae:	push	$0x83c5c18	;	{external_word}
    0x026eb7a3:	call	0x026eb7a8	
    0x026eb7b8:	pusha
    0x026eb7b9:	call	0x0822bec0	;	{runtime_call}
    0x026eb7be:	hlt

在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针的引用,有效范围为从call指令开始直到0x026eb730(指令流起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。

普通对象指针(Ordinary Object Pointer,OOP)

2.安全点

在Oop的协助下,HotSpot可以快速的完成GC Roots的枚举。这时就会产生另一个问题:枚举期间由于多线程并发和对象动态生命周期,可能会导致引用关系变化。导致OopMap内容变化的指令很多,如果每一条指令都生成对应的OopMap,将会需要大量的存储空间。

前文已经提到了OopMap是在特定的位置记录这些信息,而这些位置称为安全点(Safepoint)。所以用户程序的执行不是在任意位置停下,而是强制到达安全点后才停下并进行垃圾收集。

安全点选取标准:基本是“是否具有让程序长时间执行的特征”为标准,指令的执行是非常快速的,程序不太可能是因为指令流太长而长时间执行。长时间执行最明显的特征就是指令序列的复用,例如:方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。

线程达到安全点停下来的方案:

      1. 抢先式中断:不需要线程的执行代码主动配合,而是在垃圾收集发生时,系统首先把所有用户线程中断,如果有用户线程(赋值器)不在安全点上的线程,就会恢复这条线程,让它一会儿再中断,直到跑到安全点。(基本没有虚拟机用这种方法)
      2. 主动式中断:当垃圾收集需要中断线程时,不直接操作线程,而是设置一个标志位,各个线程执行时会不断地轮询这个标志,一旦发现中断标志为真就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于轮询操作在代码中会频繁出现,这就要求其足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简到只有一条汇编指令的程度。

轮询指令:

0x01b6d627:	call	0x01b2b210			; OopMap{[60]=Oop off=460}
                                        ; *invokeinterface size
                                        ; - Client1::main@113 (line 23)
                                        ; 	{virtual_call}
    0x01b6d62c:	nop						; OopMap{[60]=Oop off=461}
                                        ; *if_icmplt
                                        ; - Client1::main@118 (line 23)
    0x01b6d62d:	test	%eax,0x160100	; 	{poll}
    0x01b6d633: mov		0x50(%esp),%esi	
    0x01b6d637:	cmp		%eax,%esi

这里的test指令就是HotSpot生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读(内存保护陷阱),当线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样就可以仅通过一条汇编指令完成安全点轮询和触发线程中断了。

内存保护陷阱是操作系统和计算机体系结构中用来防止非法内存访问的机制,通过软硬件协作,确保进程只能访问其被授权的内存区域。(更多信息参考有关资料)

  1. 内存保护:指对内存访问的限制,防止进程访问未分配或不被允许的内存区域(OS、硬件)
  2. 陷阱(Trap):当进程尝试进行非法操作时(如访问无效内存地址),CPU就会产生一个陷阱,将控制权转移到操作系统的异常处理程序。
3.安全区域

安全点保证程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,如果程序“不执行”呢?也就是程序没有分配处理器时间,典型的就是用户线程处于Sleep状态或者Blocked状态,这时线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也不可能持续等待线程重新被激活分配处理器时间。这时就需要引入安全区域(Safe Region)。

安全区域是指能够确保在某一段代码片段中,引用关系不会发生变化,因此,在这个区域中的任何地方开始垃圾收集都是安全的。(可看作是被拉伸了的安全点)

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

4.记忆集与卡表

前文(分代收集理论)提到了为避免将整个老年代都加入GC Roots的扫描范围,垃圾收集器在新生代建立了名为记忆集(Remembered Set)的数据结构。所有涉及部分区域收集行为的垃圾收集器都会面临这种跨代引用问题(G1、ZGC、Shenandosh等)。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

记录所有跨代对象所耗费的成本是非常高的,所以在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了。所以记忆集记录的粒度就可以较为大一些:

    1. 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
    2. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
    3. 卡精度:每个记录精确到每一块内存区域,该区域内有对象含有跨代指针。

卡精度是指用一种称为卡表的方式去实现记忆集。前面提到记忆集是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。(卡表与记忆集的关系类似于HashMap和Map的关系)

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机默认也是用一行代码实现卡表标记逻辑:CARD_TABLE [this address >> 9]=1;

字节数组CRAD_TABLE的每一个元素都对应着其标识区域中一块特定大小的内存块,这个内存块叫做卡页(Card Page),一般大小为

的字节数,HotSpot为

即512字节(地址右移9位)。如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF0x0200~0x03FF0x0400~0x05FF的卡页内存块。

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

5.写屏障

这里是要解决如何维护卡表元素问题:

    1. 卡表何时变脏的:有其他分代区域的对象引用了本区域对象时,其对应卡表元素值就应该变脏,变脏时间点原则上在发生引用类型字段赋值的那一刻。
    2. 如何变脏:写屏障(HotSpot)

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外动作,也就是说赋值的前后都在写屏障的覆盖范围内(写前/写后屏障)。G1出现之前,垃圾收集器都只用到了写后屏障。

AOP也即Aspect Oriented Programming面向切面编程,通过预编译的方式和运行期动态代理实现程序功能的统一维护的一种技术。

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

应用写屏障之后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表状态操作,无论是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,但是这个开销与扫描整个老年代来说相对低很多。

卡表除了写屏障生成指令的开销之外,高并发场景下还面临“伪共享”(False Sharing)的问题。伪共享是处理并发底层细节时一种需要经常考虑的问题,现代中央处理器的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量,如果这些变量恰好共享同一个缓存行,就会互相影响彼此(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。下面是解释:

假设一个缓存行的大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总内存为32KB(64*512)(HotSpot)也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

一、不同线程写入同一个缓存行会影响性能,主要是因为现代处理器的缓存一致性协议(MESI协议)
会引入额外的开销。以下是具体原因:
1. 缓存一致性:
    当一个线程更新某个变量时,处理器需要确保其他处理器缓存中与该变量相关的缓存行保持
    一致。如果不同线程操作的变量在同一个缓存行中,更新一个变量可能会导致处理器将整个
    缓存行标记为无效,这样其他处理器需要从主内存重新加载这个缓存行,从而增加了延迟。
2. 频繁的写回和无效化:
    多个线程同时写入同一个缓存行时,会频繁发生写回(将缓存中的数据写回主内存)和无效化
    (将其他处理器缓存中的该缓存行标记为无效)操作。这种频繁的状态变更会导致性能下降,
    尤其是在高并发场景下。
3. 增加的延迟和带宽消耗:
    伪共享会导致处理器之间频繁通信,以维护缓存一致性。这不仅增加了延迟,还消耗了内存
    带宽,从而降低了整体系统性能。

二、当一个线程修改某个共享变量时,处理器必须确保其他处理器中的缓存也反映出这个修改。
这是因为现代多核处理器通常有各自的缓存,而不同核心的缓存并不会自动同步。
1. 缓存一致性:
    假设有两个线程在不同的核心上运行,如果它们都在各自的缓存中保存了同一个变量的副本。
    当一个线程修改这个变量时,它的核心会更新自己的缓存,但其他核心的缓存仍然保留旧的值。
2. 无效化:
    为了保证数据一致性,处理器必须通过协议(如MESI)来确保其他核心中的缓存行被标记为
    “无效”,以便在下次访问时强制从主内存中重新加载最新的数据。这意味着,即使两个线程
    操作的是不同的变量,但如果它们恰好位于同一个缓存行中,更新操作会影响整个缓存行,
    从而导致其他核心需要进行无效化操作。
3. 性能影响:
    这种频繁的无效化和重载操作会导致性能下降,因为它增加了延迟和带宽消耗,特别是在
    高并发的情况下。
    处理器需要保证所有核心对共享变量的视图一致,这就是“确保其他处理器缓存中与该变量
相关的缓存行保持一致”的含义。


三、当两个线程在不同的核心上同时操作同一个缓存行中的卡表元素时,会发生如下过程:
1. 缓存行的无效化:当一个线程修改缓存行中的数据时,处理器会通过缓存一致性协议(如MESI)
    将其他核心中该缓存行的副本标记为无效。这样确保了所有线程看到的都是最新的数据。
2. 重载数据:当另一个线程尝试访问这个被修改的缓存行时,因为它的副本已被标记为无效,
    处理器需要从主内存重新加载该缓存行,以获取最新的值。
3. 频繁的状态变更:如果两个线程交替更新同一个缓存行中的数据,会导致频繁的无效化和重载
    操作,这增加了延迟和内存带宽的消耗。
4. 性能下降:这种频繁的缓存一致性维护机制会导致性能下降,尤其是在高并发场景下,系统
    需要花费大量时间在处理缓存一致性方面,而不是实际的计算任务上。

为了避免伪共享问题,一种简单的方法是采用先检查卡表标记,只有当该卡表未被标记过才将其标记为脏,所以更新后的逻辑代码:

if(CARD_TABLE [this address >> 9 ] !=1)

CARD_TABLE [this address >>9 ] = 1;

HotSpot增加了参数-XX:+UseCondCardMark来决定要不要开启此判断,可以根据实际情况选定。

到这里GC Roots带来的停顿已经非常短暂而且相对固定(不随堆容量而变化)

6.并发的可达性分析

完成根节点遍历之后,,需要从GC Roots继续往下遍历对象图,这一步骤的停顿时间就会与Java堆容量成正比关系了:堆越大,存储对象越多,对象图结构越复杂,要标记更多的对象而产生的停顿时间自然就更长。

首先需要理解一个问题:为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?

这里我们引入三色标记作为工具辅助推导,把遍历图过程中遇到的对象,按照“是否访问过”这个条件标记为以下三种颜色:

白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有对象都是白色的。若在分析结束的阶段,仍然是白色的对象,即代表不可达。

黑色:表示对象已被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍。黑色对象不可能直接指向某个白色对象。

灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在着一个引用还没被扫描过。

如果用户线程与收集器并发工作,就可能导致两种情况:一是把原本消亡的对象错误标记为存活,这种情况是可以容忍的,只是产生了浮动垃圾而已;二是把原本存活的对象错误标记为消亡,这时不可接受的,程序会因此产生错误。

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

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

因此,要解决并发扫描时的对象消失问题,只需要破坏其中一个条件即可,由此便产生了两种方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning ,SATB)。

增量更新:要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。(黑色对象一旦新插入了指向白色对象的引用之后,它就变为灰色对象了)(写屏障)

原始快照:要破坏的是第二个条件,当灰色对象要删除指向白色对象的关系引用时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。(无论引用关系删除与否,都会按照开始扫描那一刻的对象图快照来进行搜索)(写屏障)

经典垃圾收集器

(讨论的是JDK7 update 4到JDK11正式发布之前,这个期间的垃圾收集器,限定为OracleJDK的HotSpot虚拟机)

1.Serial收集器

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

虽然是最早出现的,显得老而无用。但事实上,到现在它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,简单而高效,对于内存资源受限的环境,它是额外内存消耗最小的;对于单核或处理器核心较少的环境来说,Serial收集器没有线程交互的开销,专心做垃圾收集,收得了最高的单线程收集效率。Serial收集器对于运行在客户端模式下的虚拟机来说是个很好的选择。

2.ParNew收集器

实际上就是Serial收集器的多线程并行版本,而且它是目前唯一一个能与CMS收集器配合使用的收集器。

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此用户线程处于等待状态。

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序处理的吞吐量将受到一定影响。

3.Parallel Scavenge收集器(吞吐量优先收集器)

Parallel Scnvenge收集器也是一款新生代收集器,也是基于标记-复制算法实现的收集器,也是能实现并行收集的多线程收集器。。。。与其他收集器不同的是,CMS收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughout):

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花费一分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互(或需要保证服务响应质量的程序),良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序地运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge控制吞吐量的参数:

最大垃圾收集器停顿时间:-XX:MaxGCPauseMillis>0的毫秒数。收集器将尽力保证内存回收花费的时间不超过用户设定值。但是不能认为将这个参数的值调小就能使系统的垃圾收集速度变快,因为垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价的:系统将新生代调小,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集会更加频繁,原来10s收集一次,每次停顿100ms,现在变成5s收集一次,每次停顿70ms.停顿时间确实在下降,但吞吐量也降下来了。

直接设置吞吐量大小:-XX:GCTimeRatio正整数,表示用户期望虚拟机消耗在GC上的时间不超过程序运行时间的

默认为98,含义是尽可能保证应用程序执行的时间为收集器执行时间的99倍,也即收集器的时间消耗不超过总运行时间的1%。

开关参数:-XX:+UseAdaptiveSizePolicy,当这个参数被激活之后,就不需要人工指定新生代大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或最大吞吐量(垃圾收集的自适应的调节策略GC Ergonomics)。

指定新生代大小:-Xmn

Eden与Survivor区比例:-XX:+SurvivorRatio

晋升老年代对象大小:-XX:PretenureSizeThreshold

设置最大堆:-Xmx

GC Ergonomics需要指定-Xmx,-XX:MaxGCPauseMillis,-XX:GCTimeRatio参数指定优化目标,细节交由虚拟机完成。

4.Serial Old收集器

Serial Old是Serial收集器的老年代版本,它也是一个单线程收集器,使用标记-整理算法。它的意义在于供客户端模式下的HotSpot虚拟机使用。服务端模式下:1. JDK5及之前版本与Parallel Scavenge收集器搭配,2. 作为CMS失败的后备预案,在并发收集发生Concurrent Mode Failure时使用。

5.Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现。适合于注重吞吐量或者处理器资源较为稀缺的场合。

6.CMS收集器

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

CMS是基于标记-清除算法的,其运作比前面几种更为复杂,整个过程分为四个过程:

1、 初始标记(CMS initial mark):需要Stop The World,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;

2、 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,可以与垃圾收集线程并发运行,不需要Stop The World;

3、 重新标记(CMS remark):为了修正并发标记期间,因为用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,比初始标记的Stop The World时间长一点;

4、 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,不需要移动存活对象,可以与用户线程并发。

Stop The World:停顿用户线程

整个过程中耗时最长的并发标记和并发清除阶段都可以与用户线程一起工作,所以总体来说,CMS回收器的内存回收过程是与用户线程一起并发执行的。就像它的名字一样,这是一款并发收集、低停顿的收集器,官方文档也称之为“并发低停顿收集器”。

三处明显缺点:

    1. 对处理器资源敏感;
    2. 无法处理浮动垃圾,导致出现并发失败进而造成完全停顿用户线程的Full GC的产生;
    3. 收集结束会产生大量的内存碎片(标记-清除算法)。
7.Garbage First收集器(G1)(GC技术发展史上的里程碑成果)

G1开创了收集器面向局部收集的思路和基于Region的内存布局形式。是为了取代CMS收集器而出现的,使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器会收到CMS会被废弃的警告。

为了实现职责分离的设计原则,在JDK10规划功能目标时提出了“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离。

“停顿预测模型”的收集器:能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒。这几乎已经是实时Java(RTSJ)中软实时垃圾收集器的特征了。

怎么实现这个目标呢?首先要有思想上的转变,将垃圾收集的范围从面向老年代、新生代转变到面向堆内存任何部分来组成回收集。衡量标准不再是属于哪一个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

实现这个目标的关键是G1基于Region的堆内存布局。虽然G1也是遵循分代理论设计的,但是G1不再坚持固定大小和固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden、Survivor空间或者是老年代空间。收集器能够根据扮演不同角色的Region采用不同的策略去处理。

Region中还有一类特殊的Humongous区域,专门用来储存大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象,而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续Humongous Region之中,G1大多数行为都把Humongous Region作为老年代的一部分来进行看待。

虽然G1仍然保留着老年代和新生代的概念,但老年代和新生代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合,G1之所以能够建立起停顿预测模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍。这样就可以有计划的避免在整个Java堆中进行全区域的垃圾收集。更具体一点就是,让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值收益最大的那些Region,这也是Garbage First名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限时间内获取尽可能高的收集效率。

需要解决的关键问题:

    1. 将Java堆分为多个独立Region后,Region里面存在的跨Region引用对象如何解决?前面已经提到了可以使用记忆集来解决。G1中的每一个Region都维护自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。实际上G1记忆集在存储结构上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。(这是一种比普通卡表更复杂的双向卡表,即记录了“我指向谁”和“谁指向我”,内存占用也更多,大约是Java堆的10%-20%)。
    2. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?G1使用原始快照的方法解决。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序只要运行就肯定会持续有新对象被创建,G1为每个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新对象分配的对象地址都必须在这两个指针位置以上。G1默认在这个地址以上的对象是存活的(隐式标记)。如果内存回收的速度赶不上内存分配的速度,G1也要被迫冻结用户线程执行(Full GC产生长时间的Stop The World)。
    3. 怎样建立起可靠的停顿预测模型?G1收集器的停顿预测模型是以“衰减均值”为理论基础实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更能准确代表最近的平均状态。也就是说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

G1收集器的运作过程大致分为以下四个步骤(不去计算用户线程运行过程中的动作):

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

期望停顿时间设置为一两百毫秒或者两三百毫秒会比较合理,太小回收速度太慢,导致回收速度跟不上分配速度;用户线程自然不希望停顿太长时间。

里程碑的原因:从G1开始,先进的垃圾收集器的设计导向都不约而同地变为能够应对应用地内存分配效率,不追求一次把整个Java堆全部清理干净,只要收集速度能跟上分配速度即可。

整体来看,G1是基于标记-整理算法的,但从局部(两个Region之间)上看又是基于标记-复制算法的。但无论如何,G1都不会产生内存空间碎片。

从经验来看,小内存应用更适合CMS,大内存更适合G1。平衡点通常在6GB~8GB之间。但G1的持续优化改进,也会让结果更偏向G1。

低延迟垃圾收集器

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值