上一篇文章和大家介绍了C语言内存模型和malloc底层内存池实现。
本节和大家谈谈,如何在c语言内存模型和malloc的基础上尝试去设计一个隐式分配器,也就是能够自动释放不需要的块的垃圾收集器。
再聊具体的垃圾回收算法前,我想先和大家聊聊一个垃圾回收器的设计需要涉及到哪些概念。
JAVA中的数据类型基本分为两类: 基本数据类型(不需要垃圾回收),引用类型(堆上分配,需要垃圾回收)。
JAVA中每一个类最终会被编译成一个.class文件,类加载器定位,读取该文件到JVM中,按照指定class文件格式,挨个解析每个属性,将属性值放到内存中对应的class数据结构中:
/*
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
*/
type ClassFile struct {
magic uint32
minorVersion uint16
majorVersion uint16
constantPool ConstantPool
accessFlags uint16
thisClass uint16
superClass uint16
interfaces []uint16
fields []*MemberInfo
methods []*MemberInfo
attributes []AttributeInfo
}我们如果想要new一个对象,也是先定位到该class在内存中对应的Class数据结构实例对象,然后根据里面存储的类元数据信息来新创建出一个对象:
type Object struct {
//对象元数据指针
class *Class
//实例对象的变量槽
data []Slot
}
type Slot struct {
//基本数据类型的值
num int32
//引用类型的值
ref *Object
}
//通过Class元数据信息创建出一个新的实例对象---此时还没有填充属性,只是单纯创建一个原始实例对象
func newObject(class *Class) *Object {
return &Object{
class: class,
data: newSlots(class.instanceSlotCount),
}
}上面给出了一个简单的模型实现,但是如果我们想要进行垃圾回收,那么必然还需要让每个对象提供相关用于垃圾回收的信息,这些信息可以存放在一个叫做对象头的地方,当然对象头可以分为两部分,一部分包含用于垃圾回收或者其他作用的信息,另一部分包含元数据指针。

此部分可以对照深入理解JVM第三版:

进行品读。
垃圾回收的执行通常是在方法执行的某个时间点发生的,那么该时间点哪些对象是一定不会被回收的呢 ?
这些对象一定不会被回收,所以这些对象构成的集合被称为根对象集合(ROOTS集合),因为垃圾回收器需要遍历这些对象的引用链找出当前所有的活动对象,因此该集合也被称为GC ROOTS。

存在于GC ROOTS集合中某个对象引用链上的对象被称为此刻的活动对象,而无法通过引用链找到的对象就被称为此刻的非活动对象,也就是垃圾对象。
标记清除算法分为两部分:

分配的过程还可以再优先,假设空闲链表此刻情况如下:

假设此刻我们要分配20字节大小的空闲块,我们采用最佳适配原则,遍历一遍空闲链表,找到最接近当前要分配大小的空闲块,但是在空闲链表非常长的情况下,这样的遍历会导致很高的性能损耗。
此时,我们可以借鉴伙伴算法的思想,采用数组+链表的形式:

当我们需要分配大于4小于32大小的空闲块时,直接通过索引1定位到第二条空闲链表,然后遍历该链表看能否得到合适大小的空闲块。
如果发现空闲链表中两个相邻块地址相邻,可以合并这两个空闲块。
当分配时,没有合适小块,只能分配一个大块时,需要将大块划分为两个小块。
优点:
缺点:
每个对象头中腾出一块空间,用于存放计数器:0

优点:
缺点:
引用计数法最大的麻烦处在于循环引用垃圾无法被回收,为了解决这个问题,我们可以使用引用计数配合标记清除来完成,具体思路如下:
针对可能存在循环引用的对象群使用标记清除算法,而对其他对象使用引用计数算法。
此时标记清除算法就是用来查找垃圾对象的了,那么如何实现呢?
核心思路如下:




部分标记清除能够很好解决循环引用垃圾的回收问题,但是从队列搜索对象的代价还是很大的,毕竟队列中记录的都是候选垃圾,所以要搜索的对象不在少数。
搜索对象也会导致垃圾回收的最大暂停时间变大,这抵消了引用计数的优点。
最简单的复制算法思想如下:


优点:
缺点:
简单的复制算法实现只能利用半个堆,如何优化呢?
例如: 把堆分成10份,其中2份作为From和To空间来执行复制算法,剩下8份执行标记清除算法。
这里举个例子,演示一下多空间复制算法的工作流程:

