1. 程式人生 > >一文徹底搞懂python的垃圾回收機制

一文徹底搞懂python的垃圾回收機制

 

一 、什麼是記憶體管理和垃圾回收

Python GC主要使用引用計數(reference counting)來跟蹤和回收垃圾。在引用計數的基礎上,通過“標記-清除”(mark and sweep)解決容器物件可能產生的迴圈引用問題,通過“分代回收”(generation collection)以空間換時間的方法提高垃圾回收效率。

現在的高階語言如java,c#等,都採用了垃圾收集機制,而不再是c,c++裡使用者自己管理維護記憶體的方式。自己管理記憶體極其自由,可以任意申請記憶體,但如同一把雙刃劍,為大量記憶體洩露,懸空指標等bug埋下隱患。
對於一個字串、列表、類甚至數值都是物件,且定位簡單易用的語言,自然不會讓使用者去處理如何分配回收記憶體的問題。
python裡也同java一樣採用了垃圾收集機制,不過不一樣的是:

最關鍵的一句話:
python採用的是引用計數機制為主,標記-清除分代收集兩種機制為輔的策略

二、引用計數——reference count

PyObject是每個物件必有的內容,其中ob_refcnt就是做為引用計數。當一個物件有新的引用時,它的ob_refcnt就會增加,當引用它的物件被刪除,它的ob_refcnt就會減少.引用計數為0時,該物件生命就結束了。

優點:

  1. 簡單
  2. 實時性

缺點:

 

那麼關鍵問題來了,什麼時候,引用計數增加1,什麼時候引用計數減少1呢?

我們可以通過使用內建的模組sys.getrefcount(a)可以檢視a物件的引用計數,但是比正常計數大1,因為呼叫函式的時候傳入a,這會讓a的引用計數+1。

1、簡單例項:

  1. 維護引用計數消耗資源
  2. 迴圈引用——最致命的缺點(後面會定義)
  3. 導致引用計數+1的情況
    1. 物件被建立,例如a=23
    2. 物件被引用,例如b=a
    3. 物件被作為引數,傳入到一個函式中,例如func(a)
    4. 物件作為一個元素,儲存在容器中,例如list1=[a,a]
  4. 導致引用計數-1的情況
    1. 物件的別名被顯式銷燬,例如del a
    2. 物件的別名被賦予新的物件,例如a=24
    3. 一個物件離開它的作用域,例如f函式執行完畢時,func函式中的區域性變數(全域性變數不會)
    4. 物件所在的容器被銷燬,或從容器中刪除物件
import sys

class A:
    pass

def func(x):
    print(f'物件a:{sys.getrefcount(x)-1}',end='  ')
    return x

#a=123.56
a=A()       #建立物件a
print(f'物件a:{sys.getrefcount(a)-1}')
b=a         #再一次引用物件a
print(f'物件a:{sys.getrefcount(a)-1},物件b:{sys.getrefcount(b)-1}')
c=func(a)   #物件a作為函式引數
print(f'物件c:{sys.getrefcount(c)-1}')
d=list()    #物件a作為列表元素
d.append(a)
print(f'物件a:{sys.getrefcount(a)-1},物件d:{sys.getrefcount(d)-1}')

執行結果為:

物件a:1
物件a:2,物件b:2
物件a:4 ,  物件c:3
物件a:4,物件d:1

==============================================================================================

2、一個小的誤區

a=100

sys.getrefcount(a)-1

返回結果為:50   

這是為什麼呢?

這是因為python系統維護著一個常見的“整數常量池”即-5-255,在這個區間的數字會有其他的處理方式,這說明100這個數字,目前在系統中有 50 個引用。包括字串也有一些特殊的處理,所以在使用應用技術的時候,最好是使用自己自定義的資料型別,這樣方便分析,這也是上面為什麼要自定義一個型別A的原因。

3、減少引用的例項

import sys
class B:
    pass

