垃圾回收_上
前言
你應該聽說過這麼一句話:免費的其實是最貴的。
Java 虛擬機器的自動記憶體管理,將原本需要由開發人員手動回收的記憶體,交給垃圾回收器來自動回收。不過既然是自動機制,肯定沒法做到像手動回收那般精準高效 [1] ,而且還會帶來不少與垃圾回收實現相關的問題。
接下來的兩篇,我們會深入探索 Java 虛擬機器中的垃圾回收器。今天這一篇,我們來回顧一下垃圾回收的基礎知識。
引用計數法與可達性分析
垃圾回收,顧名思義,便是將已經分配出去的,但卻不再使用的記憶體回收回來,以便能夠再次分配。在Java虛擬機器的語境下,垃圾指的是死亡的物件所佔據的堆空間。這裡便涉及了一個關鍵的問題:如何辨別一個物件是存是亡?
我們先來講一種古老的辨別方法:引用計數法(reference counting)。它的做法是為每個物件新增一個引用計數器,用來統計指向該物件的引用個數。一旦某個物件的引用計數器為 0,則說明該物件已經死亡,便可以被回收了。
它的具體實現是這樣子的:如果有一個引用,被賦值為某一物件,那麼將該物件的引用計數器 +1。如果一個指向某一物件的引用,被賦值為其他值,那麼將該物件的引用計數器 -1。也就是說,我們需要截獲所有的引用更新操作,並且相應地增減目標物件的引用計數器。
除了需要額外的空間來儲存計數器,以及繁瑣的更新操作,引用計數法還有一個重大的漏洞,那便是無法處理迴圈引用物件。
舉個例子,假設物件 a 與 b 相互引用,除此之外沒有其他引用指向 a 或者 b。在這種情況下,a 和 b 實際上已經死了,但由於它們的引用計數器皆不為 0,在引用計數法的心中,這兩個物件還活著。因此,這些迴圈引用物件所佔據的空間將不可回收,從而造成了記憶體洩露。
目前Java虛擬機器的主流垃圾回收器採取的是可達性分析演算法。這個演算法的實質在於 將一系列 GC Roots 作為初始的存活物件合集(live set),然後從該合集出發,探索所有能夠被該集合引用到的物件,並將其加入到該集合中,這個過程我們也稱之為標記(mark)。最終,未被探索到的物件便是死亡的,是可以回收的。
那麼什麼是 GC Roots 呢?我們可以暫時理解為由堆外指向堆內的引用,一般而言,GC Roots 包括(但不限於)如下幾種:
- Java 方法棧楨中的區域性變數;
- 已載入類的靜態變數;
- JNI handles;
- 已啟動且未停止的 Java 執行緒。
可達性分析可以解決引用計數法所不能解決的迴圈引用問題。舉例來說,即便物件 a 和 b 相互引用,只要從 GC Roots 出發無法到達 a 或者 b,那麼可達性分析便不會將它們加入存活物件合集之中。
雖然可達性分析的演算法本身很簡明,但是在實踐中還是有不少其他問題需要解決的。
比如說,在多執行緒環境下,其他執行緒可能會更新已經訪問過的物件中的引用,從而造成誤報(將引用設定為 null)或者漏報(將引用設定為未被訪問過的物件)。
誤報並沒有什麼傷害,Java 虛擬機器至多損失了部分垃圾回收的機會。漏報則比較麻煩,因為垃圾回收器可能回收事實上仍被引用的物件記憶體。一旦從原引用訪問已經被回收了的物件,則很有可能會直接導致 Java 虛擬機器崩潰。
Stop-the-world 以及安全點
怎麼解決這個問題呢?在Java虛擬機器裡,傳統的垃圾回收演算法採用的是一種簡單粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收執行緒的工作,直到完成垃圾回收。這也就造成了垃圾回收所謂的暫停時間(GC pause)。
Java虛擬機器中的Stop-the-world是通過安全點(safepoint)機制來實現的。當 Java 虛擬機器收到 Stop-the-world 請求,它便會等待所有的執行緒都到達安全點,才允許請求 Stop-the-world 的執行緒進行獨佔的工作。
有的部落格還提到了一種比較另類的解釋:安全詞。一旦垃圾回收執行緒喊出了安全詞,其他非垃圾回收執行緒便會一一停下。
當然,安全點的初始目的並不是讓其他執行緒停下,而是找到一個穩定的執行狀態。在這個執行狀態下,Java 虛擬機器的堆疊不會發生變化。這麼一來,垃圾回收器便能夠“安全”地執行可達性分析。
舉個例子,當 Java 程式通過 JNI 執行原生程式碼時,如果這段程式碼不訪問 Java 物件、呼叫 Java 方法或者返回至原 Java 方法,那麼 Java 虛擬機器的堆疊不會發生改變,也就代表著這段原生程式碼可以作為同一個安全點。
只要不離開這個安全點,Java 虛擬機器便能夠在垃圾回收的同時,繼續執行這段原生程式碼。
由於原生程式碼需要通過 JNI 的 API 來完成上述三個操作,因此 Java 虛擬機器僅需在 API 的入口處進行安全點檢測(safepoint poll),測試是否有其他執行緒請求停留在安全點裡,便可以在必要的時候掛起當前執行緒。
除了執行JNI原生程式碼外,Java 執行緒還有其他幾種狀態:解釋執行位元組碼、執行即時編譯器生成的機器碼和執行緒阻塞。阻塞的執行緒由於處於 Java 虛擬機器執行緒排程器的掌控之下,因此屬於安全點。
其他幾種狀態則是執行狀態,需要虛擬機器保證在可預見的時間內進入安全點。否則,垃圾回收執行緒可能長期處於等待所有執行緒進入安全點的狀態,從而變相地提高了垃圾回收的暫停時間。
對於解釋執行來說,位元組碼與位元組碼之間皆可作為安全點。Java 虛擬機器採取的做法是,當有安全點請求時,執行一條位元組碼便進行一次安全點檢測。
執行即時編譯器生成的機器碼則比較複雜。由於這些程式碼直接執行在底層硬體之上,不受 Java 虛擬機器掌控,因此在生成機器碼時,即時編譯器需要插入安全點檢測,以避免機器碼長時間沒有安全點檢測的情況。HotSpot 虛擬機器的做法便是在生成程式碼的方法出口以及非計數迴圈的迴圈回邊(back-edge)處插入安全點檢測。
那麼為什麼不在每一條機器碼或者每一個機器碼基本塊處插入安全點檢測呢?原因主要有兩個。
第一,安全點檢測本身也有一定的開銷。不過 HotSpot 虛擬機器已經將機器碼中安全點檢測簡化為一個記憶體訪問操作。在有安全點請求的情況下,Java 虛擬機器會將安全點檢測訪問的記憶體所在的頁設定為不可讀,並且定義一個 segfault 處理器,來截獲因訪問該不可讀記憶體而觸發 segfault 的執行緒,並將它們掛起。
第二,即時編譯器生成的機器碼打亂了原本棧楨上的物件分佈狀況。在進入安全點時,機器碼還需提供一些額外的資訊,來表明哪些暫存器,或者當前棧幀上的哪些記憶體空間存放著指向物件的引用,以便垃圾回收器能夠列舉 GC Roots。
由於這些資訊需要不少空間來儲存,因此即時編譯器會盡量避免過多的安全點檢測。
不過,不同的即時編譯器插入安全點檢測的位置也可能不同。以 Graal 為例,除了上述位置外,它還會在計數迴圈的迴圈回邊處插入安全點檢測。其他的虛擬機器也可能選取方法入口而非方法出口來插入安全點檢測。
不管如何,其目的都是在可接受的效能開銷以及記憶體開銷之內,避免機器碼長時間不進入安全點的情況,間接地減少垃圾回收的暫停時間。
除了垃圾回收之外,Java 虛擬機器其他一些對堆疊內容的一致性有要求的操作也會用到安全點這一機制。我會在涉及的時侯再進行具體的講解。
垃圾回收的三種方式
當標記完所有的存活物件時,我們便可以進行死亡物件的回收工作了。主流的基礎回收方式可分為三種。
第一種是清除(sweep),即把死亡物件所佔據的記憶體標記為空閒記憶體,並記錄在一個空閒列表(free list)之中。當需要新建物件時,記憶體管理模組便會從該空閒列表中尋找空閒記憶體,並劃分給新建的物件。
清除這種回收方式的原理及其簡單,但是有兩個缺點。一是會造成記憶體碎片。由於 Java 虛擬機器的堆中物件必須是連續分佈的,因此可能出現總空閒記憶體足夠,但是無法分配的極端情況。
另一個則是分配效率較低。如果是一塊連續的記憶體空間,那麼我們可以通過指標加法(pointer bumping)來做分配。而對於空閒列表,Java 虛擬機器則需要逐個訪問列表中的項,來查詢能夠放入新建物件的空閒記憶體。
第二種是壓縮(compact),即把存活的物件聚集到記憶體區域的起始位置,從而留下一段連續的記憶體空間。這種做法能夠解決記憶體碎片化的問題,但代價是壓縮演算法的效能開銷。
第三種則是複製(copy),即把記憶體區域分為兩等分,分別用兩個指標 from 和 to 來維護,並且只是用 from 指標指向的記憶體區域來分配記憶體。當發生垃圾回收時,便把存活的物件複製到 to 指標指向的記憶體區域中,並且交換 from 指標和 to 指標的內容。複製這種回收方式同樣能夠解決記憶體碎片化的問題,但是它的缺點也極其明顯,即堆空間的使用效率極其低下。
當然,現代的垃圾回收器往往會綜合上述幾種回收方式,綜合它們優點的同時規避它們的缺點。在下一篇中我們會詳細介紹 Java 虛擬機器中垃圾回收演算法的具體實現。
總結與實踐
今天我介紹了垃圾回收的一些基礎知識。
Java 虛擬機器中的垃圾回收器採用可達性分析來探索所有存活的物件。它從一系列GC Roots 出發,邊標記邊探索所有被引用的物件。
為了防止在標記過程中堆疊的狀態發生改變,Java虛擬機器採取安全點機制來實現 Stop-the-world操作,暫停其他非垃圾回收執行緒。
回收死亡物件的記憶體共有三種方式,分別為:會造成記憶體碎片的清除、效能開銷較大的壓縮、以及堆使用效率較低的複製。
今天的實踐環節,你可以體驗一下無安全點檢測的計數迴圈帶來的長暫停。你可以分別測單獨跑 foo 方法或者 bar 方法的時間,然後與合起來跑的時間比較一下。
// time java SafepointTestp
/ 你還可以使用如下幾個選項
// -XX:+PrintGC
// -XX:+PrintGCApplicationStoppedTime
// -XX:+PrintSafepointStatistics
// -XX:+UseCountedLoopSafepoints
public class SafepointTest {
static double sum = 0;
public static void foo() {
for (int i = 0; i < 0x77777777; i++) {
sum += Math.sqrt(i);
}
}
public static void bar() {
for (int i = 0; i < 50_000_000; i++) {
new Object().hashCode();
}
}
public static void main(String[] args) {
new Thread(SafepointTest::foo).start();
new Thread(SafepointTest::bar).start();
}
}
相關文章
- 剖析垃圾回收機制(上)
- 垃圾回收(三)【垃圾回收通知】
- 垃圾回收(一)【垃圾回收的基礎】
- 垃圾回收
- JVM 垃圾回收演算法和垃圾回收器JVM演算法
- [JVM]垃圾回收JVM
- golang垃圾回收Golang
- Python:垃圾回收Python
- JVM垃圾回收JVM
- 垃圾回收_下
- javascript垃圾回收JavaScript
- GC垃圾回收器GC
- 【Postgresql】VACUUM 垃圾回收SQL
- JVM垃圾回收(下)JVM
- JVM垃圾回收概述JVM
- JVM垃圾回收器JVM
- JVM - 垃圾回收概述JVM
- Unity GC垃圾回收UnityGC
- JAVA垃圾回收機制和Python垃圾回收對比與分析JavaPython
- 垃圾回收(二)【Windows 系統上的大型物件堆】Windows物件
- 【JVM】垃圾回收器總結(2)——七種垃圾回收器型別JVM型別
- jvm 自動垃圾回收JVM
- Java 垃圾回收機制Java
- JVM垃圾回收機制JVM
- Python垃圾回收機制Python
- Kubernetes 中的垃圾回收
- JVM-垃圾回收篇JVM
- JVM 中的垃圾回收JVM
- 聊聊Dotnet的垃圾回收
- 淺談JVM垃圾回收JVM
- java垃圾回收機制Java
- 垃圾回收(四)【弱引用】
- js垃圾回收機制JS
- JVM垃圾回收歷險JVM
- JVM 垃圾回收機制JVM
- javascript 垃圾回收機制JavaScript
- JavaScript 中的垃圾回收JavaScript
- jvm(4)---垃圾回收(哪些物件可以被回收)JVM物件