淺談堆棧模型、JVM運行機制、JVM調優

一、數據結構中的堆棧

1. 棧:實際上就是滿足後進先出的性質,是一種數據項按序排列的數據結構,只能在一端(稱爲棧頂(top))對數據項進行插入和刪除。

2. 堆:堆是一種完全二叉樹或者近似完全二叉樹,完全二叉樹是效率很高的數據結構,像十分常用的排序算法、Dijkstra算法、Prim算法等都要用堆才能優化。

 

二、Java中的堆棧

Java把內存劃分成兩種:一種是棧內存,一種是堆內存。

1. 棧區(stack)— 由編譯器自動分配釋放 ,存放函數的參數值,局部變量的值等。其操作方式類似於數據結構中的棧。

2. 堆區(heap)— 是一個可動態申請的內存空間(其記錄空閒內存空間的鏈表由操作系統維護),在java中,所有使用new xxx()構造出來的對象都在堆中存儲一般由程序員分配釋放, 若程序員不釋放,程序結束時可能由OS回收 。注意它與數據結構中的堆是兩回事,分配方式倒是類似於鏈表。

堆是全局的,堆棧是每個函數進入的時候分一小塊,函數返回的時候就釋放了,靜態和全局變量,new得到的變量,都放在堆中,局部變量放在棧中,所以函數返回,局部變量就全沒了。

堆、棧和常量池:

1. 棧(stack)與堆(heap)都是Java用來在Ram中存放數據的地方。與C++不同,Java自動管理棧和堆,程序員不能直接地設置棧或堆。

2. 棧的優勢是,存取速度比堆要快,僅次於直接位於CPU中的寄存器。但缺點是,存在棧中的數據大小與生存期必須是確定的,缺乏靈活性。另外,棧數據可以共享,詳見第3點。

堆的優勢是可以動態地分配內存大小,所有使用new xxx()構造出來的對象都在堆中存儲,生存期也不必事先告訴編譯器,Java的垃圾收集器會自動收走這些不再使用的數據。但缺點是,由於要在運行時動態分配內存,存取速度較慢。

3. 常量池:存放字符串常量和基本類型常量(public static final)。

常量池的好處是爲了避免頻繁的創建和銷燬對象而影響系統性能,其實現了對象的共享。

例如字符串常量池,在編譯階段就把所有的字符串文字放到一個常量池中。 (1)節省內存空間:常量池中所有相同的字符串常量被合併,只佔用一個空間。 (2)節省運行時間:比較字符串時,==比equals()快。對於兩個引用變量,只用==判斷引用是否相等,也就可以判斷實際值是否相等。

 

我們看看以下的代碼。

String str1 = new String("abc"); 
String str2 = "abc"; 
System.out.println(str1==str2); //false 

創建了兩個引用。創建了兩個對象。兩個引用分別指向不同的兩個對象。

 

對於字符串:其對象的引用都是存儲在棧中的,如果是編譯期已經創建好(直接用雙引號定義的)的就存儲在常量池中,如果是運行期(new出來的)才能確定的就存儲在堆中。對於equals相等的字符串,在常量池中永遠只有一份,在堆中有多份。

這也就是有道面試題:String s = new String(“abc”);產生幾個對象?答:一個或兩個,如果常量池中原來沒有”abc”,就是兩個。

申請響應:

棧:只要棧的剩餘空間大於所申請空間,系統將爲程序提供內存,否則將報異常提示棧溢出。

堆:首先應該知道操作系統有一個記錄空閒內存地址的鏈表,當系統收到程序的申請時,會遍歷該鏈表,尋找第一個空間大於所申請空間的堆結點,然後將該結點從空閒結點鏈表中刪除,並將該結點的空間分配給程序,另外,對於大多數系統,會在這塊內存空間中的首地址處記錄本次分配的大小,這樣,代碼中的delete語句才能正確的釋放本內存空間。另外,由於找到的堆結點的大小不一定正好等於申請的大小,系統會自動的將多餘的那部分重新放入空閒鏈表中。

申請限制:

棧:在Windows下,棧是向低地址擴展的數據結構,是一塊連續的內存的區域。這句話的意思是棧頂的地址和棧的最大容量是系統預先規定好的,在 WINDOWS下,棧的大小是2M(也有的說是1M,總之是一個編譯時就確定的常數),如果申請的空間超過棧的剩餘空間時,將提示overflow。因此,能從棧獲得的空間較小。