通过TO和FROM指针不断前移,将因标记清除算法产生的碎片空间进行整理,而原先整理好的空间又可能因为应用了标记清除算法而产生新的垃圾,就这样不断重复。
大致思路如下:


//scan是当前扫描的对象
//new_address堆空闲空间起始地址
scan=new_address=head_start
while(scan<head_end){
//当前对象为存活对象
if(scan.makr==true){
scan.forward=new_address
new_address+=scan.size
}
//处理下一个对象
scan+=scan.size
} 


优点就是充分利用堆空间的同时又不产生内存碎片,缺点就是要实现前面的效果需要付出大量计算和时间资源。
分代设计不属于某种具体垃圾回收算法的实现,而是考虑到不同对象生命周期长短的特点,应该“因材施教”。
我们在对象头中腾出一定空间用于存放对象的年龄,对象每熬过一次GC,对应年龄加一。
年龄小于指定阈值的对象被称为新生代对象,否则称为老年代对象。
新生代对象具有特点是生命周期短的特点,即每次GC后存活对象都是少数;老年代对象具有生命周期长的特点,因此老年代的GC频率应该偏低。
当新生代中的对象熬过指定次数GC后,年龄便会到达晋升老年代存放的的阈值,此时该对象会移动到老年代保存。
我们可以将不同类型的对象分区存放,将堆内存分为新生代区域和老年代区域,针对不同区域采用不同的GC算法实现:


新生代空间被划分为了生成空间,From空间和To空间三部分,每次new创建的对象保存在生成空间,经历了一次GC后,存活的对象,全部复制到To空间保存,然后交换From和To的指针。
如果From幸存空间大小不足以存放当前幸存对象,那么需要通过担保机制,直接将相关对象移动到老年代保存。
幸存空间通常只占有整个新生代大小10%左右,又因为只浪费其中一半的幸存空间,所以实际只浪费了5%的空间。
分代设计中关于内存分配的常见基本策略如下:
如果我们只是针对新生代进行垃圾回收,那么光光枚举GC ROOTS还不够,因为可能存在老年代中某个对象引用新生代中对象的事情发生,因此我们需要使用一种全局数据结构来指明当前老年代中存在哪些跨代引用,这种数据结构被称为记忆集。

通过一个对象指针数组记录全部含跨代引用对象的实现方案,记忆集数组的大小会随着跨代引用增加而变大,那么能不能进行优化呢?
我们可以把老年代划分为固定大小若干相等空间,这里将每个空间看做一个卡牌,然后使用一个位数组与每个卡片进行映射,如果该卡片空间内部存在跨代引用,那么设置对应位数组索引值为1即可:

此时,我们只需要用一个固定大小的字节数组就可以通过模糊的形式告诉垃圾回收器哪块卡片内部存在跨代引用,那么由垃圾回收器遍历该卡片内部的对象,找到存在跨代引用的对象,并加入GC ROOTS集合即可。
该实现方式在HotSpot中也被称为卡表。
为了在老年代对象产生跨代引用的时候,将其加入记忆集中,我们需要拦截所有引用类型字段赋值的过程,类似AOP,会在执行完赋值指令后,判断是否需要更新记忆集,该拦截过程被称为写屏障。
判断条件如下:

GC ROOTS枚举可以借助OopMap来加速完成,但是从GC ROOTS集合沿着引用链往下遍历的耗时则会根据堆内存活对象大小逐级递增。
能否加速遍历引用链的过程,或者说减少遍历引用链过程对用户线程的阻塞影响呢?
让遍历引用链的过程和用户线程并发执行,并发执行是可以,但是这个过程中会存在一些问题:
将原本存活的对象变为垃圾对象可以容忍,但是将存活对象视为垃圾对象回收是不可容忍的,因此我们必须解决后者。
为了解决这个问题,我们可以引入三色标记法,把遍历对象图过程中遇到的对象,按照是否访问过这个条件标记为三种不同的颜色:
这里我们主要来看一下将活动对象视为垃圾对象的操作:

用户程序和垃圾回收线程并发执行过程中,用户程序将B–>C变为了A–>C,但是由于A是黑色对象,那么A是不会再被访问的,那么意味着存活对象C将一直处于白色状态,最终会被垃圾回收器回收。
如果要导致存活对象被错误回收,需要满足两个条件:
要解决上面这个问题,我们只需要让其中任意一个条件不满足即可,有两种方式:
这里简单回顾一下深入理解JVM 虚拟机第三版书中列举的一些垃圾回收器。
serial重点是单线程,需要STOP THE WORLD。


