Python · 内存管理与垃圾回收

引用计数 (即时回收) + 分代 GC (兜底循环引用) + pymalloc (小对象池) — 以及"内存只涨不降"的真相

weakref 不计入 refcnt → 允许对象被回收 计数 供内存 (≤512B) 环收不掉 → 交给 gc gc 检测 PyObject ob_refcnt · 引用计数 ob_type · 类型指针 数据本体 每个对象都带这个"头部" 引用计数 — 主回收器 a = b → INCREF; del / 离开作用域 → DECREF refcnt 归零 → 立即回收 (确定性, 无延迟) 由 GIL 串行化, INCREF/DECREF 免锁 90%+ 的对象走这条路 weakref 弱引用 weakref.ref(obj) 不增加 refcnt 对象回收后引用自动"失效"(返回 None) 缓存的标准姿势: 不阻止对象死亡 lru_cache 内部同思路 pymalloc 小对象池 arena (256KB) → pool (4KB) → block ≤512B 对象专用, 不走系统 malloc 批量分配/释放, 快且少碎片 真相: arena 归还 OS 条件苛刻 → 这就是 RSS"只涨不降"的主因 分代 GC — 兜底回收器 gen0 → gen1 → gen2 (越老扫描越懒) 阈值 (700, 10, 10): gen0 分配减去回收 超过阈值触发一次扫描 只处理一件事: 不可达的循环引用 gc.collect() / gc.set_threshold() 循环引用示例 a.child = b; b.parent = a del a, b → 两者 refcnt 仍为 1 引用计数无能为力 gc 从根遍历 → 发现环不可达 → 触发 finalize 并回收 内存排查工具链 — 定位"谁在涨" tracemalloc 快照 diff, 按 代码行定位增长 objgraph 对象数量增长曲线 show_backrefs 找引用链 gc 模块 get_objects 统计 set_debug 找未回收环 memray 火焰图级分配追踪 线上级性能影响 Legend 对象 / 工具 引用计数主路径 分代 GC pymalloc / 真相提示 示例 / 辅助

引用计数: 确定性回收

  • • refcnt 归零立即回收 — 无需等 GC 周期, 延迟确定
  • • INCREF/DECREF 非原子, 靠 GIL 串行化(与 GIL 互为因果)
  • • 90%+ 对象走这条路, 短命对象几乎零成本

分代 GC: 只管循环引用

  • • 三代策略: 活得越久, 被扫描频率越低
  • • 阈值 (700, 10, 10) — gen0 净分配超 700 触发
  • • 耗时与"可达对象数"相关, 大堆可 gc.freeze() 优化

pymalloc 与 RSS 真相

  • • 小对象内存池: arena→pool→block, 免系统 malloc
  • • arena 必须"整块空闲"才归还 OS → RSS 常驻高位
  • • RSS 不降 ≠ 泄漏; 看趋势是否无界增长才判断泄漏

💡 一句话理解

Python 的内存管理是"引用计数为主 + 分代 GC兜底"的双层结构: 引用计数让对象在失去最后一个引用的瞬间被回收(快、确定), 分代 GC 只负责引用计数搞不定的循环引用。排查内存问题时记住三问: 谁在涨(tracemalloc)? 是泄漏还是池不还(RSS vs 对象数)? 缓存有没有上限?

🧠 必知必会 必考 & 必会

