任何编程语言都会有一个内存模型, 以便管理为变量分配的内存空间. 不同的编程语言, 如 C,C++,Java,C#,Python, 它们的内存模型都是不相同的, 本文将以现在最流行的 Python 语言为例, 来说明动态类型语言的内存管理方式.
1. 重复使用内存空间
赋值语句是 Python 语言中最简单的语句之一, 虽然赋值语言很简单, 但却内含玄机.
例如, 将一个值赋给一个变量是最常见的赋值操作.
n = 1 # 将 1 赋给变量 n
整数 1 是一个值, 而 n 是一个对象. 这是最简单不过的赋值语句了. 那么在内存中是如何操作的呢? 其实在 Python 中, 任何值都可以看做是一个对象, 例如, 1 是 int 类的实例, True 是 bool 类的实例. 所以将 1 赋给变量 n, 其实是 n 指向了 int 类型的对象, 所以 n 本质上就是一个对象的引用.
Python 作为动态语言, 采用了引用与对象分离的策略, 这也使得任何引用都可以指向任何对象, 而且可以动态改变引用指向的对象类型, 也就是说, 可以将一个指向 int 类型的对象的引用重新指向 bool 类型的对象. 所以可以将 Python 语言的对象模型看做是超市里的储物柜(这里只是用储物柜作为内存模型的比喻, 不要与超市储物柜实际的操作进行比较).
每一个小柜子相当于一块内存区域, 这块内存区域保存了不同类型的值. 对于像 C++,Java 一样的静态语言, 一旦分配了某一个小柜子, 就意味着这个柜子只能保存特定的物品, 如只能放鞋子, 只能放手套, 只能放衣服. 而对于打开小柜子的钥匙(相当于变量), 同时也只能打开某一个特定的小柜子, 相当于一个变量同时只能指向一个对象一样. 当然, 在钥匙上进行设置后, 该钥匙可以指向其他同类型的小柜子(相当于改变变量指向的对象, 如将一个指向 int 类型对象的变量指向了另外一个 int 类型的对象).
不过 Python 语言就不一样了. 在 Python 版的储物柜中, 每一个小柜子并不限定存储物品的类型, 而一把钥匙经过设置后, 可以打开任意一个小柜子(相当于任意改变变量指向的对象). 这样做的好处是更灵活, 没必要为存储特定的物品, 增加新的储物柜, 只要还有空的小柜子, 就可以放任何物品. 但缺点也很明显, 就是打开一个小柜子后, 需要多进行一步判断的操作, 判断这个小柜子到底是存储的什么物品.
当然, 对于同一个特定的小柜子, 可能会配有多把钥匙, 这些钥匙都可以打开这个特定的小柜子, 这就相当于多个变量指向同一个对象. 例如,
x = 10y = 10z = 10
x,y 和 z 三个变量的值都是 10, 这个 10 就相当于要保存在小柜子中的物品. x,y 和 z 相当于 3 把钥匙. 而 3 个变量中的值都是 10, 所以被认为是同一个值(物品), 因此, 就只需要动用一个小柜子保存 10, 而 3 个变量都会指向这个小柜子(由于计算机中值具有无限可复制性, 所以只要有一个物品, 就可以无限复制, 所以不必考虑现实中将小柜子中的东西拿走了就为空的情况). 所以其实 x,y 和 z 这 3 个变量指向了同一个内存地址(相当于小柜子的序号). 可以用 id 函数验证这 3 个变量的内存地址是否相同, 代码如下:
print(id(x))print(id(y))print(id(z))
输出结果如下:
- 4470531424
- 4470531424
- 4470531424
也可以用下面的代码将内存地址转换为十六进制形式.
print(hex(id(x)))print(hex(id(y)))print(hex(id(z)))
输出结果如下:
- 0x10a76e560
- 0x10a76e560
- 0x10a76e560
根据前面的输出结果, 很显然, x,y 和 z 指向的是同一个内存地址. 读者可以将 10 换成其他的对象, 如 True,10.12,"hello world", 结果都是一样(由于机器不同, 输出的内存地址可能不同, 但 3 个变量的内存地址肯定都是相同的).
也可以用 is 运算符判断这 3 个变量是否指向同一个值.
print(x is y is z) # 输出结果: True
但要注意, 只有不可变类型, 如 int,float,bool,string 等, 才会使用同一个储物柜. 如果是可变类型, 如列表, 对象, 每次都会分配新的内存空间. 这里的不可变是指值一旦确定, 值本身无法修改. 例如 int 类型的 10, 这个 10 是固定的, 不能修改, 如果修改成 11, 那么就是新的值了, 需要申请新的小柜子. 而列表, 如空列表[], 以后还可以向空列表中添加任何类型的值, 也可以修改和删除列表中的值. 所以没有办法为所有的空列表分配同一个小柜子, 因为有的空列表, 现在是空, 以后不一定是空. 所以每一个列表类型的值都会新分配一个小柜子, 但元组就不同了, 由于元组是只读的, 所以一开始是空的元组, 那么这个元组今生今世将永远是空, 所以可以为所有的空元组, 以及所有相同元素个数和值的元组分配同一个小柜子. 看下面代码:
- class MyClass:passa = []
- b = []
- c = MyClass()
- d = MyClass()
- t1 = (1,2,3)
- t2 = (1,2,3)print(a is b) # False 元素个数和类型相同的列表不会使用同一个内存空间(小柜子)print(c is d) # False MyClass 类的不同实例不会使用同一个内存空间(小柜子)print(t1 is t2) # True 元素个数和类型相同的元组会使用同一个内存空间(小柜子)
这种将相同, 但不可变的值保存在同一个内存空间的方式也称为值的缓存, 这样做非常节省内存空间, 而且程序的执行效率更高. 因为省去了大量分配内存空间的时间.
2. 引用计数器
在 Python 语言中是无法自己释放变量内存的, 所以 Python 虚拟机提供了自动回收内存的机制, 那么 Python 虚拟机是如何知道哪一个变量占用的内存可以被回收呢? 通常的做法是为每一块被占用的内存设置一个引用计数器, 如果该内存块没有被任何变量引用(也就是引用计数器为 0), 那么该内存块就可以被释放, 否则无法被释放.
在 sys 模块中有一个 getrefcount 函数, 可以用来获取任何变量指向的内存块的引用计数器当前的值. 用法如下:
- from sys import getrefcount
- a = [1, 2, 3]print(getrefcount(a)) # 输出 2 b = aprint(getrefcount(b)) # 输出 3print(getrefcount(a)) # 输出 3 x = 1print(getrefcount(x)) #输出 1640y = 1print(getrefcount(x)) # 输出 1641print(getrefcount(y)) # 输出 1641
要注意, 使用 getrefcount 函数获得引用计数器的值时, 实际上会创建一个临时的引用, 所以 getrefcount 函数返回的值会比实际的值多 1. 而对于具体的值(如本例的 1), 系统可能在很多地方都引用了该值, 所以根据 Python 版本和当前运行的应用不同, getrefcount 函数返回的值是不确定的.
3. 对象引用
像 C++ 这样的编程语言, 对象的传递分为值传递和指针传递. 如果是值传递, 就会将对象中的所有成员属性的值都一起复制, 而指针传递, 只是复制了对象的内存首地址. 不过在 Python 中, 并没有指针的概念. 只有一个对象引用. 也就是说, Python 语言中对象的复制与 C++ 中的对象指针复制是一样的. 只是将对象引用计数器加 1 而已. 具体看下面的代码:
- from sys import getrefcount
- # 类的构造方法传入另外一个对象的引用 class MyClass(object):def __init__(self, other_obj):
- self.other_obj = other_obj # 这里的 other_obj 与后面的 data 指向了同一块内存地址 data = {'name':'Bill','Age':30}print(getrefcount(data)) # 输出 2my = MyClass(data)print(id(my.other_obj)) # 输出 4364264288print(id(data)) #输出 4364264288
- print(getrefcount(data)) # 输出 3
在 Python 中, 一切都是对象, 包括值. 如 1,2,3,"abcd" 等. 所以 Python 会在使用这些值时, 先将其保存在一块固定的内存区域, 然后将所有赋给这些值的变量指向这块内存区域, 同时引用计数器加 1.
例如,
a = 1
b = 1
其中 a 和 b 指向了同一块内存空间, 这两个变量其实都保存了对 1 的引用. 使用 id 函数查看这两个变量的引用地址是相同的.
4. 循环引用与拓扑图
如果对象引用非常多, 就可能会构成非常复杂的拓扑结果. 例如, 下面代码的引用拓扑关系就非常复杂. 估计大多数同学都无法一下子看出这段程序中各个对象的拓扑关系.
- class MyClass1:def __init__(self, obj):
- self.obj = obj
- class MyClass2:def __init__(self,obj1,obj2):
- self.obj1 = obj1
- self.obj2 = obj2
- data1 = ['hello', 'world']
- data2 = [data1, MyClass1(data1),3,dict(data = data1)]
- data3 = [data1,data2,MyClass2(data1,data2),MyClass1(MyClass2(data1,data2))]
看不出来也不要紧, 可以使用 objgraph 模块绘制出某个变量与其他变量的拓扑关系, objgraph 是第三方模块, 需要使用 pip install objgraph 命令安装, 如果机器上安装了多个 Python 环境, 要注意看看 pip 命令是否属于当前正在使用的 Python 环境, 不要将 objgraph 安装在其他的 Python 环境中.
安装完 objgraph 后, 可以使用下面命令看看 data3 与其他对象的引用关系.
- import objgraph
- objgraph.show_refs([data3], filename='对象引用关系. png')
show_refs 函数会在当前目录下生成一个 "对象引用关系. png" 的图像文件, 如下图所示.
如果对象之间互相引用, 有可能会形成循环引用. 也就是 a 引用 b,b 引用 a, 见下面的代码.
- import objgraphfrom sys import getrefcount
- a = {
- }
- b = {
- 'data':a
- }
- a['value'] = b
- objgraph.show_refs([b], filename='循环引用 1.png')
在这段代码中. a 和 b 都是一个字典, b 中的一个 value 引用了 a, 而 a 的一个 value 引用了 b, 所以产生了一个循环引用. 这段代码的引用拓扑图如下:
很明显, 这两个字典是循环引用的.
不光是多个对象之间的引用可以产生循环引用, 只有一个对象也可以产生循环引用, 代码如下:
- a = {
- }
- a['value'] = a
- a = []
- a.append(a)print(getrefcount(a))
- objgraph.show_refs([a], filename='循环引用 2.png')
在这段代码中, 字典 a 的一个值是自身, 拓扑图如下:
5. 减少引用计数的两种方法
前面一直说让引用计数器增加的方法, 那么如何让引用计数器减少呢? 通常有如下两种方法:
(1)用 del 删除某一个引用
(2)将变量指向另外一个引用, 或设置为 None, 也就是引用重定向.
用 del 删除某一个引用
del 语句可以删除一个变量对某一个块内存空间的引用, 也可以删除集合对象中的某个 item, 代码如下:
- from sys import getrefcount
- person = {'name':'Bill','age':40}
- person1 = personprint(getrefcount(person1)) # 输出 3
- del person # 删除 person 对字典的引用 print(getrefcount(person1)) # 由于引用少了一个, 所以输出为 2# print(person) # 抛出异常 # 被删除的变量相当于重来没定义过, 所以这条语句会抛出异常
- del person1['age'] # 删除字典中 key 为 age 的值对 print(person1)
引用重定向
- from sys import getrefcount
- value1 = [1,2,3,4]
- value2 = value1
- value3 = value2print(getrefcount(value2)) # 输出 4value1 = 20print(getrefcount(value2)) # 输出 3, 因为 value1 重新指向了 20value3 = Noneprint(getrefcount(value2)) # 输出 2, 因为 value3 被设置为 None, 也就是不指向任何内存空间, 相当于空指针
6. 垃圾回收
像 Java,JavaScript,Python 这样的编程语言, 都不允许直接通过代码释放变量占用的内存, 虚拟机会自动释放这些内存区域. 所以很多程序员就会认为在这些语言中可以放心大胆地申请各种类型的变量, 并不用担心变量的释放问题, 因为系统会自动替我们完成这些烦人的工作.
没错, 这些语言的虚拟机会自动释放一些不需要的内存块, 用专业术语描述就是: 垃圾回收. 相当于为系统减肥或减负. 因为不管你的计算机有多少内存, 只要不断创建新的变量, 哪怕该变量只占用了 1 个字节的内存空间, 内存也有用完的一天. 所以虚拟机会在适当的时候释放掉不需要的内存块.
在前面已经提到过, 虚拟机会回收引用计数为 0 的内存块, 因为这些内存块没有任何变量指向他们, 所以留着没有任何意义. 那么到底虚拟机在什么时候才会回收这些内存块呢? 通常来讲, 虚拟机会设置一个内存阈值, 一旦超过了这个阈值, 就会自动启动垃圾回收器来回收不需要的内存空间. 对于不同编程语言的这个阈值是不同的. 对于 Python 来说, 会记录其中分配对象 (object allocation) 和取消分配对象 (object deallocation) 的次数. 当两者的差值高于某个阈值时, 垃圾回收才会启动.
我们可以通过 gc 模块的 get_threshold()方法, 查看该阈值:
import gcprint(gc.get_threshold())
输出的结果为:
(700, 10, 10)
这个 700 就是这个阈值. 后面的两个 10 是与分代回收相关的阈值, 后面会详细介绍. 可以使用 gc 模块中的 set_threshold 方法设置这个阈值.
由于垃圾回收是一项昂贵的工作, 所以如果计算机的内存足够大, 可以将这个阈值设置的大一点, 这样可以避免垃圾回收器频繁调用.
当然, 如果觉得必要, 也可以使用下面的代码手工启动垃圾回收器. 不过要注意, 手工启动垃圾回收器后, 垃圾回收器也不一定会立刻启动, 通常会在系统空闲时启动垃圾回收器.
gc.collect()
7. 变量不用了要设置为 None
有大量内存被占用, 是一定要被释放的. 但释放这些内存有一个前提条件, 就是这个内存块不能有任何变量引用, 也就是引用计数器为 0. 如果有多个变量指向同一个内存块, 而且有一些变量已经不再使用了, 一个好的习惯是将变量设置为 None, 或用 del 删除该变量.
- person = {
- 'Name':'Bill'
- }
- value = [1,2,3]del person
- value = None
当删除 person 变量, 以及将 value 设置为 None 后, 就不会再有任何变量指向字典和列表了, 所以字典和列表占用的内存空间会被释放.
8. 解决循环引用的回收问题
在前面讲了 Python GC(垃圾回收器)的一种算法策略, 就是引用计数法, 这种方法是 Python GC 采用的主要方法. 不过这种策略也有其缺点. 下面就看一下引用计数法的优缺点.
优点: 简单, 实时(一旦为 0 就会立刻释放内存空间, 毫不犹豫)
缺点: 维护性高(简单实时, 但是额外占用了一部分资源, 虽然逻辑简单, 但是麻烦. 好比你吃草莓, 吃一次洗一下手, 而不是吃完洗手.), 不能解决循环引用的问题.
那么 Python 到底是如何解决循环引用释放的问题呢? 先看下面的代码.
- import objgraphfrom sys import getrefcount
- a = {
- }
- b = {
- 'data':a
- }
- a['value'] = bdel adel b
在这段代码中, 很明显, a 和 b 互相引用. 最后通过 del 语句删除 a 和 b. 由于 a 和 b 是循环引用, 如果按前面引用计数器的方法, 在删除 a 和 b 之前, 两个字典分别由两个引用(引用计数器为 2), 一个是自身引用, 另一个是 a 或 b 中的 value 引用的自己. 如果只是删除了 a 和 b, 似乎这两个字典各自还剩一个引用. 但其实这两个字典的内存空间已经释放. 那么 Python 是如何做到的呢?
其实 Python GC 在检测所有引用时, 会检测哪些引用之间是循环引用, 如果检测到某些变量之间循环引用, 例如, a 引用 b,b 引用 a, 就会在检测 a 时, 将 b 的引用计数器减 1, 在检测 b 时, 会将 a 的引用计数器减 1. 也就是说, Python GC 当发现某些引用是循环引用后, 会将这些引用的计数器多减一个 1. 所以这些循环引用指向的空间仍然会被释放.
9. 分代回收
如果是多年的朋友, 或一起做了多年的生意, 有多年的业务往来, 往往会产生一定的信任. 通常来讲, 合作的时间越长, 产生的信任感就会越深. Python GC 采用的垃圾回收策略中, 也会使用这种信任感作为辅助算法, 让 GC 运行得更有效率. 这种策略就是分代 (generation) 回收.
分代回收的策略有一个基本假设, 就是存活的越久, 越可能被经常使用, 所以出于信任和效率, 对这些 "长寿" 对象给予特殊照顾, 在 GC 对所有对象进行检测时, 就会尽可能少地检测这些 "长寿" 对象. 就是现在有很多企业是免检企业一样, 政府出于对这些企业的信任, 给这些企业生产出的产品予以免检的特殊优待.
那么 Python 对什么样的对象会给予哪些特殊照顾呢? Python 将对象共分为 3 代, 分别用 0,1,2 表示. 任何新创建的对象是 0 代, 不会给予任何特殊照顾, 当某一个 0 代对象经过若干次垃圾回收后仍然存活, 那么就会将这个对象归入 1 代对象, 如果这个 1 代对象, 再经过若干次回收后, 仍然存活, 就会将该对象归为 2 代对象.
在前面的描述中, 涉及到一个 "若干次" 回收, 那么这个 "若干次" 是指什么呢? 在前面使用 get_threshold 函数获取阈值时返回了(700,10,10), 这个 700 就是引用计数策略的阈值, 而后面的两个 10 与分代策略有关. 第 1 个 10 是指第 0 代对象经过了 10 次垃圾回收后仍然存在, 就会将其归为第 1 代对象. 第 2 个 10 是指第 1 代对象经过了 10 次垃圾回收后仍然存在, 就会将其归为第 2 代对象. 也就是说, GC 需要执行 100 次, 才会扫描到第 2 代对象. 当然, 也可以通过 set_threshold 函数来调整这些值.
- import gc
- gc.set_threshold(600, 5, 6)
总结
本文主要讲了 Python 如何自动释放内存. 主要有如下 3 种策略:
引用计数策略(为 0 时释放)
循环引用策略(将相关引用计数器多减 1)
分代策略(解决了 GC 的效率问题)
通过这些策略的共同作用, 可以让 Python 更加有效地管理内存, 更进一步地提高 Python 的性能.
Python 深度探索(1): 内存管理机制
来源: http://www.bubuko.com/infodetail-3357360.html