我們知道,目前的計算機都采用的是圖靈機架構,其本質就是用一條無限長的紙帶,對應今天的存儲器。隨后在工程學的推演中,逐漸出現了寄存器、易失性存儲器(內存)以及永久性存儲器(硬盤)等產品。由於不同的存儲器,其速度越快,單位價格也就越昂貴,因此,妥善利用好每一寸告訴存儲器的空間,永遠是系統設計的一個核心。
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 的性能。
