跳至主要内容

Go语言GC详解

垃圾回收(GC)到底在干什么——从零开始讲清楚

这份文档从"为什么需要GC"讲起,一步步搭到"为什么并发GC需要写屏障和短暂STW",尽量把每个术语在第一次出现时就解释清楚。


1. 内存管理要解决的问题

程序运行时会不断申请内存(比如 new 一个对象、append 一个切片)。申请之后,早晚有一天这块内存不再需要了,就应该被收回,否则内存会一直涨,最终把机器耗尽。

回收内存的方式,历史上只有两条路:

  • 手动管理(C/C++ 的 malloc/free):程序员自己决定什么时候释放。问题很直接——忘了释放 → 内存泄漏;释放早了 → 其他地方还在用这块内存,程序直接崩溃或读到脏数据(这类 bug 叫 use-after-free,是很多安全漏洞的根源)。

  • 自动管理(Go、Java、Python、JS……):语言运行时自己判断哪些内存还在用、哪些可以收回。这个"自动判断"的机制,就是 GC(Garbage Collector,垃圾回收器)。

GC 要解决的核心问题只有一个:在程序运行的过程中,怎么知道一块内存现在还有没有人在用?


2. GC 怎么判断"这块内存还有没有人用"

严格来说,"这个对象以后还会不会被用到"这个问题,在数学上是无法精确判定的(跟停机问题是一类问题)。所以所有实用的 GC 都退而求其次,换了一个可以被严格证明、计算代价也可控的替代问题:

不问"这个对象以后有没有用",只问"从程序当前能直接访问到的地方出发,沿着指针能不能走到这个对象"。

