深入理解Java虚拟机》学习总结-垃圾收集

一.分代收集理论

当前商业虚拟机的垃圾收集器,大多数遵循了“分代收集”的理论,该理论建立在两个分代假说之上:

1)弱分代假说:绝大多数对象都是朝生夕灭的

2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡

3)跨代引用假说:跨代引用相对于同代引用来说仅占极少部分

这两个分代假说共同奠定了垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同区域,然后将回收对象依据其年龄分配到不同的区域之中去存储。

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

*新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集

*老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为

*混合收集(Mixed GC):指目标是收集整哥新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

二.垃圾收集算法

1)标记清除算法:算法分为“标记”和“清除”两个阶段,首先需要标记出所有需要回收的对象,标记完成后统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未标记的对象。标记过程就是对象是否属于垃圾的判定过程(主要使用可达性分析算法)。

缺点:①执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要回收的,这时需要大量标记和清除的动作

:②内存空间的碎片化问题,标记清除之后会产生大量不连续的内存碎片

2)标记-复制算法:这种算法将可用的内存按容量划分为大小相等的两快,每次只使用其中一块,当一块的内存用完了,就将还活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。当多数对象都是需要回收的时候,效率较高,而且每次都是针对半区进行内存回收,不需要考虑有空间碎片的复制情况,简单高效

缺点:①如果内存中多数对象中都是存活的,这种算法会产生大量内存间复制的开销

:②将可用内存缩小了一半

3)标记-整理算法:标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低,而且如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都是100%存活的极端情况,所以在老年代一般不能直接选用这种算法。标记-整理算法和标记-清除算法类似,本质区别在于标记整理算法在进行垃圾回收时会将存活的对象整理到一块,解决了空间碎片问题。

缺点:如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将是极为负重的操作,而且这种对象移动操作必须暂停用户应用程序才能进行

三.根节点枚举

所有收集器在根结点枚举(可达性分析算法)这一步骤都是必须暂停用户线程的,因此根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。现在可达性分析算法耗时最长的查找引用链过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个保障一致性的快照中才得以进行——这里的“一致性”的意思是整个枚举期间执行子系统看起来就行被冻在某一个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况。

四.记忆集和卡表

为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代,老年代采用跨代引用问题。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,如果不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构,但实际上我们只需要通过记忆集判断出某一块非收集区域是否存在有指向收集区域的指针就可以了,并不需要了解跨代指针的全部细节。那么设计者在实现记忆集的时候便可以选择更为粗犷记录粒度来节省记忆集的存储和维护成本,如:

1)字长精度:每个记录精确记录到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针

2)对象精度:每个记录精确到一个对象,该对象里含有跨代指针

3)卡精度:每个记录精确到一块内存区域,该区域内含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”的方式去实现记忆集,这也是目前最常用的一种记忆集实现方式。

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机也确实是这样做,以下这行代码是HotSpot默认的卡表标记逻辑

深入理解Java虚拟机》学习总结-垃圾收集

字节数据CARD_TABLE的每一个元素都对应者其标识的内存区域中一块特定大小的内存块,如下:

深入理解Java虚拟机》学习总结-垃圾收集

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

五.并发的可达性分析

我们遍历对象的时候,按照“是否访问过”这个条件标记成以下三种颜色:

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

2)黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象代表已经扫描过,它是安全存活的。(黑色对象不可知直接(不经过灰色对象)指向某个白色对象)。

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

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

*赋值器插入了一条或多条从黑色对象到白色对象新引用

*赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

ps:刚开始我还疑惑,不是把灰色对象到白色对象的直接或间接引用全删了不就行了吗,为什么要同时满足两个条件呢,但是后来我仔细一想就发现了,虚拟机的垃圾收集机制不会无缘无故删除引用的,假如我删除了对一个对象的引用,那它肯定会被其它对象所引用,或者连同引用它的对象一起消失(也就是本就应该时白色),所以当同时满足者两个条件才能判定该对象会被误删。

因此,我们要解决并发扫描的对象消失问题,只需破坏这两个条件的任意一个就行了。由此分别产生了两种解决方案:增量更新和原始快照

增量更新:增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以简化理解为:黑色对象一旦新擦汗如指向白色对象的引用后,它就变回灰色对象了。

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

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的(待研究)

CMS基于增量更新,G1和Shenandoah则是原始快照

六.经典垃圾收集器

