1. 程式人生 > >Python深度探索(1):記憶體管理機制

Python深度探索(1):記憶體管理機制

任何程式語言都會有一個記憶體模型,以便管理為變數分配的記憶體空間。不同的程式語言,如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 = 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的效能。   獲取更多學習資源,可以關注“極客起源”公眾號