1 運行在Linux上的JVM
本文想講Java的內存管理,首先花一點篇幅在OS的內存上。這里以大部分服務器運行的基礎Linux為例。
Linux內存
學習過OS的都知道,一個CPU指令想要訪問或者寫內存的某一個區域,就會有一個尋址的操作。OS的內存模型里,內存的每一個區域都有一個地址。而32位CPU的地址的位數限制在32位,意即CPU只能在最大4GB(232)的空間內完成尋址。而64位CPU可以完成更大范圍內的尋址操作(264,自己算算呢)。當前,我相信大部分的CPU與OS都已經進化到了64位。 雖然CPU可以在這么寬廣的范圍內尋址,但是實際機器的內存可能并沒有那么大。所以,大部分現代的OS支持虛擬內存。比如我的機器有4GB RAM內存,我可以假裝有8GB內存,其余4GB是虛擬內存,存儲在磁盤的交換區上(對于linux存儲在特殊的分區swap上,相信自己裝過ubuntu的都知道)。當當前任務使用的內存超出物理內存時,OS會根據一些算法把一部分最近不常使用的區域換出內存到交換區,騰出來的空間給當前急用。對于Linux和大部分現代OS,內存交換的單位都是頁。
還需要提一下OS的進程模型。一個進程擁有自己神圣不可侵犯的進程空間。而一個進程的空間被分為很多的頁,有可能一部分頁被OS調度到交換區。顯而易見,這是這個進程不想看到的事情。
下圖可以看到兩個JVM進程,都擁有自己的尋址空間。在JVM服務中,可以通過-Xmx,-Xms來限制JVM進程的最大內存在物理內存內,并預留足夠空間給OS還有其他服務進程。
如果內存耗盡
地址空間耗盡一般發生在32位的機器上。內存泄漏或過度使用本機內存會迫使OS使用swap。訪問經過交換的內存地址比讀取駐留在物理內存中的地址慢得多,因為必須從磁盤拉取數據。可能會分配大量內存來用完所有物理內存和所有交換內存(頁面空間),在 Linux 上,這將觸發內核內存不足(OOM)結束程序,強制結束最消耗內存的進程。
從JVM外簡單梳理了一下內存管理后,我們可以來看看JVM內部的內存管理。
2 JVM內存布局
JVM內部的內存布局可以看下圖。
大致可以分為3部分:
- 方法區(Method Area) 所有線程共享的區域。
- 堆(Heap) 所有線程共享的區域。
- 線程的內存空間區域,包括:程序計數器(PC),虛擬機棧(VM Stack),本地方法棧(Native Method Stack)。
對于HotSpotVM,虛擬機棧和本地方法棧放在了一起。
2.1 方法區
方法區域Java堆一樣,是各個線程共享的內存區域,用于存儲已被虛擬機加載的類信息、常量、靜態變量、JIT編譯后的代碼等數據。
許多人稱這部分區域為永久代(Permanent Generation)。但是二者并不等價。HotSpot團隊把GC分代收集擴展到方法區,使用永久代來實現方法區的回收和管理,避免專門為方法區編寫內存管理代碼。在其它的虛擬機實現并不存在永久代的概念。而HotSpot這樣的設計并不是一個很好的設計,更容易遇到內存溢出的問題。可以看下圖,只要方法區到達了XX:MaxPermSize上限就溢出了,但是,其它虛擬機,如J9、JRockit只要沒有觸碰出可用內存上限,就不會出現問題。在JDK 7中,已經把字符串常量從永久代中移出,JDK 8中正式移除了永久代這個概念(JDK features),原先永生代中大部分內容比如類的元信息會被放入本地內存(Metaspace)。
JVM規范對方法區的限制很寬松,甚至可以選擇不實現垃圾收集。而垃圾收集在這個區域很少見。主要內存回收的目標是針對常量池和對類型的卸載。對類型的卸載非常苛刻。
運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中,除了有類的版本、字段、方法、接口等描述信息外,還有一項信息室常量池(Constant Pool Table),用于存放編譯期生成的各種字面量和符號引用。這部分內容在類被加載后進入方法區的運行時常量池中存放。
而運行時常量池相對于Class文件中的常量池的一個重要特征是具有動態性。運行期間也可以把新的常量放入池中。比如:String的intern方法。
String.intern
public String intern()
Returns a canonical representation for the string object.
A pool of strings, initially empty, is maintained privately by the class String.
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.
All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java Language Specification.
Returns:
a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.
String.intern在java7之前,過度使用可能會導致方法區(永久代)觸發OOM。Java7及之后將運行時常量池被放到堆中管理,避免了這種情況。
2.2 方法棧
方法棧有兩種,JVM棧與本地方法棧(Native Method Stacks)。它是線程私有的,生命周期與線程相同。每個方法在執行時都會創建一個棧幀(Stack Frame)存儲局部變量表、操作數棧、動態鏈接、方法出口等。局部變量表存放了編譯期可知的各種基本數據類型、對象引用和returnAddress類型(指向一條字節碼指令的地址)。
局部變量表所需的內存空可以在編譯期間完全確定,runtime它的大小不會被改變。當線程請求的棧深度大于設定閾值,JVM會拋出StackOverflowError;如果無法申請到足夠內存完成棧的動態擴展,將會拋出OutOfMemoryError異常。
本地方法棧為VM使用的本地方法服務。虛擬機規范并未對其使用語言、方式、數據結構有強制規定,而許多VM實現,包括HotSpot虛擬機,把VM棧和本地方法棧合二為一,一樣也可能拋出StackOverflowError或者OOM。
2.3 程序計數器
程序計數器是每個線程私有的用于記錄當前線程所執行字節碼的行號指示器。在VM的概念模型中,字節碼解釋器工作時就是通過改變這個計數器來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等都依賴這個計數器完成。當線程執行java方法時,它存儲正在執行的字節碼的地址;如果是本地方法,它的值為undefined。此內存區域是JVM規范中沒有規定任何OOM情況的區域。
2.4 堆
堆應該是內存管理中最大的一塊,被所有線程共享。所有的對象實例都在堆上分配。但是,隨著JIT的發展與逃逸分析技術的成熟,棧上分配、標量替換優化技術會導致一些微妙變化發生,所有對象在堆上分配便不那么絕對了。
現代的垃圾收集器基本采用分代收集算法,可以參考下圖:
分為新生代和老年代,還可以細分為:Eden,From Survivor,To Survivor,等。線程共享的java堆中,可能會劃分出多個線程私有的分配緩沖區(Thread Local Allocation buffer,TLAB)。進一步劃分的目的是為了更好地回收內存,或更快地分配內存。
而Java堆可以處于物理上不連續的空間中。
2.5 其它內存占用
還有一些其它的內存占用值得注意。直接內存(Direct Memory)不是VM運行時數據區的一部分,也不是VM規范定義的內存區域。但是它也會被頻繁使用,可能導致OOM。
JDK 1.4中加入的NIO(Nonblocking IO)引入了基于通道Channel與緩沖區(Buffer)的IO方式。它使用native函數庫直接分配堆外內存,通過存在Java堆中的DirectByteBuffer對象作為這塊內存的引用來操作。在許多場景中,這樣的設計因避免在Java堆與native對中來回復制而提高了性能。但是它的分配不會受到Java堆大小的限制,但還是會收到本機內存的限制。如果在配置JVM參數時候忽略了直接內存,使得各個內存區域總和大于物理內存限制,會導致動態擴展時出現OOM。
3 自動內存管理
這里我們以官方的HotSpot虛擬機為例,看JVM如何自動管理內存。
3.1 對象的創建
對象的創建可以分為如下的過程:
- 檢查并類加載如果需要
- 分配內存空間
- 初始化零值(可能會在TLAB分配新空間時做)
- 對象頭設置元信息
JVM在遇到一條new指令時,首先檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,這個類是否被加載、解析和初始化過。如果沒有,這執行類加載過程。
加載類完畢后,接下來VM將會為新對象分配內存。對象所需要的內存大小在類加載完成后便可完全確定。
分配對象有兩種情況:
- 如果Java堆內存絕對規整 使用一個指針指向已經分配的內存區域和未分配內存區域的地址,新的對象創建只需要將這個指針向空閑區域方向移動給定空間。這種分配方式被稱為『指針碰撞』。
- 如果Java內存并不規整 『指針碰撞』無法適用。VM需要維護一個列表,記錄哪些內存塊是可用的,在列表中找到足夠大的空間分配給對象。這種方法叫『空閑列表』。
Java堆是否規整由采用的垃圾收集器是否帶有壓縮整理功能決定。CMS默認使用空閑列表,而Serial、parNew等帶compat過程的收集器就采用指針碰撞。JVM啟動時的參數XX+UseCMSCompactAtFullCollection可以在CMS垃圾收集器中開啟Full GC時整理、壓縮堆內存。
對象創建是VM中非常頻繁的行為,并發情況下如何保證一致性,通常有兩種解決方案:
- 加鎖同步 通過VM使用CAS(java.util.concurrent包中借助CAS實現了區別于synchronize同步鎖的一種樂觀鎖)配上失敗重試來保證內存分配的原子性。
- 分配TLAB劃分不同線程的內存空間 將不同線程的內存分配隔離在不同的內存區域。每個線程擁有一個內存分配緩沖(TLAB,Thread Local Allocation Buffer)。 只有在TLAB用盡分配新的空間給TLAB時候才需要同步鎖定。可以通過-XX:/-UseTLAB參數設定是否使用TLAB。
內存分配完畢后,VM會將所有空間都初始化為零值(不包括對象頭)。然后VM會對對象頭做初始化。對于VM來說,一個新的對象已經誕生了;但從Java的角度,對象創建才剛剛開始——開始執行<init>方法,按照程序員的意愿初始化數據。
而對象的回收才是進入到JVM的垃圾回收器的工作原理。
3.2 回收算法概述
基本的垃圾收集算法由4種:
-
標記-清除法(Mark-Sweep) 首先標記全部需要回收的對象,標記完成后統一回收。他的不足在于:標記、清除的過程效率并不高;同時會產生大量不連續的內存碎片。
image -
復制算法(Copy) 它將內存劃分為兩個部分,每次只使用一塊。當這塊要用完了,就把還存活的對象復制到另一塊上面;原來的一塊全部清理掉。這樣,可以使用『指針碰撞』的方法順序地分配內存。缺點是一半的內存無法使用,并且在對象存活率較高的時候效率較低。可以參考下圖。
image -
標記整理法(mark-Compact) 標記整理類似于標記清除,不同的是,標記之后不是清除(Sweep),而是讓存活對象都向一端移動,然后直接清除掉端邊界之外的內存。
image本節圖片均來自于《深入理解jVM虛擬機》3.3
3.3 堆分代收集
如下圖所示,JVM主要會被分為兩個部分:年輕代與老年代。
[圖片上傳失敗...(image-e5966-1510413201979)]
把對象按照不同的年齡分離開,使用不同的策略清理,能夠提高垃圾收集器的效率。
JVM的開發者發現,一個程序中的大部分對象都會在年輕的時候死亡。IBM專門的研究表明,年輕代98%的對象都是朝生夕死的。不需要按照1:1比例劃分內存空間。
年輕代又分為Eden和兩個大小相等的Survivor區域:from和to。每次使用Eden和一塊Survivor。新的對象在Eden里分配。當Eden空間不足時,會發生一次Minor GC。
Minor GC將在使用的Survivor和Eden存活的對象一次性復制到另一個Survivor中,然后清理Eden和之前的Survivor。HotSpot虛擬機默認Eden:Survivor大小比例是8:1。這樣,年輕代自浪費了10%的空間。當然,當Survivor空間不夠用時,需要依賴老年代(Tenured)進行分配擔保(Handle Promotion)。
一個對象的年齡即它經歷的Minor GC次數。在年齡到達一定次數時,對象會被移動到老年代(Promotion)。發生分配擔保時也會有年輕代對象被移到老年代。
而由于老年代的對象存活率較高,沒有額外空間進行分配擔保,必須使用『標記-清除』或者『標記-整理』算法來回收。
3.4 如何標記
HotSpot實現上述的算法時,必須要保證高效的執行效率。
如何標記存活實例呢?目前主要有兩種方法:
- 引用計數法 VM維護每個對象被引用的次數,引用次數為0的對象被標記清除。這個算法足夠簡單,但是無法回收循環依賴的對象。Java并沒有采用這樣的算法。
- 標記存活對象 從一系列根節點出發,根據引用關系遍歷對象,遍歷的對象標記為存活對象。剩余對象為需要回收的對象。正是因為大部分對象都是朝生暮死,所以找活著的比找死去的容易一些。
那么,對于JVM來說,哪些是根節點對象呢?
- 方法棧中的對象引用
- static靜態變量 只有類被VM卸載的時候(類和方法保存在方法區,即Metaspace,原Perm),static變量才會被回收。
- main thread對象
- JNI引用
Java會從這些根節點對象開始掃描存活對象。實際上,HotSpot為了更加高效地實現標記算法,在編譯期間與運行期間都使用了很多手段降低標記運行時間。可達性分析對執行時間非常敏感,尤其是GC停頓的時候。可達性分析需要在一個能夠確保一致性的堆內存快照中進行,在分析過程中不會出現對象引用關系仍然在不斷變化的情況。此時便會停頓所有執行線程(Stop the World)。在幾乎不停頓的CMS收集器中仍然會在枚舉根節點的時候停頓。
同時,JVM會保證任何停頓都發生在SafePoint或者SafeRegion。
具體JVM針對枚舉根節點過程的優化可以參考《深入理解JVM虛擬機》3.4節。
3.5 垃圾回收器
下圖包含了HotSpot虛擬機的全部虛擬機,還有不同回收器之間是否可以配合使用(用線連接)。而不同的收集器在不同的區域使用。
[圖片上傳失敗...(image-41cc80-1510413201979)]
CMS和G1是兩個比較復雜的收集器,G1更是在JDK 7 Update14后在移除了實驗的標簽。沒有完美的收集器,而是針對具體應用最適合的收集器。
新生代回收器
新生代收集器包含3種:Serial,ParNew,Parrallel Scavenge。
Serial
[圖片上傳失敗...(image-7edf57-1510413201979)]
一圖省千言。單GC線程,Stop the World,但是Serial收集器仍然是Java client模式下默認的年輕代垃圾收集器,簡單而高效。
ParNew
ParNew其實就是Serial的多線程版本,除了多GC線程外,還可以通過JVM啟動參數調整,如:-XX:SurvivorRatio
, -XX:PretenureSizeThreshold
, -XX:HandlePromotionFailure
等。
ParNew是用戶使用-XX:+UseConcMarkSweepGC
來使用CMS作為老年代的收集器時,ParNew是默認年輕代的收集器。
Parrallel Scavenge
它也是復制算法的收集器,同時是并行的。與ParNew相比的特別之處在于,它的目標是要達到一個可控制的吞吐量,而其他收集器如CMS關注于盡可能縮短垃圾收集時用戶線程停頓的時間。
所謂的吞吐量,是CPU用于運行用戶代碼的時間與CPU總消耗時間的比值。
Parrallel Scavenge提供了比較豐富的可控制的參數,并且自己有一套自適應的調節策略。
老年代回收器
老年代包含:Serial Old收集器,Parrallel Old收集器,和CMS。CMS比較復雜,應用比較廣泛,單獨來說。
Serial Old是單線程采用標記-整理算法的收集器,會Stop the World后單GC線程標記-整理。它也是Client模式下的默認老年代收集器。在JDK 5以及之前與Parrallel Scavenge搭配使用。另一個作用是作為CMS的后備方案,在發生Concurrent Mode Failure的時候使用。
Parrallel Old是Parrallel Scavenge的老年代版本,使用多線程和標記-整理算法。從JDK6開始提供。Parrallel Old配合Parrallel Scavenge成為了『吞吐量優先』收集器的應用組合,用于注重吞吐量和CPU資源敏感的場合。
CMS
CMS全稱為Concurrent Mark-Sweep,是一種以獲取最短回收停頓時間為目標的收集器。被廣泛用于互聯網的后端服務等注重服務響應速度的場合。
CMS是基于標記-清除算法實現的,過程比較復雜,整個過程可以分為4步,其中初始標記與重新標記步驟需要Stop the World,二者的耗時得到了比較好的控制,總體上來說CMS是HotSpot虛擬機第一款真正意義上的并發收集器,第一次實現了GC線程與用戶線程同時工作,是具有劃時代意義的。
下面按照4個步驟分解一下收集器的收集過程。
CMS初始標記 initial mark
初始標記比較簡單,標記一下GC根節點能夠關聯到的對象,速度很快,但是需要Stop the World。
CMS并發標記 concurrent mark
并發標記,JVM會開啟多線程與用戶線程一起工作。進行GC根節點的tracing過程。在trace過程中,堆中對象的引用關系可能在不斷變化。
CMS重新標記 remark
remark需要再次Stop the World,為了修正并發標記過程中因用戶程序繼續運作導致標記產生變動的那一部分對象的標記記錄。耗時會比初始標記長,但遠比并發標記的時間短。
CMS并發清除 concurrent sweep
并發清除就是與用戶線程一起同時對標記完后未被標記的對象做清理。此時,因為不會做整理,所以會產生不少的碎片。
整個過程可以看下圖。
[圖片上傳失敗...(image-53e113-1510413201979)]
CMS的缺陷與常見問題
CMS對CPU資源非常敏感。CMS雖然會與用戶線程并發執行,但是仍然會占用一部分CPU資源,造成吞吐降低。CMS默認啟動的回收線程數是(CPU數量+3)/4。尤其是CPU核數較小的時候對用戶程序影響比較明顯。
CMS無法處理浮動垃圾,可能出現Concurrent Mode Failure進而導致另一次Full GC發生。在CMS并發清除時,用戶程序還在運行,仍然會產生垃圾。這部分垃圾并不會在這次GC過程中被回收。它們就叫浮動垃圾。在過程期間,CMS需要預留足夠空間給用戶的程序使用(這個值可以通過JVM參數
-XX:CMSInitiatingoccupancyFraction
設定,JDK 6中為92%)。如果預留空間無法滿足用戶程序需要,就會發生Concurrent Mode Failure。此時,JVM啟動預案,即使用Serial Old收集器重新進行老年代的垃圾收集,導致停頓時間非常長。當進行Handle Promotion的時候,survivor空間放不下年輕代的存活對象,對象只能放入老年代,但是老年代也放不下的時候,就會出現 擔保失敗(Promotion Failure)。
擔保失敗的出現很可能是因為老年代內存碎片太多,而新生代來的對象比較大造成。此時會提前觸發stop the world的CMS的Full GC過程,但是CMS默認使用標記-清除算法,并不會整理。可以通過Java啟動參數-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5
來開啟CMS算法的每5次Full GC進行一次標記整理,從而控制老年代的碎片。
下圖是promotion failure的一條gc日志:
2016-11-18T07:26:09.665+0800: 39287.719: [GC2016-11-18T07:26:09.666+0800: 39287.719: [ParNew (promotion failed)
: 557937K->566208K(566208K), 0.1302900 secs]2016-11-18T07:26:09.796+0800: 39287.850: [CMS: 2310524K->290069K(2516608K), 1.4146810 secs] 2855851K->290069K(3082816K), [CMS Perm : 117537K->116340K(195872K)], 1.5457950 secs] [Times: user=1.67 sys=0.00, real=1.54 secs]
Garbage First (G1)
G1是面向服務端應用,未來的目標是全面替代JDK 5發布的CMS收集器。
相比其他收集器,G1有如下特點:
- 并行與并發 充分利用多核縮短Stop the World停頓時間。
- 分代收集 繼承了分代收集特點,無需其他收集器配合就可以獨立管理整個GC堆。
- 空間整合 整體看采用標記-整理算法實現。局部上看是通過復制實現。不會產生空間碎片。有利于程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發GC。
- 可預測的停頓 可以建立可預測的停頓時間模型,讓使用者明確指定一個長度為M毫秒的時間片段內,消耗在GC上的時間不得超過N毫秒。做到這點是因為,G1可以有計劃避免在整個Java堆進行全區域GC。
G1的堆的內存布局不再是連續的年輕代與老年代了,而是如下圖這樣的被分為多個大小相等的獨立region。G1會跟蹤各個region里垃圾堆積的價值大小,維護優先隊列,根據允許的收集時間,優先回收最大的region。
其實,實現的復雜度很高。Java堆雖然被分為了多個region,卻不可能是互相孤立的,一個region的對象可能與整個java堆的任意region的對象有引用關系。在做可達性判定時還是無法避免掃描整個堆。在各個收集器都有類似的問題,G1尤其突出。
G1使用Recommended Set來避免全堆掃描。具體細節不表,參考《深入理解JVM虛擬機》3.5.7。
G1的處理流程也可以大致分為以下幾步:
- 初始標記 initial marking
- 并發標記 concurrent marking
- 最終標記 final marking
- 篩選回收 live data counting and evacuation
[圖片上傳失敗...(image-3537c-1510413201979)]
具體的過程我還需要研究一下,暫且留個坑在這里。
//TODO
References
- 《深入理解JVM虛擬機》
- 胖胖的回答-Java 的垃圾回收機制的主要原理是什么?什么情況下會影響到程序的運行效率?
- Java內存詳解