溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

Python的內(nèi)存管理機制

發(fā)布時間:2020-06-16 16:08:13 來源:億速云 閱讀:205 作者:元一 欄目:編程語言

背景

在Python中內(nèi)存使用內(nèi)存表來表示。內(nèi)存表告訴我們數(shù)據(jù)在堆內(nèi)存中的地址。我們通常使用變量(數(shù)據(jù)的引用,即地址)來指向數(shù)據(jù)。任何編程語言都會有一個內(nèi)存模型,以便管理為變量分配的內(nèi)存空間。不同的編程語言,如C、C++、Java、C#,Python,它們的內(nèi)存模型都是不相同的,本文將以現(xiàn)在最流行的Python語言為例,來說明動態(tài)類型語言的內(nèi)存管理方式。

1. 重復使用內(nèi)存空間

賦值語句是Python語言中最簡單的語句之一,雖然賦值語言很簡單,但卻內(nèi)含玄機。

例如,將一個值賦給一個變量是最常見的賦值操作。

n = 1 # 將1賦給變量n

整數(shù)1是一個值,而n是一個對象。這是最簡單不過的賦值語句了。那么在內(nèi)存中是如何操作的呢?其實在Python中,任何值都可以看做是一個對象,例如,1是int類的實例,True是bool類的實例。所以將1賦給變量n,其實是n指向了int類型的對象,所以n本質(zhì)上就是一個對象的引用。

Python作為動態(tài)語言,采用了引用與對象分離的策略,這也使得任何引用都可以指向任何對象,而且可以動態(tài)改變引用指向的對象類型,也就是說,可以將一個指向int類型的對象的引用重新指向bool類型的對象。所以可以將Python語言的對象模型看做是超市里的儲物柜(這里只是用儲物柜作為內(nèi)存模型的比喻,不要與超市儲物柜實際的操作進行比較)。

Python的內(nèi)存管理機制
每一個小柜子相當于一塊內(nèi)存區(qū)域,這塊內(nèi)存區(qū)域保存了不同類型的值。對于像C++、Java一樣的靜態(tài)語言,一旦分配了某一個小柜子,就意味著這個柜子只能保存特定的物品,如只能放鞋子、只能放手套、只能放衣服。而對于打開小柜子的鑰匙(相當于變量),同時也只能打開某一個特定的小柜子,相當于一個變量同時只能指向一個對象一樣。當然,在鑰匙上進行設(shè)置后,該鑰匙可以指向其他同類型的小柜子(相當于改變變量指向的對象,如將一個指向int類型對象的變量指向了另外一個int類型的對象)。

不過Python語言就不一樣了。在Python版的儲物柜中,每一個小柜子并不限定存儲物品的類型,而一把鑰匙經(jīng)過設(shè)置后,可以打開任意一個小柜子(相當于任意改變變量指向的對象)。這樣做的好處是更靈活,沒必要為存儲特定的物品,增加新的儲物柜,只要還有空的小柜子,就可以放任何物品。但缺點也很明顯,就是打開一個小柜子后,需要多進行一步判斷的操作,判斷這個小柜子到底是存儲的什么物品。

當然,對于同一個特定的小柜子,可能會配有多把鑰匙,這些鑰匙都可以打開這個特定的小柜子,這就相當于多個變量指向同一個對象。例如,

x = 10y = 10z = 10

x、y和z三個變量的值都是10,這個10就相當于要保存在小柜子中的物品。x、y和z相當于3把鑰匙。而3個變量中的值都是10,所以被認為是同一個值(物品),因此,就只需要動用一個小柜子保存10,而3個變量都會指向這個小柜子(由于計算機中值具有無限可復制性,所以只要有一個物品,就可以無限復制,所以不必考慮現(xiàn)實中將小柜子中的東西拿走了就為空的情況)。所以其實x、y和z這3個變量指向了同一個內(nèi)存地址(相當于小柜子的序號)??梢杂胕d函數(shù)驗證這3個變量的內(nèi)存地址是否相同,代碼如下:

print(id(x))print(id(y))print(id(z))

輸出結(jié)果如下:

4470531424

4470531424

4470531424

也可以用下面的代碼將內(nèi)存地址轉(zhuǎn)換為十六進制形式。

print(hex(id(x)))print(hex(id(y)))print(hex(id(z)))

輸出結(jié)果如下:

0x10a76e560

0x10a76e560

0x10a76e560

根據(jù)前面的輸出結(jié)果,很顯然,x、y和z指向的是同一個內(nèi)存地址。讀者可以將10換成其他的對象,如True、10.12、"hello world",結(jié)果都是一樣(由于機器不同,輸出的內(nèi)存地址可能不同,但3個變量的內(nèi)存地址肯定都是相同的)。