堆:堆是向高地址擴展的數據結構,是不連續的內存區域。這是由於系統是用鏈表來存儲的空閒內存地址的,自然是不連續的,而鏈表的遍歷方向是由低地址向高地址。堆的大小受限於計算機系統中有效的虛擬內存。由此可見,堆獲得的空間比較靈活,也比較大。

堆和棧的區別用比喻來看:

使用棧就象我們去飯館裏吃飯,只管點菜(發出申請)、付錢、和吃(使用),吃飽了就走,不必理會切菜、洗菜等準備工作和洗碗、刷鍋等掃尾工作,他的好處是快捷,但是自由度小。

使用堆就象是自己動手做喜歡吃的菜餚,比較麻煩,但是比較符合自己的口味,而且自由度大。

堆棧緩存方式:

棧使用的是一級緩存, 他們通常都是被調用時處於存儲空間中,調用完畢立即釋放。

堆則是存放在二級緩存中,生命週期由虛擬機的垃圾回收算法來決定(並不是一旦成爲孤兒對象就能被回收)。所以調用這些對象的速度要相對來得低一些。

在JAVA中,有六個不同的地方可以存儲數據:

1. 寄存器(register):這是最快的存儲區,因爲它位於不同於其他存儲區的地方——處理器內部。但是寄存器的數量極其有限,所以寄存器由編譯器根據需求進行分配。你不能直接控制,也不能在程序中感覺到寄存器存在的任何跡象。

2. 棧(stack):存放基本類型的變量數據和對象的引用。位於通用RAM中,但通過它的“堆棧指針”可以從處理器哪裏獲得支持。堆棧指針若向下移動,則分配新的內存;若向上移動,則釋放那些內存。這是一種快速有效的分配存儲方法,僅次於寄存器。創建程序時候,JAVA編譯器必須知道存儲在堆棧內所有數據的確切大小和生命週期,因爲它必須生成相應的代碼,以便上下移動堆棧指針。這一約束限制了程序的靈活性。

3. 堆(heap):一種通用性的內存池(也存在於RAM中),用於存放所有的JAVA對象。堆不同於堆棧的好處是:編譯器不需要知道要從堆裏分配多少存儲區 域,也不必知道存儲的數據在堆裏存活多長時間。因此,在堆裏分配存儲有很大的靈活性。當你需要創建一個對象的時候,只需要new寫一行簡單的代碼,當執行 這行代碼時,會自動在堆裏進行存儲分配。當然,爲這種靈活性必須要付出相應的代價,用堆進行存儲分配比用堆棧進行存儲存儲需要更多的時間。

4. 靜態存儲(static storage):這裏的“靜態”是指“在固定的位置”。靜態存儲裏存放程序運行時一直存在的數據。你可用關鍵字static來標識一個對象的特定元素是靜態的,但JAVA對象本身從來不會存放在靜態存儲空間裏。

5. 常量存儲(constant storage):存放字符串常量和基本類型常量(public static final)。常量值通常直接存放在程序代碼內部,這樣做是安全的,因爲它們永遠不會被改變。

6. 非RAM存儲:硬盤等永久存儲空間。如果數據完全存活於程序之外,那麼它可以不受程序的任何控制,在程序沒有運行時也可以存在。

就速度來說,有如下關係: 寄存器 > 堆棧 > 堆 >其他

三、JVM運行機制

正常情況下我們編寫helloworld.java通過javac編譯成字節碼文件helloworld.class。通過java命令,將類放到jvm(java虛擬機中運行)

一次編寫到處運行:

計算機最後執行的是機器碼。(jvm)可以將一個代碼編譯成適用於不同操作系統的機器碼,通過不同的jdk(有不同的jvm)實現的。這就是爲什麼,我們安裝對應的jdk不同的jdk版本。
這樣實現了我們不需要改變程序,讓jdk幫我們完成底層的修改。

å¨è¿éæå¥å¾çæè¿°

JVM組成:

  • 類裝載子系統:將c程序放到方法區中。
  • 運行時數據區(內存模型):堆,棧(線程),本地方法棧,方法區,程序計數器。
  • 字節碼執行引擎:執行一些GC(垃圾回收機制)。

