python作為一門解釋型語言,以代碼簡(jiǎn)潔易懂著稱。我們可以直接對(duì)名稱賦值,而不必聲明類型。名稱類型的確定、內(nèi)存空間的分配與釋放都是由python解釋器在運(yùn)行時(shí)進(jìn)行的。python這一自動(dòng)管理內(nèi)存功能極大的減小了程序員負(fù)擔(dān),這也是成就python自身的重要原因之一。所以,這一篇文章我們就聊一聊python的內(nèi)存管理。
引用計(jì)數(shù)
Python中,主要通過引用計(jì)數(shù)(Reference Counting)進(jìn)行垃圾回收。
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;
在Python中每一個(gè)對(duì)象的核心就是一個(gè)結(jié)構(gòu)體PyObject,它的內(nèi)部有一個(gè)引用計(jì)數(shù)器(ob_refcnt)。程序在運(yùn)行的過程中會(huì)實(shí)時(shí)的更新ob_refcnt的值,來反映引用當(dāng)前對(duì)象的名稱數(shù)量。當(dāng)某對(duì)象的引用計(jì)數(shù)值為0,那么它的內(nèi)存就會(huì)被立即釋放掉。
以下情況是導(dǎo)致引用計(jì)數(shù)加一的情況:
- 對(duì)象被創(chuàng)建,例如a=2
- 對(duì)象被引用,b=a
- 對(duì)象被作為參數(shù),傳入到一個(gè)函數(shù)中
- 對(duì)象作為一個(gè)元素,存儲(chǔ)在容器中
下面的情況則會(huì)導(dǎo)致引用計(jì)數(shù)減一:
- 對(duì)象別名被顯示銷毀 del
- 對(duì)象別名被賦予新的對(duì)象
- 一個(gè)對(duì)象離開他的作用域
- 對(duì)象所在的容器被銷毀或者是從容器中刪除對(duì)象
我們還可以通過sys包中的getrefcount()來獲取一個(gè)名稱所引用的對(duì)象當(dāng)前的引用計(jì)數(shù)(注意,這里getrefcount()本身會(huì)使得引用計(jì)數(shù)加一)
sys.getrefcount(a)
引用計(jì)數(shù)法有其明顯的優(yōu)點(diǎn),如高效、實(shí)現(xiàn)邏輯簡(jiǎn)單、具備實(shí)時(shí)性,一旦一個(gè)對(duì)象的引用計(jì)數(shù)歸零,內(nèi)存就直接釋放了。不用像其他機(jī)制等到特定時(shí)機(jī)。將垃圾回收隨機(jī)分配到運(yùn)行的階段,處理回收內(nèi)存的時(shí)間分?jǐn)偟搅似綍r(shí),正常程序的運(yùn)行比較平穩(wěn)。但是,引用計(jì)數(shù)也存在著一些缺點(diǎn),通常的缺點(diǎn)有:
- 邏輯簡(jiǎn)單,但實(shí)現(xiàn)有些麻煩。每個(gè)對(duì)象需要分配單獨(dú)的空間來統(tǒng)計(jì)引用計(jì)數(shù),這無形中加大的空間的負(fù)擔(dān),并且需要對(duì)引用計(jì)數(shù)進(jìn)行維護(hù),在維護(hù)的時(shí)候很容易會(huì)出錯(cuò)。
- 在一些場(chǎng)景下,可能會(huì)比較慢。正常來說垃圾回收會(huì)比較平穩(wěn)運(yùn)行,但是當(dāng)需要釋放一個(gè)大的對(duì)象時(shí),比如字典,需要對(duì)引用的所有對(duì)象循環(huán)嵌套調(diào)用,從而可能會(huì)花費(fèi)比較長(zhǎng)的時(shí)間。
- 循環(huán)引用。這將是引用計(jì)數(shù)的致命傷,引用計(jì)數(shù)對(duì)此是無解的,因此必須要使用其它的垃圾回收算法對(duì)其進(jìn)行補(bǔ)充。
也就是說,Python 的垃圾回收機(jī)制,很大一部分是為了處理可能產(chǎn)生的循環(huán)引用,是對(duì)引用計(jì)數(shù)的補(bǔ)充。
標(biāo)記清除解決循環(huán)引用
Python采用了“標(biāo)記-清除”(Mark and Sweep)算法,解決容器對(duì)象可能產(chǎn)生的循環(huán)引用問題。(注意,只有容器對(duì)象才會(huì)產(chǎn)生循環(huán)引用的情況,比如列表、字典、用戶自定義類的對(duì)象、元組等。而像數(shù)字,字符串這類簡(jiǎn)單類型不會(huì)出現(xiàn)循環(huán)引用。作為一種優(yōu)化策略,對(duì)于只包含簡(jiǎn)單類型的元組也不在標(biāo)記清除算法的考慮之列)
跟其名稱一樣,該算法在進(jìn)行垃圾回收時(shí)分成了兩步,分別是:
- A)標(biāo)記階段,遍歷所有的對(duì)象,如果是可達(dá)的(reachable),也就是還有對(duì)象引用它,那么就標(biāo)記該對(duì)象為可達(dá);
- B)清除階段,再次遍歷對(duì)象,如果發(fā)現(xiàn)某個(gè)對(duì)象沒有標(biāo)記為可達(dá),則就將其回收。
如下圖所示,在標(biāo)記清除算法中,為了追蹤容器對(duì)象,需要每個(gè)容器對(duì)象維護(hù)兩個(gè)額外的指針,用來將容器對(duì)象組成一個(gè)雙端鏈表,指針分別指向前后兩個(gè)容器對(duì)象,方便插入和刪除操作。python解釋器(Cpython)維護(hù)了兩個(gè)這樣的雙端鏈表,一個(gè)鏈表存放著需要被掃描的容器對(duì)象,另一個(gè)鏈表存放著臨時(shí)不可達(dá)對(duì)象。在圖中,這兩個(gè)鏈表分別被命名為”O(jiān)bject to Scan”和”Unreachable”。圖中例子是這么一個(gè)情況:link1,link2,link3組成了一個(gè)引用環(huán),同時(shí)link1還被一個(gè)變量A(其實(shí)這里稱為名稱A更好)引用。link4自引用,也構(gòu)成了一個(gè)引用環(huán)。從圖中我們還可以看到,每一個(gè)節(jié)點(diǎn)除了有一個(gè)記錄當(dāng)前引用計(jì)數(shù)的變量ref_count還有一個(gè)gc_ref變量,這個(gè)gc_ref是ref_count的一個(gè)副本,所以初始值為ref_count的大小。

