Python 内存分析:从栈和堆理解对象引用

0 阅读8分钟

前端里常说“基础类型在栈,引用类型在堆”。

Python 里更常说“变量名绑定到对象”。

先记住这个区别:

前端入门模型:

  • 基础类型:栈中保存值
  • 引用类型:栈中保存地址,堆中保存对象

Python 推荐模型:

  • 栈帧中保存名字和引用
  • 堆中保存对象
  • 不管 intstrlistdict,本质上名字都是引用到对象

这篇不是 Python 源码级内存布局,而是业务开发时能用的心智模型。

一、先把前端的栈和堆讲清楚

前端学习时通常这样解释:

let age = 18;
let nums = [10, 20, 30];

前端入门模型可以简化为:

01-js-stack-heap.svg

所以基础类型赋值像复制值:

let age = 18;
let otherAge = age;

otherAge = 20;

console.log(age); // 18
console.log(otherAge); // 20

内存图:

07-js-basic-copy.svg

数组赋值像复制地址:

let nums1 = [10, 20, 30];
let nums2 = nums1;

nums2.push(40);

console.log(nums1); // [10, 20, 30, 40]
console.log(nums2); // [10, 20, 30, 40]

内存图:

08-js-array-reference.svg

但 Python 不能直接照搬“基础类型值直接放栈里”。

二、Python 的栈和堆:栈帧保存名字,堆保存对象

Python 运行代码时,也可以理解为有执行栈。

但 Python 的重点不是“变量盒子里直接放值”,而是:

  • 当前作用域里有一张“名字表”
  • 名字表里保存对象引用
  • 对象本身在堆中

看代码:

age = 18
nums = [10, 20, 30]

内存图:

02-python-stack-heap.svg

这里和前端入门模型最大的差别是:JS 入门模型里,基础类型常被理解成“值直接在栈里”;Python 里,age 这个名字保存的是引用,真正的 18 是 int 对象。

可以用 id() 观察对象身份:

age = 18
nums = [10, 20, 30]

print(id(age))      # 打印:地址A,代表 int 对象 18 的地址
print(id(nums))     # 打印:地址B,代表 list 对象的地址
print(id(nums[0]))  # 打印:地址C,代表 int 对象 10 的地址

print(hex(id(age))) # 打印:0x001,用十六进制查看地址

id() 适合学习时观察对象身份,业务代码里很少用它做判断。

三、Python 的变量不是盒子,是名字

看这段:

age = 18
other_age = age

内存图:

09-python-name-binding.svg

可以验证:

age = 18
other_age = age

print(age is other_age) # 打印:True,说明两个名字指向同一个对象
print(id(age))          # 打印:地址A
print(id(other_age))    # 打印:地址A,和 age 一样

is 看对象身份,== 看值是否相等。

a = [1, 2, 3]
b = [1, 2, 3]

print(a == b) # 打印:True,值相等
print(a is b) # 打印:False,不是同一个对象

四、不可变对象:不能改对象,只能换引用

Python 里的 intfloatboolstrtuple 都是常见不可变对象。

看代码:

age = 18
print(id(age)) # 打印:地址A,代表 int 对象 18

age = age + 1
print(id(age)) # 打印:地址B,代表 int 对象 19,和上面不同
print(age)     # 打印:19

不可变不是说 age 变量不能重新赋值。

而是说:age 指向的那个 int 对象 18,不能在原地变成 19

当你写 age = age + 1 时,Python 是让 age 这个名字改为指向另一个对象。

内存变化可以理解成:

03-python-immutable-rebind.svg

重点:不是原地修改,而是创建新的引用进行重新绑定

字符串也是一样,name = name + '-python' 不是原地把字符串变长,而是得到新字符串,再让 name 绑定过去。

所以,不可变对象的“修改”,本质是重新绑定引用。

五、可变对象:引用不换,对象内部能改

Python 里的 listdictset 是常见可变对象。

可变的意思是:栈里的名字仍然指向同一个对象,堆里的对象内部内容可以原地修改(也就是内存地址不变)。

nums = [10, 20, 30]
print(id(nums)) # 打印:地址A

nums.append(40)
print(id(nums)) # 打印:地址A,和 append 前一样
print(nums)     # 打印:[10, 20, 30, 40]

可以理解成:

10-python-list-append.svg

id(nums) 不变,因为 nums 还是指向同一个 list 对象,变的是堆里 list 对象的内部内容。

再看共享引用:

nums1 = [10, 20, 30]
nums2 = nums1

nums2.append(40)

print(nums1)          # 打印:[10, 20, 30, 40]
print(nums2)          # 打印:[10, 20, 30, 40]
print(nums1 is nums2) # 打印:True,两个名字指向同一个 list

内存图:

12-python-shared-list.svg

所以通过 nums2 修改,nums1 也能看到。这和前端引用类型的心智很接近。

六、列表内部保存的也是引用

nums = [10, 20, 30, 40]

list 对象里不是直接塞了 10203040 的值本身,而是保存了一组引用:

04-python-list-slots.svg

也就是说:变量名保存引用,容器的每个槽位保存的也是引用。

看修改:

nums = [10, 20, 30, 40]
print(id(nums))    # 打印:地址A,list 对象的地址
print(id(nums[0])) # 打印:地址B,int 对象 10 的地址

