1. 程式人生 > 實用技巧 >Python - 垃圾回收機制

Python - 垃圾回收機制

引言:

直譯器在執行到定義變數的語法時,會申請記憶體空間來存放變數的值,而記憶體的容量是有限的,這就涉及到變數值所佔用記憶體空間的回收問題,當一個變數值沒有用了(簡稱垃圾)就應該將其佔用的記憶體給回收掉,那什麼樣的變數值是沒有用的呢?

單從邏輯層面分析,我們定義變數將變數值存起來的目的是為了以後取出來使用,而取得變數值需要通過其繫結的直接引用(如x=10,10被x直接引用)或間接引用(如l=[x,],x=10,10被x直接引用,而被容器型別l間接引用),所以當一個變數值不再繫結任何引用時,我們就無法再訪問到該變數值了,該變數值自然就是沒有用的,就應該被當成一個垃圾回收。

毫無疑問,記憶體空間申請

回收都是非常耗費精力的事情,而且存在很大的危險性,稍有不慎就有可能引發記憶體溢位問題,好在Cpython直譯器提供了自動的垃圾回收機制來幫我們解決了這件事。

一:簡介

1.什麼是垃圾

在python中,會定義一個變數名,然後賦給它變數值,如果這個變數呼叫結束了,不需要它了,將它刪除,但是這時候變數名被刪除了,變數值還在,這時候這個變數值就是 垃圾

2.什麼是垃圾回收機制

垃圾回收機制(Garbage Collection)簡稱GC, 是python直譯器自帶的一種機制, 專門用來回收不可用的變數值所佔用的記憶體空間

3.為什麼要有垃圾回收機制

程式在執行的過程中會申請記憶體空間, 而對於一些無用的記憶體空間如果不及時清理

的話會一直佔用記憶體空間, 進而造成記憶體溢位, 最終導致程式GG
但是記憶體管理是一件麻煩事, Python則提供了垃圾回收機制來釋放不必要的記憶體空間

4.堆區 與 棧區

棧區:

用於存放 變數名值記憶體地址的關聯關係。

堆區:

用於存放 變數值,也是記憶體管理回收的地方。

5.檢視變數的記憶體地址

x = 10
y = 20

# id(x) 可以檢視變數x的id
print(id(x))        # 140716865820608
print(id(y))        # 140716865820928

# 用hex()把id(x)包起來,可以檢視變數x的記憶體地址
print(hex(id(x)))   # 0x7ffb32cd17c0
print(hex(id(y)))   # 0x7ffb32cd1900

二:引用計數

1.什麼是引用計數

Python垃圾回收主要以引用計數為主分代回收為輔。引用計數法的原理是每個物件維護一個ob_ref,用來記錄當前物件被引用的次數,也就是來追蹤到底有多少引用指向了這個物件.

1個變數值的引用計數為0時,才會被當做垃圾回收

x = 1   # 1的引用計數為1
y = 1   # 1的引用計數為2
z = 2   # 1的引用計數為3
z = y   # 1的引用計數為2

print(hex(id(x)))   # 0x7ffb24b016a0
print(hex(id(y)))   # 0x7ffb24b016a0
print(hex(id(z)))   # 0x7ffb24b016a0

引用計數增加的情況

1.物件被建立  a = 1
2.物件被引用  b = a
3.物件被作為引數,傳到函式中   func(a)
4.物件作為一個元素,儲存在容器中   l1 = {1, 'a', 'b',2}

引用計數減少的情況

1.當該物件的別名被顯式銷燬時  del a
2.當該物件的引別名被賦予新的物件,   a = 30
3.一個物件離開它的作用域,例如 func函式執行完畢時,函式裡面的區域性變數的引用計數器就會減一(但是全域性變數不會)
4.將該元素從容器中刪除時,或者容器被銷燬時。

2.直接引用 與 間接引用

直接引用:

直接引用指的是從棧區出發直接引用到的記憶體地址

x = 10
y = 20
z = 30

print(hex(id(x)))       # 0x7ffb32cd17c0
print(hex(id(y)))       # 0x7ffb32cd1900
print(hex(id(z)))       # 0x7ffb2bec1a40

間接引用:

間接引用指的是從棧區出發引用到堆區後,再通過進一步引用才能到達的記憶體地址。

x = 10
y = x
l1 = [20, 30]
l2 = [x, l1]

print(hex(id(x)))       # 0x7ffb32cd17c0
print(hex(id(y)))       # 0x7ffb32cd17c0
print(hex(id(l1)))      # 0x293b663fb80
print(hex(id(l2)))      # 0x293b67868c0

迴圈引用 問題:

l1l2互相引用

l1 = [1]                    # 此時l1被引用一次,引用計數為1
l2 = [2]                    # 此時l2被引用一次,引用計數為1

l1.append(l2)               # l1=[值1的記憶體地址,l2列表的記憶體地址]    #此時l2又被引用一次,引用計數為2
l2.append(l1)               # l2=[值222的記憶體地址,l1列表的記憶體地址]  #此時l1又被引用一次,引用計數為2

print(hex(id(l1[0])))       # 0x7ffb231416a0    # 1的記憶體地址
print(hex(id(l1[1])))       # 0x2be2d426900     # l2的記憶體地址   # l1引用l2
print(hex(id(l2)))          # 0x2be2d426900

print(hex(id(l2[0])))       # 0x7ffb231416c0    # 2的記憶體地址
print(hex(id(l2[1])))       # 0x2be2d2dfb80     # l1的記憶體地址   # l2引用l1
print(hex(id(l1)))          # 0x2be2d2dfb80

