从C和C++内存管理来谈谈JVM的垃圾回收算法设计-下


引言

上一篇文章和大家介绍了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第三版:
在这里插入图片描述
进行品读。


GC ROOTS

垃圾回收的执行通常是在方法执行的某个时间点发生的那么该时间点哪些对象是一定不会被回收的呢

  • 全局性引用如常量引用或者类静态属性
  • 局部性引用如当前执行方法中局部变量引用的对象,这些对象存放与当前活动栈帧的局部变量表和操作数栈中

这些对象一定不会被回收所以这些对象构成的集合被称为根对象集合(ROOTS集合),因为垃圾回收器需要遍历这些对象的引用链找出当前所有的活动对象因此该集合也被称为GC ROOTS。

在这里插入图片描述
存在于GC ROOTS集合中某个对象引用链上的对象被称为此刻的活动对象,而无法通过引用链找到的对象就被称为此刻的非活动对象,也就是垃圾对象。


垃圾回收常见算法

标记清除

标记清除算法分为两部分:

  1. 标记: 通过遍历GC ROOTS引用链,标记出那些存活对象
  2. 清除: 将垃圾对象占用空间加入空闲链表

在这里插入图片描述

  1. 给每个对象头中添加一个存活标记位,遍历GC ROOTS引用链,给引用链上每个对象设置存活标记位为1
  2. 清除阶段,遍历堆中所有对象,如果对象存活标记位为1,那么设置为0然后继续遍历,否则将对象添加进空闲链表
  3. 当分配新对象时,遍历空闲链表,优先分配垃圾对象占用的空间
  4. 可以考虑在某个时机将内存上连续的垃圾对象占用的空间进行合并

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

在这里插入图片描述
假设此刻我们要分配20字节大小的空闲块我们采用最佳适配原则遍历一遍空闲链表找到最接近当前要分配大小的空闲块但是在空闲链表非常长的情况下这样的遍历会导致很高的性能损耗。

此时我们可以借鉴伙伴算法的思想采用数组+链表的形式:

在这里插入图片描述
当我们需要分配大于4小于32大小的空闲块时直接通过索引1定位到第二条空闲链表然后遍历该链表看能否得到合适大小的空闲块。

如果发现空闲链表中两个相邻块地址相邻可以合并这两个空闲块。

当分配时,没有合适小块只能分配一个大块时需要将大块划分为两个小块。


优缺点

优点:

  • 实现简单
  • 垃圾回收过程不移动对象

缺点:

  • 碎片化
  • 分配需要遍历空闲链表耗时长

引用计数

每个对象头中腾出一块空间用于存放计数器:0
在这里插入图片描述

  • 创建新对象实例时,将对象的计数器设置为1
  • 改变引用关系时,将引用前对象的计数器减去1,如果此时为0,那么将对象加入空闲链表(递归处理该对象的引用的子对象)被引用的对象计数器加一

优缺点

优点:

  • 垃圾可以在改变引用关系时就被检测出来,然后进行回收
  • 当分配新对象时,直接查询空闲链表如果其中没有空闲空间,那么不需要专门进行一波垃圾回收,而是直接抛出堆内存不足的异常节省了遍历GC ROOTS的时间

缺点:

  • 计数器增减过程繁多改变一次引用都需要增减计数器,包括递归回收操作
  • 计数器占据空间大如果给计数器分配较小的位数可能导致计数器溢出
  • 循环引用无法回收

部分标记清除算法

引用计数法最大的麻烦处在于循环引用垃圾无法被回收为了解决这个问题我们可以使用引用计数配合标记清除来完成具体思路如下:

针对可能存在循环引用的对象群使用标记清除算法,而对其他对象使用引用计数算法。

此时标记清除算法就是用来查找垃圾对象的了那么如何实现呢?

核心思路如下:
在这里插入图片描述

  • 当删除根对象到某个外部对象的引用时如果此时该对象的计数器不为0 ,将对应的外部对象加入一个待选队列中该队列中存放的都是有可能产生循环引用的垃圾对象如果计数器为0那么加入空闲链表。
    在这里插入图片描述
  • 当分配对象先尝试从空闲链表分配如果空闲链表没有剩余空间那么遍历候选队列如果此时队列为空说明堆内存已经满了抛出异常
  • 遍历候选队列取出每个对象将当前对象的引用的子对象的引用计数减一然后进行递归处理并做好标记避免重复处理已经处理过的对象。
    在这里插入图片描述
  • 重新从A对象开始搜索将计数器为0的垃圾对象进行回收

在这里插入图片描述


优缺点

部分标记清除能够很好解决循环引用垃圾的回收问题但是从队列搜索对象的代价还是很大的毕竟队列中记录的都是候选垃圾所以要搜索的对象不在少数。

