一篇簡單易懂的原理文章,讓你把JVM玩弄與手掌之中
jvm原理
Java虛擬機器是整個java平臺的基石,是java技術實現硬體無關和作業系統無關的關鍵環節,是java語言生成極小體積的編譯程式碼的執行平臺,是保護使用者機器免受惡意程式碼侵襲的保護屏障。JVM是虛擬機器,也是一種規範,他遵循著馮·諾依曼體系結構的設計原理。馮·諾依曼體系結構中,指出計算機處理的資料和指令都是二進位制數,採用儲存程式方式不加區分的儲存在同一個儲存器裡,並且順序執行,指令由操作碼和地址碼組成,操作碼決定了操作型別和所操作的數的數字型別,地址碼則指出地址碼和運算元。從dos到window8,從unix到ubuntu和CentOS,還有MACOS等等,不同的作業系統指令集以及資料結構都有著差異,而JVM透過在作業系統上建立虛擬機器,自己定義出來的一套統一的資料結構和操作指令,把同一套語言翻譯給各大主流的作業系統,實現了跨平臺執行,可以說JVM是java的核心,是java可以一次編譯到處執行的本質所在。
一、JVM的組成和執行原理
JVM的畢竟是個虛擬機器,是一種規範,雖說符合馮諾依曼的計算機設計理念,但是他並不是實體計算機,所以他的組成也不是什麼儲存器,控制器,運算器,輸入輸出裝置。在我看來,JVM放在執行在真實的作業系統中表現的更像應用或者說是程式,他的組成可以理解為JVM這個程式有哪些功能模組,而這些功能模組的運作可以看做是JVM的執行原理。JVM有多種實現,例如Oracle的JVM,HP的JVM和IBM的JVM等,而在本文中研究學習的則是使用最廣泛的Oracle的HotSpot JVM。
1.JVM在JDK中的位置。
JDK是java開發的必備工具箱,JDK其中有一部分是JRE,JRE是JAVA執行環境,JVM則是JRE最核心的部分。
從最底層的位置可以看出來JVM有多重要,而實際專案中JAVA應用的效能最佳化,OOM等異常的處理最終都得從JVM這兒來解決。HotSpot是Oracle關於JVM的商標,區別於IBM,HP等廠商開發的JVM。Java HotSpot Client VM和Java HotSpot Server VM是JDK關於JVM的兩種不同的實現,前者可以減少啟動時間和記憶體佔用,而後者則提供更加優秀的程式執行速度。
2.JVM的組成
JVM由4大部分組成:ClassLoader,Runtime Data Area,Execution Engine,Native Interface。
2.1.ClassLoader是負責載入class檔案,class檔案在檔案開頭有特定的檔案標示,並且ClassLoader只負責class檔案的載入,至於它是否可以執行,則由Execution Engine決定。
2.2.Native Interface是負責呼叫本地介面的。他的作用是呼叫不同語言的介面給JAVA用,他會在Native Method Stack中記錄對應的本地方法,然後呼叫該方法時就透過Execution Engine載入對應的本地lib。原本多於用一些專業領域,如JAVA驅動,地圖製作引擎等,現在關於這種本地方法介面的呼叫已經被類似於Socket通訊,WebService等方式取代。
2.3.Execution Engine是執行引擎,也叫Interpreter。Class檔案被載入後,會把指令和資料資訊放入記憶體中,Execution Engine則負責把這些命令解釋給作業系統。
2.4.Runtime Data Area則是存放資料的,分為五部分:Stack,Heap,Method Area,PCRegister,Native Method Stack。幾乎所有的關於java記憶體方面的問題,都是集中在這塊。
可以看出它把Method Area化為了Heap的一部分,基於網上的資料大部分認為Method Area是Heap的邏輯區域,但這取決於JVM的實現者,而HotSpot JVM中把Method Area劃分為非堆記憶體,顯然是不包含在Heap中的。下圖是javacodegeeks.com中,2014年9月刊出的一片博文中關於RuntimeData Area的劃分,其中指出,NonHeap包含PermGen和Code Cache,PermGen包含MethodArea,而且PermGen在JAVA SE 8中已經不再用了。查閱資料得知,java8中PermGen已經從JVM中移除並被MetaSpace取代,java8中也不會見到OOM:PermGen Space的異常。目前Runtime Data Area可以用下圖描述它的組成:
2.4.1.Stack是java棧記憶體,它等價於C語言中的棧,棧的記憶體地址是不連續的,每個執行緒都擁有自己的棧。棧裡面儲存著的是StackFrame,在《JVM Specification》中文版中被譯作java虛擬機器框架,也叫做棧幀。棧幀包含三類資訊:區域性變數,執行環境,運算元棧。區域性變數用來儲存一個類的方法中所用到的區域性變數。執行環境用於儲存解析器對於java位元組碼進行解釋過程中需要的資訊,包括:上次呼叫的方法、區域性變數指標和運算元棧的棧頂和棧底指標。運算元棧用於儲存運算所需要的運算元和結果,它被設計為一個後進先出的棧。StackFrame在方法被呼叫時建立,在某個執行緒中,某個時間點上,只有一個框架是活躍的,該框架被稱為Current Frame,而框架中的方法被稱為Current Method,其中定義的類為Current Class。區域性變數和運算元棧上的操作總是引用當前框架。當StackFrame中方法被執行完之後,或者呼叫別的StackFrame中的方法時,則當前棧變為另外一個StackFrame。Stack的大小是由兩種型別,固定和動態的,動態型別的棧可以按照執行緒的需要分配。
2.4.2.Heap是用來存放物件資訊的,和Stack不同,Stack代表著一種執行時的狀態。換句話說,棧是執行時單位,解決程式該如何執行的問題,而堆是儲存的單位,解決資料儲存的問題。Heap是伴隨著JVM的啟動而建立,負責儲存所有物件例項和陣列的。堆的儲存空間和棧一樣是不需要連續的,它分為Young Generation和Old Generation(也叫Tenured Generation)兩大部分。YoungGeneration分為Eden和Survivor,Survivor又分為From Space和 ToSpace。
和Heap經常一起提及的概念是PermanentSpace,它是用來載入類物件的專門的記憶體區,是非堆記憶體,和Heap一起組成JAVA記憶體,它包含MethodArea區(在沒有CodeCache的HotSpotJVM實現裡,則MethodArea就相當於GenerationSpace)。在JVM初始化的時候,我們可以透過引數來分別指定,PermanentSpace的大小、堆的大小、以及Young Generation和Old Generation的比值、Eden區和From Space的比值,從而來細粒度的適應不同JAVA應用的記憶體需求。
2.4.3.PC Register是程式計數暫存器,每個JAVA執行緒都有一個單獨的PC Register,他是一個指標,由Execution Engine讀取下一條指令。如果該執行緒正在執行java方法,則PC Register儲存的是正在執行的java指令操作碼(例如iadd、ladd等),主要它只儲存操作碼,當java指令執行時,會從PC Register讀取操作碼,然後在運算元棧中取對應的運算元,如果是本地方法,PC Register的值沒有定義。PC暫存器非常小,只佔用一個字寬,可以持有一個returnAdress或者特定平臺的一個指標。
2.4.4.Method Area在HotSpot JVM的實現中屬於非堆區,非堆區包括兩部分:Permanet Generation和Code Cache,而Method Area屬於Permanert Generation的一部分。Permanent Generation用來儲存類資訊,比如說:classdefinitions,structures,methods, field, method (data and code) 和 constants。Code Cache用來儲存Compiled Code,即編譯好的原生程式碼,在HotSpot JVM中透過JIT(Just In Time) Compiler生成,JIT是即時編譯器,他是為了提高指令的執行效率,把位元組碼檔案編譯成本地機器程式碼,如下圖:
引用一個經典的案例來理解Stack,Heap和Method Area的劃分,就是Sring a=”xx”;Stirngb=”xx”,問是否a==b? 首先==符號是用來判斷兩個物件的引用地址是否相同,而在上面的題目中,a和b按理來說申請的是Stack中不同的地址,但是他們指向Method Area中Runtime Constant Pool的同一個地址,按照網上的解釋,在a賦值為“xx”時,會在RuntimeContant Pool中生成一個String Constant,當b也賦值為“xx”時,那麼會在常量池中檢視是否存在值為“xx”的常量,存在的話,則把b的指標也指向“xx”的地址,而不是新生成一個String Constant。我查閱了網路上大家關於String Constant的儲存的說說法,存在略微差別的是,它儲存在哪裡,有人說Heap中會分配出一個常量池,用來儲存常量,所有執行緒共享它。而有人說常量池是Method Area的一部分,而Method Area屬於非堆記憶體,那怎麼能說常量池存在於堆中?
我認為,其實兩種理解都沒錯。Method Area的確從邏輯上講可以是Heap的一部分,在某些JVM實現裡從堆上開闢一塊儲存空間來記錄常量是符合JVM常量池設計目的的,所以前一種說法沒問題。對於後一種說法,HotSpot JVM的實現中的確是把方法區劃分為了非堆記憶體,意思就是它不在堆上。我在HotSpot JVM做了個簡單的實驗,定義多個常量之後,程式丟擲OOM:PermGen Space異常,印證了JVM實現中常量池是在Permanent Space中的說法。JDK1.7中InternedStrings已經不再儲存在PermanentSpace中,而是放到了Heap中;JDK8中PermanentSpace已經被完全移除,InternedStrings也被放到了MetaSpace中(如果出現記憶體溢位,會報OOM:MetaSpace)。
所以在oracle hotspot 1.7中,PermGen Space是非堆記憶體,方法區屬於PermGen Space,而執行時常量池是方法區的一部分。
2.4.5.Native MethodStack是供本地方法(非java)使用的棧。每個執行緒持有一個Native Method Stack。
3.JVM的執行原理簡介
Java 程式被javac工具編譯為.class位元組碼檔案之後,我們執行java命令,該class檔案便被JVM的ClassLoader載入,可以看出JVM的啟動是透過JAVA Path下的java.exe或者java進行的。JVM的初始化、執行到結束大概包括這麼幾步:
呼叫作業系統API判斷系統的CPU架構,根據對應CPU型別尋找位於JRE目錄下的/lib/jvm.cfg檔案,然後透過該配置檔案找到對應的jvm.dll檔案(如果我們引數中有-server或者-client, 則載入對應引數所指定的jvm.dll,啟動指定型別的JVM),初始化jvm.dll並且掛接到JNIENV結構的例項上,之後就可以透過JNIENV例項裝載並且處理class檔案了。class檔案是位元組碼檔案,它按照JVM的規範,定義了變數,方法等的詳細資訊,JVM管理並且分配對應的記憶體來執行程式,同時管理垃圾回收。直到程式結束,一種情況是JVM的所有非守護執行緒停止,一種情況是程式呼叫System.exit(),JVM的生命週期也結束。
二、JVM的記憶體管理和垃圾回收
JVM中的記憶體管理主要是指JVM對於Heap的管理,這是因為Stack,PCRegister和Native Method Stack都是和執行緒一樣的生命週期,線上程結束時自然可以被再次使用。雖然說,Stack的管理不是重點,但是也不是完全不講究的。
1.棧的管理
JVM允許棧的大小是固定的或者是動態變化的。在Oracle的關於引數設定的官方文件中有關於Stack的設定,是透過-Xss來設定其大小。關於Stack的預設大小對於不同機器有不同的大小,並且不同廠商或者版本號的jvm的實現其大小也不同。
我們一般透過減少常量,引數的個數來減少棧的增長,在程式設計時,我們把一些常量定義到一個物件中,然後來引用他們可以體現這一點。另外,少用遞迴呼叫也可以減少棧的佔用因為棧幀中會儲存父棧幀,遞迴會導致父棧幀也在存或狀態,所以如果遞迴呼叫過深就會導致棧記憶體被大量佔用,甚至出現StackOverFlow。棧是不需要垃圾回收的,儘管說垃圾回收是java記憶體管理的一個很熱的話題,棧中的物件如果用垃圾回收的觀點來看,他永遠是live狀態,是可以reachable的,所以也不需要回收,他佔有的空間隨著Thread的結束而釋放。
關於棧一般會發生以下兩種異常:
1.當執行緒中的計算所需要的棧超過所允許大小時,會丟擲StackOverflowError。
2.當Java棧試圖擴充套件時,沒有足夠的儲存器來實現擴充套件,JVM會報OutOfMemoryError。
另外棧上有一點得注意的是,對於原生程式碼呼叫,可能會在棧中申請記憶體,比如C呼叫malloc(),而這種情況下,GC是管不著的,需要我們在程式中,手動管理棧記憶體,使用free()方法釋放記憶體。
2.堆的管理
上圖是 Heap和PermanentSapce的組合圖,其中 Eden區裡面存著是新生的物件,From Space和To Space中存放著是每次垃圾回收後存活下來的物件,所以每次垃圾回收後,Eden區會被清空。 存活下來的物件先是放到From Space,當From Space滿了之後移動到To Space。當To Space滿了之後移動到Old Space。Survivor的兩個區是對稱的,沒先後關係,所以同一個區中可能同時存在從Eden複製過來 物件,和從前一個Survivor複製過來的物件,而複製到年老區的只有從第一個Survivor複製過來的物件。而且,Survivor區總有一個是空的。同時,根據程式需要,jvm提供對Survivor區複製次數的配置(-XX:MaxTenuringThreshold引數),即經過多少次複製後仍然存活的物件會被放到老年區,透過增多兩個Survivor區複製的次數可以增加物件在年輕代中的存在時間,減少被放到年老代的可能。
Old Space中則存放生命週期比較長的物件,而且有些比較大的新生物件也放在Old Space中,透過-XX:PretenureSizeThreshold設定,超過此大小的新生物件會直接放入老年區。
堆的大小透過-Xms和-Xmx來指定最小值和最大值,透過-Xmn來指定Young Generation的大小(一些老版本也用-XX:NewSize指定), 即上圖中的Eden加FromSpace和ToSpace的總大小。然後透過-XX:NewRatio來指定Eden區的大小,在Xms和Xmx相等的情況下,該引數不需要設定。透過-XX:SurvivorRatio來設定Eden和一個Survivor區的比值。
堆異常分為兩種,一種是Out ofMemory(OOM),一種是Memory Leak(ML)。MemoryLeak最終將導致OOM。實際應用中表現為:從Console看,記憶體監控曲線一直在頂部,程式響應慢,從執行緒看,大部分的執行緒在進行GC,佔用比較多的CPU,最終程式異常終止,報OOM。OOM發生的時間不定,有短的一個小時,有長的10天一個月的。關於異常的處理,確定OOM/ML異常後,一定要注意保護現場,可以dump heap,如果沒有現場則開啟GCFlag收集垃圾回收日誌,然後進行分析,確定問題所在。如果問題不是ML的話,一般透過增加Heap,增加實體記憶體來解決問題,是的話,就修改程式邏輯。
3.垃圾回收
JVM中會在以下情況觸發回收:物件沒有被引用,作用域發生未捕捉異常,程式正常執行完畢,程式執行了System.exit(),程式發生意外終止。
JVM中標記垃圾使用的演算法是一種根搜尋演算法。簡單的說,就是從一個叫GC Roots的物件開始,向下搜尋,如果一個物件不能達到GC Roots物件的時候,說明它可以被回收了。這種演算法比一種叫做引用計數法的垃圾標記演算法要好,因為它避免了當兩個物件啊互相引用時無法被回收的現象。
JVM中對於被標記為垃圾的物件進行回收時又分為了一下3種演算法:
1.標記清除演算法,該演算法是從根集合掃描整個空間,標記存活的物件,然後在掃描整個空間對沒有被標記的物件進行回收,這種演算法在存活物件較多時比較高效,但會產生記憶體碎片。
2.複製演算法,該演算法是從根集合掃描,並將存活的物件複製到新的空間,這種演算法在存活物件少時比較高效。
3.標記整理演算法,標記整理演算法和標記清除演算法一樣都會掃描並標記存活物件,在回收未標記物件的同時會整理被標記的物件,解決了記憶體碎片的問題。
JVM中,不同的 記憶體區域作用和性質不一樣,使用的垃圾回收演算法也不一樣,所以JVM中又定義了幾種不同的垃圾回收器(圖中連線代表兩個回收器可以同時使用):
1.Serial GC。從名字上看,序列GC意味著是一種單執行緒的,所以它要求收集的時候所有的執行緒暫停。這對於高效能的應用是不合理的,所以序列GC一般用於Client模式的JVM中。
2.ParNew GC。是在SerialGC的基礎上,增加了多執行緒機制。但是如果機器是單CPU的,這種收集器是比SerialGC效率低的。
3.Parrallel ScavengeGC。這種收集器又叫吞吐量優先收集器,而吞吐量=程式執行時間/(JVM執行回收的時間+程式執行時間),假設程式執行了100分鐘,JVM的垃圾回收佔用1分鐘,那麼吞吐量就是99%。ParallelScavenge GC由於可以提供比較不錯的吞吐量,所以被作為了server模式JVM的預設配置。
4.ParallelOld是老生代並行收集器的一種,使用了標記整理演算法,是JDK1.6中引進的,在之前老生代只能使用序列回收收集器。
5.Serial Old是老生代client模式下的預設收集器,單執行緒執行,同時也作為CMS收集器失敗後的備用收集器。
6.CMS又稱響應時間優先回收器,使用標記清除演算法。他的回收執行緒數為(CPU核心數+3)/4,所以當CPU核心數為2時比較高效些。CMS分為4個過程:初始標記、併發標記、重新標記、併發清除。
7.GarbageFirst(G1)。比較特殊的是G1回收器既可以回收Young Generation,也可以回收Tenured Generation。它是在JDK6的某個版本中才引入的,效能比較高,同時注意了吞吐量和響應時間。
對於垃圾收集器的組合使用可以透過下表中的引數指定:
預設的GC種類可以透過jvm.cfg或者透過jmap dump出heap來檢視,一般我們透過jstat -gcutil [pid] 1000可以檢視每秒gc的大體情況,或者可以在啟動引數中加入:-verbose:gc-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:./gc.log來記錄GC日誌。
GC中有一種情況叫做Full GC,以下幾種情況會觸發Full GC:
1.Tenured Space空間不足以建立打的物件或者陣列,會執行FullGC,並且當FullGC之後空間如果還不夠,那麼會OOM:java heap space。
2.Permanet Generation的大小不足,存放了太多的類資訊,在非CMS情況下回觸發FullGC。如果之後空間還不夠,會OOM:PermGen space。
3.CMS GC時出現promotion failed和concurrent mode failure時,也會觸發FullGC。promotion failed是在進行Minor GC時,survivor space放不下、物件只能放入舊生代,而此時舊生代也放不下造成的;concurrentmode failure是在執行CMS GC的過程中同時有物件要放入舊生代,而此時舊生代空間不足造成的,因為CMS是併發執行的,執行GC的過程中可能也會有物件被放入舊生代。
4.判斷MinorGC後,要晉升到TenuredSpace的物件大小大於TenuredSpace的大小,也會觸發FullGC。
可以看出,當FullGC頻繁發生時,一定是記憶體出問題了。
三、JVM的資料格式規範和Class檔案
1.資料型別規範
依據馮諾依曼的計算機理論,計算機最後處理的都是二進位制的數,而JVM是怎麼把java檔案最後轉化成了各個平臺都可以識別的二進位制呢?JVM自己定義了一個抽象的儲存資料單位,叫做Word。一個字足夠大以持有byte、char、short、int、float、reference或者returnAdress的一個值,兩個字則足夠持有更大的型別long、double。它通常是主機平臺一個指標的大小,如32位的平臺上,字是32位。
同時JVM中定義了它所支援的基本資料型別,包括兩部分:數值型別和returnAddress型別。數值型別分為整形和浮點型。
returnAddress型別的值是Java虛擬機器指令的操作碼的指標。
對比java的基本資料型別,jvm的規範中沒有boolean型別。這是因為jvm中堆boolean的操作是透過int型別來進行處理的,而boolean陣列則是透過byte陣列來進行處理。
至於String,我們知道它儲存在常量池中,但他不是基本資料型別,之所以可以存在常量池中,是因為這是JVM的一種規定。如果檢視String原始碼,我們就會發現,String其實就是一個基於基本資料型別char的陣列。
2.位元組碼檔案
透過位元組碼檔案的格式我們可以看出jvm是如何規範資料型別的。下面是ClassFile的結構:
其中u1、u2、u4分別代表1、2、4個位元組無符號數。
magic:
魔數,魔數的唯一作用是確定這個檔案是否為一個能被虛擬機器所接受的Class檔案。魔數值固定為0xCAFEBABE,不會改變。
minor_version、major_version:
分別為Class檔案的副版本和主版本。它們共同構成了Class檔案的格式版本號。不同版本的虛擬機器實現支援的Class檔案版本號也相應不同,高版本號的虛擬機器可以支援低版本的Class檔案,反之則不成立。
constant_pool_count:
常量池計數器,constant_pool_count的值等於constant_pool表中的成員數加1。
constant_pool[]:
常量池,constant_pool是一種表結構,它包含Class檔案結構及其子結構中引用的所有字串常量、類或介面名、欄位名和其它常量。常量池不同於其他,索引從1開始到constant_pool_count -1。
access_flags:
訪問標誌,access_flags是一種掩碼標誌,用於表示某個類或者介面的訪問許可權及基礎屬性。access_flags的取值範圍和相應含義見下表:
this_class:
類索引,this_class的值必須是對constant_pool表中專案的一個有效索引值。constant_pool表在這個索引處的項必須為CONSTANT_Class_info型別常量,表示這個Class檔案所定義的類或介面。
super_class:
父類索引,對於類來說,super_class的值必須為0或者是對constant_pool表中專案的一個有效索引值。如果它的值不為0,那constant_pool表在這個索引處的項必須為CONSTANT_Class_info型別常量,表示這個Class檔案所定義的類的直接父類。當然,如果某個類super_class的值是0,那麼它必定是java.lang.Object類,因為只有它是沒有父類的。
interfaces_count:
介面計數器,interfaces_count的值表示當前類或介面的直接父介面數量。
interfaces[]:
介面表,interfaces[]陣列中的每個成員的值必須是一個對constant_pool表中專案的一個有效索引值,它的長度為interfaces_count。每個成員interfaces[i] 必須為CONSTANT_Class_info型別常量。
fields_count:
欄位計數器,fields_count的值表示當前Class檔案fields[]陣列的成員個數。
fields[]:
欄位表,fields[]陣列中的每個成員都必須是一個fields_info結構的資料項,用於表示當前類或介面中某個欄位的完整描述。
methods_count:
方法計數器,methods_count的值表示當前Class檔案methods[]陣列的成員個數。
methods[]:
方法表,methods[]陣列中的每個成員都必須是一個method_info結構的資料項,用於表示當前類或介面中某個方法的完整描述。
attributes_count:
屬性計數器,attributes_count的值表示當前Class檔案attributes表的成員個數。
attributes[]:
屬性表,attributes表的每個項的值必須是attribute_info結構。
3.jvm指令集
在Java虛擬機器的指令集中,大多數的指令都包含了其操作所對應的資料型別資訊。舉個例子,iload指令用於從區域性變數表中載入int型的資料到運算元棧中,而fload指令載入的則是float型別的資料。這兩條指令的操作可能會是由同一段程式碼來實現的,但它們必須擁有各自獨立的運算子。
對於大部分為與資料型別相關的位元組碼指令,他們的操作碼助記符中都有特殊的字元來表明專門為哪種資料型別服務:i代表對int型別的資料操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助記符中沒有明確的指明操作型別的字母,例如arraylength指令,它沒有代表資料型別的特殊字元,但運算元永遠只能是一個陣列型別的物件。還有另外一些指令,例如無條件跳轉指令goto則是與資料型別無關的。
由於Java虛擬機器的操作碼長度只有一個位元組,所以包含了資料型別的操作碼對指令集的設計帶來了很大的壓力(只有256個指令):如果每一種與資料型別相關的指令都支援Java虛擬機器所有執行時資料型別的話,那恐怕就會超出一個位元組所能表示的數量範圍了。因此,Java虛擬機器的指令集對於特定的操作只提供了有限的型別相關指令去支援它,換句話說,指令集將會故意被設計成非完全獨立的(Not Orthogonal,即並非每種資料型別和每一種操作都有對應的指令)。有一些單獨的指令可以在必要的時候用來將一些不支援的型別轉換為可被支援的型別。
透過查閱jvm指令集和其對應的資料型別的關係發現,大部分的指令都沒有支援整數型別byte、char和short,甚至沒有任何指令支援boolean型別。編譯器會在編譯期或執行期會將byte和short型別的資料帶符號擴充套件(Sign-Extend)為相應的int型別資料,將boolean和char型別資料零位擴充套件(Zero-Extend)為相應的int型別資料。與之類似的,在處理boolean、byte、short和char型別的陣列時,也會轉換為使用對應的int型別的位元組碼指令來處理。因此,大多數對於boolean、byte、short和char型別資料的操作,實際上都是使用相應的對int型別作為運算型別(Computational Type)。
四、一個java類的例項分析
為了瞭解JVM的資料型別規範和記憶體分配的大體情況,下面舉個簡單的例子來說明一下ClassFile的結構:
透過javap工具我們能看到這個簡單的類的結構,如下:
我們可以看到一些資訊包括主副版本號、常量池、ACC_FLAGS等,再來開啟Class檔案看一下:
根據前面所述的ClassFile結構,我們來分析下:
可以看到前4個位元組為魔數,也就是0xCAFEBABE,這裡都是十六進位制。
魔數後2個位元組為副版本號,這裡副版本號是0.
再後2個位元組是主版本號0x0033,轉為十進位制,主版本號是51,和Javap工具所看到的一樣,這裡我用的JDK版本是1.7。
這兩個位元組是常量池計數器,常量池的數量為0x0017,轉為十進位制是23,也就是說常量池的索引為1~22,這與Javap所看到的也相符。
常量池計數器後面就是常量池的內容,我們根據javap所看到的資訊找到最後一個常量池項java/lang/Object,在位元組碼中找到對應的地方:
常量池後面兩個位元組是訪問標誌access_flags:
值為0x0021,在javap中我們看到這個類的標誌是
其中ACC_PUBLIC的值為0x0001,ACC_SUPER的值為0x0020,與位元組碼是相匹配的。
至於ClassFile的其他結構,包括this_class、super_class、介面計數器、介面等等都可以透過同樣的方法進行分析,這裡就不再多說了。
五、關於jvm最佳化
不管是YGC還是Full GC,GC過程中都會對導致程式執行中中斷,正確的選擇,調整JVM、GC的引數,可以極大的減少由於GC工作,而導致的程式執行中斷方面的問題,進而適當的提高Java程式的工作效率。但是調整GC是以個極為複雜的過程,由於各個程式具備不同的特點,如:web和GUI程式就有很大區別(Web可以適當的停頓,但GUI停頓是客戶無法接受的),而且由於跑在各個機器上的配置不同(主要cup個數,記憶體不同),所以使用的GC種類也會不同。
1. gc策略
現在比較常用的是分代收集(generational collection,也是SUN VM使用的,J2SE1.2之後引入),即將記憶體分為幾個區域,將不同生命週期的物件放在不同區域裡:younggeneration,tenured generation和permanet generation。絕大部分的objec被分配在young generation(生命週期短),並且大部分的object在這裡die。當younggeneration滿了之後,將引發minor collection(YGC)。在minor collection後存活的object會被移動到tenured generation(生命週期比較長)。最後,tenured generation滿之後觸發major collection。major collection(Full gc)會觸發整個heap的回收,包括回收young generation。permanet generation區域比較穩定,主要存放classloader資訊。
young generation有eden、2個survivor 區域組成。其中一個survivor區域一直是空的,是eden區域和另一個survivor區域在下一次copy collection後活著的objecy的目的地。object在survivo區域被複制直到轉移到tenured區。
我們要儘量減少 Full gc 的次數(tenuredgeneration 一般比較大,收集的時間較長,頻繁的Full gc會導致應用的效能收到嚴重的影響)。
JVM(採用分代回收的策略),用較高的頻率對年輕的物件(young generation)進行YGC,而對老物件(tenured generation)較少(tenured generation 滿了後才進行)進行Full GC。這樣就不需要每次GC都將記憶體中所有物件都檢查一遍。
GC不會在主程式執行期對PermGen Space進行清理,所以如果你的應用中有很多CLASS(特別是動態生成類,當然permgen space存放的內容不僅限於類)的話,就很可能出現PermGen Space錯誤。
2. 記憶體申請過程
1.JVM會試圖為相關Java物件在Eden中初始化一塊記憶體區域;
2.當Eden空間足夠時,記憶體申請結束。否則到下一步;
3.JVM試圖釋放在Eden中所有不活躍的物件(minor collection),釋放後若Eden空間4.仍然不足以放入新物件,則試圖將部分Eden中活躍物件放入Survivor區;
5.Survivor區被用來作為Eden及old的中間交換區域,當OLD區空間足夠時,Survivor區的物件會被移到Old區,否則會被保留在Survivor區;
6.當old區空間不夠時,JVM會在old區進行major collection;
7.完全垃圾收集後,若Survivor及old區仍然無法存放從Eden複製過來的部分物件,導致JVM無法在Eden區為新物件建立記憶體區域,則出現"Out of memory錯誤";
3.效能考慮
對於GC的效能主要有2個方面的指標:吞吐量throughput(工作時間不算gc的時間佔總的時間比)和暫停pause(gc發生時app對外顯示的無法響應)。
1.Total Heap
預設情況下,vm會增加/減少heap大小以維持free space在整個vm中佔的比例,這個比例由MinHeapFreeRatio和MaxHeapFreeRatio指定。
一般而言,server端的app會有以下規則:
對vm分配儘可能多的memory;
將Xms和Xmx設為一樣的值。如果虛擬機器啟動時設定使用的記憶體比較小,這個時候又需要初始化很多物件,虛擬機器就必須重複地增加記憶體。
處理器核數增加,記憶體也跟著增大。
2.The Young Generation
另外一個對於app流暢性執行影響的因素是younggeneration的大小。young generation越大,minor collection越少;但是在固定heap size情況下,更大的young generation就意味著小的tenured generation,就意味著更多的major collection(major collection會引發minorcollection)。
NewRatio反映的是young和tenuredgeneration的大小比例。NewSize和MaxNewSize反映的是young generation大小的下限和上限,將這兩個值設為一樣就固定了younggeneration的大小(同Xms和Xmx設為一樣)。
如果希望,SurvivorRatio也可以最佳化survivor的大小,不過這對於效能的影響不是很大。SurvivorRatio是eden和survior大小比例。
一般而言,server端的app會有以下規則:
首先決定能分配給vm的最大的heap size,然後設定最佳的young generation的大小;
如果heap size固定後,增加young generation的大小意味著減小tenured generation大小。讓tenured generation在任何時候夠大,能夠容納所有live的data(留10%-20%的空餘)。
4.經驗總結
1.年輕代大小選擇
響應時間優先的應用:儘可能設大,直到接近系統的最低響應時間限制(根據實際情況選擇).在此種情況下,年輕代收集發生的頻率也是最小的.同時,減少到達年老代的物件.
吞吐量優先的應用:儘可能的設定大,可能到達Gbit的程度.因為對響應時間沒有要求,垃圾收集可以並行進行,一般適合8CPU以上的應用.
避免設定過小.當新生代設定過小時會導致:1.YGC次數更加頻繁 2.可能導致YGC物件直接進入舊生代,如果此時舊生代滿了,會觸發FGC.
2.年老代大小選擇
響應時間優先的應用:年老代使用併發收集器,所以其大小需要小心設定,一般要考慮併發會話率和會話持續時間等一些引數.如果堆設定小了,可以會造成記憶體碎片,高回收頻率以及應用暫停而使用傳統的標記清除方式;如果堆大了,則需要較長的收集時間.最最佳化的方案,一般需要參考以下資料獲得:
a.併發垃圾收集資訊、持久代併發收集次數、傳統GC資訊、花在年輕代和年老代回收上的時間比例。
b.吞吐量優先的應用:一般吞吐量優先的應用都有一個很大的年輕代和一個較小的年老代.原因是,這樣可以儘可能回收掉大部分短期物件,減少中期的物件,而年老代盡存放長期存活物件.
3.較小堆引起的碎片問題
因為年老代的併發收集器使用標記,清除演算法,所以不會對堆進行壓縮.當收集器回收時,他會把相鄰的空間進行合併,這樣可以分配給較大的物件.但是,當堆空間較小時,執行一段時間以後,就會出現"碎片",如果併發收集器找不到足夠的空間,那麼併發收集器將會停止,然後使用傳統的標記,清除方式進行回收.如果出現"碎片",可能需要進行如下配置:
-XX:+UseCMSCompactAtFullCollection:使用併發收集器時,開啟對年老代的壓縮.
-XX:CMSFullGCsBeforeCompaction=0:上面配置開啟的情況下,這裡設定多少次Full GC後,對年老代進行壓縮
4.用64位作業系統,Linux下64位的jdk比32位jdk要慢一些,但是吃得記憶體更多,吞吐量更大
5.XMX和XMS設定一樣大,MaxPermSize和MinPermSize設定一樣大,這樣可以減輕伸縮堆大小帶來的壓力
6.使用CMS的好處是用盡量少的新生代,經驗值是128M-256M, 然後老生代利用CMS並行收集, 這樣能保證系統低延遲的吞吐效率。實際上cms的收集停頓時間非常的短,2G的記憶體, 大約20-80ms的應用程式停頓時間
7.系統停頓的時候可能是GC的問題也可能是程式的問題,多用jmap和jstack檢視,或者killall -3 java,然後檢視java控制檯日誌,能看出很多問題。(相關工具的使用方法將在後面的blog中介紹)
8.仔細瞭解自己的應用,如果用了快取,那麼年老代應該大一些,快取的HashMap不應該無限制長,建議採用LRU演算法的Map做快取,LRUMap的最大長度也要根據實際情況設定。
9.採用併發回收時,年輕代小一點,年老代要大,因為年老大用的是併發回收,即使時間長點也不會影響其他程式繼續執行,網站不會停頓
10.-Xnoclassgc禁用類垃圾回收,效能會高一點;
11.-XX:+DisableExplicitGC禁止System.gc(),免得程式設計師誤呼叫gc方法影響效能
12.JVM引數的設定(特別是 –Xmx –Xms –Xmn -XX:SurvivorRatio -XX:MaxTenuringThreshold等引數的設定沒有一個固定的公式,需要根據PV old區實際資料 YGC次數等多方面來衡量。為了避免promotion faild可能會導致xmn設定偏小,也意味著YGC的次數會增多,處理併發訪問的能力下降等問題。每個引數的調整都需要經過詳細的效能測試,才能找到特定應用的最佳配置。
5.promotion failed:
垃圾回收時promotion failed是個很頭痛的問題,一般可能是兩種原因產生,第一個原因是救助空間不夠,救助空間裡的物件還不應該被移動到年老代,但年輕代又有很多物件需要放入救助空間;第二個原因是年老代沒有足夠的空間接納來自年輕代的物件;這兩種情況都會轉向Full GC,網站停頓時間較長。
解決方方案一:
第一個原因我的最終解決辦法是去掉救助空間,設定-XX:SurvivorRatio=65536 -XX:MaxTenuringThreshold=0即可,第二個原因我的解決辦法是設定CMSInitiatingOccupancyFraction為某個值(假設70),這樣年老代空間到70%時就開始執行CMS,年老代有足夠的空間接納來自年輕代的物件。
解決方案一的改進方案:
又有改進了,上面方法不太好,因為沒有用到救助空間,所以年老代容易滿,CMS執行會比較頻繁。我改善了一下,還是用救助空間,但是把救助空間加大,這樣也不會有promotionfailed。具體操作上,32位Linux和64位Linux好像不一樣,64位系統似乎只要配置MaxTenuringThreshold引數,CMS還是有暫停。為了解決暫停問題和promotion failed問題,最後我設定-XX:SurvivorRatio=1 ,並把MaxTenuringThreshold去掉,這樣即沒有暫停又不會有promotoinfailed,而且更重要的是,年老代和永久代上升非常慢(因為好多物件到不了年老代就被回收了),所以CMS執行頻率非常低,好幾個小時才執行一次,這樣,伺服器都不用重啟了。
-Xmx4000M-Xms4000M -Xmn600M -XX:PermSize=500M -XX:MaxPermSize=500M -Xss256K-XX:+DisableExplicitGC -XX:SurvivorRatio=1 -XX:+UseConcMarkSweepGC-XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0-XX:+CMSClassUnloadingEnabled -XX:LargePageSizeInBytes=128M-XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=80 -XX:SoftRefLRUPolicyMSPerMB=0-XX:+PrintClassHistogram -XX:+PrintGCDetails -XX:+PrintGCTimeStamps-XX:+PrintHeapAtGC -Xloggc:log/gc.log
6.CMSInitiatingOccupancyFraction值與Xmn的關係公式
上面介紹了promontion faild產生的原因是EDEN空間不足的情況下將EDEN與From survivor中的存活物件存入To survivor區時,To survivor區的空間不足,再次晉升到old gen區,而old gen區記憶體也不夠的情況下產生了promontion faild從而導致full gc.那可以推斷出:eden+from survivor (Xmx-Xmn)*(1-CMSInitiatingOccupancyFraction/100)>=(Xmn-Xmn/(SurvivorRatior+2)) 進而推斷出:
CMSInitiatingOccupancyFraction
例如:
當xmx=128 xmn=36 SurvivorRatior=1時CMSInitiatingOccupancyFraction
當xmx=128 xmn=24 SurvivorRatior=1時CMSInitiatingOccupancyFraction
當xmx=3000 xmn=600 SurvivorRatior=1時 CMSInitiatingOccupancyFraction
CMSInitiatingOccupancyFraction低於70% 需要調整xmn或SurvivorRatior值。
對此,網上牛人們得出的公式是是:(Xmx-Xmn)*(100-CMSInitiatingOccupancyFraction)/100>=Xmn。
jvm的調優
一、JVM記憶體模型及垃圾收集演算法
1.根據Java虛擬機器規範,JVM將記憶體劃分為:
New(年輕代)
Tenured(年老代)
永久代(Perm)
其中New和Tenured屬於堆記憶體,堆記憶體會從JVM啟動引數(-Xmx:3G)指定的記憶體中分配,Perm不屬於堆記憶體,有虛擬機器直接分配,但可以透過-XX:PermSize -XX:MaxPermSize 等引數調整其大小。
年輕代(New):年輕代用來存放JVM剛分配的Java物件
年老代(Tenured):年輕代中經過垃圾回收沒有回收掉的物件將被Copy到年老代
永久代(Perm):永久代存放Class、Method元資訊,其大小跟專案的規模、類、方法的量有關,一般設定為128M就足夠,設定原則是預留30%的空間。
New又分為幾個部分:
Eden:Eden用來存放JVM剛分配的物件
Survivor1
Survivro2:兩個Survivor空間一樣大,當Eden中的物件經過垃圾回收沒有被回收掉時,會在兩個Survivor之間來回Copy,當滿足某個條件,比如Copy次數,就會被Copy到Tenured。顯然,Survivor只是增加了物件在年輕代中的逗留時間,增加了被垃圾回收的可能性。
2.垃圾回收演算法
垃圾回收演算法可以分為三類,都基於標記-清除(複製)演算法:
Serial演算法(單執行緒)
並行演算法
併發演算法
JVM會根據機器的硬體配置對每個記憶體代選擇適合的回收演算法,比如,如果機器多於1個核,會對年輕代選擇並行演算法,關於選擇細節請參考JVM調優文件。
稍微解釋下的是,並行演算法是用多執行緒進行垃圾回收,回收期間會暫停程式的執行,而併發演算法,也是多執行緒回收,但期間不停止應用執行。所以,併發演算法適用於互動性高的一些程式。經過觀察,併發演算法會減少年輕代的大小,其實就是使用了一個大的年老代,這反過來跟並行演算法相比吞吐量相對較低。
還有一個問題是,垃圾回收動作何時執行?
當年輕代記憶體滿時,會引發一次普通GC,該GC僅回收年輕代。需要強調的時,年輕代滿是指Eden代滿,Survivor滿不會引發GC
當年老代滿時會引發Full GC,Full GC將會同時回收年輕代、年老代
當永久代滿時也會引發Full GC,會導致Class、Method元資訊的解除安裝
另一個問題是,何時會丟擲OutOfMemoryException,並不是記憶體被耗空的時候才丟擲
JVM98%的時間都花費在記憶體回收
每次回收的記憶體小於2%
滿足這兩個條件將觸發OutOfMemoryException,這將會留給系統一個微小的間隙以做一些Down之前的操作,比如手動列印Heap Dump。
二、記憶體洩漏及解決方法
1.系統崩潰前的一些現象:
每次垃圾回收的時間越來越長,由之前的10ms延長到50ms左右,FullGC的時間也有之前的0.5s延長到4、5s
FullGC的次數越來越多,最頻繁時隔不到1分鐘就進行一次FullGC
年老代的記憶體越來越大並且每次FullGC後年老代沒有記憶體被釋放
之後系統會無法響應新的請求,逐漸到達OutOfMemoryError的臨界值。
2.生成堆的dump檔案
透過JMX的MBean生成當前的Heap資訊,大小為一個3G(整個堆的大小)的hprof檔案,如果沒有啟動JMX可以透過Java的jmap命令來生成該檔案。
3.分析dump檔案
下面要考慮的是如何開啟這個3G的堆資訊檔案,顯然一般的Window系統沒有這麼大的記憶體,必須藉助高配置的Linux。當然我們可以藉助X-Window把Linux上的圖形匯入到Window。我們考慮用下面幾種工具開啟該檔案:
Visual VM
IBM HeapAnalyzer
JDK 自帶的Hprof工具
使用這些工具時為了確保載入速度,建議設定最大記憶體為6G。使用後發現,這些工具都無法直觀地觀察到記憶體洩漏,Visual VM雖能觀察到物件大小,但看不到呼叫堆疊;HeapAnalyzer雖然能看到呼叫堆疊,卻無法正確開啟一個3G的檔案。因此,我們又選用了Eclipse專門的靜態記憶體分析工具:Mat。
4.分析記憶體洩漏
透過Mat我們能清楚地看到,哪些物件被懷疑為記憶體洩漏,哪些物件佔的空間最大及物件的呼叫關係。針對本案,在ThreadLocal中有很多的JbpmContext例項,經過調查是JBPM的Context沒有關閉所致。
另,透過Mat或JMX我們還可以分析執行緒狀態,可以觀察到執行緒被阻塞在哪個物件上,從而判斷系統的瓶頸。
5.迴歸問題
Q:為什麼崩潰前垃圾回收的時間越來越長?
A:根據記憶體模型和垃圾回收演算法,垃圾回收分兩部分:記憶體標記、清除(複製),標記部分只要記憶體大小固定時間是不變的,變的是複製部分,因為每次垃圾回收都有一些回收不掉的記憶體,所以增加了複製量,導致時間延長。所以,垃圾回收的時間也可以作為判斷記憶體洩漏的依據
Q:為什麼Full GC的次數越來越多?
A:因此記憶體的積累,逐漸耗盡了年老代的記憶體,導致新物件分配沒有更多的空間,從而導致頻繁的垃圾回收
Q:為什麼年老代佔用的記憶體越來越大?
A:因為年輕代的記憶體無法被回收,越來越多地被Copy到年老代
三、效能調優
除了上述記憶體洩漏外,我們還發現CPU長期不足3%,系統吞吐量不夠,針對8core×16G、64bit的Linux伺服器來說,是嚴重的資源浪費。
在CPU負載不足的同時,偶爾會有使用者反映請求的時間過長,我們意識到必須對程式及JVM進行調優。從以下幾個方面進行:
執行緒池:解決使用者響應時間長的問題
連線池
JVM啟動引數:調整各代的記憶體比例和垃圾回收演算法,提高吞吐量
程式演算法:改程式序邏輯演算法提高效能
1.Java執行緒池(java.util.concurrent.ThreadPoolExecutor)
大多數JVM6上的應用採用的執行緒池都是JDK自帶的執行緒池,之所以把成熟的Java執行緒池進行羅嗦說明,是因為該執行緒池的行為與我們想象的有點出入。Java執行緒池有幾個重要的配置引數:
corePoolSize:核心執行緒數(最新執行緒數)
maximumPoolSize:最大執行緒數,超過這個數量的任務會被拒絕,使用者可以透過RejectedExecutionHandler介面自定義處理方式
keepAliveTime:執行緒保持活動的時間
workQueue:工作佇列,存放執行的任務
Java執行緒池需要傳入一個Queue引數(workQueue)用來存放執行的任務,而對Queue的不同選擇,執行緒池有完全不同的行為:
SynchronousQueue:
一個無容量的等待佇列,一個執行緒的insert操作必須等待另一執行緒的remove操作,採用這個Queue執行緒池將會為每個任務分配一個新執行緒
LinkedBlockingQueue :
無界佇列,採用該Queue,執行緒池將忽略
maximumPoolSize引數,僅用corePoolSize的執行緒處理所有的任務,未處理的任務便在LinkedBlockingQueue中排隊
ArrayBlockingQueue: 有界佇列,在有界佇列和
maximumPoolSize的作用下,程式將很難被調優:更大的Queue和小的maximumPoolSize將導致CPU的低負載;小的Queue和大的池,Queue就沒起動應有的作用。
其實我們的要求很簡單,希望執行緒池能跟連線池一樣,能設定最小執行緒數、最大執行緒數,當最小數最大數時,應該等待有空閒執行緒再處理該任務。
但執行緒池的設計思路是,任務應該放到Queue中,當Queue放不下時再考慮用新執行緒處理,如果Queue滿且無法派生新執行緒,就拒絕該任務。設計導致“先放等執行”、“放不下再執行”、“拒絕不等待”。所以,根據不同的Queue引數,要提高吞吐量不能一味地增大maximumPoolSize。
當然,要達到我們的目標,必須對執行緒池進行一定的封裝,幸運的是ThreadPoolExecutor中留了足夠的自定義介面以幫助我們達到目標。我們封裝的方式是:
以SynchronousQueue作為引數,使maximumPoolSize發揮作用,以防止執行緒被無限制的分配,同時可以透過提高maximumPoolSize來提高系統吞吐量
自定義一個RejectedExecutionHandler,當執行緒數超過maximumPoolSize時進行處理,處理方式為隔一段時間檢查執行緒池是否可以執行新Task,如果可以把拒絕的Task重新放入到執行緒池,檢查的時間依賴keepAliveTime的大小。
2.連線池(org.apache.commons.dbcp.BasicDataSource)
在使用org.apache.commons.dbcp.BasicDataSource的時候,因為之前採用了預設配置,所以當訪問量大時,透過JMX觀察到很多Tomcat執行緒都阻塞在BasicDataSource使用的Apache ObjectPool的鎖上,直接原因當時是因為BasicDataSource連線池的最大連線數設定的太小,預設的BasicDataSource配置,僅使用8個最大連線。
我還觀察到一個問題,當較長的時間不訪問系統,比如2天,DB上的Mysql會斷掉所以的連線,導致連線池中快取的連線不能用。為了解決這些問題,我們充分研究了BasicDataSource,發現了一些最佳化的點:
Mysql預設支援100個連結,所以每個連線池的配置要根據集中的機器數進行,如有2臺伺服器,可每個設定為60
initialSize:引數是一直開啟的連線數
minEvictableIdleTimeMillis:該引數設定每個連線的空閒時間,超過這個時間連線將被關閉
timeBetweenEvictionRunsMillis:後臺執行緒的執行週期,用來檢測過期連線
maxActive:最大能分配的連線數
maxIdle:最大空閒數,當連線使用完畢後發現連線數大於maxIdle,連線將被直接關閉。只有initialSize
initialSize是如何保持的?經過研究程式碼發現,BasicDataSource會關閉所有超期的連線,然後再開啟initialSize數量的連線,這個特性與minEvictableIdleTimeMillis、timeBetweenEvictionRunsMillis一起保證了所有超期的initialSize連線都會被重新連線,從而避免了Mysql長時間無動作會斷掉連線的問題。
3.JVM引數
在JVM啟動引數中,可以設定跟記憶體、垃圾回收相關的一些引數設定,預設情況不做任何設定JVM會工作的很好,但對一些配置很好的Server和具體的應用必須仔細調優才能獲得最佳效能。透過設定我們希望達到一些目標:
GC的時間足夠的小
GC的次數足夠的少
發生Full GC的週期足夠的長
前兩個目前是相悖的,要想GC時間小必須要一個更小的堆,要保證GC次數足夠少,必須保證一個更大的堆,我們只能取其平衡。
(1)針對JVM堆的設定一般,可以透過-Xms -Xmx限定其最小、最大值,為了防止垃圾收集器在最小、最大之間收縮堆而產生額外的時間,我們通常把最大、最小設定為相同的值
(2)年輕代和年老代將根據預設的比例(1:2)分配堆記憶體,可以透過調整二者之間的比率NewRadio來調整二者之間的大小,也可以針對回收代,比如年輕代,透過 -XX:newSize -XX:MaxNewSize來設定其絕對大小。同樣,為了防止年輕代的堆收縮,我們通常會把-XX:newSize -XX:MaxNewSize設定為同樣大小
(3)年輕代和年老代設定多大才算合理?這個我問題毫無疑問是沒有答案的,否則也就不會有調優。我們觀察一下二者大小變化有哪些影響
更大的年輕代必然導致更小的年老代,大的年輕代會延長普通GC的週期,但會增加每次GC的時間;小的年老代會導致更頻繁的Full GC
更小的年輕代必然導致更大年老代,小的年輕代會導致普通GC很頻繁,但每次的GC時間會更短;大的年老代會減少Full GC的頻率
如何選擇應該依賴應用程式物件生命週期的分佈情況:如果應用存在大量的臨時物件,應該選擇更大的年輕代;如果存在相對較多的持久物件,年老代應該適當增大。但很多應用都沒有這樣明顯的特性,在抉擇時應該根據以下兩點:(A)本著Full GC儘量少的原則,讓年老代儘量快取常用物件,JVM的預設比例1:2也是這個道理 (B)透過觀察應用一段時間,看其他在峰值時年老代會佔多少記憶體,在不影響Full GC的前提下,根據實際情況加大年輕代,比如可以把比例控制在1:1。但應該給年老代至少預留1/3的增長空間
(4)在配置較好的機器上(比如多核、大記憶體),可以為年老代選擇並行收集演算法: -XX:+UseParallelOldGC ,預設為Serial收集
(5)執行緒堆疊的設定:每個執行緒預設會開啟1M的堆疊,用於存放棧幀、呼叫引數、區域性變數等,對大多數應用而言這個預設值太了,一般256K就足用。理論上,在記憶體不變的情況下,減少每個執行緒的堆疊,可以產生更多的執行緒,但這實際上還受限於作業系統。
(4)可以透過下面的引數打Heap Dump資訊
-XX:HeapDumpPath
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:/usr/aaa/dump/heap_trace.txt
透過下面引數可以控制OutOfMemoryError時列印堆的資訊
-XX:+HeapDumpOnOutOfMemoryError
請看一下一個時間的Java引數配置:(伺服器:Linux 64Bit,8Core×16G)
JAVA_OPTS="$JAVA_OPTS -server -Xms3G -Xmx3G -Xss256k -XX:PermSize=128m -XX:MaxPermSize=128m -XX:+UseParallelOldGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/aaa/dump -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/usr/aaa/dump/heap_trace.txt -XX:NewSize=1G -XX:MaxNewSize=1G"
經過觀察該配置非常穩定,每次普通GC的時間在10ms左右,Full GC基本不發生,或隔很長很長的時間才發生一次
jvm-垃圾回收
一:為什麼需要垃圾回收?
jvm把記憶體管理權從開發人員收回,開發人員只需要建立資料物件即可,記憶體的分配和回收都由jvm自動完成。
程式只管建立物件,不管物件的回收,記憶體最終會被耗盡。
二:怎麼判斷物件為垃圾?
如果要實現垃圾回收,首先必須能判斷哪些物件是垃圾。
物件不再被使用就認為是垃圾。jvm自動回收垃圾,但它如何才能知道一個物件是否不再被使用?
常見的策略有如下兩種:引用計數器 、可達性檢測。
2.1 引用計數器:
即如果一個物件被外部引用則計數器加 1, 反之減 1。如果計數器為0,則說明當前對外象沒有被任何外部使用,則認為是垃圾。
優點:實現簡單
缺點:無法解決迴圈引用的問題;如:物件A,B相互引用,除此再沒有被其它物件引用,那麼它們兩個都是垃圾,但計數器卻均為1,而無法回收。
注意事項:引用計數器只是一個理論方案,從來沒有一個主流的jvm使用這種方式
2.2 可達性檢測
引用計數器無法解決迴圈引用的問題,因此更好的辦法是透過可達性分析。jvm中的任何非垃圾物件透過引用鏈向上追溯,都可以到達一些根物件(法方區的靜態變數、常量、棧中的變數),這些根物件都是存活的物件,那麼被活物件引用的物件很有可能會繼續使用,因此反過來,從根物件向下追溯到的物件都可以認為是存活的物件。這種從根物件追溯的方法稱為可達性分析。
如下:從根物件向下追溯,紅色標記的物件是不可達的,因此它們就是垃圾,會被GC回收。
2.3 根物件種類
可以做為GC root(根物件)的物件有以下幾種:
虛擬機器棧(棧幀中變數引用的物件)
方法區中靜態屬性(static 屬性)
方法區中的常量(static final),(jdk8及以上,為後設資料區)
本地方法棧中引用的物件
三:垃圾回收演算法
標記出哪些物件是垃圾後,就需要對這些垃圾物件進行回收。
常用的回收演算法有:標記-清除、複製、標記-整理
3.1 標記-清除
透過標記、清除兩個階段回收垃圾物件。因為標記的是存活物件,清除的是非存活物件,所以需要兩個階段:先標記,再遍歷所有物件,過濾出非存活物件。
如下圖:(綠色-存活物件;紅色-垃圾;白色-空閒)
首先,透過可達性分析,標記出存活的物件(綠色塊)
其次,遍歷堆中所有物件,把非存活的物件全部清空。
優點:實現簡單,並且是其它演算法的基礎
缺點:A:標記效率不高,清除演算法也不高(遍歷所有物件進行清除).
B:產生大量記憶體碎片
3.2 複製演算法
為了解決標記-清除 演算法的效率問題,使用複製演算法。
複製演算法需要一塊同樣大小額外的記憶體做為中轉。
因為複製的是存活物件,不需再次遍歷。
步驟:透過可達性分析,標記出存活物件,並同時把存活物件複製到另一塊對等記憶體。
當所有存活物件都複製完後,直接清空原記憶體塊(不需要遍歷,直接移動堆頂指標即可)。
優點: 不需要兩階段,存活物件少時效率高。
沒有記憶體碎片
缺點:需要額片記憶體,同一時間總有一塊記憶體處於備用狀態-浪費記憶體。
存活物件很多時效率也不高(主要是因為物件複製代價高昂)
使用場景:存活物件多,記憶體緊張的場景。
複製演算法變種:
複製演算法最大的缺點是需要一個相同大小的記憶體塊,為了減少記憶體浪費,複製演算法還有一種變種。
如果物件中存活的很少,就不需要一個相同大小的額外記憶體塊,而只需要兩個小記憶體塊,交替做為中轉站就可以完美解決。
前提:存活的物件很少,IBM研究表明新生代90%以上甚至98%的物件朝生夕死。
步驟:
A:設定三塊記憶體,第一塊大記憶體塊,第二第三為兩個相等的小記憶體塊
B:建立物件分配置在大記憶體塊和 兩小記憶體塊中的任一個,另外一小記憶體塊保持空閒備用。
C:回收:透過可達性分析,標記出第一塊和其中使用的小塊記憶體中存活物件,同時把存活物件複製到備用的另一塊小記憶體中
D:清空大記憶體塊和被回收的小塊記憶體。此時:大記憶體被清空,其中兩塊小記憶體:一塊清空,一塊儲存了上次存活的數
E:然後交替使用兩塊小記憶體塊做為清空大記憶體和另一塊小記憶體的中轉。
優點:減少了記憶體浪費,同時又保持了複製演算法的優點。
缺點:未完全杜絕記憶體浪費,同時大資料量時,效率低;存活物件數量佔比較大時,小記憶體塊無法做為中轉站。
使用場景:在存活物件較少,追求高效率,記憶體無碎片的場景。
3.3 標記-整理
標記清除演算法效率低,碎片嚴重; 複製演算法存活物件少時效率高,無碎片,但記憶體浪費;為了折中兩種演算法的優點,有人提供另一種演算法:標記-整理演算法。
步驟:
A:根據可達性分析,標記出所有存活的物件
B:遍歷所有物件,過濾出非存活的物件,並把這些物件一個一個,從記憶體的某一個角落順序排列。
優點:沒有記憶體浪費,無碎片
缺點:效率最低,小於標記清除(需要兩個階段;移動類似複製,代價高於直接清除,存活物件越多,移動代價越大)
四:分代演算法
準確的講,分代演算法不是一種回收演算法,它只是按物件生命週期和特點不同,合理選用以上三種回收演算法的手段。
記憶體模型中,我們大概瞭解了堆記憶體的分代結構如下:
為什麼需要分代?
因為不同的物件生命週期不同,有的很長(如:session),有的很短(如:方法中的變更);如果不分代,每次可達性分析標記時,都要遍歷暫時不會回收的老物件,當老物件越來越多時,重複對老物件的無用遍利檢查,會嚴重影響回收效能。
如果把物件按年齡隔離,分成新生代和老年代,老年代儲存生命週期長的物件,新生代儲存新建立的物件,那麼老年代就可以長時間不回收,而新年代大部分是朝生夕死,就可以頻繁回收。即保證了效率,又保證了新生代記憶體的及時回收。
總結:新生代:時間換空間(頻繁回收:由於存活的資料量少,頻繁回收的代價也可以接受)
老年代:空間換時間(需要時回收:存活的多,頻繁回收嚴重影響效能;有些物件可能已經變垃圾了,但仍然存在老年代中,等到新生代不夠或其它條件時,才回收老年代)
如何區分新老物件?
這個與垃圾回收器實現有關,對應回收器有相關的配置。
主要有幾種情況:
大物件直接進行老年代
年齡達到一定閥值(每經歷過一次回收還活著:年齡加1, 預設閥值為:15,可配置)
survivor空間中相同年齡所有物件大小的總和超過survivor空間的一半時,即使沒達到年齡閥值
五:垃圾回收器
垃圾回收演算法只是垃圾回收的理論基礎,垃圾回收器是對垃圾回演算法的實現。
垃圾回收器關注三個方法:A:垃圾回收演算法選擇 B:序列和並行(併發)的選擇 C:新老代的選擇
下面先了解一下jvm中的垃圾回收器種類:
垃圾回收器根據新老代不同區分,一部分只用於新生代回收(Serial、ParNew、Parallel),一部分只用於老年代(Serial old、CMS、Parallel old); G1是一個特殊的存在,後續再講。
下面我一個一個分析各自的原理及特點,然後分析他們為什麼只能使用新生代或老年代;以及實戰中如何選擇。
5.1 Serial
serial/serial old 收集示意圖(圖片來自:)
使用於:新生代
垃圾回收演算法:複製演算法
序列/並行/併發:序列,單執行緒
stw:是
serial是一個單執行緒,且用於新生代的垃圾回收器。它執行時,需要stw,暫停所有使用者執行緒。所以,堆配置過大,且垃圾太多時,會導致程式有明顯的停頓。
由於新生代是存活量少,回收頻繁,所以必須使用最高效的回收演算法-複製演算法;複製演算法大量存活資料,且需要額外記憶體的情況下是不符合老年代的,因此當前回收器只能用於新生代。
注意:此收集器,只適用於client模式,不適用於生產環境的server模式(當今伺服器已經很少有單cpu,單執行緒在多cpu下,會浪費過多cpu資源,導致垃圾回收停頓時間過長和頻繁)
5.2 ParNew
(圖片來自:)
分代:用於新生代
垃圾回收演算法:複製演算法
序列/並行/併發:併發,多執行緒
stw:是
ParNew是serial收集器的多執行緒模式,除此之外沒有任何區別。多執行緒大大提高了多cpu伺服器的垃圾回收效率,減少停頓時間。
5.3 Parallel Scavenge
分代:用於新生代
垃圾回收演算法:複製演算法
序列/並行/併發:並行,多執行緒
stw:是
Parallel Scavenge 與 ParNew一樣也是多執行緒,但是與ParNew不同的是,它關注的點是垃圾回收的吞吐量(使用者執行緒時間/(使用者執行緒時間 + 垃圾回收時間)),也就是:它期望儘可能壓榨cpu,多用於業務捃,它關注的是整體,而不是一次。
如:假如每分鐘執行1000次垃圾回收,每次的停頓時間很短,但1000次總停頓時間要高於 每分種100次的時間。那麼100次垃圾回收就是Parallel Scavenge期望的。
5.4 Serial old
分代:用於老年代
垃圾回收演算法:標記-整理演算法
序列/並行/併發:序列,單執行緒
stw:是
由於老年代,活的多,死的少,且最好沒有碎片:標記整理演算法;
跟Serial收集器一樣,當前收集器也是單執行緒,因此也不適合多核時代的伺服器上,是預設的client模式,同時做cms收集器失敗時的備選收集器(因為cms是併發的,如果併發失敗,就不要併發了,所以使用了serial Old)。
5.5 CMS
(圖片來自:)
分代:用於老年代
垃圾回收演算法:標記-清除演算法,有碎片
序列/並行/併發:多執行緒
stw:初始標記stw; 重新標記stw
CMS是首個併發收集器,垃圾回假步驟中的部分階段可以與使用者執行緒併發執行。
垃圾回收器的最終目標就是:減少垃圾回收對使用者執行緒的影響(停頓頻率小、停頓時間少)。
為此,CMS把垃圾回收分為四個階段,把不需要停頓的階段與使用者執行緒一起執行:
初始標記
併發標記
重新標記
併發清理
初始標記:從GC ROOTS只標記一級物件(存活的),所以速度很快;但需要stw。
併發標記:從一級物件開始向下追塑引用鏈,標記引用鏈上的物件;不需要stw,與使用者執行緒併發執行。速度是慢。
重新標記:修正併發標記過程中,因使用者執行緒繼續進行而導致標記變更的那部分物件;速度比初始標記慢,但比並發標記快很多。(但是:到底是修正了標記存活的物件還是其它?如果是修改存活的,那麼可以做為浮動垃圾等到下一次回收即可阿???)
併發清理:垃圾回收執行緒與使用者執行緒併發執行,清除垃圾(如果標記的活著物件,那麼不stw如果清除垃圾,此時如果使用者執行緒又產生物件了?透過ooM?暫時沒想通)
優點:單次停頓的時間更短
缺點:有碎片
作者:爛豬皮
來源:https://my.oschina.net/u/3636867/blog/1809198#comment-lis
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2249/viewspace-2801173/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 簡單易懂的索引原理索引
- 讓HTTPS簡單易懂HTTP
- 記憶體?java模型?jvm結構?一篇文章讓你全部看懂!記憶體Java模型JVM
- 簡單易懂的tinker熱修復原理分析
- 這是一篇你能看懂 Java JVM 文章JavaJVM
- 一篇文章帶你吃透 Docker 原理Docker
- 一篇文章告訴你Dalvik 和JVM的區別JVM
- 簡單易懂的PromisePromise
- 一篇文章讓你搞懂原型和原型鏈原型
- 通俗易懂,一篇文章告訴你程式語言是個啥?
- 一篇文章讓你明白運維發展方向運維
- 一篇文章讓你徹底掌握 shell 語言
- 簡單易懂的程式與執行緒詳解執行緒
- 一次簡單易懂的多型重構實踐,讓你理解條件邏輯多型
- 對JVM還一知半解?這篇文章讓你徹底搞定JVM!JVM
- 一篇文章讓你明白你多級快取的分層架構快取架構
- 簡單易懂的 Go 泛型使用和實現原理介紹Go泛型
- VMTools的安裝 (簡單易懂)
- 簡單易懂的氣泡排序排序
- 簡單易懂的JSON框架JSON框架
- spring思維導圖,讓spring更加簡單易懂Spring
- Go For Web:一篇文章帶你用 Go 搭建一個最簡單的 Web 服務、瞭解 Golang 執行 web 的原理WebGolang
- 瞭解 Oracle 中單引號與雙引號的用法,一篇文章教會你!Oracle
- 一篇文章讓你徹底瞭解Java內部類Java
- 一篇文章讓你學透Linux系統中的more命令Linux
- 一篇文章讓你讀懂iOS和Android的歷史起源iOSAndroid
- 一篇文章讓你瞭解Android各個版本的歷程Android
- 對JVM還有什麼不懂的?資深架構師一篇文章帶你深入淺出JVM!JVM架構
- 你對Redis持久化了解多少?一篇文章讓你明白Redis持久化Redis持久化
- 簡單易懂 —— this、self、static 的區別
- 簡單易懂的設計模式(上)設計模式
- 超簡單易懂的LNMP架構LNMP架構
- Java volatile關鍵字最全總結:原理剖析與例項講解(簡單易懂)Java
- 萬億級資料的方法,簡單易懂!
- 最簡單易懂的ChatGPT入門指南!ChatGPT
- MongoDB4 事務 簡單易懂的?MongoDB
- "簡單"的jvm調優JVM
- “簡單”的jvm調優JVM