簡述Python垃圾回收機制和常量池的驗證
- 2021 年 3 月 8 日
- 筆記
通過程式碼驗證python解釋器內部使用了常量池
Python的引入
人類認識世界是從認識世界中的一個又一個實物開始,然後再對其用語言加以描述。例如當中國人看到蘋果時,便會用中文「蘋果」加以描述,而用英語的一些國家則會用「apple」加以描述。
以上說到的中文和英文都是人類認識並描述世界的一個工具,而在電腦的世界中,為了讓電腦去認知世界,從而幫助人類完成更多的任務。在電腦領域中也發展了語言這個工具,從早期的機器語言到彙編語言再到現在使用範圍較廣的高級語言。而我們接下來要介紹的Python則屬於高級語言這一分支。
變數的引入
為什麼要有變數
上面說到Python是電腦世界中用來描述外部世界的,並且也提及了世界就是一個又一個實物的堆疊,描述世界其實就是去描述那一個又一個實物,人類如此,電腦也是如此。因此電腦語言開發者們為了使用電腦語言的人更好的在電腦中去描述這些實物,便在電腦語言中引入了變數這個概念,Python也不例外。簡單點說,變數就是用來描述世間萬物的。
定義變數
為了在電腦書寫方便,定義一變數也有一定的規則,在這裡我們僅說說Python中變數的定義規則,首先我們先定義兩個變數:
name = 'chenyoude'
year = 2021
上述程式碼中我們便定義了兩個變數,從上面定義的兩個變數中,我們可以看到,變數的組成分為三個部分:
- 變數名:反應變數值所描述的意義,並且可以用來引用變數值。
- 賦值符號:賦值。
- 變數值:存放數據,用來記錄現實世界中的某種狀態。
常量引入
上面簡單講解了Python中的變數,通過字面意思,可以看到變數其實是一個變化的量,例如,下面這個實例:
year = 2021
year = year + 1
print(year) # 輸出結果:2022
剛開始我們賦予了year一個變數值為2021,當我們對year進行加1操作時,可以發現year值變成了2022。對於上述現象我們不難理解,因為之前說過Python中變數是用來描述世間萬物的,世間萬物在現實中是可以變化的,變數當然也可以隨之變化。
但是在某個局部範圍內,變數可能是不會變化的,例如在2021年這一年,都只會是2021年,沒有人會說2021年是2022年。如果你有豐富的開發經驗,會明白變數定義出來不是存放在那裡給你看的,更多的是要拿來用的。也就是說如果在2021年中的某個程式需要使用year這個變數,但這個變數是不需要進行修改的。為了防止誤操作對year這個變數進行了修改,電腦語言便設計了常量這個概念,也就是說常量相對於變數是一個不會變化的量。
在Python中,有沒有常量呢?不嚴格的講,其實是有的,只是在定義常量的時候常量名必須的全大寫,例如,下面這個實例:
YEAR = 2021
YEAR = YEAR + 1
print(YEAR) # 輸出結果:2022
上面這個常量的實例令人大吃一驚,因為使用常量YEAR後和使用變數year的結果一致,也就是說常量YEAR遭到了更改。但是,稍微解釋你就明白了。
在Python中,雖然也和其他很多電腦語言一樣擁有常量這個概念,但更多的是約定俗成的,Python並沒有嚴格的對常量進行控制,只是規定常量名必須全部大寫。原因很簡單:都是常量了,你為什麼還要修改?
常量池引入
上面講到常量就是一個不會變化的變數,嚴格的講,在Python中是沒有常量這個概念的。但是,在Python中又有另外一種例外,那就是常量池,為了搞清楚常量池,首先我們得弄明白Python的幾個小知識,接下來一一敘說。
Python解釋器
上面提及到Python是電腦用來描述世間萬物的一種語言,由於電腦沒有人腦那麼強大,電腦更多的只是認識高低壓電頻,再通過對高低壓電頻的轉化進而編碼成我們看到的一個又一個字元,也就是說電腦是無法直接認識利用Python寫下的字元的。(此處設計電腦組成原理,不多做介紹)
也就是說,當我們利用Python寫下一個又一個字元並且交給電腦時,需要通過編碼這個過程,而這個編碼的過程有時候也被稱為解釋。解釋的原理就相當於從中文轉成英文,只不過此時不是需要讓英文使用者看懂中文,而是讓電腦能夠看懂Python。
中文轉成英文的時候,可能需要一個翻譯員或一個翻譯軟體,利用Python寫下的字元轉化為電腦能看懂的語言同樣如此,這個轉化過程也需要一個外物的幫助——Python解釋器。
Python變數存儲機制
假設我們使用Python解釋器定義了以下一個變數:
year = 2021
當我們通過字元定義變數時,一定會好奇這些變數被Python解釋器解釋後到底去了哪?如果對電腦的組成熟悉的同學,一定會清楚電腦的核心組件為:CPU、記憶體、外存、輸入設備、輸出設備。也就是說,這些字元應該存儲在這些核心組件中。在這裡就不賣關子了,當我們通過字元定義變數並對其用Python解釋器進行解釋時,他們會以電腦能看懂的形式進入記憶體當中。
上面講的對於很多非科班出身的朋友可能很難理解,在這裡將它生動化。現在假設江西師範大學相當於電腦記憶體,每當有一批新學生進入師大時,師大都會開闢出一個新教室給這批新同學使用,並且會給每一個教室一個獨一無二的教室牌號。由於把師大看作是記憶體,這批新同學就可以看成是變數值,而教室牌號就是變數名。也就是說,對於師大這個大記憶體,每定義一個變數year=2021
,就會在這個大記憶體中開闢一個小空間,小空間中放變數值2021,然後大記憶體會給這個小空間定義一個變數名year,此時變數名year指向變數值2021。
上面說到每當Python解釋器解釋一個變數時,會將這個變數存放到記憶體中的一個小空間中,但如何知道這個小空間的具體位置呢?此處介紹Python的一個內置函數id()
,通過這個函數可以獲取某一個變數所在的記憶體地址,例如下面這個實例:
year = 2021
print(id(year)) # 輸出4499932432
Python垃圾回收機制
對於上述師大的例子,此處再做延伸。由於那一批學生所在班級新轉來了幾位同學,需要那一批學生更換更大一點教室,也就是給他們一個新的教室。那麼學校應該會這樣處理,首先開闢一個新的教室,然後拿下那一批學生原有教室的教室牌號更換到這個新教室,最後會清空原有教室。
在Python中,也是如此,如果到了新的一年,我們會重新定義一個year變數,也就是year=2022
。如果這是在同一個程式中如此做,Python會沿用上述更換教室的方法,它首先會解除year和2021的連接,開闢一個新記憶體存放變數值2022,讓year與2022連接。此時,會發現2021這個變數值只有變數值而沒有變數名,因此這個沒有變數名的變數值會變成Python眼中的一個垃圾變數,從而觸發Python垃圾回收機制,對這個2021所在的記憶體空間進行回收。
為了更好地理解Python垃圾回收機制,可以看下面這個例子:
year = 2021
print(id(year)) # 輸出4499932720
print(year) # 輸出2021
year = 2022
print(id(year)) # 輸出4499932560
print(year) # 輸出2022
通過上述例子,可以看到當新定義了一個year變數時,year會與新的變數進行一個連接。當然,此處所說的垃圾回收機制只是為了引入引用計數這個概念,並不是完全正確的解釋,並且上述實例還無法證明變數值2021所在記憶體是否被回收,下面將通過引用計數的實例會進一步說明並重新解釋垃圾回收機制。
引用計數
上面講到如果某個變數值綁定著變數名,就是一個正常的變數,如果該變數值沒有綁定著門牌號,這個變數就是一個垃圾變數,對於垃圾變數,Python會觸發垃圾回收機制回收這個變數所佔有的記憶體。進而可以想到,Python中一個變數名一定只能對應一個變數值。
在這裡我們就不能沿用師大這個例子了,而得引出一個新的名詞——引用計數。
為了解釋引用計數,我們首先得明白在Python中,當定義了一個變數值為2021的變數時,它可以表示年份、也可以表示山的高度…也就是說一個變數名只能對應一個變數值,但是一個變數值可以對應不同的變數名,這種設計也是比較合理的。
現在我們引出引用計數這個概念,當相同的變數值被賦予不同的變數名時,變數值每增加一個變數名的賦予,則該變數值的引用計數加1。由於我們可以通過Python內置sys模組中的getrefcount()
函數獲取某一個變數的引用計數(getrefcount輸出值默認從3開始),可以通過下面這個例子感受下:
import sys
# 引用計數初始值為3
print(sys.getrefcount(2021)) # 輸出為3
year = 2021
print(sys.getrefcount(2021)) # 輸出為4
height = 2021
print(sys.getrefcount(2021)) # 輸出為5
del year
print(sys.getrefcount(2021)) # 輸出為4
從上述程式碼可以看出變數值2021的引用計數由於每一次賦予新的變數名,引用計數都會增加,而當我們利用del關鍵字刪除變數值2021的一個變數名year時,引用計數則會減少。
為了更加嚴謹的表達引用計數,此處不得不再次深入,引用計數字面意思可以理解為引用的次數,也就是說上面的例子其實並不嚴謹,更嚴謹的講,只有當一個變數值每一次被直接或間接引用時,引用計數才會增加,在Python中讓引用計數增加共有三種方法:
- 變數被創建,變數值引用計數加1
- 變數被引用,變數值引用計數加1
- 變數作為參數傳入到一個函數,變數值引用計數加2
具體看下述實例:
import sys
# 引用計數初始值為3
print(sys.getrefcount(2021)) # 輸出為3
# 變數被創建,變數值引用計數加1
year = 2021
print(sys.getrefcount(2021)) # 輸出為4
# 變數被引用,變數值引用計數加1
height = year
print(sys.getrefcount(2021)) # 輸出為5
# 變數作為參數傳入到一個函數,變數值引用計數加2
def func(year):
print(sys.getrefcount(year))
func(year) # 輸出為7
Python中既然有增加引用計數的方法, 也當然會減少引用計數的方法,共有以下4種:
- 變數值對應的變數名被銷毀
- 變數值對應的變數名被賦予新的值
- 變數值對應的變數名離開它的作用域
- 變數值對應的變數名的容器被銷毀
重看Python垃圾回收機制
有了getrefcount()
方法並通過引用計數,我們就可以解開垃圾回收機制遺留的一個問題——如何判斷是否觸發了垃圾回收機制。每當一個變數定義,他的getrefcount輸出值為3,而如果該變數值被垃圾回收機制回收,則它的getrefcount輸出值回到3,可以通過下面實例驗證上述猜想:
import sys
print(sys.getrefcount(2021)) # 輸出為3
year = 2021
print(sys.getrefcount(2021)) # 輸出為4
print(id(year)) # 輸出4499932720
print(year) # 輸出2021
year = 2022
print(sys.getrefcount(2021)) # 輸出為3
print(id(year)) # 輸出4499932560
print(year) # 輸出2022
通過上述實例,可以發現由於變數值2021對應的變數名被新的變數值2022引用,它的getrefcount輸出值為3,引用計數變成了0,因此可以證明Python觸發了垃圾回收機制。
如果對上述驗證Python觸發垃圾回收機制的實例深入挖掘,會發現當把year賦給變數值2022時,變數值的2021的引用計數為0,此時觸發了Python的垃圾回收機制,那麼是否可以表明只有當變數值2021的引用計數為0時才能觸發垃圾回收機制呢?而不是上一次說的當變數值的變數名被新的變數值被引用了才會銷毀呢?因為變數值可以對應多個變數名,下面通過下述實例驗證:
import sys
print(sys.getrefcount(2021)) # 輸出為3
year = 2021
print(sys.getrefcount(2021)) # 輸出為4
height = 2021
print(sys.getrefcount(2021)) # 輸出為5
year = 2022
print(sys.getrefcount(2021)) # 輸出為4
del height
print(sys.getrefcount(2021)) # 輸出為3
通過上述實例,可以發現由於定義一個變數後,該變數對應的變數值引用計數可以不斷增加,而只要引用計數不為0,那麼Python就一直還在記憶體中保留著這個變數值並且對其引用,只有當該變數的引用計數為0時,Python才會觸發垃圾回收機制對該變數值進行回收,這才是比較正確的垃圾回收機制。當然,如果深入,Python的回收機制還有分代回收,此處不做延展,了解上述這些就足矣了解接下來講的小整數池。
常量池
在上述各個知識的打通之後,現在可以正式引入常量池這個概念。上面講到在Python中嚴格的講是沒有常量這個概念的,即使你通過約定俗成的方法定義了一個常量,但這個常量也只是一個變數,也就是說只要你對這個常量做出修改,這個常量原有對應的常量值引用計數就會變成0,由於常量等同於變數,它一樣會被Python垃圾回收機制回收。
但是在Python中,存在著一些例外,這些例外就是一個小整數池,顧名思義,小整數池表示的是從-5到256範圍內的整數,這些整數定義出來後就是一個常量,也就是說他們的引用計數即使為0,也不會被Python的垃圾回收機制回收,可以通過下述實例驗證:
import sys
first_l = [] # 定義列表l存儲[-5,256]中的所有整數的引用計數
add_l = [] # 定義列表add_l存儲[-5,256]中的所有整數的引用計數加1後的引用計數
del_l = [] # 定義列表del_l存儲[-5,256]中的所有整數的引用計數減1後的引用計數
for i in range(-5, 256):
first_l.append(sys.getrefcount(i))
add = i
add_l.append(sys.getrefcount(i))
del add
del_l.append(sys.getrefcount(i))
first_l.sort()
add_l.sort()
del_l.sort()
print(f'min(first_l): {min(first_l)}') # 獲取[-5,256]中所有整數的最小引用計數,輸出為4
print(f'min(add_l): {min(add_l)}') # 獲取[-5,256]中所有整數的最小引用計數,輸出為5
print(f'min(del_l): {min(del_l)}') # 獲取[-5,256]中所有整數的最小引用計數,輸出為4
從上述實例可以看出,[-5,256]中的整數的getrefcount默認初始值為4,也就是說即使沒有對這些整數進行初始化的創建,Python早已對他們進行了引用,即使他們的引用計數為0,他們也不會也不可能被刪除,因為他們從Python解釋器啟動開始就已經被生成。
當然,也可以通過垃圾回收機制判斷小整數池中的整數是否會被垃圾回收機制回收,可用如下實例證明(由於Pycharm等解釋器會一次性編譯整個文件,固使用終端編輯程式碼):
>>> a = 5
>>> id(a)
4529334480
>>> del a
>>> b = 5
>>> id(b)
4529334480
>>>
>>> a = 257
>>> id(a)
4533920752
>>> del a
>>> b = 257 # 消除分代回收對結果的影響
>>> del b
>>> b = 257
>>> id(b)
4531031792
>>>
從上述實例中可以看出,變數值5即使被垃圾回收機制回收後,再次創建變數值為5的變數,該變數的記憶體地址始終無變化,即該變數未被垃圾回收機制回收,小整數池中的其他整數同理;而變數值257卻已經被垃圾回收機制回收,非小整數池中的其他變數同理。
當然,還可以通過下述方法查看這些小整數池的整數的記憶體地址的變化,如下:
a = 256
b = int("256")
print(id(a), id(b)) # 4544968752 4544968752
a = 257
b = int("257")
print(id(a), id(b)) # 4548719792 4546289360
a = -5
b = int("-5")
print(id(a), id(b)) # 4544960400 4544960400
a = -6
b = int("-6")
print(id(a), id(b)) # 4690036912 4546289360
對於上述實例,在Python中,由於每生成一個變數便會開闢一個新的記憶體空間給該變數,但是上述實例表明當變數值為-5和256時,每次開闢的記憶體空間地址都是一樣的;而當變數值不屬於[-5,256]時,每次定義變數值時,記憶體空間的地址都是不一樣的。
總結
在Python中,變數是用來描述世間萬物的,變數顧名思義是變化的一個量,而在某一個局部範圍內,有些量可能是不會變化的,因此語言設計者在電腦中定義了常量這個概念,但是在Python中並沒有規定的常量,只有約定俗稱的常量,也就是變數名全大寫的則是常量。但是Python中有一個另外,也就是小整數池[-5,256],在這個小整數池中的整數對於Python來說就是一個常量,因為從引用計數的列印中可以看出它在Python解釋器啟動的時候就已經生成並佔用了一個固定的記憶體空間,並且不會因為引用計數變為0之後就會被Python的垃圾回收機制回收,而這些小整數池也可以稱作Python的常量池。