Parallel Scavenge和ParNew一样,也是采用了标记复制算法,并且也是采用多线程收集模式。
Parallel Scavenge优于ParNew的地方在于,它可以通过参数调整来争取让程序吞吐量达到最高:

提供的相关参数主要控制垃圾回收花费时间不超过用户设置的值,或者垃圾回收时间占总时间比例不超过指定值。
并且相关参数还可以在运行中自适应调整。


注重实现最短回收停顿时间,适合注重响应时间的应用程序,如: Web程序。
CMS采用并发的标记清除算法实现,实现步骤如下:

垃圾回收线程能够和用户线程一起并发运行未必能够带来更好的效率,反而对于某些核数较少的机器而言,会因为频繁的上下文切换,降低运行效率。
并发标记和并发清理阶段,用户线程会不断产生新的垃圾对象,这些垃圾被称为浮动垃圾,CMS垃圾回收器无法在本次垃圾回收阶段回收掉这批浮动垃圾,因此并发回收过程中,需要预留一部分空间用作存放用户线程新对象的创建。
也就是说,如果老年代堆内存占用到达指定阈值,那么需要对老年代进行一次GC,从jdk 6起,该值被设置为92%,如果CMS运行期间,预留的内存无法满足新对象的分配,会触发"并发失败",此时需要采用逃生门设计: 暂停用户线程,使用Serial old来重新进行老年代的收集。
如果是因为内存碎片过多,导致无法找到足够大的内存空间来分配新对象,那么会触发一次FULL GC,默认会在进行FULL GC前,先对老年代进行一次碎片整理。
将堆划分为内存大小相同的连续区域(Region),每个独立的区域都可以扮演新生代的Eden空间,Survivor空间,或者老年代空间,G1会根据Region扮演的不同角色采用不同的策略去处理。

上面展示的是将Eden中幸存的对象复制到幸存区中。
G1将Region作为回收的最小单元,每次回收的内存空间都是Region的整数倍大小,G1通过跟踪各个Region里面的垃圾收集价值,在后台维护一个优先级列表,每次根据用户设定的允许收集停顿时间,优先处理那些回收价值最大的Region。
对于大对象的存储,Region采用了一类特殊的Humongous区域,只要某个对象大小超过单个Region容量一半,就认为该对象为大对象。G1大部分行为都会将Humongous Region当做老年代来看待。
G1通过化整为零的方法在解决跨代引用方面到变得复杂了起来,G1通过在每个Region上都维护一个记忆集,来记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页范围内,通过一个双向卡表结构,不仅记录谁指向我,也记录了我指向谁。由于每个Region都需要维护一个记忆集,此时G1收集器就需要耗费整个堆内存约10%-20%的额外内存来存放这些信息。
CMS采用增量更新的算法来实现垃圾回收线程和用户线程的并发可达性,和G1则采用原始快照算法来实现,为了存储并发执行过程中程序新创建的对象,G1为每个Region腾出一部分空间用于存放并发回收过程中新对象的分配。
与CSM并发失败导致FULL GC类似,如果G1的内存回收速度赶不上内存分配速度,G1也会产生FULL GC,从而产生长时间的STOP THE WORLD。
G1是如何计算得出某个Region的回收优先级的呢?
G1工作流程:

G1小结:
G1可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收效益最大,这就是G1收集器的Mixed GC模式
G1虽然仍然保留新生代和老年代的概念,但新生代和老年代不再固定,他们都是一系列区域的动态集合,G1收集器能够建立可预测的停顿时间模型,是因为他将Region作为单次回收的最小单元,即每次回收到的内存空间都是Region大小的整数倍,这样可以避免在整个java堆中进行全区域的垃圾收集。
G1会去跟踪各个region里面的垃圾堆积的价值大小,价值即回收所获得的空间大小及回收需要的时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定的收集停顿时间来优先处理回收价值收益最大的那些region
在决定进行回收的时候,g1会对region按照回收价值和成本排序,根据用户期望的停顿时间来指定回收计划,可以自由选择任意多个region组成回收集,然后把决定回收的那一部分region的存活对象复制到空的.region中,再清理掉整个旧的region的全部空间。
本文涉及的垃圾回收算法主要参考深入理解JVM第三版和垃圾回收算法设计与实现一书。
本文只涉及到垃圾回收中最经典,理解难度偏易的处理方法,更多复杂算法可以参考相关经典书籍和论文。