Golang 中 sync.Pool 在 GC 触发时的对象清理策略与 Victim 缓存
Go的sync.Pool在GC时并非全量清空,而是分两轮清理:第一轮将对象移至victim缓存,第二轮才真正丢弃。对象最多存活到“下下一次GC”。victim为只读缓存,只会在常规获取路径后被动查询,以缓解GC突刺性回收,但使用者必须手动重置对象状态。
很多人都以为 sync.Pool 在 GC 时会一股脑儿把所有对象都清空,其实不然。它的清理分两轮:第一轮 GC 把对象从本地池挪到 victim 缓存里“缓刑”一轮,第二轮 GC 才真正丢弃。换句话说,你放进池子的对象最多能活到“下下一次 GC”之前,但具体是哪一次被回收,你没法提前知道。

GC 清理不是全量清空,而是分阶段迁移
每次 GC 触发时,runtime 都会调用 poolCleanup,但它并不是直接释放所有对象——它会做以下几件事:
- 每个 P 的本地私有池(
private)和共享队列(shared)被清空,里面的对象失去引用,等待 GC 回收。 - 当前全局池(
p.local)被整体“降级”为 victim 缓存(p.victim),而新分配的p.local则初始化为空。 - 原来的 victim 缓存(如果存在)会被彻底丢弃——这就是“两轮 GC 才清空”的来源。
举个例子:你在第 N 次 GC 前 Put 进去的对象,可能在第 N+1 次 GC 后还留在 victim 里,直到第 N+2 次 GC 才消失。但只要你的 goroutine 在第 N+1 次 GC 后立刻调用 Get(),它仍有可能从 victim 里捞出那个对象——这就给了对象一次“回光返照”的机会。
Victim 缓存不是“备用池”,而是 GC 协同机制
victim 不对外暴露,也不参与常规 Get() 的 fast path。它的唯一价值在于:缓解 GC 突刺性回收带来的复用率骤降。它是怎么做的呢?
Get()走完本地 private → local shared → 其他 P 的 shared 之后,才会去查victim。victim是只读的:不接受Put(),也不会被任何 goroutine 主动填充。- 它的存在让对象多了一次“苟延残喘”的机会,但代价是延长了内存驻留时间。这一点对大对象或带指针字段的结构体尤其敏感——可能会拖慢整体 GC 效率。
假设你 Put 了一个含 *bytes.Buffer 字段的结构体,没有重置指针,它进入 victim 后仍然持有对底层字节数组的引用,那块内存就无法被归还给 OS。这是典型的“好心办坏事”。
为什么不能靠 Victim 缓存做状态兜底
很多人会误以为“victim 里的对象更新,字段应该更干净”,这是一个危险的错觉。
- victim 里的对象和原池里的对象一样,字段值完全随机——它只是没被 GC 扫描掉,不代表被自动重置了。
New函数绝不会在 victim 命中时触发,所以你拿不到兜底初始化逻辑。- 哪怕对象刚从 victim 返回,你也必须像对待任何
Get()结果一样,手动调用Reset()或清零关键字段。
一个经典反例:buf := pool.Get().(*bytes.Buffer); buf.WriteString("hello")——如果没调用 buf.Reset() 就直接 Put(),下次从 victim 拿出来的 buf 里可能还残留 "hello\000\000...",导致解析错误甚至 panic。真正关键的从来不是 victim 存多久,而是你是否在每次 Get() 后、使用之前就重置状态——GC 和 victim 都不关心你的业务逻辑,它们只管内存能不能回收。


































