1. 程式人生 > 其它 >淺析Python垃圾回收機制

淺析Python垃圾回收機制

概述

程式是指在執行的過程中動態的申請記憶體空間,隨著程式的執行不再需要使用這些記憶體空間。這時如果不釋放這些空間,就會駐留記憶體成為無用的垃圾,也就是造成了記憶體洩漏。
垃圾回收機制:GC,垃圾回收機制的存在,使得開發人員可以把更多的精力關注業務邏輯,而不是記憶體中垃圾的回收,因此GC的存在幫助了程式開發人員管理記憶體。
Python中的垃圾回收以引用計數為主,標記清除和分代回收為輔,同時還有快取機制。

一、引用計數

1、環狀雙向連結串列refchain

在Python程式執行時,會建立一個環狀雙向連結串列refchain, 程式執行過程中建立的任何一個物件最終都會加入到這個雙向連結串列中。
比如:

name = 'weilanhanf 
age = 100 
hobby = ["eat", "sleep"]

以上三個不同型別的物件,首先對進行初始化,然後進行一下封裝成雙向連結串列節點,根據資料型別,會封裝一些不同的屬性。然後插入到雙向連結串列中

封裝的屬性包括:[上一個物件的引用,下一個物件的引用,物件型別,引用個數]
name = 'weilanhanf'
new_name = name  # 引用個數屬性+1

封裝的屬性包括:[上一個物件的引用,下一個物件的引用,物件型別,引用個數, value=100]
age = 100

封裝的屬性包括:[上一個物件的引用,下一個物件的引用,物件型別,引用個數, items, 元素個數等]
hobby = ["eat", "sleep"]

在Python中,一切都是物件。在Cpython中,每個Py物件又都對應個一個C語言結構體,其都有相同的屬性:PyObject(包含4個屬性:前後引用,引用計數器,資料型別)。如果是容器型別,還會有額外的ob_size(元素個數)等屬性。

2、型別結構體

比如浮點型別

data = 3.14
CPython內部物件建立,包含屬性:
_obj_next = rechain中上一個物件
_obj_pre = rechain中下一個物件
ob_refcat = 1
ob_type = float
ob_fval = 3.14

其他型別物件也類似,都對應著一個相似的結構體物件

3、引用計數器

如程式中有如下程式碼

v1 = 3.14
v2 = 999
v3 = (1, 2, 3 )

python直譯器初始化的時候,就會生成refchain連結串列。執行Python程式的時候,底層會逐個建立refchain連結串列節點物件,物件會根據程式中變數型別初始化屬性,然後加入到refchain連結串列中。節點物件中維護一個refcnct的值,也就是引用計數器,記錄對該物件的引用個數。當有其他物件增加引用,引用計數器的值+1。當減少引用,值-1。

v1 = 3.14

# 增加引用
v4 = v1  

# 減少引用
del v4
del v1

當有一個物件的引用計數器值為0,意味著物件無用,這個物件在記憶體中認為是垃圾,需要進行銷燬。從rechain連結串列中移除,然後進行快取或者回收其所佔用的記憶體,詳細見如下的快取機制。

4、迴圈引用問題

引用計數通過記錄物件是否被引用。但是可能存在容器物件中的元素分別又是別的容器物件,這樣就會產生迴圈引用問題。如下:

v1 = [11,22,33]  # refchain中建立一個列表物件。由於v1=物件,所以列表引物件用計數器為1.
v2 = [44,55,66]  # 把v2追加到v1中,則v2對應的[44,55,66]物件的引用計數器加1,最終為2.

v1.append(v2)  # 把v2追加到v1中,則v2對應的[44,55,66]物件的引用計數器加1,最終為2.
v2.append(v1)  # 把v2追加到v1中,則v2對應的[44,55,66]物件的引用計數器加1,最終為2.

del v1  # 引用計數器-1
del v2  # 引用計數器-1

del操作之後,v1,v2的引用都為1。雖然刪除引用了,預設無用。但是兩個數陣列還在連結串列中,常駐記憶體,成為垃圾佔用記憶體。
所以python引用標記清除解決這個迴圈引用存在的問題。

二、標記清除

目的:為了解決迴圈引用產生的問題。
實現:在Python的底層再維護一個連結串列,專門存放可能存在迴圈引用的物件(列表,字典,元組等)。
也就是除了refchain雙向迴圈連結串列之外還要在維護一個連結串列,暫且稱為連結串列A。當建立的容器物件,還要再新增到第二個連結串列A中。