del 不是 free
del a 只是删除名字绑定并 DECREF, 对象是否回收取决于计数是否归零; 还有别处引用就还活着。
import sys
a = [1, 2, 3]; b = a
del a                              # 关键: 只删名字 + DECREF, b 还指着 → 对象活着
sys.getrefcount(b)                 # → 2 (b + getrefcount 形参)
del b                              # 计数归零 → 此刻立即回收(确定性)
循环引用
a.child=b; b.parent=a 后 del a,b, 计数各剩 1, 只有分代 GC 从根不可达推导才能回收。含 __del__ 的环(旧版本)要两轮才收掉。
import gc
a, b = Node(), Node(); a.child, b.parent = b, a   # 互指成环
del a, b                          # 关键: 两者 refcnt 各剩 1, 引用计数收不掉
gc.collect()                       # → 2 — 分代 GC 从根不可达推导, 回收环
weakref
弱引用不增计数, 对象死后引用自动失效 — 缓存持有资源的标准姿势, 避免"缓存阻止了对象释放"。
import weakref
class Node: ...
r = weakref.ref(Node())            # 关键: 不增 refcnt
r()                                # → None — 临时对象已死, 引用自动失效
分代阈值
gc.set_threshold(700,10,10): gen0 净分配超 700 触发 gen0 回收; 每 10 次 gen0 回收触发 1 次 gen1, 依此类推。高频创建长寿对象的服务可调大/配合 gc.freeze()。
import gc
gc.get_threshold()                 # → (700, 10, 10) — gen0 净分配超 700 触发扫描
gc.set_threshold(50_000, 50, 50)  # 关键: 长寿命高频分配服务调大, 少扫
gc.freeze()                        # 常驻对象移出扫描集 (预热后调用)
RSS ≠ 堆使用
进程 RSS 高不一定是泄漏: pymalloc arena 归还条件苛刻 + glibc malloc 不还 OS + 内存碎片。判断泄漏看对象数量/堆统计是否无界增长, 不看 RSS 一时高低。
del big_list; gc.collect()        # 对象已回收, RSS 往往纹丝不动
# 关键: arena 须整块空闲才还 OS → RSS 常驻高位 ≠ 泄漏
len(gc.get_objects())              # 判泄漏看对象数/堆统计是否无界增长
__slots__
默认每个实例一个 __dict__(可动态加属性, 费内存)。声明 __slots__ 后按布局存储, 百万实例场景省 40%+ 内存, 代价是不能再随意加属性。
class Slots: __slots__ = ('x', 'y')   # 关键: 实例不挂 __dict__, 百万级省 40%+
s = Slots(); s.x = 1                # 按固定 slot 布局存取
s.z = 2                          # → AttributeError: 不能再动态加属性
大对象不走 pymalloc
>512B 直接系统分配; list/str 预分配容量时注意 over-allocation 策略(约 1/8 余量)带来的额外内存。
import sys
sys.getsizeof([])                  # → 56 — 空 list 头 (64 位)
lst = [0] * 1000                   # 关键: >512B 的大块直接走系统 malloc
sys.getsizeof(lst)                 # → 8056 — append 扩容还有 ≈1/8 余量

🏭 生产实战 real world

场景 1 · 服务内存只涨不降, 定位增长点

现象: Python 服务 RSS 每天涨 200MB。用 tracemalloc 两个时间点快照对比, 精确到代码行:

import tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
# …… 跑一段时间 / 处理一批请求后
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, 'lineno')[:10]:
    print(stat)   # 输出: 文件:行号  增量字节数  增量块数

常见凶手: 模块级 list/dict 当缓存只进不出、异常对象带整条 traceback 被存进日志集合、闭包捕获了大 response。

场景 2 · 百万实例的内存优化

风控特征服务加载 300 万个实体对象, 默认 dict 布局 ~1.9GB, 三板斧降到 ~0.9GB:

class Feature:
    __slots__ = ('uid', 'score', 'tags')   # 1. 去掉实例 dict

features = [Feature(*row) for row in cursor]     # 2. 惰性分页加载, 别一次全进内存
tags = tuple(t for t in raw_tags)                # 3. tuple 代替 list (省 header + 不可变可共享)

场景 3 · 缓存必须有上限和过期

无上缓存的缓存 = 定时炸弹。函数级用带 maxsize 的 lru_cache, 服务级用 cachetools:

from functools import lru_cache

@lru_cache(maxsize=100_000)            # 有上限! 不写 maxsize=无界
def get_region(ip): ...

from cachetools import TTLCache
cache = TTLCache(maxsize=50_000, ttl=300)   # 容量 + 过期双保险

场景 4 · 服务启动内存基线优化

import 即占内存: 一个 Flask 应用顶层 import pandas 就白吃 80MB。重依赖延迟到真正使用的函数内:

def export_endpoint():
    import pandas as pd        # 首次调用才加载: 不用该功能的部署省 80MB+
    df = pd.read_sql(...)
    return df.to_csv()

# 验证基线: python -X importtime app.py 2>&1 | sort -t'|' -k2 -rn | head

场景 5 · 大 JSON 响应的序列化降耗

网关聚合接口返回几 MB JSON, 换 orjson(Rust 实现)吞吐 5-10 倍、峰值更低, 还省二次编码:

import orjson
from fastapi import Response

@app.get("/aggregate")
def aggregate():
    data = build_big_payload()                     # dict/list 结构
    return Response(orjson.dumps(data),   # 直接产 bytes
                    media_type="application/json")

场景 6 · DataFrame 分块流式聚合

分析任务别一次 read_csv 整个文件: chunksize 迭代 + 只读需要的列, 内存从 O(文件) 降到 O(块):

total = 0
for chunk in pd.read_csv("events.csv", chunksize=100_000,
                        usecols=["user_id", "amount"]):   # 只读两列
    total += chunk.groupby("user_id")["amount"].sum()
result = total.groupby(level=0).sum()             # 分块部分和再合并

场景 7 · 超大文件 mmap 内存映射

几 GB 的模型/索引文件不全读进内存, 按页惰性加载, 多进程共享同一份物理页:

import mmap, struct

with open("index.bin", "rb") as f:
    mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
    def read_record(i):
        off = i * 16                              # 访问哪页, OS 才载哪页
        return struct.unpack_from("<qd", mm, off)  # (id, score)
    # fork 出的 worker 共享同一物理页 → 内存 ≈ 单份

场景 8 · 常驻服务 gc 调参(Instagram 方案)

预热完成后大部分对象永远活在 gen2, 每轮全代扫描白费 CPU — 冻结常驻对象 + 调大阈值:

def on_warmup_complete():            # 预热请求打完后调用一次
    gc.collect()                       # 先收一轮干净
    gc.freeze()()                   # 常驻对象移出扫描集 (PEP 570+)
    gc.set_threshold(50_000, 50, 50)  # gen0 阈值 700 → 50k: 扫描少 70 倍

场景 9 · 资源生命周期统一 with 收敛

泄漏大头是"忘关": 文件、连接、游标全部上下文管理器化, 旧 API 用 closing 包:

from contextlib import closing

with closing(make_legacy_client()) as client:   # 老库没有上下文协议也能保证 close
    client.send(payload)
# review 清单: 每个 acquire 必须配 with / try-finally, 无裸 open/connect

场景 10 · 多 worker 共享只读大模型

8 个 worker 各载 2GB 模型 = 16GB; fork 前(或启动早期)加载, COW 共享物理页:

# master.py — 先加载再 fork worker (gunicorn preload 等价)
MODEL = load_2g_model()              # 顶层加载
def handle(req):
    return MODEL.predict(req)        # 只读不写 → COW 页不复制 → 8 worker 仍 ≈ 2GB
# gunicorn --preload app:app 即此模式; 写操作会触发页复制, 保持模型只读

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 全局集合无限累积 — RESULTS = [] 模块级列表每个请求 append。正解: deque(maxlen=N) / 有界缓存 / 定期清理。
RESULTS = []                        # 错: 模块级 list 只进不出, RSS 无界涨
RESULTS.append(row)
RESULTS = deque(maxlen=10_000)  # 对: 有界集合, 满了自动挤掉最旧
坑 2 · 默认参数用可变对象 — def f(acc=[]) 的 list 跨调用共享且常驻。正解: 默认 None, 函数体内 acc = acc or []。
def f(acc=[]):                     # 错: list 跨调用共享且常驻
    acc.append(1); return acc     # f(); f() → [1], [1, 1]
def f(acc=None):                 # 对: 默认 None, 函数体内新建
    acc = acc or []; ...
