1. 程式人生 > >JVM記憶體模型與調優

JVM記憶體模型與調優

 

https://blog.csdn.net/persistencegoing/article/details/84376427

圖片來源(https://blog.csdn.net/qq_22152261/article/details/79491536)

JVM 記憶體區域

一. 執行緒私有區域

執行緒私有資料區域生命週期與執行緒相同, 依賴使用者執行緒的啟動/結束而建立/銷燬(在Hotspot VM內, 每個執行緒都與作業系統的本地執行緒直接對映, 因此這部分記憶體區域的存/否跟隨本地執行緒的生/死).

1. Program Counter Register(程式計數器):
       一塊較小的記憶體空間, 作用是當前執行緒所執行位元組碼的行號指示器, 類似PC在每次指令執行後自增, 維護下一個將要執行指令的地址. 在JVM模型中, 位元組碼直譯器就是通過改變PC值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴PC完成(僅限於Java方法, Native方法該計數器值為undefined). 為了執行緒切換後能恢復到正確的執行位置, 每條執行緒都需要有一個獨立的程式計數器, 這類記憶體被稱為“執行緒私有”記憶體.

2. Java Stack(虛擬機器棧):
       虛擬機器棧描述的是Java方法執行的記憶體模型: 每個方法被執行時會建立一個棧幀(Stack Frame)用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊. 每個方法被呼叫至返回的過程, 就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程(VM提供了-Xss來指定執行緒的最大棧空間, 該引數也直接決定了函式呼叫的最大深度).

       區域性變量表(對應我們常說的‘堆疊’中的‘棧’)存放了編譯期可知的各種基本資料型別(如boolean、int、double等) 、物件引用(reference : 不等同於物件本身, 可能是一個指向物件起始地址的指標, 也可能指向一個代表物件的控制代碼或其他與此物件相關的位置, 見下: HotSpot物件定位方式) 和 returnAddress型別(指向一條位元組碼指令的地址). 其中long和double佔用2個區域性變數空間(Slot), 其餘只佔用1個. 如下Java方法程式碼可以使用javap命令或javassist等位元組碼工具讀到:
public String test(int a, long b, float c, double d, Date date, List<String> list) {
    StringBuilder sb = new StringBuilder().append(a).append(b).append(c).append(d).append(date);

    for (String str : list) {
        sb.append(str);
    }

    return sb.toString();
}

注: javap/javassist讀到的其實是靜態資料, 而區域性變量表記憶體儲的卻是執行時動態載入的動態資料, 但因為區域性變量表所需的記憶體空間在編譯期間即可完成分配, 當進入一個方法時, 這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間大小不會改變, 因此可以在概念上認定這兩部分內容儲存的資料格式相同.

3. Native Method Stack(本地方法棧):
與Java Stack作用類似, 區別是Java Stack為執行Java方法服務, 而本地方法棧則為Native方法服務, 如果一個VM實現使用C-linkage模型來支援Native呼叫, 那麼該棧將會是一個C棧(詳見: JVM學習筆記-本地方法棧(Native Method Stacks)), 但HotSpot VM直接就把本地方法棧和虛擬機器棧合二為一.

二. 執行緒共享區域

隨虛擬機器的啟動/關閉而建立/銷燬.

1. Heap(Java堆)
幾乎所有物件例項和陣列都要在堆上分配(棧上分配、標量替換除外), 因此是VM管理的最大一塊記憶體, 也是垃圾收集器的主要活動區域. 由於現代VM採用分代收集演算法, 因此Java堆從GC的角度還可以細分為: 新生代(Eden區、From Survivor區和To Survivor區)和老年代; 而從記憶體分配的角度來看, 執行緒共享的Java堆還還可以劃分出多個執行緒私有的分配緩衝區(TLAB). 而進一步劃分的目的是為了更好地回收記憶體和更快地分配記憶體.

2. Method Area(方法區)
即我們常說的永久代(Permanent Generation), 用於儲存被JVM載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料. HotSpot VM把GC分代收集擴充套件至方法區, 即使用Java堆的永久代來實現方法區, 這樣HotSpot的垃圾收集器就可以像管理Java堆一樣管理這部分記憶體, 而不必為方法區開發專門的記憶體管理器(永久帶的記憶體回收的主要目標是針對常量池的回收和型別的解除安裝, 因此收益一般很小).

不過在1.7的HotSpot已經將原本放在永久代的字串常量池移出: 而在1.8中, 永久區已經被徹底移除, 取而代之的是元資料區Metaspace(這一點在檢視GC日誌和使用jstat -gcutil檢視GC情況時可以觀察到),與永久代不同, 如果不指定Metaspace大小, 如果方法區持續增長, VM會預設耗盡所有系統記憶體.

執行時常量池 
方法區的一部分. Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項常量池(Constant Pool Table)用於存放編譯期生成的各種字面量和符號引用, 這部分內容會存放到方法區的執行時常量池中(如前面從test方法中讀到的signature資訊). 但Java語言並不要求常量一定只能在編譯期產生, 即並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池, 執行期間也可能將新的常量放入池中, 如String的intern()方法.

三. 直接記憶體

直接記憶體並不是JVM執行時資料區的一部分, 但也會被頻繁的使用: 在JDK 1.4引入的NIO提供了基於Channel與Buffer的IO方式, 它可以使用Native函式庫直接分配堆外記憶體, 然後使用DirectByteBuffer物件作為這塊記憶體的引用進行操作(詳見: Java I/O 擴充套件), 這樣就避免了在Java堆和Native堆中來回複製資料, 因此在一些場景中可以顯著提高效能. 
顯然, 本機直接記憶體的分配不會受到Java堆大小的限制(即不會遵守-Xms、-Xmx等設定), 但既然是記憶體, 則肯定還是會受到本機總記憶體大小及處理器定址空間的限制, 因此動態擴充套件時也會出現OutOfMemoryError異常.

HotSpot物件
物件新建
new一個Java Object(包括陣列和Class物件), 在JVM會發生如下步驟:

        VM遇到new指令: 首先去檢查該指令的引數是否能在常量池中定位到一個類的符號引用, 並檢查這個符號引用代表的類是否已被載入、解析和初始化過. 如果沒有, 必須先執行相應的類載入過程.
類載入檢查通過後: VM將為新生物件分配記憶體(物件所需記憶體的大小在類載入完成後便可完全確定), VM採用指標碰撞(記憶體規整: Serial、ParNew等有記憶體壓縮整理功能的收集器)或空閒連結串列(記憶體不規整: CMS這種基於Mark-Sweep演算法的收集器)方式將一塊確定大小的記憶體從Java堆中劃分出來.
         除了考慮如何劃分可用空間外, 由於在VM上建立物件的行為非常頻繁, 因此需要考慮記憶體分配的併發問題. 解決方案有兩個: 
對分配記憶體空間的動作進行同步 -採用 CAS配上失敗重試 方式保證更新操作的原子性;
把記憶體分配的動作按照執行緒劃分在不同的空間之中進行 -每個執行緒在Java堆中預先分配一小塊記憶體, 稱為本地執行緒分配緩衝TLAB, 各執行緒首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB時才需要同步鎖定(使用-XX:+/-UseTLAB引數設定).
接下來將分配到的記憶體空間初始化為零值(不包括物件頭, 且如果使用TLAB這一個工作也可以提前至TLAB分配時進行). 這一步保證了物件的例項欄位可以不賦初始值就直接使用(訪問到這些欄位的資料型別所對應的零值).
然後要對物件進行必要的設定: 如該物件所屬的類例項、如何能訪問到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等, 這部分息放在物件頭中(詳見下).
         上面工作都完成之後, 在虛擬機器角度一個新物件已經產生, 但在Java視角物件的建立才剛剛開始(<init>方法尚未執行, 所有欄位還都為零). 所以new指令之後一般會(由位元組碼中是否跟隨有invokespecial指令所決定-Interface一般不會有, 而Class一般會有)接著執行<init>方法, 把物件按照程式設計師的意願進行初始化, 這樣一個真正可用的物件才算完全產生出來.

物件儲存佈局
HotSpot VM內, 物件在記憶體中的儲存佈局可以分為三塊區域:物件頭、例項資料和對齊填充:

物件頭包括兩部分: 
一部分是型別指標, 即是物件指向它的類元資料的指標: VM通過該指標確定該物件屬於哪個類例項. 另外, 如果物件是一個數組, 那在物件頭中還必須有一塊資料用於記錄陣列長度. 
注意: 並非所有VM實現都必須在物件資料上保留型別指標, 也就是說查詢物件的元資料並非一定要經過物件本身(詳見下面控制代碼定位物件方式).

一部分用於儲存物件自身的執行時資料: HashCode、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等, 這部分資料的長度在32位和64位的VM(暫不考慮開啟壓縮指標)中分別為32bit和64bit, 官方稱之為“Mark Word”; 其儲存格式如下:
狀態    標誌位    儲存內容
未鎖定    01    物件雜湊碼、物件分代年齡
輕量級鎖定    00    指向鎖記錄的指標
膨脹(重量級鎖定)    10    執行重量級鎖定的指標
GC標記    11    空(不需要記錄資訊)
可偏向    01    偏向執行緒ID、偏向時間戳、物件分代年齡
例項資料部分是物件真正儲存的有效資訊, 也就是我們在程式碼裡所定義的各種型別的欄位內容(無論是從父類繼承下來的, 還是在子類中定義的都需要記錄下來). 這部分的儲存順序會受到虛擬機器分配策略引數和欄位在Java原始碼中定義順序的影響. HotSpot預設的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers), 相同寬度的欄位總是被分配到一起, 在滿足這個前提條件下, 在父類中定義的變數會出現在子類之前. 如果CompactFields引數值為true(預設), 那子類中較窄的變數也可能會插入到父類變數的空隙中.
對齊填充部分並不是必然存在的, 僅起到佔位符的作用, 原因是HotSpot自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍, 即物件的大小必須是8位元組的整數倍.