在這裏插入圖片描述

  • 方法區(元空間):常量和靜態變量,類元信息(有哪些方法)。堆中對象的頭指針找到方法去的指令碼的內存地址,把地址放到動態鏈接中。
  • 程序計數器:放線程馬上要執行的指令碼(行號)內存地址。
  • 本地方法棧:c語言中局部變量存放的位置。native本地方法,時間線與c語言的交互。
  • 棧(線程):存放局部變量的存儲,,一個線程一個棧,不同的方法有不同的棧。先進後出的數據結構,main和compute先後進棧,compute執行結束出棧,然後繼續執行main方法。棧中的對象類型局部變量,是有地址指向堆中。

在這裏插入圖片描述

  • 局部變量表:創建局部變量,操作數棧中的值賦值給它。
  • 操作數棧:臨時存放數據
  • 動態鏈接:就是存儲指向該方法指令碼的地址符號
  • 方法出口:一個方法結束,返回到主方法的哪行指令碼。
  • :存放new出的對象。(下面細細講)

在這裏插入圖片描述
堆:啓動600M,老年代近400M。
Eden:存放對象。放滿之後通過 minor gc垃圾收集。沒被銷燬的放入Survivor區。
Survivor from:存放上一層的對象。放滿之後通過 minor gc垃圾收集。沒被銷燬的放入to區。
Survivor to:存放上一層的對象。放滿之後通過 minor gc垃圾收集。沒被銷燬的放入from區。
通過15次gc還沒銷燬的放入老年代(方法區的 靜態變量,數據庫連接池)。
老年代滿了full gc後都是有用的內,就會內存溢出。

執行引擎,執行gc,在執行full gc的時候會停掉應用線程的gc,影響程序性能。

四、JVM性能調優 

性能調優:

性能調優包含多個層次,比如:架構調優、代碼調優、JVM調優、數據庫調優、操作系統調優等。

架構調優和代碼調優是JVM調優的基礎,其中架構調優是對系統影響最大的。

性能調優基本上按照以下步驟進行:明確優化目標、發現性能瓶頸、性能調優、通過監控及數據統計工具獲得數據、確認是否達到目標。

何時進行JVM調優:

  • Heap內存(老年代)持續上漲達到設置的最大內存值;
  • Full GC 次數頻繁;
  • GC 停頓時間過長(超過1秒);
  • 應用出現OutOfMemory 等內存異常;
  • 應用中有使用本地緩存且佔用大量內存空間;
  • 系統吞吐量與響應性能不高或下降。

JVM調優的基本原則:

  • 大多數的Java應用不需要進行JVM優化;
  • 大多數導致GC問題的原因是代碼層面的問題導致的(代碼層面);
  • 上線之前,應先考慮將機器的JVM參數設置到最優;
  • 減少創建對象的數量(代碼層面);
  • 減少使用全局變量和大對象(代碼層面);
  • 優先架構調優和代碼調優,JVM優化是不得已的手段(代碼、架構層面);
  • 分析GC情況優化代碼比優化JVM參數更好(代碼層面);

JVM調優目標:

  • 延遲:GC低停頓和GC低頻率;
  • 低內存佔用;
  • 高吞吐量;

JVM調優量化目標:

  • Heap 內存使用率 <= 70%;
  • Old generation內存使用率<= 70%;
  • avgpause <= 1秒;
  • Full gc 次數0 或 avg pause interval >= 24小時 ;

以上爲參考,不同應用的JVM調優量化目標是不一樣的。

JVM調優的步驟:

  • 分析GC日誌及dump文件,判斷是否需要優化,確定瓶頸問題點;
  • 確定JVM調優量化目標;
  • 確定JVM調優參數(根據歷史JVM參數來調整);
  • 依次調優內存、延遲、吞吐量等指標;
  • 對比觀察調優前後的差異;
  • 不斷的分析和調整,直到找到合適的JVM參數配置;
  • 找到最合適的參數,將這些參數應用到所有服務器,並進行後續跟蹤。