也可以用is運算符判斷這3個變量是否指向同一個值。

print(x is y is z) # 輸出結(jié)果:True

但要注意,只有不可變類型,如int、float、bool、string等,才會使用同一個儲物柜。如果是可變類型,如列表、對象,每次都會分配新的內(nèi)存空間。這里的不可變是指值一旦確定,值本身無法修改。例如int類型的10,這個10是固定的,不能修改,如果修改成11,那么就是新的值了,需要申請新的小柜子。而列表,如空列表[],以后還可以向空列表中添加任何類型的值,也可以修改和刪除列表中的值。所以沒有辦法為所有的空列表分配同一個小柜子,因為有的空列表,現(xiàn)在是空,以后不一定是空。所以每一個列表類型的值都會新分配一個小柜子,但元組就不同了,由于元組是只讀的,所以一開始是空的元組,那么這個元組今生今世將永遠是空,所以可以為所有的空元組,以及所有相同元素個數(shù)和值的元組分配同一個小柜子??聪旅娲a:

class MyClass:passa = []
b = []
c = MyClass()
d = MyClass()
t1 = (1,2,3)
t2 = (1,2,3)print(a is b) # False 元素個數(shù)和類型相同的列表不會使用同一個內(nèi)存空間(小柜子)print(c is d) # False MyClass類的不同實例不會使用同一個內(nèi)存空間(小柜子)print(t1 is t2) # True 元素個數(shù)和類型相同的元組會使用同一個內(nèi)存空間(小柜子)

這種將相同,但不可變的值保存在同一個內(nèi)存空間的方式也稱為值的緩存,這樣做非常節(jié)省內(nèi)存空間,而且程序的執(zhí)行效率更高。因為省去了大量分配內(nèi)存空間的時間。

2. 引用計數(shù)器

在Python語言中是無法自己釋放變量內(nèi)存的,所以Python虛擬機提供了自動回收內(nèi)存的機制,那么Python虛擬機是如何知道哪一個變量占用的內(nèi)存可以被回收呢?通常的做法是為每一塊被占用的內(nèi)存設(shè)置一個引用計數(shù)器,如果該內(nèi)存塊沒有被任何變量引用(也就是引用計數(shù)器為0),那么該內(nèi)存塊就可以被釋放,否則無法被釋放。

在sys模塊中有一個getrefcount函數(shù),可以用來獲取任何變量指向的內(nèi)存塊的引用計數(shù)器當前的值。用法如下:

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函數(shù)獲得引用計數(shù)器的值時,實際上會創(chuàng)建一個臨時的引用,所以getrefcount函數(shù)返回的值會比實際的值多1。而對于具體的值(如本例的1),系統(tǒng)可能在很多地方都引用了該值,所以根據(jù)Python版本和當前運行的應用不同,getrefcount函數(shù)返回的值是不確定的。

3. 對象引用

像C++這樣的編程語言,對象的傳遞分為值傳遞和指針傳遞。如果是值傳遞,就會將對象中的所有成員屬性的值都一起復制,而指針傳遞,只是復制了對象的內(nèi)存首地址。不過在Python中,并沒有指針的概念。只有一個對象引用。也就是說,Python語言中對象的復制與C++中的對象指針復制是一樣的。只是將對象引用計數(shù)器加1而已。具體看下面的代碼:

from sys import getrefcount 
# 類的構(gòu)造方法傳入另外一個對象的引用class MyClass(object):def __init__(self, other_obj):
self.other_obj = other_obj # 這里的other_obj與后面的data指向了同一塊內(nèi)存地址 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會在使用這些值時,先將其保存在一塊固定的內(nèi)存區(qū)域,然后將所有賦給這些值的變量指向這塊內(nèi)存區(qū)域,同時引用計數(shù)器加1。

例如,

a = 1

b = 1

其中a和b指向了同一塊內(nèi)存空間,這兩個變量其實都保存了對1的引用。使用id函數(shù)查看這兩個變量的引用地址是相同的。

4. 循環(huán)引用與拓撲圖

如果對象引用非常多,就可能會構(gòu)成非常復雜的拓撲結(jié)果。例如,下面代碼的引用拓撲關(guān)系就非常復雜。估計大多數(shù)同學都無法一下子看出這段程序中各個對象的拓撲關(guān)系。

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模塊繪制出某個變量與其他變量的拓撲關(guān)系,objgraph是第三方模塊,需要使用pip install objgraph命令安裝,如果機器上安裝了多個Python環(huán)境,要注意看看pip命令是否屬于當前正在使用的Python環(huán)境,不要將objgraph安裝在其他的Python環(huán)境中。

