在编程世界中,高效的内存管理是保证程序稳定运行的关键之一。然而Python作为一门高级编程语言,其自动垃圾回收(Garbage Collection, GC)常常被Pythoner们当做理所当然的黑盒,可一旦线上出现内存飙升,GC就成了最熟悉的陌生人。今天就来拆开这个黑盒,一窥究竟,看看Python是如何优化的帮我们进行垃圾回收的。

Python的垃圾回收主要依赖于引用计数,并在此基础上引入标记-清除和分代回收两种机制,以解决循环引用和提升回收效率的问题。

引用计数:最朴素的记账法

每个Python对象中都包含一个名为PyObject的基础结构体,其中,ob_refcnt字段即为该对象的引用计数,其基本规则如下:

  • 当对象被新的引用指向时 -> ob_refcnt++;
  • 当某个引用被删除或离开作用域时,del -> ob_refcnt–;
  • ob_refcnt ==0 -> 一旦引用计数归零,对象会被立即回收。

这样做的可以让实现变得简单,逻辑很直观,于此同时,实时性很强,内存可立即被释放;当然,这样做也会有一些局限性,比如维护计数需要额外开销、无法处理循环引用(即:两个或多个对象相互引用,却无外部引用指向它们 )的情况等问题。

1
2
3
4
5
6
7
8
import sys

a = [1, 2, 3] # 引用计数为 1
b = a # 引用计数增加为 2
print(sys.getrefcount(a)) # 输出 3(getrefcount本身也会增加一个临时引用)

del b # 引用计数减为 1
# 函数结束时,a 离开作用域,引用计数归零,对象被回收

标记-清除:解决循环引用的利器

针对引用计数无法处理的循环引用问题,Python引入了标记-清除(Mark & Sweep)机制。其基本思路是这样的:

  • 先按需分配内存,直到空闲内存不足时触发垃圾回收;
  • 从根对象(如寄存器和程序栈中的引用)出发,遍历所有可达对象,并对其进行标记;
  • 遍历结束后,清除所有未被标记的对象,释放其占用的内存空间。

通过这样的处理方式,即使对象之间存在循环引用,只要它们已经不再被实际使用,就会被正确回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import gc
import psutil
import os

def rss_mb():
"""获取当前进程占用的物理内存 (MB)"""
return psutil.Process(os.getpid()).memory_info().rss // 1024 // 1024

class Node:
"""一个会互相引用的小节点"""
def __init__(self, value):
self.value = value
self.partner = None

def __repr__(self):
return f"Node({self.value})"

# -------------------------------------------------
# 1) 制造 100 万个循环引用
print("Step 1: 制造循环引用")
print("RSS before:", rss_mb(), "MB")
nodes = []
for i in range(1_000_000):
a, b = Node(i), Node(-i)
a.partner = b
b.partner = a
nodes.extend([a, b])

print("RSS after create:", rss_mb(), "MB")

# 2) 删除外部引用,观察内存
del nodes
print("RSS after del nodes:", rss_mb(), "MB") # 大概率不会降多少

# 3) 手动触发一次标记-清除
gc.collect()
print("RSS after gc.collect():", rss_mb(), "MB")

分代回收: 以空间换时间的优化策略

为了进一步提升回收的效率,Python使用了分代回收的策略。该策略将内存中的对象按其存活时间划分为不同的集合(称为“代”,Generational GC),Python默认定义了三代:

  • 第0代:新创建的对象;
  • 第1代:经历过一次垃圾回收后仍然存活的对象;
  • 第2代:存活时间更长的对象。

回收频率随代号的增加而降低,即代数越高,GC越“懒得管”。比如说:

  • 新分配的对象会被放在第0代(GC的常客,回收最勤快);
  • 如果它们经历过一次GC后仍然存活,则被移至第1代(偶尔光顾);
  • 若再经历一次GC仍未被回收,则进入第二代(几乎佛系,除非内存告急)。

这样做的好处是:大部分新对象往往很快就不再使用,而对“年轻代”进行频繁回收,可以在更短时间内清理掉大部分垃圾,而检查老一代对象的频率较低,从而减少了整体的开销。通俗的说,这种“年纪越大越免检”的策略,让GC把80%的精力花在最容易产生垃圾的第0代,整体吞吐量大幅提升。

1
2
3
4
5
6
7
8
9
import gc

# 查看分代回收的阈值
print(gc.get_threshold()) # 输出 (700, 10, 10)

# 手动执行垃圾回收
gc.collect() # 回收所有代
gc.collect(0) # 只回收第0代
gc.collect(1) # 回收第0代和第1代

总结:

Python的垃圾回收机制是一个结合了引用计数、标记-清除和分代回收的复合系统。它既保证了内存回收的实时性,也有效解决了循环引用问题,并通过分代策略实现了高性能的自动内存管理。

了解这一机制后,我们在后续的编码中可以考虑:

  • 尽量使用局部变量,缩短对象生命周期
  • 手动断开长引用链:a.b = None
  • 调优GC:get_threshold(), 或在低延迟场景下,gc.disable()后手动触发。

另外,在日常开发中如果遇到了OOM相关问题,可以尝试分析的思路有:

  • 实时监控内存使用
  • 定位内存增长点
    • 先确定内存中一直保持增长的是什么类型的对象,看看内存分配最频繁的地方是哪里;
  • 分析大对象
    • 将重点怀疑的对象通过工具输出引用关系(如:objgraph),从而进一步分析出内存的问题源自哪里。

有了上述的思路后,可以尝试借助下列工具进行更进一步的问题分析和定位:

下列工具的具体使用方法,可点击链接前往官方文档进行学习使用。

  • 官方工具:tracemalloc——跟踪内存分配
  • 第三方库:objgraph——对象引用关系可视化
  • 第三方库:pympler——详细对象大小分析
  • 第三方库:memory_profiler——逐行内存分析

本站由 BluesSen 使用 Stellar 1.33.1 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

本站总访问量