本文始發於個人公眾號:TechFlow,原創不易,求個關注
今天是Python的第15篇文章,我們來聊聊Python中內存管理機制,以及循環引用的問題。
Python的內存管理機制
對於工程師而言,內存管理機制非常重要,是繞不過去的一環。如果你是Java工程師,面試的時候一定會問JVM。C++工程師也一定會問內存泄漏,同樣我們想要深入學習Python,內存管理機制也是繞不過去的一環。
不過好在Python的內存管理機制相對來說比較簡單,我們也不用特別深入其中的細節,簡單做個了解即可。
Python內存管理機制的核心就是引用計數,在Python當中一切都是對象,對象通過引用來使用。

我們看到的是變量名,但是變量名指向了內存當中的一塊對象。這種關系在Python當中稱為引用,我們通過引用來操作對象。所以根據這點,引用計數很好理解,也就是說我們會對每一個對象進行統計所有指向它的指針的數量。如果一個對象引用計數為0,那么說明它沒有任何引用指向它,也就是說它已經沒有在使用了,這個時候,Python就會將這塊內存收回。
簡單來說引用計數原理就是這些,但我們稍微深入一點,來簡單看看哪些場景會引起對象引用的變化。
引用計數的變化顯然只有兩種,一種是增加,一種是減少,這兩種場景都只有4種情況。我們先來看下增加的情況:
-
首先是初始化,最簡單的就是我們用 賦值操作給一個變量賦值。舉個例子:
n = 123
這就是最簡單的初始化操作,雖然123在我們來看是一個常數,但是在Python底層同樣被認為是一個常數對象。n是它的一個引用。
-
第二種情況是引用的傳遞,最簡單的就是我們將一個變量的值賦值給了另外一個變量。
m = n
比如我們將n賦值給m,它的本質是我們創建了一個新的引用,指向了同樣一塊內存。如果我們用id操作去查看m和n的id,會發現它們的id是一樣的。也就是說它們並不是存儲了兩份相同的值,而是指向了同一份值。並不是有兩個叫做王小二的人,而是王小二有兩個不同的賬號。
-
第三種情況是作為元素被存儲進了容器當中,比如被存儲進了list當中。
a = [1, 2, 123]
雖然我們用到了一個容器,但是容器並不會拷貝一份這些對象,還是只是存儲這些對象的引用。
-
最后一種情況就是作為參數傳給函數,在Python當中,所有的傳參都是引用傳遞。這也是為什么,我們經常看到有人會這樣寫代碼的原因:
def test(a):
a.append(3) a = [] test(a) print(a)
我們根據上面列舉的這四種引用計數增加的情況,不難推導出引用減少的情況, 其實基本上是對稱的操作。
-
和初始化對應的操作是 銷毀,比如我們創建的對象被del操作給銷毀了,那么同樣引用計數會-1
del n
-
和賦值給其他變量名的操作相反的操作是 覆蓋,比如之前我們的n=123,也就是n這個變量指向123,現在我們將n賦值成其他值,那么123這個對象的引用計數同樣會減少。
n = 124
-
既然元素存儲在容器當中會帶來引用計數,那么同樣元素 從容器當中移除也會減少引用計數。這個也很好理解,最簡單的就是list調用remove方法移除一個元素:
a.remove(123)
-
最后一個對應的就是作用域,也就是當變量 離開了作用域,那么它對應的內存塊的引用計數同樣會減少。比如我們函數調用結束,那么作為參數的這些變量對應的引用計數都會減1。
如果一個對象的引用計數減到0,也就是沒有引用再指向它的時候,那么當Python進行gc的時候,這塊內存就會被釋放,也就是這個對象會被清除,騰出空間來。
注意一下,引用計數減到0與內存回收之間並不是立即發生的,而是有一段間隔的。根據Python的機制,內存回收只會在特定條件下執行。在占用內存比較小還有很多富裕的情況下,往往是不會執行內存回收的。因為Python在執行gc(garbage collection)的時候也會stop the world,也就是暫停其他所有的任務,所以這是影響性能的一件事情,只會在有必要的時候執行。
我們費這么大勁來介紹Python中的內存機制,除了向大家科普一下這一塊內容之外,更重要的一點是為了引出我們開發的時候經常遇見的一種情況——循環引用。
循環引用
如果熟悉了Python的引用,來理解循環引用是非常容易的。說白了也很簡單,就是你的一個變量引用我,我的一個變量引用你。
我們來寫一段簡單的代碼,來看看循環引用:
class Test:
def __init__(self): pass if __name__ == '__main__': a = Test() b = Test() a.t = b b.t = a
如果你打個斷點來看的話,會看到a和b之間的循環引用:

