JDK8->JDK17 GC变化专题
垃圾收集(Garbage Collection,简称GC)是一种自动内存管理机制,负责在运行时自动回收不再使用的对象,释放内存,以避免内存泄漏和提高程序性能。
判断对象是否是垃圾的算法
引用计数算法(Reference Counting Collector)
堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。
优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。
缺点: 难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。
早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历(根搜索算法)。
根搜索算法(Tracing Collector)
这种算法的基本思路:
(1)给定一个“GC Roots”的引用作为起始点出发,寻找对应的引用节点。
(2)找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点。
(3)重复(2)。
(4)搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。
Java和C#中都是采用根搜索算法来判定对象是否存活的。
GC roots 就是正在执行的Java程序可以访问的引用变量(注意:不是对象)的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。
标记可达对象:
前置知识补充:

JVM 内存区域被划分为线程私有与线程共享的区域。方法区与堆都是线程共享的区域,这两部分占用 JVM 大部分内存,剩下三个小弟将会跟线程绑定,随着线程消亡,将自动被 JVM 回收。
堆
堆应该是大家最熟悉的一块区域,几乎所有对象实例都将会在此出生,通常也是虚拟机上占用内存最大一块区域。
方法区
方法区会保存已被虚拟机上加载的类信息、常量,静态变量,字节码等信息,堆上的对象正是通过方法区这些信息,才能正确创建出来。
栈
虚拟机栈由一系列栈帧组成,每个栈帧其实代表一个方法,栈帧中会保存一个方法的局部变量表,方法出入口信息,操作栈等。每当调用一个方法,就将会把这个栈帧压入栈中,执行结束,出栈。
本地方法栈与虚拟机栈比较类似,最大区别在于,虚拟机栈执行的 Java 方法,而本地方法栈将会用来执行 Native 方法服务。下面方法就会在本地方法栈中执行。
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos,int length);
程序计数器
程序计算器可以说是这几块区域占用最小的一部分,但是功能却十分重要。Java 源代码通过编译变成字节码,然后被 JVM 载入运行之后,将会变成一条条指令,而程序计数器的工作就是告诉当前线程下一条需要执行的指令。这样即使发生了线程切换,等待恢复的时候,当前线程依然知道接下去要执行的指令。
JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图。

首先,垃圾回收器将某些特殊的对象定义为GC根对象。所谓的GC根对象包括:
(1)虚拟机栈中引用的对象(栈帧中的本地变量表);
(2)方法区中的常量引用的对象;
(3)方法区中的类静态属性引用的对象;
(4)本地方法栈中JNI(Native方法)的引用对象。
(5)活跃线程。
接下来,垃圾回收器会对内存中的整个对象图进行遍历,它先从GC根对象开始,然后是根对象引用的其它对象,比如实例变量。回收器将访问到的所有对象都标记为存活。
存活对象在上图中被标记为蓝色。当标记阶段完成了之后,所有的存活对象都已经被标记完了。其它的那些(上图中灰色的那些)也就是GC根对象不可达的对象,也就是说你的应用不会再用到它们了。这些就是垃圾对象,回收器将会在接下来的阶段中清除它们。
关于标记阶段有几个关键点是值得注意的:
(1)开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便JVM可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。
(2)暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。
(3)在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:
1.如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
2.如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
(4)实际上GC判断对象是否可达看的是强引用。
当标记阶段完成后,GC开始进入下一阶段,删除不可达对象。
回收垃圾对象内存的算法
标记—清除算法 (Mark-Sweep)
标记—清除算法是最基础的收集算法,为了解决引用计数法的问题而提出。它使用了根集的概念,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程。
优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
缺点:(1)标记和清除过程的效率都不高。(这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小。对空闲列表的管理会增加分配对象时的工作量。如图4.1所示。)。(2)**标记清除后会产生大量不连续的内存碎片。**虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败(在Java中就是一次OutOfMemoryError)不得不触发另一次垃圾收集动作。


标记—整理算法 (Mark-Compact)
该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。在基于Compacting算法的收集器的实现中,一般增加句柄和句柄表。
优点:(1)经过整理之后,新对象的分配只需要通过指针碰撞便能完成(Pointer Bumping),相当简单。 (2)使用这种方法空闲区域的位置是始终可知的,也不会再有碎片的问题了。
缺点:GC暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。
如果Java堆中内存是绝对规整的,所有被使用过的的内存都被放到一边,空闲的内存放到另外一边,中间放着一个指针作为分界点的指示器,所分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的实例,这种分配方式就是指针碰撞。


标记—复制算法 (Mark-Copy)
该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。
复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。一种典型的基于Coping算法的垃圾回收是stop-and-copy算法,它将堆分成对象区和空闲区,在对象区与空闲区的切换过程中,程序暂停执行。
优点:(1)标记阶段和复制阶段可以同时进行。(2)每次只对一块内存进行回收,运行高效。(3)只需移动栈顶指针,按顺序分配内存即可,实现简单。(4)内存回收时不用考虑内存碎片的出现(得活动对象所占的内存空间之间没有空闲间隔)。
缺点:需要一块能容纳下所有存活对象的额外的内存空间。因此,可一次性分配的最大内存缩小了一半。


分代收集算法 (Generational Collector)
堆内存被划分为新生代、年老代和持久代。新生代又被进一步划分为Eden和Survivor区,最后Survivor由FromSpace(Survivor0)和ToSpace(Survivor1)组成。
从上面三种GC算法可以看到,并没有一种空间与时间效率都是比较完美的算法,所以只能做的是综合利用各种算法特点将其作用到不同的内存区域。

