1. 程式人生 > 實用技巧 >python垃圾回收與記憶體管理

python垃圾回收與記憶體管理

用通俗的語言解釋記憶體管理和垃圾回收的過程,搞懂這一部分就可以去面試、去裝逼了…

基於C語言原始碼底層,讓你瞭解垃圾回收機制的實現

更多詳細關於垃圾回收:https://pythonav.com/wiki/detail/6/88/

1、引用計數器

1.1、環狀雙向連結串列refchain

在Python的C原始碼中有一個名為refchain的環狀雙向連結串列,這個連結串列比較牛逼了,因為Python程式中一旦建立物件都會把這個物件新增到refchain這個連結串列中。也就是說他儲存著所有的物件。例如:

name = "武沛齊"
age = 18
hobby = ["籃球", "美女"]


內部會建立一些資料【上一個物件、下一個物件、型別、引用個數】
name 
= '武沛齊' new = name 內部會建立一些資料【上一個物件、下一個物件、型別、引用個數、val=18】 age = 18 內部會建立一些資料【上一個物件、下一個物件、型別、引用個數、元素個數】 hobby = ["籃球", "美女"]

在C原始碼中如何體現每個物件中都有相同的值,PyObject結構體(4個值)

有多個元素組成的物件,PyObject結構體(4個值) + ob_size

1.2、型別封裝結構體

data = 3.14

內部會建立
_ob_next = refchain的上一個物件
_ob_prev = refchain的下一個物件
ob_type = float
ob_refcnt 
= 1 ob_fval = 3.14

1.3、引用計數器

當python執行程式時, 會根據資料型別的不同找到對應的結構體, 根據結構體中的欄位來進行建立相關的資料,然後將物件新增到refchain雙向連結串列中

在C原始碼中有兩個關鍵的結構體,PyObject、PyVarObject

每個物件 中都有ob_refcnt就是引用計數器,預設為1,當有其他變數引用物件時, 引用計數器發生變化

當值被多次引用時候,不會在記憶體中重複建立資料,而是引用計數器+1。 當物件被銷燬時候同時會讓引用計數器-1,如果引用計數器為0,則將物件從refchain連結串列中摘除,同時在記憶體中進行銷燬(暫不考慮快取等特殊情況)。

age = 18
name = "武沛齊"
nickname = name   # 物件武沛齊的引用計數器 + 1     引用
del name   # 物件武沛齊的引用計數器 - 1       刪除引用

1.4、引用計數器的bug(迴圈引用問題)

基於引用計數器進行垃圾回收非常方便和簡單,但他還是存在迴圈引用的問題,導致無法正常的回收一些資料,例如:

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

對於上述程式碼會發現,執行del操作之後,沒有變數再會去使用那兩個列表物件,但由於迴圈引用的問題,他們的引用計數器不為0

所以他們的狀態:永遠不會被使用、也不會被銷燬。專案中如果這種程式碼太多,就會導致記憶體一直被消耗,直到記憶體被耗盡,程式崩潰。

2、標記清除

為了解決迴圈引用的問題,引入了標記清除技術,專門針對那些可能存在迴圈引用的物件進行特殊處理。

可能存在迴圈應用的型別有:列表、元組、字典、集合、自定義類等那些能進行資料巢狀的型別。

2.1、實現標記清除:

在python底層在維護一個連結串列,專門儲存可能存在迴圈引用的型別:列表、元組、字典、集合。

在python內部某種情況觸發,回去掃描可能存在迴圈引用的連結串列中的每個元素,檢查是否存在迴圈引用,如果存在則讓雙方的引用計數器均 - 1 ,如果是0則垃圾回收

2.2、標記清除問題

1、什麼時候掃描?

2、可能存在迴圈引用的連結串列掃描代價大,每次掃描耗時久

3、分代回收

對標記清除中的連結串列進行優化,將那些可能存在循引用的物件拆分到3個連結串列,連結串列稱為:0/1/2三代,每代都可以儲存物件和閾值,當達到閾值時,就會對相應的連結串列中的每個物件做一次掃描,除迴圈引用各自減1並且銷燬引用計數器為0的物件。

  • 0代:0代中物件個數閥值達到700個掃描一次
  • 1代: 0代掃描的次數閥值達到10次,則1代掃描一次
  • 2代:1代掃描的次數閥值達到10次,則2代掃描一次

第一步:當建立物件age=19時,會將物件新增到refchain連結串列中。

第二步:當建立物件num_list = [11,22]時,會將列表物件新增到 refchain 和 generations 0代中。

