python 程序在運行的時候,需要在內存中開辟出一塊空間,用於存放運行時產生的臨時變量;計算完成后,再將結果輸出到永久性存儲器中。如果數量過大,
內存空間管理不善,就會出現 OOM(out of memory), 俗稱爆內存,程序可能被操作系統終止。
引用計數
Python 中一切皆對象。因此,一切變量,本質上都是對象的一個指針。
import os import psutil # 顯示當前 python 程序占用的內存大小 def show_memory_info(hint): pid = os.getpid() # 進程ID p = psutil.Process(pid) # 返回進程對象,不傳 pid 默認會獲取當前的pid info = p.memory_full_info() # pfullmem 對象 memory = info.uss / 1024. / 1024 print(f'{hint} memory used: {memory} MB') 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: 8.32421875 MB after a created memory used: 396.21484375 MB finished memory used: 9.0078125 MB """
示例中調用函數 func(), 在列表 a 被創建之后,內存占用迅速增加到了 396MB, 而在函數調用之后,內存返回正常。
這是因為函數內部聲明的列表a是局部變量,在函數返回后,局部變量的引用會注銷掉;此時,列表a所指代對象的引用計數為0,python便會執行垃圾
回收,因此之前占用的大量內存被釋放了。
import os import psutil def show_memory_info(hint): pid = os.getpid() p = psutil.Process() info = p.memory_full_info() memory = info.uss / 1024 / 1024 print(f'{hint} memory used: {memory} MB') 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: 8.4765625 MB after a created memory used: 395.86328125 MB finished memory used: 395.86328125 MB """
global 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')
這是最常見的幾種情況。
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))
sys.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)) # 八次
理解了引用這個概念后,引用釋放是一種非常自然和清晰的思想。相比C語言里,你需要使用free去手動釋放內存,python 自帶垃圾回收。
如果想手動回收可以先 del a 來刪除一個對象;然后強制調用 gc.collect(),即可手動啟動垃圾回收。
import gc import os import psutil def show_memory_info(hint): pid = os.getpid() p = psutil.Process(pid) info = p.memory_full_info() memory = info.uss / 1024 / 1024 print(f'{hint} memory used: {memory} MB') 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')
引用計數為0是垃圾回收的充要條件么?
循環引用
如果有兩個對象,它們互相引用,並且不再被別的對象引用,那么它們應該被垃圾回收么?(python自帶的不會,手動卻可以)
import os import psutil def show_memory_info(hint): pid = os.getpid() p = psutil.Process(pid) info = p.memory_full_info() memory = info.uss / 1024 / 1024 print(f'{hint} memory used: {memory} MB') 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: 8.48046875 MB after a, b created memory used: 783.83203125 MB finished memory used: 783.83203125 MB """
這里 a 和 b 互相引用,並且,作為局部變量,在函數 func 調用結束后,a 和 b 這兩個指針從程序意義上已經不存在了。但是,很明顯,依然有內存
占用。因為互相引用,導致它們的引用數都不為0。
如果這段代碼運行在生產環境中,哪怕 a 和 b 一開始占用的空間不是很大,但經過長時間的運行后,所占內存會越來越大,最終會撐爆服務器。
互相引用還是很容易發現的,更隱蔽的情況是出現一個引用環,在工程代碼比較復雜的情況下,引用環很難被發現。
解決這類問題,我們可以通過手動垃圾回收,即顯式的調用 gc.collect(), 來啟動垃圾回收。
import gc import os import psutil def show_memory_info(hint): pid = os.getpid() p = psutil.Process(pid) info = p.memory_full_info() memory = info.uss / 1024 / 1024 print(f'{hint} memory used: {memory} MB') 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: 8.453125 MB after a, b created memory used: 783.90625 MB finished memory used: 9.37109375 MB """
雖然 a,b 的引用計數不為0,但是我們也可以通過 gc.collect() 進行垃圾回收
python 使用標記清除(mark-sweep)算法和分代收集(generational), 來啟用針對循環引用的自動垃圾回收。
先來看看標記清除算法。我們先用圖論來理解不可達的概念。對於一個有向圖,如果從一個節點出發進行遍歷,並標記其經過的所有節點;
那么,在遍歷結束后,所有沒有被標記的節點,我們稱之為不可達節點。顯然,這些節點的存在沒有任何意義,我們就需要對它們進行垃圾回收。
當然,每次都遍歷全圖,對於python而言是一種巨大的性能浪費。所以在python垃圾回收實現中,mark-sweep 使用雙向鏈表維護了一個數據結構,
並且只考慮容器類的對象(只有容器類對象才有可能產生循環引用)。
而分代收集算法,則是另一個優化手段。
python將所有對象分為三代。剛剛創立的對象是第0代;經過一次垃圾回收后,依然存在的對象,便會依次從上一代挪到下一代。而每一代啟動自動垃圾
回收的閾值,則是可以單獨指定的。當垃圾回收器中新增對象(新建的對象)減去刪除對象(手動調用del刪除的對象、函數運行結束釋放的對象等)達到相應的閾值時,就會對這一代對象啟動垃圾回收。
事實上,分代收集基於的思想是,新生的對象更有可能被垃圾回收,而存活更久的對象也有更高的概率繼續存活。因此,通過這種做法,可以節約不少
計算量,從而提高python的性能。
引用計數是其中最簡單的實現,引用計數並非充要條件。還有其他的可能性,比如循環引用就是其中之一。
調試內存泄漏
雖然有了自動回收機制,還是會出現內存泄露的情況。
可以通過 objgraph(一個可視化引用關系的包)。在這個包中,主要關注兩個函數,第一個是 show_refs(), 它可以生成清晰的引用關系圖。
需要手動下載安裝graphviz,然后將其 bin 目錄放入到環境變量中,才能出來圖片。在 jupyter notebook 中可以直接顯示圖片。但是在pycharm中
會顯示圖片地址,需要自己去手動打開。
通過下面這段代碼和生成的引用調用圖,你能非常直觀的發現,有兩個list互相引用,說明這里極有可能引起內存泄漏。這樣一來,再去代碼層排查就容易多了。
import objgraph import os os.environ["PATH"] += os.pathsep + r'D:\GoogleDownload\graphviz-2.38\release\bin' a = [1, 2, 3] b = [4, 5, 6] a.append(b) b.append(a) objgraph.show_refs([a])
而另一個非常有用的函數是 show_backrefs()
import objgraph import os os.environ["PATH"] += os.pathsep + r'D:\GoogleDownload\graphviz-2.38\release\bin' a = [1, 2, 3] b = [4, 5, 6] a.append(b) b.append(a) objgraph.show_backrefs([a])
這個代碼顯示的圖片比之前的復雜的多。show_backrefs() 有很多有用的參數,比如層數限制(max_depth)、寬度限制(too_many)、輸出格式控制(filename output)、
節點過濾(filter, extra_ignore)等。
總結
- 垃圾回收是 python 自帶的機制,用於自動釋放不會再用到的內存空間;
- 引用計數是其中最簡單的實現,這只是充分非必要條件,因為循環引用需要通過不可達判定,來確定是否可以回收;
- Python 的自動回收算法包括標記清除和分代收集,主要針對的是循環引用的垃圾收集;
- 調試內存泄漏的工具:objgraph;
- 這只是皮毛。