物件定位
建立物件是為了使用物件, Java程式需要通過棧上的reference來操作堆上的具體物件. 主流的有控制代碼和直接指標兩種方式去定位和訪問堆上的物件:

控制代碼: Java堆中將會劃分出一塊記憶體來作為控制代碼池, reference中儲存物件的控制代碼地址, 而控制代碼中包含了物件例項資料與型別資料的具體各自的地址資訊: 

直接指標(HotSpot使用): 該方式Java堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊, reference中儲存的直接就是物件地址: 


這兩種物件訪問方式各有優勢: 使用控制代碼來訪問的最大好處是reference中儲存的是穩定控制代碼地址, 在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,而reference本身不變. 而使用直接指標最大的好處就是速度更快, 它節省了一次指標定位的時間開銷,由於物件訪問非常頻繁, 因此這類開銷積小成多也是一項非常可觀的執行成本.

 

JVM記憶體調優

        首先需要注意的是在對JVM記憶體調優的時候不能只看作業系統級別Java程序所佔用的記憶體,這個數值不能準確的反應堆記憶體的真實佔用情況,因為GC過後這個值是不會變化的,因此記憶體調優的時候要更多地使用JDK提供的記憶體檢視工具,比如JConsole和Java VisualVM。

    對JVM記憶體的系統級的調優主要的目的是減少GC的頻率和Full GC的次數,過多的GC和Full GC是會佔用很多的系統資源(主要是CPU),影響系統的吞吐量。特別要關注Full GC,因為它會對整個堆進行整理,導致Full GC一般由於以下幾種情況:

