天天看點

[Python]記憶體管理第二次啟動解釋器緩沖區被重複利用

本文主要為了解釋清楚python的記憶體管理機制,首先介紹了一下python關于記憶體使用的一些基本概念,然後介紹了引用計數和垃圾回收gc子產品,并且解釋了分代回收和“标記-清除”法,然後分析了一下各種操作會導緻python變量和對象的變化,最後做了一下小結。本來是為了解決前幾天遇到把伺服器記憶體耗光的問題,結果後來檢查發現并不是因為記憶體管理的問題…

  • 1. Python變量、對象、引用、存儲
  • 2. Python記憶體管理機制和操作對變量的影響
    • 2.1 記憶體管理機制
    • 2.2 各種操作對變量位址的改變
  • 3. 小結
  • 4. 引用

1. Python變量、對象、引用、存儲

python語言是一種解釋性的程式設計語言,它不同于一些傳統的編譯語言,不是先編譯成彙編再程式設計機器碼,而是在運作的過程中,逐句将指令解釋成機器碼,是以造就了python語言一些特别的地方。例如a=1,其中a是變量,1是對象。這裡所謂的變量,它的意義類似一個指針,它本身是沒有類型的,隻有它指向的那個對象是什麼類型,它才是什麼類型,一旦把它指到别的地方,它的類型就變了,現在指向的是1,它的類型可以認為是int,假如接下來執行a=2.5,那麼變量的類型就變了。甚至當先給a=1,a=a+1時,a的位址也會改變。而這裡的1,2.5或者一個list一個dict就是一個被執行個體化的對象,對象擁有真正的資源與取值,當一個變量指向某個對象,被稱為這個對象的産生了一個引用,一個對象可以有多個變量指向它,有多個引用。而一個變量可以随時指向另外的對象。同時一個變量可以指向另外一個變量,那麼它們指向的那個對象的引用就增加了一個。

Python有個特别的機制,它會在解釋器啟動的時候事先配置設定好一些緩沖區,這些緩沖區部分是固定好取值,例如整數[-5,256]的記憶體位址是固定的(這裡的固定指這一次程式啟動之後,這些數字在這個程式中的記憶體位址就不變了,但是啟動新的python程式,兩次的記憶體位址不一樣)。有的緩沖區就可以重複利用。這樣的機制就使得不需要python頻繁的調用記憶體malloc和free。下面的id是取記憶體位址,hex是轉成16進制表示。

#第一次啟動解釋器 

>>> hex(id(1)) 

‘0x14c5158’
           

第二次啟動解釋器

>>> hex(id(1))

‘0xe17158’

緩沖區被重複利用

>>> hex(id(100000))

‘0xe5be00’

>>> hex(id(1000000))

‘0xe5be00’

>>> hex(id(10000000))

‘0xe5be00’

>>> hex(id(100000000))

‘0xe5be00’

針對整數對象,它的記憶體區域似乎是一個單獨的區域,跟string、dict等的記憶體空間都不一樣,從實驗結果來看,它的位址大小隻有’0xe5be00’,其他的是’0x7fe7e03c7698’。而存儲整數對象的這塊區域,有一塊記憶體區域是事先配置設定好的,即[-5,256]範圍内的整數。這塊稱為小整數緩沖池,靜态配置設定,對某個變量指派就是直接從裡面取就行了,在python初始化時被建立。而另外的整數緩沖池稱為大整數緩沖池,這塊記憶體也是已經配置設定好了,隻是要用的時候再指派。可以從下面的例子中看到,針對257這個數字,雖然給a和b賦了相同的值,但是解釋器實際上是先配置設定了不同的位址,再把這個位址給兩個變量。

>>> a = 1 

>>> b = 1 

>>> hex(id(a)) 

'0xe17158' 

>>> hex(id(b)) 

'0xe17158' 

>>> b = 257 

>>> a = 257 

>>> hex(id(a)) 

