runtime.SetFinalizer 不释放内存,反而让对象多活一个 GC 周期:首次 GC 发现不可达时触发终结器并解绑,但因参数持有指针使对象重变可达;第二次 GC 才真正回收。

runtime.SetFinalizer 不释放内存,反而让对象多活一个 GC 周期——它只解绑终结器,不回收对象。
SetFinalizer 为什么让对象“延迟回收”
GC 在第一次扫描到带 SetFinalizer 的不可达对象时,会做两件事:先起一个 goroutine 执行终结器函数,再把该对象从 finalizer 队列中移除、解除绑定。此时对象在终结器函数执行期间重新变为“可达”(因为函数参数持有了它的指针),所以本次 GC 不会真正释放它。只有下一次 GC 再次发现它不可达且无 finalizer 绑定,才会回收内存。
- 这不是 bug,是设计行为:官方文档明确说“it makes obj reachable again, but now without an associated finalizer”
- 对高分配频率的场景(如短生命周期 struct 大量 new),可能堆积大量“半死”对象,推高堆占用
- 用
runtime.ReadMemStats()观察M.FinalizeNum持续不降,就是典型信号
常见误用导致对象永远不回收
最隐蔽的坑是循环引用 + SetFinalizer。只要两个对象互相持有对方指针,哪怕都调用了 SetFinalizer,GC 就永远判定它们“可达”,finalizer 不触发,内存永不释放。
- 例如:
x.Other = &y且y.Other = &x,再对x调用SetFinalizer→x和y都卡住 - 全局变量、闭包捕获、channel 缓冲区里存过一次指针,也等同于“隐式持有”,对象无法进入不可达状态
- 程序 main 函数退出前没触发 GC,或 GC 没跑完就进程终止,
SetFinalizer直接被 runtime 强制丢弃
指针类型不匹配导致静默失效
传给 SetFinalizer 的第一个参数必须是显式的 *T 类型指针,且和终结器函数签名中的参数类型严格一致。否则注册失败,但不 panic、不报错,只是什么也不发生。
- ❌
runtime.SetFinalizer(&MyStruct{}, f):临时结构体取地址,逃逸失败,地址无效 - ❌
runtime.SetFinalizer(obj, func(MyStruct) {}):函数参数是值类型,类型不匹配 - ❌
var i interface{} = obj; runtime.SetFinalizer(i, f):interface{} 底层是两字宽 header,类型失配 - ✅ 正确姿势:
obj := &MyStruct{}; runtime.SetFinalizer(obj, func(*MyStruct) {})
终结器执行时机完全不可控
它不是析构函数,也不是资源清理钩子。是否执行、何时执行、在哪个 goroutine 执行,全部由 runtime 调度决定,且不保证重试。
- 终结器里 panic → 该 finalizer 被永久丢弃,后续 GC 再也不会尝试
- 终结器执行耗时 > 10ms?可能被 runtime 中断,且不通知
- 终结器里调
obj.Close()或C.free(ptr)?一旦用户已手动调过,就是 double-free;没调过,又不能保证它一定跑 —— 两头落空 - 真要兜底,只该做无副作用的事:比如打日志
log.Printf("leaked: %p", obj)或原子计数器减一
最常被忽略的一点:你写的 SetFinalizer 代码,大概率根本没注册成功。验证方式只有一种——断掉所有引用、强制 GC、看日志有没有输出。别信“写了就等于生效”。