搜索对象也会导致垃圾回收的最大暂停时间变大这抵消了引用计数的优点。


复制算法

最简单的复制算法思想如下:

  • 将堆空间一分为二一半为From空间,用于存放所有对象,一半To空间用于临时存放存活对象
  • 遍历GC ROOTS引用链,将所有存活对象都复制到To空间保存

在这里插入图片描述

  • 清空From空间然后交换From和To空间指针交换后的的From空间中剩余的就是紧凑存放的存活对象了

在这里插入图片描述


优缺点

优点:

  • 分配对象时无需遍历空闲链表因为空闲空间是一块连续的内存空间
  • 不存在碎片化问题
  • 有引用关系的对象复制过后被排列在了一起可以提高高速缓存利用率

缺点:

  • 堆空间利用率低
  • 需要移动对象
  • 复制对象时需要递归复制子对象,这个过程耗时长

多空间复制算法

简单的复制算法实现只能利用半个堆如何优化呢

例如: 把堆分成10份其中2份作为From和To空间来执行复制算法剩下8份执行标记清除算法。

这里举个例子演示一下多空间复制算法的工作流程:
在这里插入图片描述
通过TO和FROM指针不断前移,将因标记清除算法产生的碎片空间进行整理而原先整理好的空间又可能因为应用了标记清除算法而产生新的垃圾就这样不断重复。


标记整理(标记压缩)

大致思路如下:

  • 给每个对象的对象头中增加两个属性: mark 当前对象在GC后是否存活 forwad: 当前对象移动到哪个位置

在这里插入图片描述

  • 遍历GC ROOTS设置存活对象的mark标记位为true

在这里插入图片描述

  • 遍历堆中所有对象设置存活对象的forward指针伪算法如下:
//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算法实现:

在这里插入图片描述

  • 考虑到新生代每次GC过后存活对象较少又需要频繁的进行GC因此适合采用标记复制算法不适合采用标记清除是考虑到频繁GC会导致大量内存碎片产生。
  • 传统的标记复制算法会导致一半的堆内存大小被浪费这显然不太能够接受但是结合新生代这个场景来看每次存活的对象都很少因此我们可以只划分出一小块空间来存放存活对象这里直接给出深入理解JVM虚拟机第三版中的分代模型图:

在这里插入图片描述
新生代空间被划分为了生成空间,From空间和To空间三部分每次new创建的对象保存在生成空间经历了一次GC后存活的对象全部复制到To空间保存然后交换From和To的指针。

如果From幸存空间大小不足以存放当前幸存对象那么需要通过担保机制直接将相关对象移动到老年代保存。

幸存空间通常只占有整个新生代大小10%左右,又因为只浪费其中一半的幸存空间所以实际只浪费了5%的空间。

  • 老年代每次GC后会存活较多的对象因此更适合采用标记清除算法但是一旦标记清除算法导致内存碎片产生过多无法分配一个合适大小的块来存放新的对象了此时需要采用标记整理算法去老年代内存空间进行整理。

分代设计中关于内存分配的常见基本策略如下:

  • 对象优先分配在EdenEden空间不足引发minor gc
  • 对象大小超过指定阈值时直接分配在老年代避免在Eden和Survivor区域中进行复制
  • 长期存活的对象进入老年代
  • 幸存区空间不足以存放存活对象时需要通过担保机制把幸存区无法容纳的对象直接送入老年代之所以叫做担保是因为需要确认老年代有足够的剩余空间来存放这些存活对象如果剩余空间不够会引发一次FULL GC。

HotSpot具体实现

  1. 根节点枚举: GC ROOTS枚举过程需要暂停用户线程,因此为了加快枚举过程HotSpot采用了OopMap数据结构在安全点生成对应的OopMap数据结构该数据结构记录了当前线程的GC ROOTS集合。
  2. 程序不是在执行的任何时刻都可以直接暂停进行垃圾回收的而必须是执行到某个安全点处才可以暂停进行垃圾回收安全点设置要合理不能太多也不能太少通常在方法调用,循环跳转和异常跳转等指令处会加上一个安全点指令。
  3. 线程执行到安全点指令时会检查对应的垃圾收集标志是否被设置了如果被设置了说明此刻垃圾收集线程已经发出了GC命令正在等待所有用户线程主动暂停自己那么当前用户线程就中断挂起。
  4. 如果垃圾回收线程发出GC命令时存在某些用户线程处于Sleep或者Block状态(也被称为处于安全区域)那么当这些用户线程被唤醒时不能直接运行用户程序需要首先检查相关GC阻塞标志是否被撤销如果还没撤销说明当前垃圾回收阶段还处于STOP THE WORLD的阶段那么当前用户线程中断挂起。