'0xe5be00' 

>>> hex(id(b)) 

'0xe5bdd0' 

           

針對string類型,它也有自己的緩沖區,也是分為固定緩沖區和可重複緩沖區,固定的是256個ASCII碼字元。還發現一個有意思的現象,string中隻要不出現除了字母和數字其他字元,那麼對a和b賦同樣的值,它們的記憶體位址都相同。但是如果string對象中有其他字元,那麼對兩個變量賦相同的string值,它們的記憶體位址還是不一樣的。

>>> b = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' 

>>> hex(id(b)) 

'0x7fe7e03af848' 

>>> a = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' 

>>> hex(id(a)) 

'0x7fe7e03af848'

                
>>> a = 'abcd%' 

>>> b = 'abcd%' 

>>> hex(id(a)) 

'0x7fe7e02d4900' 

>>> hex(id(b)) 

'0x7fe7e02d48d0' 


      

而另外的dict和list的緩沖區也是事先配置設定好,大小為80個對象。

是以變量的存儲有三個區域,事先配置設定的靜态記憶體、事先配置設定的可重複利用記憶體以及需要通過malloc和free來控制的自由記憶體。

2. Python記憶體管理機制和操作對變量的影響

2.1 記憶體管理機制

python的記憶體在底層也是由malloc和free的方式來配置設定和釋放,隻是它代替程式員決定什麼時候配置設定什麼時候釋放,同時也提供接口讓使用者手動釋放,是以它有自己的一套記憶體管理體系,主要通過兩種機制來實作,一個是引用計數,一個是垃圾回收。前者負責确定目前變量是否需要釋放,後者解決前者解決不了的循環引用問題以及提供手動釋放的接口[2]。

引用計數(reference counting),針對可以重複利用的記憶體緩沖區和記憶體,python使用了一種引用計數的方式來控制和判斷某快記憶體是否已經沒有再被使用。即每個對象都有一個計數器count,記住了有多少個變量指向這個對象,當這個對象的引用計數器為0時,假如這個對象在緩沖區内,那麼它位址空間不會被釋放,而是等待下一次被使用,而非緩沖區的該釋放就釋放。

這裡通過sys包中的getrefcount()來擷取目前對象有多少個引用。這裡傳回的引用個數分别是2和3,比預計的1和2多了一個,這是因為傳遞參數給getrefcount的時候産生了一個臨時引用[1]。

>>> a = [] 

>>> getrefcount(a) 

2 

>>> b = a 

>>> getrefcount(a) 

3 

           

當一個變量通過另外一個變量指派,那麼它們的對象引用計數就增加1,當其中一個變量指向另外的地方,之前的對象計數就減少1。

>>> a = [] 

>>> getrefcount(a) 

2 

>>> b = a 

>>> getrefcount(a) 

3 

>>> getrefcount(b) 

3 

>>> b = [] 

>>> getrefcount(a) 

2 

>>> getrefcount(b) 

2 

           

垃圾回收(Garbage Collection)python提供了del方法來删除某個變量,它的作用是讓某個對象引用數減少1。當某個對象引用數變為0時并不是直接将它從記憶體空間中清除掉,而是采用垃圾回收機制gc子產品,當這些引用數為0的變量規模達到一定規模,就自動啟動垃圾回收,将那些引用數為0的對象所占的記憶體空間釋放。這裡gc子產品采用了分代回收方法,将對象根據存活的時間分為三“代”,所有建立的對象都是0代,當0代對象經過一次自動垃圾回收,沒有被釋放的對象會被歸入1代,同理1代歸入2代。每次當0代對象中引用數為0的對象超過700個時,啟動一次0代對象掃描垃圾回收,經過10次的0代回收,就進行一次0代和1代回收,1代回收次數超過10次,就會進行一次0代、1代和2代回收。而這裡的幾個值是通過查詢get_threshold()傳回(700,10,10)得到的。此外,gc子產品還提供了手動回收的函數,即gc.collect()。

