(阅读笔记)垃圾回收的算法与实现 - Reference Counting

本文深入探讨了引用计数法的基础实现及其优缺点,包括计数器操作繁重、占位多、实现复杂和循环引用无法回收等问题。接着介绍了几种改进算法,如延迟引用计数法、Sticky 引用计数法、1 位引用计数法和部分 MarkSweep 算法,分析了它们如何解决原算法的不足。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


MarkSweep 的逻辑是判断对象是否存在引用,而 引用计数法 则在这个逻辑上更进了一步,引入 计数器的概念来统计对象当前的被引用次数。如果次数为 0 了,那么也就是非活动对象,可以被回收,否则就是活动对象。

基础实现

引用计数法实现的核心是针对计数器本身的增减,并且当减至为 0 的时候,执行回收操作,所以在该算法中并没有如 MarkSweep 中那么明确的 GC 执行开始时间。

代码

计数器修改的时机有两个,分别是对象创建,以及指针更新的时候


// 对象定义
type object struct {
	ReferenceCount int
	Filed          interface{}
	Next           *object
	Size           int
}

// 新建对象
func NewObject() *object {
	return &object{
		ReferenceCount: 1,
	}
}

// 指针更新(引用更新)注意增减顺序
func UpdatePointer(ptr **object, o *object) {
	increaseObjectRef(o)
	decreaseObjectRef(ptr)
	*ptr = o
}

func increaseObjectRef(o *object) {
	o.ReferenceCount++
}

// 减少引用过程伴随对象回收的操作
func decreaseObjectRef(o *object) {
	o.ReferenceCount--
	if o.ReferenceCount == 0 {
		decreaseObjectRef(o.Next)
		reclaim(o)
	}
}

注意: UpdatePointer 增减计数器的顺序,是先增加被引用对象的计数以后,再减少要变更引用的对象的计数的, 考虑特殊情况,假如两者是相同对象,则会导致出错。因为 decreaseObjectRef 中有对象回收的操作
注意:golang 函数是值传递,所以这里的 UpdatePointer 传参相对原书是有所修改。

优点

  1. 可以立即回收垃圾 (decreaseObjectRef 中实现的)
  2. 最大暂停时间短(可以认为几乎没有,因为该算法的 GC 相关都在对象的操作中完成了)
  3. 没有必要沿指针查找 (没必要由根沿指针查找。当我们想减少沿指 针查找的次数时,它就派上用场了)

缺点

  1. 计数器的操作繁重(尤其是从根出发的引用)
  2. 计数器占位多
  3. 实现繁琐复杂
  4. 循环引用无法回收

下面同样是重点针对缺点做分析

计数器操作繁重

通过代码可以看到,每次指针变化要对应两次计数器的操作,那么当指针变化频繁时,计数器部分的消耗就无法忽视。尤其是从根(调用栈、寄存器)引用的指针变化会非常频繁。

改进措施有

  1. 延迟引用计数法
计数器占位多

这个是存储的问题,严格讲,计数器最大可以到 2 的(机器位数)次方(理论上一个对象可以被引用这么多次数),那么内存浪费就太大了。

改进措施有

  1. Sticky 引用计数法
  2. 1 位引用计数法
实现繁琐复杂

因为那个 UpdatePointer 是要在应用程序内调用的,那么就不能按照语言自身的变更去写 ptr=&obj, 这样是很麻烦的。

循环引用无法回收

假设 A 引用 B 的同时,B 也引用了 A,那么 A 和 B 的引用计数都会是 1,那么在该算法下就无法被回收了

改进措施有

  1. 部分 MarkSweep 算法(4 色算法)

算法改进

  1. 延迟引用计数法 (改善计数繁重)
  2. Sticky 引用计数法(改善计数占位)
  3. 1 位引用计数法(改善计数占位)
  4. 部分 MarkSweep 算法(解决不能回收循环垃圾的问题)

延迟引用计数法

原生算法的计数繁重,尤其是是来自根的引用变化引发的部分,而延迟引用计数法的主要目的就是解决这部分,但是也同时引入了新的计数问题导致与原生算法不能兼容,最后又通过引入新的数据结构 ZCT 以及配套的措施来解决这个问题。所以,该算法可以被分解成两个部分

  1. 根引用变化不使用引用计数法,而是直接修改
  2. 引入的 ZCT ,以及针对 ZCT 部分的逻辑
ZCT