在執行的過程中,某些情況下,會去掃描迴圈引用的連結串列A中的每個元素,檢查是否有迴圈引用。如果有,讓迴圈引用的雙方的引用計數器-1,如果引用計數器為0,則認為是垃圾,從連結串列移除,進行回收。
使用標記清除也會有兩個問題:

  • 什麼時候掃描存放容器型別物件的連結串列A?
  • 掃描連結串列然後在檢測是否有迴圈引用本身會很耗時,怎麼解決?

三、分代回收

為了解決標記清除中的兩個問題,將存放可能存在迴圈引用的物件的連結串列A分成了3個連結串列:

  • 0代連結串列:0代中物件達到700個,掃描0代連結串列
  • 1代連結串列:0代連結串列掃描10次,則1代連結串列掃描1次
  • 2代連結串列:1代連結串列掃描10次,則2代連結串列掃描1次

所有的容器物件先新增的時候,都要先放到0代連結串列中,然後依次往1代,2代中新增。當0代連結串列達到700,進行掃描0代連結串列。如果連結串列存在迴圈引用的物件,其引用計數器-1。如果計數器為0,進行垃圾回收。如果不為0,則放入到1代連結串列中,此次1代連結串列記錄0代連結串列掃描次數+1。
同時解決了標記回收的兩個問題:
什麼時候掃描:當連結串列達到閾值時候進行掃描
掃描耗時問題:分成三條連結串列,減少耗時

四、Python的GC小結

綜上,Python中的垃圾回收以引用計數為主,標記清除和分代回收為輔。
程式執行的過程中維護了一個refchain的雙向環狀連結串列,這個連結串列中儲存程式建立的所有物件,每種型別的物件中都有一個ob_ refcnt引用計數器的值會根據引用個數動態+1、-1。最後當引用計數器變為0時會進行垃圾回收(物件銷燬、refchain中移除)。
但是,那些可以有多個元素組成的物件可能會存在迴圈引用的問題,為了解決這個問題Python又引入了標記清除和分代回收。執行過程中,維護了4個連結串列,

refchain
2代連結串列
1代連結串列
0代連結串列

分代連結串列當達到各自的閾值時,就會觸發掃描連結串列進行標記清除的動作。

五、快取機制

Python中,不同型別的物件有自己的快取機制。

1、 小資料池

有時候在建立同一個物件,可能會出現,其地址相同的現象。比如:

v1 = 7
v2 = 9
v3 = 9

此時為同一個物件,變數指向記憶體地址一致
id(v2) == id(v3)  # True

這就是因為開闢了小物件池的原因,v2和v3指向的是同一塊記憶體空間。
為了避免重複建立和銷燬一些常見物件,Python會維護一個物件池,或者說是小資料池,其中包括一些int和短字元物件。比如啟動直譯器時候,Python建立的整型小資料池包括:-5,-4,... 257,不包括257和-5,開區間。
小資料物件池中物件中的引用計數器在建立時新增引用預設為1,所以無論程式中自己編寫的變數新增引用或者減少引用,小資料池中物件的引用計數器都不會為0,也都不會被回收,程式執行過程中永遠駐留記憶體。

2、free_list快取

free_list快取適用於float/list/tuple/dict等型別。
當一個以上型別的物件的引用計數器為0的時候,其實不會從refchain連結串列剔除然後立即回收,而是新增到一個free_list連結串列中當作快取,以備後續使用。如果有新的物件被建立,不用再重新開闢記憶體,而是從free_list中取快取。

v1 = 3.14  # 開闢記憶體,建立物件,初始化引用計數器等值,加入到refchain中

del v1  # 從refcain中移除,然後將節點物件新增到free_list中

v9 = 999.99  # 不會立即開闢空間建立物件,如果free_list不為空,則是從free_list中獲取快取,然後初始化從快取中獲取的物件,然後在新增到refchain中

同樣free_list也是有上限的,如果free_list滿,比如閾值為80,則從refchain中移除的物件不會快取到free_list中,而是立即銷燬。

六、Golang的GC小結

Golang中的GC隨著版本的提高,GC機制也越來越高效。

  • V1.3 普通標記清除法,整體過程需要STW(Stop The World),效率低下
  • V1.5 三色標記法,堆空間啟用寫屏障,棧空間不啟動,全部掃描之後,還需要一次STW掃描棧,效率一般
  • V1.8 三色標記法,讀和寫屏障機制,棧空間不啟動,堆空間啟動,整體過程幾乎不需要STW,效率較高
    V1.8表示1.8版本,對比Golang的也就是,通常的GC都要將程式中物件用連結串列或者其他的資料結構組織起來,然後根據物件之間的引用鏈,逐個掃描物件,標記物件是否為記憶體垃圾然後決定是否回收。

:以上僅為自己的學習筆記,如有相同或者不對地方,輕噴,謝謝謝謝。