以上操作步驟中,某些步驟是需要多次不斷迭代完成的。一般是從滿足程序的內存使用需求開始的,之後是時間延遲的要求,最後纔是吞吐量的要求,要基於這個步驟來不斷優化,每一個步驟都是進行下一步的基礎,不可逆行之。

JVM參數:

JVM調優最重要的工具就是JVM參數了。先來了解一下JVM參數相關內容。

-XX 參數被稱爲不穩定參數,此類參數的設置很容易引起JVM 性能上的差異,使JVM存在極大的不穩定性。如果此類參數設置合理將大大提高JVM的性能及穩定性。

不穩定參數語法規則包含以下內容。

布爾類型參數值:

  • -XX:+
  • -XX:-

數字類型參數值:

  • -XX:

字符串類型參數值:

  • -XX:

JVM參數解析及調優:

比如以下參數示例:

-Xmx4g –Xms4g –Xmn1200m –Xss512k -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:PermSize=100m -XX:MaxPermSize=256m -XX:MaxTenuringThreshold=15複製代碼

上面爲Java7及以前版本的示例,在Java8中永久代的參數-XX:PermSize和-XX:MaxPermSize已經失效。這在前面章節中已經講到。

參數解析:

  • -Xmx4g:堆內存最大值爲4GB。
  • -Xms4g:初始化堆內存大小爲4GB。
  • -Xmn1200m:設置年輕代大小爲1200MB。增大年輕代後,將會減小年老代大小。此值對系統性能影響較大,Sun官方推薦配置爲整個堆的3/8。
  • -Xss512k:設置每個線程的堆棧大小。JDK5.0以後每個線程堆棧大小爲1MB,以前每個線程堆棧大小爲256K。應根據應用線程所需內存大小進行調整。在相同物理內存下,減小這個值能生成更多的線程。但是操作系統對一個進程內的線程數還是有限制的,不能無限生成,經驗值在3000~5000左右。
  • -XX:NewRatio=4:設置年輕代(包括Eden和兩個Survivor區)與年老代的比值(除去持久代)。設置爲4,則年輕代與年老代所佔比值爲1:4,年輕代佔整個堆棧的1/5
  • -XX:SurvivorRatio=8:設置年輕代中Eden區與Survivor區的大小比值。設置爲8,則兩個Survivor區與一個Eden區的比值爲2:8,一個Survivor區佔整個年輕代的1/10
  • -XX:PermSize=100m:初始化永久代大小爲100MB。
  • -XX:MaxPermSize=256m:設置持久代大小爲256MB。
  • -XX:MaxTenuringThreshold=15:設置垃圾最大年齡。如果設置爲0的話,則年輕代對象不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率。如果將此值設置爲一個較大值,則年輕代對象會在Survivor區進行多次複製,這樣可以增加對象再年輕代的存活時間,增加在年輕代即被回收的概論。

新生代、老生代、永久代的參數,如果不進行指定,虛擬機會自動選擇合適的值,同時也會基於系統的開銷自動調整。

可調優參數:

-Xms:初始化堆內存大小,默認爲物理內存的1/64(小於1GB)。

-Xmx:堆內存最大值。默認(MaxHeapFreeRatio參數可以調整)空餘堆內存大於70%時,JVM會減少堆直到-Xms的最小限制。

-Xmn:新生代大小,包括Eden區與2個Survivor區。

-XX:SurvivorRatio=1:Eden區與一個Survivor區比值爲1:1。

-XX:MaxDirectMemorySize=1G:直接內存。報java.lang.OutOfMemoryError: Direct buffer memory異常可以上調這個值。

-XX:+DisableExplicitGC:禁止運行期顯式地調用System.gc()來觸發fulll GC。

注意: Java RMI的定時GC觸發機制可通過配置-Dsun.rmi.dgc.server.gcInterval=86400來控制觸發的時間。

-XX:CMSInitiatingOccupancyFraction=60:老年代內存回收閾值,默認值爲68。

-XX:ConcGCThreads=4:CMS垃圾回收器並行線程線,推薦值爲CPU核心數。

-XX:ParallelGCThreads=8:新生代並行收集器的線程數。

-XX:MaxTenuringThreshold=10:設置垃圾最大年齡。如果設置爲0的話,則年輕代對象不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率。如果將此值設置爲一個較大值,則年輕代對象會在Survivor區進行多次複製,這樣可以增加對象再年輕代的存活時間,增加在年輕代即被回收的概論。

