1. 程式人生 > >Python垃圾回收機制!非常實用!不會的看過來!

Python垃圾回收機制!非常實用!不會的看過來!

python作為一門解釋型語言,以程式碼簡潔易懂著稱。我們可以直接對名稱賦值,而不必宣告型別。名稱型別的確定、記憶體空間的分配與釋放都是由python直譯器在執行時進行的。python這一自動管理記憶體功能極大的減小了程式設計師負擔,這也是成就python自身的重要原因之一。所以,這一篇文章我們就聊一聊python的記憶體管理。

引用計數

Python中,主要通過引用計數(Reference Counting)進行垃圾回收。

Copy

1
2
3
4
 typedef struct_object {
 int ob_refcnt;
 struct_typeobject *ob_type;
} PyObject;

在Python中每一個物件的核心就是一個結構體PyObject,它的內部有一個引用計數器(ob_refcnt)。程式在執行的過程中會實時的更新ob_refcnt的值,來反映引用當前物件的名稱數量。當某物件的引用計數值為0,那麼它的記憶體就會被立即釋放掉。

進群:548377875   即可獲取數十套PDF哦!

以下情況是導致引用計數加一的情況:

  • 物件被建立,例如a=2
  • 物件被引用,b=a
  • 物件被作為引數,傳入到一個函式中
  • 物件作為一個元素,儲存在容器中

下面的情況則會導致引用計數減一:

  • 物件別名被顯示銷燬 del
  • 物件別名被賦予新的物件
  • 一個物件離開他的作用域
  • 物件所在的容器被銷燬或者是從容器中刪除物件

我們還可以通過sys包中的getrefcount()來獲取一個名稱所引用的物件當前的引用計數(注意,這裡getrefcount()本身會使得引用計數加一)

Copy

1
sys.getrefcount(a)

 

引用計數法有其明顯的優點,如高效、實現邏輯簡單、具備實時性,一旦一個物件的引用計數歸零,記憶體就直接釋放了。不用像其他機制等到特定時機。將垃圾回收隨機分配到執行的階段,處理回收記憶體的時間分攤到了平時,正常程式的執行比較平穩。但是,引用計數也存在著一些缺點,通常的缺點有:

  • 邏輯簡單,但實現有些麻煩。每個物件需要分配單獨的空間來統計引用計數,這無形中加大的空間的負擔,並且需要對引用計數進行維護,在維護的時候很容易會出錯。
  • 在一些場景下,可能會比較慢。正常來說垃圾回收會比較平穩執行,但是當需要釋放一個大的物件時,比如字典,需要對引用的所有物件迴圈巢狀呼叫,從而可能會花費比較長的時間。
  • 迴圈引用。這將是引用計數的致命傷,引用計數對此是無解的,因此必須要使用其它的垃圾回收演算法對其進行補充。

也就是說,Python 的垃圾回收機制,很大一部分是為了處理可能產生的迴圈引用,是對引用計數的補充。

標記清除解決迴圈引用

Python採用了“標記-清除”(Mark and Sweep)演算法,解決容器物件可能產生的迴圈引用問題。(注意,只有容器物件才會產生迴圈引用的情況,比如列表、字典、使用者自定義類的物件、元組等。而像數字,字串這類簡單型別不會出現迴圈引用。作為一種優化策略,對於只包含簡單型別的元組也不在標記清除演算法的考慮之列)

跟其名稱一樣,該演算法在進行垃圾回收時分成了兩步,分別是:

  • A)標記階段,遍歷所有的物件,如果是可達的(reachable),也就是還有物件引用它,那麼就標記該物件為可達;
  • B)清除階段,再次遍歷物件,如果發現某個物件沒有標記為可達,則就將其回收。

如下圖所示,在標記清除演算法中,為了追蹤容器物件,需要每個容器物件維護兩個額外的指標,用來將容器物件組成一個雙端連結串列,指標分別指向前後兩個容器物件,方便插入和刪除操作。python直譯器(Cpython)維護了兩個這樣的雙端連結串列,一個連結串列存放著需要被掃描的容器物件,另一個連結串列存放著臨時不可達物件。在圖中,這兩個連結串列分別被命名為”Object to Scan”和”Unreachable”。圖中例子是這麼一個情況:link1,link2,link3組成了一個引用環,同時link1還被一個變數A(其實這裡稱為名稱A更好)引用。link4自引用,也構成了一個引用環。從圖中我們還可以看到,每一個節點除了有一個記錄當前引用計數的變數ref_count還有一個gc_ref變數,這個gc_ref是ref_count的一個副本,所以初始值為ref_count的大小。

