>>>PyPathPython 学习站
首页›进阶篇›kp-033
进阶篇 · Advanced lv.3 进阶 kp-033

内存管理与垃圾回收

前置知识:kp-003、kp-023

1. 一句话定义

CPython 以引用计数为主、分代垃圾回收为辅:引用归零立即释放;循环引用由 GC 周期性清扫——理解这两层才能诊断"内存为什么涨上去不下来"。

2. 为什么重要

长驻进程(Web 服务、爬虫)的内存问题 90% 不是"GC 不干活",而是该释放的对象仍被引用着(全局缓存、闭包、异常 traceback)。会排查内存,是服务端 Python 的硬技能。

3. 前置知识

kp-003(名字绑定与引用)、kp-023(闭包持有)。

4. 核心概念

  • 引用计数:每个对象记录"被引用次数";del x、名字重绑、容器移除都会减计数;归零立即回收。sys.getrefcount(x) 查看(注意自身多 1 个临时引用)。
  • 循环引用:a.b = b; b.a = a 计数永不归零 → 交给分代 GC。
  • 分代 GC:0/1/2 三代,新对象在 0 代;扫描越老越少、越老越慢。
  • __slots__:用固定槽位替代实例 __dict__,大批量小对象省内存 30-50%。
  • weakref:弱引用不增加计数——缓存场景的标准解。

5. 原理与机制

python
import sys, gc, weakref

a = [1, 2]
print(sys.getrefcount(a))      # 2(a 本身 + 传参临时引用)
b = a                          # +1
del a                          # -1;对象仍活着(b 还指它)

class Node: pass
x, y = Node(), Node()
x.peer, y.peer = y, x          # 循环引用
del x, y                       # 计数未归零
gc.collect()                   # 分代 GC 清扫循环垃圾

# 常见滞留源
class Handler:
    def __init__(self):
        self.cache = {}
    def handle(self, exc):
        self.cache["last"] = exc      # 异常对象带着 traceback → 引用整条调用栈帧!

tracemalloc 定位增长点:

python
import tracemalloc
tracemalloc.start()
...  # 运行一段时间
snap = tracemalloc.take_snapshot()
for stat in snap.statistics("lineno")[:5]:
    print(stat)          # 哪一行分配的内存最多

6. 关键事实(模型/图示)

text
主通道:refcount → 0 → 立刻析构(确定性、快)
辅通道:分代 GC → 清循环引用(threshold 可调;gc.disable() 对纯脚本常安全)
第三通道:weakref → 不阻止回收(观察者/缓存的标准姿势)
大对象特例:NumPy 等缓冲区走 malloc 之外的通道,refcount 照常管理

7. 直观类比

引用计数是会议室门上的签到表:进出各划一笔,最后一个人走(计数归零)随手关灯锁门——即时且确定。循环引用像两个房间互留钥匙,谁也别想"最后离开"——于是物业(分代 GC)定期巡楼,发现整层没人却亮着灯就统一拉闸。

8. 实例与案例

python
# 缓存标准写法:弱引用值
import weakref
cache = weakref.WeakValueDictionary()

# 大列表小对象:__slots__
class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y): self.x, self.y = x, y
# 1e6 个 Point:带 dict ≈ 152MB → slots ≈ 56MB(数量级演示)

9. 常见误区

  1. "内存涨 = 泄漏 = GC 坏了" —— 多数是全局容器只进不出(缓存无上限、日志追加);先 tracemalloc 定位。
  2. 捕获异常后长期保存异常对象 —— 异常的 __traceback__ 持有整条栈帧(局部变量全在);记录日志后丢弃或只存字符串。
  3. 认为 del x 会立即释放 —— 只减引用;还有别的名字/容器/闭包引用着就活着。
  4. __slots__ 万能 —— 它放弃了动态属性;且与多重继承的某些组合冲突,有明确内存痛点再上。

10. 自测题

  1. 循环引用为什么引用计数解决不了?谁来解决?
  2. sys.getrefcount(a) 返回值为什么总比"真实引用数"大 1?
  3. 服务内存持续上涨的第一步排查动作?
参考答案
  1. 互相引用使计数永不归零;分代垃圾回收器通过周期性遍历检测不可达的循环结构。
  2. 函数传参本身创建了一个临时引用。
  3. 开 tracemalloc 对比两个时间点的快照(compare_to),定位增长最快的分配点。

11. 与其他知识点的关系

  • kp-029 GIL:引用计数的线程安全正是 GIL 存在的理由。
  • kp-034 性能:内存布局影响速度(cache 命中、slots)。
  • kp-023 闭包:隐式持引用的高发区。

12. 延伸阅读

  • gc 模块文档;tracemalloc 文档
  • 《CPython Internals》内存管理章节