【Java雜貨鋪】JVM#Java高牆之GC與內存分配策略

Java與C++之間有一堵由內存動態分配和垃圾回收技術所圍成的「高牆」,牆外的人想進去,牆外的人想出來。——《深刻理解Java虛擬機》java

前言

上一章看了高牆的一半,接下來看另外一半——GC。算法

爲何須要GC和內存分配策略?當須要排查各類內存溢出、內存泄漏問題時,當垃圾回收成爲系統達到更高併發量的瓶頸時,咱們就須要對這些「自動化」的技術實施必要的控制和調節。數組

程序計數器、虛擬機棧、本地方法棧生命週期時伴隨着線程的,因此更多的須要考慮Java堆和方法區的垃圾回收。咱們只有在程序處於運行期間時才能知道會建立哪些對象,這部份內存的分配和回收都是動態的。緩存

對象已死嗎?

如何判斷對象是沒有用了,該塊內存能夠被GC回收掉了。主要有兩個方法。安全

引用計數算法

就是每一個對象都有個計數器,若是有一個地方對該對象有引用,計數器就加1,不然就減1,知道計數器的值爲0的時候就說明這個對象沒有被使用了,能夠回收之。可是,主流的Java虛擬機都沒有使用這個方法,由於沒法解決循環引用的問題。好比有個對象A,引用了對象B,同時對象B又引用了對象A,此時兩個對象的計數器都是1,可是這兩個對象在邏輯上已經沒有用了,白白佔用了內存空間。服務器

可達性分析算法

主流的虛擬機使用的都是這個算法來判斷對象是否存活(或者被使用)。這個算法的基本思路就是經過一系列的被稱爲「GC Roots」的對象做爲起始點,從這些節點開始向下搜索,搜索所通過的路徑被稱爲引用鏈(搜索的是引用,不是對象自己)。當一個對象到GC Roots沒有任何引用鏈相鏈接的時候,就被視爲不可用了。例如大佬書中很是經典的圖,Object五、Object六、Object7 都是能夠被回收的對象。數據結構

做爲GC Roots的對象包括一下幾種:多線程

  1. 虛擬機棧(棧幀中的本地本地變量表)中引用的對象。
  2. 方法去中類靜態屬性引用的對象。
  3. 方法區中常量引用的對象。
  4. 本地方法棧中JNL(Native方法)引用的對象。

引用類型

Java引用的定義很傳統:若是reference類型的數據中儲存的數值表明的是另一塊內存的起始地址,就稱這塊內存表明着一個引用。可是有些引用符合引用定義,可是此引用所指向的對象可能已經不可用了。因此對傳統定義加強的解釋就是:當內存空間還足夠時,則能保存在內存之中;若是內存空間在進行垃圾收集後仍是很是緊張,則能夠拋棄這些對象,不少系統的緩存功能都符合這個定義。併發

因此,引用就被分紅了4種類型。高併發

  1. 強引用:最多見的引用,就是new個對象,該對象可達GC Roots。只要又強引用在,GC永遠不會回收該空間。
  2. 軟引用:軟引用用來描述一些還有用但不是必須的對象。軟引用在內存溢出異常以前,將會對這些對象列進回收範圍之中進行第二次回收。若是此次回收尚未足夠空間,纔會拋出內存溢出異常。
  3. 弱引用:弱引用也是用來描述非必須的對象的,只是強度弱於軟引用,弱引用所關聯的對象只能生存到下一次垃圾回收發生以前,不管內存是否夠用,都會回收之。
  4. 虛引用:形同虛設的引用。一個對象是否虛引用的存在,徹底不會對其生存時間構成影響,也沒法經過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的惟一目的就是能在這個對象被回收器回收時收到一個系統通知。

finalize()的做用

被檢測到可達性不可達的對象,並非當即就被收回內存,至少須要經歷兩次標記。第一次標記並進行一次篩選,篩選條件是是否重寫了finalize()方法,如沒有,或者此對象已經執行過finalize()方法(一個對象最多隻能執行一次finalize()方法)了,虛擬機將它視爲「沒有必要執行」。

若是此對象重寫了finalize()方法,而且沒有執行,此對象就會被放到一個F-Queue隊列中,而且根據低優先級的Finalizer線程去執行它。因爲Finalizer線程優先級很低,因此須要在執行線程中sleep一下子等待它的執行。Finalizer線程的執行也不必定要等它執行完才進行垃圾回收,畢竟這裏面執行的任務多是很是耗時的。

在重寫的finalize()方法,此對象有一次(只有一次機會,畢竟finalize()方法只能執行一次)機會挽救本身,此時能夠將本身(使用this關鍵字)從新與引用鏈上的對象創建關聯,可達性可達就好。

