Java垃圾回收机制与垃圾收集器
目录
一、垃圾回收机制:
1、垃圾回收的过程:
2、确认对象是否存活:
算法流程
CMS 解决方案
G1 解决方案
3、对象的回收经历:
4、垃圾收集算法:
5、对象内存分配策略:
二、垃圾收集器:
💡6、CMS收集器:
💡7、G1收集器(JDK9后的默认收集器):
一、垃圾回收机制:
1、垃圾回收的过程:
JVM内存的程序计数器、虚拟机栈、本地方法栈的生命周期是和线程是同步的,随着线程的销毁而自动释放内存,所以只有方法区和堆需要GC,方法区主要是针对常量池的回收和对类型的卸载,堆针对的是不再使用的对象进行回收内存空间。我们常说的GC一般指的是堆的垃圾回收,堆内存可以进一步划分新生代和老年代,老年代会发生Full GC,年轻代会发生Minor GC,年轻代又可以分成三部分:一个Eden区和两个Survivor区(即From区(S0)和To区(S1)),比例为8:1:1。
Minor GC主要针对Java堆中的新生代(Young Generation)进行垃圾回收。
- 当程序创建新的对象时,这些对象首先会被分配到Eden区。当Eden区满时,会触发Minor GC。在Minor GC过程中,垃圾回收器会扫描并标记所有存活的对象,然后将这些存活的对象移动到Survivor区。
Full GC主要针对Java堆中的老年代(Old Generation)进行垃圾回收。老年代中存放的是存活时间较长的对象。
- Full GC会对整个Java堆进行垃圾回收,包括新生代和老年代。Full GC的触发条件通常有多种,例如老年代空间不足、永久代空间不足、频繁的Minor GC等。
在GC开始时,对象会存在Eden和From区,To区是空的,当Eden区没有足够的内存分配给对象时,虚拟机会发起一次Minor GC。进行GC时,Eden区存活的对象会被复制到To区,From区存活的对象会根据年龄值决定去向,达到阈值的对象会被移动到老年代中,没有达到阈值的对象会被复制到To区(但如果符合“动态年龄判断”的条件,即使未达到阈值也会从Survivor区直接移动到老年代)。这时Eden区和From区已经被清空了,接下来From区和To区交换角色,以保证To区在GC开始时是空的。Minor GC会一直重复这样的过程,直到To区被填满,To被填满后,会将所有对象移动到老年代中。如果老年代内存空间不足,则会触发一次Full GC。
GC年龄阈值默认是15,该阈值是否可以调整?能否调整为16?年龄阈值是可以调整的,但是由于对象头中只分配了 4bit 位用于记录对象的GC年龄,因此最大只能调整为15
2、确认对象是否存活:
垃圾收集器在对堆进行回收前,首先要确定对象是否存活,判断对象是否存活主要有两种算法:引用计数算法 和 可达性分析算法。
(1)引用计数算法:对象创建时,给对象添加一个引用计数器,每当有一个地方引用到它时,计数器值加1;引用失效时,计数器值减1;当计数值值为0时,这个对象就是不可能再被引用的。
(2)可达性分析算法:以“GC Roots”对象为起点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连接时,则证明此对象是不可用的。
GC Roots对象包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 本地方法栈中JVM(Native)引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象。
四种对象引用类别:(关联强度向下递减)
- 强引用:GC不会回收强引用的对象。
- 软引用:如果内存不紧张,这类对象可以不回收;如果内存紧张,这类对象就会被回收
- 弱引用:被弱引用关联的对象,只能生存到下一次垃圾收集。
- 虚引用:目的是能在对象被回收时收到一个系统通知。
💡注:在java中当我们创建一个对象时Object obj=new Object(),默认的引用就是强引用,如需更换则可以通过java提供的对应的方法转换。
💡(3)三色标记算法(CMS、G1):
三色标记法是一种垃圾回收法,它可以让JVM不发生或仅短时间发生STW(Stop The World),从而达到清除JVM内存垃圾的目的。JVM中的CMS、G1垃圾回收器的并发标记算法所使用垃圾回收算法即为三色标记法。
三色标记算法思想
三色标记法将对象的颜色分为了黑、灰、白,三种颜色。
白色:该对象没有被标记过。(对象垃圾)
灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)
黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)
算法流程
从我们main方法的根对象(JVM中称为GC Root)开始沿着他们的对象向下查找,用黑灰白的规则,标记出所有跟GC Root相连接的对象,扫描一遍结束后,一般需要进行一次短暂的STW(Stop The World),再次进行扫描,此时因为黑色对象的属性都也已经被标记过了,所以只需找出灰色对象并顺着继续往下标记(且因为大部分的标记工作已经在第一次并发的时候发生了,所以灰色对象数量会很少,标记时间也会短很多), 此时程序继续执行,GC线程扫描所有的内存,找出扫描之后依旧被标记为白色的对象(垃圾),清除。
具体流程:
- 首先创建三个集合:白、灰、黑。
- 先将所有对象放入白色集合中。
- 然后从根节点开始遍历所有对象(注意这里并不递归遍历),把遍历到的对象从白色集合放入灰色集合。
- 之后遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
- 重复 4 直到灰色中无任何对象
- 通过write-barrier检测对象有变化,重复以上操作
- 收集所有白色对象(垃圾)
三色标记算法缺陷
不知道你是否还记得我们前言说的,所有垃圾收集器在根节点枚举这一步骤时都是必须暂停用户线程的,产生 STW,这对实时性要求高的系统来说,这种需要长时间挂起用户线程是不可接受的。想要解决或者降低用户线程的停顿的问题,我们才引入了三色标记算法。
三色标记算法也存在缺陷,在并发标记阶段的时候,因为用户线程与 GC 线程同时运行,有可能会产生多标或者漏标。
多标
假设已经遍历到 E(变为灰色了),此时应用执行了 objD.fieldE = null (D > E 的引用断开) 。
D > E 的引用断开之后,E、F、G 三个对象不可达,应该要被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
漏标
假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程先执行了:
var G = objE.fieldG;
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; //
此时切回到 GC 线程,因为 E 已经没有对 G 的引用了,所以不会将 G 置为灰色;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G 会一直是白色,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
不难分析,漏标只有同时满足以下两个条件时才会发生:
- 一个或者多个黑色对象重新引用了白色对象;即黑色对象成员变量增加了新的引用。(新引用)
- 灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化(断开原来引用)。
重新标记是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记 STW 的时间,这个是优化问题了。看到了没?三色标记算法也并不能完全解决 STW 的问题,只能尽可能缩短 STW 的时间,尽可能达到停顿时间最少。
漏标解决方案
正如前面所说,三色标记算法会造成漏标和多标问题。但多标问题相对不是那么严重,而漏标问题才是最严重的。我们经过分析可以知道,漏标问题要发生需要满足如下两个充要条件:
- 有至少一个黑色对象在自己被标记之后指向了这个白色对象
- 所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用
只有当上面两个条件都满足,三色标记算法才会发生漏标的问题。换言之,如果我们破坏任何一个条件,这个白色对象就不会被漏标。这其实就产生了两种方式,分别是:增量更新、原始快照。CMS 回收器使用的增量更新方案,G1 采用的是原始快照方案。
CMS 解决方案
CMS 回收器采用的是增量更新方案,即破坏第一个条件:「有至少一个黑色对象在自己被标记之后指向了这个白色对象」。
既然有黑色对象在自己标记后,又重新指向了白色对象。那么我就把这个黑色对象的引用记录下来,在后续「重新标记」阶段再以这个黑色对象为跟,对其引用进行重新扫描。通过这种方式,被黑色对象引用的白色对象就会变成灰色,从而变为存活状态。
这种方式有个缺点,就是会重新扫描新增的这部分黑色对象,会浪费多一些时间。但是这段时间相对于并发标记整个链路的扫描,还是小巫见大巫,毕竟真正发生引用变化的黑色对象是比较少的。
G1 解决方案
G1 回收器采用的是原始快照的方案,即破坏第二个条件:「所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用」。
既然灰色对象在扫描完成后删除了对白色对象的引用,那么我是否能在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来。随后在「重新标记」阶段再以白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。
这种方式有个缺点,就是会产生浮动垃圾。 因为当用户线程取消引用的时候,有可能是真的取消引用,对应的对象是真的要回收掉的。这时候我们通过这种方式,就会把本该回收的对象又复活了,从而导致出现浮动垃圾。但相对于本该存活的对象被回收,这个代码还是可以接受的,毕竟在下次 GC 的时候就可以回收了。
对于 CMS 和 G1 这两种处理方案哪种更好,很多资料说的是 G1 这种解决方案更好。 原因是其觉得 G1 这种方式产生了一些浮动垃圾,但节省了一些时间。
3、对象的回收经历:
目前最普遍使用的判断对象是否存活的算法是可达性分析算法,对象在真正死亡,需要经历两个阶段:
(1)可达性分析后,如果对象没有与GC Roots相连的引用链,会被第一次标记并筛选出来。如果对象覆盖了finalize()方法 且 未调用过finalize()方法,则对象会被放在F-Queue队列中,等待线程执行finalize()方法。
(2)若对象想要存活下来,finalize()方法是最后的机会,只需在finalize()方法中重新与引用链上的对象相关联,否则,GC对F-Queue队列进行第二次小规模标记后,真正地进行垃圾回收。
4、垃圾收集算法:
确认对象已经不可达之后,在触发GC时就要对这类对象进行回收,常见的GC算法如下:
(1)标记-清除算法:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。缺点:会产生大量不连续的内存碎片。
(2)标记-整理算法:首先标记出所有需要回收的对象,接着将所有存活的对象都向一端移动,然后清理掉端边界以外的内存。缺点:移动对象很笨重,需要STW(暂停所有线程,等待垃圾回收执行完成)才能进行,所以老年代比较适合这种算法。
(3)标记-复制算法:将可用内存划分成大小相等的两块,每次只使用其中的一块,当这块的内存用完时,就将还存活的对象复制到另一块内存中,然后再把原来的内存空间清理掉。缺点:内存缩小为原来的一半。
(4)分代收集算法:根据各个年龄代的特点选择合适的收集算法。在新生代中,每次垃圾收集都有大量的对象死去,因此采用复制算法。老年代中,因为对象的存活率高,没有额外的空间对他进行担保,因此使用“标记-清除”和“标记-整理”算法。
5、对象内存分配策略:
前面部分介绍了GC的过程,而为了避免频繁发生GC,JVM在为对象分配内存时也定义了一套策略:
(1)对象优先在Eden分配:防止频繁发生Full GC
(2)大对象直接进入老年代:避免Eden区及两个Survivor区之间发生大量的内存复制。
(3)长期存活的对象将进入老年代:年轻代中的对象每经过一次Minor GC,则年龄加1,若年龄超过阈值(默认15),则被晋升到老年代。
(4)动态年龄判断:在GC时会对Survivor中的对象进行判断,Survivor空间中年龄相同的对象占用内存总和大于等于Survivor空间一半的话,大于或等于该年龄的对象就会被复制到老年代
例如:当Eden区Minor gc后有60MB对象需要移到Survior区中,而Survior区分配的大小为100MB,那么就超过了一半,那么这60MB对象就会直接移到老年代中。当这种现象出现很多次后那么就会增加老年代的压力,即Full GC的次数会增多。解决办法:合理分配好Eden区和Surivor区的大小,避免因为超过一半内存而直接移入老年代。
(5)空间分配担保:Minor GC前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间,若成立,则Minor GC是安全的。若不成立,则检查是否允许担保失败,如果允许,检查老年代最大可用连续空间是否大于历次晋升到老年代的平均大小,大于,则尝试进行Minor GC;如果小于或者不允许冒险,则Full GC。
二、垃圾收集器:
1、Serial收集器:
Serial 收集器是一个新生代收集器,使用复制算法。由于是单线程执行的,所以在进行垃圾收集时,必须暂停其他所有的用户线程(Stop the world),对于限定单个CPU的环境来说,由于没有线程切换的开销,可以获得最高的单线程收集效率。
2、ParNew收集器:
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器完全一样,包括控制参数、收集算法、Stop The Worl、对象分配规则、回收策略等。
在多核CPU上,回收效率会高于Serial收集器;反之在单核CPU, 效率会不如Serial收集器。ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过 -XX:ParallelGCThreads 参数来限制垃圾收集器的线程数;
3、Parallel Scavenge收集器:
Parallel Scavenge 收集器是新生代收集器,使用复制算法,并行多线程收集。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短GC时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。(吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了100分钟。其中垃圾收集花掉1分钟,那吞吐量就是99%)。高吞吐量可以最高效率地利用CPU时间,尽快完成程序的运算任务,主要适用于在后台不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精准控制吞吐量:
(1)-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数。
(2)-XX:GCTimeRatio:直接设置吞吐量大小,是一个大于0小于100的整数,也就是程序运行时间占总时间的比率,默认值是99,即垃圾收集运行最大1%(1/(1+99))的垃圾收集时间。
(3)支持自适应的GC调节策略。它还提供一个参数:-XX:+UseAdaptiveSizePolicy,这是个开关参数,打开之后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、新生代晋升年老代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以达到最大吞吐量。
4、Serial Old收集器:
Serial Old是Serial收集器的老年代版本,使用单线程执行和“标记-整理”算法。主要用途是作为CMS收集器的后备垃圾收集方案,在并发收集发生 Concurrent Mode Failure 的时候,临时启动Serial Old收集器重新进行老年代的垃圾收集。
5、Parallel Old收集器:
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,JDK1.6之后开始提供,使用多线程和“标记-整理”算法。
在JDK1.6之前,新生代使用 Parallel Scavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge 和年老代 Parallel Old 收集器的搭配策略。
💡6、CMS收集器:
CMS(Concurrent Mark Sweep)收集器应用于老年代,采用多线程和“标记-清除”算法实现的,实现真正意义上的并发垃圾收集器,是一种以获取最短回收停顿时间为目标的收集器。整个收集过程大致分为4个步骤,如下图所示:
(1)初始标记:需要停顿所有用户线程,初始标记仅仅是标记出GC ROOTS能直接关联到的对象,速度很快。
(2)并发标记:CMS收集器引入了三色标记算法,可以进行并发标记,判定对象是否存活,和用户线程一起工作,不需要暂停工作线程。
(3)重新标记:修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。需要停顿所有用户线程,停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
(4)并发清除:清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。
整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的虽然真正意义上实现了并发收集以及低停顿,但CMS还远远达不到完美,主要有四个显著缺点:
(1)CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。
(2)CMS收集器无法处理浮动垃圾。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,称为“浮动垃圾”,CMS 无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。
(3)由于垃圾收集阶段会产生“浮动垃圾”,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致 “Concurrent Mode Failure” 失败,性能反而降低。
(4)CMS是基于“标记-清除”算法实现的收集器,会产生大量不连续的内存碎片。空间碎片太多时,如果无法找到一块足够大的连续内存存放对象时,将不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。
💡7、G1收集器(JDK9后的默认收集器):
(1)G1(Garbage First)收集器是JDK1.7提供的一个新收集器,与CMS收集器相比,最突出的改进是:
- 基于“标记-整理”算法实现,不会产生内存碎片。
- 可以在不牺牲吞吐量前提下,精确控制停顿时间,实现低停顿垃圾回收
其他特点:
并行性: 回收期间, 可由多个线程同时工作, 有效利用多核cpu资源;
并发性: 与应用程序可交替执行, 部分工作可以和应用程序同时执行,
分代GC: 分代收集器,同时兼顾年轻代和老年代。他能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过了多次GC的对象,以便获取更好的GC效果。
(2)垃圾收集原理:
G1 收集器不采用传统的新生代和老年代物理隔离的布局方式,将整个堆内存划分为2048个大小相等的独立内存块Region,每个Region是逻辑连续的一段内存,具体大小根据堆的实际大小而定,整体被控制在 1M - 32M 之间,且为2的N次幂(1M、2M、4M、8M、16M和32M),并使用不同的Region来表示新生代和老年代,即每个区域都可以充当eden,survivor,old,humongous(为大对象准备),G1不再要求相同类型的 Region 在物理内存上相邻,而是通过Region的动态分配方式实现逻辑上的连续。
G1收集器通过跟踪Region中的垃圾堆积情况,每次根据设置的垃圾回收时间,回收优先级最高的区域,避免整个新生代或整个老年代的垃圾回收,使得stop the world的时间更短、更可控,同时在有限的时间内可以获得最高的回收效率。
通过区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。
(3)G1的收集过程:
分成三个阶段:年轻代回收、并发标记、混合收集。
如果不考虑维护Remembered Set的操作,可以分为上图4个步骤(与CMS较为相似),其中初始标记、并发标记、重新标记跟CMS收集器相同,只有第四阶段的筛选回收有些区别。
筛选回收:首先排序各个Region的回收价值和成本,然后根据用户期望的GC停顿时间来制定回收计划, 最后按计划回收一些价值高的Region中垃圾对象。
1.年轻代回收:
在初始状态时,所有的区域都是空闲状态,那么创建了一些对象后,就会挑出一些空闲区域作为伊甸园区存储这些对象。
新生代的占比大约在5%-6%波动(由G1控制),在伊甸园需要垃圾回收时,采用复制算法复制所有存活对象,需要暂停所有用户线程(STW)。
然后经历过一段时间后,伊甸园区的内存又不足,就需要将伊甸园区和之前幸存区中的对象采用复制算法,复制到新的幸存区,其中老的对象就会晋升到老年代中。
2.年轻代回收+并发标记:
当老年代占用内存超过阈值(默认45%),会触发并发标记,这时候无需暂停用户线程。
在并发标记(三色标记算法)之后,会进行重新标记,这时会暂停所有用户线程(STW)。
3.混合收集阶段:
在上面的步骤完成后,就知道了老年代有哪些存活的对象,随后进入了混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是Gabage First名称的由来)。然后就将回收价值高的老年代,和年轻代(伊甸园区和幸存区) 进行回收(包含年轻代和老年代=混合收集)。
复制完成后,进行下一轮年轻代回收,并发标记,混合回收。
注:
如果一个对象太大,一个区域存放不下,那么会开辟一个连续的空间来存放巨型对象。
如果并发失败(回收速度赶不上创建新对象的速度),会触发Full GC。