變量與對象
Python 作為一種動態類型的語言,其對象和引用分離。在 Python 中萬物皆對象,因此Python 的存儲問題等同於對象的存儲問題,對於每個對象,Python 都會分配一塊內存空間去存儲它 。
我們通過一個簡單的賦值語句來理解 變量與對象,如下:
變量(testops),通過變量指針引用對象,變量指針指向具體對象的內存空間,取對象的值。對象 (9527),類型已知,每個對象都包含一個頭部信息(類型標識符和引用計數器)。Python中變量名沒有類型,類型屬於對象,對象的類型決定了變量的類型。
如上,整數 9527 為一個對象, test 是一個變量。利用賦值語句,引用 test 指向對象 9527 。9527 對象存儲在內存中,我們可以通過 id 函數,查看對象的內存地址,引用示意圖如下:
對於整數和短小的字符等,會觸發Python的緩存機制,即Python將這些對象進行緩存,不會為相同的對象分配不同的內存空間,如下:
如上,我們使用 is 關鍵字判斷兩個引用所指的對象是否相同。可以看到,由於Python緩存了小整數(其實也緩存了短字符串,Python2),因此每個對象只存有一份,比如,使用賦值語句創建小整數,如 27,並沒有創建出新的對象,而是創建了一個引用。而當使用賦值語句創建大的整數可以有多個相同的對象,如使用賦值語句創建大整數 27000,此時創建出多個對象。
引用計數
在Python中,每個對象都有指向該對象的引用總數,即引用計數(reference count)。一個對象會記錄著引用自己的對象的個數,每增加1個引用,個數加1,每減少1個引用,個數減1。在垃圾回收過程中,利用引用計數器方法,在檢測到對象引用個數為 0 時,對普通的對象進行釋放內存的機制。
我們可以使用 sys.getrefcount 方法,來查看每個對象的引用計數。需要注意的是,當使用某個引用作為參數,傳遞給 getrefcount方法時,參數實際上創建了一個臨時的引用。因此,getrefcount 方法所得到的結果比期望的多1。
由上可見,l 中的 [t,27] 兩個元素,都指向了同一個對象,實際上,容器對象(如,列表、字典等)中包含的並不是元素對象本身,是指向各個元素對象的引用。同時,l 的引用計數隨著 ll 的創建和刪除,引用計數也隨著增加1和減少1。
導致引用計數增加的場景如下:
- 對象被創建:t = 27
- 其它的別名被創建:ll = l
- 作為參數傳遞給函數:getrefcount(l)
- 作為容器對象的一個元素:l = [t, 27]
導致引用計數減少的場景如下:
- 對象的別名被顯式的銷毀:del ll
- 對象的一個別名被賦值給其他對象:l = 789
- 對象所在的容器被銷毀或從容器中刪除對象 如,del ll 或 l.remove(t)。
- 一個本地引用離開了它的作用域,比如上面的 getrefcount(x) 函數結束時,x指向的對象引用減1。
引用計數中的循環引用
循環引用即對象之間進行相互引用,出現循環引用後,利用上述引用計數機制無法對循環引用中的對象進行釋放空間,從而導致內存泄漏,這就是循環引用問題,如下:
對象 test 中的元素引用 ops,而對象 ops 中元素同時來引用 test ,從而造成僅僅刪除 test和 ops對象,無法釋放其內存空間,因為他們依然在被引用(引用個數不為0)。進一步解釋就是循環引用後,test 和 ops 被引用個數為2,刪除 test 和 ops 對象後,兩者被引用的個數變為1,並不是0,而Python只有在檢查到一個對象的被引用個數為0時,才會自動釋放其內存,所以這裡無法釋放 test 和 ops 的內存空間,因此這也是導致內存泄漏的原因之一。
垃圾回收
在Python中的對象越來越多,占用的內存越來越大,垃圾回收機制就是將沒用的對象清除,釋放內存。Python垃圾回收採用引用計數機制為主,標記-清除和分代回收機制為輔的策略,其中標記清除機制用來解決技術引用帶來的循環引用而無法釋放內存的問題,分代回收機制是為提升垃圾回收的效率。
當Python的對象的引用計數降為 0時,說明沒有任何引用指向該對象,該對象就成為要被回收的垃圾。比如新建一個對象,被賦值給某個變量,則該對象的引用計數變為1。如果變量被刪除,對象的引用計數為0,那麼該對象就會被垃圾回收。
如上,執行 del t 後,已經沒有任何引用指向之前建立的對象 9527,該對象引用計數變為0,用戶不可能通過任何方式使用這個對象,當垃圾回收啟動時,Python掃描到這個引用計數為0的對象,就將它所占用的內存進行回收。
標記-清除機制——解決循環引用問題
標記-清除機制顧名思義,首先標記對象(垃圾檢測),然後清除垃圾(垃圾回收),標記清除用來解決引用計數機制產生的循環引用,進而導致內存泄漏的問題。循環引用只有在容器對象才會產生,比如字典,元組,列表等。首先為了追蹤對象,需要每個容器對象維護兩個額外的指針,用來將容器對象組成一個鍊表,指針分別指向前後兩個容器對象,這樣可以將對象的循環引用摘除,就可以得出兩個對象的有效計數,我們通過如下示例,進一步了解一下。
在 標記-清除機制中,存在root鍊表、unreachable鍊表,這裡簡單介紹一下。
如上,在未執行 del 語句時,test、ops的引用計數都為 2。但是在 del 執行完以後,test、ops 引用次數互相減 1。test、ops陷入循環引用中,此時標記清除機制來打破這種循環引用,找到其中一端 test 開始拆test、ops的引用環。即從 test 出發,因為它有一個對 ops的引用,則將 ops的引用計數減1,然後順著引用達到 ops,因為 ops有一個對 test的引用,同樣將 test的引用減1,如此就完成了循環引用對象間環摘除。
引用環去掉以後發現,test、ops循環引用變為了0,所以test、ops就被添加到 unreachable鍊表 中直接被回收掉。
分代回收機制-提升垃圾回收效率
解決循環引用問題,引入的標記-清除機制,處理過程非常繁瑣,需要處理每一個容器對象,因此Python考慮一種改善性能的做法,基於「對象存在時間越長,越不可能在後面的程序中變成垃圾」的假設,提出分代回收機制。出於信任和效率,對於這樣一些「壽命長」的對象,我們相信它們的存在價值,所以降低在垃圾回收中掃描它們的頻率,分代回收是一種以空間換時間的操作方式。我們可以通過 gc.get_threshold 方法,查看分代回收機制的參數閾值設置,如下:
Python將所有的對象分為年輕代(第0代)、中年代(第1代)、老年代(第2代)三代。所有的新建對象默認是 第0代對象。當某一代對象經歷過垃圾回收,若依然存活,那麼它就將被劃分到下一代對象。垃圾回收啟動時,會掃描所有的 第0代對象。如果 第0代經過一定次數垃圾回收,那麼就觸發對0代和1代的掃描清理。當第1代也經歷了一定次數的垃圾回收後,那麼會觸發對 第0,1,2代,即對所有對象進行掃描。
如上,gc.get_threshold 方法返回的 (700, 10, 10),700即是垃圾回收啟動的閾值,返回的兩個10是指,每10次0代垃圾回收,會執行1次1代的垃圾回收;而每10次1代的垃圾回收,會執行1次的2代垃圾回收。
同樣可以用 gc.set_threshold 來調整分代回收策略,比如對 第2代對象進行更頻繁的掃描,如下:
通過此分代回收機制,循環引用中的內存回收處理過程就會得到很大的性能提升。
垃圾回收的時機
在以下三種情況下,會觸發垃圾回收機制:
- 自動回收:當gc模塊的計數器達到閥值的時候,觸發自動回收。
- 手動回收:使用gc模塊中的collect 方法。
- 程序退出
接下來我們進一步了解一下 自動回收和手動回收 方式。
自動回收
垃圾回收時,Python不能運行其它的任務,頻繁的垃圾回收將極大的降低Python的工作效率,當開啟垃圾回收機制時(默認開啟),在Python運行過程,會記錄其中新增對象和釋放對象的次數,當兩者的差值高於某個閾值時,自動啟動垃圾回收。
在Python中默認開啟自動回收,其中涉及方法如下:
- gc.enable:開啟垃圾回收機制(默認開啟)。
- gc.disable:關閉垃圾回收機制。
- gc.isenabled:判定是否開啟。
手動回收
使用 gc.collect 方法,手動執行分代回收機制。
上面例子中test、ops為循環引用,通過 gc.collect 手動回收垃圾,實現了回收的兩個test、ops的對象。此外,gc.collect 方法返回此次垃圾回收的unreachable(不可達)對象個數,上面例子中回收的兩個都是unreachable對象,即清除 我們在標記-清除機制中提到的unreachable 鍊表中的對象。