Python
Python 变量与内存模型:对象引用、深浅拷贝与垃圾回收
借助标签与对象的类比理解变量、可变性和函数传参,梳理深浅拷贝、循环引用、垃圾回收以及元组和字典表达式。
变量与内存模型
这是你最深入、卡点最多的一条线,建议重点复习。
一、变量是”标签”,不是”盒子”
核心结论:Python 变量是贴在数据上的标签,不是装数据的盒子。
1 2 3
| a = 10 b = 10 print(id(a) == id(b))
|
a = 10 发生了什么?
- 内存里创建对象
10
- 把标签
a 贴上去
a = 20 发生了什么?
- 创建对象
20
- 标签
a 从 10 撕下来,改贴到 20
变量本身没变,只是指向的目标换了。
多个标签指向同一个东西
1 2 3 4
| a = [1, 2, 3] b = a b.append(4) print(a)
|
类比
变量不是盒子,是标签。= 不是”把值放进盒子”,而是”把标签贴到数据上”。
二、栈区 vs 堆区
|
栈 Stack |
堆 Heap |
| 大小 |
小 |
大 |
| 速度 |
快 |
慢 |
| 管理 |
自动(函数结束就收) |
需 GC |
| 存什么 |
局部变量名、函数调用 |
真正的对象 |
| 生命周期 |
短 |
长 |
Python 里:变量名在栈,变量值在堆,用”指向”连接。
类比
栈 = 手边工作台(小、快、用完就收)
堆 = 后面大仓库(大、慢、长期存)
关键理解
栈上的标签消失 ≠ 堆上的对象消失。只要还有人指向,堆对象就活着。
1 2 3 4 5 6
| def make_list(): x = [1, 2, 3] return x
result = make_list()
|
三、可变 vs 不可变(分水岭)
不可变:数字、字符串、元组
可变:列表、字典、集合
验证
1 2 3 4 5 6 7 8 9
| a = [1, 2, 3] print(id(a)) a.append(4) print(id(a))
b = "hi" print(id(b)) b = b + "!" print(id(b))
|
口诀
不可变一改就换新;可变能原地改。
四、函数传参(pass by object reference)
调用 f(a) → 把 a 的标签复制一份,贴到形参上。
1 2 3
| a ──┐ ├──→ [同一个对象] x ──┘
|
| 函数里做了什么 |
外面变不变 |
原因 |
x = 新值(改标签) |
不变 |
只换指向 |
x.append() / x[0]=(改对象) |
变 |
改的是同一对象 |
| 传不可变对象 |
不变 |
没法原地改 |
口诀
改标签,外面不变;改对象,外面就变。
三种情况
情况 1:传不可变对象
1 2 3 4 5 6
| def f(x): x = 100
a = 10 f(a) print(a)
|
情况 2:传可变对象,但重新赋值
1 2 3 4 5 6
| def f(x): x = [9, 9, 9]
a = [1, 2, 3] f(a) print(a)
|
情况 3:传可变对象,且原地修改
1 2 3 4 5 6
| def f(x): x.append(4)
a = [1, 2, 3] f(a) print(a)
|
经典陷阱
1 2 3 4 5 6
| def f(x): x += [4]
a = [1, 2, 3] f(a) print(a)
|
对比:
1 2 3 4 5 6
| def f(x): x = x + [4]
a = [1, 2, 3] f(a) print(a)
|
默认参数大坑
1 2 3 4 5 6 7
| def f(x, lst=[]): lst.append(x) return lst
print(f(1)) print(f(2)) print(f(3))
|
原因:默认参数 [] 只在函数定义时创建一次,之后所有调用共用同一个列表。
正确写法:
1 2 3 4 5
| def f(x, lst=None): if lst is None: lst = [] lst.append(x) return lst
|
对比其他语言
| 语言 |
传参方式 |
特点 |
| C |
传值 |
改啥外面都不变(除非传指针) |
| C++ |
传值 / 传引用 |
可以选 |
| Java |
传值(对象传引用的副本) |
和 Python 很像 |
| Python |
传对象引用 |
官方叫 pass by object reference |
五、深浅拷贝
|
外层 |
内层 |
改内层影响对方? |
赋值 b=a |
共用 |
共用 |
✅ 影响 |
| 浅拷贝 |
独立 |
共用 |
✅ 影响 |
| 深拷贝 |
独立 |
独立 |
❌ 不影响 |
口诀
赋值是贴标签,浅拷贝只剥一层皮,深拷贝连骨头都换新。
浅拷贝的写法(全都只复制一层)
1 2 3 4 5 6
| b = a[:] b = list(a) b = a.copy() b = dict(d) b = d.copy() b = set(s)
|
这些写法一览
| 写法 |
用于什么 |
结果 |
本质 |
a[:] |
列表 |
新列表 |
切片复制 |
list(a) |
任何可迭代对象 |
新列表 |
构造新列表 |
a.copy() |
列表 |
新列表 |
列表自带复制 |
dict(d) |
可转字典的东西 |
新字典 |
构造新字典 |
d.copy() |
字典 |
新字典 |
字典自带复制 |
set(s) |
任何可迭代对象 |
新集合 |
构造新集合 |
命名规律:类型名(对象) = 把这个对象转成该类型的新对象。
浅拷贝的坑
1 2 3 4
| a = [1, 2, [3, 4]] b = a[:] b[2].append(5) print(a)
|
深拷贝
1 2
| import copy b = copy.deepcopy(a)
|
验证
1 2 3 4 5
| a = [1, 2, [3, 4]] b = copy.deepcopy(a)
print(a is b) print(a[2] is b[2])
|
什么时候用哪个
| 场景 |
推荐 |
| 传副本给函数,不嵌套 |
浅拷贝 a[:] / a.copy() |
| 嵌套结构,想彻底独立 |
deepcopy |
| 多个名字指同一个东西 |
直接 b = a |
| 数据很大,deepcopy 太慢 |
想想能否避免拷贝 |
不可变对象的拷贝
1 2 3
| a = (1, 2, 3) b = copy.copy(a) print(a is b)
|
六、循环引用
结构
1 2 3 4 5 6
| a ──→ ┌──────────────┐ │ 0: ──────────┼──┐ └──────────────┘ │ ↑ │ └───────────┘ 自己指向自己
|
为什么是麻烦?
- 手写循环复制会无限递归 → 卡死
- 引用计数永远 ≥ 1 → 回收不掉 → 内存泄漏
deepcopy 怎么解决?
内部有”记录表”,记住哪些复制过了,遇到重复直接复用,不会死循环。
1 2 3 4
| import copy a = [] a.append(a) b = copy.deepcopy(a)
|
类比
两个人互相推诿:”你找 B””你找 A”……永远结束不了。
deepcopy 拿个本子记:”这个我处理过了”,直接跳过。
现实中的循环引用
- 双向链表:A 指向 B,B 也指向 A
- 图结构:节点互相连接
- 对象的父子关系:父有子,子存父
七、垃圾回收三机制
| 机制 |
管什么 |
时机 |
能清循环吗 |
速度 |
| 引用计数 |
普通对象 |
实时 |
❌ |
快 |
| 标记清除 |
循环引用 |
定期 |
✅ |
慢 |
| 分代回收 |
优化标记清除 |
按代数 |
✅ |
较快 |
三者关系
1 2 3 4 5
| 引用计数(主力,实时) ↓ 搞不定循环引用 标记清除(专治循环) ↓ 为了更快 分代回收(分三代,新的多扫、老的多留)
|
口诀
引用计数管日常,标记清除补漏洞,分代回收提效率。
引用计数
- 有标签指向 → 计数 +1
- 标签消失 → 计数 -1
- 计数变成 0 → 立刻回收
- 死穴:循环引用
标记清除
思路分两步:
- 标记(Mark):从”根”出发,把所有能到达的对象标记为”活着”
- 清除(Sweep):没被标记的,就是垃圾,清掉
分代回收
对象分三代:
- 第 0 代(新生代):扫得最勤
- 第 1 代(中年代):扫得较少
- 第 2 代(老年代):扫得最少
默认阈值 (700, 10, 10):
- 0 代数量 - 1 代数量 > 700 → 触发 0 代回收
- 0 代回收 10 次 → 触发 1 代
- 1 代回收 10 次 → 触发 2 代
手动干预
1 2 3 4 5 6 7
| import gc gc.collect() gc.disable() gc.enable() gc.get_count() gc.get_threshold() gc.get_objects()
|
循环引用导致内存泄漏
1 2 3 4 5 6 7 8 9 10
| class Node: def __init__(self): self.other = None
a = Node() b = Node() a.other = b b.other = a a = None b = None
|
Python 靠分代 GC 定期清理这种”互相抱团”的垃圾。
避免循环引用
- 手动断开:
a.other = None
- 弱引用:
weakref.ref(obj)
- 结构上避免互相引用
1 2 3
| import weakref a.other = weakref.ref(b) b.other = weakref.ref(a)
|
八、完整流程
1 2 3 4 5 6
| 对象创建 → 引用计数实时管 → 循环引用漏网 → 分代回收定期触发 → 标记清除动手清 → 内存归还
|
完整流程图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| ╔══════════════════════════════════════════════════════════════════════╗ ║ Python 垃圾回收机制 · 完整流程图 ║ ╚══════════════════════════════════════════════════════════════════════╝
┌──────────────────────────────────────────────────────────────────────┐ │ 第 0 步:对象诞生 │ │ a = [1, 2, 3] │ │ · 对象创建在【堆】上 │ │ · 引用计数 = 1(标签 a 指向它) │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 第 1 关:引用计数(实时监控,主力机制) │ │ 有标签指向 → 计数 +1 │ │ 标签消失 → 计数 -1 │ │ ┌─────────────────────┐ │ │ │ 计数 == 0 ? │ │ │ └──────────┬──────────┘ │ │ 是 │ 否 │ │ ┌────────┘ └────────┐ │ │ ▼ ▼ │ │ 【立即回收】 对象继续存活 │ │ ✅ 完成 进入第 2 关检查 │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 第 2 关:循环引用检测 │ │ a = [] │ │ a.append(a) ← 自己指向自己,计数永远 ≥ 1 │ │ ┌─────────────────────┐ │ │ │ 存在循环引用? │ │ │ └──────────┬──────────┘ │ │ 是 │ 否 │ │ ┌────────┘ └────────┐ │ │ ▼ ▼ │ │ 引用计数搞不定 正常对象 │ │ 交给第 3 关 计数为 0 时已被回收 │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 第 3 关:分代回收触发 │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 第 0 代 │→ │ 第 1 代 │→ │ 第 2 代 │ │ │ │ 扫得最勤 │ │ 扫得较少 │ │ 扫得最少 │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 第 4 关:标记清除(真正动手清理) │ │ ① 标记阶段:从根出发,能到达的标记为"活着" ✅ │ │ ② 清除阶段:没被标记的 → 垃圾 → 回收 ❌ │ │ ⚠️ 扫描时会"暂停程序"(stop the world) │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 第 5 关:回收完成 │ │ · 垃圾内存释放 │ │ · 活着的对象"晋升"到下一代 │ │ ✅ 内存干净了 │ └──────────────────────────────────────────────────────────────────────┘
|
九、元组 / 字典判断表达式
元组判断
1 2 3 4 5 6
| if x in (1, 2, 3): print("在里面")
day = "周六" if day in ("周六", "周日"): print("休息日")
|
字典判断
1 2 3 4 5 6 7
| d = {"name": "Tom", "age": 18}
if "name" in d: print("有 name 这个键")
if "Tom" in d.values(): print("有 Tom 这个值")
|
重点:字典的 in 默认只看 key,不看 value!
字典推导式
1 2 3 4 5 6
| squares = {i: i * i for i in range(5)}
d = {"a": 1, "b": 2, "c": 3} big = {k: v for k, v in d.items() if v > 1}
|
元组 vs 字典 对比
|
元组 |
字典 |
| 判断写法 |
x in (1,2,3) |
k in d(只看 key) |
| 判断什么 |
有没有相等的元素 |
有没有这个 key |
| 适合场景 |
少量固定选项 |
有键值对应关系 |
易混点:元组推导式
1 2 3 4
| t = (i for i in range(5)) print(t)
t = tuple(i for i in range(5))
|
记住:Python 没有真正的”元组推导式”,括号包起来的是生成器。