JVM垃圾回收
对于Java,大部分的对象都是分配在堆内存中,但是内存是有限的,不可能无限地去申请内存分配给对象,所以就需要有垃圾回收,来将一些没有被引用,已经不需要的东西释放掉来省出空间来。本文主要大概地总结下Java如何判断一个对象的存活、Java的垃圾收集算法、Java的经典垃圾收集器和一些内存分配策略。
判断对象存活
通常所比较常见的一种判断对象存活的方法是引用计数法,即通过一个引用计数器记录该对象的引用次数,当其计数为0时即没有人再使用它。但是这种方法也存在一个问题,就是如果有两个对象之间相互引用,那么它们的引用就不会为0,而Java也确实没有采用这种引用计数法,而是采用了可达性分析
来作为判断对象存活的方法。
可达性分析
对于可达性分析的介绍,我引用《深入理解Java虚拟机》里的话:
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
像图中的obj5、6、7因为到达不了GC Roots,在垃圾回收时它们就会被回收掉。
可以作为GC Roots的对象为以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
前三种对象我分别举个例子:
虚拟机栈中引用的对象:
1 | public class Root{ |
类静态属性引用的对象:
1 | public class Root{ |
常量引用的对象:
1 | public class Root{ |
四种引用
Java将引用分为了如下四种:
- 强引用:只要强引用关系(类似Object obj = new Object())存在则被引用的对象永远不会被回收
- 软引用:只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
方法区回收
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
废弃常量的判断是比较简单的,对于一个类是否不再被使用,则需要同时满足以下三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
即使同时满足了上面三个条件,虚拟机也只是允许它们可以被回收,没有说一定就要执行回收。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
垃圾收集算法
标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
标记-清除算法主要有两个缺点:第一个是执行效率的不稳定,标记和清除的操作是需要花费时间的,如果堆中大量对象需要被回收,就会进行大量的标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是容易产生内存碎片,标记清除后会产生大量不连续的内存碎片,如果碎片太多,就可能会导致一个大对象因为得不到一个连续的内存而提前触发另一次垃圾回收。
标记-复制算法
针对标记-清除算法的问题,提出了标记-复制算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。通常情况下多数的对象都是可回收的,因此需要复制的存活对象是占少数的,内存复制的开销不会太大。而且分配时也不用考虑碎片的情况,只要移动堆顶指针按顺序分配即可。但该算法的缺陷就是造成了空间浪费,原本能使用的内存空间被砍了一半。
针对新生代大部分对象都活不过第一轮收集,像HotSpot的Serial、ParNew等新生代收集器采用了Appel式回收
的策略来设计新生代的内存布局,具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。Eden和Survivor的大小比例默认是8:1,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
标记-整理算法
针对老年代大部分对象都是存活这种特征,采用标记-复制的话会花费很大的开销,针对老年代的情况,提出了标记-整理算法。其标记过程和标记-清除一样,后续的步骤则是是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
分代收集理论
Java将堆划分成了不同的区域,这样垃圾收集器就可以针对区域来进行回收而不需要去关注整个堆内存。通常的分代将堆分为新生代和老年代,新生代的对象存活周期大部分是很短的,如果一个对象在新生代存活到一定周期则会晋升到老年代中。上面所提到的三种算法也正是基于分代收集理论发展出来的。对于新生代这种存活对象少的,采用的比较多的是复制算法。老年代这种存活对象多的则采用清除算法或者整理算法,使用哪种看具体的垃圾收集器。
HotSpot的一些算法细节
Java采用可达性分析来判断一个对象是否存活,根节点的枚举是必要的。而根节点的枚举是必须暂停用户线程的,也就是要面临Stop The World
,不然如果在枚举的过程中引用关系一直发生变化的话,分析的准确性会造成影响,即使是CMS、G1、ZGC这些停顿时间可控或者几乎不会有停顿的垃圾收集器,在进行根节点枚举时也是要暂停用户线程的。
HotSpot使用了OopMap这一数据结构来实现直接得到哪个地方存放着对象引用这一目的,这一就不需要一个个地从方法区等GC Roots开始查找。而HotSpot不会为每条指令生成OopMap,只会在某些特定的位置记录这些信息,这些位置就被称之为安全点
,程序执行到安全点后才能暂停。但实际中可能会有线程在没到达安全点之前阻塞或者休眠了,无法到安全点,虚拟机也不可能一直等待线程被激活,所以就有了安全区域
。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
前面提到了分代收集理论,分代会存在这么一种情况,就是新生代里的对象被老年代引用,即跨代引用,像G1、ZCG、Shenandpah这些有涉及到部分区域收集行为的收集器也会面临跨代引用,这会使得整个老年代都会加入到GC Roots的扫描范围中,为了避免这种问题,垃圾收集器在新生代中建立了一种叫记忆集
的数据结构。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。记忆集最常见的实现形式是通过卡表
去实现。HotSpot对卡表的实现逻辑是一个字节数组,其默认的卡表标记逻辑如下:
1 | CARD_TABLE [this address >> 9] = 0; |
Java将地址右移9位来映射到卡表的索引,也是说明HotSpot中卡页的大小是512字节,即
一个卡页变脏原则上是发生在引用类型字段赋值的时候,将其在数组的位置赋值为1。卡表的状态我们要进行维护的,在解释处理器中维护是比较容易的,但是在编译执行的场景中,经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中,而HotSpot通过写屏障(Write Barrier)
来维护卡表的状态,这里的写屏障和volatile字段的写屏障并不是同一个概念。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。直到G1收集器(G1采用写前屏障和写后屏障)出现前,其他收集器采用的都是写后屏障。其简化逻辑如下:
1 | void oop_field_store(oop* field, oop new_value) { |
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,也就是说写屏障是宁可错失也不放过。
在高并发的场景中,卡表也会面临伪共享
的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。对于一个64字节大小的缓存行,由于卡表元素大小为一个字节,就会有64个卡页共享同一个缓存行,一个卡页的大小为512字节,这样卡页的总内存就达到了32KB(64*512字节)的大小。如果不同的线程更新到了这一区域的对象,就会导致更新卡表时写入同一个缓存行而影响性能。为了避免伪共享的问题,采用了有条件的写屏障,即先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
1 | if (CARD_TABLE [this address >> 9] != 0) |
JDK 7之后HotSpot增加了一个参数-XX:+UseCondCardMark来决定是否开启卡表更新的条件判断,虽然开启后会增加额外判断的开销,但是可以避免伪共享的问题。
在并发场景的可达性分析中,可能会有这么两种情况,一种是把原本消亡的对象错误标记为存活,造成浮动垃圾,这是可以容忍的情况,下次收集时清理掉就行。另一种情况是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。根节点的枚举和对象图的遍历是要保持在一个能保障一致性的快照上进行的,通过三色标记
来推导这些过程。针对对象是否被访问过,我们采用黑、白、灰三种颜色。白色表示对象未被垃圾收集器访问过,在可达性分析开始时除了根节点都是白色节点,分析结束后仍为白色的节点则是不可达的;黑色表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的节点是安全存活的,而且黑色节点不可能不经过灰色对象直接指向白色对象;灰色对象表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。正常的标记过程如下图所示:
但在并发下可能会有下图的情况:
对象消失的问题当且仅当同时满足以下两个条件时发生:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
所以要解决对象消失的问题只需要破坏其中一个条件即可。解决方案有两种:增量更新
和原始快照
。
增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。CMS收集器的并发标记是通过增量更新来进行。
原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。G1、Shenandoah收集器采用原始快照来进行并发标记。
经典垃圾收集器
Serial收集器
Serial收集器是最基础、历史最悠久的收集器,是一个单线程工作的收集器,在进行垃圾收集时必须暂停其他所有工作线程直到结束。其运行过程如图所示:
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew收集器
ParNew收集器是Serial收集器的多线程并发版本。除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。
ParNew是许多运行在服务端模式下的虚拟机的首要选择。随着时代的发展,ParNew并入了CMS,成为CMS专门处理新生代的组成部分。
Parallel Scavenge
Parallel Scavenge也是一款采用标记-复制的新生代收集器,也是java1.8默认的收集器(Parallel Scavenge + Parallel Old)。其目的是达到一个可控制的吞吐量,也就是高效率地利用CPU,适合在后台运算而不需要太多交互的任务。吞吐量是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。如果对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。
Serial Old收集器
Serial Old是Serial的老年代版本。在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Parallel Old收集
Parallel Old是Parallel Scavenge的老年代版本。支持多线程并发收集,基于标记-整理算法实现。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。CMS是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。CMS是基于标记-清除算法实现的,过程分为如下四个步骤:
- 初始标记:初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。需要Stop The World。
- 并发标记:从GC Roots的直接关联对象开始遍历整个对象图,耗时较长但不会停止用户线程,可以与垃圾收集进程一起并发运行,由于有用户线程的进行,可能会导致引用发生变化,因此采用增量更新跟踪记录发生变化的地方。
- 重新标记:修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这一过程需要Stop The World,停顿的时间会比初始标记稍长一些,但是远比并发标记的时间短。
- 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
两个耗时比较长的并发标记和并发清除阶段都可以与用户线程一起运行,所以总体上CMS的内存回收是于用户线程一起运行的。
CMS有并发收集、低停顿的优点,但也存在下面三个缺点:
- 对CPU资源敏感:虽然并发阶段不会停顿用户线程,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是
,当处理器核心数不够4个时,CMS对用户程序的影响就可能变大。 - 无法处理浮动垃圾:这可能会出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。Java提供一个参数**-XX:CMSInitiatingOccu-pancyFraction**来使CMS在老年代使用了指定空间后被激活。参数如果设置过高容易使得预留内存不足导致并发失败触发Serial Old来进行老年代的收集而降低性能。
- 垃圾收集结束时可能会产生大量空间碎片:这是标记-清除算法的缺陷。CMS提供了两个参数(这两个参数在JDK 9开始废弃)-XX:+UseCMS-CompactAtFullCollection和**-XX:CMSFullGCsBefore-Compaction**。前者用于在CMS不得不Full GC时开启内存碎片的合并整理,但是会造成停顿时间长。后者是CMS在执行参数指定次不整理空间的Full GC后在进行下一次Full GC前进行碎片整理。
G1收集器
G1是一款主要面向服务端应用的垃圾收集器,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1主要针对配备多颗处理器及大容量内存的机器。 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
G1不再像之前的收集器那样划分新生代和老年代,而是根据哪块内存的垃圾回收收益最大来进行回收,这就是G1的Mixed GC模式。G1通过基于Region的内存布局来实现这个目标,每个Region的大小都是相等的,每个Region都可以根据需要充当新生代或者老年代的角色。Region中有一类特殊的区域Humongous区域,专门用于存储大对象,只要大小超过Region容量一半的都会被G1认为是大对象,对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。G1之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。其做法是G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也是"Garbage First"的名字由来。Region划分空间和按优先级回收区域的方式保证了G1收集器在有限的时间内获取尽可能高的收集效率。
G1也是使用记忆集来解决跨Region引用的问题,每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。
对于并发标记的问题,G1采用了原始快照(SATB)的方法来实现,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。
G1的运行过程划分为以下四个阶段:
-
初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
-
并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
-
最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
-
筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
除了并发标记阶段都需要暂停用户线程。
G1有这么些特点:
- 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。这意味着垃圾收集后仍能提供规整的可用内存。
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 可预测停顿:G1能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
- 分代收集:虽然G1采用了基于Region的内存布局,但还是保留了分代的概念。
G1比起CMS,也是有存在缺点的。G1产生的内存占用和额外执行负载都比CMS要高,G1的卡表占用堆的空间可能达到20%甚至更多,而CMS只需维护一份卡表即可。还有G1为了实现原始快照算法,除了写后屏障还需要写前屏障来跟踪并发时的指针变化,虽然原始快照对比起增量更新能减少并发标记和重新标记的消耗,但跟踪引用变化带来的负担确实是会产生且要消耗更多的运算资源的,这就使得G1采用一种类似于消息队列的结构,把写前屏障和写后屏障要做的事情放到队列里然后再异步处理,而CMS只采用了写后屏障,而且可以同步操作。
内存分配和回收策略
最后来说下Java对内存的分配和回收。
对于大部分的对象,都是在Eden区中进行分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。如果GC期间发现Survivor空间的内存不足以将Eden的一些对象存入进去,则会通过分配担保机制将对象从新生代提前转移到老年代中。
对于大对象(需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串和元素数量庞大的数组),则直接放入老年代中,这样做是为了避免在Eden区和两个Survivor区之间来回复制而产生大量的内存复制操作。在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
对于在新生代中长期存活的对象,则要将对象放入老年代中,为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。这个年龄的阈值是可以通过参数-XX:MaxTenuringThreshold来设置的,在CMS中,这个值为6。为了适应不同程序的内存状况,HotSpot并不永远要求对象年龄达到阈值才可以进入老年代,而是会采用动态年龄判断,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
前面我们提到了分配担保机制,也就是空间分配担保,在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。虽然担保失败时会造成更长的停顿时间,但是通常情况下还是会允许担保来避免频繁的Full GC的。
JVM的垃圾回收这部分内容大概总结的就是这些了(其实相当于摘抄了),看书的时候也没怎么感觉到内容多,但没想到记起笔记来时会有这么的东西要记录,真的是超乎想象了,Mark了9000多个字和几个小时的笔记真的是好累(特别是眼睛)。限于篇幅和精力,ZGC和Shenandoah这两款低延迟垃圾收集器没有在这里进行笔记,这两款收集器在书里的描述还是挺多的,看不懂的话网上也有文章讲解这两款收集器。还是很推荐学习Java的人去阅读这本《深入理解Java虚拟机》的,纯看八股是死记硬背,尽管JavaGuide上面也已经总结好了,但我觉得自己结合书本再总结一遍那是更好的。
最后放个表情来表达我写完笔记后的状态:
参考
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第三版)》
- JavaGuide:JVM垃圾回收详解
- 本文标题:JVM垃圾回收
- 本文作者:Johnson
- 创建时间:2022-09-01 22:47:24
- 本文链接:https://iconson.top/JVM垃圾回收/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!