ZCT 就是 zero count table,该表的功能是用来记录下在 decreaseObjectRef (也就是减少引用)时候,计数器变为 0 的对象。当没有空余空间时,则通过扫描 ZCT 判断是否被根引用,如果被引用则跳过,没有引用则进行回收,从而释放空间。

zct 的操作方法有 pushscan_zctpush 的作用就是将对象放入 zct 进行管理,下面是 scan_zct 的代码

func ScanZCT() {
	for _,o := range roots{
		o.ReferenceCount++
	}

	for i,o := range zct {
		if o.ReferenceCount == 0 {
			zct[i]=nil
			delete(o)
		}
	}

	for _,o := range roots{
		o.ReferenceCount--
	}
}
优点
  1. 延迟了根引用的技术,减轻了负担
缺点
  1. 因为延迟,也让垃圾可以立即回收不再可能
  2. scan_zct 因为是遍历,会增加最大暂停时间。zct 越大,暂停时间越长,但是如果 zct 越小, scan_zct 的调用次数就会增加

个人感觉实在是很纠结的算法实现

Sticky 引用计数法

Sticky 方法是改善计数器占用空间过多的问题,解决方法很暴力,就是直接减少计数器的位数,那么潜在的风险就是计数溢出。

一般情况下对象的引用次数也不会太多,所以适当缩减位数不会造成什么影响。但是还是要解决溢出的风险

处理方法有两种

  1. 啥都不做
  2. 使用 MarkSweep 进行管理
啥都不做

该方法其实就是考虑现实情况中两个因素

  1. 对象引用次数不会太多
  2. 如果太多,那么一般也不会是垃圾,不需要回收

所以,当计数器真的溢出的时候,就不再增加值,对象也就放那不管了,成为垃圾的可能性也很低。所以尽管会有浪费空间的风险,但是这个风险也是很小的。所以,啥都不做也是一个不错的处理方法

使用 MarkSweep

mark 阶段先将所有引用数清零,然后从根出发,检索对象,并确保对象只被检索一次(检查到了,就增加引用数,以后就通过检查该值来确定是否被检索过)。sweep 阶段扫描整堆,清理计数为 0 的对象

优点就是能解决垃圾循环引用的问题。但是缺点也很明显,重置、扫描、清理需要多次查找活动对象,那么就会消耗更多时间,吞吐量就会缩小

1 位引用计数法(标签法)

该算法算是 Sticky 算法的极端情况,计数位只使用 1 位,可取的值,只有 0 和 1,所以也可以成为标签法(已经不是计数的意义)另外,在该算法中该标签位不是被对象持有,而是放在对 object 的指针里面

  1. 0 代表只有一个引用
  2. 1 代表有多个引用

这里有个很有意思的细节问题,既然放在指针里,放在指针哪里呢??

因为通常指针是 4 字节对齐,那么低 2 位其实是无用的状态。那么用其中 1 位做标签就好了

关于更新指针书上原文

基本上,1 位引用计数法也是在更新指针的时候进行内存管理的。不过它不像以往那样,指定要引用的对象来更新指针,而是通过复制某个指针来更新指针

我不是很确定我正确理解了上文的说法。至少在 copyPointer 中确实是复制了某个指针而不是指定要引用的指针,我的 copyPointer 实现并不完整,没有写标签相关操作。这里重点想理解那个 复制指针的含义

type c struct {
	Value int
}

type o struct {
	Value *c
}

func updatePointer(src **c, dest *c) {
	*src = dest
}

func copyPointer(src **c, dest **c) {
	*src = *dest
}

func main() {
	c1 := c{Value: 1}
	o1 := o{Value: &c1}

	c2 := c{Value: 2}
	o2 := o{Value: &c2}

	println(fmt.Sprintf("origin %p %p", &o1.Value, &o2.Value))

	updatePointer(&o1.Value, o2.Value)
	println(fmt.Sprintf("updatePointer %p %p", &o1.Value, &o2.Value))

	copyPointer(&o1.Value, &o2.Value)
	println(fmt.Sprintf("copyPointer %p %p", o1.Value, o2.Value))
}

output

origin 0xc00000e018 0xc00000e020
updatePointer 0xc00000e018 0xc00000e020
copyPointer 0xc000018098 0xc000018098
优点

因为我对该算法的理解有问题,所以我粘一下原文

