當今流行的編程語言,大多具備垃圾回收(Garbage Collection,以下簡稱GC)功能。它能夠將不再使用的內存區域收回并重新分配。
這一功能可以說,將程序員的注意力從內存的分配/釋放工作中解放了出來,可以專注于業務邏輯的實現。但這并不意味著說,程序員在寫代碼的時候就可以無所顧忌了。
因為他們面對的環境里,資源畢竟是有限的,而GC也不能包辦一切工作。尤其是程序需要運行時性能的時候,對代碼的編寫就有更高的要求了。
而在優化程序性能時,也不能憑著猜想去實施,這就需要對編程語言的內存布局與管理有清楚的了解。這樣才能做到有的放矢,事半而功倍。
下面我們先從編譯技術的基本概念說起。
編譯技術
編譯器方式,這種方式是將代碼經過預處理、編譯、匯編、鏈接之后,得到一個可執行文件。這個文件里面包含的都是二進制的機器指令,它的優點是程序執行速度快,能將硬件性能充分發揮出來。
它的缺點則是編譯過程需要耗費時間,程序修改之后必須重新編譯才能使用。在早些年硬件性能不高的時候,編譯一個大型的程序需要一兩個小時是很平常的事。
此類語言的典型代表是C/C++,以及現在十分流行的Go語言。
解釋器方式,程序代碼直接運行在一個解釋器中,沒有編譯的過程。優點則是可以立即運行,且可移植性好,代碼編寫一次即可在任何平臺上運行,而且預期效果也一樣。而編譯器方式則要麻煩的多,它需要為每一個平臺單獨編譯一次。
不過解釋器方式的缺點也同樣明顯,就是它的性能受限。畢竟是隔著一層解釋器去執行,遠遠比不了翻譯成機器指令的二進制可執行文件。
此類語言的代表則有Python、ruby、php、JAVAscript等。可以認為,腳本類語言都屬于解釋器方式執行。
中間代碼方式,這是一種折衷式的方案,它會先對代碼有一次編譯過程,但不是編譯成可執行文件,而是一份中間代碼。然后這份中間代碼會放到一個虛擬機里去執行。以這樣的方式既獲得了良好的可移植性,也能夠擁有高于解釋器的速度。
java語言即是最佳代表。它會先編譯出一個字節碼文件,然后Java Virtual machine(JVM)通過讀取字節碼來運行程序。
微軟的.NET也是類似的結構,它使用的是Common Language Runtime(CLR),以此支持多種語言。例如C#、VB.net等。
基礎知識
不論一個程序用何種語言編寫,它的運行時內存布局都是一致的。我們先從一個程序的三種基本內存區域說起。
靜態區:這個區域主要存放的是程序的全局變量、常量數據,以及編譯成二進制指令的代碼。可以看到,這個區域存放的,主要是貫穿于程序整個生命周期所要使用到的數據與指令。
棧區:熟悉數據結構的朋友們都知道,棧(stack)是一個后入先出(LIFO)的隊列。在程序運行中,它用來實現函數的調用。程序執行函數調用時,會在棧上依次壓入參數,局部變量、返回位置等,執行完成后再依次將數據出棧。所以,棧上的數據都是臨時性的,只在調用時可用。
堆區:所有動態申請的內存都從堆區分配。在使用C/C++語言時,程序員對待內存的申請與釋放就必須特別小心,一個疏忽就會造成內存泄漏。而后來的java、C#等,語言內置了GC技術,情況相對改善,但也要養成良好的編程習慣。
對于程序來說,靜態區和堆區都是全局存在的,即所有線程共享這二者。而棧區則是為每個線程單獨準備一個,這一點程序員要記住。因為棧區的數據在函數調用之后就會失效,如果還引用棧區的數據,則會產生不可預料的問題。
程序運行時內存布局
OOP語言的內存結構
因為現在市場上面向對象編程語言(OOP)占據主流地位,所以接下來的討論也將以OOP語言的典型內存結構進行講解。我們了解清楚對象的存儲區域,方法的調用之后,就會更加明白編程時應當注意哪些方面。
我們以使用較為廣泛的Java語言進行說明,先要厘清一個總是爭論不休的問題。就是Java語言中究竟有沒有指針?
Java中的一系列邏輯功能,都是通過對象的間的消息傳遞和方法調用來實現的。對象是實現功能的最小單元,而一個對象是怎么來的,它存放在哪里?
先看一段派生對象的代碼:
MyCar one = new MyCar()
Java語言中的new的實質是動態創建內存,用以存放對象實例。根據上節的知識,我們知道new操作的結果是從堆區申請了一塊內存,它將這塊內存的地址返回,變量one就可以通過這個地址實現對象的操作了。
所以,變量one中存儲的不是對象本身,而是指向對象所在內存的地址。好吧,簡單說就兩個字:指針。在Java的術語體系里,它也叫引用。不過不管怎么稱呼,這種內存結構就是典型的指針式操作。
既然我們知道Java語言中所有的對象都生成在堆區,那么需要注意之處就來了:堆區的存儲空間是有限的,不能將運行時環境想象成內存無限的場景,要對自己使用的對象所占空間做到心中有數。
接下來還要注意的,就是對象復制的操作,示例代碼:
MyCar one = new MyCar()
MyCar two = one; one.SetSpeed(100);
two.SetSpeed(0);
有了上面的知識,我們清楚地知道,MyCar two = one;這條語句并沒有復制一個對象給two變量,它和one指向的都是同一個對象實例。所以代碼執行的結果,就是這輛車以百公里時速狂奔的下一秒就減速到零,想想都挺嚇人的吧。
方法表與屬性
那么,對象的方法代碼是存放在哪里呢?答案是在靜態區。因為方法是可以在編譯時就形成二進制指令的,因此編譯后放在靜態區就可以了。
類的信息是存放在靜態區的,它會包含一張方法表(有的語言中也稱為虛函數表)。方法表中的方法名實際上是一個函數指針,它在運行時是指向靜態區的方法代碼的。有了方法表,OOP語言就可以實現多態機制了。
這種方式可以節省程序存儲空間,所以從本質上說,所有的對象實例都是在共用同一段方法代碼。只是在調用時通過壓入不同的參數以實現對象個性化的操作。
對象的屬性變量又是存放在哪里?答案是在堆區,所以我們現在知道,一個對象實例里,屬性變量的大小決定了它實際占用的存儲空間。
需要注意的事項又來了:不要在類的聲明中,將屬性變量定義的過大。例如為了圖方便,定義個超大的數組。這樣帶來的問題,一是會影響對象生成的效率,因為動態分配一段大內存是很耗時的;二是會導致內存空間急劇減少。
GC的運行并不是實時清理的,它會有延時判斷策略,那么大量閑置的內存還來不及回收,新的對象又得不到可用空間,這只會降低程序的運行時性能了。
通過方法表,繼承結構也得以實現。對于超類中的方法,子類中無需再存儲相同的副本,它只要在自己的方法表中增加一條指向超類的方法引用即可。
對象通過方法表調用方法
GC會回收哪些對象實例?
通過上述幾節的知識,我們知道GC要處理的肯定是在堆區上動態分配的對象實例。那是不是有了這個原則,我們就可以高枕無憂了呢?并不是,這要從GC的回收原理上說起。
GC的實現基礎,必定是通過引用計數來判定對象是否被使用,未被使用的對象則會進入回收工作中。但是如果對象變量是在靜態區或者棧區,那么這個對象永遠都不會被回收。
靜態區的對象,在Java中就是以static定義的類變量。程序員對此一定要心中有數,一定要記住類變量生成的對象,它的生命周期是和程序本身一樣的。
而棧上所引用的對象,它的存活周期則和方法調用一致。也就是說如果方法退出,那么期間所產生的對象不再使用了,是會被回收的。
在多線程環境中,程序員要注意,如果一個方法是長期后臺運行的,則不要進行頻繁地創建對象的工作,以避免內存無法回收。
被棧區和靜態區引用的對象是不會被回收的
總結
經過了解編程語言的內存布局與管理,我們發現還是有很多細節處不注意的話,很容易掉到坑里去的。那時候,代碼功能看著都正常,但程序運行一段時間后性能就下降。不得不來一次萬能的重啟以解決問題,這顯然不是最佳解決辦法。
所以,我將文中涉及到的注意事項,整理出來再列舉如下。希望可以幫助遇到性能問題的程序員們。
- 堆區的存儲空間是有限的,創建對象時要心中有數;
- 對象變量存儲的不是實例本身,而是指向堆區實例的指針;
- 類中屬性變量不要定義過大,避免出現超大數組;
- 堆區和棧區所引用的對象,是不會被GC所回收的。