跨代引用

如果我们只是针对新生代进行垃圾回收,那么光光枚举GC ROOTS还不够因为可能存在老年代中某个对象引用新生代中对象的事情发生因此我们需要使用一种全局数据结构来指明当前老年代中存在哪些跨代引用这种数据结构被称为记忆集。

在这里插入图片描述
通过一个对象指针数组记录全部含跨代引用对象的实现方案记忆集数组的大小会随着跨代引用增加而变大那么能不能进行优化呢?

我们可以把老年代划分为固定大小若干相等空间这里将每个空间看做一个卡牌然后使用一个位数组与每个卡片进行映射如果该卡片空间内部存在跨代引用那么设置对应位数组索引值为1即可:
在这里插入图片描述
此时我们只需要用一个固定大小的字节数组就可以通过模糊的形式告诉垃圾回收器哪块卡片内部存在跨代引用那么由垃圾回收器遍历该卡片内部的对象找到存在跨代引用的对象并加入GC ROOTS集合即可。

该实现方式在HotSpot中也被称为卡表。


为了在老年代对象产生跨代引用的时候将其加入记忆集中我们需要拦截所有引用类型字段赋值的过程类似AOP会在执行完赋值指令后判断是否需要更新记忆集该拦截过程被称为写屏障。

判断条件如下:
在这里插入图片描述


并发可行性

GC ROOTS枚举可以借助OopMap来加速完成但是从GC ROOTS集合沿着引用链往下遍历的耗时则会根据堆内存活对象大小逐级递增。

能否加速遍历引用链的过程或者说减少遍历引用链过程对用户线程的阻塞影响呢

  • 并发执行

让遍历引用链的过程和用户线程并发执行并发执行是可以但是这个过程中会存在一些问题:

  • 用户线程通过改变引用关系将存活对象变为了垃圾对象但是因为之前已经标记过了因此垃圾回收器不会将其视为垃圾对象
  • 用户线程通过改变引用关系创建新对象新对象被当做垃圾对象回收掉

将原本存活的对象变为垃圾对象可以容忍但是将存活对象视为垃圾对象回收是不可容忍的因此我们必须解决后者。

为了解决这个问题我们可以引入三色标记法把遍历对象图过程中遇到的对象按照是否访问过这个条件标记为三种不同的颜色:

  • 白色: 没有被垃圾回收器访问过代表不可达
  • 黑色: 已经被垃圾回收器访问过,并且该对象直接引用的子对象也都被访问过了因此垃圾回收器在此次垃圾回收过程中不会再访问该对象
  • 灰色: 已经被垃圾回收器访问过但是该对象直接引用的子对象至少存在一个还没有被扫描过下次再遇到该对象时垃圾回收器必须再次访问其子对象直到确认全部扫描完毕将其标记为黑色

这里我们主要来看一下将活动对象视为垃圾对象的操作:
在这里插入图片描述
用户程序和垃圾回收线程并发执行过程中用户程序将B–>C变为了A–>C,但是由于A是黑色对象,那么A是不会再被访问的那么意味着存活对象C将一直处于白色状态最终会被垃圾回收器回收。

如果要导致存活对象被错误回收需要满足两个条件:

  • 用户程序插入了一条或者多条黑色对象到白色对象的新引用
  • 用户程序同时删除了灰色对象到该白色对象的直接或者间接引用

要解决上面这个问题我们只需要让其中任意一个条件不满足即可有两种方式:

  • 增量更新(破坏第一个条件): 当黑色对象插入了新的指向白色对象的引用时通过写屏障将该插入的引用记录下来在重新标记阶段再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以认为: 当黑色对象插入了指向白色对象的引用后,它就变回了灰色对象。
  • 原始快照(破坏第二个条件): 当灰色对象要删除指向白色对象的引用关系时,利用写屏障将这个要删除的引用记录下来在重新标记阶段再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。可以认为: 无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照进行搜索。 (但是需要找额外的地方存放新创建的对象)

经典垃圾回收器

这里简单回顾一下深入理解JVM 虚拟机第三版书中列举的一些垃圾回收器。

Serial新生代垃圾回收器

serial重点是单线程,需要STOP THE WORLD。
在这里插入图片描述

  • 单线程占用资源少实现简单但是GC过程需要暂停用户线程。

ParNew新生代垃圾回收器

在这里插入图片描述

  • 采用多线程来回收新生代垃圾但是回收过程仍然需要STOP THE WORLD并且只有在多核或者超线程情况下才有可能超越单线程工作效率。

Parallel Scavenge新生代垃圾回收器

Parallel Scavenge和ParNew一样也是采用了标记复制算法并且也是采用多线程收集模式。

