第 2 期:变量背后是什么?理解 Python 的对象与内存模型
Python 变量不是装数据的盒子,而是指向对象的名称。很多看似奇怪的修改、复制和传参问题,都可以从这句话中找到答案。
写在前面
刚开始学习 Python 时,我们通常会把变量理解成一个容器:
user_name = "Alice"
这段代码似乎是把字符串 "Alice" 放进了变量 user_name。这种理解足以帮助我们学习语法,却无法解释下面的行为:
primary_users = ["Alice"]
backup_users = primary_users
backup_users.append("Bob")
print(primary_users)
运行结果不是 ["Alice"],而是:
["Alice", "Bob"]
如果 backup_users 是一份独立备份,修改它就不应该影响 primary_users。问题在于,赋值语句并没有复制列表,只是让两个名称关联到了同一个对象。
这类问题广泛存在于实际项目中:
- 修改函数参数后,调用方的数据也发生了变化。
- 使用字典模板生成记录,多条记录却共享同一个列表。
- 对配置执行了浅拷贝,修改嵌套字段时仍然污染原配置。
- 服务运行一段时间后内存持续增长,却找不到没有释放的变量。
- 删除对象后,操作系统看到的进程内存没有立即下降。
要解释这些现象,需要建立一套更准确的对象模型。
本文主要讨论 Python 语言层面的对象与引用,同时会说明 CPython 中的引用计数、垃圾回收和内存分配行为。CPython 是最常用的 Python 实现,但它的部分行为并不是 Python 语言规范的统一保证。
本文示例以 Python 3.10 及以上版本为基线,使用了内置集合泛型和 | 联合类型标注。
一、变量保存的究竟是什么
Python 中更准确的说法是:名称绑定到对象。
执行下面的赋值时:
user_names = ["Alice", "Bob"]
解释器主要完成两个动作:
- 创建一个列表对象。
- 将名称
user_names绑定到这个列表对象。
当另一个名称接收 user_names 时:
backup_names = user_names
不会自动创建新列表。此时的关系可以简化为:
user_names ─┐
├──> ["Alice", "Bob"]
backup_names ─┘
因此,通过任何一个名称修改列表,另一个名称再次访问时都会看到变化:
backup_names.append("Carol")
print(user_names)
# ["Alice", "Bob", "Carol"]
这里需要区分两个动作。
1. 修改对象
append() 改变了原列表对象的内容,没有让名称指向其他对象。
2. 重新绑定名称
重新赋值会让名称绑定到另一个对象:
backup_names = ["David"]
print(user_names)
# ["Alice", "Bob", "Carol"]
现在两个名称指向不同的列表。重新绑定 backup_names 不会改变 user_names 的绑定关系,也不会修改原列表。
理解“修改对象”和“重新绑定名称”的区别,是分析 Python 数据变化问题的第一步。
二、对象、引用与 id() 的关系
Python 中的对象通常具有三个需要关注的特征:
- 身份:用于区分两个对象,可以通过
id()观察。 - 类型:决定对象支持哪些操作,可以通过
type()获取。 - 值:对象所承载的数据。
可以通过下面的代码观察两个名称是否绑定到同一个对象:
primary_users = ["Alice"]
backup_users = primary_users
print(id(primary_users))
print(id(backup_users))
print(primary_users is backup_users)
两个 id() 在对象存活期间相同,is 的结果也是 True。
需要注意:
is判断的是对象身份是否相同。==判断的是对象值是否相等。
left = ["Alice"]
right = ["Alice"]
print(left == right) # True
print(left is right) # False
两个列表的值相同,但它们是两个独立对象。
业务代码通常使用 == 比较值。is 最常见且明确的用途,是判断一个值是否为 None:
if result is None:
...
不要依赖小整数或短字符串可能出现的对象复用行为,也不要使用 is 代替普通值比较。解释器可以对部分不可变对象进行缓存或驻留,这属于实现优化,不应该成为业务逻辑的前提。
在 CPython 中,id() 通常与对象的内存地址相关;从 Python 语言层面看,只能保证对象存活期间 id() 唯一。对象被销毁后,原来的 id() 可能被后续对象重新使用。
三、可变对象与不可变对象
对象创建后能否改变自身内容,是理解引用行为的另一个关键。
常见的不可变对象包括:
- 整数、浮点数和布尔值。
- 字符串和字节串。
- 元组。
frozenset。
常见的可变对象包括:
- 列表。
- 字典。
- 集合。
- 大多数自定义类实例。
1. 不可变对象的“修改”实际上创建了新对象
retry_count = 3
original_id = id(retry_count)
retry_count += 1
print(original_id == id(retry_count))
# False
整数对象不能原地改变。retry_count += 1 计算出新的整数对象,再将名称 retry_count 绑定到新对象。
字符串拼接也遵循类似思路:
message = "task"
message += " completed"
从语言语义看,字符串本身没有被修改,message 最终绑定到了一个新字符串。
2. 可变对象可以在原对象上修改
tasks = ["extract"]
original_id = id(tasks)
tasks.append("load")
print(original_id == id(tasks))
# True
append() 修改了原列表,因此对象身份没有变化。
对于内置列表,+= 通常也会原地扩展:
tasks = ["extract"]
alias = tasks
tasks += ["load"]
print(alias)
# ["extract", "load"]
这与整数、字符串等不可变对象的 += 行为不同。面对不熟悉的自定义类型时,还要查看它是否实现了原地操作协议,不能只根据运算符外观判断。
3. 元组不可变,不代表它引用的对象都不可变
pipeline = ("daily", ["extract"])
pipeline[1].append("load")
print(pipeline)
# ("daily", ["extract", "load"])
元组不能替换自己的元素,但第二个元素绑定的是一个列表对象。修改这个列表,并没有改变元组保存的引用关系。
因此,“不可变”描述的是对象自身结构,而不是从它出发能够访问到的所有对象。
四、赋值、浅拷贝与深拷贝
当业务需要一份独立数据时,单纯赋值通常不够。Python 中需要区分三种情况。
1. 赋值:不创建新容器
source = {"tags": ["python"]}
target = source
print(source is target)
# True
source 和 target 指向同一个字典。
2. 浅拷贝:复制外层容器
source = {"tags": ["python"]}
target = source.copy()
print(source is target)
print(source["tags"] is target["tags"])
外层字典已经不同,但嵌套的列表仍然是同一个对象:
False
True
此时修改嵌套列表仍会影响原数据:
target["tags"].append("memory")
print(source)
# {"tags": ["python", "memory"]}
列表切片、list()、dict() 以及 copy.copy() 通常也只完成浅拷贝。它们会创建新的外层容器,但不会递归复制其中引用的所有对象。
3. 深拷贝:递归复制对象图
from copy import deepcopy
source = {"tags": ["python"]}
target = deepcopy(source)
target["tags"].append("memory")
print(source)
# {"tags": ["python"]}
deepcopy() 会递归处理嵌套对象,并使用内部备忘机制处理共享引用和循环引用。
但深拷贝不是默认最优解:
- 大型对象图的复制会消耗时间和内存。
- 文件句柄、网络连接、锁等资源对象不适合直接复制。
- 自定义对象可能需要实现特定复制逻辑。
- 原数据中的共享关系可能具有业务含义,盲目复制会改变语义。
更稳妥的做法,是先明确哪些数据需要隔离。如果只需要修改一个嵌套字段,可以定向创建新对象:
source = {"name": "task", "tags": ["python"]}
target = {
**source,
"tags": [*source["tags"], "memory"],
}
这种方式明确表达了复制范围,也避免了对整个对象图执行不必要的深拷贝。
五、函数参数是如何传递的
Python 的函数调用可以理解为:形参名称绑定到调用方传入的对象。这种模型也常被称为对象共享传递。
先看修改可变对象的情况:
def add_task(tasks: list[str]) -> None:
tasks.append("load")
pipeline_tasks = ["extract"]
add_task(pipeline_tasks)
print(pipeline_tasks)
输出为:
["extract", "load"]
函数内的 tasks 和调用方的 pipeline_tasks 在调用期间指向同一个列表。append() 修改了该对象,所以调用方能够看到变化。
如果函数只是重新绑定形参,调用方不会受到影响:
def replace_tasks(tasks: list[str]) -> None:
tasks = ["load"]
pipeline_tasks = ["extract"]
replace_tasks(pipeline_tasks)
print(pipeline_tasks)
输出仍然是:
["extract"]
tasks = ["load"] 只改变了函数局部名称的绑定,没有改变调用方名称,也没有修改原列表。
设计函数时,应该明确它是否允许修改传入对象:
- 如果函数职责是原地更新,应通过命名、类型和文档说明副作用。
- 如果调用方需要保留原值,应返回新对象,或者在边界处进行必要复制。
- 不要为了“绝对安全”无条件深拷贝所有参数,这会掩盖所有权设计问题并增加开销。
六、可变默认参数为什么会污染数据
默认参数在函数定义执行时求值,而不是每次调用函数时重新求值。
下面的函数只会创建一次默认列表:
def collect_event(event: str, events: list[str] = []) -> list[str]:
events.append(event)
return events
print(collect_event("started"))
print(collect_event("completed"))
第二次调用会继续使用第一次调用时的列表:
["started"]
["started", "completed"]
这个问题在 Web 请求处理、批处理任务和测试用例中尤其隐蔽,因为不同调用之间可能出现不符合预期的状态共享。
常见修复方式是使用 None 作为哨兵值:
def collect_event(
event: str,
events: list[str] | None = None,
) -> list[str]:
current_events = [] if events is None else events
current_events.append(event)
return current_events
这里必须使用 events is None,而不是 if not events。调用方主动传入空列表时,空列表虽然是假值,但仍可能是需要被原地更新的有效对象。
不可变默认参数通常没有这个问题,例如 None、整数、字符串和不可变枚举值。不过,如果默认对象内部仍然引用可变数据,也要继续分析其真实共享关系。
七、CPython 如何回收对象
Python 语言要求无法再访问的对象最终可以被回收,但不同解释器的具体实现并不相同。下面主要讨论 CPython。
1. 引用计数
CPython 会为对象维护引用计数。增加一个有效引用,计数通常会上升;引用离开作用域或被重新绑定,计数通常会下降。当计数降到零时,对象一般可以立即释放。
可以使用 sys.getrefcount() 辅助观察:
import sys
tasks = ["extract"]
alias = tasks
print(sys.getrefcount(tasks))
输出值会比直觉多一个,因为将 tasks 传给 getrefcount() 时产生了临时引用。因此,它适合帮助理解,不适合直接作为生产监控指标。
引用计数的优点是大部分对象能够及时回收,但它不能单独处理循环引用:
first: list[object] = []
second: list[object] = [first]
first.append(second)
即使外部名称都被移除,两个列表仍然互相引用,引用计数不会自然降到零。
2. 循环垃圾回收
CPython 还提供了循环垃圾回收器,用于检测和回收部分容器对象形成的引用环。gc 模块可以用于观察和调试相关行为。
在正常业务代码中,不应该频繁调用 gc.collect() 来掩盖内存增长。更合理的顺序是:
- 确认对象是否仍被业务结构引用。
- 找到引用关系为什么没有按预期结束。
- 修复无界容器、缓存或生命周期设计。
- 只有在明确理解收益和成本时,才调整垃圾回收策略。
其他 Python 实现可能使用不同的垃圾回收机制,因此不能依赖“离开作用域后对象一定立即销毁”来保证业务正确性。文件、连接和锁等资源应该通过上下文管理器显式管理。
八、对象释放后,进程内存为什么没有下降
排查 Python 内存问题时,经常会遇到一个现象:代码中的对象已经释放,但操作系统看到的常驻内存仍然很高。
这不一定意味着对象仍然存活。
CPython 会通过自身的内存分配器管理大量小对象。对象释放后,相关内存可能先回到 Python 的内存池,等待后续对象复用,而不是立即归还操作系统。不同尺寸的分配还可能经过系统分配器或第三方库。
因此,需要区分三类问题:
- 对象仍被引用:容器、缓存、回调或任务保存了对象,属于真正的生命周期问题。
- 对象已释放但内存被分配器保留:后续可能复用,进程 RSS 不一定立即下降。
- 非 Python 内存增长:C 扩展、图像库、数值计算库或网络库在 Python 堆之外分配内存。
只观察 RSS 无法判断属于哪一种。应该同时查看对象分配、业务容器大小和进程内存趋势。
sys.getsizeof() 也不能直接给出一个复杂对象的完整占用:
import sys
payload = {"items": [1, 2, 3]}
print(sys.getsizeof(payload))
它通常只返回对象自身的浅层大小,不会自动递归统计字典引用的列表及列表中的对象。
九、常见的内存持续增长场景
Python 中很多“内存泄漏”并不是垃圾回收失效,而是程序仍然持有不再需要的数据。
1. 无界容器
processed_records: list[dict[str, object]] = []
def process(record: dict[str, object]) -> None:
processed_records.append(record)
只要全局列表持续保存记录,对象就不会被回收。需要根据用途改成有界队列、批量落盘或只保留聚合结果。
2. 无界缓存
functools.cache 或 lru_cache(maxsize=None) 会持续保存不同参数对应的结果。如果输入空间没有边界,缓存也没有边界。
生产代码需要明确:
- 缓存最大条目数。
- 过期策略。
- 单个值的大小。
- 命中率是否值得占用内存。
3. 回调、闭包与事件监听器
长期存活的回调列表可能间接引用大型对象。闭包也会保留其使用的外部变量。注册回调后没有注销,常常会让整个对象关系长期存活。
不需要拥有对象生命周期时,可以评估使用 weakref,但弱引用会增加生命周期的不确定性,应该在明确语义后使用。
4. 消费速度跟不上生产速度
队列持续增长不一定是泄漏,也可能是生产者速度长期高于消费者。此时需要有界队列、背压、限流或扩容,而不是只依赖垃圾回收。
5. 未及时结束的任务和生成器
异步任务、线程任务或生成器可能保留执行栈中的局部对象。如果任务没有完成、取消或关闭,相关对象也可能继续存活。
6. 外部资源没有关闭
文件、响应体、数据库连接和进程句柄没有关闭,主要表现为资源泄漏,也可能伴随缓冲区和关联对象无法释放。应该优先使用上下文管理器或明确的生命周期管理。
十、如何排查 Python 内存问题
内存排查应该从可复现和可比较开始,而不是看到内存增长后立即调用垃圾回收。
第一步:确认增长模式
先回答几个问题:
- 内存是持续线性增长,还是达到某个水平后稳定?
- 增长与请求数、任务数还是数据量相关?
- 停止流量后,内存是否仍然增长?
- 同一输入能否在测试环境复现?
- 增长发生在 Python 堆,还是第三方扩展分配的内存?
稳定在高水位可能是缓存或分配器复用;持续无界增长才更值得警惕。
第二步:使用 tracemalloc 比较快照
tracemalloc 是标准库工具,可以跟踪 Python 分配的内存,并比较两个时间点的差异:
import tracemalloc
tracemalloc.start(25)
before = tracemalloc.take_snapshot()
run_target_workload()
after = tracemalloc.take_snapshot()
for statistic in after.compare_to(before, "lineno")[:10]:
print(statistic)
重点观察持续增长的代码位置,而不是只看某一时刻占用最大的对象。
tracemalloc 主要跟踪 Python 内存分配。如果增长来自某些 C 扩展或系统库,还需要结合对应工具和库自身的诊断能力。
第三步:检查对象数量和引用关系
gc 模块可以辅助查看被垃圾回收器跟踪的对象。gc.get_referrers() 能帮助寻找谁仍在引用目标对象,但它会受到调试环境和临时引用干扰,结果需要谨慎解释。
排查时可以重点检查:
- 全局字典和列表的长度。
- 缓存条目数与命中率。
- 队列积压数量。
- 未结束的异步任务数量。
- 连接池和线程池状态。
- 业务批次结束后,对象数量是否回落。
第四步:缩小到最小复现
关闭无关功能,减少输入规模,反复执行目标路径。如果每次执行都稳定增加同一类对象,通常比直接分析整个生产进程更容易找到原因。
第五步:修复后验证趋势
修复不能只验证“功能仍然正确”,还要重复相同负载,比较:
- 峰值内存。
- 稳态内存。
- 每轮任务结束后的对象数量。
- 吞吐量和执行时间。
某些内存优化会增加计算或 I/O 成本,需要一起评估,而不是只追求更低的 RSS。
十一、减少不必要的对象创建和复制
理解对象模型之后,可以用更有针对性的方式优化内存。
1. 使用生成器处理流式数据
如果数据只需要遍历一次,不必一次性构造完整列表:
def read_valid_lines(file_path: str):
with open(file_path, encoding="utf-8") as file:
for line in file:
normalized = line.strip()
if normalized:
yield normalized
生成器只保留当前执行状态,适合大文件和数据流。但生成器也可能持有局部变量和打开的资源,消费方应及时遍历或关闭。
2. 分批处理,控制峰值
数据库查询、接口数据和消息处理应根据数据量设置合理批次。批次太大增加峰值内存,批次太小则增加网络和调度开销,需要通过测量选择。
3. 避免无条件深拷贝
先明确数据所有权,只复制将被修改且必须隔离的部分。不可变数据可以安全共享,业务明确的只读对象也不需要重复构造。
4. 为缓存和队列设置边界
任何长期运行的容器都应该回答“最多保存多少数据”。边界可以是条目数、字节数、时间窗口或磁盘容量。
5. 复用重量级客户端
数据库连接池、HTTP 客户端和模型客户端通常包含连接与缓冲区。应按框架生命周期正确复用,而不是每次请求重复创建;同时也不能把本应短期存在的请求数据挂在全局客户端上。
6. 对大量简单实例评估 slots
当程序创建大量结构相同的轻量对象时,可以评估 __slots__ 或 dataclass(slots=True),减少每个实例的属性存储开销。它会限制动态属性并影响部分继承和序列化行为,不应该在缺少测量时全面启用。
7. 缩短大对象的有效生命周期
在长函数中,大对象可能因为局部引用而持续存活。优先通过拆分职责和缩小作用域自然结束引用。del 可以用于明确移除某个名称,但它删除的是绑定,不保证对象一定释放,也不应该替代清晰的生命周期设计。
十二、实践:定位并修复一次数据污染
下面用一个记录构建函数复现两个常见问题:
- 可变默认参数导致多次调用共享记录列表。
- 浅拷贝导致不同记录共享嵌套的标签列表。
问题代码
DEFAULT_METADATA = {"tags": []}
def build_record(name: str, records: list[dict] = []) -> list[dict]:
metadata = DEFAULT_METADATA.copy()
metadata["tags"].append(name)
records.append({"name": name, "metadata": metadata})
return records
连续调用两次:
first_result = build_record("Alice")
second_result = build_record("Bob")
print(first_result is second_result)
print(first_result)
实际结果为:
True
[
{"name": "Alice", "metadata": {"tags": ["Alice", "Bob"]}},
{"name": "Bob", "metadata": {"tags": ["Alice", "Bob"]}},
]
可以进一步验证共享关系:
first_tags = first_result[0]["metadata"]["tags"]
second_tags = first_result[1]["metadata"]["tags"]
print(first_tags is second_tags)
# True
问题来自两层共享:
records默认列表只在函数定义时创建一次。dict.copy()只复制外层字典,tags仍然引用模板中的同一个列表。
修复代码
def build_record(
name: str,
records: list[dict] | None = None,
) -> list[dict]:
current_records = [] if records is None else records
metadata = {"tags": [name]}
current_records.append({"name": name, "metadata": metadata})
return current_records
再次验证:
first_result = build_record("Alice")
second_result = build_record("Bob")
assert first_result is not second_result
assert first_result[0]["metadata"]["tags"] == ["Alice"]
assert second_result[0]["metadata"]["tags"] == ["Bob"]
如果业务要求调用方传入同一个 records 列表并持续追加,该行为仍然保留:
records: list[dict] = []
build_record("Alice", records)
build_record("Bob", records)
assert len(records) == 2
修复的关键不是“所有对象都复制一遍”,而是明确所有权:
- 未传入记录列表时,每次调用创建自己的列表。
- 调用方传入列表时,函数按约定原地追加。
- 每条记录拥有独立的元数据和标签列表。
这比无条件调用 deepcopy() 更清晰,也减少了不必要的对象复制。
十三、对象与内存问题检查清单
遇到数据被意外修改或内存持续增长时,可以依次检查:
- 两个名称是否通过
is指向同一个对象? - 当前操作是在修改对象,还是重新绑定名称?
- 对象自身是否可变,内部是否引用了可变对象?
- 当前复制是赋值、浅拷贝还是深拷贝?
- 函数是否修改了调用方传入的对象?
- 默认参数是否包含列表、字典、集合或自定义可变对象?
- 是否存在无界列表、字典、缓存或队列?
- 回调、闭包、任务或全局对象是否延长了数据生命周期?
- 增长来自 Python 对象、内存分配器,还是第三方扩展?
- 修复后是否使用相同负载验证了内存趋势?
结语
Python 对象模型并不复杂,但如果一直沿用“变量是盒子”的理解,赋值、复制和函数传参就会表现得反复无常。
更稳定的判断方式是追踪三个问题:
- 当前有哪些对象?
- 哪些名称或容器正在引用它们?
- 代码是在修改对象,还是改变引用关系?
当内存出现异常时,还要继续区分对象是否存活、分配器是否保留内存,以及内存是否来自 Python 之外。只有先确定增长属于哪一类,优化才不会变成盲目调用垃圾回收或无条件深拷贝。
下一期将从对象与引用继续向外扩展,讨论如何把一个能够运行的单文件脚本,整理成配置清晰、边界明确、可以测试和持续维护的 Python 项目。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)