4、小結

在python中維護了一個refchain的雙向環狀連結串列,這個連結串列中儲存程式建立的所有物件,每種型別的物件中都有一個ob_refcnt引用計數器的值, 引用個數+1 -1, 最後當引用計數為0時會進行垃圾回收(物件銷燬,refchain中移除)

但是,在python中對於那些可以有多個元素組成的物件可能會存在迴圈引用的問題,為了解決這個問題python引入了標記清除和分代回收

5、python快取機制

從上文大家可以瞭解到當物件的引用計數器為0時,就會被銷燬並釋放記憶體。

而實際上他不是這麼的簡單粗暴,因為反覆的建立和銷燬會使程式的執行效率變低。Python中引入了“快取機制”機制。
例如:引用計數器為0時,不會真正銷燬物件,而是將他放到一個名為free_list的連結串列中,之後會再建立物件時不會在重新開闢記憶體,而是在free_list中將之前的物件來並重置內部的值來使用。

float型別,維護的free_list連結串列最多可快取100個float物件。

  v1 = 3.14    # 開闢記憶體來儲存float物件,並將物件新增到refchain連結串列。
  print( id(v1) ) # 記憶體地址:4436033488
  del v1    # 引用計數器-1,如果為0則在rechain連結串列中移除,不銷燬物件,而是將物件新增到float的free_list.
  v2 = 9.999    # 優先去free_list中獲取物件,並重置為9.999,如果free_list為空才重新開闢記憶體。
  print( id(v2) ) # 記憶體地址:4436033488
  # 注意:引用計數器為0時,會先判斷free_list中快取個數是否滿了,未滿則將物件快取,已滿則直接將物件銷燬。

int型別,不是基於free_list,而是維護一個small_ints連結串列儲存常見資料(小資料池),小資料池範圍:-5 <= value < 257。即:重複使用這個範圍的整數時,不會重新開闢記憶體。

  v1 = 38    # 去小資料池small_ints中獲取38整數物件,將物件新增到refchain並讓引用計數器+1。
  print( id(v1))  #記憶體地址:4514343712
  v2 = 38 # 去小資料池small_ints中獲取38整數物件,將refchain中的物件的引用計數器+1。
  print( id(v2) ) #記憶體地址:4514343712
  # 注意:在直譯器啟動時候-5~256就已經被加入到small_ints連結串列中且引用計數器初始化為1,程式碼中使用的值時直接去small_ints中拿來用並將引用計數器+1即可。另外,small_ints中的資料引用計數器永遠不會為0(初始化時就設定為1了),所以也不會被銷燬。

str型別,維護unicode_latin1[256]連結串列,內部將所有的ascii字元快取起來,以後使用時就不再反覆建立。

  v1 = "A"
  print( id(v1) ) # 輸出:4517720496
  del v1
  v2 = "A"
  print( id(v1) ) # 輸出:4517720496
  # 除此之外,Python內部還對字串做了駐留機制,針對那麼只含有字母、數字、下劃線的字串(見原始碼Objects/codeobject.c),如果記憶體中已存在則不會重新在建立而是使用原來的地址裡(不會像free_list那樣一直在記憶體存活,只有記憶體中有才能被重複利用)。
  v1 = "wupeiqi"
  v2 = "wupeiqi"
  print(id(v1) == id(v2)) # 輸出:True

list型別,維護的free_list陣列最多可快取80個list物件。

  v1 = [11,22,33]
  print( id(v1) ) # 輸出:4517628816
  del v1
  v2 = ["","沛齊"]
  print( id(v2) ) # 輸出:4517628816

tuple型別,維護一個free_list陣列且陣列容量20,陣列中元素可以是連結串列且每個連結串列最多可以容納2000個元組物件。元組的free_list陣列在儲存資料時,是按照元組可以容納的個數為索引找到free_list陣列中對應的連結串列,並新增到連結串列中。

  v1 = (1,2)
  print( id(v1) )
  del v1  # 因元組的數量為2,所以會把這個物件快取到free_list[2]的連結串列中。
  v2 = ("武沛齊","Alex")  # 不會重新開闢記憶體,而是去free_list[2]對應的連結串列中拿到一個物件來使用。
  print( id(v2) )

dict型別,維護的free_list陣列最多可快取80個dict物件。

  v1 = {"k1":123}
  print( id(v1) )  # 輸出:4515998128
  del v1
  v2 = {"name":"武沛齊","age":18,"gender":""}
  print( id(v1) ) # 輸出:4515998128