>>> a = [] 

>>> b = a 

>>> getrefcount(a) 

3 

>>> del b 

>>> getrefcount(a) 

2 

           

而垃圾回收還有一個重要功能是,解決循環引用的問題,通常發生在某個變量a引用了自己或者變量a與b互相引用。考慮引用自己的情況,可以從下面的例子中看到,a所指向的記憶體對象有3個引用,但是實際上隻有兩個變量,假如把這兩個變量都del掉,對象引用個數還是1,沒有變成0,這種情況下,如果隻有引用計數的機制,那麼這塊沒有用的記憶體會一直無法釋放掉。是以python的gc子產品利用了“标記-清除”法,即認為有效的對象之間能通過有向圖連接配接起來,其中圖的節點是對象,而邊是引用,下圖中obj代表對象,ref代表引用,從一些不能被釋放的對象節點出發(稱為root object,一些全局引用或者函數棧中的引用[5],例如下圖的obj_1,箭頭表示obj_1引用了obj_2)周遊各代引用數不為0的對象。在python源碼中,每個變量不僅有一個引用計數,還有一個有效引用計數gc_ref,後者一開始等于前者,但是啟動标記清除法開始周遊對象時,從root object出發(初始圖中的gc_ref為(1,1,1,1,1,1,1)),當對象i引用了對象j時,将對象j的有效引用個數減去1,這樣下圖中各個對象有效引用個數變為了(1, 0, 0, 0, 0, 0, 0),接着将所有對象配置設定到兩個表中,一個是reachable對象表,一個是unreachable對象表,root object和在圖中能夠直接或者間接與它們相連的對象就放入reachable,而不能通過root object通路到且有效引用個數變為0的對象作為放入unreachable,進而通過這種方式來消去循環引用的影響。

在人工調用gc.collect()的時候會有一個傳回值,這個傳回值就是這一次掃描unreachable的對象個數。在上面談到的每一代的回收過程中,都會啟用“标記-清除”法。

>>> a = [] 

>>> b = a 

>>> getrefcount(b) 

3 

>>> a.append(a) 

>>> getrefcount(b) 

4 

>>> del a 

>>> getrefcount(b) 

3 

>>> del b 

>>> unreachable = gc.collect() 

>>> unreachable 

1 

           
[Python]記憶體管理第二次啟動解釋器緩沖區被重複利用

圖1 變量形成的有向圖

2.2 各種操作對變量位址的改變

當處理指派、加減乘除時,這些操作實際上導緻變量指向的對象發生了改變,已經不是原來的那個對象了,并不是通過這個變量來改變它指向的對象的值。

>>> a = 10 

>>> hex(id(a)) 

'0xe17080' 

>>> a = a - 1 

>>> hex(id(a)) 

'0xe17098' 

>>> a = a + 1 

>>> hex(id(a)) 

'0xe17080' 

>>> a = a * 10 

>>> hex(id(a)) 

'0xe177a0' 

>>> a = a / 2 

>>> hex(id(a)) 

'0xe17488' 

           

增加減少list、dict對象内容是在對對象本身進行操作,此時變量的指向并沒有改變,它作為對象的一個别名/引用,通過操縱變量來改變對應的對象内容。但是一旦将變量指派到别的地方去,那麼變量位址就改變了。

>>> a = [] 

>>> hex(id(a)) 

'0x7fe7e02caef0' 

>>> a.append(1) 

>>> hex(id(a)) 

'0x7fe7e02caef0' 

>>> a = [1] 

>>> hex(id(a)) 

'0x7fe7e02caea8' 

           

當把一個list變量指派給另外一個變量時,這兩個變量是等價的,它們都是原來對象的一個引用。

>>> a = [] 

>>> b = a 

>>> a.append(1) 

>>> b 

[1] 

>>> hex(id(a)) 

'0x7fe7e02caea8' 