del d[0]  #刪除列表d中的元素a
print(f'物件a:{sys.getrefcount(a)-1},物件d:{sys.getrefcount(d)-1}')

a=B()   # a被重新複製,引用計數為1
print(f'物件a:{sys.getrefcount(a)-1}')
del a   #刪除了a
  

執行結果為:

物件a:3,物件d:1
物件a:1

 4、引用計數的致命缺陷——迴圈引用導致的記憶體洩漏

什麼是記憶體洩漏呢?

指由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體的情況。記憶體洩漏並非指記憶體在物理上的消失,而是應用程式分配某段記憶體後,由於設計錯誤,失去了對該段記憶體的控制,因而造成了記憶體的浪費。導致程式執行速度減慢甚至系統崩潰等嚴重後果。
有 __del__() 函式的物件間的迴圈引用是導致記憶體洩漏的主凶。不使用一個物件時使用:del object 來刪除一個物件的引用計數就可以有效防止記憶體洩漏問題。通過 Python 擴充套件模組 gc 來檢視不能回收的物件的詳細資訊。可以通過 sys.getrefcount(obj) 來獲取物件的引用計數,並根據返回值是否為 0 來判斷是否記憶體
洩漏。

def f2():
    while True:
        c1=ClassA()
        c2=ClassA()
        c1.t=c2
        c2.t=c1
        del c1
        del c2

建立了c1,c2後,0x237cf30(c1對應的記憶體,記為記憶體1),0x237cf58(c2對應的記憶體,記為記憶體2)這兩塊記憶體的引用計數都是1,執行c1.t=c2c2.t=c1後,這兩塊記憶體的引用計數變成2.
在del c1後,記憶體1的物件的引用計數變為1,由於不是為0,所以記憶體1的物件不會被銷燬,所以記憶體2的物件的引用數依然是2,在del c2後,同理,記憶體1的物件,記憶體2的物件的引用數都是1。
雖然它們兩個的物件都是可以被銷燬的,但是由於迴圈引用,導致垃圾回收器都不會回收它們,所以就會導致記憶體洩露。

list1 = []
list2 = []
list1.append(list2)
list2.append(list1)

 list1與list2相互引用,如果不存在其他物件對它們的引用,list1與list2的引用計數也仍然為1,所佔用的記憶體永遠無法被回收,這將是致命的。

5、針對“迴圈引用”的解決辦法

(1)標記清除技術——mark and sweep

(2)分代回收技術——generation collection

(3)手動使用gc模組

 

二、標記-清除機制——mark and sweep

基本思路是先按需分配,等到沒有空閒記憶體的時候從暫存器和程式棧上的引用出發,遍歷以物件為節點、以引用為邊構成的圖,把所有可以訪問到的物件打上標記,然後清掃一遍記憶體空間,把所有沒標記的物件釋放。

針對迴圈引用的情況:我們有一個“孤島”或是一組未使用的、互相指向的物件,但是誰都沒有外部引用。換句話說,我們的程式不再使用這些節點物件了,所以我們希望Python的垃圾回收機制能夠足夠智慧去釋放這些物件並回收它們佔用的記憶體空間。但是這不可能,因為所有的引用計數都是1而不是0。Python的引用計數演算法不能夠處理互相指向自己的物件。你的程式碼也許會在不經意間包含迴圈引用並且你並未意識到。事實上,當你的Python程式執行的時候它將會建立一定數量的“浮點數垃圾”,Python的GC不能夠處理未使用的物件因為應用計數值不會到零。 
這就是為什麼Python要引入Generational GC演算法的原因! 
 


  『標記清除(Mark—Sweep)』演算法是一種基於追蹤回收(tracing GC)技術實現的垃圾回收演算法。它分為兩個階段:第一階段是標記階段,GC會把所有的『活動物件』打上標記,第二階段是把那些沒有標記的物件『非活動物件』進行回收。那麼GC又是如何判斷哪些是活動物件哪些是非活動物件的呢?
  
  物件之間通過引用(指標)連在一起,構成一個有向圖,物件構成這個有向圖的節點,而引用關係構成這個有向圖的邊。從根物件(root object)出發,沿著有向邊遍歷物件,可達的(reachable)物件標記為活動物件,不可達的物件就是要被清除的非活動物件。根物件就是全域性變數、呼叫棧、暫存器。

 

  在上圖中,我們把小黑圈視為全域性變數,也就是把它作為root object,從小黑圈出發,物件1可直達,那麼它將被標記,物件2、3可間接到達也會被標記,而4和5不可達,那麼1、2、3就是活動物件,4和5是非活動物件會被GC回收。