可是finalize()方法機會不多有業務上的需求,畢竟它的功能try-finally也能夠完成,畢竟這對於你的某個方法來講更具備實時性,而且更好控制。

回收方法區

這部分不是重點,畢竟如今流行的JDK1.8已經沒有了方法區,而且這塊空間的垃圾回收效率極低。只須要知道這塊空間只要被回收的是兩部分,廢棄常量和無用的類就好。

廢棄常量好理解,就比方說一個字符串"abc",沒有再被引用,根據可達性算法這個很好判斷。對於無用的類判斷條件須要符合如下三條:

  1. 該類全部的實例都已經被回收,也就是Java堆中不存在該類的任何實例。
  2. 加載該類的類加載器ClassLoader已經被回收。
  3. 該對象的java.lang.Class對象沒有在任何一個地方被引用,沒法在任何地方經過反射訪問該類的方法。

垃圾收集算法

標記-清除算法

最基礎的算法就是「標記-清除」(Mark-Sweep)算法,算法分爲「標記」和「清除」兩個階段:首先標記全部須要回收的對象,在標記完成後統一回收全部被標記的對象。標記清除算法有兩點不足:第一就是效率問題,兩個階段效率都不高。第二個問題就是空間問題,標記清楚會產生大量碎片,讓物理空間不連續,致使給較大對象分配空間的時候,很容易觸發一次垃圾回收機制。

複製算法

複製算法將空間分紅兩個部分,一次只是用一個部分,當這一部分的空間用完了,直接將還存活的對象複製到另一部分上去,而後將這一部分使用過的空間一次性清理掉。這樣就是每次只對空間的通常及逆行GC操做。這樣就不須要考慮碎片整理的問題了,只要移動堆頂指針,按順序分配內存就好了。

如今的商業虛擬機基本都用這種算法回收新生代的數據。當一次GC,新生代分爲兩部分,一個Eden空間和兩個Survivor,這兩部分大小比通常是8:1:1。當一次GC操做存活的對象超過新生代的Survivor時,就須要老年代分配擔保,來補充不足的空間。

標記-整理算法

「標記-整理」算法首先將不可達的對象進行標記,而後將存活的對象向一端移動,而後直接清除掉端邊界之外的內存。這樣空間物理上就是連續的了。

分代收集算法

分代收集算法,是指不一樣的空間根據本身的實際狀況選擇不一樣的回收機制。通常來講新生代使用複製算法。老年代通常使用標記-整理算法。

HotSpot算法實現

枚舉根節點

因爲JVM管理的內存十分的大,對象引用所佔的空間可能很小而且十分零散,避免在一次GC消耗過長的時間,因此須要有種方式快速獲取到對象引用。在HotSpot的虛擬機實現裏面,有一個叫作OopMap的數據結構來儲存這些對象引用,用於快速定位。在執行一個方法的時候,字節碼層面會遇到一個OopMap記錄,它經過偏移量記錄着本次方法操做的字節碼什麼位置有引用,這樣就能夠找到引用了。

安全點

雖然說OopMap能夠快速找到全部的引用,可是不可能爲每一條指令都添加OopMap記錄,畢竟這樣的內存消耗是十分大的。只有在一些特定的地方纔會添加OopMap記錄,這些地點被稱爲安全點。安全點的選取須要符合「是否讓程序長時間執行」的特徵。「長時間執行」的最明顯的特徵就是指令序列的複用。比方說,方法調用、循環跳轉、異常跳轉等功能上。這裏還須要注意一個問題,某一個線程就是當達到安全點了,要開始啓動GC了,須要讓整個程序都停下來,防止在GC的過程當中產生新的垃圾,讓本次垃圾回收不完全。因此須要讓全部的線程都到安全點,而後進行統一的垃圾回收。這裏又兩種機制,搶先式中斷主動式中斷

搶先式中斷:在GC發生時,先把全部線程中斷,若是發現有些線程沒有在安全點,讓它們恢復活躍,從新跑到安全點再中斷,而後進行垃圾回收。

主動式中斷:不直接對線程操做,僅僅簡單設置一個標誌,各個線程去輪詢訪問這個標誌,當某個線程執行到安全點就去輪詢一下,發現標誌是中斷狀態,就將本身掛起,當全部線程都掛起的時候,就進行一次GC操做。

安全區域

進行一次GC,都須要在安全點完成,可是有些線程是沒有辦法等它到達安全點的,好比說sleep(),不可能全部線程都等它睡完了再繼續執行。因此除了安全點,還要引入安全區域的概念。安全區域是指在一段代碼片斷之中,引用關係不會再發生變化,因此GC是安全的。在某個線程執行在安全區域的時候,能夠隨意GC,當這個線程要離開安全區域的時候,須要查看此時是否又GC操做,沒有的話就能夠離開,若是有GC操做,就須要等待GC完成後再離開安全區域。