坑 3 · 异常对象长生命周期 — except Exception as e 后把 e 存起来: traceback 持有整条栈帧(所有局部变量)。正解: 存 repr(e) 或 traceback.format_exc() 字符串。
except Exception as e: errs.append(e)        # 错: e 带整条 traceback, 锁住栈帧全部局部变量
except Exception as e: errs.append(repr(e))  # 对: 存 repr(e) / format_exc() 字符串
坑 4 · lru_cache 不写 maxsize — @lru_cache 无参 = maxsize 128(还行), 但自己写 dict 当缓存无界。正解: 一切缓存必须有容量/TTL, 并监控 hit rate 决定大小。
cache = {}                         # 错: 自写 dict 缓存无界, 键集合只涨不跌
@lru_cache(maxsize=100_000)      # 对: 一切缓存必须有容量/TTL 上限
def get_region(ip): ...
坑 5 · 误判"内存泄漏" — 看到 RSS 不降就重启。先查: 对象数是否真的在涨(tracemalloc/objgraph)? pymalloc/glibc 本来就少还内存。只有无界增长才是泄漏。
# 错: RSS 不降就断定泄漏重启 — pymalloc/glibc 本来就少还内存
del big; gc.collect()              # 对: 先看对象数/堆统计是否无界增长
snap2.compare_to(snap1, 'lineno')[:10]  # tracemalloc 定位真正增长点
坑 6 · 循环引用 + __del__ — 老代码 __del__ 里的清理逻辑可能不执行(gc 需两轮)。正解: 用 weakref.finalize 或上下文管理器, 不依赖 __del__。
class Res:                          # 错: 清理押在 __del__ — 环要 gc 两轮, 异常还会被吞
    def __del__(self): self.close()
