1. 程式人生 > >[Python之路] 記憶體管理&垃圾回收

[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.