【Python】内存结构
在 Python 中,内存分配是一个高度分层的自动化过程。它的核心目标是:大对象直接找系统,小对象内部自己分,常用对象提前建好。 本地环境:
- python: 3.12
- 系统: mac 64位(M2处理器)
Python 内存分配金字塔架构(python3.12)
+-------------------------------------------------------------+
| 第 3 层:用户应用层 (User Application) |
| * 你的 Python 代码、各种第三方库(如 NumPy、Django) |
+-------------------------------------------------------------+
|
v (创建对象请求)
+-------------------------------------------------------------+
| 第 2 层:Python 对象分配器 (Object Allocator) |
| * 针对具体类型(如 PyList_New, PyDict_New)的高层分配机制 |
+-------------------------------------------------------------+
| (判断大小:≤ 512B) | (判断大小:> 512B)
v v
+-----------------------------------+ +----------------------+
| 第 1 层:专有分配器 (Pymalloc) | | 第 0 层:C库分配器 |
| * 统一向系统申请 1M 的 Arena | | * 直接调用标准 C 库 |
| * 内部切分成 16KB Pool 和小 Block(32中规格) | | 的 malloc() 和 |
| * 高效处理微小内存,防止碎片化 | | free() |
+-----------------------------------+ +----------------------+
| |
+------------+-------------+
|
v
+-------------------------------------------------------------+
| 最底层:操作系统内核与虚拟内存 (OS & Hardware) |
| * 物理内存(RAM)管理、虚拟页面映射 |
+-------------------------------------------------------------+
Pymalloc 内存池内部管理结构(≤ 512 字节)
当对象小于或等于 512 字节时,第 1 层的 Pymalloc 会使用以下三级结构(Arena -> Pool -> Block)来高效切分、吐出内存:
+-----------------------------------------------------------------------+
| Arena (堆场,大小为 1M) |
| * Python 直接向系统申请的大块连续物理内存空间 |
| |
| +-----------------------------+ +-----------------------------+ |
| | Pool A (内存池,大小为 16 KB) | | Pool B (内存池,大小为 32 KB) | |
| | * 专门管理 【16字节】 的块 | | * 专门管理 【32字节】 的块 | |
| | | | | |
| | +-----+ +-----+ +-----+ +-----+ | | +-----+ +-----+ +-----+ +-----+ | |
| | | 16B | | 16B | | 16B | | 16B | | | | 32B | | 32B | | 32B | | 32B | | |
| | | Blk | | Blk | | Blk | | Blk | | | | Blk | | Blk | | Blk | | Blk | | |
| | +-----+ +-----+ +-----+ +-----+ | | +-----+ +-----+ +-----+ +-----+ | |
+-----------------------------------------------------------------------+
- 按需分流:当你在 Python 中新建变量,第 2 层会计算它需要的字节数。如果大于 512 字节(大对象),右侧通道打开,直接由 第 0 层(OS / C 库) 分配;如果是小对象,左侧通道打开,交由 第 1 层(Pymalloc)。
- 同规格聚合:Pymalloc 会把 4 KB 的 Pool 打上规格标签(比如 Pool A 贴上 16B 标签)。该 Pool 内部所有的 Block 就会被整齐切分为 16 字节。当程序需要 16 字节内存时,直接从 Pool A 中吐出一个 Block,省去了反复向系统申请的时间。
Arena详情
在 Python(CPython)启动并到达交互式命令行(REPL)提示符的那一刻,系统内部通常已经分配一定数量的 Arena。 Python 专门提供了一个隐藏的内置函数,可以把当前内存中 Arena 的数量、Pool 的分配详情和 Block 的使用状态原原本本地打印到控制台。
import sys
# 打印当前底层的 Pymalloc 内存池状态(包含 Arena 详细统计)
sys._debugmallocstats()
Small block threshold = 512, in 32 size classes.
class size num pools blocks in use avail blocks
----- ---- --------- ------------- ------------
0 16 1 55 966
1 32 2 676 344
2 48 12 3906 174
3 64 28 6969 171
4 80 22 4329 159
5 96 4 536 144
6 112 2 241 49
7 128 4 475 33
8 144 1 86 27
9 160 14 1355 73
10 176 1 80 12
11 192 1 48 37
12 208 4 253 59
13 224 4 230 58
14 240 2 124 12
15 256 3 157 32
16 272 4 180 60
17 288 2 98 14
18 304 1 49 4
19 320 1 49 2
20 336 1 46 2
21 352 1 25 21
22 368 1 29 15
23 384 1 16 26
24 400 4 141 19
25 416 1 25 14
26 432 1 16 21
27 448 1 24 12
28 464 1 30 5
29 480 1 10 24
30 496 1 14 18
31 512 2 36 26
# arenas allocated total = 3
# arenas reclaimed = 0
# arenas highwater mark = 3
# arenas allocated current = 3
3 arenas * 1048576 bytes/arena = 3,145,728
# bytes in allocated blocks = 1,845,552
# bytes in available blocks = 252,864
63 unused pools * 16384 bytes = 1,032,192
# bytes lost to pool headers = 6,192
# bytes lost to quantization = 8,928
# bytes lost to arena alignment = 0
Total = 3,145,728
arena map counts
# arena map mid nodes = 1
# arena map bot nodes = 1
# bytes lost to arena map root = 262,144
# bytes lost to arena map mid = 262,144
# bytes lost to arena map bot = 131,072
Total = 655,360
11 free PyDictObjects * 48 bytes each = 528
6 free PyFloatObjects * 24 bytes each = 144
63 free PyListObjects * 40 bytes each = 2,520
5 free 1-sized PyTupleObjects * 32 bytes each = 160
67 free 2-sized PyTupleObjects * 40 bytes each = 2,680
2 free 3-sized PyTupleObjects * 48 bytes each = 96
4 free 4-sized PyTupleObjects * 56 bytes each = 224
5 free 5-sized PyTupleObjects * 64 bytes each = 320
14 free 6-sized PyTupleObjects * 72 bytes each = 1,008
5 free 7-sized PyTupleObjects * 80 bytes each = 400
0 free 8-sized PyTupleObjects * 88 bytes each = 0
2 free 9-sized PyTupleObjects * 96 bytes each = 192
1 free 10-sized PyTupleObjects * 104 bytes each = 104
1 free 11-sized PyTupleObjects * 112 bytes each = 112
4 free 12-sized PyTupleObjects * 120 bytes each = 480
1 free 13-sized PyTupleObjects * 128 bytes each = 128
6 free 14-sized PyTupleObjects * 136 bytes each = 816
1 free 15-sized PyTupleObjects * 144 bytes each = 144
1 free 16-sized PyTupleObjects * 152 bytes each = 152
1 free 17-sized PyTupleObjects * 160 bytes each = 160
1 free 18-sized PyTupleObjects * 168 bytes each = 168
2 free 19-sized PyTupleObjects * 176 bytes each = 352
0 free 20-sized PyTupleObjects * 184 bytes each = 0
- 3 arenas * 1048576 bytes/arena = 3,145,728(1.84 MB)
解释器一共向操作系统申请了 3 个 Arena,总共占用了 3 MB 的虚拟内存空间。
- bytes in allocated blocks=1,845,552 当前内存中那些用来存数字、文本、列表等正在活跃的对象,实际占用了 1,845,552 字节
- bytes in available blocks=252,864(25.2 KB) 在已经开辟的池子(Pool)里,还有一些零散的、没分配出去的空闲 Block(块)。
- 63 unused pools * 16384 bytes=1,032,192(约 1 MB)
- 损耗(Headers & Quantization):约 15 KB
- pool headers = 6,192:每个 Pool 头部的管理指针消耗。
- quantization = 8,928:内存对齐、字节规整化导致的微小碎片。
3 个 1 MB 的 Arena 理论上总共可以切出 192 个 Pool (3 × 1024 / 16)。其中大部分规格的 Pool 还没被贴上标签(没被使用)。这 63 个闲置的 16 KB 池子,是 Python 预留给未来新规格对象的“弹药库”,占了 1.03 MB。
Arena Map 的巨额开销
# bytes lost to arena map root = 262,144
# bytes lost to arena map mid = 262,144
# bytes lost to arena map bot = 131,072
Total = 655,360 (共 640 KB)
- 这是什么:这是 Python 用来管理所有 Arena 地址映射的一颗 Radix Tree(基数树)。
- 为什么存在:当垃圾回收器或者指针需要查找一个对象到底属于哪一个 Arena、哪一个 Pool 时,必须依赖这个 Map 进行极速路由(类似于路由器的 IP 路由表)。
Free Lists(空闲链表)
日志的最后一段展示了 Python 另一项极致的优化: 11 free PyDictObjects * 48 bytes each = 528 63 free PyListObjects * 40 bytes each = 2,520
- 机制揭秘:当你在代码里销毁(del)一个字典、列表或元组时,Python 并不会真的把它们的 C 语言结构体内存释放掉。
- 做法:它会把这些已经死掉的空壳(比如这 11 个 PyDictObject 头结构)挂到一个叫 Free List(空闲链表) 的钩子上。
- 效果:当你下一次在代码里写 new_dict = {} 时,Python 连 Pymalloc 分配器都不找了,直接从钩子上把这 48 字节的空壳拿下来复用。这也就是为什么频繁创建、销毁空列表/空字典在 Python 里速度极快的原因!
表头含义拆解
- class: 规格编号,当前 Python 版本的 Pymalloc内存池机制下,class(规格编号)的最大值就是 31
- size: 内存块大小(单位:字节 Byte)代表当前级别下,一个 Block 占据多大空间。最小16字节
- num pools: 已开辟的内存池数量针对该规格,Python 当前向 Arena 申请了几个 Pool。
- blocks in use: 正在使用的内存块数量
- avail blocks: 空闲、可用的内存块数量
512字节门槛
我们可以创建两个刚好跨越 512 字节门槛的原始字节串(Bytes)。由于 bytes 对象有 33 (sys.getsizeof(b"")打印33)字节的固定 C 结构体头部开销,我们通过精确计算来做实验:
import time
# 用当前时间戳,确保 A、B、C 内部的数据字节完全不同,编译器绝不可能复用
unique_suffix_A = bytes([int(time.time()) % 250])
unique_suffix_B = bytes([int(time.time() + 1) % 250])
unique_suffix_C = bytes([int(time.time() + 2) % 250])
# 1. 独立的小对象 A(400字节,彼此不同)
obj_small_A = b'a' * 399 + unique_suffix_A
addr_A = id(obj_small_A)
# 2. 独立的大对象 B(500字节,明确跨越512)
obj_big_B = b'b' * 499 + unique_suffix_B
addr_B = id(obj_big_B)
# 3. 独立的小对象 C(400字节,彼此不同)
obj_small_C = b'c' * 399 + unique_suffix_C
addr_C = id(obj_small_C)
print(f"小对象 A 地址: {addr_A}")
print(f"大对象 B 地址: {addr_B}")
print(f"小对象 C 地址: {addr_C}")
print("-" * 40)
print(f"真正独立申请的 A 和 C 差值: {abs(addr_A - addr_C)} 字节")
print(f"独立申请的 A 和 B 差值: {abs(addr_A - addr_B)} 字节")
运行结果
小对象 A 地址: 4351846960
大对象 B 地址: 4427319440
小对象 C 地址: 4351847408
----------------------------------------
真正独立申请的 A 和 C 差值: 448 字节
独立申请的 A 和 B 差值: 75472480 字节
由于33字节头部,最终: A:433字节 B:533字节 C:433字节
- 根据规格:31号最大就是512字节,A与C能容纳的最近的就是27号规格(size为448)能容纳,而且只有pool池,所以A与C刚好相差448字节(因为id是10进制地址)
- A与C相差75472480 字节,大概75M,arena总共3M,B肯定不在arena区
是否真的销毁内存
针对小于512字节在arena内的数据 示例:
import ctypes
import sys
# 1. 创建一个小对象,内部存入极具特征的数据:b"SECRET_12345"
# 加上 33 字节头,总大小在 512 字节以内,走 Pymalloc
spy_obj = b"SECRET_12345"
target_address = id(spy_obj)
data_length = len(spy_obj)
print(f"小对象 A 存活时的物理地址: {target_address}")
print(f"小对象 A 存活时的数据净重: {data_length} 字节")
print("-" * 50)
# 2. 账面销毁小对象 A
# 执行后,A 的引用计数归零,Pymalloc 账本将该 Block 标为“空闲”
del spy_obj
# 3. 【核心物理证明】:对象已经死了,我们直接用 ctypes 强行读取这个绝对地址
# 我们从该物理地址往后偏移 33 字节(绕过已经被注销的对象头),直接读取数据区
raw_memory_reader = (ctypes.c_char * data_length).from_address(target_address + 33)
print("🚨 强行窥探已被销毁的内存块底层:")
print(f"残留在原地的脏数据为: {raw_memory_reader.value}")
运行结果
小对象 A 存活时的物理地址: 4344282832
小对象 A 存活时的数据净重: 12 字节
--------------------------------------------------
🚨 强行窥探已被销毁的内存块底层:
残留在原地的脏数据为: b'ECRET_12345'
我已经"del spy_obj"删除对象spy_obj引用,但是通过地址还是拿到了数据,说明内存没有销毁,数据没有擦拭。
针对上面测试,不禁会问这样一个问题,既然针对小于512字节的数据不会销毁内存,也不会擦拭数据,那是不是重新申请了一个变量刚好分配到这里,是不是可能读取到脏数据? Python(CPython)在底层通过:
- 明确的长度截断(不依赖“默认值”清零) 新对象在入住这个存有脏数据的 Block 时,Python 并不需要先把整个房间用 0 擦拭干净。它采用的是一种更高效的物理控制方法:用长度进行死死卡位。 假设这个 Block 原来存了 10 个字节的脏数据:[S, E, C, R, E, T, 1, 2, 3, 4]。现在你创建了一个只有 3 个字节的新变量 b"abc" 住进了这个房间。
- memcpy 执行后,房间的前 3 个字节被强行覆盖成了 [a, b, c]。
- 房间里剩下的 7 个字节其实依然是旧的脏数据:[1, 2, 3, 4]。
- 但是! 此时 Python 已经在头部把这个对象的长度锁死成了 3。
- 当你在 Python 代码里打印或使用这个新变量时,Python 内部的读取器只会严格读取前 3 个字节。后面那 7 个字节的脏数据虽然物理上还躺在内存里,但在账面上它们已经变成了“不可见的虚无空气”。因此,新变量永远不可能越界读到它们。
地址复用
import sys
# 33字节Bytes对象头 + 400字节数据 = 433字节
# 按照 Pymalloc 规则,433字节必须放进 448 字节(Class 27 规格)的格子
ROOM_SIZE = 400
print("============ 物理实验开始 ============")
# 1. 申请第一个 448 字节的格子,给前任 a
a = b"A" * ROOM_SIZE
addr_A = id(a)
print(f"1. 【前任 a】占用的 448 字节格子物理地址: {addr_A}")
# 2. 核心步骤:彻底销毁 a(退房)
# 执行后,这个 448 字节的格子立刻变为空闲,并被挂在 Class 26 空闲链表的最头部
del a
# 3. 关键一步:立刻申请一个新变量 b
# 它的内容和 a 完全不同,但我们通过精准控制长度(同样是 33 + 400 = 433字节),
# 确保它向 Pymalloc 要的房间尺寸同样不多不少是 448 字节!
b = b"B" * ROOM_SIZE
addr_B = id(b)
print(f"2. 【新变量 b】拿到的新变量物理地址 : {addr_B}")
print("-" * 55)
# 4. 计算两个门牌号的物理绝对差值
address_diff = abs(addr_A - addr_B)
print(f"【最终结论】两个变量的地址差值: {address_diff} 字节")
if address_diff == 0:
print("🔥 铁证如山:新变量 b 精准放进了前任 a 刚刚 del 腾出来的同一个格子里!")
else:
print("❌ 地址不同,说明此时后台有隐形噪音插队抢走了格子。")
运行结果
============ 物理实验开始 ============
1. 【前任 a】占用的 448 字节格子物理地址: 4343867952
2. 【新变量 b】拿到的新变量物理地址 : 4343867952
-------------------------------------------------------
【最终结论】两个变量的地址差值: 0 字节
🔥 铁证如山:新变量 b 精准放进了前任 a 刚刚 del 腾出来的同一个格子里
结果表明a与b的地址复用了,a变量声明后再删除,a删除后声明了b,结果b的地址就是a。 这是因为:
- a的大小为433,b的大小为433。这个规格有27号的448符合
- a申请后落到448规格的pool,这个pool只有一个
- a取消引用,a占用的block空出来了
- b根据规格也会用到同一个pool,由于a的空出来了,b就用到了这个空间
实际多线程多进程环境,a的block空出来后可能很快会被占用,而且可能有多个448规格的pool,b就不一定会落到a空出来的block,这里用了个巧。
这里a,b都是字符串,是否与字符串声明过程矛盾呢? 其实没有的,b在创建字符串时,发现这个字符串在内存中是没有的,于是申请空间,由于b需要的空间为433,刚好会选取到唯一的pool,这个pool在本测试环境刚好唯一,就只能这个pool,而a刚刚让出空间(del a),就把这个空间给了b。
脏数据
上面我们说过删除一个变量,会吧变量引用-1,内存仍然存在,数据也存在,证明示例:
import ctypes
import sys
# 完全保留你的两个独立长度变量
ROOM_SIZE = 405 # 前任 33 + 405 = 438 字节 -> 占用 448 字节格子
ROOM_SIZE_B = 400 # 现任 33 + 400 = 433 字节 -> 同样占用 448 字节格子
print("============ 物理实验开始 ============")
# 1. 申请第一个 448 字节的格子,给前任 a
# 我们在 405 字节数据的最末尾,精准埋下 8 字节特征字符 b"FOUND_IT"
secret_tail = b"FOUND_IT"
a = b"A" * (ROOM_SIZE - len(secret_tail)) + secret_tail
addr_A = id(a)
print(f"1. 【前任 a】占用的 448 字节格子物理地址: {addr_A}")
# 2. 核心步骤:彻底销毁 a(退房)
# 执行后,这个 448 字节的格子立刻变为空闲,并被挂在 Class 27 空闲链表的最头部
del a
# 3. 关键一步:立刻申请一个新变量 b
# 它的内容和 a 完全不同(全是 "B"),但房间尺寸同样不多不少是 448 字节!
b = b"B" * ROOM_SIZE_B
addr_B = id(b)
print(f"2. 【新变量 b】拿到的新变量物理地址 : {addr_B}")
print("-" * 55)
# 4. 计算两个门牌号的物理绝对差值
address_diff = abs(addr_A - addr_B)
print(f"【最终结论】两个变量的地址差值: {address_diff} 字节")
if address_diff == 0:
print("🔥 铁证如山:新变量 b 精准放进了前任 a 刚刚 del 腾出来的同一个格子里!")
print("-" * 55)
# =================================================================
# 【核心实现:通过偏移打印上任脏数据】
# 现任 b 连头带尾加上它的 \0 结束符,总共只写到了第 434 字节。
# 强行去读取【第 432 到第 440 字节】这 8 字节前任写过
# steal_offset是偏移量,也是索引
steal_offset = 431
spy_length = len(secret_tail) # 精准提取 8 字节
# 1. 强行在指定物理地址映射为一个原始 C 语言字节数组
raw_array = (ctypes.c_char * spy_length).from_address(addr_B + steal_offset)
# 2. 通过切片 [:] 强制转换成原生 bytes 打印,彻底绕过 \0 提前截断的问题
raw_bytes = raw_array[:]
print(f"现任 b 账面合法的纯净数据(截取后段): ...{b[-10:]}")
print(f"🚨 【打捞成功】在绝对原位的内存盲区里,成功抓获上任未擦拭脏数据: ")
print(f"原始物理字节内容 : {raw_bytes}")
else:
print("❌ 地址不同,说明此时环境仍有极强干扰。")
运行结果
============ 物理实验开始 ============
1. 【前任 a】占用的 448 字节格子物理地址: 4311706608
2. 【新变量 b】拿到的新变量物理地址 : 4311706608
-------------------------------------------------------
【最终结论】两个变量的地址差值: 0 字节
🔥 铁证如山:新变量 b 精准放进了前任 a 刚刚 del 腾出来的同一个格子里!
-------------------------------------------------------
现任 b 账面合法的纯净数据(截取后段): ...b'BBBBBBBBBB'
🚨 【打捞成功】在绝对原位的内存盲区里,成功抓获上任未擦拭脏数据:
原始物理字节内容 : b'B\x00D_IT\x00\x00'
我们拿到了a残余的脏数据"D_IT",按道理应该是"ND_IT",因为a比b多5个字节,但是b后面会有一个'\0'字符,把'N'覆盖了,最终只拿到了没有覆盖的4个。
注:这段代码可能在你的环境运行不起来
最后留两个问题:
- 是不是字符串在内存中存储在末尾是否真的会添加'\0',如果有,这个是谁加的?
- 申请刚好大小为size大小的数据,他是放在刚好这个大小的block里还是往下一个更大的block放?如果是前者,'\0'哪去了?还是直接没有了?