1)Serial收集器:Serial收集器是最基础,历史最悠久的收集器。该线程是一个单线程的收集器,它的单线程主要是强调它在进行垃圾收集时,必须暂停其它所有工作线程,直到它收集结束,也就是“Stop The World”。如下是Serial/Serial Old收集器运行过程:

深入理解Java虚拟机》学习总结-垃圾收集

2)ParNew收集器:ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行收集之外,其余的行为基本与Serial收集器一致,ParNew收集器工作工程如图所示:

深入理解Java虚拟机》学习总结-垃圾收集

除了Serial收集器外,目前只有它能与CMS收集器配合,CMS是一个老年代收集器,它首次实现了垃圾收集线程与用户线程同时工作,JDK9后,取消了ParNew+Serial Old以及Serial+CMS收集器组合的支持,这意味这ParNew和CMS只能互相搭配使用。

3)Parallel Scavenge收集器Parallel Scavenge收集器是一款新生代收集器,它基于标记复制算法实现,也是能够并行收集的多线程收集器,该收集器重视的可控制的吞吐量

深入理解Java虚拟机》学习总结-垃圾收集

该收集器可以控制最大垃圾收集停顿时间(停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的),以及直接设置吞吐量大小。

4)Serial Old收集器:该收集器是Serial收集器的老年版本,它同样是一个单线程收集器使用标记-整理算法,可以与Parailel Scavenge搭配使用,还可作为CMS收集器失败时的备选方案

5)Parallel Old收集器:该收集器是Parallel Scavenge的老年版本支持多线程并发收集基于标记整理算法实现。新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器则只能选择Parallel Old收集器。在注重 吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组 合。Parallel Old收集器的工作过程如图:

深入理解Java虚拟机》学习总结-垃圾收集

6)CMS收集器该收集器是一个老年代收集器,是一种获取最短回收停顿时间为目标的收集器,它收集的过程分为四个步骤:

*初始标记需要“Stop The World”,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

*并发标记:该阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时长,但可以与用户线程并发运行。

*重新标记需要“Stop The World”,为了修正并发标记阶段标记期间,因用户程序继续运作而导致标记产生变得的那一部分对象标记记录(增量更新(CMS采用),原始快照)

*并发清除:该阶段会清理删除掉标记阶段判定已经死亡的对象,由于不需要移动存活对象,所以也是可以与用户线程同时并发的。

由于整个过程中耗时最长的并发标记和并发清除阶段,垃圾收集线程都可以与用户线程一起工作,所以从总体上看,CMS收集器的内存回收过程是与用户线程一起并发执行的。CMS运作图如下所示:

深入理解Java虚拟机》学习总结-垃圾收集

缺点:①由于并发收集,对处理器资源非常敏感 ②无法处理“浮动垃圾”,在CMS的并发标记和并发清理阶 段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分 垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集 时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运 行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待 到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被**,这是一个偏保守的设置。③CMS是基于标记-清除算法实现的收集器,会产生空间碎片

7)Garbage First收集器(G1收集器):G1是一款主要面向服务端应用的垃圾收集器,HotSpot开发团队希望它未来可以替换掉CMS收集器。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的大小可以通过参数-XX:G1HeapRegionSize设 定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代 的一部分来进行看待

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

·初始标记(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的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

七.内存分配与回收策略

对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即使编译后被拆散为标量类型并间接的在栈上分配)。在经典分代设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能直接分配在老年代。对象分配的规则不是固定的,这取决于虚拟机当前使用哪一种垃圾收集器。

Serial加Seral Old收集器内存分配与回收策略如下:

1)大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

2)大对象直接进入老年代

3)长期存活的对象进入老年代,内存回收时必须能决策哪些存活对象应当放在新生代,哪些存活对象应放在老年代。为做到这点,虚拟机给每个对象定义了一个年龄(Age)计数器,存储在对象头中。对象通常在Eden区诞生,如果经过一次Minor GC后仍然存活,并且能被Suvivor容纳的话,该对象会移动到Survivor空间中,并将年龄设为1岁。对象每在Survior区中熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。

4)动态对象年龄判定,HotSpot虚拟机并不是永远要求对象年龄必须达到XX:MaxTenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代

5)空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会看是否允许担保失败,如果允许的话,那么继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试”冒险“进行一次Minor GC,否则进行一次Full GC

解释一下“冒险”冒的是什么险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间。担保失败的话,会重新发起一次Full GC。