JVM内存模型及CMS、G1和ZGC垃圾回收器详解
1. JVM 內存模型
JVM 內存模型主要指運行時的數據區,包括 5 個部分,如下圖所示。
-
棧也叫方法棧,是線程私有的,線程在執行每個方法時都會同時創建一個棧幀,用來存儲局部變量表、操作棧、動態鏈接、方法出口等信息。調用方法時執行入棧,方法返回時執行出棧。
-
本地方法棧與棧類似,也是用來保存線程執行方法時的信息,不同的是,執行 Java 方法使用棧,而執行 native 方法使用本地方法棧。
-
程序計數器保存著當前線程所執行的字節碼位置,每個線程工作時都有一個獨立的計數器。程序計數器為執行 Java 方法服務,執行 native 方法時,程序計數器為空。
棧、本地方法棧、程序計數器這三個部分都是線程獨占的。 -
堆是 JVM 管理的內存中最大的一塊,堆被所有線程共享,目的是為了存放對象實例,幾乎所有的對象實例都在這里分配。當堆內存沒有可用的空間時,會拋出 OOM 異常。根據對象存活的周期不同,JVM 把堆內存進行分代管理,由垃圾回收器來進行對象的回收管理。
-
方法區也是各個線程共享的內存區域,又叫非堆區。用于存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據,JDK 1.7 中的永久代和 JDK 1.8 中的 Metaspace 都是方法區的一種實現。
2. JMM 內存可見性
JMM 是 Java 內存模型,與剛才講到的 JVM 內存模型是兩回事,JMM 的主要目標是定義程序中變量的訪問規則,如下圖所示,所有的共享變量都存儲在主內存中共享。每個線程有自己的工作內存,工作內存中保存的是主內存中變量的副本,線程對變量的讀寫等操作必須在自己的工作內存中進行,而不能直接讀寫主內存中的變量。
在多線程進行數據交互時,例如線程 A 給一個共享變量賦值后,由線程 B 來讀取這個值,A 修改完變量是修改在自己的工作區內存中,B 是不可見的,只有從 A 的工作區寫回主內存,B 再從主內存讀取自己的工作區才能進行進一步的操作。由于指令重排序的存在,這個寫—讀的順序有可能被打亂。因此 JMM 需要提供原子性、可見性、有序性的保證。
3. JMM 如何保證原子性、可見性,有序性
-
原子性
JMM 保證對除 long 和 double 外的基礎數據類型的讀寫操作是原子性的。另外關鍵字 synchronized 也可以提供原子性保證。synchronized 的原子性是通過 Java 的兩個高級的字節碼指令 monitorenter 和 monitorexit 來保證的。 -
可見性
JMM 可見性的保證,一個是通過 synchronized,另外一個就是 volatile。volatile 強制變量的賦值會同步刷新回主內存,強制變量的讀取會從主內存重新加載,保證不同的線程總是能夠看到該變量的最新值。 -
有序性
對有序性的保證,主要通過 volatile 和一系列 happens-before 原則。volatile 的另一個作用就是阻止指令重排序,這樣就可以保證變量讀寫的有序性。
happens-before 原則包括一系列規則,如:
程序順序原則,即一個線程內必須保證語義串行性;
鎖規則,即對同一個鎖的解鎖一定發生在再次加鎖之前;
happens-before 原則的傳遞性、線程啟動、中斷、終止規則等。
4. 類加載機制
類的加載指將編譯好的 Class 類文件中的字節碼讀入內存中,將其放在方法區內并創建對應的 Class 對象。類的加載分為加載、鏈接、初始化,其中鏈接又包括驗證、準備、解析三步。如下圖所示。
- 加載是文件到內存的過程。通過類的完全限定名查找此類字節碼文件,并利用字節碼文件創建一個 Class 對象。
- 驗證是對類文件內容驗證。目的在于確保 Class 文件符合當前虛擬機要求,不會危害虛擬機自身安全。主要包括四種:文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證。
- 準備階段是進行內存分配。為類變量也就是類中由 static 修飾的變量分配內存,并且設置初始值。這里要注意,初始值是 0 或者 null,而不是代碼中設置的具體值,代碼中設置的值是在初始化階段完成的。另外這里也不包含用 final 修飾的靜態變量,因為 final 在編譯的時候就會分配。
- 解析主要是解析字段、接口、方法。主要是將常量池中的符號引用替換為直接引用的過程。直接引用就是直接指向目標的指針、相對偏移量等。
- 初始化,主要完成靜態塊執行與靜態變量的賦值。這是類加載最后階段,若被加載類的父類沒有初始化,則先對父類進行初始化。
只有對類主動使用時,才會進行初始化,初始化的觸發條件包括在創建類的實例時、訪問類的靜態方法或者靜態變量時、Class.forName() 反射類時、或者某個子類被初始化時。
如上圖所示,淺綠的兩個部分表示類的生命周期,就是從類的加載到類實例的創建與使用,再到類對象不再被使用時可以被 GC 卸載回收。這里要注意一點,由 Java 虛擬機自帶的三種類加載器加載的類在虛擬機的整個生命周期中是不會被卸載的,只有用戶自定義的類加載器所加載的類才可以被卸載。
5. 類加載器
如上圖所示,Java 自帶的三種類加載器分別是:BootStrap 啟動類加載器、擴展類加載器和應用加載器(也叫系統加載器)。圖右邊的桔黃色文字表示各類加載器對應的加載目錄。啟動類加載器加載 java home 中 lib 目錄下的類,擴展加載器負責加載 ext 目錄下的類,應用加載器加載 classpath 指定目錄下的類。除此之外,可以自定義類加載器。
Java 的類加載使用雙親委派模式,即一個類加載器在加載類時,先把這個請求委托給自己的父類加載器去執行,如果父類加載器還存在父類加載器,就繼續向上委托,直到頂層的啟動類加載器,如上圖中藍色向上的箭頭。如果父類加載器能夠完成類加載,就成功返回,如果父類加載器無法完成加載,那么子加載器才會嘗試自己去加載。如圖中的桔黃色向下的箭頭。
這種雙親委派模式的好處,可以避免類的重復加載,另外也避免了 Java 的核心 API 被篡改。
6. 垃圾分代回收
Java 的堆內存被分代管理,為什么要分代管理呢?分代管理主要是為了方便垃圾回收,這樣做基于2個事實,第一,大部分對象很快就不再使用;第二,還有一部分不會立即無用,但也不會持續很長時間。
虛擬機劃分為年輕代、老年代、和永久代,如下圖所示。
年輕代主要用來存放新創建的對象,年輕代分為 Eden 區和兩個 Survivor 區。大部分對象在 Eden 區中生成。當 Eden 區滿時,還存活的對象會在兩個 Survivor 區交替保存,達到一定次數的對象會晉升到老年代。
老年代用來存放從年輕代晉升而來的,存活時間較長的對象。
永久代,主要保存類信息等內容,這里的永久代是指對象劃分方式,不是專指 1.7 的 PermGen,或者 1.8 之后的 Metaspace。
根據年輕代與老年代的特點,JVM 提供了不同的垃圾回收算法。垃圾回收算法按類型可以分為引用計數法、復制法和標記清除法。
引用計數法是通過對象被引用的次數來確定對象是否被使用,缺點是無法解決循環引用的問題。
復制算法需要 from 和 to 兩塊相同大小的內存空間,對象分配時只在 from 塊中進行,回收時把存活對象復制到 to 塊中,并清空 from 塊,然后交換兩塊的分工,即把 from 塊作為 to 塊,把 to 塊作為 from 塊。缺點是內存使用率較低。
標記清除算法分為標記對象和清除不在使用的對象兩個階段,標記清除算法的缺點是會產生內存碎片。
JVM 中提供的年輕代回收算法 Serial、ParNew、Parallel Scavenge 都是復制算法,而 CMS、G1、ZGC 都屬于標記清除算法。
6. CMS 算法
基于分代回收理論,詳細介紹幾個典型的垃圾回收算法,先來看 CMS 回收算法。CMS 在 JDK1.7 之前可以說是最主流的垃圾回收算法。CMS 使用標記清除算法,優點是并發收集,停頓小。CMS 算法如下圖所示。
第一個階段是初始標記,這個階段會 stop the world,標記的對象只是從 root 集最直接可達的對象;
第二個階段是并發標記,這時 GC 線程和應用線程并發執行。主要是標記可達的對象;
第三個階段是重新標記階段,這個階段是第二個 stop the world 的階段,停頓時間比并發標記要小很多,但比初始標記稍長,主要對對象進行重新掃描并標記;
第四個階段是并發清理階段,進行并發的垃圾清理;
最后一個階段是并發重置階段,為下一次 GC 重置相關數據結構。
7. G1 算法
G1 在 1.9 版本后成為 JVM 的默認垃圾回收算法,G1 的特點是保持高回收率的同時,減少停頓。
G1 算法取消了堆中年輕代與老年代的物理劃分,但它仍然屬于分代收集器。G1 算法將堆劃分為若干個區域,稱作 Region,如下圖中的小方格所示。一部分區域用作年輕代,一部分用作老年代,另外還有一種專門用來存儲巨型對象的分區。
G1 也和 CMS 一樣會遍歷全部的對象,然后標記對象引用情況,在清除對象后會對區域進行復制移動整合碎片空間。G1 回收過程如下。
G1 的年輕代回收,采用復制算法,并行進行收集,收集過程會 STW。
G1 的老年代回收時也同時會對年輕代進行回收。主要分為四個階段:
依然是初始標記階段完成對根對象的標記,這個過程是STW的;
并發標記階段,這個階段是和用戶線程并行執行的;
最終標記階段,完成三色標記周期;
復制/清除階段,這個階段會優先對可回收空間較大的 Region 進行回收,即 garbage first,這也是 G1 名稱的由來。
G1 采用每次只清理一部分而不是全部的 Region 的增量式清理,由此來保證每次 GC 停頓時間不會過長。
總結如下,G1 是邏輯分代不是物理劃分,需要知道回收的過程和停頓的階段。此外還需要知道,G1 算法允許通過 JVM 參數設置 Region 的大小,范圍是 1~32MB,可以設置期望的最大 GC 停頓時間等。有興趣讀者也可以對 CMS 和 G1 使用的三色標記算法做簡單了解。
7. ZGC垃圾回收器
7.1 ZGC 特點
ZGC 是最新的 JDK1.11 版本中提供的高效垃圾回收算法,ZGC 針對大堆內存設計可以支持 TB 級別的堆,ZGC 非常高效,能夠做到 10ms 以下的回收停頓時間。這么快的響應,ZGC 是如何做到的呢?這是由于 ZGC 具有以下特點。
ZGC 使用了著色指針技術,我們知道 64 位平臺上,一個指針的可用位是 64 位,ZGC 限制最大支持 4TB 的堆,這樣尋址只需要使用 42 位,那么剩下 22 位就可以用來保存額外的信息,著色指針技術就是利用指針的額外信息位,在指針上對對象做著色標記。
第二個特點是使用讀屏障,ZGC 使用讀屏障來解決 GC 線程和應用線程可能并發修改對象狀態的問題,而不是簡單粗暴的通過 STW 來進行全局的鎖定。使用讀屏障只會在單個對象的處理上有概率被減速。
由于讀屏障的作用,進行垃圾回收的大部分時候都是不需要 STW 的,因此 ZGC 的大部分時間都是并發處理,也就是 ZGC 的第三個特點。
第四個特點是基于 Region,這與 G1 算法一樣,不過雖然也分了 Region,但是并沒有進行分代。ZGC 的 Region 不像 G1 那樣是固定大小,而是動態地決定 Region 的大小,Region 可以動態創建和銷毀。這樣可以更好的對大對象進行分配管理。
第五個特點是壓縮整理。CMS 算法清理對象時原地回收,會存在內存碎片問題。ZGC 和 G1 一樣,也會在回收后對 Region 中的對象進行移動合并,解決了碎片問題。
雖然 ZGC 的大部分時間是并發進行的,但是還會有短暫的停頓。來看一下 ZGC 的回收過程。
7.2 ZGC 回收過程
如下圖所示,使用 ZGC 算法進行回收,從上往下看。初始狀態時,整個堆空間被劃分為大小不等的許多 Region,即圖中綠色的方塊。
開始進行回收時,ZGC 首先會進行一個短暫的 STW,來進行 roots 標記。這個步驟非常短,因為 roots 的總數通常比較小。
然后就開始進行并發標記,如上圖所示,通過對對象指針進行著色來進行標記,結合讀屏障解決單個對象的并發問題。其實,這個階段在最后還是會有一個非常短的 STW 停頓,用來處理一些邊緣情況,這個階段絕大部分時間是并發進行的,所以沒有明顯標出這個停頓。
下一個是清理階段,這個階段會把標記為不在使用的對象進行回收,如上圖所示,把橘色的不在使用的對象進行了回收。
最后一個階段是重定位,重定位就是對 GC 后存活的對象進行移動,來釋放大塊的內存空間,解決碎片問題。
重定位最開始會有一個短暫的 STW,用來重定位集合中的 root 對象。暫停時間取決于 root 的數量、重定位集與對象的總活動集的比率。
最后是并發重定位,這個過程也是通過讀屏障,與應用線程并發進行的。
總結
以上是生活随笔為你收集整理的JVM内存模型及CMS、G1和ZGC垃圾回收器详解的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于Ext.grid.EditorGri
- 下一篇: Java后端编程技术微信群和QQ群