gc啟動(dòng)的時(shí)候,會(huì)逐個(gè)遍歷”O(jiān)bject to Scan”鏈表中的容器對(duì)象,并且將當(dāng)前對(duì)象所引用的所有對(duì)象的gc_ref減一。(掃描到link1的時(shí)候,由于link1引用了link2,所以會(huì)將link2的gc_ref減一,接著掃描link2,由于link2引用了link3,所以會(huì)將link3的gc_ref減一…..)像這樣將”O(jiān)bjects to Scan”鏈表中的所有對(duì)象考察一遍之后,兩個(gè)鏈表中的對(duì)象的ref_count和gc_ref的情況如下圖所示。這一步操作就相當(dāng)于解除了循環(huán)引用對(duì)引用計(jì)數(shù)的影響。

接著,gc會(huì)再次掃描所有的容器對(duì)象,如果對(duì)象的gc_ref值為0,那么這個(gè)對(duì)象就被標(biāo)記為GC_TENTATIVELY_UNREACHABLE,并且被移至”Unreachable”鏈表中。下圖中的link3和link4就是這樣一種情況。

如果對(duì)象的gc_ref不為0,那么這個(gè)對(duì)象就會(huì)被標(biāo)記為GC_REACHABLE。同時(shí)當(dāng)gc發(fā)現(xiàn)有一個(gè)節(jié)點(diǎn)是可達(dá)的,那么他會(huì)遞歸式的將從該節(jié)點(diǎn)出發(fā)可以到達(dá)的所有節(jié)點(diǎn)標(biāo)記為GC_REACHABLE,這就是下圖中l(wèi)ink2和link3所碰到的情形。

