变量与内存模型

这是你最深入、卡点最多的一条线,建议重点复习。

一、变量是”标签”,不是”盒子”

核心结论:Python 变量是贴在数据上的标签,不是装数据的盒子。

1
2
3
a = 10
b = 10
print(id(a) == id(b)) # True,指向同一个对象

a = 10 发生了什么?

  1. 内存里创建对象 10
  2. 把标签 a 贴上去

a = 20 发生了什么?

  1. 创建对象 20
  2. 标签 a 从 10 撕下来,改贴到 20

变量本身没变,只是指向的目标换了。

多个标签指向同一个东西

1
2
3
4
a = [1, 2, 3]
b = a # 不是复制,是贴了第二张标签
b.append(4)
print(a) # [1, 2, 3, 4] ← a 也变了!
1
2
3
4
a        b
\ /
↓ ↓
[同一个列表对象]

类比

变量不是盒子,是标签。= 不是”把值放进盒子”,而是”把标签贴到数据上”。


二、栈区 vs 堆区

栈 Stack 堆 Heap
大小 小 大
速度 快 慢
管理 自动(函数结束就收) 需 GC
存什么 局部变量名、函数调用 真正的对象
生命周期 短 长

Python 里:变量名在栈,变量值在堆,用”指向”连接。

类比

栈 = 手边工作台(小、快、用完就收)
堆 = 后面大仓库(大、慢、长期存)

关键理解

栈上的标签消失 ≠ 堆上的对象消失。只要还有人指向,堆对象就活着。

1
2
3
4
5
6
def make_list():
x = [1, 2, 3] # x 是栈上的标签,列表对象在堆上
return x

result = make_list() # result 接住了堆上那个列表
# 函数结束后 x 被撕掉,但堆上的列表还在

三、可变 vs 不可变(分水岭)

不可变:数字、字符串、元组

1
2
a = "hi"
a = a + "!" # 新建对象,标签换目标

可变:列表、字典、集合

1
2
a = [1,2,3]
a.append(4) # 原地改,门牌号不变

验证

1
2
3
4
5
6
7
8
9
a = [1, 2, 3]
print(id(a)) # 假设 2001
a.append(4)
print(id(a)) # 还是 2001!没变

b = "hi"
print(id(b)) # 假设 3001
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 # 只是把 x 这张标签撕下来,改贴到 100 上

a = 10
f(a)
print(a) # 10,没变

情况 2:传可变对象,但重新赋值

1
2
3
4
5
6
def f(x):
x = [9, 9, 9] # x 改贴到新列表上了

a = [1, 2, 3]
f(a)
print(a) # [1, 2, 3],没变!

情况 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] ← 变了!

经典陷阱

1
2
3
4
5
6
def f(x):
x += [4] # 列表 += 是原地扩展(相当于 extend)!

a = [1, 2, 3]
f(a)
print(a) # [1, 2, 3, 4] 变了!

对比:

1
2
3
4
5
6
def f(x):
x = x + [4] # + 是新建列表,x 改贴新对象

a = [1, 2, 3]
f(a)
print(a) # [1, 2, 3] 没变

默认参数大坑

1
2
3
4
5
6
7
def f(x, lst=[]):   # ❌ 危险![] 只创建一次
lst.append(x)
return lst

print(f(1)) # [1]
print(f(2)) # [1,2] ← 记住了上次的!
print(f(3)) # [1,2,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) # list() 构造
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, [3, 4, 5]] ← 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) # False
print(a[2] is b[2]) # False ← 内层也是不同的了!

什么时候用哪个

场景 推荐
传副本给函数,不嵌套 浅拷贝 a[:] / a.copy()
嵌套结构,想彻底独立 deepcopy
多个名字指同一个东西 直接 b = a
数据很大,deepcopy 太慢 想想能否避免拷贝

不可变对象的拷贝

1
2
3
a = (1, 2, 3)        # 元组,不可变
b = copy.copy(a)
print(a is b) # True!没必要复制

六、循环引用

1
2
a = []
a.append(a) # a 里面装了自己

结构

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 → 立刻回收
  • 死穴:循环引用

标记清除

思路分两步:

  1. 标记(Mark):从”根”出发,把所有能到达的对象标记为”活着”
  2. 清除(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(实时系统)
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 # a 指向 b
b.other = a # b 指向 a
a = None
b = None # 外面的标签都撕了,但互相拽着,回收不掉

Python 靠分代 GC 定期清理这种”互相抱团”的垃圾。

避免循环引用

  1. 手动断开:a.other = None
  2. 弱引用:weakref.ref(obj)
  3. 结构上避免互相引用
1
2
3
import weakref
a.other = weakref.ref(b) # 弱引用,不增加 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):        # 看 x 在不在这一组固定选项里
print("在里面")

day = "周六"
if day in ("周六", "周日"):
print("休息日")

字典判断

1
2
3
4
5
6
7
d = {"name": "Tom", "age": 18}

if "name" in d: # 判断 key 存不存在
print("有 name 这个键")

if "Tom" in d.values(): # 判断 value 存不存在
print("有 Tom 这个值")

重点:字典的 in 默认只看 key,不看 value!

1
2
"name" in d     # True  (key)
"Tom" in d # False (Tom 是 value,不是 key)

字典推导式

1
2
3
4
5
6
squares = {i: i * i for i in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

d = {"a": 1, "b": 2, "c": 3}
big = {k: v for k, v in d.items() if v > 1}
# {"b": 2, "c": 3}

元组 vs 字典 对比

元组 字典
判断写法 x in (1,2,3) k in d(只看 key)
判断什么 有没有相等的元素 有没有这个 key
适合场景 少量固定选项 有键值对应关系

易混点:元组推导式

1
2
3
4
t = (i for i in range(5))   # 这其实不是元组!是"生成器"
print(t) # <generator object ...>

t = tuple(i for i in range(5)) # 想得到元组要这样

记住:Python 没有真正的”元组推导式”,括号包起来的是生成器。