如何回收 Python 中的“垃圾”?

本文不再更新,最新版本請查看:https://error.work/python/48.html

前言

對於 python 來說,一切皆爲對象,所有的變量賦值都遵循着對象引用機制。程序在運行的時候,需要在內存中開闢出一塊空間,用於存放運行時產生的臨時變量;計算完成後,再將結果輸出到永久性存儲器中。如果數據量過大,內存空間管理不善就很容易出現
OOM(out of memory),俗稱爆內存,程序可能被操作系統中止。
而對於服務器,內存管理則顯得更爲重要,不然很容易引發內存泄漏。這裏的泄漏,並不是說你的內存出現了信息安全問題,被惡意程序利用了,而是指程序本身沒有設計好,導致程序未能釋放已不再使用的內存。內存泄漏也不是指你的內存在物理上消失了,而是意味着代碼在分配了某段內存後,因爲設計錯誤,失去了對這段內存的控制,從而造成了內存的浪費。也就是這塊內存脫離了 gc 的控制

計數引用

因爲 python 中一切皆爲對象,你所看到的一切變量,本質上都是對象的一個指針。
當一個對象不再調用的時候,也就是當這個對象的引用計數(指針數)爲 0 的時候,說明這個對象永不可達,自然它也就成爲了垃圾,需要被回收。可以簡單的理解爲沒有任何變量再指向它。

import osimport 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

可以看到調用函數 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 聲明爲全局變量。那麼,即使函數返回後,列表的引用依然存在,於是對象就不會被垃圾回收掉,依然佔用大量內存。同樣,如果我們把生成的列表返回,然後在主程序中接收,那麼引用依然存在,垃圾回收就不會被觸發,大量內存仍然被佔用着:

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

那怎麼可以看到變量被引用了多少次呢?通過 sys.getrefcount

import sys

a = []

# 兩次引用,一次來自 a,一次來自 getrefcountprint(sys.getrefcount(a))

def func(a):
# 四次引用,a,python 的函數調用棧,函數參數,和 getrefcountprint(sys.getrefcount(a))

func(a)
# 兩次引用,一次來自 a,一次來自 getrefcount,函數 func 調用已經不存在print(sys.getrefcount(a))

########## 輸出 ##########
242

如果其中涉及函數調用,會額外增加兩次:

  1. 函數棧
  2. 函數調用

從這裏就可以看到 python 不再需要像 C 那種的認爲的釋放內存,但是 python 同樣給我們提供了手動釋放內存的方法 gc.collect()

import gc
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
del agc.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

---------------------------------------------------------------------------NameErrorTraceback(most recent call last)<ipython-input-12-153e15063d8a> in<module>1112 show_memory_info('finish')---> 13print(a)

NameError: name 'a'isnotdefined

截止目前,貌似 python 的垃圾回收機制非常的簡單,只要對象引用次數爲 0,必定爲觸發 gc,那麼引用次數爲 0 是否是觸發 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()
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 就已經從程序意義上不存在了。但是因爲它們的互相引用,導致了它們的引用數都不爲 0。
這時要如何規避呢?

  1. 從代碼邏輯上進行整改,避免這種循環引用
  2. 通過人工回收
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 針對循環引用,有它的自動垃圾回收算法:

  1. 標記清除(mark-sweep)算法
  2. 分代收集(generational)

標記清除

標記清除的步驟總結爲如下步驟:

  1. GC 會把所有的『活動對象』打上標記
  2. 把那些沒有標記的對象『非活動對象』進行回收

那麼 python 如何判斷何爲非活動對象?
通過用圖論來理解不可達的概念。對於一個有向圖,如果從一個節點出發進行遍歷,並標記其經過的所有節點;那麼,在遍歷結束後,所有沒有被標記的節點,我們就稱之爲不可達節點。顯而易見,這些節點的存在是沒有任何意義的,自然的,我們就需要對它們進行垃圾回收。
但是每次都遍歷全圖,對於 Python 而言是一種巨大的性能浪費。所以,在 Python 的垃圾回收實現中,mark-sweep 使用雙向鏈表維護了一個數據結構,並且只考慮容器類的對象(只有容器類對象,list、dict、tuple,instance,纔有可能產生循環引用)。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-2r9RsI2n-1575256830824)(https://tool.error.work/imgur/ZogCwCO.png)]
圖中把小黑圈視爲全局變量,也就是把它作爲 root object,從小黑圈出發,對象 1 可直達,那麼它將被標記,對象 2、3 可間接到達也會被標記,而 4 和 5 不可達,那麼 1、2、3 就是活動對象,4 和 5 是非活動對象會被 GC 回收。

分代回收

分代回收是一種以空間換時間的操作方式,Python 將內存根據對象的存活時間劃分爲不同的集合,每個集合稱爲一個代,Python 將內存分爲了 3 “代”,分別爲年輕代(第 0 代)、中年代(第 1 代)、老年代(第 2 代),他們對應的是 3 個鏈表,它們的垃圾收集頻率與對象的存活時間的增大而減小。新創建的對象都會分配在年輕代,年輕代鏈表的總數達到上限時(當垃圾回收器中新增對象減去刪除對象達到相應的閾值時),Python 垃圾收集機制就會被觸發,把那些可以被回收的對象回收掉,而那些不會回收的對象就會被移到中年代去,依此類推,老年代中的對象是存活時間最久的對象,甚至是存活於整個系統的生命週期內。同時,分代回收是建立在標記清除技術基礎之上。
事實上,分代回收基於的思想是,新生的對象更有可能被垃圾回收,而存活更久的對象也有更高的概率繼續存活。因此,通過這種做法,可以節約不少計算量,從而提高 Python 的性能。
所以對於剛剛的問題,引用計數只是觸發 gc 的一個充分非必要條件,循環引用同樣也會觸發。

調試

可以使用 objgraph 來調試程序,因爲目前它的官方文檔,還沒有細讀,只能把文檔放在這供大家參閱啦~
其中兩個函數非常有用

  1. show_refs()
  2. show_backrefs()

總結

垃圾回收是 Python 自帶的機制,用於自動釋放不會再用到的內存空間;

引用計數是其中最簡單的實現,不過切記,這只是充分非必要條件,因爲循環引用需要通過不可達判定,來確定是否可以回收;

Python 的自動回收算法包括標記清除和分代回收,主要針對的是循環引用的垃圾收集;

調試內存泄漏方面, objgraph 是很好的可視化分析工具。

via:https://mp.weixin.qq.com/s/d2b0SesdVPy5Q7f5SrzkuQ

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章