Java 垃圾收集技術
阿新 • • 發佈:2020-03-21
#### 前言
在電腦科學中,垃圾收回(`GC: garbage collection`)是記憶體自動管理的一種方式,它並不是同 `Java` 語言一起誕生的,實際上,早在 1959 年為了簡化 [Lisp](https://en.wikipedia.org/wiki/Lisp_(programming_language)) 語言的手動記憶體管理,該語言的作者就開始使用了記憶體自動管理技術。 `垃圾收集`和`手動記憶體管理`剛好相反,後者需要程式設計人員自己去指定需要釋放的物件然後將記憶體歸還給作業系統,而前者不需要關心給物件分配的記憶體回收問題。`Java` 語言使用自動垃圾收集器來管理物件生命週期中的記憶體,要進行垃圾收集首先需要明確三個問題:`1. 哪些記憶體需要回收`、`2. 什麼時候進行回收`、`3. 怎麼進行記憶體回收`。接下來讓我們一起看看 `Java` 語言對這些問題是如何處理的。
#### 哪些記憶體需要回收
為了方便管理和跨平臺,`Java` 虛擬機器規範規定在執行 `Java` 程式的時候把它所管理的記憶體劃分為若干個不同的資料區域。這些區域都有著各自不同的用途以及建立和銷燬的時間,有的資料區域隨著使用者執行緒的啟動和結束而建立和銷燬,有的區域會隨著虛擬機器程序的啟動和停止而存在和銷燬。更多有關執行時資料區域的內容請看 [Java 執行時資料區域](https://www.mghio.cn/post/8a061473.html)。
由於 `Java` 執行時資料區域中的 `程式計數器`、`虛擬機器棧`和`本地方法棧`和執行緒的生命週期一致,隨執行緒的啟動和結束而建立和銷燬。而且當我們的類結構確定了之後,在編譯期間,一個棧幀需要分配記憶體的大小基本上也就確定下來了,這三個區域的記憶體分配和收回都是具備確定性的,不需要我們過多的去考慮記憶體回收問題。主要考慮`Java 堆`和`方法區`的記憶體回收的問題。
#### 什麼時候進行回收
在 `Java` 語言中,一個物件的生命週期分為以下三個階段:
- **物件建立階段** 通常我們使用 `new` 關鍵字進行物件建立 `e.g. Object obj = new Object();`,當我們建立物件時,`Java` 虛擬機器將分配一定大小的記憶體來儲存該物件,分配的記憶體量可能會根據虛擬機器廠商的不同而有所不同。
- **物件使用階段** 在這個階段,物件被應用程式的其它物件使用(其它活動物件擁有指向它的引用)。在使用期間,該物件會一直駐留在記憶體當中,並且可能包含對其它物件的引用。
- **物件銷燬階段** 垃圾收集系統監視物件,如果發現物件不被任何物件引用了,則進行該物件記憶體回收操作。
那麼問題來了,該如何去判斷一個物件有沒有被引用呢?目前,主要有兩種判斷物件是否存活的演算法,分別是 `引用計數演算法(Reference counting algorithm)`和`可達性分析演算法(Accessibility analysis algorithm)`。
##### 引用計數演算法
首先我們看看`引用計數演算法`是如何判斷的,該演算法的主要思想就是給每個物件都新增一個引用計數器,當該物件被變數或者另一個物件引用時該計數器值就會加 1,同時當物件的一個引用無效時,物件計數器的值會相應的減 1。當物件引用計數器的值為 0 時,說明該物件已經不再被引用了,那麼就可以銷燬物件進行記憶體回收操作了。這個演算法的實現比較簡單,物件是否“存活”的判斷效率也比較高,這個演算法看起來確實不錯,但是它有個致命的缺點就是:`無法解決物件間相互引用的問題`。相互引用簡單來說就是,有兩個物件 `object1` 和 `object2` 都有一個引用型別欄位 `ref`,並且做了如下賦值操作:
```Java
object1.ref = object2;
object2.ref = object1;
```
這兩個物件除了上面這個賦值之外,不被其它任何物件引用,實際上這兩個物件都不可能再被訪問了,但是因為它們倆都互相引用了對方,導致引用計數器不為 0,導致使用引用計數器演算法的 `垃圾收集器` 無法收集它們,它們就會一直存在於記憶體之中直到虛擬機器程序結束。正是因為這個原因,市場上主流的 `Java` 虛擬機器大部分都沒有選用這個演算法來管理記憶體,下面介紹的 `可達性分析演算法` 就可以很好的避免了物件間相互引用的問題。
##### 可達性分析演算法
`Java` 虛擬機器是通過`可達性分析演算法`來判斷物件是否存活的,該演算法的主要思想是將一系列稱為 `GC Root` 的物件作為起點,向下進行搜尋,搜尋經過的路徑稱為`引用鏈(Reference chain)`,當一個物件到 `GC Root` 物件沒有任何引用鏈的時候,則表示該物件是不可達的,可以對其進行記憶體回收。
![accessibility-analysis-algorithm.png](https://i.loli.net/2020/03/21/aSIOt2ei6xHg1Zz.png)
在 `Java` 虛擬機器中,規定以下幾種情況可以作為 `GC Root` 物件:
- 虛擬機器棧中引用的物件
- 方法區中類靜態屬性引用的物件
- 方法區中常量引用的物件
- 本地方法棧中 `Native` 方法引用的物件
#### 怎麼進行記憶體回收
當我們建立的物件不可達之後,`Java` 虛擬機器會在後臺自動去收集回收不可達物件的記憶體,自 `Java` 語言誕生以來,在垃圾收集演算法上進行了許多更新,主要有`標記-清除演算法(Mark and sweep algorithm)`、`複製演算法(Copying algorithm)`、`標記—整理演算法(Mark and compact algorithm)`和`分代收集演算法(Generational collection algorithm)`,根據這些演算法實現的垃圾收集器在後臺默默執行以釋放記憶體,下面讓我們看看它們是如何工作的。
##### 標記-清除演算法(mark and sweep algorithm)
`標記—清除演算法`是初始且非常基本的演算法,主要分為以下兩個階段:
1. 標記需要回收物件,找出程式中所有需要回收的物件並標記。
2. 清除所有標記物件,在標記完成後統一回收被標記物件。
首先標記出需要回收的物件,標記完成後再統一回收被標記物件。這個演算法是最基礎的垃圾收集演算法,後面將要介紹的幾個演算法都是在它的基礎上優化改進的,演算法主要有兩個不足的地方:① `效率不高`,標記和清除過程的效率都不高。② `空間利用率不高`,標記清除之後會產生大量不連續的記憶體碎片,後面如果要分配大物件的時候由於連續記憶體不足可能會再次觸發垃圾收集操作。
##### 複製演算法(copying algorithm)
`複製演算法`就是為了解決`標記—清除演算法`的效率問題的,主要思想就是將可用的記憶體分為大小相等的兩個部分,每一次都只使用其中的一塊,當這塊記憶體使用完了之後,就將依然存活的物件複製到另一塊記憶體上去,然後再把這塊含有可回收物件的記憶體清理掉,這樣每次都是清理一半的連續記憶體了,就不會存在記憶體碎片的情況。但是這個演算法的缺點也很明顯,它把可用記憶體的大小縮小到了一半。
##### 標記-整理演算法(mark and compact algorithm)
如果物件的存活率比較低的情況下,上面介紹的`複製演算法`效率還是很高的,畢竟只要複製少部分存活物件到另一塊記憶體中即可,但是當物件的存活率比較高時就會進行多次複製操作。比如老年代,老年代的物件是經過多次垃圾回收依然存活的物件,物件的存活率相對來說比較高,根據老年代的這個特點,於是針對這種情況就有了另一個演算法稱之為`標記-整理演算法`,主要思想和其名字一樣也是分為`標記`和`整理`兩個階段,第一個標記階段依然和`標記—清除演算法`一樣,後面的第二個整理階段就不是直接對可回收物件進行清理了,而是讓所有存活的物件都向記憶體的同一側移動,然後就直接清除掉另一側的記憶體。
##### 分代收集演算法(generational collection algorithm)
根據不同分代的特點,現在商業上的虛擬機器針對不同的分代採取適合的垃圾收集,一般是把 `Java` 堆分為新生代和老年代。在新生代中,物件大部分存活時間都很短每次垃圾收集都會有很多的物件被清除,只有少部分物件可以存活下來,那麼此時就可以使用`複製演算法`,只需要複製出少部分存活的物件即可效率高。然而在老年代中大部分物件的存活時間比較長,則需採用`標記-清除演算法`或者`標記-整理演算法`來進行垃圾收集。
垃圾收集演算法對於垃圾回收來說類似於我們程式中的介面,是一套垃圾回收的指導演算法,演算法的具體實現我們稱之為`垃圾收集器`。但是 `Java` 虛擬機器規範中並沒有對垃圾收集器的實現有任何規定。所以不同的廠商和不同版本的虛擬機器實現的垃圾收集器也不一樣,不過一般都會提供一些配置引數來讓使用者根據自身情況來設定所需的垃圾收集器。
#### JVM 相關 GC 配置
`Java` 虛擬機器部分垃圾收集(`Garbage Collection,GC`)相關配置如下
|引數|描述|
| :---: | :---: |
| -Xms2048m | 設定初始堆大小(新生代 + 老年代) |
| -XX:InitialHeapSize=3g | 設定初始堆大小(新生代 + 老年代)|
| -Xmx3g | 設定最大堆大小(新生代 + 老年代) |
| -XX:MaxHeapSize=3g | 設定最大堆大小(新生代 + 老年代)|
| -XX:NewSize=128m | 設定堆初始新生代大小 |
| -XX:MaxNewSize=128m | 設定堆最大新生代大小 |
| -XX:PermSize=512m(JDK 1.7) | 設定初始永久代(元空間)大小 |
| -XX:MetaspaceSize=512m(JDK 1.8+) | 設定初始永久代(元空間)大小 |
| -XX:MaxPermSize=1g(JDK 1.7) | 設定最大永久代(元空間)大小 |
| -XX:MaxMetaspaceSize=1g(JDK 1.8+) | 設定最大永久代(元空間)大小 |
| -XX:+DisableExplicitGC | 忽略應用程式對 `System.gc()` 方法的任何呼叫 |
| -XX:+PrintGCDetails | 列印輸出 `GC` 收集相關資訊 |
---
參考文章
- [深入理解Java虛擬機器(第2版)](https://book.douban.com/subject/24722612)
- [Garbage collection (computer science)](https://en.wikipedia.org/wiki/Garbage_collection_(computer_science))
- [The Java® Virtual Machine Specification(Java SE 8 Edition)](https://docs.oracle.com/javase/specs/jvms/s