[Python之路] 記憶體管理&垃圾回收
一、python原始碼
1.準備原始碼
下載Python原始碼:https://www.python.org/ftp/python/3.8.0/Python-3.8.0.tgz
解壓得到資料夾:
我們主要關注Include中的".h"檔案以及Objects目錄中的".c"檔案。
我們從Include和Objects中的檔案型別就可以看出Python直譯器是C語言編寫的。
2.object.h
在Include資料夾中,全部都是".h"檔案。
這些C語言標頭檔案中主要存放著巨集、函式宣告、結構體宣告、全域性變數等。
我們在Python中所有的類都繼承自Object,所以在這個C語言的object.h中,我們可以看看是如何實現的。
我們首先看object.h檔案內容(小部分):
#define _PyObject_HEAD_EXTRA \ struct _object *_ob_next; \ struct _object *_ob_prev; typedef struct _object { // 維護雙向連結串列refchain _PyObject_HEAD_EXTRA // 引用計數 Py_ssize_t ob_refcnt; // 資料的型別 struct _typeobject *ob_type; } PyObject; typedef struct { PyObject ob_base; // 資料型別為多元素時,維護一個容量個數 Py_ssize_t ob_size; /* Number of items in variable part */ } PyVarObject;
我們可以從上面的原始碼中看到,兩個結構體PyObject和PyVarObject,區別是PyVarObject多一個ob_size屬性,這個屬性代表的是元素的個數(例如list、dict中元素的個數)。
所以,這兩個結構體,分別對應不同型別的資料的頭(Python中任何資料的定義,都會有這個頭):
PyObject:float
PyVarObject:list、dict、tuple、set、int、str、bool
因為Python中的int是不限制長度的,所以底層實現是用的str,所以int也屬於PyVarObject陣營。Python中的bool實際上是0和1,所以也是int,也屬於PyVarObject陣營。
3.floatobject.h
typedef struct { PyObject_HEAD double ob_fval; } PyFloatObject;
我們以float型別為例,可以看到建立一個float型別的資料,實際上是建立了一個PyFloatObject結構體的例項。
PyFloatObject結構體中包含了一個PyObject_HEAD(這就是object.h中的PyObject),以及一個double ob_fval,這個double變數就是我們存放的值。
我們以Python中的實際操作,來看原始碼中的過程:
1)python中定義變數v = 0.3:
原始碼流程:
a.開闢記憶體(記憶體大小,是sizeof(PyFloatObject))
b.初始化
ob_fval=0.3
ob_type=float
ob_refcnt=1
c.將物件加入雙向連結串列refchain中
2)python執行操作name=v:
原始碼流程:
ob_refcnt+=1
3)python執行操作del v:
原始碼流程:
ob_refcnt-=1
4)python執行
def func(arg): print(arg) func(name)
原始碼流程:
執行時開闢棧:ob_refcnt+=1
結束時銷燬棧:ob_refcnt-=1
5)python執行del name:
原始碼流程:
ob_refcnt-=1
在這幾次操作中,每次進行ob_refcnt-=1的時候都會判斷ob_refcnt是否等於0。如果是0,這將其歸為垃圾,按理說GC回收器應該將其回收,請看第二節。
二、快取機制
在第一節中,如果float變數的引用都被刪除,引用計數為0以後,按理說GC回收器應該對其進行回收。
1.free_list快取連結串列
但編譯器認為,使用者經常都要定義float型別的變數,所以他將該PyFloatObject物件從refchain連結串列中拿出來,並且放到另一個單向連結串列中,這個單向連結串列就是快取(叫free_list)。
我們做個驗證:
>>> v = 8.9 >>> name = v >>> del v >>> id(name) 1706304905888 >>> del name >>> xx = 9.0 >>> id(xx) 1706304905888 >>>
可以看到,name的id為1706304905888,刪除name後,由建立了一個float變數xx,結果xx的id還是為170630490588。這就驗證了快取的機制。
為什麼要使用快取(free_list)?
因為回收記憶體空間和開闢記憶體空間都要消耗時間,所以,如果將空間放到快取中,有新的float變數被定義的話,直接從快取中拿到地址,重新進行一次初始化,並將新的值賦給ob_fval即可。
2.free_list最大長度
注意,這裡的單向連結串列(free_list)只是針對PyFloatObject型別的。而且這個連結串列有最大長度100。可以在floatobject.c中看到相關定義:
#ifndef PyFloat_MAXFREELIST // 定義free_list的最大長度 #define PyFloat_MAXFREELIST 100 #endif // 用numfree來表示當前free_list有多長 static int numfree = 0; // free_list指標 static PyFloatObject *free_list = NULL;
例如同時有1000個float變數的引用計數變為0,則歸入free_list的只有100個,其餘900個可能會被回收。
在float中,free_list的最大長度是100,而在其他的資料型別中,最大長度可能不一樣。
例如list的free_list的最大長度為80:
#ifndef PyList_MAXFREELIST #define PyList_MAXFREELIST 80 #endif static PyListObject *free_list[PyList_MAXFREELIST]; static int numfree = 0;
dict也為80:
#ifndef PyDict_MAXFREELIST #define PyDict_MAXFREELIST 80 #endif static PyDictObject *free_list[PyDict_MAXFREELIST]; static int numfree = 0; static PyDictKeysObject *keys_free_list[PyDict_MAXFREELIST]; static int numfreekeys = 0;
3.其他優化機制
也不是所有的資料型別都使用free_list快取機制,例如int用的是小資料池進行優化:
#ifndef NSMALLPOSINTS #define NSMALLPOSINTS 257 #endif #ifndef NSMALLNEGINTS #define NSMALLNEGINTS 5 #endif
三、垃圾回收機制
Python的GC主要遵循以下原則:
引用計數器為主,標記清除和分代回收為輔。
1.引用計數器(同上,略)
2.迴圈引用
迴圈引用一般發生在列表、字典、物件等容器類物件,他們之間可以互相巢狀,例如:
a = [1, 2] b = [4, 5] # b的引用計數會加1,變為2 a.append(b) # a的引用計數變為0 del a # b的引用計數變為1,但是已經無法訪問b,所以就形成了記憶體洩漏 del b
在這種情況下, 記憶體發生了洩漏,就要利用標記清除來解決迴圈引用的問題。
2.標記清除
針對那些容器類的物件,在Python中會將他們單獨放到一個雙向連結串列(非refchain)中,做定期掃描。
參考:https://www.cnblogs.com/saolv/p/8411993.html
#第一組迴圈引用# a = [1,2] b = [3,4] a.append(b) b.append(a) del a ## #第二組迴圈引用# c = [4,5] d = [5,6] c.append(d) d.append(c) del c del d #至此,原a和原c和原d所引用的物件的引用計數都為1,b所引用的物件的引用計數為2, e [7,8] del e
現在說明一下標記清除:程式碼執行到上面這塊了,此時,我們的本意是想清除掉c和d和e所引用的物件,而保留a和b所引用的物件。但是c和d所引用物件的引用計數都是非零,原來的簡單的方法只能清除掉e,c和d所引用物件目前還在記憶體中。
假設,此時我們預先設定的週期時間到了,此時該標記清除大顯身手了。他的任務就是,在a,b,c,d四個可變物件中,找出真正需要清理的c和d,而保留a和b。
首先,他先劃分出兩撥,一撥叫root object(存活組),一撥叫unreachable(死亡組)。然後,他把各個物件的引用計數複製出來,對這個副本進行引用環的摘除。
環的摘除:假設兩個物件為A、B,我們從A出發,因為它有一個對B的引用,則將B的引用計數減1;然後順著引用達到B,因為B有一個對A的引用,同樣將A的引用減1,這樣,就完成了迴圈引用物件間環摘除。
摘除完畢,此時a的引用計數的副本是0,b的引用計數的副本是1,c和d的引用計數的副本都是0。那麼先把副本為非0的放到存活組,副本為0的打入死亡組。如果就這樣結束的話,就錯殺了a了,因為b還要用,我們把a所引用的物件在記憶體中清除了b還能用嗎?顯然還得在審一遍,別把無辜的人也給殺了,於是他就在存活組裡,對每個物件都分析一遍,由於目前存活組只有b,那麼他只對b分析,因為b要存活,所以b裡的元素也要存活,於是在b中就發現了原a所指向的物件,於是就把他從死亡組中解救出來。至此,進過了一審和二審,最終把所有的任然在死亡組中的物件通通殺掉,而root object繼續存活。b所指向的物件引用計數任然是2,原a所指向的物件的引用計數仍然是1
掃描後存活組的物件,將放到另外一個連結串列中去,一共有3個這樣的連結串列,代表3代。
3.分代回收
分代回收就是指維護容器類物件的三個連結串列,3個連結串列對應三層。對最底層的連結串列掃描10次,才對上層的連結串列掃描一次。
這其實是為了節省效能,儘量少掃描物件。
認為沒有問題經常使用的物件放入上一層,減少掃描次數。
所以,在Python的記憶體管理中,一共維護著4個連結串列,其中一個連結串列refchain用來管理一般的資料型別,例如float等。而另外3個連結串列組成分代,管理容器類資料型別。
參考部落格:https://www.cnblogs.com/wupeiqi/articles/11507404.