Python内存布局详解:从变量引用到垃圾回收,彻底搞懂栈、堆与对象池

Python内存布局详解:从变量引用到垃圾回收,彻底搞懂栈、堆与对象池

很多人第一次接触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 这些工具,你就知道它们各自是在补哪一块短板。先想清楚对象在哪、谁握着引用、计数怎么变化,再动手写代码或者调优,顺序反过来只会越调越焦虑。

相关推荐

属虎的为什么叫寅虎 了解寅虎的来历介绍
曾被小米们倚重,“真全面屏”手机为何销声匿迹了?
移动网上办卡多久送到?你想了解的都在这了!