-XX:CMSFullGCsBeforeCompaction=4:指定進行多少次fullGC之後,進行tenured區 內存空間壓縮。

-XX:CMSMaxAbortablePrecleanTime=500:當abortable-preclean預清理階段執行達到這個時間時就會結束。

在設置的時候,如果關注性能開銷的話,應儘量把永久代的初始值與最大值設置爲同一值,因爲永久代的大小調整需要進行FullGC才能實現。

內存優化示例:

當JVM運行穩定之後,觸發了FullGC我們一般會拿到如下信息:

image

以上gc日誌中,在發生fullGC之時,整個應用的堆佔用以及GC時間。爲了更加精確需多次收集,計算平均值。或者是採用耗時最長的一次FullGC來進行估算。上圖中,老年代空間佔用在93168kb(約93MB),以此定爲老年代空間的活躍數據。則其他堆空間的分配,基於以下規則來進行。

  • java heap:參數-Xms和-Xmx,建議擴大至3-4倍FullGC後的老年代空間佔用。
  • 永久代:-XX:PermSize和-XX:MaxPermSize,建議擴大至1.2-1.5倍FullGc後的永久帶空間佔用。
  • 新生代:-Xmn,建議擴大至1-1.5倍FullGC之後的老年代空間佔用。
  • 老年代:2-3倍FullGC後的老年代空間佔用。

基於以上規則,則對參數定義如下:

java -Xms373m -Xmx373m -Xmn140m -XX:PermSize=5m -XX:MaxPermSize=5m複製代碼

延遲優化示例:

對延遲性優化,首先需要了解延遲性需求及可調優的指標有哪些。

  • 應用程序可接受的平均停滯時間: 此時間與測量的Minor
  • GC持續時間進行比較。可接受的Minor GC頻率:Minor
  • GC的頻率與可容忍的值進行比較。
  • 可接受的最大停頓時間:最大停頓時間與最差情況下FullGC的持續時間進行比較。
  • 可接受的最大停頓發生的頻率:基本就是FullGC的頻率。

其中,平均停滯時間和最大停頓時間,對用戶體驗最爲重要。對於上面的指標,相關數據採集包括:MinorGC的持續時間、統計MinorGC的次數、FullGC的最差持續時間、最差情況下,FullGC的頻率。

image

如上圖,Minor GC的平均持續時間0.069秒,MinorGC的頻率爲0.389秒一次。

新生代空間越大,Minor GC的GC時間越長,頻率越低。如果想減少其持續時長,就需要減少其空間大小。如果想減小其頻率,就需要加大其空間大小。

這裏以減少了新生代空間10%的大小,來減小延遲時間。在此過程中,應該保持老年代和持代的大小不變化。調優後的參數如下變化:

java -Xms359m -Xmx359m -Xmn126m -XX:PermSize=5m -XX:MaxPermSize=5m複製代碼

吞吐量調優:

吞吐量調優主要是基於應用程序的吞吐量要求而來的,應用程序應該有一個綜合的吞吐指標,這個指標基於整個應用的需求和測試而衍生出來的。

評估當前吞吐量和目標差距是否巨大,如果在20%左右,可以修改參數,加大內存,再次從頭調試,如果巨大就需要從整個應用層面來考慮,設計以及目標是否一致了,重新評估吞吐目標。

對於垃圾收集器來說,提升吞吐量的性能調優的目標就是儘可能避免或者很少發生FullGC或者Stop-The-World壓縮式垃圾收集(CMS),因爲這兩種方式都會造成應用程序吞吐降低。儘量在MinorGC 階段回收更多的對象,避免對象提升過快到老年代。

調優工具:

藉助GCViewer日誌分析工具,可以非常直觀地分析出待調優點。可從以下幾方面來分析:

Memory,分析Totalheap、Tenuredheap、Youngheap內存佔用率及其他指標,理論上內存佔用率越小越好;

Pause,分析Gc pause、Fullgc pause、Total pause三個大項中各指標,理論上GC次數越少越好,GC時長越小越好;

本文參考:

(1)https://cloud.tencent.com/developer/article/1453511

(2)https://juejin.im/post/5dc8d0ea518825592c566a5d

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章