很多人第一次接触Python内存布局,是被一道面试题给问懵的:
PYTHON
复制
1
a = [1, 2, 3]
2
b = [1, 2, 3]
3
4
print(a == b) # True
5
print(a is b) # False
== 比较的是值,is 比较的是“是不是同一个东西”。这个结论背下来很容易,但如果你没真正理解 Python 内存布局,后面写代码照样会踩坑——变量赋值“复制”了个寂寞、列表当参数传进函数被改了、del 之后内存还迟迟不释放……这些全是同一个底层问题。
这篇文章我想从内存布局讲起,用生活化的类比把 CPython 的栈、堆、小整数对象池、引用计数、循环引用、垃圾回收这些概念一次讲透。面向的是刚入门 Python 或者学了一段时间但始终觉得“脑子里有层雾”的朋友,我也会穿插一些实际排查内存问题的经验和代码片段,希望能帮你省掉自己踩坑的时间。
1. 三个区域与一张“名片”:先搞懂变量到底存了什么
1.1 变量不是盒子,是一张写着门牌号的纸条
新手最容易犯的一个认知错误,是把变量想象成一个“盒子”,a = 10 就是往盒子里装了数字 10,以后 a 就代表这个盒子。
Python 里不是这么干的。a = 10 真正做的事情是:先在内存里某个位置建好整数 10 这个对象,然后让名字 a 指向它。a 这个名字本身只是一个引用,记录的其实是“那个对象住在哪”的地址信息。
我用一个类比帮自己彻底转过弯来:
把对象想象成一个真正的箱子,箱子放在仓库(堆内存)里,每个箱子有一个编号。变量名不是箱子,而是一张写着编号的纸条,贴在你自己桌上(栈内存)或者笔记本里(函数帧)。
你操作 a 的时候,Python 会根据纸条上的编号去仓库找到那个箱子,再对箱子做操作。所以:
b = a 不是把箱子搬到 b 那里,而是又写了一纸条,写了同一个编号。
a = 10; a = 20 不是“10 变成 20”,而是把 a 这张纸条换成了指向 20 那个箱子的新纸条,原来的 10 箱子没人引用,变成垃圾,等待回收。
这个认知是整个内存布局的地基,后面所有内容都从这里长出来。
1.2 栈、堆、静态区的分工
CPython 进程的内存大致分为三个区域,理解它们各管什么,很多问题就迎刃而解:
区域
存什么
生活类比
栈(Stack)
函数调用帧、局部变量名、临时引用
书桌上的便签,记录了你要找的箱子编号
堆(Heap)
真正的对象数据:整数、字符串、列表、字典、实例等
仓库里的箱子,数据本体全部住这
静态区/常量池
小整数缓存、驻留字符串、模块级常量、代码对象
仓库里一个“常用件专区”,高频用的小东西直接放这
特别强调一下:Python 是“万物皆对象”,所以基本类型 int、str 的实例也是对象,也分配在堆上。栈上永远只放引用和调用帧,不放对象本体。这也是为什么 Python 里函数调用开销比 C 大——每次封装一个栈帧,对象还得在堆上再转一圈。
用 id() 可以看到对象在 CPython 中的内存地址,这个函数在排查问题时特别有用。两个引用指向同一个地址,说明它们持有的是同一个对象。
1.3 怎么验证:id、is 与 == 的区别
建议亲自跑一遍下面几行,把印象刻进脑子:
PYTHON
复制
1
x = [1, 2]
2
y = x # 两张纸条,同一个箱子
3
z = [1, 2] # 新箱子,内容和 x 一样
4
5
print(x == y) # True 值一样
6
print(x is y) # True 同一个对象(同一张编号)
7
print(x == z) # True 值一样
8
print(x is z) # False 不同对象
is 的实现本质上就是比较两个对象的地址是否相同,在 CPython 里就是比较 id() 。而 == 默认会调用对象的 __eq__ 方法,很多时候会退化为比较地址,但对于 list、str 这些类型,会逐元素比较内容。
判断“两个引用是不是指向同一个对象”,必须用 is;判断“内容是否相等”,用 ==。尤其是处理 None 的时候,规范写法永远是 if x is None:,因为 None 是全局单例,用 is 既快又安全。
2. 小整数缓存和字符串驻留:为什么 256 和 257 待遇不一样
2.1 小整数对象池:[-5, 256] 的秘密
在交互式环境里跑一下:
PYTHON
复制
1
a = 256
2
b = 256
3
print(a is b) # True
4
5
c = 257
6
d = 257
7
print(c is d) # False(交互式环境下通常如此)
为什么 256 就 is,257 就 is 不了?这不是随机行为,而是 CPython 的一个经典优化:启动时,解释器会预先创建 -5 到 256 之间所有整数对象,放进一个小整数对象池。因为这段范围内的整数在代码里太常用了——循环计数 i、下标 index、状态码——每次都新建对象很浪费。
所以当你写 a = 256 时,解释器直接从池子里把预建好的对象地址交给 a;而 257 不在池子范围内,就得现算一个新建出来,d = 257 又新建一个,两个自然是不同对象。
这个设计让 Python 里的“小整数运算”非常快,但代价是你必须记住:不要依赖小整数池范围来做业务判断,它只是一个 CPython 的实现细节。如果你真的需要判断两个整数是不是同一个对象,is 在跨版本、跨实现(PyPy、Jython)的行为都可能不同。
2.2 字符串驻留:被“省”出来的单例
字符串也有类似的“单例化”机制,叫字符串驻留。如果两个字符串内容相同且满足驻留条件,它们会指向同一个对象。
PYTHON
复制
1
s1 = "hello"
2
s2 = "hello"
3
print(s1 is s2) # True,因为 "hello" 被驻留
4
5
s3 = "".join(["hel", "lo"])
6
print(s1 is s3) # False,运行时拼接产生的新字符串没有驻留
粗略规则是:只含字母、数字、下划线,看起来像合法标识符的短字符串,更容易被驻留,而且发生在编译期。而运行时拼接、读取文件、网络解析出来的字符串,通常不会进驻留池。
这个机制用好了能省不少内存——比如解析大量 JSON 时,字段名("id"、"name"、"status")反复出现,如果能走驻留,这些字符串就都共享同一份数据。但别反着来:不要在业务代码里用 is 比较字符串内容,永远用 ==,驻留只是“碰巧省内存”的运气,不是语言承诺。
2.3 别把这些缓存当规则,尤其注意实现差异
这里必须泼一盆冷水:小整数池、字符串驻留都不是 Python 语言规范里的强制要求,只是 CPython 的典型实现。 在 PyPy、Jython 这类实现里,行为可能完全不同。
更坑的是,同一个实现里,缓存行为还受代码形态影响。比如在 .py 文件里写:
PYTHON
复制
1
a = 257
2
b = 257
3
print(a is b)
因为编译器把同一条作用域里的两个字面量优化成了同一个常量,结果可能是 True,但在交互式环境里就是 False。这个“不一致”非常迷惑人,所以我一直建议:这类缓存机制只需要知道“为什么存在”,不要写任何依赖它的代码。
真正要依赖的,是 is 对 None、True、False 的单例判定,这是 CPython 明确且稳定的行为。
3. 可变对象最容易踩的坑:共享引用、浅拷贝和函数传参
3.1 列表赋值“复制”了个寂寞
这是新手最常见的翻车现场:
PYTHON
复制
1
origin = [1, 2, [3, 4]]
2
backup = origin # 想留个备份
3
backup.append(5)
4
print(origin) # [1, 2, [3, 4], 5] 原列表也被改了!
原因就是第 1 节说的:backup = origin 只是复制了“写着编号的纸条”,两个纸条指向同一个列表对象。你通过 backup 修改列表,origin 当然跟着变。
这时候多数人会想到切片。origin[:] 确实会创建一个新列表,但它只是“浅拷贝”——第一层是新箱子,如果列表里装的是更深的可变对象,比如 [3, 4] 这个子列表,新老两个列表仍然共享它:
PYTHON
复制
1
origin = [1, 2, [3, 4]]
2
shallow = origin[:]
3
shallow[2].append(99)
4
print(origin) # [1, 2, [3, 4, 99]] 子列表还是同一个
这种一层套一层的行为,不实际跑一遍真的容易记错。我的经验是:只要看到“拷贝”,先问自己:我要拷贝哪一层? 全部层都要复制,就用 copy.deepcopy;只要最外层独立,就用 copy.copy 或切片;连拷贝都想要原地共享,就用等号赋值,但要清楚后续修改是双向的。
3.2 shallow vs deep:copy 模块到底在拷贝哪一层
copy 模块给了两个现成工具:
PYTHON
复制
1
import copy
2
3
origin = [1, 2, [3, 4]]
4
5
shallow = copy.copy(origin) # 外层独立,内层共享
6
deep = copy.deepcopy(origin) # 所有层全部独立
7
8
shallow[2].append(99)
9
deep[2].append(88)
10
11
print(origin) # [1, 2, [3, 4, 99]]
浅拷贝的“外独立、内共享”特性,在某些场景正好是我们要的,比如只需要给最外层加元素、不想整个图都复制一遍。深度拷贝则安全,但耗时间和内存,而且遇到自定义对象、包含文件句柄或数据库连接时,deepcopy 可能直接报错或复制出奇怪状态。我的建议是:
数据结构简单、只有一层:用切片或 copy.copy。
多层嵌套的纯数据(JSON 解析结果、配置字典):用 copy.deepcopy,图省心。
对象里有非纯数据资源:尽量避免深拷贝,考虑重新构建或放弃拷贝需求。
3.3 函数传参:默认参数里的隐藏炸弹
Python 函数传参是“传对象引用”。这意味着可变对象传入函数后,函数内部的修改会直接影响外部变量;而重新赋值 param = something_else 不会影响外部——因为那只是让参数名指向了新对象。
这个机制本身不坑,真正坑的是默认参数:
PYTHON
复制
1
def add_item(item, items=[]):
2
items.append(item)
3
return items
4
5
print(add_item(1)) # [1]
6
print(add_item(2)) # [1, 2] ?!
items=[] 这个列表在函数定义时就被创建了,之后每次调用,只要没传 items,用的都是同一个列表对象。你不会想让第一次调用的结果“长在”函数定义上。
正确写法是默认参数用 None,函数内再新建:
PYTHON
复制
1
def add_item(item, items=None):
2
if items is None:
3
items = []
4
items.append(item)
5
return items
顺带一提,这个“默认参数在定义时求值”的行为,也适用于理解其他 Python 的惰性求值陷阱,比如生成器、装饰器里的闭包变量,本质都是“引用到底指向哪个对象”的问题。
4. del 之后,对象并没有马上消失:引用计数与循环引用
4.1 每个对象身上都挂着一个计数器
给你看一个更颠覆直觉的场景:
PYTHON
复制
1
a = [1, 2, 3]
2
del a
3
print("执行完,进程还在")
del a 之后,a 这个名字没了,但那个列表对象真的立刻被销毁了吗?在 CPython 里,这取决于引用计数——每个对象内部都有一个 ob_refcnt 字段,记录当前有多少个地方引用它。当引用计数归零,解释器才真正释放对象。
del a 做的事情,只是把 a 这个引用从当前作用域里撕掉,然后让对象的引用计数减 1。如果还有 b = a 这个引用存在,哪怕删了 a,对象照样活着:
PYTHON
复制
1
a = [1, 2, 3]
2
b = a
3
del a
4
# 此时列表还在内存里,因为 b 还握着它的地址
5
print(b)
唯一需要注意的是,函数内部的局部变量会在函数退出时逐一清空引用,这也是为什么大量临时对象的生命周期往往集中在函数框架内。想查看一个对象的实时引用计数,可以用 sys.getrefcount:
PYTHON
复制
1
import sys
2
3
a = [1, 2, 3]
4
print(sys.getrefcount(a))
输出的值会比预期多 1,因为 getrefcount(a) 这个调用本身会让 a 的引用计数临时加一。正常现象,不用困惑。
4.2 循环引用:计数器永远数不清的死结
引用计数最尴尬的破绽是循环引用。看这个案例:
PYTHON
复制
1
class Node:
2
def __init__(self):
3
self.peer = None
4
5
a = Node()
6
b = Node()
7
a.peer = b
8
b.peer = a
9
10
del a
11
del b
两个对象互相持有对方的引用,外面已经没有任何名字能触达它们,但它们的引用计数都是 1——因为对方还引用着自己。引用计数算法无法把它们识别为垃圾,这部分内存就“锁死”了。
古人早就为此准备了后手。CPython 里除了引用计数,还存在一个循环垃圾收集器,专门负责寻找互相引用但已经不可达的对象,然后把它们回收。它按“分代”管理:对象被创建时进入第 0 代,经历过一次回收仍然存活,就被提升到第 1 代,再存活到第 2 代。回收频率随代数逐级降低,因为越能熬的垃圾越少。
默认情况下,CPython 在第 0 代对象积累到 700 个左右时触发一次收集,然后按配置依次轮动。绝大多数程序根本感觉不到它,但如果你处理大量带有相互引用的对象图,这个机制就在默默兜底。
4.3 gc 模块能做什么,不能做什么
gc 模块让你能手动干预这个回收过程:
PYTHON
复制
1
import gc
2
3
# 查看当前阈值
4
print(gc.get_threshold()) # (700, 10, 10)
5
6
# 手动触发一次全量回收
7
gc.collect()
8
9
# 打印当前被追踪的循环引用对象数量
10
print(len(gc.get_objects()))
理解 gc 的边界很重要:
它不能解决“外部还有强引用”造成的泄漏——比如你写了个全局 list,每处理一个任务就 append 一个大对象,用完忘了移除。这不是循环引用,引用计数也觉得一切正常,对象永远不会被回收。这种问题只能靠开发者在逻辑层面解决。
它可以解决循环引用,但需要对象“真正不可达”,也就是外部引用链全部断开。
用弱引用 weakref.ref 或 weakref.WeakValueDictionary,可以让你持有对象的同时不增加强引用计数,非常适合缓存、观察者模式这类“存在最好,不存在就算了”的场景。
我建议在长期运行的服务里,不要过度依赖 gc.collect() 手动回收,让它走默认阈值即可。频繁手动触发反而会拖慢吞吐,还可能把还没有晋升代的对象反复扫描。
5. 内存布局的实战价值:排查泄漏、压缩内存、选对工具
5.1 用 sys.getrefcount 和 weakref 揪出“被偷走的内存”
理论讲完,说点实际排查经验。
前几年我维护过一个常驻服务,每次处理完一批任务,memory 曲线就像断了线的风筝一样往上飘,几小时后就逼近容器上限。当时第一反应是怀疑循环引用,其实真正的问题是:有人写了一大坨业务代码,把所有任务结果都存到了一个模块级 CACHE 字典里,用完了也没删,导致每个结果对象被字典持续强引用,垃圾收集器拿它们毫无办法。
排查思路可以复现:
先看对象数量是否异常:gc.get_objects() 或 len(gc.get_objects()),持续监测。
对可疑对象用 sys.getrefcount(obj) 看引用计数,如果长期维持在高位且不降,基本可以确定有“游离的强引用”。
用 weakref.ref(obj, callback) 注册一个回调,当对象被回收时触发,验证生命周期结束点。
PYTHON
复制
1
import weakref
2
3
class Task:
4
pass
5
6
def on_collect(ref):
7
print("对象被回收了")
8
9
task = Task()
10
ref = weakref.ref(task, on_collect)
11
del task
12
# 很快就会看到 "对象被回收了"
如果 del 之后回调迟迟不触发,说明对象还被别的地方强引用着,顺着 gc.get_referrers(obj) 就能找到“揪住不放”的那只手。这个方法比瞎猜快得多。
5.2 slots:给海量实例“瘦身”
另一个常见需求是控内存,最典型的手段是给类加 __slots__。
普通 Python 实例内部默认有一个 __dict__ 字典,用来动态存储属性名和值。字典是散列表,为了支持增删查随机操作,开销相当大——每个属性键值对都要额外存储哈希、指针、空槽位,几十个字节起步。当你有十万个实例时,光是这些 __dict__ 就是一笔巨款。
__slots__ 的做法是:显式告诉解释器,这个类只允许这几个属性,于是实例不再创建 __dict__,属性改用紧凑的固定槽位数组存储:
PYTHON
复制
1
class Config:
2
__slots__ = ("name", "retries", "timeout")
3
4
def __init__(self, name, retries, timeout):
5
self.name = name
6
self.retries = retries
7
self.timeout = timeout
加了 __slots__ 之后,实例的 __dict__ 没有了,属性访问反而更快,通常每个实例能省下几十甚至上百字节。我测过十万个五字段对象,用 __slots__ 比普通类少占约 30% 内存——具体数值取决于属性名长度和字典负载因子,但方向很明确。
代价是:不能随便给实例增加新属性,不能跟 __dict__ 共存(除非显式把 '__dict__' 放进 __slots__),多继承时的行为也更绕。所以我的建议是:数据模型稳定、实例数量巨大(万级以上)的类才值得加 __slots__,业务临时小类没必要,写起来还难受。
5.3 tracemalloc 与 memory_profiler:定位内存大头
如果项目启动后内存就莫名高企,我推荐先上 tracemalloc,它是标准库内置的内存分析工具,不用装任何第三方包。
PYTHON
复制
1
import tracemalloc
2
3
tracemalloc.start()
4
5
# 正常业务代码,比如批量读取大文件、解析数据
6
data = []
7
for i in range(100000):
8
data.append({"idx": i, "payload": "x" * 100})
9
10
snapshot = tracemalloc.take_snapshot()
11
top_stats = snapshot.statistics("lineno")
12
13
for stat in top_stats[:10]:
14
print(stat)
这段代码会按“文件和行号”统计内存分配来源,直接告诉你哪一行代码创建了最多的对象、占了多少字节。用这个方法排查内存问题的效率比人肉猜高一个数量级。
需要整条时间线的走势,可以用第三方库 memory_profiler:
BASH
复制
1
pip install memory_profiler
PYTHON
复制
1
from memory_profiler import profile
2
3
@profile
4
def heavy_func():
5
arr = [0] * 1000000
6
return sum(arr)
7
8
if __name__ == "__main__":
9
heavy_func()
函数执行完会输出每一行的内存增量,非常直观。要注意 @profile 装饰器需要脚本运行,而不是交互式环境。
再提醒一个影响内存的隐藏因素:Python 的小对象被大量创建和销毁时,CPython 的内存分配器为了性能,并不会把内存立刻归还操作系统,而是放进自己的内存池里复用。所以即使你回收了成千上万个对象,从 Top 或容器监控看到的 RSS 可能依然很高。这是正常现象,不代表泄漏。判断泄漏的正确方式,是看持续运行、持续做同类操作之后内存是否单调上升,而不是看“一时半会儿不回降”。
我用 tracemalloc 做过一次实战排查:一个解析配置文件的工具每次运行都会多占 200MB,把 statistics("lineno") 打出来,发现 80% 的内存来自一行正则匹配——某个正则写得太贪心,.* 把一个几万行的原始串一口气抓进了内存。把正则改成非贪婪并分段匹配后,内存直接降到 30MB。这种问题靠肉眼看代码真的很难发现,但工具一照就现形。
如果项目里实例数量特别大,还可以配合 __slots__、array 模块、numpy 这类方案,把底层数据的存储方式从“对象数组”换成“紧凑数组”,内存差距可以达到一个数量级。比如存一百万个整数,用 Python 列表存的是指向整数对象的指针数组,每个整数对象本身还要再占一大坨;而 array('i') 或 numpy 数组直接把一百万个数连续存在一起,不创建任何独立整数对象。内存布局理解到这个程度,再谈优化才不会瞎做。
最后说点我自己的体会。Python 内存这块知识,难的不是某个概念,而是它们彼此咬合:不理解引用,就搞不懂拷贝;不理解缓存,就搞不懂 is;不理解引用计数,就搞不懂 del 为什么“不彻底”;不理解分代回收,就不知道循环引用由谁兜底。把这几个点串起来之后,再看 deepcopy、weakref、__slots__、tracemalloc 这些工具,你就知道它们各自是在补哪一块短板。先想清楚对象在哪、谁握着引用、计数怎么变化,再动手写代码或者调优,顺序反过来只会越调越焦虑。