任何编程语言都会有一个内存模型,以便管理为变量分配的内存空间。不同的编程语言,如C、C++、Java、C#,Python,它们的内存模型都是不相同的,本文将以现在最流行的Python语言为例,来说明动态类型语言的内存管理方式。
整数1是一个值,而n是一个对象。这是最简单不过的赋值语句了。那么在内存中是如何操作的呢?其实在Python中,任何值都可以看做是一个对象,例如,1是int类的实例,True是bool类的实例。所以将1赋给变量n,其实是n指向了int类型的对象,所以n本质上就是一个对象的引用。
Python作为动态语言,采用了引用与对象分离的策略,这也使得任何引用都可以指向任何对象,而且可以动态改变引用指向的对象类型,也就是说,可以将一个指向int类型的对象的引用重新指向bool类型的对象。所以可以将Python语言的对象模型看做是超市里的储物柜(这里只是用储物柜作为内存模型的比喻,不要与超市储物柜实际的操作进行比较)。
每一个小柜子相当于一块内存区域,这块内存区域保存了不同类型的值。对于像C++、Java一样的静态语言,一旦分配了某一个小柜子,就意味着这个柜子只能保存特定的物品,如只能放鞋子、只能放手套、只能放衣服。而对于打开小柜子的钥匙(相当于变量),同时也只能打开某一个特定的小柜子,相当于一个变量同时只能指向一个对象一样。当然,在钥匙上进行设置后,该钥匙可以指向其他同类型的小柜子(相当于改变变量指向的对象,如将一个指向int类型对象的变量指向了另外一个int类型的对象)。
不过Python语言就不一样了。在Python版的储物柜中,每一个小柜子并不限定存储物品的类型,而一把钥匙经过设置后,可以打开任意一个小柜子(相当于任意改变变量指向的对象)。这样做的好处是更灵活,没必要为存储特定的物品,增加新的储物柜,只要还有空的小柜子,就可以放任何物品。但缺点也很明显,就是打开一个小柜子后,需要多进行一步判断的操作,判断这个小柜子到底是存储的什么物品。
当然,对于同一个特定的小柜子,可能会配有多把钥匙,这些钥匙都可以打开这个特定的小柜子,这就相当于多个变量指向同一个对象。例如,
x = 10
y = 10
z = 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:
pass
a = []
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 = a
print(getrefcount(b)) # 输出3
print(getrefcount(a)) # 输出3
x = 1
print(getrefcount(x)) #输出1640
y = 1
print(getrefcount(x)) # 输出1641
print(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)) # 输出2
my = MyClass(data)
print(id(my.other_obj)) # 输出4364264288
print(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 objgraph
from 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,也就是引用重定向。
(1)用del删除某一个引用
del语句可以删除一个变量对某一个块内存空间的引用,也可以删除集合对象中的某个item,代码如下:
from sys import getrefcount
person = {‘name‘:‘Bill‘,‘age‘:40}
person1 = person
print(getrefcount(person1)) # 输出3
del person # 删除person对字典的引用
print(getrefcount(person1)) # 由于引用少了一个,所以输出为2
# print(person) # 抛出异常 # 被删除的变量相当于重来没定义过,所以这条语句会抛出异常
del person1[‘age‘] # 删除字典中key为age的值对
print(person1)
(2)引用重定向
from sys import getrefcount
value1 = [1,2,3,4]
value2 = value1
value3 = value2
print(getrefcount(value2)) # 输出4
value1 = 20
print(getrefcount(value2)) # 输出3,因为value1重新指向了20
value3 = None
print(getrefcount(value2)) # 输出2,因为value3被设置为None,也就是不指向任何内存空间,相当于空指针
6. 垃圾回收
像Java、JavaScript、Python这样的编程语言,都不允许直接通过代码释放变量占用的内存,虚拟机会自动释放这些内存区域。所以很多程序员就会认为在这些语言中可以放心大胆地申请各种类型的变量,并不用担心变量的释放问题,因为系统会自动替我们完成这些烦人的工作。
没错,这些语言的虚拟机会自动释放一些不需要的内存块,用专业术语描述就是:垃圾回收。 相当于为系统减肥或减负。因为不管你的计算机有多少内存,只要不断创建新的变量,哪怕该变量只占用了1个字节的内存空间,内存也有用完的一天。所以虚拟机会在适当的时候释放掉不需要的内存块。
在前面已经提到过,虚拟机会回收引用计数为0的内存块,因为这些内存块没有任何变量指向他们,所以留着没有任何意义。那么到底虚拟机在什么时候才会回收这些内存块呢?通常来讲,虚拟机会设置一个内存阈值,一旦超过了这个阈值,就会自动启动垃圾回收器来回收不需要的内存空间。对于不同编程语言的这个阈值是不同的。对于Python来说,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。当两者的差值高于某个阈值时,垃圾回收才会启动。
我们可以通过gc模块的get_threshold()方法,查看该阈值:
import gc
print(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 objgraph
from sys import getrefcount
a = {}
b = {‘data‘:a}
a[‘value‘] = b
del a
del 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种策略:
1. 引用计数策略(为0时释放)
2. 循环引用策略(将相关引用计数器多减1)
3. 分代策略(解决了GC的效率问题)
通过这些策略的共同作用,可以让Python更加有效地管理内存,更进一步地提高Python的性能。
获取更多学习资源,可以关注“极客起源”公众号