1 位引用计数法的优点,是不容易出现高速缓存缺失。
缓存作为一块存储空间,比内存的读取速度要快得多。如果要读取的数据就在缓存里的话,计算机就能进行高速处理;但如果需要的数据不在缓存里(即高速缓存缺失)的话,就需要读取内存,从内存中查找数据并将其读取到缓存里,这样一来就会浪费许多时间。
也就是说,当某个对象 A 要引用在内存中离它很远的对象 B 时,以往的引用计数法会在增减计数器值的时候读取 B,从而导致高速缓存缺失,白白浪费大把时间。
1 位引用计数法就不会这样,它不需要在更新计数器(或者说是标签)的时候读取要引用的对象。各位应该能看明白吧,在图 3.8 中完全没读取 C 和 D,指针的复制过程就完成了。
此外,因为没必要给计数器留出多余的空间,所以节省了内存消耗量。这也不失为 1 位 引用计数法的一个优点。

缺点

与 Sticky 面临同样的溢出风险

部分 MarkSweep 算法( 4 色算法)

引用计数法是一个很不错的 GC 机制,不过存在循环引用的问题, 尽管 MarkSweep 可以解决这个问题,但是 MarkSweep 做的是整堆的扫描,代价很高,也就是说如果只是为了解决循环引用,则有些得不偿失。所以,如果可以仅针对 可能有循环引用的对象群 执行 MarkSweep ,就能兼顾两者的优点。这种方法就是 部分MarkSweep算法

在部分MarkSweep中,使用下面四种颜色代表不同状态:

  1. 黑 (BLACK) ,绝对不是垃圾的对象(对象产生时的初始颜色)
  2. 白 (WHITE) ,绝对是垃圾的对象
  3. 灰 (GRAY) ,搜索完毕的对象
  4. 阴影 (HATCH) ,可能是循环垃圾的对象

一个垃圾对象的颜色转换过程如下
BLACK -> HATCH -> GRAY -> WHITE -> BLACK

大致实现的代码如下

const (
	Black = iota
	Hatch
	Gray
	White
)

type obj struct {
	RefCount int
	Color    int
	Field    interface{}
	Children []*obj
}

// 引入了队列,存放经过一次减少引用但是仍旧存在的对象
var hatchQueue = make(chan *obj, 1E6)

// 减少引用
func decRefCount(o *obj) {
	o.RefCount--
	if o.RefCount == 0 {
		delete(o)
	} else if o.Color != Hatch {
		o.Color = Hatch
		hatchQueue <- o   // 入列
	}
}

// 获取新对象
func newObj(size int) (*obj, error) {
	obj, err := pickup_chunk(size)
	if err == nil {
		obj.Color = Black  // 初始颜色
		obj.RefCount = 1
		return obj, nil
	} else {
		if len(hatchQueue) > 0 {  // 存在潜在的循环垃圾引用
			scanHatchQueue()
			return newObj(size)
		} else {  // 如果队列为空,也就是没有需要 GC 的对象
			return nil, errors.New("没有可用空间")
		}
	}
}

func scanHatchQueue() {
	if o, ok := <-hatchQueue; ok {
		if o.Color == Hatch {
			paint_gray(o)
			scan_gray(o)
			collect_white(o)
		} else {
			scanHatchQueue()  // 递归调用
		}
	} // 队列为空,则退出
}

func paint_gray(o *obj) {
	if o.Color == Black || o.Color == Hatch {
		o.Color = Gray
		for _, child := range o.Children {
			child.RefCount--  // 只减少子节点引用数量
			paint_gray(child)  // 配合递归调用,就能检测出循环垃圾,也就是引用数都会变为 0 
		}
	}
}

func scan_gray(o *obj) {
	if o.Color == Gray {
		if o.RefCount > 0 {
			paint_black(o)
		} else {
			o.Color = White // 标记
			for _, child := range o.Children {
				scan_gray(child)  // 递归标记垃圾节点
			}
		}
	}
}
func collect_white(o *obj) {
	if o.Color == White {
		o.Color = Black
		for _, child := range o.Children {
			collect_white(child) // 递归回收
		}
		reclaim(o) // 从而释放了空间
	}
}

func paint_black(o *obj) {
	o.Color = Black
	for _, child := range o.Children {
		child.RefCount++ // 考虑前面 paint_gray 的减少子节点引用逻辑,这里就是增加子节点引用。进行归位
		if child.Color != Black {
			paint_black(child)  // 继续递归调用
		}
	}
}
缺点
  1. 队列搜索成本高,队列内存储的都是候选垃圾,而不是一定就是垃圾,所以搜索的对象基数会很多
  2. 再就是总共有 3 次搜索,mark_gray,scan_gray,collect_white 。 增加内存管理时间。
  3. 引用计数法的最大暂停时间短这个优点,在该算法中由于搜索而变得不存在了
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值