文章目录
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 传参相对原书是有所修改。
优点
- 可以立即回收垃圾 (
decreaseObjectRef
中实现的) - 最大暂停时间短(可以认为几乎没有,因为该算法的 GC 相关都在对象的操作中完成了)
- 没有必要沿指针查找 (没必要由根沿指针查找。当我们想减少沿指 针查找的次数时,它就派上用场了)
缺点
- 计数器的操作繁重(尤其是从根出发的引用)
- 计数器占位多
- 实现繁琐复杂
- 循环引用无法回收
下面同样是重点针对缺点做分析
计数器操作繁重
通过代码可以看到,每次指针变化要对应两次计数器的操作,那么当指针变化频繁时,计数器部分的消耗就无法忽视。尤其是从根(调用栈、寄存器)引用的指针变化会非常频繁。
改进措施有
- 延迟引用计数法
计数器占位多
这个是存储的问题,严格讲,计数器最大可以到 2 的(机器位数)次方(理论上一个对象可以被引用这么多次数),那么内存浪费就太大了。
改进措施有
- Sticky 引用计数法
- 1 位引用计数法
实现繁琐复杂
因为那个 UpdatePointer
是要在应用程序内调用的,那么就不能按照语言自身的变更去写 ptr=&obj
, 这样是很麻烦的。
循环引用无法回收
假设 A 引用 B 的同时,B 也引用了 A,那么 A 和 B 的引用计数都会是 1,那么在该算法下就无法被回收了
改进措施有
- 部分 MarkSweep 算法(4 色算法)
算法改进
- 延迟引用计数法 (改善计数繁重)
- Sticky 引用计数法(改善计数占位)
- 1 位引用计数法(改善计数占位)
- 部分 MarkSweep 算法(解决不能回收循环垃圾的问题)
延迟引用计数法
原生算法的计数繁重,尤其是是来自根的引用变化引发的部分,而延迟引用计数法的主要目的就是解决这部分,但是也同时引入了新的计数问题导致与原生算法不能兼容,最后又通过引入新的数据结构 ZCT
以及配套的措施来解决这个问题。所以,该算法可以被分解成两个部分
- 根引用变化不使用引用计数法,而是直接修改
- 引入的 ZCT ,以及针对 ZCT 部分的逻辑
ZCT
ZCT
就是 zero count table
,该表的功能是用来记录下在 decreaseObjectRef
(也就是减少引用)时候,计数器变为 0 的对象。当没有空余空间时,则通过扫描 ZCT
判断是否被根引用,如果被引用则跳过,没有引用则进行回收,从而释放空间。
zct
的操作方法有 push
和 scan_zct
,push
的作用就是将对象放入 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--
}
}
优点
- 延迟了根引用的技术,减轻了负担
缺点
- 因为延迟,也让垃圾可以立即回收不再可能
scan_zct
因为是遍历,会增加最大暂停时间。zct
越大,暂停时间越长,但是如果zct
越小,scan_zct
的调用次数就会增加
个人感觉实在是很纠结的算法实现
Sticky 引用计数法
Sticky 方法是改善计数器占用空间过多的问题,解决方法很暴力,就是直接减少计数器的位数,那么潜在的风险就是计数溢出。
一般情况下对象的引用次数也不会太多,所以适当缩减位数不会造成什么影响。但是还是要解决溢出的风险
处理方法有两种
- 啥都不做
- 使用 MarkSweep 进行管理
啥都不做
该方法其实就是考虑现实情况中两个因素
- 对象引用次数不会太多
- 如果太多,那么一般也不会是垃圾,不需要回收
所以,当计数器真的溢出的时候,就不再增加值,对象也就放那不管了,成为垃圾的可能性也很低。所以尽管会有浪费空间的风险,但是这个风险也是很小的。所以,啥都不做也是一个不错的处理方法
使用 MarkSweep
mark 阶段先将所有引用数清零,然后从根出发,检索对象,并确保对象只被检索一次(检查到了,就增加引用数,以后就通过检查该值来确定是否被检索过)。sweep 阶段扫描整堆,清理计数为 0 的对象
优点就是能解决垃圾循环引用的问题。但是缺点也很明显,重置、扫描、清理需要多次查找活动对象,那么就会消耗更多时间,吞吐量就会缩小
1 位引用计数法(标签法)
该算法算是 Sticky 算法的极端情况,计数位只使用 1 位,可取的值,只有 0 和 1,所以也可以成为标签法(已经不是计数的意义)另外,在该算法中该标签位不是被对象持有,而是放在对 object 的指针里面。
- 0 代表只有一个引用
- 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中,使用下面四种颜色代表不同状态:
- 黑 (BLACK) ,绝对不是垃圾的对象(对象产生时的初始颜色)
- 白 (WHITE) ,绝对是垃圾的对象
- 灰 (GRAY) ,搜索完毕的对象
- 阴影 (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) // 继续递归调用
}
}
}
缺点
- 队列搜索成本高,队列内存储的都是候选垃圾,而不是一定就是垃圾,所以搜索的对象基数会很多
- 再就是总共有 3 次搜索,mark_gray,scan_gray,collect_white 。 增加内存管理时间。
- 引用计数法的最大暂停时间短这个优点,在该算法中由于搜索而变得不存在了