能走到,就叫可达(reachable),判定为存活;走不到,就判定为垃圾,可以回收。这个概念叫可达性分析(reachability analysis),是几乎所有追踪式 GC(Go、Java、C#、Python 用的都是这一类)共同的理论基础。

"程序当前能直接访问到的地方"叫根(roots),具体包括:

  • 所有 goroutine 栈上的局部变量
  • 全局变量
  • CPU 寄存器里当前正持有的指针

下面这张图展示了可达性分析的基本样子:根对象出发,能顺着指针链条走到的 A、B、C 判定为存活;而 D 虽然也占着一块内存,但没有任何路径能从根走到它,所以判定为垃圾:

根对象 (栈/全局变量) A B C D 可达 → 判定为存活 不可达 → 判定为垃圾

打个比方:这就像你搬家时清点东西——从"家门口"(根)出发,能摸得到的箱子都留下;至于某个箱子里的东西以后到底用不用得上,没法预知,但如果这个箱子已经完全脱离了家(没有任何路径能碰到它),那不管里面东西多好,都只能当成垃圾扔掉,因为你已经够不着它了。

这里有个不对称性很重要:GC 宁可"错判一点垃圾没收干净"(东西早就没用了但恰好还挂在某个引用链上),也绝对不能"错判一个存活对象为垃圾"(明明还够得着,却被当垃圾清掉,会直接导致程序崩溃)。后面第 5 节的"写屏障",本质上就是在保这条底线。


3. 最朴素的实现:标记-清除(Mark-Sweep)

有了"可达性"这个判定标准,最直接的实现方式分两步:

  1. 标记(Mark):从所有根出发,做一次图遍历(类似 BFS/DFS),把能走到的对象都打上"存活"标记。
  2. 清除(Sweep):把整个堆扫一遍,没被标记的对象,内存直接收回。

最朴素的版本会怎么做?——先把整个程序冻结住(不许任何 goroutine 继续跑,也就是 Stop The World,STW),保证标记的时候指针关系不会变,标记完、清除完,再放开程序继续跑。

这也是"GC = 卡顿"这个刻板印象的来源——早期的 GC(包括 Go 1.5 之前)确实是这么干的,堆越大、对象越多,标记遍历越久,STW 时间就越长,服务会明显卡一下。


4. 为什么现代 GC 不想一直 STW

对于跑在服务端、需要低延迟响应的程序(典型如 HTTP 服务),"每隔几秒钟卡住所有请求几十毫秒甚至几百毫秒"是完全不能接受的。所以 Go、Java 等语言这些年做的核心优化方向都是同一件事:能不能让 GC 的标记过程和程序的正常执行"同时"进行,而不是先停下来再标记?

这就是并发标记(concurrent mark)——GC 的标记线程和你的业务 goroutine 一起跑,谁也不等谁。

但这样做马上会引出一个新问题:标记的时候,程序还在改指针,会不会把一个"其实还活着"的对象漏标,误当成垃圾清掉?

答案是:,如果什么都不做的话。


5. 并发标记的陷阱:对象丢失问题

要理解这个陷阱,需要先引入 三色标记法(tri-color marking)——它是把"标记"这个遍历过程拆成状态机的标准做法,把每个对象标成三种颜色之一:

  • 白色:还没被发现可达(初始状态,遍历结束时仍是白色 = 判定为垃圾)
  • 灰色:已经发现可达,但它自己指向的对象还没检查完(在待处理队列里)
  • 黑色:已经发现可达,而且它指向的所有对象也都已经扔进了待处理队列

标记过程就是不断从灰色队列里取对象、检查它指向了谁、把新发现的对象染灰、自己变黑,直到灰色队列空了为止——队列空了,就意味着"从根出发能走到的对象,全都已经是黑色",遍历自然结束。

问题出在这里:如果标记进行到一半,业务代码(这里专门给它起个名字,叫 mutator,也就是会修改对象图的程序本体,跟负责标记的 collector 相对)同时做了两件事:

  1. 把一个已经扫描完的黑色对象,直接指向一个还没被碰过的白色对象(新增一条引用);
  2. 与此同时,把原来那条能发现这个白色对象的灰色路径给删掉了。

GC 不会再回头重新扫描已经变黑的对象,而原来那条能发现它的灰色路径又被删了——于是这个白色对象在这一轮标记里彻底"消失",即使它其实还活着,也会在 Sweep 阶段被当垃圾清掉。这就是并发 GC 理论里经典的 lost object(对象丢失)问题,本质是 mutator 和 collector 同时读写同一张对象图产生的竞态,不是"扫描得再快一点"能解决的。


6. 解决方案:写屏障(Write Barrier)

Go(以及绝大多数并发/增量 GC)用的解法是写屏障在标记阶段开启之后,每一次指针写入(ptr.field = obj)都会被运行时拦截一下,顺带做点额外的事,防止上面那种丢失发生。Go 1.8 之后用的是"混合写屏障"(插入屏障+删除屏障的组合),效果可以简化理解成:只要有指针写入涉及到标记还没完成的对象,就顺手把相关对象染灰,保证它不会被漏检

程序执行 ptr.field = obj 写屏障拦截 write barrier 正常完成指针写入 顺带把obj染成灰色 加入待扫描队列

有了写屏障,第 5 节里那种"黑色对象直接指向白色对象、又没有其他路径能发现它"的情况就不会再发生——因为这次写入本身就会被拦截,顺手把白色对象染灰,它照样会被扫描到。

代价是什么? 每一次指针写入都多了一点点额外开销(判断要不要染色、要不要入队),这是用"写入变慢一点点"换来"标记阶段可以和程序并发运行、不用整体停下来",在绝大多数场景下这笔交易是划算的。


7. 即便有写屏障,为什么还要留两次短暂 STW

写屏障解决了标记过程中的竞态问题,但整个 GC 周期里,Go 仍然保留了两个必须暂停所有 goroutine 的极短时刻:

  • Sweep Termination(标记开始前):需要把"写屏障"这个开关对所有 goroutine 原子性地统一打开——不能有的 goroutine 已经开了、有的还没开,这个切换瞬间必须全局一致,否则前面讲的竞态照样会发生。
  • Mark Termination(标记结束时):做最后的确认——检查灰色队列是不是真的空了、有没有在临界窗口里溜进来的新工作,这个"收尾清点"也需要一个短暂的、一致的快照。

除了这两处,标记和清除的主体都是并发进行的,跟业务代码同时跑。完整的一轮 GC 周期长这样:

Sweep Termination(STW) Concurrent Mark (写屏障开启) Mark Termination(STW) Concurrent Sweep 通常<1ms 与业务代码并发运行 通常<1ms 后台异步回收内存 ↻ 进入下一轮 GC

两段红色的 STW 通常在亚毫秒级,而且耗时主要跟当前 goroutine 数量有关,跟堆里对象数量、堆大小基本无关——这也是 Go GC 这些年一直在优化收窄的部分。

顺带澄清一个常见误解:并不是所有 GC 实现都必须留这两次 STW。业界确实存在完全零 STW 的方案(比如 Java 的 ZGC/Shenandoah、Azul 的 C4),它们用"染色指针 + 读屏障"把同步开销分摊到每一次指针读取上,彻底消灭了全局暂停。Go 选择"写屏障 + 两次极短 STW"这条路,是因为写屏障只在指针写入时触发(比读屏障便宜得多),日常运行的开销更低——这是工程取舍,不是"GC 理论上必须整体暂停"。


8. 回到 Go 的实际设计

结合前面的原理,Go GC 的几个特点就比较好理解了:

  • 并发、非分代、非移动式:跟 Java 常见的分代 GC(新生代/老年代分开回收、还会挪动对象压缩内存)不同,Go 的对象一旦分配,内存地址就不会再变。这是因为 Go 允许指针直接指向堆内任意位置(cgo、unsafe.Pointer 等场景更是要求地址稳定),如果 GC 要挪动对象、还要在并发场景下同步更新所有指针,实现复杂度会陡增。代价是牺牲了分代 GC 在某些场景下的吞吐优势。
  • 触发时机:由 GOGC(默认 100)控制——当前存活堆大小涨到相当于上一轮 GC 结束时的 2 倍左右就触发下一轮;Go 1.19 之后还可以用 GOMEMLIMIT 设置一个硬性的内存上限兜底。
  • Pacer(步调控制器):自适应调节并发标记的 CPU 占用节奏(默认让标记工作占用大约 25% 的 CPU),目标是让 STW 尽量短、标记速度能跟上分配速度,避免堆无限膨胀。

9. 呼应最初的问题:GC 压力和 sync.Pool

回到我们最早聊的话题——new(gin.Context) 单次调用本身很便宜(前面实测大约几十纳秒),真正的成本在于:HTTP 服务器每秒处理成千上万次请求,意味着每秒都在往堆上扔新对象,这些对象迟早都要经历一遍上面讲的"标记-清除"全过程。分配得越多,并发标记阶段要追的"新增对象"就越多,Pacer 就得让标记跑得更勤、占用更多 CPU;而 sync.Pool 复用对象,相当于直接把这部分分配需求砍掉了,是从源头减少 GC 的工作量,而不是让单次分配变快。


附:常见误解速查

误解 更准确的说法
GC 一定会把整个程序完全停住 现代并发 GC(包括 Go)只在标记开始/结束两个瞬间短暂 STW,其余时间和程序并发运行
白色对象 = 程序写错了 白色只是"遍历没找到通往它的路",绝大多数白色对象就是正常该被回收的垃圾
GC 变慢 = 内存泄漏 更常见的原因是分配速率太高(短时间内 new 太多对象),不一定是"泄漏"(还被意外引用着回不去)
new()/分配本身很"贵" 单次分配是纳秒级的,真正的成本是高并发下大量分配的累积 GC 压力,而不是某一次分配很慢

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。