除了將所有可達(dá)節(jié)點(diǎn)標(biāo)記為GC_REACHABLE之外,如果該節(jié)點(diǎn)當(dāng)前在”Unreachable”鏈表中的話,還需要將其移回到”O(jiān)bject to Scan”鏈表中,下圖就是link3移回之后的情形。

第二次遍歷的所有對(duì)象都遍歷完成之后,存在于”Unreachable”鏈表中的對(duì)象就是真正需要被釋放的對(duì)象。如上圖所示,此時(shí)link4存在于Unreachable鏈表中,gc隨即釋放之。
上面描述的垃圾回收的階段,會(huì)暫停整個(gè)應(yīng)用程序,等待標(biāo)記清除結(jié)束后才會(huì)恢復(fù)應(yīng)用程序的運(yùn)行。
分代回收
在循環(huán)引用對(duì)象的回收中,整個(gè)應(yīng)用程序會(huì)被暫停,為了減少應(yīng)用程序暫停的時(shí)間,Python 通過“分代回收”(Generational Collection)以空間換時(shí)間的方法提高垃圾回收效率。
分代回收是基于這樣的一個(gè)統(tǒng)計(jì)事實(shí),對(duì)于程序,存在一定比例的內(nèi)存塊的生存周期比較短;而剩下的內(nèi)存塊,生存周期會(huì)比較長(zhǎng),甚至?xí)某绦蜷_始一直持續(xù)到程序結(jié)束。生存期較短對(duì)象的比例通常在 80%~90% 之間,這種思想簡(jiǎn)單點(diǎn)說就是:對(duì)象存在時(shí)間越長(zhǎng),越可能不是垃圾,應(yīng)該越少去收集。這樣在執(zhí)行標(biāo)記-清除算法時(shí)可以有效減小遍歷的對(duì)象數(shù),從而提高垃圾回收的速度。
python gc給對(duì)象定義了三種世代(0,1,2),每一個(gè)新生對(duì)象在generation zero中,如果它在一輪gc掃描中活了下來,那么它將被移至generation one,在那里他將較少的被掃描,如果它又活過了一輪gc,它又將被移至generation two,在那里它被掃描的次數(shù)將會(huì)更少。
gc的掃描在什么時(shí)候會(huì)被觸發(fā)呢?答案是當(dāng)某一世代中被分配的對(duì)象與被釋放的對(duì)象之差達(dá)到某一閾值的時(shí)候,就會(huì)觸發(fā)gc對(duì)某一世代的掃描。值得注意的是當(dāng)某一世代的掃描被觸發(fā)的時(shí)候,比該世代年輕的世代也會(huì)被掃描。也就是說如果世代2的gc掃描被觸發(fā)了,那么世代0,世代1也將被掃描,如果世代1的gc掃描被觸發(fā),世代0也會(huì)被掃描。
該閾值可以通過下面兩個(gè)函數(shù)查看和調(diào)整:
gc.get_threshold() # (threshold0, threshold1, threshold2).
gc.set_threshold(threshold0[, threshold1[, threshold2]])
下面對(duì)set_threshold()中的三個(gè)參數(shù)threshold0, threshold1, threshold2進(jìn)行介紹。gc會(huì)記錄自從上次收集以來新分配的對(duì)象數(shù)量與釋放的對(duì)象數(shù)量,當(dāng)兩者之差超過threshold0的值時(shí),gc的掃描就會(huì)啟動(dòng),初始的時(shí)候只有世代0被檢查。如果自從世代1最近一次被檢查以來,世代0被檢查超過threshold1次,那么對(duì)世代1的檢查將被觸發(fā)。相同的,如果自從世代2最近一次被檢查以來,世代1被檢查超過threshold2次,那么對(duì)世代2的檢查將被觸發(fā)。get_threshold()是獲取三者的值,默認(rèn)值為(700,10,10).