舊生代空間不足
    調優時儘量讓物件在新生代GC時被回收、讓物件在新生代多存活一段時間和不要建立過大的物件及陣列避免直接在舊生代建立物件 

Pemanet Generation空間不足
    增大Perm Gen空間,避免太多靜態物件 

    統計得到的GC後晉升到舊生代的平均大小大於舊生代剩餘空間
    控制好新生代和舊生代的比例 

System.gc()被顯示呼叫
    垃圾回收不要手動觸發,儘量依靠JVM自身的機制 

    調優手段主要是通過控制堆記憶體的各個部分的比例和GC策略來實現,下面來看看各部分比例不良設定會導致什麼後果

1)新生代設定過小

    一是新生代GC次數非常頻繁,增大系統消耗;二是導致大物件直接進入舊生代,佔據了舊生代剩餘空間,誘發Full GC

2)新生代設定過大

    一是新生代設定過大會導致舊生代過小(堆總量一定),從而誘發Full GC;二是新生代GC耗時大幅度增加

    一般說來新生代佔整個堆1/3比較合適

3)Survivor設定過小

    導致物件從eden直接到達舊生代,降低了在新生代的存活時間

4)Survivor設定過大

    導致eden過小,增加了GC頻率

    另外,通過-XX:MaxTenuringThreshold=n來控制新生代存活時間,儘量讓物件在新生代被回收

    由記憶體管理和垃圾回收可知新生代和舊生代都有多種GC策略和組合搭配,選擇這些策略對於我們這些開發人員是個難題,JVM提供兩種較為簡單的GC策略的設定方式

