前言
?? JVM 是 Java Virtual Machine(Java 虛擬機)的縮寫,它也是一個虛構出來的計算機,是通過在實際的計算機上仿真模擬各種計算機功能來完成的。Java 語言最重要的特性之一的自動垃圾回收機制,也是基于 JVM 實現的,而且在我們日常開發中經常會遇到內存溢出、內存泄漏、程序卡頓等問題,要針對這些問題進行排查和性能調優,就需要我們了解 JVM 的垃圾回收機制(Garbage Collection,GC),那么垃圾回收機制到底是如何實現的呢?接下來讓我們一探究竟。
?? 在了解 JVM 的垃圾回收機制之前,我們需要先了解 JVM 運行時的數據區域是怎么劃分的,才能知道在哪里產生和回收垃圾。
?? Java 虛擬機在執行 Java 程序的過程中會把它所管理的內存劃分為若干個不同的數據區域。這些區域都有各自的用途,以及創建和銷毀的時間,有的區域隨著虛擬機進程的啟動而存在,有些區域則依賴用戶線程的啟動和結束而建立和銷毀。根據《Java 虛擬機規范(Java SE 7 版)》的規定,Java 虛擬機所管理的內存將會包括以下幾個運行時數據區域,如圖所示。
程序計數器(Program Counter Register)是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型里(僅是概念模型,各種虛擬機可能會通過一些更高效的方式去實現),字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
由于 Java 虛擬機的多線程是通過線程輪流切換并分配處理器執行時間的方式來實現的, 在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)都只會執行一條線程中的指令。因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。 如果線程正在執行的是一個 Java 方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是 Native 方法,這個計數器值則為空(Undefined)。此內存區域是唯一一個在 Java 虛擬機規范中沒有規定任何 OutOfMemoryError 情況的區域。
與程序計數器一樣,Java虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是 Java 方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。 局部變量表存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、 float、long、double)、對象引用( reference 類型,它不等同于對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和 returnAddress 類型(指向了一條字節碼指令的地址)。 其中 64 位長度的 long 和 double 類型的數據會占用 2 個局部變量空間(Slot),其余的數據類型只占用 1 個。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。 在 Java 虛擬機規范中,對這個區域規定了兩種異常狀況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出 StackOverflowError 異常;如果虛擬機棧可以動態擴展(當前大部分的 Java 虛擬機都可動態擴展,只不過 Java 虛擬機規范中也允許固定長度的虛擬機棧),如果擴展時無法申請到足夠的內存,就會拋出 OutOfMemoryError 異常。
?? 本地方法棧(Native Method Stack)與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧為虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務。在虛擬機規范中對本地方法棧中方法使用的語言、使用方式與數據結構并沒有強制規定,因此具體的虛擬機可以自由實現它。甚至有的虛擬機(譬如 Sun HotSpot 虛擬機)直接就把本地方法棧和虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧區域也會拋出 StackOverflowError 和 OutOfMemoryError 異常。
對于大多數應用來說,Java 堆(Java Heap)是 Java 虛擬機所管理的內存中最大的一塊。 Java 堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這里分配內存。這一點在 Java 虛擬機規范中的描述是:所有的對象實例以及數組都要在堆上分配,但是隨著 JIT 編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也漸漸變得不是那么“絕對”了。 Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做“ GC 堆”(Garbage Collected Heap)。從內存回收的角度來看,由于現在收集器基本都采用分代收集算法,所以 Java 堆中還可以細分為:新生代和老年代;再細致一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。從內存分配的角度來看,線程共享的 Java 堆中可能劃分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,存儲的都仍然是對象實例,進一步劃分的目的是為了更好地回收內存,或者更快地分配內存。 根據 Java 虛擬機規范的規定,Java 堆可以處于物理上不連續的內存空間中,只要邏輯上是連續的即可,就像我們的磁盤空間一樣。在實現時,既可以實現成固定大小的,也可以是可擴展的,不過當前主流的虛擬機都是按照可擴展來實現的(通過 -Xmx 和 -Xms 控制)。如果在堆中沒有內存完成實例分配,并且堆也無法再擴展時,將會拋出 OutOfMemoryError 異常。
?? 方法區(Method Area)與 Java 堆一樣,是各個線程共享的內存區域,它用于存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。雖然 Java 虛擬機規范把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。 Java 虛擬機規范對方法區的限制非常寬松,除了和 Java 堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。相對而言,垃圾收集行為在這個區域是比較少出現的,但并非數據進入了方法區就如永久代的名字一樣“永久”存在了。這區域的內存回收目標主要是針對常量池的回收和對類型的卸載,一般來說,這個區域的回收“成績”比較難以令人滿意,尤其是類型的卸載,條件相當苛刻,但是這部分區域的回收確實是必要的。 根據 Java 虛擬機規范的規定,當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError 異常。
?? 運行時常量池(Runtime Constant Pool)是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用于存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載后進入方法區的運行時常量池中存放。 Java 虛擬機對 Class 文件每一部分(自然也包括常量池)的格式都有嚴格規定,每一個字節用于存儲哪種數據都必須符合規范上的要求才會被虛擬機認可、裝載和執行,但對于運行時常量池,Java 虛擬機規范沒有做任何細節的要求,不同的提供商實現的虛擬機可以按照自己的需要來實現這個內存區域。不過,一般來說,除了保存 Class 文件中描述的符號引用外, 還會把翻譯出來的直接引用也存儲在運行時常量池中。 運行時常量池相對于 Class 文件常量池的另外一個重要特征是具備動態性,Java 語言并不要求常量一定只有編譯期才能產生,也就是并非預置入 Class 文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是 String 類的 intern() 方法。 既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。
?? 直接內存(Direct Memory)并不是虛擬機運行時數據區的一部分,也不是Java虛擬機規范中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致 OutOfMemoryError 異常出現,所以我們放到這里一起講解。 在 JDK 1.4 中新加入了 NIO(New Input/Output)類,引入了一種基于通道(Channel)與緩沖區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外內存,然后通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回復制數據。 顯然,本機直接內存的分配不會受到 Java 堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存(包括 RAM 以及 SWAP 區或者分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置 -Xmx 等參數信息,但經常忽略直接內存,使得各個內存區域總和大于物理內存限制(包括物理的和操作系統級的限制), 從而導致動態擴展時出現 OutOfMemoryError 異常。
前面說過,在堆里面存放著 Java 世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象之中哪些還“存活”著,哪些已經“死去”,這些垃圾對象可以簡單的理解為?不可能再被任何途徑使用的對象?,怎么區分出這些死亡和存活的對象呢,就需要用到垃圾判斷算法,主要有?引用計數法?和?可達性分析法?。
給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為 0 的對象就是不可能再被使用的。這些引用計數為 0 的對象,就可以稱之為垃圾,可以被收集。
微軟公司的 COM(Component Object Model)技術、使用 ActionScript 3 的 FlashPlayer、Python 語言和在游戲腳本領域被廣泛應用的 Squirrel 中都使用了引用計數算法進行內存管理。
優點:實現簡單,判定效率也很高。
缺點:需要額外的空間來存儲計數器,難以檢測出對象之間的循環引用。
?? 現在主流的 Java 虛擬機都沒有使用引用計數法,最主要的原因就是它很難解決對象之間互相循環引用的問題。在兩個對象循環引用時,引用計數器都為 1,當對象周期結束后應該被回收卻無法回收,造成內存泄漏,代碼如下:
publicclassTestReferenceCounter{publicstaticvoidmain(String[]args){TestObject obj1=newTestObject();TestObject obj2=newTestObject();obj1.instance=obj2;obj2.instance=obj1;obj1=null;obj2=null;}staticclassTestObject{Object instance;}}
2. 可達性分析法(Reachability Analysis)
?? 在主流的商用程序語言(Java、C#,甚至包括前面提到的古老的 Lisp )的主流實現中,都是通過可達性分析來判定對象是否存活的。這個算法的基本思路就是:
通過一系列的稱為 “GC Roots” 的對象作為起始點,從這些節點開始向下搜索;
搜索所走過的路徑稱為引用鏈(Reference Chain);
當一個對象到 GC Roots 沒有任何引用鏈相連 (用圖論的話來說,就是從 GC Roots 到這個對象不可達)時,則證明此對象是不可用的。
? ?如下圖所示,對象object 5、object 6、object 7雖然互相有關聯,但是它們到 GC Roots 是不可達 的,所以它們將會被判定為是可回收的對象。
在 Java 語言中,可作為 GC Roots 的對象包括下面幾種:
虛擬機棧(棧幀中的本地變量表)中引用的對象。
方法區中類靜態屬性引用的對象。
方法區中常量引用的對象。
本地方法棧中 JNI(即一般說的 Native 方法)引用的對象。
可達性分析法的優缺點:
優點:可以解決循環引用的問題,不需要占用額外的空間 ;
缺點:實現比較復雜,需要分析大量數據,消耗大量時間;分析過程需要 GC 停頓(引用關系不能發生變化),即停頓所有 Java 執行線程(稱為 “Stop The World”,是垃圾回收重點關注的問題);
?? 無論是通過引用計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象的引用鏈是否可達,判定對象是否存活都與“引用”有關。
3.1. JDK 1.2 以前 Java 的引用定義:
?? 如果 reference 類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表著一個引用。這種定義太過狹隘,無法描述更多信息。
3.2. JDK 1.2 之后,Java 對引用的概念進行了擴充,分為?強、軟、弱、虛引用,這 4 種引用強度依次逐漸減弱。
強引用(Strong Reference)
強引用就是指在程序代碼之中普遍存在的,類似 “Object obj = new Object()” 這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
測試代碼:
Object object=newObject();System.out.println("Strong Reference object = "+object);System.gc();Thread.sleep(500);System.out.println("Strong Reference gc object = "+object);
運行結果:
Strong Reference object=java.lang.Object@39ba5a14Strong Reference gc object=java.lang.Object@39ba5a14
軟引用(Soft Reference)
軟引用是用來描述一些還有用但并非必需的對象。對于軟引用關聯著的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收范圍之中進行第二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。在 JDK 1.2 之后,提供了 SoftReference 類來實現軟引用。
測試代碼:
Object object=newObject();SoftReference<Object>softReference=newSoftReference<>(object);object=null;System.out.println("Soft Reference object = "+softReference.get());System.gc();Thread.sleep(500);System.out.println("Soft Reference gc object = "+softReference.get());
運行結果:
Soft Reference object=java.lang.Object@39ba5a14Soft Reference gc object=java.lang.Object@39ba5a14
弱引用(Weak Reference)
弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠, 都會回收掉只被弱引用關聯的對象。在 JDK 1.2 之后,提供了 WeakReference 類來實現弱引用。
測試代碼:
WeakReference<Object>weakReference=newWeakReference<>(newObject());System.out.println("Weak Reference object = "+weakReference.get());System.gc();Thread.sleep(500);System.out.println("Weak Reference gc object = "+weakReference.get());
運行結果:
Weak Reference object=java.lang.Object@511baa65Weak Reference gc object=null
虛引用(Phantom Reference)
虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。
虛引用?主要用來?跟蹤對象?被垃圾回收器?回收?的活動。?虛引用?與?軟引用?和?弱引用?的一個區別在于:
虛引用必須和引用隊列 (ReferenceQueue) 聯合使用。當垃圾回收器準備回收一個對象時,如果發現它還有虛引用,就會?在回收對象的內存之前,把這個虛引用加入到與之關聯的引用隊列中。
ReferenceQueue<Object>phantomReferenceQueue=newReferenceQueue<>();// 創建虛引用,要求必須與一個引用隊列關聯PhantomReference<Object>phantomReference=newPhantomReference<>(newObject(),phantomReferenceQueue);
程序可以通過判斷引用?隊列?中是否已經加入了?虛引用,來了解被引用的對象是否將要進行?垃圾回收。如果程序發現某個虛引用已經被加入到引用隊列,那么就可以在所引用的對象的?內存被回收之前?采取必要的行動。
在可達性分析算法中被標記為不可達的對象,也不一定會被回收,它還有第二次重生的機會。每一個對象在被回收之前要進行兩次標記,一次是?沒有關聯引用鏈?會被標記一次,第二次是?判斷該對象是否覆蓋 finalize()?方法,如果?沒有覆蓋則真正的被定了“死刑”。
如果這個對象被 jvm 判定為有必要執行 finalize() 方法,那么這個對象會被放入?F-Queue?隊列中,并在稍后由一個由虛擬機自動創建的、低優先級的?finalizer?線程去執行它。但是這里的“執行”是指虛擬機會觸發這個方法,但是并不代表會等它運行結束。虛擬機在此處是做了優化的,因為如果某個對象在?finalize?方法中長時間運行或者發生死循環,將可能導致?F-Queue?隊列中其他對象永遠處于等待,甚至可能會導致整個內存回收系統崩潰。
在 finalize() 方法中我們可以實現對這個對象的重生,代碼如下:
publicclassFinalizeEscapeGC{publicstaticFinalizeEscapeGC SAVE_HOOK=null;publicvoidisAlive(){System.out.println("yes,i am still alive");}@Overrideprotectedvoidfinalize()throwsThrowable{super.finalize();System.out.println("finalize method executed!");FinalizeEscapeGC.SAVE_HOOK=this;}publicstaticvoidmain(String[]args)throwsThrowable{SAVE_HOOK=newFinalizeEscapeGC();// 對象第一次成功拯救自己SAVE_HOOK=null;System.gc();// 因為 finalize 方法優先級很低,所以暫停 0.5 秒以等待它Thread.sleep(500);if(SAVE_HOOK!=null){SAVE_HOOK.isAlive();}else{System.out.println("no,i am dead");}//下面這段代碼與上面的完全相同,但是這次自救卻失敗了SAVE_HOOK=null;System.gc();//因為 finalize 方法優先級很低,所以暫停0.5秒以等待它Thread.sleep(500);if(SAVE_HOOK!=null){SAVE_HOOK.isAlive();}else{System.out.println("no,i am dead");}}}
運行結果:
finalize method executed!yes,i am still aliveno,i am dead
?? 從運行結果可以看到,第一次拯救成功,第二次拯救失敗,所以需要注意的是 finalize() 方法只會被系統調用一次,多次被 gc 只有第一次會被調用,因此只有一次的重生機會。
假如一個字符串 “abc” 已經進入了常量池中,但是當前系統沒有任何一個 String 對象是 “abc” ,那么這個對象就應該回收。方法區( HotSpot 虛擬機中的永久代)的垃圾收集主要回收兩部分內容:廢棄常量和無用的類。比如上述的 “abc” 就是屬于廢棄常量,那么哪些類是無用的類呢?
該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例;
加載該類的 ClassLoader 已經被回收;
該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
?? 通過上面的介紹,我們已經知道了什么是垃圾以及如何判斷一個對象是否是垃圾。那么接下來,我們就來了解如何回收垃圾,這就是垃圾回收算法和垃圾回收器需要做的事情了。
最基礎的收集算法是 “標記-清除”(Mark-Sweep)算法,分為 “標記” 和 “清除” 兩個階段:首先標記出所需回收的對象,在標記完成后統一回收掉所有被標記的對象,它的標記過程其實就是前面的可達性分析法中判定垃圾對象的標記過程。
下圖為 “標記-清除” 算法的執行過程 :
優點:
不需要進行對象的移動,并且僅對不存活的對象進行處理,在存活對象比較多的情況下極為高效。
缺點:
標記和清理的兩個過程效率都不高;
容易產生內存碎片,碎片空間太多可能導致無法存放大對象。
?標記-整理?算法標記的過程與 “標記-清除” 算法中的標記過程一樣,但對標記后出的垃圾對象的處理情況有所不同,它不是直接對可回收對象進行清理,而是讓所有的對象都向一端移動,然后直接清理掉端邊界以外的內存。
下圖為 “標記-整理” 算法的示意圖:
優點:
經過整理之后,新對象的分配只需要通過指針碰撞便能完成,比較簡單;使用這種方法,空閑區域的位置是始終可知的,也不會再有碎片的問題了。
缺點:
GC 暫停的時間會增長,因為你需要將所有的對象都拷貝到一個新的地方,還得更新它們的引用地址。
?復制?算法的提出是為了?解決效率問題?和?堆碎片?的垃圾回收。它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。
下圖為?復制?算法的示意圖:
優點:
標記階段和復制階段可以同時進行;
每次只對一塊內存進行回收,運行高效;
只需移動棧頂指針,按順序分配內存即可,實現簡單;
內存回收時不用考慮內存碎片的出現。
缺點:
可用內存縮小了一半。
?? 復制算法比較適合于新生代(短生存期的對象),在老年代(長生存期的對象)中,對象存活率比較高,如果執行較多的復制操作,效率將會變低,所以老年代一般會選用其他算法,如“標記-整理”算法。
4. 分代收集(Generational Collector)算法
?分代收集?算法是將堆內存劃分為新生代、老年代?和?永久代,在 jdk8 以后取消了永久代的說法,而是用元空間取代。新生代又被進一步劃分為?Eden?和?Survivor?區,其中?Survivor?由?FromSpace(Survivor0)和?ToSpace(Survivor1)組成。一般年輕代使用復制算法(對象存活率低),老年代使用標記整理算法(對象存活率高)。
新生代(Young Generation):
幾乎所有新生成的對象首先都是放在年輕代的,新生代內存按照?8:1:1?的比例分為一個?Eden?區和兩個 **Survivor(Survivor0,Survivor1)**區。新生代對象生命周期如下:
大部分對象在?Eden?區中生成。
當新對象生成,Eden?空間申請失敗(因為空間不足等),則會發起一次?GC(Minor GC / Scavenge GC)。
回收時先將?Eden?區存活對象復制到一個?Survivor0?區,然后清空?Eden?區。
當這個?Survivor0?區也存放滿了時,則將?Eden?區和?Survivor0?區存活對象復制到另一個?Survivor1?區,然后清空?Eden?和這個?Survivor0?區,此時?Survivor0?區是空的,然后將?Survivor0?區和?Survivor1?區交換,即保持?Survivor1?區為空, 如此往復。
當?Survivor1?區不足以存放?Eden?和?Survivor0?的存活對象時,就將存活對象直接存放到老年代。
當對象在?Survivor?區躲過一次?GC?的話,其對象年齡便會加?1。
默認情況下,如果對象年齡達到?15?歲,就會移動到老年代中。
若是老年代也滿了就會觸發一次?Full GC,也就是新生代、老年代都進行回收。
老年代(Old Generation):
?? 在新生代中經歷了 N 次垃圾回收后仍然存活的對象,就會被放到年老代中。因此,可以認為年老代中存放的都是一些生命周期較長的對象。老年代有以下特點:
內存比新生代大很多(大概比例是?1:2)。
作為?Eden?區的?空間分配擔保,當?MinorGC?時,如果存活對象過多,無法完全放入?Survivor?區,就會向老年代借用內存存放對象。
動態對象年齡判定,如果?Survivor?區中相同年齡所有對象的大小總和大于?Survivor?區空間一半,年齡大于或者等于該年齡的對象在?MinorGC?時將復制到老年代。
當老年代內存滿時觸發?Major GC?即?Full GC,Full GC?發生頻率比較低,老年代對象存活時間比較長,存活率高。
出現了?Major GC?經常會伴隨至少一次?Minor GC(非絕對),Major GC?的速度一般會比?Minor GC?慢?10?倍以上。
一般大對象直接進入老年代。所謂的大對象是指需要?大量連續存儲空間?的對象,最常見的一種大對象就是大數組。
長期存活的對象將進入老年代,默認為?15?歲。
對于晉升老年代的分代年齡閾值,我們可以通過?-XX:MaxTenuringThreshold?參數進行控制,前面提到晉升老年代的年齡閾值默認的 15 歲,為什么不是 16 或者 17 呢?
這里就涉及到對象的內存布局了,在 HotSpot 虛擬機中,對象在內存中存儲的布局可以分為 3 塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
實際上,HotSpot 虛擬機的?對象頭?其中一部分用于存儲對象自身的?運行時數據,如哈希碼、GC 分代年齡、鎖狀態標志、線程持有的鎖、偏向線程 ID、偏向時間戳?等,這部分數據的長度在 32 位和 64 位的虛擬機(未開啟壓縮指針)中分別為 32bit 和 64bit,官方稱它為?Mark word。
在 32 位的 HotSpot 虛擬機中,如果對象處于未被鎖定的狀態下,那么?Mark Word?的 32bit 空間中的 25bit 用于存儲對象哈希碼,4bit 用于存儲對象分代年齡,2bit 用于存儲鎖標志位,1bit 固定為 0,如下表所示:
在 64 位系統及 64 位 JVM 下,開啟指針壓縮,那么頭部存放 Class 指針的空間大小還是 4 字節,而?Mark Word?區域會變大,變成 8 字節,也就是頭部最少為 12 字節,如下表所示:
?? 可以看到,對象的分代年齡占 4 位,也就是從 0000 到 1111 ,而其值最大為 15,所以分代年齡也就不可能超過 15 這個數值了。
永久代(Permanent Generation):
?? 用于存放靜態文件(class 類、方法)和常量等。永久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者調用一些 class,例如 Hibernate 等,在這種時候需要設置一個比較大的持久代空間來存放這些運行過程中新增的類。對永久代的回收主要回收兩部分內容:廢棄常量和無用的類。永久代在 Java SE8 特性中已經被移除了,取而代之的是元空間(MetaSpace),因此也不會再出現java.lang.OutOfMemoryError: PermGen error的錯誤了。
卡表(Card Table)提升 GC 效率
?? 在某些場景下,老年代的對象可能引用新生代的對象,那標記存活對象的時候,需要掃描老年代中的所有對象。因為該對象擁有對新生代對象的引用,那么這個引用也會被稱為 GC Roots,這樣豈不是每次進行 Minor GC 時也要進行全堆的掃描?
HotSpot 給出的解決方案是一項叫做卡表(Card Table)的技術,該技術將整個堆劃分為一個個大小為 512 字節的卡,并且維護一個卡表,用來存儲每張卡的一個標識位。這個標識位代表對應的卡是否可能存有指向新生代對象的引用。如果可能存在,那么我們就認為這張卡是臟的。如下圖:
?? 在進行 Minor GC 的時候,我們便可以不用掃描整個老年代,而是在卡表中尋找臟卡,并將臟卡中的對象加入到 Minor GC 的 GC Roots 里。當完成所有臟卡的掃描之后,Java 虛擬機便會將所有臟卡的標識位清零。卡表能用于減少老年代的全堆空間掃描,這能很大的提升 GC 效率。
?? 如果說收集算法是內存回收的方法論,那么垃圾收集器就是內存回收的具體實現。Java 虛擬機規范中對垃圾收集器應該如何實現并沒有任何規定,因此不同的廠商、不同版本的虛擬機所提供的垃圾收集器都可能會有很大差別,并且一般都會提供參數供用戶根據自己的應用特點和要求組合出各個年代所使用的收集器。這里討論的收集器基于JDK 1.7 Update 14之后的 HotSpot 虛擬機(在此版本中正式提供了商用的G1收集器,之前G1仍處于實驗狀態) ,這個虛擬機包含的所有收集器如圖所示。
圖中展示了 7 種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機所處的區域,則表示它是屬于新生代收集器還是老年代收集器。
?? Serial 收集器是最基本、發展歷史最悠久的收集器,看名字就會知道,這個收集器是一個單線程的收集器,Serial 收集器采用單線程方式進行收集,且在 GC 線程工作時,系統不允許應用線程打擾。此時,應用程序進入暫停狀態,即 Stop-the-world。Stop-the-world 暫停時間的長短,是衡量一款收集器性能高低的重要指標。
回收區域:新生代;
采用算法:復制算法;
運行環境:運行在 Client 模式下的默認新生代收集器。
?? ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其余行為包括 Serial 收集器可用的所有控制參數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一樣,在實現上,這兩種收集器也共用了相當多的代碼。ParNew 收集器的工作過程如圖所示:
?? Parallel Scavenge 是針對新生代的垃圾回收器,采用“復制”算法,和 ParNew 類似,但更注重吞吐率。在 ParNew 的基礎上演化而來的 Parallel Scanvenge 收集器被譽為“吞吐量優先”收集器。吞吐量就是 CPU 用于運行用戶代碼的時間與 CPU 總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 + 垃圾收集時間)。如虛擬機總運行了 100 分鐘,其中垃圾收集花掉 1 分鐘,那吞吐量就是99%。
?? Parallel Scanvenge 收集器在 ParNew 的基礎上提供了一組參數,用于配置期望的收集時間或吞吐量,然后以此為目標進行收集。通過 VM 選項可以控制吞吐量的大致范圍:
-XX:MaxGCPauseMills:期望收集時間上限,用來控制收集對應用程序停頓的影響。
-XX:GCTimeRatio:期望的 GC 時間占總時間的比例,用來控制吞吐量。
-XX:UseAdaptiveSizePolicy:自動分代大小調節策略。
?? 但要注意停頓時間與吞吐量這兩個目標是相悖的,降低停頓時間的同時也會引起吞吐的降低。因此需要將目標控制在一個合理的范圍中。
Serial Old 是 Serial 收集器的老年代版本,單線程收集器,采用“標記-整理”算法。這個收集器的主要意義也是在于給 Client 模式下的虛擬機使用。
?? Parallel Old 是 Parallel Scanvenge 收集器的老年代版本,多線程收集器,采用“標記-整理”算法。這個收集器是在 JDK 1.6 中才開始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直處于比較尷尬的狀態。原因是,如果新生代選擇了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外別無選擇(Parallel Scavenge 收集器無法與CMS收集器配合工作)。由于老年代 Serial Old 收集器在服務端應用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整體應用上獲得吞吐量最大化的效果,由于單線程的老年代收集中無法充分利用服務器多 CPU 的處理能力,在老年代很大而且硬件比較高級的環境中,這種組合的吞吐量甚至還不一定有 ParNew 加 CMS 的組合“給力”。
直到Parallel Old收集器出現后,“吞吐量優先”收集器終于有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作過程如圖所示:
?? CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在互聯網站或者 B/S 系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS 收集器就非常符合這類應用的需求。
?? 從名字(包含 “Mark Sweep” )上就可以看出,CMS 收集器是基于“標記—清除”算法實現的,它的運作過程相對于前面幾種收集器來說更復雜一些,整個過程分為 4 個步驟,包括:
初始標記(CMS initial mark)
并發標記(CMS concurrent mark)
重新標記(CMS remark)
并發清除(CMS concurrent sweep)
?? 其中,初始標記、重新標記這兩個步驟仍然需要 “Stop The World”。初始標記僅僅只是標記一下 GC Roots 能直接關聯到的對象,速度很快,并發標記階段就是進行 GC RootsTracing 的過程,而重新標記階段則是為了修正并發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比并發標記的時間短。
?? 由于整個過程中耗時最長的并發標記和并發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS 收集器的內存回收過程是與用戶線程一起并發執行的。通過下圖可以比較清楚地看到 CMS 收集器的運作步驟中并發和需要停頓的時間。
優點:
并發收集,低停頓。
缺點:
CPU 資源非常敏感;
無法處理浮動垃圾;
是基于“標記-清除”算法,該算法的缺點都有。
?? CMS 收集器之所以能夠做到并發,根本原因在于采用基于“標記-清除”的算法并對算法過程進行了細粒度的分解。前面已經介紹過“標記-清除”算法將產生大量的內存碎片這對新生代來說是難以接受的,因此新生代的收集器并未提供 CMS 版本。
?? G1(Garbage-First)收集器是當今收集器技術發展最前沿的成果之一,它是一款 面向服務端應用的垃圾收集器,HotSpot 開發團隊賦予它的使命是(在比較長期的)未來可以替換掉 JDK 1.5 中發布的 CMS 收集器。與其他 GC 收集器相比,G1 具備如下特點:
并行與并發:G1 能夠重發利用多 CPU、多核環境下的優勢,使用多個 CPU 來縮短 Stop-The-World 停頓時間。
分代收集:與其他收集器一樣,分代概念在 G1 中依然存在。雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠采用不同方式去處理新創建的對象和已存活一段時間、熬過多次 GC 的舊對象來獲取更好的收集效果。
空間整合:與 CMS 的“標記-清理”算法不同,G1 從整體來看是基于“標記-整理”來實現的收集器,從局部(兩個 Region 之間)上來看是基于“復制”算法實現的,這兩種算法都意味著 G1 運作期間不會產生內存空間碎片,收集后能夠提供整體的可用內存。
可預測停頓:G1 除了追求低停頓之外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。
橫跨整個堆內存
使用G1收集器時,Java堆的內存布局與其他收集器有很大的區別,它將?整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留著新生代和老年代的概念,但?新生代和老年代不再是物理隔離的了,他們都是一部分Region(不需要連續)的集合。
建立可預測的時間模型
G1 收集器之所以能夠建立可預測的停頓時間模型,是因為它可以?有計劃地避免在整個Java對中進行全區域的垃圾收集。G1 跟蹤各個 Region 里面垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需要時間的經驗值),在后臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region(這也是 Garbage-First 名稱的由來)。G1 收集的運作過程大致如下:
初始標記(Initial Marking):僅僅只是標記一下 GC Roots 能直接關聯到的對象,并且修改 TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序并發運行時,能在正確可用的 Region 中創建新對象,這階段需要停頓線程,但耗時很短。
并發標記(Concurrent Marking):是從GC Roots開始堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序并發執行。
最終標記(Final Marking):是為了修正并發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程 Remembered Set Logs 里面,最終標記階段需要把 Remembered Set Logs 的數據合并到 Remembered Set 中,這階段需要停頓線程,但是可并行執行。
篩選回收(Live Data Counting and Evacuation):首先對各個 Region 的回收價值和成本進行排序,根據用戶所期望的 GC 停頓時間來制定回收計劃。這個階段也可以做到與用戶程序一起并發執行,但是因為只回收一部分 Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。
通過下圖可以比較清楚地看到 G1 收集器的運作步驟中并發和需要停頓的階段(Safepoint處):
G1 的 GC 模式可以分為兩種,分別為:
Young GC:?在分配一般對象(非巨型對象)時,當所有 Eden 區域使用達到最大閥值并且無法申請足夠內存時,會觸發一次 YoungGC。每次 Young GC 會回收所有 Eden 以及 Survivor 區,并且將存活對象復制到 Old 區以及另一部分的 Survivor 區。
Mixed GC:?當越來越多的對象晉升到老年代時,為了避免堆內存被耗盡,虛擬機會觸發一個混合的垃圾收集器,即 Mixed GC,該算法并不是一個 Old GC,除了回收整個新生代,還會回收一部分的老年代,這里需要注意:是一部分老年代,而不是全部老年代,可以選擇哪些 Old 區域進行收集,從而可以對垃圾回收的耗時時間進行控制。G1 沒有 Full GC 概念,需要 Full GC 時,調用 Serial Old GC 進行全堆掃描。
查看 JVM 使用的默認垃圾收集器,可以在 Mac 終端或者 Windows 的 CMD 執行如下命令:
java-XX:+PrintCommandLineFlags-version
以我的電腦為例,執行結果為:
-XX:G1ConcRefinementThreads=10-XX:GCDrainStackTargetSize=64-XX:InitialHeapSize=267050880-XX:MaxHeapSize=4272814080-XX:+PrintCommandLineFlags-XX:ReservedCodeCacheSize=251658240-XX:+SegmentedCodeCache-XX:+UseCompressedClassPointers-XX:+UseCompressedOops-XX:+UseG1GC-XX:-UseLargePagesIndividualAllocationjava version"12.0.1"2019-04-16Java(TM)SE Runtime Environment(build12.0.1+12)JavaHotSpot(TM)64-Bit Server VM(build12.0.1+12,mixed mode,sharing)
垃圾收集器參數總結:
UseSerialGC:?虛擬機運行在 Client 模式下的默認值,打開次開關后,使用 Serial + Serial Old 的收集器組合進行內存回收。
UseParNewGC:?打開次開關后,使用 ParNew + Serial Old 的收集器組合進行內存回收。
UseConcMarkSweepGC:?打開次開關后,使用ParNew + CMS + Serial Old的收集器組合進行內存回收,Serial Old 收集器將作為 CMS 收集器出現 Concurrent Mode Failure 失敗后的備用收集器使用。
UseParallelGC:?虛擬機運行在 Server 模式下的默認值,打開此開關后,使用 Parallel Scavenge + Serial Old(PS MarkSweep)的收集器組合進行內存回收。
UseParallelOldGC:?打開此開關后,使用 Parallel Scavenge + Parallel Old 的收集器組合進行內存回收。
SurvivorRatio:?新生代中 Eden 區域與 Survivor 區域的容量比值,默認為 8 ,代表 Eden :Survivor = 8 : 1。
PretenureSizeThreshold:?直接晉升到老年代的對象大小,設置這個參數后,大于這個參數的對象將直接在老年代分配。
MaxTenuringThreshold:?晉升到老年代的對象年齡。每個對象在堅持過一次 Minor GC 后,年齡就增加 1 ,當超過這個參數值時就進入老年代。
UseAdaptiveSizePolicy:?動態調整 Java 堆中各個區域的大小以及進入老年代的年齡。
HandlePromotionFailure:?是否允許分配擔保失敗,即老年代的剩余空間不足以應付新生代的整個 Eden 和 Survivor 區的所有對象都存活的極端情況。
ParallelGCThreads:?設置并行 GC 時進行內存回收的線程數。
GCTimeRatio:?GC 時間占總時間的比例,默認值為99,即允許 1% 的 GC 時間。僅在使用 Parallel Scavenge 收集器時生效。
MaxGCPauseMillis:?設置 GC 的最大停頓時間。僅在使用 Parallel Scavenge 收集器時生效。
CMSInitiatingOccupancyFraction:?設置 CMS 收集器在老年代空間被使用多少后觸發垃圾收集。默認值為 68%,僅在使用 CMS 收集器時生效。
UseCMSCompactAtFullCollection:?設置 CMS 收集器在完成垃圾收集后是否要進行一次內存碎片整理。僅在使用 CMS 收集器時生效。
CMSFullGCsBeforeCompaction:?設置 CMS 收集器在進行若干次垃圾收集后再啟動一次內存碎片整理。僅在使用 CMS 收集器時生效。
參考資料:
《深入理解 Java 虛擬機》 周志明 著