Python 内存管理:引用计数、循环垃圾回收与内存泄漏排查

文章目录

    • [一、引用计数:Python 内存管理的基石](#一、引用计数:Python 内存管理的基石)
      • [1.1 引用计数的工作原理](#1.1 引用计数的工作原理)
      • [1.2 引用增减的触发场景](#1.2 引用增减的触发场景)
      • [1.3 引用计数的局限性](#1.3 引用计数的局限性)
    • 二、循环引用:引用计数的盲区
      • [2.1 什么是循环引用](#2.1 什么是循环引用)
      • [2.2 循环引用在列表和字典中更常见](#2.2 循环引用在列表和字典中更常见)
      • [2.3 循环引用为何难以察觉](#2.3 循环引用为何难以察觉)
    • 三、分代垃圾回收:循环引用的终结者
      • [3.1 分代假设](#3.1 分代假设)
      • [3.2 三代分代机制](#3.2 三代分代机制)
      • [3.3 gc 模块基础](#3.3 gc 模块基础)
    • 四、标记-清除:回收机制的代价与收益
      • [4.1 标记-清除算法原理](#4.1 标记-清除算法原理)
      • [4.2 停顿问题:对延迟敏感服务的影响](#4.2 停顿问题:对延迟敏感服务的影响)
      • [4.3 手动 GC 的正确使用方式](#4.3 手动 GC 的正确使用方式)
    • [五、Python 内存泄漏的三大来源](#五、Python 内存泄漏的三大来源)
      • [5.1 全局集合持续增长](#5.1 全局集合持续增长)
      • [5.2 C 扩展持有引用](#5.2 C 扩展持有引用)
      • [5.3 闭包捕获可变对象](#5.3 闭包捕获可变对象)
    • 六、内存泄漏排查工具实战
      • [6.1 tracemalloc:Python 3.4+ 内置的内存分析工具](#6.1 tracemalloc:Python 3.4+ 内置的内存分析工具)
      • [6.2 objgraph:对象引用图生成器](#6.2 objgraph:对象引用图生成器)
      • [6.3 memory_profiler:逐行内存分析](#6.3 memory_profiler:逐行内存分析)
      • [6.4 综合排查流程](#6.4 综合排查流程)
    • 七、weakref:打破循环引用的工具
      • [7.1 弱引用基础](#7.1 弱引用基础)
      • [7.2 弱引用打破循环引用](#7.2 弱引用打破循环引用)
      • [7.3 WeakValueDictionary:自动清理的缓存](#7.3 WeakValueDictionary:自动清理的缓存)
    • [八、pymalloc:CPython 的内存分配器](#八、pymalloc:CPython 的内存分配器)
      • [8.1 pymalloc 的分层架构](#8.1 pymalloc 的分层架构)
      • [8.2 对象内存池的工作原理](#8.2 对象内存池的工作原理)
      • [8.3 sys.getsizeof() 的局限性](#8.3 sys.getsizeof() 的局限性)
    • 九、总结与知识点串联

前置知识串联 :本文建立在类与对象模块与包类型注解与 mypy的基础之上。理解 Python 的内存管理机制,需要对对象、引用和函数闭包有基本认知。


一、引用计数:Python 内存管理的基石

1.1 引用计数的工作原理

CPython(最常用的 Python 实现)采用引用计数(Reference Counting) 作为主要内存管理手段。每个 Python 对象内部都有一个 ob_refcnt 字段,记录有多少个引用指向该对象。当引用计数归零时,对象立即被销毁,内存立即释放------这是 Python 区别于 Java(依赖周期性 GC)的核心差异。

python 复制代码
import sys

a = [1, 2, 3]        # 创建列表对象,refcnt = 1(变量 a 指向它)
print(sys.getrefcount(a))  # 输出 2:getrefcount 本身产生一次临时引用

b = a                  # 变量 b 也指向同一个对象
print(sys.getrefcount(a))  # 输出 3:现在有 a、b 两个引用,加上 getrefcount 的一次

del a                  # 删除变量 a 的引用
print(sys.getrefcount(b))  # 输出 2:只剩 b 和 getrefcount

b = None               # 删除最后一个引用
# 此时 refcnt = 0,对象立即被销毁,内存释放------没有"等下次 GC"的延迟

引用计数的关键优势在于实时性:对象死亡后立即释放,不会等到"下次 GC 运行"。这使得 Python 的内存使用模式非常可预测------大多数情况下,一个对象在不再需要时会立刻消失。

1.2 引用增减的触发场景

引用计数的增减由具体操作自动触发,理解这些触发场景才能预判对象的生命周期:

python 复制代码
import sys

# 场景一:赋值语句------引用从旧对象转移到新对象
x = [1, 2]      # refcnt = 1
x = [3, 4]      # 旧对象 refcnt -1 → 0 → 立即销毁;新对象 refcnt = 1

# 场景二:函数传参------参数传递产生临时引用
def process(obj):
    print(sys.getrefcount(obj))  # 调用时 +1,返回时 -1

lst = [1, 2, 3]
process(lst)        # 参数传递时引用计数临时 +1

# 场景三:容器嵌套------子对象被父容器引用
outer = [1, 2, 3]      # outer refcnt = 1
inner = [outer, 4]     # inner refcnt = 1,outer 的 refcnt +1 = 2
del outer                   # outer 变量消失,但 inner[0] 仍引用它
                             # 此时 outer 对象的 refcnt = 1(被 inner 持有)

# 场景四:循环引用(后文详述)
a = []
b = []
a.append(b)    # b 的 refcnt +1
b.append(a)    # a 的 refcnt +1
# 此时 a 和 b 的 refcnt 都不为 0,但外部已经没有引用指向它们

1.3 引用计数的局限性

引用计数并非银弹。引用计数无法解决循环引用 ,且在多线程环境下需要加锁保护 ob_refcnt(Python 通过 GIL 保证了这方面的线程安全,但这也意味着引用计数的修改是串行的,存在一定性能开销)。

python 复制代码
import sys

# ⚠️ sys.getrefcount() 本身会产生一次临时引用
# 所以对象的"真实"引用数比返回值少 1
obj = object()
print(f"真实引用数: {sys.getrefcount(obj) - 1}")  # 输出 1(只有变量 obj)

二、循环引用:引用计数的盲区

2.1 什么是循环引用

当两个或多个对象相互引用,形成闭环时,就产生了循环引用:

python 复制代码
class Node:
    def __init__(self, name):
        self.name = name
        self.next = None

a = Node("A")
b = Node("B")
c = Node("C")

# 创建循环引用
a.next = b
b.next = c
c.next = a   # 形成 a → b → c → a 的闭环

# 此时没有任何外部变量指向 a/b/c,但它们之间互相引用
# refcnt 永远无法归零------引用计数机制在这里失效了

用图来理解这个结构:
next
next
next
Node A

refcnt=2

(外部无引用)
Node B

refcnt=2
Node C

refcnt=2

每个节点的 refcnt 至少为 1(被上一个节点引用),但外部代码已经无法访问它们------典型的"孤岛对象"。

2.2 循环引用在列表和字典中更常见

列表和字典是最容易产生循环引用的数据结构:

python 复制代码
# 列表的循环引用
lst1 = []
lst2 = []
lst1.append(lst2)   # lst2 refcnt +1
lst2.append(lst1)   # lst1 refcnt +1

# 删除所有外部引用
del lst1
del lst2

# 此时 lst1 和 lst2 形成循环,但外部无法访问------内存泄漏!

# 字典的循环引用
d1 = {}
d2 = {}
d1["partner"] = d2
d2["partner"] = d1

del d1
del d2
# 同上,两个字典对象互相引用,外部引用消失但内存无法释放

这是一个真实的陷阱------在 Python 基础系列中,待办事项管理器的实现里如果不小心让列表项互相引用,也会遇到同样的问题。

2.3 循环引用为何难以察觉

循环引用导致的内存泄漏往往在测试阶段不会暴露------因为测试函数退出时,局部变量被清理,循环引用随之消失。只有在长期运行的服务进程中,这个问题才会逐渐显现:内存持续增长,直到进程被 OOM Killer 终止或手动重启。

python 复制代码
# 这段代码在测试时完全正常------函数退出后局部变量全部清理
def bad_cache():
    cache = []
    for i in range(100):
        item = {"id": i, "data": [1, 2, 3]}
        cache.append(item)
        item["ref"] = cache  # ⚠️ 循环引用!每个 item 都引用 cache 列表
    # 函数退出,cache 被清理,循环引用链断开------测试通过

# 但如果 cache 是全局变量,问题立刻暴露:
cache = []
for i in range(100):
    item = {"id": i}
    cache.append(item)
    item["ref"] = cache   # 全局 cache 被所有 item 持有,永不释放

# cache 永远无法被回收------内存持续增长

三、分代垃圾回收:循环引用的终结者

3.1 分代假设

为了解决循环引用问题,CPython 在引用计数之上叠加了一层分代垃圾回收(Generational Garbage Collection) 。这套机制基于一个重要的统计学假设:大多数对象在创建后很快就会变成垃圾------"朝生夕死"

举一个日常场景:打开一个网页,创建了 DOM 节点对象来处理用户交互------交互结束后,这些对象大多数不会再被用到。如果每次创建对象都进行完整的内存扫描,效率极低。分代回收的核心思想是:新创建的对象大概率是临时的,不需要频繁扫描;只有存活时间足够长的对象才值得花精力检查

3.2 三代分代机制

CPython 将所有对象分为三代(generation 0、1、2),规则如下:

描述 触发条件
第 0 代(年轻代) 新创建的对象 每次 alloc 分配后直接进入
第 1 代(中年代) 经历过一次 0 代回收仍然存活的对象 0 代回收时存活下来的对象晋升
第 2 代(老年代) 经历过多次 1 代回收仍然存活的对象 1 代回收时存活下来的对象晋升
python 复制代码
import gc

# 查看 GC 触发阈值
print(gc.get_threshold())
# 输出:(700, 10, 10)
# 含义:第 0 代累计分配 700 个对象后触发第 0 代扫描
#       第 1 代每回收 10 次第 0 代后,触发一次第 1 代扫描
#       第 2 代每回收 10 次第 1 代后,触发一次第 2 代扫描

分代 GC 的工作流程如下:
否,外部有引用
是,形成闭环

外部无引用
是,触发 GC

可达
不可达

(循环引用)
新对象创建

(refcnt > 0)
是否为循环引用?
活跃对象

留在当前代
循环引用对象

暂留在当前代
该代触发阈值到达?
扫描该代及更年轻代

寻找不可达对象
对象是否可达

(外部有引用路径)?
对象存活

晋升到下一代
释放对象

回收内存

关键点:GC 只负责回收循环引用的"孤岛对象",正常有外部引用的对象由引用计数机制独立管理。 两者互不干扰,各司其职。

3.3 gc 模块基础

gc 模块提供了分代 GC 的编程接口:

python 复制代码
import gc

# 禁用/启用自动 GC
gc.disable()    # 暂停自动 GC(某些性能敏感场景可能需要)
gc.enable()     # 恢复自动 GC

# 手动触发全量 GC
gc.collect()     # 收集所有三代的对象
gc.collect(generation=0)  # 只收集第 0 代

# 查看当前各代对象数量
print(gc.get_count())   # 输出:(478, 12, 3) ------ 各代待回收对象数
print(gc.get_stats())   # 详细 GC 统计信息

# 查看当前存在的所有对象(用于调试泄漏)
print(gc.get_objects())

四、标记-清除:回收机制的代价与收益

4.1 标记-清除算法原理

分代 GC 回收循环引用对象时,使用的是标记-清除(Mark-and-Sweep) 算法,分为两个阶段:

  • 标记阶段(Mark):从根对象(全局变量、栈变量)出发,遍历所有可达对象,标记为"存活"
  • 清除阶段(Sweep):扫描所有对象,未被标记的对象即为"不可达"的垃圾,执行释放

不可达对象(等待清除)
可达对象(标记存活)
根对象集合
全局变量表
调用栈帧
Object A
Object B
Object D
循环引用组

(节点 E/F/G 互指)

4.2 停顿问题:对延迟敏感服务的影响

标记-清除算法需要**停止所有 Python 线程(Stop-the-World,STW)**才能准确判断对象可达性。在 GC 期间,整个 Python 进程暂停执行,直到标记-清除完成。

对于长时间运行的服务(如 Web API、实时数据处理),这意味着:

python 复制代码
import gc
import time

# 模拟一个 GC 停顿场景
def simulate_request_processing():
    # 每次请求创建大量临时对象
    data = [{"key": i, "value": i * 2} for i in range(10000)]
    # 处理数据...
    return sum(item["value"] for item in data)

# 正常请求处理时间
start = time.perf_counter()
for _ in range(100):
    simulate_request_processing()
print(f"正常处理耗时: {time.perf_counter() - start:.3f}s")

# 手动触发 GC 强制停顿
gc.disable()  # 暂停自动 GC
for _ in range(10000):
    simulate_request_processing()
gc.enable()
gc.collect()  # 这里会触发一次明显的 GC 停顿

在生产环境中,这种停顿表现为请求延迟毛刺(latency spike)------99% 分位延迟正常,偶尔出现几百毫秒的尖峰,排查起来非常困难。G1GC 等增量式 GC 算法在 JVM 中缓解了这个问题,但 CPython 目前没有内置的增量 GC 方案。

缓解策略

  • 降低 GC 触发频率:gc.set_threshold(1000, 15, 15) 调高阈值
  • 对延迟敏感的场景使用 gc.disable() + 定期 gc.collect(generation=0) 的手动控制模式
  • 对象池(Object Pooling)复用对象,减少分配频率

4.3 手动 GC 的正确使用方式

在某些场景下,主动控制 GC 时机比让 GC 自动运行更优:

python 复制代码
import gc

# 在 Python 应用启动完成后主动清理
def app_startup():
    # 启动时创建大量临时对象(配置解析、日志初始化等)
    pass
    # 启动完成后主动 GC,防止启动阶段的"冷数据"占用内存
    gc.collect()

# 在批量任务完成后清理
def process_batch(items: list):
    results = []
    for item in items:
        # 处理每个 item...
        results.append(process(item))
    # 批量处理完成后清理孤儿对象
    gc.collect(generation=0)
    return results

# ⚠️ 不要在每次小操作后都调用 gc.collect()------这会导致频繁 STW
# ✅ 正确的做法是按批(batch)或按阶段(stage)触发

五、Python 内存泄漏的三大来源

理解了引用计数和 GC 机制后,可以清晰地归纳 Python 内存泄漏的三大来源------它们都与"对象应该被回收但没有被回收"有关。

5.1 全局集合持续增长

最常见的泄漏源是全局容器(list、dict、set)持续追加对象但从不清理

python 复制代码
# ⚠️ 全局日志缓冲区无限增长
request_log = []   # 全局变量

def handle_request(request):
    # 每次请求都追加日志,但从不清理
    request_log.append({
        "timestamp": time.time(),
        "path": request.path,
        "duration": request.duration
    })
    # request_log 会无限增长,直到进程内存耗尽
python 复制代码
# ✅ 正确做法:设置上限或定期清理
from collections import deque

request_log = deque(maxlen=10000)  # 固定容量,超出自动丢弃最旧记录

# 或者定期刷新
class LogBuffer:
    def __init__(self, max_size=10000):
        self.max_size = max_size
        self._buffer = []

    def append(self, record):
        self._buffer.append(record)
        if len(self._buffer) > self.max_size:
            # 删除最旧的一半记录
            self._buffer = self._buffer[self.max_size // 2:]

5.2 C 扩展持有引用

使用 NumPy、Pandas、Cython 编写的 C 扩展模块时,如果 C 代码中持有 Python 对象的引用(没有正确调用 Py_DECREF),这些对象的引用计数永远不会归零------这属于最难排查的泄漏类型,因为问题不在 Python 代码层,而在 C 扩展的实现细节。

python 复制代码
# 这段 Python 代码本身没有泄漏,但 C 扩展可能持有引用
import numpy as np

# NumPy 内部由 C 实现,如果 C 代码持有数组引用,
# Python 的 gc 模块无法追踪到这些引用
arr = np.zeros((10000, 10000))  # 占用 ~800MB 内存
del arr  # Python 层面删除了引用,但如果 C 扩展没有正确释放,
          # 内存不会被归还给系统

排查这类问题时,tracemallocobjgraph 能提供关键线索------如果 tracemalloc 显示内存没有释放但 Python 对象数量正常,问题极有可能出在 C 扩展层。

5.3 闭包捕获可变对象

Python 闭包(Closure)捕获外部作用域变量时,如果不小心捕获了可变对象(如 list、dict),可能导致意外的对象持有:

python 复制代码
# ⚠️ 闭包持有对大列表的隐式引用
def create_processor():
    large_data = [i for i in range(1000000)]  # 占用大量内存

    def process(x):
        # process 闭包捕获了 large_data,即使 process 不使用它
        return x * 2

    return process  # large_data 随 create_processor 返回而被 process 闭包持有

handler = create_processor()
# large_data 虽然在 create_processor 的局部作用域中消失,
# 但 process 闭包持有了对它的引用------内存无法释放!

# ✅ 正确做法:不要在闭包中隐式捕获大对象,或用 nonlocal 显式管理
def create_processor_optimized():
    large_data = [i for i in range(1000000)]

    def process(x):
        return x * 2

    # 在返回前显式清理
    del large_data
    return process

是(闭包持有)

create_processor 调用
large_data 创建

占用 ~8MB
process 闭包定义

隐式捕获 large_data
函数返回,局部变量清理
large_data 引用

是否还存在?
内存持续占用

泄漏
内存正常释放


六、内存泄漏排查工具实战

6.1 tracemalloc:Python 3.4+ 内置的内存分析工具

tracemalloc 是 Python 标准库提供的内存分配追踪工具,从 Python 3.4 开始引入。它的核心能力是对比两个时间点的内存分配差异,精确定位泄漏源头。

python 复制代码
import tracemalloc
import time

# 启动追踪
tracemalloc.start()

# ... 执行一些操作 ...

# 获取当前内存快照
snapshot1 = tracemalloc.take_snapshot()

# ... 更多操作(可能包含泄漏)...

# 获取第二个快照
snapshot2 = tracemalloc.take_snapshot()

# 对比两个快照:找出内存增长最多的位置
top_stats = snapshot2.compare_to(snapshot1, "lineno")

print("内存增长 Top 10:")
for stat in top_stats[:10]:
    print(stat)

典型输出:

复制代码
Memory increase Top 10:
<filename>:<line>: size=+2.4 MiB, count=+30000
    ├─ <frozen importlib._bootstrap>: -1 B
    ├─ ...
<filename>:<line>: size=+512.0 KiB, count=+8000
    ├─ ...

更强大的用法------按调用栈(traceback)分组,追踪完整的调用链:

python 复制代码
import tracemalloc

tracemalloc.start()

# 模拟泄漏场景
leaked_list = []

def allocate_data():
    for i in range(10000):
        # 每行代码分配的对象被 tracemalloc 追踪
        leaked_list.append({"id": i, "data": "x" * 100})

allocate_data()

# 获取快照并按调用栈分组
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("traceback")

print("内存分配 Top 3(按调用栈):")
for stat in top_stats[:3]:
    print(f"\n{stat.size / 1024:.1f} KB --- {stat.count} 次分配")
    for line in stat.traceback.format():
        print(f"  {line}")

tracemalloc 的一个重要限制:它只能追踪 Python 层的内存分配,C 扩展(如 NumPy 数组)分配的内存不会被追踪到。因此,如果 NumPy 数组内存增长了但 tracemalloc 显示没有变化,问题出在 C 层。

6.2 objgraph:对象引用图生成器

objgraph 是一个强大的第三方库,可以生成对象引用图,帮助追踪是什么持有了某个对象

bash 复制代码
pip install objgraph
python 复制代码
import objgraph
import gc

# 创建一个循环引用
a = []
b = {"data": a}
a.append(b)

del a
del b

# gc.collect() 后,循环引用对象应该被回收
gc.collect()

# 查看仍然存在的指定类型对象数量
print(objgraph.count("list"))   # 当前活跃的 list 对象数量
print(objgraph.count("dict"))   # 当前活跃的 dict 对象数量
print(objgraph.count("tuple"))  # 当前活跃的 tuple 对象数量

# 找出持有某个对象的所有引用(突破点!)
class Node:
    def __init__(self, name):
        self.name = name
        self.next = None

head = Node("head")
node1 = Node("node1")
node2 = Node("node2")
head.next = node1
node1.next = node2
node2.next = head   # 循环引用

# 查看是什么持有 node2 的引用
print(objgraph.find_referrers(node2))
# 输出:包含 node1.next 的引用信息

# 生成引用链可视化
objgraph.show_refs([head], filename="ref_graph.png")
# 生成一张 ref_graph.png,显示 head 对象的完整引用链

6.3 memory_profiler:逐行内存分析

memory_profiler 可以逐行追踪函数的内存使用量:

bash 复制代码
pip install memory_profiler
python 复制代码
from memory_profiler import profile

@profile
def memory_heavy_function():
    data = [i for i in range(1000000)]  # 这里会有明显的内存增长
    total = sum(data)
    return total

if __name__ == "__main__":
    memory_heavy_function()

运行后输出:

复制代码
Line #    Mem usage    Increment   Line Contents
===============================================
     4      30.2 MiB      0.0 MiB   @profile
     5      30.2 MiB      0.0 MiB   def memory_heavy_function():
     6      65.1 MiB     34.9 MiB       data = [i for i in range(1000000)]
     7      65.1 MiB      0.0 MiB       total = sum(data)
     8      65.1 MiB      0.0 MiB       return total

逐行分析让泄漏定位变得非常精确------哪行代码导致了内存增长,一目了然。

6.4 综合排查流程



否,分布均匀
否(tracemalloc 无变化)
发现内存持续增长
tracemalloc 追踪到

Python 层分配增长?
增长集中在

特定调用栈?
检查该调用栈

是否有全局容器增长
检查是否有大量

短生命周期对象未释放
定位泄漏代码

(全局集合/闭包)
C 扩展层泄漏

检查 NumPy/Pandas/Cython
用系统工具定位

(Valgrind/massif)
✅ 修复完成


七、weakref:打破循环引用的工具

7.1 弱引用基础

weakref(弱引用)允许持有对象的引用但不增加其引用计数。当对象只有弱引用指向它时,该对象可以被 GC 正常回收:

python 复制代码
import weakref
import gc

class Cache:
    def __init__(self, key):
        self.key = key

# 创建弱引用
obj = Cache("expensive_data")
weak_ref = weakref.ref(obj)

print(f"对象存在: {weak_ref() is not None}")  # True
print(f"引用计数: {len(gc.get_referrers(obj))}")  # > 1(有变量 + 弱引用

del obj  # 删除强引用
print(f"对象存在: {weak_ref() is not None}")  # False --- 对象已被 GC 回收!
# 因为没有其他强引用指向它了

7.2 弱引用打破循环引用

考虑一个缓存场景:缓存条目需要持有数据的引用,但又不希望缓存本身阻止数据被回收:

python 复制代码
import weakref

class TreeNode:
    def __init__(self, value):
        self.value = value
        self.parent = None
        self.children = []

    def add_child(self, child):
        child.parent = self
        self.children.append(child)

# ⚠️ 普通引用:父子互相引用,循环引用
# parent → child 且 child → parent,双方 refcnt 永不为 0

# ✅ 弱引用:parent 持有 child 的强引用,child 只持有 parent 的弱引用
class SafeTreeNode:
    def __init__(self, value):
        self.value = value
        self._parent_ref = None
        self.children = []

    @property
    def parent(self):
        return self._parent_ref() if self._parent_ref else None

    @parent.setter
    def parent(self, node):
        # 弱引用:node 的存在不依赖于 child
        self._parent_ref = weakref.ref(node) if node else None

    def add_child(self, child):
        child._parent_ref = weakref.ref(self)  # 弱引用,避免循环
        self.children.append(child)

7.3 WeakValueDictionary:自动清理的缓存

weakref 模块还提供了几种特殊容器,其中最实用的是 WeakValueDictionary------当被引用的对象被 GC 回收后,自动从字典中移除:

python 复制代码
import weakref
import gc

class HeavyObject:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"HeavyObject({self.name})"

# WeakValueDictionary:值是弱引用的字典
cache = weakref.WeakValueDictionary()

cache["user1"] = HeavyObject("Alice")
cache["user2"] = HeavyObject("Bob")

print(len(cache))        # 2
print(cache["user1"])   # HeavyObject(Alice)

# 删除最后一个强引用
del cache["user1"]       # 显式从缓存删除
del cache["user2"]

# 或者当没有其他强引用时
obj = HeavyObject("Carol")
cache["user3"] = obj
print(len(cache))        # 1
del obj                  # 删除强引用后,cache["user3"] 自动消失!
print(len(cache))        # 0 --- 弱引用对象已被 GC 清理

gc.collect()             # 确保 GC 运行
print(cache.get("user3"))  # None --- 对象已不存在

这个模式非常适合实现LRU 缓存元数据缓存------缓存不会阻止对象被回收,当对象在其他地方不再被使用时,缓存条目会自动消失。


八、pymalloc:CPython 的内存分配器

8.1 pymalloc 的分层架构

CPython 的内存分配器分为三层,从上到下:
小对象

(≤ 512 bytes)
大对象

(> 512 bytes)
Python 对象分配

(list.append / dict[key]=value / 创建对象)
pymalloc 分配器

(PyObject_Malloc)
请求大小

属于哪个 pool?
PyMem_Malloc

对象内存池

(Arena + Block)
libc malloc

直接向系统申请

内存映射(mmap)
Arena(4KB 页)

管理多个 Block Pool
Pool(8KB)

同大小 Block 池
Block(8/16/.../512 bytes)

最小分配单元
释放:归还 Block

→ Pool → Arena
释放:munmap

归还系统

8.2 对象内存池的工作原理

pymalloc 将小对象分为多个"大小类"(size class),例如 8 字节、16 字节、32 字节......每个大小类维护一个独立的内存池(Pool)。分配时,从对应池中取出一个空闲 Block;释放时,将 Block 归还池中。

这种设计有两大优势:

  • 分配速度快 :无需每次都调用 libc 的 malloc(),大部分分配在 Python 进程内部完成
  • 内存碎片低:同大小对象聚集在同一池中,空闲块可以复用

但也带来一个问题:Arena 可能被部分填满后无法归还系统 。即使 Arena 中的所有 Pool 都是空的,arena 本身也不会归还给系统(除非 Python 显式调用 PyObject_Free,但这通常不会发生)。

8.3 sys.getsizeof() 的局限性

sys.getsizeof() 返回对象的表层大小,不包括它引用的其他对象:

python 复制代码
import sys

# 基本类型:返回精确值
print(sys.getsizeof(42))              # 28 bytes(int 对象)
print(sys.getsizeof("hello"))         # 55 bytes(str 对象)
print(sys.getsizeof([1, 2, 3]))       # 88 bytes(列表头,不含元素!)

# 列表的真实占用
lst = [1, 2, 3, 4, 5]
print(f"列表头大小: {sys.getsizeof(lst)} bytes")           # 88 bytes
print(f"列表元素引用大小: {sys.getsizeof(1) * len(lst)} bytes")  # 28 * 5 = 140 bytes
# 真实占用 ≈ 88 + 140 = 228 bytes

# 嵌套结构:getsizeof 完全无法反映真实大小
nested = {"key": [1, 2, 3], "nested2": {"inner": "value"}}
print(f"字典大小: {sys.getsizeof(nested)}")  # 232 bytes
# 实际占用远大于此------字典里的 list、嵌套 dict、str 都是独立对象

# ✅ 正确做法:用 tracemalloc 获取真实占用
import tracemalloc
tracemalloc.start()
nested = {"key": [1, 2, 3], "nested2": {"inner": "value"}}
current, peak = tracemalloc.get_traced_memory()
print(f"当前: {current / 1024:.1f} KB, 峰值: {peak / 1024:.1f} KB")

这个坑在计算"程序实际占用多少内存"时非常容易踩到。sys.getsizeof() 适合用于比较同类对象的相对大小,但不适合用于精确内存核算


九、总结与知识点串联

知识点 核心要点 对应前文
引用计数 refcnt 归零即释放,无需等待 GC 类与对象
循环引用 容器互相引用 + 外部无引用 = 内存泄漏 列表与字典
分代 GC 三代 + 阈值触发,解决循环引用 类型注解
标记-清除 STW 停顿是 GC 的代价,对延迟敏感服务需关注 综合实战
内存泄漏来源 全局集合/C 扩展/闭包 模块与包
tracemalloc 对比两个时间点的分配差异,定位泄漏 类型注解
weakref 不增加 refcnt,打破循环引用的工具 类与对象
pymalloc 分层分配器,表层大小 ≠ 真实占用 标准库精讲

Python 的内存管理是引用计数与分代 GC 的组合------两者各司其职。引用计数负责绝大多数对象的即时释放,分代 GC 作为补充,专门处理循环引用这个"漏网之鱼"。理解这套机制之后,再回头看"全局 list 无限增长"、"闭包持有大对象"这些问题,就不再是玄学,而是有据可查的系统性分析。


如果觉得这篇文章有帮助,欢迎点赞、关注!

往期回顾:

相关推荐
AI技术增长1 小时前
Pytorch图像去噪实战(七):Noise2Noise自监督图像去噪实战,没有干净图也能训练模型
人工智能·pytorch·python
PSLoverS1 小时前
Navicat全局查找与替换字符突然失效怎么办_重置与缓存清理
jvm·数据库·python
m0_602857762 小时前
如何提升SQL存储过程逻辑复用_封装通用存储过程函数
jvm·数据库·python
傻啦嘿哟3 小时前
如何在 Python 中使用 colorama 库来给输出添加颜色
开发语言·python
forEverPlume3 小时前
mysql如何实现高可用集群架构_基于MHA环境搭建与部署
jvm·数据库·python
geovindu4 小时前
go: Visitor Pattern
开发语言·设计模式·golang·访问者模式
宣宣猪的小花园.4 小时前
C语言重难点全解析:内存管理到位运算
c语言·开发语言·单片机
方安乐8 小时前
python之向量、向量和、向量点积
开发语言·python·numpy
zh1570239 小时前
JavaScript中WorkerThreads解决服务端计算瓶颈
jvm·数据库·python