標記清除演算法作為Python的輔助垃圾收集技術主要處理的是一些容器物件,比如list、dict、tuple,instance等,因為對於字串、數值物件是不可能造成迴圈引用問題。Python使用一個雙向連結串列將這些容器物件組織起來。不過,這種簡單粗暴的標記清除演算法也有明顯的缺點:清除非活動的物件前它必須順序掃描整個堆記憶體,哪怕只剩下小部分活動物件也要掃描所有物件。 
 

三、分代技術——generation collection

分代回收的整體思想是:將系統中的所有記憶體塊根據其存活時間劃分為不同的集合,每個集合就成為一個“代”,垃圾收集頻率隨著“代”的存活時間的增大而減小,存活時間通常利用經過幾次垃圾回收來度量。

Python預設定義了三代物件集合,索引數越大,物件存活時間越長。

  1. 分代技術是一種典型的以空間換時間的技術,這也正是java裡的關鍵技術。這種思想簡單點說就是:物件存在時間越長,越可能不是垃圾,應該越少去收集。
  2. 這樣的思想,可以減少標記-清除機制所帶來的額外操作。分代就是將回收物件分成數個代,每個代就是一個連結串列(集合),代進行標記-清除的時間與代內物件
  3. 存活時間成正比例關係。
  4. 從上面程式碼可以看出python裡一共有三代,每個代的threshold值表示該代最多容納物件的個數。預設情況下,當0代超過700,或1,2代超過10,垃圾回收機制將觸發。
  5. 0代觸發將清理所有三代,1代觸發會清理1,2代,2代觸發後只會清理自己。

舉例: 當某些記憶體塊M經過了3次垃圾收集的清洗之後還存活時,我們就將記憶體塊M劃到一個集合A中去,而新分配的記憶體都劃分到集合B中去。當垃圾收集開始工作時,大多數情況都只對集合B進行垃圾回收,而對集合A進行垃圾回收要隔相當長一段時間後才進行,這就使得垃圾收集機制需要處理的記憶體少了,效率自然就提高了。在這個過程中,集合B中的某些記憶體塊由於存活時間長而會被轉移到集合A中,當然,集合A中實際上也存在一些垃圾,這些垃圾的回收會因為這種分代的機制而被延遲。

總結:

分代回收是一種以空間換時間的操作方式,Python將記憶體根據物件的存活時間劃分為不同的集合,每個集合稱為一個代,Python將記憶體分為了3“代”,分別為年輕代(第0代)、中年代(第1代)、老年代(第2代),他們對應的是3個連結串列,它們的垃圾收集頻率與物件的存活時間的增大而減小。新建立的物件都會分配在年輕代,年輕代連結串列的總數達到上限時,Python垃圾收集機制就會被觸發,把那些可以被回收的物件回收掉,而那些不會回收的物件就會被移到中年代去,依此類推,老年代中的物件是存活時間最久的物件,甚至是存活於整個系統的生命週期內。同時,分代回收是建立在標記清除技術基礎之上。分代回收同樣作為Python的輔助垃圾收集技術處理那些容器物件.

四、垃圾回收與效能調優

1.手動垃圾回收

2.調高垃圾回收閾值
3.避免迴圈引用(手動解迴圈引用和使用弱引用)