>>> hex(id(b)) 

'0x7fe7e02caea8' 

           

但是實際使用中,可能需要的是将裡面的内容給複制出來到一個新的位址空間,這裡可以使用python的copy子產品,copy子產品分為兩種拷貝,一種是淺拷貝,一種是深拷貝。假設處理一個list對象,淺拷貝調用函數copy.copy(),産生了一塊新的記憶體來存放list中的每個元素引用,也就是說每個元素的跟原來list中元素位址是一樣的。是以從下面例子中可看出當原list中要是包含list對象,分别在a和b對list元素做操作時,兩邊都受到了影響。此外,通過b=list(a)來對變量b指派時,也跟淺拷貝的效果一樣。

>>> a = [1, 1000, ['a', 'b']] 

>>> b = copy.copy(a) 

>>> b 

[1, 1000, ['a', 'b']] 

>>> hex(id(a)) 

'0x7fe7e02e1368' 

>>> hex(id(b)) 

'0x7fe7e02e1518' 

>>> hex(id(a[2])) 

'0x7fe7e02caea8' 

>>> hex(id(b[2])) 

'0x7fe7e02caea8' 

>>> a[2].append('a+') 

>>> a 

[1, 1000, ['a', 'b', 'a+']] 

>>> b 

[1, 1000, ['a', 'b', 'a+']] 

>>> b[2].append('b+') 

>>> a 

[1, 1000, ['a', 'b', 'a+', 'b+']] 

>>> b 

[1, 1000, ['a', 'b', 'a+', 'b+']] 

>>> a[0] = 2 

>>> a 

[2, 1000, ['a', 'b', 'a+', 'b+']] 

>>> b 

[1, 1000, ['a', 'b', 'a+', 'b+']] 

           

而深拷貝則調用copy.deepcopy(),它将原list中每個元素都複制了值到新的記憶體中去了,是以跟原來的元素位址不相同,那麼再對a和b的元素做操作,就是互相不影響了。

>>> a = [1, 1000, ['a', 'b']] 

>>> b = copy.deepcopy(a) 

>>> hex(id(a)) 

'0x7fe7e02cae18' 

>>> hex(id(b)) 

'0x7fe7e02e1368' 

>>> hex(id(a[2])) 

'0x7fe7e02e14d0' 

>>> hex(id(b[2])) 

'0x7fe7e02e1320' 

>>> a[2].append('a+') 

>>> a 

[1, 1000, ['a', 'b', 'a+']] 

>>> b 

[1, 1000, ['a', 'b']] 

>>> b[2].append('b+') 

>>> a 

[1, 1000, ['a', 'b', 'a+']] 

>>> b 

[1, 1000, ['a', 'b', 'b+']] 

           

當把一個變量傳入一個函數時,它對應的對象引用個數增加2。

3. 小結

本來是因為前天把128g的伺服器用當機了,想搞清楚為什麼會導緻那個問題,寫完這篇去檢查了一下,發現并不是因為對記憶體的使用有誤導緻的,而是因為我用到了多次hist函數,這個函數占了記憶體,換成了numpy的histgram函數就好了。不過寫完也覺得很有意思,特别是垃圾回收其實是一個比較重要的不僅局限于python語言的一個東西,看了不少部落格直接拿源碼過來分析也是好牛的感覺。而對于标記清除法,個人不是特别了解為什麼要加有效引用計數,那些循環引用的一個對象或者兩個三個對象不應該跟有效的對象本來就是隔離開的,既然在周遊的時候,就能知道哪些對象是通路不到的,那麼這些對象不就應該形成了環麼。

4. 引用

[1] Python深入06 Python的記憶體管理

[2] Python Garbage Collection

[3] Python記憶體池管理與緩沖池設計

[4] Python垃圾回收機制:gc子產品

[5] 《Python源碼剖析》,陳儒著,2008

【轉載自】chenrudan.github.io