Python 与内存管理,第二部分:堆内存
Source: Dev.to
(请提供需要翻译的正文内容,我才能为您完成简体中文的翻译。)
Python 的内存区段:对象的所在位置
当你运行一个 Python 程序时,操作系统会为该进程创建多个内存区域。
在 Linux 上,你可以使用下面的脚本检查这些区域(保存为文件后执行):
#!/usr/bin/env python3
import os, sys
def main():
pid = os.getpid()
with open(f"/proc/{pid}/maps") as f:
for line in f:
line = line.strip()
parts = line.split()
if len(parts) >>>> Found: {name} ({id(obj):#x}) in this block")
# Objects to track
vars = {
"True": True,
"small number": 4,
"large number": 2**123,
"small string": "a",
"large string": "a" * 2000,
"int class": int,
"sys module": sys,
"main function": main,
}
if __name__ == "__main__":
main()
该脚本读取 /proc/<pid>/maps,打印每个内存块,并报告 vars 中的对象是否位于该块中。
下面是你最常会看到的几类内存区域的简要概述。
1. 可执行代码区(只读)
这些块包含 Python 解释器二进制文件以及它加载的所有共享库。
典型的权限是 r—p(只读)或 r‑xp(可读可执行)。
00400000-0041f000 r--p 00000000 08:11 545993 /usr/bin/python3.14
0041f000-006d2000 r-xp 0001f000 08:11 545993 /usr/bin/python3.14
006d2000-00945000 r--p 002d2000 08:11 545993 /usr/bin/python3.14
...
7f3d6c6ab000-7f3d6c6d1000 r--p 00000000 08:11 541316 /usr/lib/x86_64-linux-gnu/libc.so.6
7f3d6c6d1000-7f3d6c827000 r-xp 00026000 08:11 541316 /usr/lib/x86_64-linux-gnu/libc.so.6
这些区段是 Python 可执行文件或它依赖的共享对象的一部分。
2. 数据段(读写)
该区域保存属于 Python 可执行文件本身的读写数据,例如单例对象 True、小整数以及解释器启动时预先分配的内建类型。
00946000-00a85000 rw-p 00545000 08:11 545993 /usr/bin/python3.14
>>>>> Found: True (0x95a0a0) in this block
>>>>> Found: small number (0xa5bb08) in this block
>>>>> Found: int class (0x958cc0) in this block
3. 堆区(动态分配)
由操作系统管理的堆(brk/sbrk)用于需要超过 512 字节的对象。
这些分配通过 C 库的 malloc 完成。
1039c000-10449000 rw-p 00000000 00:00 0 [heap]
>>>>> Found: large string (0x10406840) in this block
4. 匿名内存映射(Python 的 pymalloc)
Python 的私有分配器(pymalloc)会获取称为 arena 的大块匿名内存。
每个 arena 被细分为 pool,用于服务小对象。
>>>> Found: small string (0x7f75e62ec730) in this block
>>>> Found: main function (0x7f75e62d85e0) in this block
>>>> Found: large number (0x7f75e67e3f30) in this block
>>>> Found: sys module (0x7f75e67a2ca0) in this block
想更深入了解 arena、pool 以及整体策略,请参阅:
5. 栈区(函数调用)
栈保存 C 级别的调用帧和局部变量。Python 对象本身永远不会驻留在栈上。
7fff20372000-7fff20394000 rw-p 00000000 00:00 0 [stack]
6. 内核接口区
这些映射([vvar]、[vdso])由内核用于提供快速系统调用接口。
它们与 Python 的对象分配并无直接关系。
7fff203b8000-7fff203bc000 r--p 00000000 00:00 0 [vvar]
7fff203bc000-7fff203be000 r-xp 00000000 00:00 0 [vdso]
Source: …
Python 如何通过引用计数进行垃圾回收
每个 CPython 对象都包含一个引用计数 (ob_refcnt)。当计数降为零时,对象的内存会被回收。
/* CPython’s PyObject structure */
typedef struct _object {
Py_ssize_t ob_refcnt; /* reference count */
PyTypeObject *ob_type; /* pointer to type object */
/* ... type‑specific fields follow ... */
} PyObject;
小示例
import sys
import weakref
class Node:
def __init__(self, name):
self.name = name
self.child = None
def __repr__(self):
return f"Node({self.name!r})"
# 创建两个节点
a = Node('a') # refcnt = 1
b = Node('b') # refcnt = 1
# 弱引用 **不会** 增加引用计数
a_weak = weakref.ref(a)
print('a refcnt:', sys.getrefcount(a)) # 通常为 2(一个来自 getrefcount 参数的临时引用)
print('a weakref alive?', a_weak() is not None)
# 创建引用循环
a.child = b
b.child = a
print('a refcnt after cycle:', sys.getrefcount(a))
print('b refcnt after cycle:', sys.getrefcount(b))
# 打破强引用
del a
del b
# 循环仍然使对象的引用计数大于 0,因而对象仍然存活,
# 但垃圾回收器最终会将它们收集。
import gc
gc.collect()
sys.getrefcount()显示当前的引用计数(调用本身会临时增加一个引用)。- 弱引用 (
weakref.ref) 允许在不影响计数的情况下引用对象。 - 当创建引用循环 (
a.child = b; b.child = a) 时,引用计数永远达不到零,于是 CPython 的循环垃圾回收器会介入,打破循环并释放内存。
在下一节中我们将深入探讨该循环垃圾回收器的细节。
Source: …
引用计数与弱引用
import sys, weakref, gc
class Node:
child = None
a = Node()
b = Node()
b_ref = weakref.ref(b)
# 创建一个循环
a.child = b
b.child = a # 引用计数 = 2
print("All refs a, b, and a_ref assigned", f"{sys.getrefcount(a_ref())=}")
# 2(两个强引用)+ 1 用于传递给 getrefcount 的参数 = 3
del a
print("After variable a deleted", f"{sys.getrefcount(a_ref())=}") # 引用计数 = 1
del b
print("After both variables deleted", f"{sys.getrefcount(a_ref())=}")
# 引用计数 = 0,对象已释放 —— 但 `sys.getrefcount` 仍显示 1!
print("After both variables deleted", f"{sys.getrefcount(b_ref())=}")
# 同样的结果
发生了什么?
即使在删除 a 和 b 之后,弱引用仍然可以取回对象。
添加下面的代码可以查看还有哪些东西在引用 a:
import gc # 垃圾回收器接口模块
print(f"Referrers to a: {gc.get_referrers(a_ref())}")
输出显示了两个引用:
a.child → bb.child → a
虽然我们删除了变量 a,但属性 a.child 仍然存在,使得 b 仍然存活,反之亦然。
Python 如何处理这种循环?
Python 使用 标记‑清除(mark‑and‑sweep) 垃圾回收器,其工作方式如下:
- 从一组 根 对象(全局变量、局部变量等)开始。
- 递归标记所有可以从这些根对象到达的对象。
- 清除所有 未被标记(即不可达)的对象。
如果在每次赋值后都运行该算法,开销将会非常大,因此 CPython 会 周期性 地运行它,依据代(generation)阈值。
import gc
print(f"GC generations thresholds: {gc.get_threshold()}") # 默认: (700, 10, 10)
代(Generations)
| 代 | 描述 | 收集频率 |
|---|---|---|
| 0 | 新创建且尚未被扫描的对象。 | 每 700 次分配(默认) |
| 1 | 在第 0 代收集后仍然存活的对象。 | 每 10 次第 0 代收集后 |
| 2 | 在第 1 代收集后仍然存活的对象。 | 每 10 次第 1 代收集后 |
这遵循 代际假设:大多数对象“早逝”,而长寿对象往往会一直存活。
因为循环 a ↔ b 仍然位于第 0 代,当我们删除变量时,垃圾回收器尚未运行,所以弱引用仍然能够访问这些对象。
强制进行一次收集
import sys, weakref, gc
class Node:
child = None
a = Node()
b = Node()
b_ref = weakref.ref(b)
a.child = b
b.child = a
print("All refs a, b, and a_ref assigned", f"{sys.getrefcount(a_ref())=}")
del a
print("After variable a deleted", f"{sys.getrefcount(a_ref())=}")
del b
print("After both variables deleted", f"{sys.getrefcount(b_ref())=}")
# 强制收集第 0、1、2 代
n_collected = gc.collect(2)
print(f"Collected {n_collected} objects") # → 2 objects
GIL 在垃圾回收中的作用
全局解释器锁(GIL) 使得引用计数的更新是原子操作,从而保证了线程安全的垃圾回收。如果没有 GIL,引用计数的并发修改可能导致竞争条件、内存损坏,进而破坏回收器的工作。
希望你喜欢这次对 CPython 内存管理的深入探讨。理解引用计数、弱引用以及代际垃圾回收器的工作原理,有助于解释在编写 Python 代码时可能遇到的许多细微行为。