深度解析Python垃圾回收機制(超級詳細)
我們知道,目前的計算機都採用的是圖靈機架構,其本質就是用一條無限長的紙帶,對應今天的儲存器。隨後在工程學的推演中,逐漸出現了暫存器、易失性儲存器(記憶體)以及永久性儲存器(硬碟)等產品。由於不同的儲存器,其速度越快,單位價格也就越昂貴,因此,妥善利用好每一寸告訴儲存器的空間,永遠是系統設計的一個核心。
Python程式在執行時,需要在記憶體中開闢出一塊空間,用於存放執行時產生的臨時變數,計算完成後,再將結果輸出到永久性儲存器中。但是當資料量過大,或者記憶體空間管理不善,就很容易出現記憶體溢位的情況,程式可能會被作業系統終止。
而對於伺服器這種用於永不中斷的系統來說,記憶體管理就顯得更為重要了,不然很容易引發記憶體洩漏。
這裡的記憶體洩漏是指程式本身沒有設計好,導致程式未能釋放已不再使用的記憶體,或者直接失去了對某段記憶體的控制,造成了記憶體的浪費。
那麼,對於不會再用到的記憶體空間,Python 是通過什麼機制來管理的呢?其實在前面章節已大致接觸過,就是引用計數機制。
Python引用計數機制
在學習 Python 的整個過程中,我們一直在強調,Python 中一切皆物件,也就是說,在 Python 中你用到的一切變數,本質上都是類物件。
那麼,如何知道一個物件永遠都不能再使用了呢?很簡單,就是當這個物件的引用計數值為 0 時,說明這個物件永不再用,自然它就變成了垃圾,需要被回收。
舉個例子:
import os import psutil # 顯示當前 python 程式佔用的記憶體大小 def show_memory_info(hint): pid = os.getpid() p = psutil.Process(pid) info = p.memory_full_info() memory = info.uss / 1024. / 1024 print('{} memory used: {} MB'.format(hint, memory)) def func(): show_memory_info('initial') a = [i for i in range(10000000)] show_memory_info('after a created') func() show_memory_info('finished')
輸出結果為:
initial memory used: 47.19140625 MB
after a created memory used: 433.91015625 MB
finished memory used: 48.109375 MB
注意,執行此程式之前,需安裝 psutil 模組(獲取系統資訊的模組),可使用 pip 命令直接安裝,執行命令為 $pip install psutil,如果遇到 Permission denied 安裝失敗,請加上 sudo 重試。
可以看到,當呼叫函式 func() 且列表 a 被建立之後,記憶體佔用迅速增加到了 433 MB,而在函式呼叫結束後,記憶體則返回正常。這是因為,函式內部宣告的列表 a 是區域性變數,在函式返回後,區域性變數的引用會登出掉,此時列表 a 所指代物件的引用計數為 0,Python 便會執行垃圾回收,因此之前佔用的大量記憶體就又回來了。
明白了這個原理後,稍微修改上面的程式碼,如下所示:
def func(): show_memory_info('initial') global a a = [i for i in range(10000000)] show_memory_info('after a created') func() show_memory_info('finished')
輸出結果為:
initial memory used: 48.88671875 MB
after a created memory used: 433.94921875 MB
finished memory used: 433.94921875 MB
上面這段程式碼中,global a 表示將 a 宣告為全域性變數,則即使函式返回後,列表的引用依然存在,於是 a 物件就不會被當做垃圾回收掉,依然佔用大量記憶體。
同樣,如果把生成的列表返回,然後在主程式中接收,那麼引用依然存在,垃圾回收也不會被觸發,大量記憶體仍然被佔用著:
def func(): show_memory_info('initial') a = [i for i in derange(10000000)] show_memory_info('after a created') return a a = func() show_memory_info('finished')
輸出結果為:
initial memory used: 47.96484375 MB
after a created memory used: 434.515625 MB
finished memory used: 434.515625 MB
以上最常見的幾種情況,下面由表及裡,深入看一下 Python 內部的引用計數機制。先來分析一段程式碼:
import sys a = [] # 兩次引用,一次來自 a,一次來自 getrefcount print(sys.getrefcount(a)) def func(a): # 四次引用,a,python 的函式呼叫棧,函式引數,和 getrefcount print(sys.getrefcount(a)) func(a) # 兩次引用,一次來自 a,一次來自 getrefcount,函式 func 呼叫已經不存在 print(sys.getrefcount(a))
輸出結果為:
2
4
2
注意,sys.getrefcount() 函式用於檢視一個變數的引用次數,不過別忘了,getrefcount 本身也會引入一次計數。
另一個要注意的是,在函式呼叫發生的時候,會產生額外的兩次引用,一次來自函式棧,另一個是函式引數。
import sys a = [] print(sys.getrefcount(a)) # 兩次 b = a print(sys.getrefcount(a)) # 三次 c = b d = b e = c f = e g = d print(sys.getrefcount(a)) # 八次
輸出結果為:
2
3
8
分析一下這段程式碼,a、b、c、d、e、f、g 這些變數全部指代的是同一個物件,而 sys.getrefcount() 函式並不是統計一個指標,而是要統計一個物件被引用的次數,所以最後一共會有 8 次引用。
理解引用這個概念後,引用釋放是一種非常自然和清晰的思想。相比 C 語言中需要使用 free 去手動釋放記憶體,Python 的垃圾回收在這裡可以說是省心省力了。
不過,有讀者還是會好奇,如果想手動釋放記憶體,應該怎麼做呢?方法同樣很簡單,只需要先呼叫 del a 來刪除一個物件,然後強制呼叫 gc.collect() 即可手動啟動垃圾回收。例如:
import gc show_memory_info('initial') a = [i for i in range(10000000)] show_memory_info('after a created') del a gc.collect() show_memory_info('finish') print(a)
輸出結果為:
initial memory used: 48.1015625 MB
after a created memory used: 434.3828125 MB
finish memory used: 48.33203125 MB
NameError Traceback (most recent call last)
<ipython-input-12-153e15063d8a> in <module>
11
12 show_memory_info('finish')
---> 13 print(a)
NameError: name 'a' is not defined
是不是覺得垃圾回收非常簡單呢?這裡再問大家一個問題:引用次數為 0 是垃圾回收啟動的充要條件嗎?還有沒有其他可能性呢?
其實,引用計數是其中最簡單的實現,引用計數並非充要條件,它只能算作充分非必要條件,至於其他的可能性,下面所講的迴圈引用正是其中一種。
迴圈引用
首先思考一個問題,如果有兩個物件,之間互相引用,且不再被別的物件所引用,那麼它們應該被垃圾回收嗎?
舉個例子:
def func(): show_memory_info('initial') a = [i for i in range(10000000)] b = [i for i in range(10000000)] show_memory_info('after a, b created') a.append(b) b.append(a) func() show_memory_info('finished')
輸出結果為:
initial memory used: 47.984375 MB
after a, b created memory used: 822.73828125 MB
finished memory used: 821.73046875 MB
程式中,a 和 b 互相引用,並且作為區域性變數在函式 func 呼叫結束後,a 和 b 這兩個指標從程式意義上已經不存在,但從輸出結果中看到,依然有記憶體佔用,這是為什麼呢?因為互相引用導致它們的引用數都不為 0。
試想一下,如果這段程式碼出現在生產環境中,哪怕 a 和 b 一開始佔用的空間不是很大,但經過長時間執行後,Python 所佔用的記憶體一定會變得越來越大,最終撐爆伺服器,後果不堪設想。
有讀者可能會說,互相引用還是很容易被發現的呀,問題不大。可是,更隱蔽的情況是出現一個引用環,在工程程式碼比較複雜的情況下,引用環真不一定能被輕易發現。那麼應該怎麼做呢?
事實上,Python 本身能夠處理這種情況,前面剛剛講過,可以顯式呼叫 gc.collect() 來啟動垃圾回收,例如:
import gc def func(): show_memory_info('initial') a = [i for i in range(10000000)] b = [i for i in range(10000000)] show_memory_info('after a, b created') a.append(b) b.append(a) func() gc.collect() show_memory_info('finished')
輸出結果為:
initial memory used: 49.51171875 MB
after a, b created memory used: 824.1328125 MB
finished memory used: 49.98046875 MB
事實上,Python 使用標記清除(mark-sweep)演算法和分代收集(generational),來啟用針對迴圈引用的自動垃圾回收。
先來看標記清除演算法。我們先用圖論來理解不可達的概念。對於一個有向圖,如果從一個節點出發進行遍歷,並標記其經過的所有節點;那麼,在遍歷結束後,所有沒有被標記的節點,我們就稱之為不可達節點。顯而易見,這些節點的存在是沒有任何意義的,自然的,我們就需要對它們進行垃圾回收。
當然,每次都遍歷全圖,對於 Python 而言是一種巨大的效能浪費。所以,在 Python 的垃圾回收實現中,標記清除演算法使用雙向連結串列維護了一個資料結構,並且只考慮容器類的物件(只有容器類物件才有可能產生迴圈引用)。
而分代收集演算法,則是將 Python 中的所有物件分為三代。剛剛創立的物件是第 0 代;經過一次垃圾回收後,依然存在的物件,便會依次從上一代挪到下一代。而每一代啟動自動垃圾回收的閾值,則是可以單獨指定的。當垃圾回收器中新增物件減去刪除物件達到相應的閾值時,就會對這一代物件啟動垃圾回收。
事實上,分代收集基於的思想是,新生的物件更有可能被垃圾回收,而存活更久的物件也有更高的概率繼續存活。因此,通過這種做法,可以節約不少計算量,從而提高 Python 的效能。