weakref.finalize(res, cleanup)      # 对: 注册收尾钩子, 对象回收时保证调用
坑 7 · 用 is 比较小整数/短字符串 — a is 256 恰好为 True(CPython 缓存 -5~256 与驻留字符串), 换个解释器/更长字符串就翻车。正解: 永远用 == 比值, is 只用于 None/单例哨兵。
a = 256; b = 256
a is b                             # → True: CPython 缓存 -5~256, 纯实现巧合
a = 257; b = 257; a is b              # 错: 257 起不保证同对象 — is 结果不可依赖
a == b                             # 对: 比值永远用 ==, is 只给 None/单例
坑 8 · 列表乘法复制引用 — grid = [[0]*3]*3 三行是同一个 list, 改一处三处全变。正解: [[0]*3 for _ in range(3)]; 所有"乘法造容器"都要检查元素是否可变。
grid = [[0] * 3] * 3              # 错: 三行是同一个 list
grid[0][0] = 9                    # → [[9,0,0],[9,0,0],[9,0,0]] 三行全变
grid = [[0] * 3 for _ in range(3)]  # 对: 每行独立新建
坑 9 · 切片是浅拷贝 — copy = data[:] 只复制外层, 嵌套的 list/dict 仍共享子对象, 一边 clear 另一边数据消失。正解: copy.deepcopy, 或改造为不可变结构(tuple/冻结模型)。
copy = data[:]                     # 错: 只复制外层, 嵌套子对象仍共享
copy[0].clear()                    # data[0] 也跟着空了
copy = copy.deepcopy(data)         # 对: 深拷贝, 或改造为不可变结构
坑 10 · tuple 里的 list 执行 += — t[0] += [1] 抛 TypeError 却修改已生效(先改再报错), 事务性混乱。正解: 元组里不放可变元素; 需要修改就用 list 套 list。
t = ([1], 2)
t[0] += [9]                         # 错: → TypeError 抛出, 但 t 已变成 ([1, 9], 2)
t[0].append(9)                    # 对: 元组里不放可变元素; 要改就 list 套 list
坑 11 · 边遍历边 remove — for x in lst: lst.remove(x) 跳元素且 O(n²)。正解: 推导式重建 lst = [x for x in lst if keep(x)], 或倒序索引删除。
for x in lst: lst.remove(x)      # 错: 跳元素 + O(n²)
lst = [x for x in lst if keep(x)]  # 对: 推导式重建; 或倒序索引删
坑 12 · 可变对象当 dict/set 键 — list 键直接 TypeError; 用 tuple 前, 元素也必须全不可变(list 套 tuple 仍不可哈希)。正解: 数据建模时区分"标识(不可变)"与"载荷(可变)"。
d = {[1, 2]: 'v'}                # 错: → TypeError: unhashable type: 'list'
d = {(1, 2): 'v'}                # 对: 标识用不可变 tuple, 载荷才用 list
坑 13 · 双向引用结构忘记弱引用 — parent.children 与 child.parent 互指 = 整棵树靠 gc 才能收, 大对象图拖慢分代扫描。正解: 反向引用用 weakref.ref 或 weakref.WeakSet。
child.parent = parent              # 错: 与 parent.children 互指, 整棵树靠 gc 才能收
child.parent = weakref.ref(parent) # 对: 反向引用用 weakref.ref / WeakSet
坑 14 · fork 后共享的连接对象错乱 — 父进程的 DB 连接/socket 被 fork 复制, 两个进程写同一个 fd → 协议错乱。正解: 子进程内重建连接(engine.dispose() 后重连), 或 spawn 模式天然规避。
# 错: 子进程复制父进程的 DB 连接/socket, 同一 fd 两个进程写 → 协议错乱
engine.dispose(close=False)        # 对: worker 初始化钩子里重连
set_start_method('spawn')         # 或: spawn 不继承连接, 天然规避
坑 15 · 批处理里 gc.disable() 忘恢复 — 短命批任务禁 GC 提吞吐是合法技巧, 但若进程常驻(定时器复用), 循环引用垃圾开始堆积。正解: 任务结束 gc.enable(); gc.collect() 收口。
gc.disable()                       # 错: 常驻服务禁完不恢复, 循环引用垃圾持续堆积
# 对: 短命批任务可用, 任务结束必须收口
gc.enable(); gc.collect()
坑 16 · pandas 链式赋值隐式复制 — df[df.a>1].b = 2 改的是临时副本, 警告都没看清就上线了。正解: df.loc[mask, 'b'] = 2; 理解 copy-on-write 语义, 大 df 还省一份内存。
df[df.a > 1].b = 2              # 错: 改的是临时副本, 原 df 没变 (还带警告)
df.loc[df.a > 1, 'b'] = 2        # 对: loc 直接定位原 df, 语义明确
坑 17 · readlines() 全量物化 — f.readlines() 一口气造完整列表, GB 级文件直接 OOM。正解: for line in f 惰性迭代(文件对象就是迭代器), 需要随机访问再考虑 mmap。
lines = f.readlines()              # 错: GB 级文件一口气全列表 → OOM
for line in f: ...                  # 对: 文件对象天生是惰性迭代器, 逐行流过
坑 18 · pickle 反序列化的内存尖峰 — pickle.loads(blob) 需要源 bytes + 展开后的对象图同时驻留, 峰值 ≈ 2 倍。正解: 超大状态分块 pickle, 或用逐条记录格式(jsonl/parquet)流式恢复。
obj = pickle.loads(blob)           # 错: 源 bytes + 展开对象图同时驻留, 峰值 ≈ 2 倍
for rec in read_jsonl(path): ...    # 对: 超大状态分块 pickle / 逐条流式恢复
坑 19 · 闭包/lambda 捕获循环变量 — 回调拿到的是变量本身而非当时的值, 循环结束全是最后一个。正解: 默认参数固化 lambda x=x: ... 或 functools.partial。
fns = [lambda: i for i in range(3)]
[f() for f in fns]                   # 错: → [2, 2, 2] — 闭包拿到的是变量本身
fns = [lambda i=i: i for i in range(3)]  # 对: 默认参数固化 → [0, 1, 2]
坑 20 · 清理逻辑全押在 __del__ — __del__ 时机不确定(循环引用要等 gc 多轮), 异常还会被吞。正解: weakref.finalize(obj, cleanup) 或显式 with/close, __del__ 只做最后兜底。
class Res:                          # 错: __del__ 时机不确定, 环要等 gc 多轮, 异常被吞
    def __del__(self): ...
weakref.finalize(res, cleanup)      # 对: 显式注册收尾; 或 with/close, __del__ 只兜底