nums[0] = 100

print(id(nums))    # 打印:地址A,list 对象地址不变
print(id(nums[0])) # 打印:地址C,int 对象 100 的地址
print(nums)        # 打印:[100, 20, 30, 40]

变化过程:

14-python-list-slot-rebind.svg

所以,list 对象本身没有换,id(nums) 不变;int 对象 10 没有被改成 100,只是 list 第 0 个槽位改为保存新的引用。

这句话很重要:列表可变,指的是列表槽位可以换引用;整数不可变,指的是 int 对象本身不能被改。

七、需要复制时才 copy

如果你想要两个独立的列表,要显式复制:

a = [1, 2, 3]
b = a.copy()

b.append(4)

print(a)      # 打印:[1, 2, 3]
print(b)      # 打印:[1, 2, 3, 4]
print(a is b) # 打印:False,copy 后是两个不同 list

内存图:

13-python-copy-list.svg

八、函数传参:形参也是名字

函数调用时,会创建新的栈帧。形参是这个函数栈帧里的名字,调用方传进来的对象会绑定到形参上。

不可变对象

def update_age(age):
    age = age + 1
    print('函数内部:', age) # 打印:函数内部: 19


age = 18
update_age(age)
print('函数外部:', age) # 打印:函数外部: 18

可以理解成:

17-python-function-immutable-frame.svg

函数内部只是让形参 age 指向了新对象,所以外部 age 不变。

可变对象

def add_user(users):
    users.append('王五')


users = ['张三', '李四']
add_user(users)
print(users) # 打印:['张三', '李四', '王五']

可以理解成:

05-python-function-frames.svg

函数内部没有让 users 重新绑定到新对象,而是原地修改了同一个 list 对象,所以外部能看到变化。

判断关键:

  • 重新绑定名字:通常不影响外部。
  • 原地修改对象:所有引用这个对象的地方都会看到变化。

九、浅拷贝:只复制外层容器

先记一句话:

浅拷贝会复制外层容器。但是外层容器里面保存的引用,不会继续往里面复制。

如果是一层普通列表,问题不大:

nums = [1, 2, 3]
new_nums = nums.copy()

new_nums.append(4)

print(nums)     # 打印:[1, 2, 3]
print(new_nums) # 打印:[1, 2, 3, 4]

因为 numsnew_nums 已经是两个不同的外层 list。

但嵌套结构就要小心:

users = [
    {'name': '张三'},
    {'name': '李四'},
]

new_users = users.copy()
new_users[0]['name'] = '张三丰'

print(users)     # 打印:[{'name': '张三丰'}, {'name': '李四'}]
print(new_users) # 打印:[{'name': '张三丰'}, {'name': '李四'}]

为什么 users 也被改了?

因为 users.copy() 只复制了外层 list:

  • users 指向外层 list A
  • new_users 指向外层 list B

但是两个外层 list 的第 0 个位置,仍然指向同一个 dict。

内存图:

06-python-shallow-copy.svg

所以 new_users[0]['name'] = '张三丰' 修改的是同一个 dict 对象,users[0] 也能看到变化。

如果要连内部对象一起复制,用深拷贝:

import copy

new_users = copy.deepcopy(users)

深拷贝的内存图可以理解成:

16-python-deepcopy.svg

先记:

  • 赋值:不复制对象
  • 浅拷贝:复制外层容器
  • 深拷贝:外层和内部对象都复制

十、tuple 不可变,但里面的对象可能可变

这个点容易懵,关键是把两层分开看:

  • tuple 的槽位不能换。
  • 槽位指向的是对象,如果这个对象是可变对象,它自己仍然可以改。

看代码:

data = ([1, 2], [3, 4])
data[0].append(100)

print(data) # 打印:([1, 2, 100], [3, 4])

data 是 tuple,所以 data[0] 这个槽位不能换成另一个对象。

但是 data[0] 当前指向的是一个 list,list 是可变对象。

所以 data[0].append(100) 改的是这个 list 的内部内容,不是替换 tuple 的槽位。

15-python-tuple-inner-list.svg

不能这样:

# data[0] = [100, 200]

因为这是想把 tuple 的第 0 个槽位,改为指向另一个 list。

所以:tuple 不可变,指的是槽位不能换引用;list 可变,指的是 list 对象内部可以改。

十一、垃圾回收:引用没了,对象才可能被清理

Python 对象在堆中。对象能不能被回收,关键看还有没有引用指向它。

a = [1, 2, 3]
b = a

del a

print(b) # 打印:[1, 2, 3]

del a 删除的是栈帧里的名字 a,不是直接删除堆里的 list 对象。

因为 b 还指向这个对象,所以它还活着。

11-python-del-gc.svg

如果再执行:

b = None

原来的 list 对象没人引用了,就有机会被回收。

Python 常见内存回收机制可以先这样理解:

机制作用
引用计数对象被多少引用指向
循环垃圾回收处理对象之间互相引用导致的循环

循环引用:

a = []
a.append(a)

这个 list 对象内部引用了自己。

18-python-cycle-reference.svg

这类问题需要垃圾回收器处理。

业务开发里不用手动管理每块内存,但要注意:无用数据如果一直被全局变量、缓存、闭包等引用,就不会被释放。