什么是垃圾回收?
垃圾回收是追蹤所有正在被使用的對象,並標注剩余的為garbage。這里我們先從JVM的GC是如何實現的說起。
手動內存管理
在開始介紹垃圾回收之前,我們先復習一下手動內存管理。它是指你需要明確的為你的數據手動分配需要的空閑內存,但是如果用完后忘了free 掉這些內存,則之后也無法再次使用這部分內存。也就是說,這部分內存是屬於被申明但未被繼續使用。這種情況稱為一個memory leak(內存泄漏)
下面是一個C語言寫的一個例子,使用手動管理內存:
int send_request(){ size_t n = read_size(); int *elements = malloc(4 * sizeof(int)); if(read_elements(n, elements) < n){ // elements not freed return -1; } free(elements) return 0; }
忘記free memory 可能是一件相當常見的事情。Memory Leak在過去也是一個較為常見的問題,而且僅能通過修改代碼才能完全解決此問題。所以,一個更好的方法是:自動回收未被用的內存,減少人本身可能犯錯的可能性。這種自動的機制就是垃圾回收(簡稱GC)
智能指針
自動垃圾回收的第一種方法基於reference counting(引用計數)。對每個object,簡單的計算它被引用了多少次,如果次數為0,則這個object可以被安全的回收。一個很著名的例子是C++的shared pointers:
int send_request() { size_t n = read_size(); shared_ptr<vector<int>> elements = make_shared<vector<int>>(); if(read_elements(n, elements) < n) { return -1; } return 0; }
shared_ptr 用於追蹤object被引用的數量。這個值會在object被傳遞時增加,在離開域時減少。一旦這個引用數量值到達0,shared_ptr 會自動刪除它底層的vector。然而,這個例子在實際使用中並不普遍,不過作為一個展示的例子足夠了。
自動內存管理
在上面的C++ 代碼中,我們已經明確的說明了什么時候我們需要考慮好內存管理。如果我們讓所有的object都使用這種自動回收內存的方式的話,肯定會方便開發人員做開發,因為他們不需要再去手動釋放一些objects。Runtime會自動獲取到哪些內存不會再被使用,並釋放這些內存。換句話說,它會自動收集這部分垃圾。
第一個垃圾回收器在1959年創建,用於Lisp語言,並且從那時候開始,垃圾回收的技術才有進展。
引用計數法
上面介紹的C++ 的shared pointers 可以被應用到所有的objects。很多語言例如Perl,Python或PHP都使用了這種方法。下面的圖片很好的展示了這個方法:
綠色的小雲表示它們指向的objects仍然在被程序員使用。從技術層面來說,這些可能是正在執行的方法中的局部變量,或是靜態變量等等。它可能在不同的編程語言中有不同的場景,這里我們不做進一步探討。
藍色的小環代表內存里當前活躍的objects,上面的數字表示它的引用計數。最后,灰色的小環表示沒有被任何當前在使用的object(也就是之別被綠色的小雲引用的)引用的objects。也就是說,灰色的小環就是需要被垃圾回收器清理的垃圾。
這個方法看起來好像很不錯,但是它有一個很大的問題,即:如果是存在一個獨立的有向回環的話,則這些object永遠不會被回收,例如:
紅色的圓環其實是需要被收集的垃圾,但是由於相互引用,引用計數不為1,所以不會被回收。所以這個方法仍舊會造成memory leak。
也有一些方法用於克服這個問題,例如使用一個特殊的 ‘weak‘ references 或應用一個單獨的算法用於收集這些回環。像之前提到過的語言 – Perl,Python以及PHP,它們都會以某種方式處理這種回環並回收垃圾。當然,這部分超出了在此討論的范圍,我們仍會以討論JVM采用的方法為主。
標記並清除
首先,JVM對於如何跟蹤一個object會有更具體的信息,所以相對於之前模糊定義的綠色的小雲,我們現在可以更清晰的定義那些被稱為Garbage Collection Roots(垃圾回收根)的一系列對象:
- 局部變量
- 活躍的線程
- 靜態區域
- JNI引用
在JVM中跟蹤所有可達的(當前活躍的)對象,並確保那些uon-reachable對象申明的內存被再次重復使用的方法,稱為Mark and Sweep(標記並清除)算法。它包含兩個步驟:
- Marking(標記):從GC roots開始,遍歷所有reachable對象,並在本地內存保存所有這些對象的一個記錄
- Sweeping(清除):確保被 non-reachable對象占用的內存空間可以在下一個allocation階段時被重新使用
在JVM中的不同的GC 算法,例如Parallel Scavenge,Parallel Mark+Copy 或CMS,都實現了上面兩個階段,但是會存在一些細微的差別。但是從概念層次上,整個過程基本與上面兩個步驟類似。
這個方法中最重要的是:解決了回環導致內存泄漏的問題。
但是這個方法的一個不太好的點是:在collect發生時,應用的線程需要被暫時stopped(停止),因為如果狀態是一直在變化的,則引用計數便不會特別准確。當所有應用被暫時stopped,以便讓JVM可以完全管理這種內部活動時,這個場景被稱為Stop The World pause。當然,STWP 發生的原因可能會有很多種,但是GC是其中最常見的一種。
Java 里的垃圾回收
之前對於Mark and Sweep 的垃圾回收的描述是一個最理論的介紹。在實際情況下,為了適應real-world的場景及需求,對此可能需要做大量的調整。作為一個簡單的例子,下面看一下在我們安全的持續分配對象時,JVM所需要做的各類記錄與操作。
碎片與緊縮
當 Sweeping 發生時,JVM需要確保的是unreachable 對象所占據的空間可以被再次使用。這個(最終一定)會產生內存碎片(類似於磁盤碎片),這樣會導致兩個問題:
- 寫操作會更耗時,因為找到下一個有足夠空間的空閑塊不再是一個低消耗操作
- 當創建一個新對象時,JVM會在連續的內存塊上分配內存。所以如果碎片的問題上升到沒有單獨、空閑、並足夠的空間以滿足新創建的對象時,會報一個allocation error
為了避免這些問題,JVM會去確保碎片問題不會失控。所以,除了做Marking and Sweeping,在垃圾回收時,也會有一個“memory defrag“的工作。這個進程重新分配所有reachable 對象,將它們相鄰排列,清除掉(或是減少)內存碎片。下面是一個示意圖:
世代假說
正如之前提到過的,在做垃圾回收時,會牽涉到完全停止應用。同時,可以明顯確認的是:對象越多,回收垃圾的耗時越長。那我們是否可以只對某些小的內存區域做操作?在研究人員對此做進一步研究后,可以發現:在應用內部,大部分內存分配發生在以下兩種場景:
- 大部分對象很快變成unused
- 對象不長期存活
這個發現促成了 Weak Generational Hypothesis。根據這個假設,VM 里的內存被分成兩部分,分別稱為Young Generation和Old Generation,后者有時也被稱為 Tenured(終身的)。
這種分離的、獨立的可清理區域,使得大量不同的算法可以對GC做很多performance上的提升。當然,這並不是說,這種方式完全沒有問題。例如,不同generations的對象可能事實上也是有相互的引用,這樣在做垃圾回收時,它們也會被認為是GC roots。
需要着重注意的是,世代假說可能實際上並不適用一些應用。因為GC的算法是對“die young(早逝)”或“有可能一直存在”的對象做優化,但JVM的行為對於(被預期為)“中期”長度生命的對象是不夠優化的。
內存池
下面是在堆內存里對內存池的划分,可能大家對此已經比較熟悉了。而對於GC如何在不同的內存池中做回收,可能比較陌生。需要注意的是,不同的GC算法可能在實現的層面稍有差別,但是從概念層面上,基本是一致的。
Eden(伊甸園)
在對象被創建時,會從Eden這個內存區域里分配內存。由於一般會有多個線程用於同時創建大量對象,Eden空間會被進一步划分為一個或多個 Thread Local Allocation Buffer(TLAB)(線程本地分配緩沖區)。這些緩存允許JVM在一個線程在它對應的TLAB中直接分配能夠分配的最多的對象,避免了與其他線程同步的消耗。
當在一個TLAB中無法完成分配動作時(一般來說是由於里面沒有足夠的空間),分配的動作會移動到一個共享的Eden空間。如果那里也沒有足夠的空間,則在Young Generation里的一個垃圾回收進程會被觸發並釋放出更多的空間。如果垃圾回收也無法在Eden里釋放足夠的空間,則對象會被分配到Old Generation。
當Eden 正在被回收時,GC會從GC roots遍歷所有可達的對象,並將它們標注為存活(alive)。我們之前提到過,對象可能存在跨generation的引用,所以一個直接的方法是:檢查所有從其他generation指向Eden的引用。但是這個可能會直接影響了之前我們提到的世代假說(原本已將它們分為兩部分,現在這兩部分卻有了聯系)。
JVM里對此做了一個優化,叫做:card-marking(卡片標記)。簡單的說,就是對於那些有被Old Generation 引用的、存在於Eden中的“臟”對象,JVM僅僅是對它做一個大致的、模糊的位置標記。
在標記階段完成后,所有在Eden中存活的對象會被復制到其中一個Survivor 空間。整個Eden現在會被認為是空的,並且它的空間可以被重新用於分配其他更多的對象。這個方法稱為“Mark and Copy”(標記並復制):活躍的對象被標記,然后被復制到(而不是移動)一個survivor 空間。
Survivor Space(幸存者空間)
鄰接Eden空間的是兩個Survivor空間,被稱為from和to。需要注意的是,這兩個Survivor空間中的其中一個一定是一直是空的。
空的Survivor 空間會在Young Generation被回收后開始往里面放入內容。所有從整個Young Generation(包括Eden 空間以及non-empty的“from”Survivor空間)存活的對象會被復制到“to”Survivor空間。在這個過程完成后,“to”Survivor現在會存放對象,而“from”Survivor空間沒有對象,它們的角色也會在這時做轉換。
這個在兩個Survivor空間中復制存活對象的過程會重復多次,直到一些對象被認為經歷的時間足夠久並“old enough”。需要注意的是,根據世代假說,存活時間較長的對象被預期是會繼續被長時間使用的。
這些長時間存活的對象因此可以被“提升”到Old Generation。當這個過程發生時,對象並不是從一個survivor空間移動到另一survivor空間,而是被移動到了Old Generation空間。這些對象會在Old Generation空間里長久存在,直到它們unreachable。
為了判斷一個對象是否是“old enough”並被移動到Old 空間,GC會跟蹤對象在回收后仍然存活的次數。在每個對象的generation在GC中完成后,這些依舊存活的對象的年紀會增加。當它們的年紀超過了一個特定的“年紀閾值”后,會被移動到Old 空間。
而實際的“年紀閾值”是JVM動態調整的,但是可以通過指定 -XX:+MaxTenuringThreshold 設置一個上限值。若將此參數設置為0,則會導致移動到Old 空間立即生效(也就是說,不會在Survivor空間之間做復制)。默認情況下,這個閾值在主流的JVM中是15輪GC。這也是HotSpot中的最大值。
Promotion(從young 空間移動到old空間)也可以在對象經歷的GC輪數達到閾值前發生,如果在Young Generation中的Survivor空間不足以存下所有存活的對象的話。
Old Generation(老生代)
Old Generation內存空間的實現更為復雜。Old Generation的空間一般會比Young Generation大得多,並且存放了那些更少可能被回收的對象。
在Old Generation中發生的GC頻率要少於Young Generation。並且,由於大部分對象被認為是在Old Generation中應該存活的,所以在這里不會有Mark and Copy(標記並復制)的過程發生。取而代之的是,對象會被四處移動,以最小化內存碎片。Old 空間的清理算法一般基於不同的基礎。基本上,會經歷以下幾個步驟:
- 標記所有可達對象(從GC roots可達的對象),設置標記位
- 刪除所有不可達對象
- 復制存活的對象,並從Old 空間的起始,將它們緊湊、相鄰地排在一起
從上面的步驟可以看到,在Old Generation的GC會將對象緊湊的排列,以避免過度的內存碎片。
PermGen(永生代)
在Java 8 以前,會存在一個特殊的空間名為“Permanent Generation”(永久代)。這個地方會存放一些metadata(例如classes)。同時,一些額外的東西,例如Internalized strings(常量字符串)也會存在PermGen。
但是它在過去常常會對Java 開發者產生大量的問題,因為這個區域到底一共需要多少空間是很難被預測的。而預測失敗的結果往往會導致 java.lang.OutOfMemoryError:Permgen space 的報錯。
除非真正導致這個OutOfMemory報錯的原因是一個內存泄漏,否則修復這個問題的方法一般是增加PermGen的空間分配,例如下面的選項指定了最高允許的PermGen內存空間為256MB:
java -XX:MaxPermSize=256m com.company.MyApplication
Metaspace(源空間)
預測metadata所需的空間是一個復雜且不方便的工作,所以Permanent Generation在Java 8 被移除了,並由Metaspace所取代。 從此,大部分雜七雜八的內容被移動到了常規的Java heap中。
但是,類的定義(class definitions),現在被加載到了名為Metaspace的地方。它存在於本地內存並且不干擾常規堆中的對象。默認情況下,Metaspace的空間僅僅由Java進程所擁有的本地可用內存所限制。這種方式解決了在新增加一個或多個類到應用時返回 java.lang.OutOfMemoryError:Permgen space 的問題。
需要注意的是,擁有這種看似無限制的內存空間並不是沒有開銷的,如果讓Metaspace無控制的增長的話,則會引入大量的swapping操作並甚至可能觸發本地內存分配報錯。
考慮到仍舊需要對此場景做控制,我們可以限制Metaspace的增長,例如,限制它的大小為256MB:
java -XX:MaxMetaspaceSize=256m com.company.MyApplication
References:
https://plumbr.io/java-garbage-collection-handbook