垃圾收集器

幾個簡單的垃圾回收器

Serial收集器:這個垃圾回收器是線程工做的,當它開始回收的時候,全部線程都須要中斷,用於新生代。

ParNew收集器:ParNew就是Serial的多線程版本。除了Serial之外,ParNew是惟一能夠與CMS收集器配合工做的。ParNew在單線程或者數量較少的多線程下(CPU數量少)性能並不比Serial優秀,畢竟切換線程也很須要成本。此收集器也是用在新生代。

Parallel Scavenge收集器:也是用在新生代,這個收集器更在意吞吐量,即用戶代碼運行的時間佔用用戶代碼和垃圾回收總時間的比重。此收集器能夠動態調整參數來保證適當的停頓時間和最大的吞吐量。

Serial Old收集器:單線程的用於老年代的收集器。

Parallel Old收集器:多線程的老年代收集器。

CMS收集器

CMS是一種獲取最短回收時間爲目標的收集器,目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務器上,這類應用尤爲重視服務的響應,但願更短的停頓時間。

CMS須要四個步驟:初始標記、併發標記、從新標記、併發清除。其中初始標記和從新標記都須要讓全部線程都終止。併發標記可讓用戶的工做線程同時運行,因此可能出現新的垃圾,從新標記就是爲了解決這個問題的。

CMS有三個明顯的缺點:

  1. CMS收集器對CPU資源很是敏感,當CPU數量少的時候性能極差。
  2. CMS閾值低,因爲須要一部分空間留給併發,因此不能達到100%就須要開啓GC。如今最高佔用空間達到92%。
  3. 因爲使用的「標記-清除」功能,因此會產生大量的碎片。

G1收集器

G1收集器是一款面向服務端應用的垃圾收集器。G1收集器能夠做用於新生代和老年代。而且有很是好的併發並行機制,能夠進行空間整理,還有個很是優秀的特色是能夠預測停頓時間,可讓使用者指定在固定的時間M毫秒內,垃圾回收所佔用的時間不能超過N毫秒。

G1 收集器可讓Java堆劃分紅多個Region空間(其中仍然有新生代和老年代)獨自管理,這樣就能夠根據某個區域內進行垃圾回收。而且後臺維護者一個優先列表,指定哪些Region空間先被手機。

同時爲了解決不一樣的Region通信問題,好比ARegion中的對象引用了BRegion內的對象,每一個Region維護着一個Remembered Set記錄着這些信息。

內存分配與收回策略

對象主要分配在新生代的Eden區上,或者分配在TLAB(線程獨享)上,少數狀況也能夠直接分配在老年代上。這取決於你使用的垃圾收集器和參數設定。下面有幾條廣泛的內存分配規則。

對象有限在Eden上分配

若是發現Eden上的空間不夠了,會進行一次新生代GC。2個Survivor一個叫FROM,一個叫TO。當進行新生代GC的時候,Eden中的數據會複製到TO中,FROM內的數據根據年齡看是去往TO仍是進入老年代。接着TO和FROM互換姓名,而後清空Eden和TO的數據。另外老年的GC收集是新生代時間的10倍。

大對象直接進入老年代

通常來講新生的對象會在新生代,過了一段時間,必定數量的新生代GC(默認15次)以後,存活下來的對象再被放進老年代中。可是有些比較大型的對象,好比字符串或者很是大的數組就直接放到老年代了,這樣就避免了屢次新生代GC,來回複製這種超長的空間了。

長期存活的對象進入老年代

必定數量的新生代GC(MaxTenuringThreshold默認15次)以後,存活下來的對象再被放進老年代中。

動態對象年齡斷定

若是再Servivor空間中相同年齡(經歷GC次數)全部對象大小的總數大於Servivor空間的一半的時候,年齡大於或等於這一數值的對象直接進入老年代,無需等待MaxTenuringThreshold要求的年齡。

空間擔保機制

空間擔保機制就是在新生代GC的時候,若是Servivor空間不夠放來自Eden的對象,能夠由擔保人老年代來放些數據。

在新生代GC以前,虛擬機會區檢查老年代最大可用的連續空間是否大於新生代全部對象的總空間,若是大於,此次GC是安全的。若是不大於,就回去看是否開啓了空間擔保機制,若是開啓了就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升老年代對象的平均大小,若是大於就能夠冒險試一下GC,若是不大於,就會觸發全局的GC(Full GC)。

爲何會有這樣的冒險?由於新生代多出來的數據老年代不必定放的下,畢竟沒人爲老年代作擔保了。究竟多出來的數據能不能放下呢,這就須要經驗來判斷,算下歷次重新生代過來的數據平均值,假定頻率等於機率,來和老年代剩餘的空間做比較。