這里是無限展開的,因為這是一個無限循環。無限循環並不會導致程序崩潰, 也不會帶來太大的問題,它的問題只有一個,就是根據前面介紹的引用計數法,a和b的引用永遠不可能為0。
也就是說根據引用計數的原則,這兩個變量永遠不會被回收,這顯然是不合理的。雖然Python當中專門建立了機制來解決引用循環的問題,但是我們並不知道它什么時候會被觸發。
這個問題在Python當中非常普遍,尤其在我們實現一些數據結構的時候。舉個最簡單的例子就是樹中的節點,就是引用循環的。因為父節點會存儲所有的孩子,往往孩子節點也會存儲父節點的信息。那么這就構成了引用循環。
class Node:
def __init__(self, val, father): self.val = val self.father = father self.childs = []
弱引用
為了解決這個問題,Python中提供了一個叫做弱引用的概念。弱引用本質也是一種引用,但是它不會增加對象的引用計數。也就是說它不能保證它引用的對象一定不會被銷毀,只要沒有銷毀,弱引用就可以返回預期的結果。
弱引用不用我們自己開發,這是Python當中集成的一個現成的模塊weakref。
這個模塊當中的方法很多,用法也很多,但是我們基本上用不到,一般來說最常用的就是ref方法。通過weakref庫中的ref方法,可以返回對象的一個弱引用。我們還是來看個例子:
import weakref
class Test: def __init__(self, name): self.name = name def __str__(self): return self.name if __name__ == '__main__': a = Test('a') b = Test('b') a.t = weakref.ref(b) b.t = weakref.ref(a) print(a.t())
其實還是之前的代碼,只是做了一點簡單的改動。一個是我們給Test加上了name這個屬性,以及str方法。另一個是我們把直接賦值改成了使用weakref。
這一次我們再打斷點進來看的話,就看不到無限循環的情況了:

ref返回的是一個獲取引用對象的方法,而不是對象本身。所以我們想要獲取這個對象的話,需要再把它當成函數調用一下。
當然這樣很麻煩,我們還有更好的辦法,就是使用property注解。通過property注解,我們可以把weakref封裝掉,這樣在使用的時候就沒有感知了。
import weakref
class Test: def __init__(self, name): self.name = name def __str__(self): return self.name @property def node(self): return None if self._node is None else self._node() @node.setter def node(self, node): self._node = weakref.ref(node)
總結
引用和循環引用都是基於Python本身的機制,如果對這塊機制不了解,很容易采坑。因為可能會出現邏輯是對的,但是有一些意想不到的bug的情況。這種時候,往往很難通過review代碼或者是測試發現,這也是我們學習的瓶頸所在。很容易發現代碼已經寫得很熟練了,但是一些進階的代碼還是看不懂或者是寫不出來,本質上就是因為缺少了對於底層的了解和認知。
循環引用的問題在我們開發代碼的時候還蠻常見的,尤其是涉及到樹和圖的數據結構的時候。由於循環引用的關系,很有可能出現被刪除的樹仍然占用着空間,內存不足的情況發生。這個時候使用weakref就很有必要了。
今天的文章就到這里,原創不易,掃碼關注我,獲取更多精彩文章。