安裝完objgraph后,可以使用下面命令看看data3與其他對象的引用關(guān)系。

import objgraph
objgraph.show_refs([data3], filename='對象引用關(guān)系.png')

show_refs函數(shù)會在當前目錄下生成一個”對象引用關(guān)系.png“的圖像文件,如下圖所示。

Python的內(nèi)存管理機制
如果對象之間互相引用,有可能會形成循環(huán)引用。也就是a引用b,b引用a,見下面的代碼。

import objgraphfrom sys import getrefcount
a = {}
b = {'data':a}
a['value'] = b
objgraph.show_refs([b], filename='循環(huán)引用1.png')

在這段代碼中。a和b都是一個字典,b中的一個value引用了a,而a的一個value引用了b,所以產(chǎn)生了一個循環(huán)引用。這段代碼的引用拓撲圖如下:
Python的內(nèi)存管理機制

很明顯,這兩個字典是循環(huán)引用的。

不光是多個對象之間的引用可以產(chǎn)生循環(huán)引用,只有一個對象也可以產(chǎn)生循環(huán)引用,代碼如下:

a = {}
a['value'] = a
a = []
a.append(a)print(getrefcount(a))
objgraph.show_refs([a], filename='循環(huán)引用2.png')

5. 減少引用計數(shù)的兩種方法

前面一直說讓引用計數(shù)器增加的方法,那么如何讓引用計數(shù)器減少呢?通常有如下兩種方法:

(1)用del刪除某一個引用

(2)將變量指向另外一個引用,或設(shè)置為None,也就是引用重定向。

用del刪除某一個引用

del語句可以刪除一個變量對某一個塊內(nèi)存空間的引用,也可以刪除集合對象中的某個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被設(shè)置為None,也就是不指向任何內(nèi)存空間,相當于空指針

6. 垃圾回收

像Java、JavaScript、Python這樣的編程語言,都不允許直接通過代碼釋放變量占用的內(nèi)存,虛擬機會自動釋放這些內(nèi)存區(qū)域。所以很多程序員就會認為在這些語言中可以放心大膽地申請各種類型的變量,并不用擔心變量的釋放問題,因為系統(tǒng)會自動替我們完成這些煩人的工作。

沒錯,這些語言的虛擬機會自動釋放一些不需要的內(nèi)存塊,用專業(yè)術(shù)語描述就是:垃圾回收。 相當于為系統(tǒng)減肥或減負。因為不管你的計算機有多少內(nèi)存,只要不斷創(chuàng)建新的變量,哪怕該變量只占用了1個字節(jié)的內(nèi)存空間,內(nèi)存也有用完的一天。所以虛擬機會在適當?shù)臅r候釋放掉不需要的內(nèi)存塊。

在前面已經(jīng)提到過,虛擬機會回收引用計數(shù)為0的內(nèi)存塊,因為這些內(nèi)存塊沒有任何變量指向他們,所以留著沒有任何意義。那么到底虛擬機在什么時候才會回收這些內(nèi)存塊呢?通常來講,虛擬機會設(shè)置一個內(nèi)存閾值,一旦超過了這個閾值,就會自動啟動垃圾回收器來回收不需要的內(nèi)存空間。對于不同編程語言的這個閾值是不同的。對于Python來說,會記錄其中分配對象(object allocation)和取消分配對象(object deallocation)的次數(shù)。當兩者的差值高于某個閾值時,垃圾回收才會啟動。

我們可以通過gc模塊的get_threshold()方法,查看該閾值:

import gcprint(gc.get_threshold())

輸出的結(jié)果為:

(700, 10, 10)

這個700就是這個閾值。后面的兩個10是與分代回收相關(guān)的閾值,后面會詳細介紹??梢允褂胓c模塊中的set_threshold方法設(shè)置這個閾值。

由于垃圾回收是一項昂貴的工作,所以如果計算機的內(nèi)存足夠大,可以將這個閾值設(shè)置的大一點,這樣可以避免垃圾回收器頻繁調(diào)用。

當然,如果覺得必要,也可以使用下面的代碼手工啟動垃圾回收器。不過要注意,手工啟動垃圾回收器后,垃圾回收器也不一定會立刻啟動,通常會在系統(tǒng)空閑時啟動垃圾回收器。

gc.collect()

7. 變量不用了要設(shè)置為None