print(l2)                   # [2, [1, [...]]]
print(l1[1])                # [2, [1, [...]]]

# 此時,l1和l2互相引用

刪除l1l2後,在堆區的l1和l2的記憶體地址中還存在著相互引用,使得它們的引用計數始終不為0

這時候它們就無法被回收,所以迴圈引用致命的,這與手動進行記憶體管理所產生的記憶體洩露毫無區別。

所以Python引入了“標記-清除” 與“分代回收”來分別解決引用計數的迴圈引用效率低的問題

l1 = [1]                    # 此時l1被引用一次,引用計數為1
l2 = [2]                    # 此時l2被引用一次,引用計數為1

l1.append(l2)               # l1=[值1的記憶體地址,l2列表的記憶體地址]    #此時l2又被引用一次,引用計數為2
l2.append(l1)               # l2=[值222的記憶體地址,l1列表的記憶體地址]  #此時l1又被引用一次,引用計數為2

print(hex(id(l1[0])))       # 0x7ffb231416a0    # 1的記憶體地址
print(hex(id(l1[1])))       # 0x2be2d426900     # l2的記憶體地址   # l1引用l2
print(hex(id(l2)))          # 0x2be2d426900

print(hex(id(l2[0])))       # 0x7ffb231416c0    # 2的記憶體地址
print(hex(id(l2[1])))       # 0x2be2d2dfb80     # l1的記憶體地址   # l2引用l1
print(hex(id(l1)))          # 0x2be2d2dfb80

print(l2)                   # [2, [1, [...]]]
print(l1[1])                # [2, [1, [...]]]

# 此時,l1和l2互相引用

del l1                      #l1引用次數-1
del l2                      #l2引用次數-1

# 但是此時我們卻無法訪問l1/l2列表內的內容,本該被當做垃圾刪除.
# 但是它的引用計數為1,被l2/l1列表間接引用。
# 這個時候引用計數在這種垃圾身上就失效了。

三:標記 - 清除

容器物件(比如:list,set,dict,class,instance)都可以包含對其他物件的引用,所以都可能產生迴圈引用

而“標記-清除”計數就是為了解決迴圈引用的問題。

標記/清除演算法的做法是當應用程式可用的記憶體空間被耗盡的時,就會停止整個程式,然後進行兩項工作,第一項則是標記,第二項則是清除

1.標記

通俗地講:

棧區相當於“”,凡是從根出發可以訪達(直接或間接引用)的,都稱之為“有根之人”,有根之人當活,無根之人當死

具體地:

標記的過程其實就是,遍歷所有的GC Roots物件(棧區中的所有內容或者執行緒都可以作為GC Roots物件),然後將所有GC Roots的物件可以直接或間接訪問到的物件標記為存活的物件其餘的均為非存活物件,應該被清除

2.清除

清除的過程將遍歷堆中所有的物件,將沒有標記的物件全部清除掉。

當有了標記 - 清除機制之後,當同時刪除l1與l2時,會清理到棧區中l1與l2的內容以及直接引用關係

啟用標記清除演算法時,從棧區出發,沒有任何一條直接或間接引用可以訪達l1與l2,即l1與l2成了“無根之人”,於是l1與l2都沒有被標記為存活,二者會被清理掉,這樣就解決了迴圈引用帶來的記憶體洩漏問題。

四:分代回收

1.效率問題

基於引用計數的回收機制,每次回收記憶體,都需要把所有物件的引用計數都遍歷一遍,這是非常消耗時間的,於是引入了分代回收提高回收效率,分代回收採用的是用“空間換時間”的策略。

2.分代

分代回收的核心思想是:

在歷經多次掃描的情況下,都沒有被回收變數,gc機制就會認為,該變數是常用變數,gc對其掃描的頻率會降低,具體實現原理如下:

分代指的是根據存活時間來為變數劃分不同等級(也就是不同的代)

新定義的變數,放到新生代這個等級中,假設每隔1分鐘掃描新生代一次,如果發現變數依然被引用,那麼該物件的權重(權重本質就是個整數)加一。

當變數的權重大於某個設定得值(假設為3),會將它移動到更高一級的青春代,青春代的gc掃描的頻率低於新生代(掃描時間間隔更長),假設5分鐘掃描青春代一次。

這樣每次gc需要掃描的變數的總個數就變少了,節省了掃描的總時間,接下來,青春代中的物件,也會以同樣的方式被移動到老年代中。

也就是等級(代)越高,被垃圾回收機制掃描的頻率越低

3.回收

回收依然是使用引用計數作為回收的依據

以班級為例,可以把學生分為三類:學霸、普通學生、學渣

​ 學霸:過於優秀,一週查一次作業

​ 普通學生:一般般,三天查一次

​ 學渣:不用多說,每天都查

雖然分級檢查了,檢查的時間效率變高了,但是存在著缺陷,如果學霸不交作業,要下一週才能 查出來,那他的學霸地位就不保了,存在漏洞。

沒有十全十美的方案:

毫無疑問,如果沒有分代回收,即引用計數機制一直不停地對所有變數進行全體掃描,可以更及時地清理掉垃圾佔用的記憶體,但這種一直不停地對所有變數進行全體掃描的方式效率極低,所以我們只能將二者中和

綜上

垃圾回收機制是在清理垃圾&釋放記憶體的大背景下,允許分代回收以極小部分垃圾不會被及時釋放為代價,以此換取引用計數整體掃描頻率的降低,從而提升其效能,這是一種以空間換時間的解決方案目錄。