【Python】内存结构

在 Python 中,内存分配是一个高度分层的自动化过程。它的核心目标是:大对象直接找系统,小对象内部自己分,常用对象提前建好。 本地环境:

  • python: 3.12
  • 系统: mac 64位(M2处理器)

Python 内存分配金字塔架构(python3.12)

text
+-------------------------------------------------------------+

|               第 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)来高效切分、吐出内存:

text
+-----------------------------------------------------------------------+

|  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 的使用状态原原本本地打印到控制台。

python
import sys

# 打印当前底层的 Pymalloc 内存池状态(包含 Arena 详细统计)
sys._debugmallocstats()

text
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 的巨额开销
text
# 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 结构体头部开销,我们通过精确计算来做实验:

python
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)} 字节")

运行结果

text
小对象 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内的数据 示例:

python
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}")

运行结果

text
小对象 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 个字节的脏数据虽然物理上还躺在内存里,但在账面上它们已经变成了“不可见的虚无空气”。因此,新变量永远不可能越界读到它们。
地址复用
python
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("❌ 地址不同,说明此时后台有隐形噪音插队抢走了格子。")

运行结果

text
============ 物理实验开始 ============
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,内存仍然存在,数据也存在,证明示例:

python
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("❌ 地址不同,说明此时环境仍有极强干扰。")

运行结果

text
============ 物理实验开始 ============
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'哪去了?还是直接没有了?