1.年轻代(Young Generation)
几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照8:1:1的比例分为一个Eden区和两个Survivor(Survivor0,Survivor1)区。大部分对象在Eden区中生成。当新对象生成,Eden Space申请失败(因为空间不足等),则会发起一次GC(Scavenge GC)。回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存放满了时,则将Eden区和Survivor0区存活对象复制到另一个Survivor1区,然后清空Eden和这个Survivor0区,此时Survivor0区是空的,然后将Survivor0区和Survivor1区交换,即保持Survivor1区为空, 如此往复。当Survivor1区不足以存放 Eden和Survivor0的存活对象时,就将存活对象直接存放到老年代。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例。
2.年老代(Old Generation)
在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。一般来说,大对象会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。比如:
byte[] data = new byte[4*1024*1024]
这种一般会直接在老年代分配存储空间。
当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和JVM的相关参数。
3.持久代(Permanent Generation)
用于存放静态文件(class类、方法)和常量等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。对永久代的回收主要回收两部分内容:废弃常量和无用的类。
永久代空间在Java SE8特性中已经被移除。取而代之的是元空间(MetaSpace)。因此不会再出现“java.lang.OutOfMemoryError: PermGen error”错误。
堆内存分配策略明确以下三点:
(1)对象优先在Eden分配。
(2)大对象直接进入老年代。
(3)长期存活的对象将进入老年代。
**新生代GC(Minor GC/Scavenge GC):**发生在新生代的垃圾收集动作。因为Java对象大多都具有朝生夕灭的特性,因此Minor GC非常频繁(不一定等Eden区满了才触发),一般回收速度也比较快。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集。
**老年代GC(Major GC/Full GC):**发生在老年代的垃圾回收动作。Major GC,经常会伴随至少一次Minor GC。由于老年代中的对象生命周期比较长,因此Major GC并不频繁,一般都是等待老年代满了后才进行Full GC,而且其速度一般会比Minor GC慢10倍以上。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。
Java中的垃圾处理器(Garbage Collector)
Serial GC
**特点:**单线程工作,适用于小型或单核系统。
**使用场景:**适用于简单的应用或小型测试。
Parallel GC
**特点:**多线程并行收集,适用于多核处理器,注重吞吐量,即在给定时间内执行尽可能多的工作。
使用场景: 适用于后台运行、处理大量数据应用,强调吞吐量而不是低延迟**。**
实现方式
**年轻代收集(Young Generation Collection):**应用程序创建的新对象会被分配到年轻代的Eden区,当Eden区满时,触发一次Minor GC,将存活的对象复制到Survivor区或老年代。
**老年代收集(Old Generation Collection):**当老年代空间不足时,触发一次Full GC。Full GC会停止应用程序,标记并清理整个堆。
在JDK 5开始做为默认GC,直到JDK 9默认GC被替换成G1。
Concurrent Mark-Sweep GC (CMS GC)
**特点:**采用多线程进行垃圾回收,主要目标是减小停顿时间。
使用场景:适用于对低延迟要求较高的中大型应用,但可能存在碎片问题。
首次出现在JDK 5中,在JDK 9中被标记为过时(deprecated),在JDK 16中被移除。
Garbage-First GC (G1 GC)
**特点:**采用分代收集算法,目标是在高吞吐量的同时,降低GC停顿时间。
使用场景:适用于大堆内存的应用,对延迟要求较高的场景,能有效避免大部分的碎片问题。
实现方式
**初始标记(Initial Mark):**标记所有的GC Roots,并标记所有的被直接引用的对象。
**并发标记(Concurrent Mark):**并发地标记所有可达的对象,不会停顿应用线程。
**最终标记(Final Mark):**针对在并发标记过程中发生变化的对象进行最后的标记。
**筛选回收(Live Data Counting and Evacuation):**根据垃圾回收的需求,选择性地清理一部分区域中的垃圾对象。
区域化内存管理: G1GC将整个堆划分为多个大小相等的区域,每个区域可以是Eden区、Survivor区或Old区。这样的区域化内存管理有助于更精确地控制垃圾收集的过程。
首次出现于JDK 7中,在JDK 9开始做为默认GC。
ZGC
**特点:**低延迟垃圾收集器,适用于需要极低GC停顿时间的应用。
使用场景:对于对低延迟要求极高的大型应用,例如金融交易系统等。
实现方式
**全并发收集:**整个垃圾收集过程中,几乎所有的垃圾收集工作都是与应用线程并发进行的。这包括标记、压缩和重定位等操作。
**Region-Based内存管理:**将Java堆划分为许多小块区域。这有助于更高效地进行垃圾收集,减小了垃圾收集停顿的时间。
**并发标记-整理(Concurrent Mark-Compact):**将Java堆划分为许多小块区域。这有助于更高效地进行垃圾收集,减小了垃圾收集停顿的时间。
**Colored Pointers:**通过对引用进行颜色标记,减少了垃圾收集的标记阶段所需的停顿时间。
**Eager Reclamation:**在标记阶段完成后,尽早地回收无用的对象。这有助于减小垃圾收集周期的长度。
在JDK 11中,ZGC被引入并成为一个实验性特性。在JDK 15开始做为默认GC。
不同版本JDK主流GC性能对比