1)吞吐量優先

    JVM以吞吐量為指標,自行選擇相應的GC策略及控制新生代與舊生代的大小比例,來達到吞吐量指標。這個值可由-XX:GCTimeRatio=n來設定

2)暫停時間優先

    JVM以暫停時間為指標,自行選擇相應的GC策略及控制新生代與舊生代的大小比例,儘量保證每次GC造成的應用停止時間都在指定的數值範圍內完成。這個值可由-XX:MaxGCPauseRatio=n來設定

 

最後彙總一下JVM常見配置

堆設定

-Xms:初始堆大小

-Xmx:最大堆大小

-XX:NewSize=n:設定年輕代大小

-XX:NewRatio=n:設定年輕代和年老代的比值。如:為3,表示年輕代與年老代比值為1:3,年輕代佔整個年輕代年老代和的1/4

-XX:SurvivorRatio=n:年輕代中Eden區與兩個Survivor區的比值。注意Survivor區有兩個。如:3,表示Eden:Survivor=3:2,一個Survivor區佔整個年輕代的1/5

-XX:MaxPermSize=n:設定持久代大小

收集器設定

-XX:+UseSerialGC:設定序列收集器

-XX:+UseParallelGC:設定並行收集器

-XX:+UseParalledlOldGC:設定並行年老代收集器

-XX:+UseConcMarkSweepGC:設定併發收集器

垃圾回收統計資訊

-XX:+PrintGC

-XX:+PrintGCDetails

-XX:+PrintGCTimeStamps

-Xloggc:filename

並行收集器設定

-XX:ParallelGCThreads=n:設定並行收集器收集時使用的CPU數。並行收集執行緒數。

-XX:MaxGCPauseMillis=n:設定並行收集最大暫停時間

-XX:GCTimeRatio=n:設定垃圾回收時間佔程式執行時間的百分比。公式為1/(1+n)

併發收集器設定

-XX:+CMSIncrementalMode:設定為增量模式。適用於單CPU情況。

-XX:ParallelGCThreads=n:設定併發收集器年輕代收集方式為並行收集時,使用的CPU數。並行收集執行緒數。

 

參考 & 拓展


深入理解Java虛擬機器https://book.douban.com/subject/24722612/
實戰Java虛擬機器https://book.douban.com/subject/26354292/
HotSpot實戰https://book.douban.com/subject/25847620/
深入理解計算機系統https://book.douban.com/subject/5333562/
JVM內幕:Java虛擬機器詳解 (薦)http://www.importnew.com/17770.html
Java記憶體管理:深入Java記憶體區域http://www.cnblogs.com/gw811/archive/2012/10/18/2730117.html
JAVA的記憶體模型及結構http://ifeve.com/under-the-hood-runtime-data-areas-javas-memory-model/
Memory Management https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf
Java HotSpot VM Optionshttps://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html
JVM實用引數(一)JVM型別以及編譯器模式http://ifeve.com/useful-jvm-flags-part-1-jvm-types-and-compiler-modes-2/

https://blog.csdn.net/hjxgood/article/details/53896229

 

希望大家關注我一波,防止以後迷路,有需要的可以加群討論互相學習java ,學習路線探討,經驗分享與java求職    群號:721515304