重要
Java 的 GC 是"分代假设"的代表——认为大部分对象短命,按新生代/老年代分层回收。Python 则以引用计数为主力、标记清除为辅助、分代回收为优化。Go 走的是第三条路——逃逸分析把短命对象留在栈上,堆上不分代,直接并发标记清理。
1. Java 分代回收
1.1 为什么分代
Java 对象全在堆上。运行时收集到的数据显示:大部分对象在年轻时死亡(分代假设)。将堆分为新生代和老年代,可以对两个区域使用不同的回收策略。
| |
1.2 Minor GC 流程
新生代满时触发 Minor GC:
| |
| 特性 | 新生代 | 老年代 |
|---|---|---|
| GC 类型 | Minor GC | Major / Full GC |
| 回收算法 | 复制算法 | 标记-清除 / 标记-整理 |
| 触发频率 | 高 | 低 |
| STW 时长 | 短(毫秒级) | 长(可达秒级) |
| 内存利用率 | 50%(复制算法需要两倍空间) | 高 |
1.3 常见收集器
| 收集器 | 目标 | 新生代算法 | 老年代算法 |
|---|---|---|---|
| Serial | 单线程,客户端 | 复制 | 标记-整理 |
| Parallel | 吞吐量优先 | 并行复制 | 并行标记-整理 |
| CMS | 低延迟 | 并行复制 | 并发标记-清除 |
| G1 | 平衡延迟与吞吐 | 复制 | 标记-整理(Region 化) |
1.4 分代法的代价
| 代价 | 说明 |
|---|---|
| 跨代引用 | 老年代引用新生代对象时,新生代 GC 必须扫描老年代——引入**卡表(Card Table)**记录跨代引用 |
| 写屏障 | 维护卡表需要额外的写屏障 |
| 内存碎片 | CMS 标记-清除产生碎片,需要额外的整理阶段 |
| 晋升失败 | Survivor 空间不足或老年代满时触发 Full GC,STW 时间数倍于 Minor GC |
2. Python 引用计数 + 分代
2.1 引用计数——主力机制
Python 每个对象头部有一个 ob_refcnt 字段记录被引用次数。
| |
| 引用计数变化 | 触发操作 |
|---|---|
| 对象创建 | refcnt = 1 |
| 赋值 / 传参 / 放入容器 | refcnt + 1 |
离开作用域 / del / 容器元素变更 | refcnt - 1 |
| refcnt = 0 | 立即回收 |
优点:实时回收,内存不会堆积。缺点也来自这里——每次引用变更都要更新计数。
2.2 循环引用——引用计数的死穴
| |
两个对象互相引用,形成引用环,refcnt 永远 ≥ 1——引用计数无法回收。
2.3 标记-清除——辅助机制
Python 的 gc 模块负责处理循环引用:
- 收集所有容器对象(
list、dict、set等,不含int、str) - 对每个对象,将
refcnt减去其内部其他容器对象的引用数 - 如果减法后
refcnt = 0,说明该对象只被容器内部引用——是循环引用的孤岛 - 回收这些对象
2.4 分代——优化机制
Python 将对象分为 3 代:
| 代 | 对象特征 | GC 频率 |
|---|---|---|
| 0 代 | 新对象 | 最高 |
| 1 代 | 熬过 0 代 GC | 中等 |
| 2 代 | 长期存活 | 最低(长生命周期对象大概率不会变成垃圾) |
每一代的 GC 阈值可配置:
| |
2.5 GIL 与 GC
CPython 的 GIL 使得引用计数的增减天然线程安全——不需要原子操作或锁。但 GIL 也是 Python 多线程性能的瓶颈。
3. Go vs Java vs Python
| Go | Java | Python (CPython) | |
|---|---|---|---|
| 主力回收机制 | 并发标记-清除 + 辅助清扫 | 分代复制/标记-整理 | 引用计数 |
| 辅助机制 | 混合写屏障 | 卡表写屏障 | 标记-清除(循环引用)+ 分代 |
| STW 时长 | < 100μs | ms ~ s 级 | 增量,几乎无全局 STW |
| 内存碎片 | 低(TCMalloc 分配器) | CMS 有碎片 | 引用计数回收粒度细 |
| 逃逸分析 | 编译期,栈上分配 | JIT 逃逸分析 | 无(全在堆) |
| 调优参数 | GOGC、GOMEMLIMIT | -Xmx、-Xms、收集器选择 | gc.set_threshold() |
三条路线的本质差异:
| 语言 | 设计哲学 | 为什么选这条路 |
|---|---|---|
| Java | 堆上全分配 → 按年龄分层 | 对象全在堆上,分代假设自然成立 |
| Python | 引用计数为主力 + 标记清除兜底 | 简单直观,实时释放;循环引用用 GC 补 |
| Go | 逃逸分析削峰 + 并发标记清除 | 短命对象在栈上消失,堆上新生代不多 → 分代收益小 |
Go 不做分代回收不是因为技术做不到,而是逃逸分析已经削掉了分代法的收益空间——短命对象根本不在堆上。
4. 总结
- Java 分代回收基于"大部分对象短命"的假说,按年龄分层使用不同算法;
- Python 引用计数实时回收,标记清除解决循环引用,分代优化 GC 频率;
- Go 的逃逸分析使分代假说失效——短命对象在栈上,堆上对象生命周期偏长,不分代反而高效。