Python垃圾回收機制!非常實用!不會的看過來!

 

 

gc啟動的時候,會逐個遍歷”Object to Scan”連結串列中的容器物件,並且將當前物件所引用的所有物件的gc_ref減一。(掃描到link1的時候,由於link1引用了link2,所以會將link2的gc_ref減一,接著掃描link2,由於link2引用了link3,所以會將link3的gc_ref減一…..)像這樣將”Objects to Scan”連結串列中的所有物件考察一遍之後,兩個連結串列中的物件的ref_count和gc_ref的情況如下圖所示。這一步操作就相當於解除了迴圈引用對引用計數的影響。

Python垃圾回收機制!非常實用!不會的看過來!

 

 

接著,gc會再次掃描所有的容器物件,如果物件的gc_ref值為0,那麼這個物件就被標記為GC_TENTATIVELY_UNREACHABLE,並且被移至”Unreachable”連結串列中。下圖中的link3和link4就是這樣一種情況。

Python垃圾回收機制!非常實用!不會的看過來!

 

 

如果物件的gc_ref不為0,那麼這個物件就會被標記為GC_REACHABLE。同時當gc發現有一個節點是可達的,那麼他會遞迴式的將從該節點出發可以到達的所有節點標記為GC_REACHABLE,這就是下圖中link2和link3所碰到的情形。

Python垃圾回收機制!非常實用!不會的看過來!

 

 

除了將所有可達節點標記為GC_REACHABLE之外,如果該節點當前在”Unreachable”連結串列中的話,還需要將其移回到”Object to Scan”連結串列中,下圖就是link3移回之後的情形。

Python垃圾回收機制!非常實用!不會的看過來!

 

 

第二次遍歷的所有物件都遍歷完成之後,存在於”Unreachable”連結串列中的物件就是真正需要被釋放的物件。如上圖所示,此時link4存在於Unreachable連結串列中,gc隨即釋放之。

上面描述的垃圾回收的階段,會暫停整個應用程式,等待標記清除結束後才會恢復應用程式的執行。

分代回收

在迴圈引用物件的回收中,整個應用程式會被暫停,為了減少應用程式暫停的時間,Python 通過“分代回收”(Generational Collection)以空間換時間的方法提高垃圾回收效率。

分代回收是基於這樣的一個統計事實,對於程式,存在一定比例的記憶體塊的生存週期比較短;而剩下的記憶體塊,生存週期會比較長,甚至會從程式開始一直持續到程式結束。生存期較短物件的比例通常在 80%~90% 之間,這種思想簡單點說就是:物件存在時間越長,越可能不是垃圾,應該越少去收集。這樣在執行標記-清除演算法時可以有效減小遍歷的物件數,從而提高垃圾回收的速度。

python gc給物件定義了三種世代(0,1,2),每一個新生物件在generation zero中,如果它在一輪gc掃描中活了下來,那麼它將被移至generation one,在那裡他將較少的被掃描,如果它又活過了一輪gc,它又將被移至generation two,在那裡它被掃描的次數將會更少。

gc的掃描在什麼時候會被觸發呢?答案是當某一世代中被分配的物件與被釋放的物件之差達到某一閾值的時候,就會觸發gc對某一世代的掃描。值得注意的是當某一世代的掃描被觸發的時候,比該世代年輕的世代也會被掃描。也就是說如果世代2的gc掃描被觸發了,那麼世代0,世代1也將被掃描,如果世代1的gc掃描被觸發,世代0也會被掃描。

該閾值可以通過下面兩個函式檢視和調整:

Copy

1
2
gc.get_threshold() # (threshold0, threshold1, threshold2).
gc.set_threshold(threshold0[, threshold1[, threshold2]])

 

下面對set_threshold()中的三個引數threshold0, threshold1, threshold2進行介紹。gc會記錄自從上次收集以來新分配的物件數量與釋放的物件數量,當兩者之差超過threshold0的值時,gc的掃描就會啟動,初始的時候只有世代0被檢查。如果自從世代1最近一次被檢查以來,世代0被檢查超過threshold1次,那麼對世代1的檢查將被觸發。相同的,如果自從世代2最近一次被檢查以來,世代1被檢查超過threshold2次,那麼對世代2的檢查將被觸發。get_threshold()是獲取三者的值,預設值為(700,10,10).

總結

總體來說,在Python中,主要通過引用計數進行垃圾回收;通過 “標記-清除” 解決容器物件可能產生的迴圈引用問題;通過 “分代回收” 以空間換時間的方法提高垃圾回收效率。

Python垃圾回收機制!非常實用!不會的看過來!