Parallel Scavenge优于ParNew的地方在于它可以通过参数调整来争取让程序吞吐量达到最高:
在这里插入图片描述
提供的相关参数主要控制垃圾回收花费时间不超过用户设置的值或者垃圾回收时间占总时间比例不超过指定值。

并且相关参数还可以在运行中自适应调整。


Serial Old老年代垃圾回收器

在这里插入图片描述

  • 单线程在目前多核环境下很难比的过那些多线程收集的垃圾回收器并且老年代一般优先选用标记清除算法然后将标记整理算法作为逃生门在内存碎片过多的情况下进行一波整理。
  • 因此Serial Old垃圾收集器一般作为CMS收集器的逃生门方案。

Parallel Old老年代垃圾回收器

在这里插入图片描述

  • 采用多线程标记整理算法实现对老年代的收集
  • 吞吐量优先

CMS老年代垃圾回收器

注重实现最短回收停顿时间适合注重响应时间的应用程序如: Web程序。

CMS采用并发的标记清除算法实现实现步骤如下:

  1. 初始标记: 需要暂停用户线程,利用OopMap快速得到GC ROOTS集合。
  2. 并发标记: 和用户线程一起运行遍历GC ROOTS引用链标记所有存活对象同时写屏障记录下其中引用变动
  3. 重新标记: 暂停用户线程,重新遍历引用发生变动的对象集合
  4. 并发清除: 和用户线程一起运行清除掉已经确认的垃圾对象
    在这里插入图片描述
    垃圾回收线程能够和用户线程一起并发运行未必能够带来更好的效率反而对于某些核数较少的机器而言会因为频繁的上下文切换降低运行效率。

并发标记和并发清理阶段用户线程会不断产生新的垃圾对象这些垃圾被称为浮动垃圾CMS垃圾回收器无法在本次垃圾回收阶段回收掉这批浮动垃圾因此并发回收过程中需要预留一部分空间用作存放用户线程新对象的创建。

也就是说如果老年代堆内存占用到达指定阈值那么需要对老年代进行一次GC从jdk 6起该值被设置为92%如果CMS运行期间预留的内存无法满足新对象的分配会触发"并发失败"此时需要采用逃生门设计: 暂停用户线程使用Serial old来重新进行老年代的收集。

如果是因为内存碎片过多导致无法找到足够大的内存空间来分配新对象那么会触发一次FULL GC默认会在进行FULL GC前先对老年代进行一次碎片整理。


G1垃圾回收器

将堆划分为内存大小相同的连续区域(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的回收优先级的呢?

  • 简单来说是通过记录每个Region的回收耗时每个Region记忆集中的脏卡数量等各个可测量步骤的花费成本最终计算出一个Region的回收优先级。这部分说的过于简化感兴趣大家可以自行研究一下

G1工作流程:
在这里插入图片描述

  • 初始标记: 标记GC ROOTS直接关联的对象
  • 并发标记: 遍历GC ROOTS引用链标记所有可达对象利用写屏障记录下所有发生引用变动的对象到STAB队列中
  • 最终标记: 处理STAB队列中发送引用变动的对象
  • 筛选回收: 更新Region统计数据对各个Region按照回收价值排序根据用户所期望的停顿时间制定回收计划然后选择任意多个Region构成回收集然后把决定回收的那一部分Region的存活对象复制到空的Region中再清理掉整个旧的Region全部空间。

G1小结:

G1可以面向堆内存任何部分来组成回收集进行回收衡量标准不再是它属于哪个分代而是哪块内存中存放的垃圾数量最多回收效益最大这就是G1收集器的Mixed GC模式

G1虽然仍然保留新生代和老年代的概念,但新生代和老年代不再固定他们都是一系列区域的动态集合G1收集器能够建立可预测的停顿时间模型是因为他将Region作为单次回收的最小单元即每次回收到的内存空间都是Region大小的整数倍这样可以避免在整个java堆中进行全区域的垃圾收集。

G1会去跟踪各个region里面的垃圾堆积的价值大小价值即回收所获得的空间大小及回收需要的时间的经验值然后在后台维护一个优先级列表每次根据用户设定的收集停顿时间来优先处理回收价值收益最大的那些region

在决定进行回收的时候g1会对region按照回收价值和成本排序根据用户期望的停顿时间来指定回收计划可以自由选择任意多个region组成回收集然后把决定回收的那一部分region的存活对象复制到空的.region中再清理掉整个旧的region的全部空间。

G1垃圾收集器


小结

本文涉及的垃圾回收算法主要参考深入理解JVM第三版和垃圾回收算法设计与实现一书。

本文只涉及到垃圾回收中最经典理解难度偏易的处理方法更多复杂算法可以参考相关经典书籍和论文。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++