有大量內(nèi)存被占用,是一定要被釋放的。但釋放這些內(nèi)存有一個前提條件,就是這個內(nèi)存塊不能有任何變量引用,也就是引用計數(shù)器為0。如果有多個變量指向同一個內(nèi)存塊,而且有一些變量已經(jīng)不再使用了,一個好的習慣是將變量設(shè)置為None,或用del刪除該變量。

person = {'Name':'Bill'}
value = [1,2,3]del person
value = None

當刪除person變量,以及將value設(shè)置為None后,就不會再有任何變量指向字典和列表了,所以字典和列表占用的內(nèi)存空間會被釋放。

8. 解決循環(huán)引用的回收問題

在前面講了Python GC(垃圾回收器)的一種算法策略,就是引用計數(shù)法,這種方法是Python GC采用的主要方法。不過這種策略也有其缺點。下面就看一下引用計數(shù)法的優(yōu)缺點。

優(yōu)點:簡單,實時(一旦為0就會立刻釋放內(nèi)存空間,毫不猶豫)

缺點: 維護性高(簡單實時,但是額外占用了一部分資源,雖然邏輯簡單,但是麻煩。好比你吃草莓,吃一次洗一下手,而不是吃完洗手。),不能解決循環(huán)引用的問題。

那么Python到底是如何解決循環(huán)引用釋放的問題呢?先看下面的代碼。

import objgraphfrom sys import getrefcount
a = {}
b = {'data':a}
a['value'] = bdel adel b

在這段代碼中,很明顯,a和b互相引用。最后通過del語句刪除a和b。由于a和b是循環(huán)引用,如果按前面引用計數(shù)器的方法,在刪除a和b之前,兩個字典分別由兩個引用(引用計數(shù)器為2),一個是自身引用,另一個是a或b中的value引用的自己。如果只是刪除了a和b,似乎這兩個字典各自還剩一個引用。但其實這兩個字典的內(nèi)存空間已經(jīng)釋放。那么Python是如何做到的呢?

其實Python GC在檢測所有引用時,會檢測哪些引用之間是循環(huán)引用,如果檢測到某些變量之間循環(huán)引用,例如,a引用b,b引用a,就會在檢測a時,將b的引用計數(shù)器減1,在檢測b時,會將a的引用計數(shù)器減1。也就是說,Python GC當發(fā)現(xiàn)某些引用是循環(huán)引用后,會將這些引用的計數(shù)器多減一個1。所以這些循環(huán)引用指向的空間仍然會被釋放。

9. 分代回收

如果是多年的朋友,或一起做了多年的生意,有多年的業(yè)務往來,往往會產(chǎn)生一定的信任。通常來講,合作的時間越長,產(chǎn)生的信任感就會越深。Python GC采用的垃圾回收策略中,也會使用這種信任感作為輔助算法,讓GC運行得更有效率。這種策略就是分代(generation)回收。

分代回收的策略有一個基本假設(shè),就是存活的越久,越可能被經(jīng)常使用,所以出于信任和效率,對這些“長壽”對象給予特殊照顧,在GC對所有對象進行檢測時,就會盡可能少地檢測這些“長壽”對象。就是現(xiàn)在有很多企業(yè)是免檢企業(yè)一樣,政府出于對這些企業(yè)的信任,給這些企業(yè)生產(chǎn)出的產(chǎn)品予以免檢的特殊優(yōu)待。

那么Python對什么樣的對象會給予哪些特殊照顧呢?Python將對象共分為3代,分別用0、1、2表示。任何新創(chuàng)建的對象是0代,不會給予任何特殊照顧,當某一個0代對象經(jīng)過若干次垃圾回收后仍然存活,那么就會將這個對象歸入1代對象,如果這個1代對象,再經(jīng)過若干次回收后,仍然存活,就會將該對象歸為2代對象。

在前面的描述中,涉及到一個“若干次”回收,那么這個“若干次”是指什么呢?在前面使用get_threshold函數(shù)獲取閾值時返回了(700,10,10),這個700就是引用計數(shù)策略的閾值,而后面的兩個10與分代策略有關(guān)。第1個10是指第0代對象經(jīng)過了10次垃圾回收后仍然存在,就會將其歸為第1代對象。第2個10是指第1代對象經(jīng)過了10次垃圾回收后仍然存在,就會將其歸為第2代對象。也就是說,GC需要執(zhí)行100次,才會掃描到第2代對象。當然,也可以通過set_threshold函數(shù)來調(diào)整這些值。

import gc 
gc.set_threshold(600, 5, 6)
向AI問一下細節(jié)

免責聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI