學過Java程式設計師對JVM應該並不陌生,如果你沒有聽過,沒關係今天我帶你走進JVM的世界。程式設計師爲什麼要學習JVM呢,其實不懂JVM也可以照樣寫出優質的程式碼,但是不懂JVM有可能別被面試官虐得體無完膚。
JJVM它是Java Virtual Machine 的縮寫,主要是通過在實際計算機模仿各種計算機功能來實現的,組成部分包括堆、方法區、棧、本地方法棧、程式計算器等部分組成的,其中方法回收堆和方法區是共用區,也就是誰都可以使用,而棧和程式計算器、本地方法棧區是歸JVM的。Java能夠被稱爲「一次編譯,到處執行」的原因就是Java遮蔽了很多的操作系統平臺相關資訊,使得Java只需要生成在JVM虛擬機器執行的目的碼也就是所說的位元組碼,就可以在多種平臺執行。
我們都知道 Java 原始檔,通過編譯器,能夠生產相應的.Class 檔案,也就是位元組碼檔案,而位元組碼檔案又通過 Java 虛擬機器中的直譯器,編譯成特定機器上的機器碼 。
也就是如下:
① Java 原始檔—->編譯器—->位元組碼檔案
② 位元組碼檔案—->JVM—->機器碼
每一種平臺的直譯器是不同的,但是實現的虛擬機器是相同的,這也就是 Java 爲什麼能夠跨平臺的原因了 ,當一個程式從開始執行,這時虛擬機器就開始範例化了,多個程式啓動就會存在多個虛擬機器範例。程式退出或者關閉,則虛擬機器範例消亡,多個虛擬機器範例之間數據不能共用。
JVM 記憶體區域主要分爲執行緒私有區域【程式計數器、虛擬機器棧、本地方法棧】、執行緒共用區域【JAVA 堆、方法區】、直接記憶體。
執行緒私有數據區域生命週期與執行緒相同, 依賴使用者執行緒的啓動/結束 而 建立/銷燬(在 HotspotVM 內, 每個執行緒都與操作系統的本地執行緒直接對映, 因此這部分記憶體區域的存/否跟隨本地執行緒的生/死對應)
執行緒共用區域隨虛擬機器的啓動/關閉而建立/銷燬。
一、程式計數器
一塊較小的記憶體空間, 是當前執行緒所執行的位元組碼的行號指示器,每條執行緒都要有一個獨立的程式計數器,這類記憶體也稱爲「執行緒私有」的記憶體。
正在執行 java 方法的話,計數器記錄的是虛擬機器位元組碼指令的地址(當前指令的地址)。如果還是 Native 方法,則爲空。
這個記憶體區域是唯一一個在虛擬機器中沒有規定任何 OutOfMemoryError 情況的區域。
二、Java虛擬機器棧
虛擬機器棧是Java執行方法的記憶體模型。每個方法被執行的時候,都會建立一個棧幀,把棧幀壓入棧,當方法正常返回或者拋出未捕獲的異常時,棧幀就會出棧。
棧幀:棧幀儲存方法的相關資訊,包含區域性變數數表、返回值、運算元棧、動態鏈接
a、區域性變數表:包含了方法執行過程中的所有變數。區域性變數陣列所需要的空間在編譯期間完成分配,在方法執行期間不會改變區域性變數陣列的大小。
b、返回值:如果有返回值的話,壓入呼叫者棧幀中的運算元棧中,並且把PC的值指向 方法呼叫指令 後面的一條指令地址。
c、運算元棧:操作變數的記憶體模型。運算元棧的最大深度在編譯的時候已經確定(寫入方法區code屬性的max_stacks項中)。運算元棧的的元素可以是任意Java型別,包括long和double,32位元數據佔用棧空間爲1,64位元數據佔用2。方法剛開始執行的時候,棧是空的,當方法執行過程中,各種位元組碼指令往棧中存取數據。
d、動態鏈接:每個棧幀都持有在執行時常數池中該棧幀所屬方法的參照,持有這個參照是爲了支援方法呼叫過程中的動態鏈接。
三、本地方法棧
本地方法棧和 Java Stack 作用類似, 區別是虛擬機器棧爲執行 Java 方法服務, 而本地方法棧則爲Native 方法服務。
四、方法區
方法區也叫永久代(Permanent Generation)。在過去(自定義類載入器還不是很常見的時候),類大多是」static」的,很少被解除安裝或收集,因此被稱爲「永久的(Permanent)」。方法區用於儲存被 JVM 載入的類資訊、常數、靜態變數、即時編譯器編譯後的程式碼等數據. HotSpot VM把GC分代收集擴充套件至方法區, 即使用Java堆的永久代來實現方法區, 這樣 HotSpot 的垃圾收集器就可以像管理 Java 堆一樣管理這部分記憶體,而不必爲方法區開發專門的記憶體管理器(永久帶的記憶體回收的主要目標是針對常數池的回收和型別的解除安裝, 因此收益一般很小)。
執行時常數池(Runtime Constant Pool)是方法區的一部分。Class 檔案中除了有類的版本、欄位、方法、介面等描述等資訊外,還有一項資訊是常數池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號參照,這部分內容將在類載入後存放到方法區的執行時常數池中。
Java 虛擬機器對 Class 檔案的每一部分(自然也包括常數池)的格式都有嚴格的規定,每一個位元組用於儲存哪種數據都必須符合規範上的要求,這樣纔會被虛擬機器認可、裝載和執行。
五、堆
它是JVM用來儲存物件範例以及陣列值的區域,可以認爲Java中所有通過new建立的物件的記憶體都在此分配,Heap中的物件的記憶體需要等待GC進行回收。
(1) 堆是JVM中所有執行緒共用的,因此在其上進行物件記憶體的分配均需要進行加鎖,這也導致了new物件的開銷是比較大的。
(2) Sun Hotspot JVM爲了提升物件記憶體分配的效率,對於所建立的執行緒都會分配一塊獨立的空間TLAB(Thread Local Allocation Buffer),其大小由JVM根據執行的情況計算而得,在TLAB上分配物件時不需要加鎖,因此JVM在給執行緒的物件分配記憶體時會盡量的在TLAB上分配,在這種情況下JVM中分配物件記憶體的效能和C基本是一樣高效的,但如果物件過大的話則仍然是直接使用堆空間分配。
(3) TLAB僅作用於新生代的Eden Space,因此在編寫Java程式時,通常多個小的物件比大的物件分配起來更加高效。
(4) 所有新建立的Object 都將會儲存在新生代Yong Generation中。如果Young Generation的數據在一次或多次GC後存活下來,那麼將被轉移到OldGeneration。新的Object總是建立在Eden Space。
六、執行引擎
執行引擎包含即時編譯器(JIT)和垃圾回收器(GC),對即時編譯器我們簡單介紹一下,主要重點在於垃圾回收器。
即時編譯器(JIT,Just-In-Time Compiler)
JIT並不是Java虛擬機器規範定義中規定必須存在的,但它又是JVM效能重要影響因素之一。
在上面的內容裡,提到了HotSpot這麼一個名字,它其實是虛擬機器的名稱。HotSpot中文意思是"熱點",而HotSpot VM的特點之一也就是可以探測並優化熱點程式碼,JIT就是它進行優化的方式。
HotSpot通過計數以及其他方式,監測到某些方法或者某些程式碼塊執行的頻率很高,就會將其編譯成爲平臺相關的機器碼,甚至於在保證結果的情況下通過優化執行順序等方式進行優化,這種機器碼的執行效率比解釋執行要高出很多。而編譯完成後,會通過"棧上替換"等方式進行動態的替換,比如回圈執行,回圈一次JIT的計數器就+1,到了閾值的時候就開始編譯重複執行的程式碼,同時爲了不影響系統的執行,原來的解釋執行仍然繼續,直到在第N次回圈時,編譯完成,會在N+1次執行前替換成編譯後的機器碼執行。
垃圾回收器(Garbage Collection)
1.什麼是垃圾?
說到垃圾回收器,首先需要說一下什麼叫垃圾。
所有的物件都存放在堆中,而有些物件用過之後就不會再被使用了,這種就叫做垃圾。概念很容易理解,但對於JVM來說,怎麼確定一個物件是否是垃圾或者說怎麼找到所有的垃圾物件就需要演算法的支援。
2.怎麼確定一個物件是垃圾?
不得不提的一種是參照計數法,實現起來最簡單,一個物件被參照一次,計數器就+1,失去參照就計數器-1,等到計數器減爲0了,這個物件就沒有其他物件在使用了,也就可以對它進行回收了。這種演算法效率很高,但這種會有一個問題在於,兩個物件相互參照,但兩個物件都沒有被其他物件繼續參照了,計數器仍然不會減爲0。
通過參照計數來看,node1被node2參照着,node2也被node1參照着,兩個互相參照,卻沒有其他地方在參照,應該被清除掉,但參照計數器的值並沒有減爲0,無法回收。所以幾乎已經被現代語言拋棄掉了,取而代之的是可達性分析標記存活物件而後使用其他演算法。
可達性分析是從一個GC Root節點開始找參照的節點,找到後繼續找其參照的節點,直到查詢完畢,其餘沒有被找到過的節點就是垃圾節點,一般作爲GC Root的物件有Java棧中的本地變數物件,方法區的靜態變數參照的物件,方法區的常數參照的物件,本地方法棧中參照的物件等。
如上圖所示,遍歷所有的GC Root(黑色的物件),然後向下尋找所有的參照關係,能夠找到的就標記爲存活(藍色的物件)。而無法找到的,也就無法打上標記(黃色的物件),這些沒有存活標記的就是可以回收的物件。
七、類載入器子系統
顧名思義,這是用於類載入的一個子系統。
類載入的過程
類載入的過程包括了載入,驗證,準備,解析和初始化這5個步驟:
1.載入:找到位元組碼檔案,讀取到記憶體中。類的載入方式分爲隱式載入和顯示載入兩種。隱式載入指的是程式在使用new關鍵詞建立物件時,會隱式的呼叫類的載入器把對應的類載入到jvm中。顯示載入指的是通過直接呼叫class。forName()方法來把所需的類載入到jvm中。
2.驗證:驗證此位元組碼檔案是不是真的是一個位元組碼檔案,畢竟後綴名可以隨便改,而內在的身份標識是不會變的。在確認是一個位元組碼檔案後,還會檢查一系列的是否可執行驗證,元數據驗證,位元組碼驗證,符號參照驗證等。Java虛擬機器規範對此要求很嚴格,在Java 7的規範中,已經有130頁的描述驗證過程的內容。
3.準備:爲類中static修飾的變數分配記憶體空間並設定其初始值爲0或null。可能會有人感覺奇怪,在類中定義一個static修飾的int,並賦值了123,爲什麼這裏還是賦值0。因爲這個int的123是在初始化階段的時候才賦值的,這裏只是先把記憶體分配好。但如果你的static修飾還加上了final,那麼就會在準備階段就會賦值。
4.解析:解析階段會將java程式碼中的符號參照替換爲直接參照。比如參照的是一個類,我們在程式碼中只有全限定名來標識它,在這個階段會找到這個類載入到記憶體中的地址。
5.初始化:如剛纔準備階段所說的,這個階段就是對變數的賦值的階段。
類與類載入器
每一個類,都需要和它的類載入器一起確定其在JVM中的唯一性。換句話來說,不同類載入器載入的同一個位元組碼檔案,得到的類都不相等。我們可以通過預設載入器去載入一個類,然後new一個物件,再通過自己定義的一個類載入器,去載入同一個位元組碼檔案,拿前面得到的物件去instanceof,會得到的結果是false。
雙親委派機制 機製
類載入器一般有4種,其中前3種是必然存在的:
1.啓動類載入器:載入<JAVA_HOME>\lib下的
2.擴充套件類載入器:載入<JAVA_HOME>\lib\ext下的
3.應用程式類載入器:載入Classpath下的
4.自定義類載入器
而雙親委派機制 機製是如何運作的呢?
我們以應用程式類載入器舉例,它在需要載入一個類的時候,不會直接去嘗試載入,而是委託上級的擴充套件類載入器去載入,而擴充套件類載入器也是委託啓動類載入器去載入。
啓動類載入器在自己的搜尋範圍內沒有找到這麼一個類,表示自己無法載入,就再讓擴充套件類載入器去載入,同樣的,擴充套件類載入器在自己的搜尋範圍內找一遍,如果還是沒有找到,就委託應用程式類載入器去載入.如果最終還是沒找到,那就會直接拋出異常了。
而爲什麼要這麼麻煩的從下到上,再從上到下呢?
這是爲了安全着想,保證按照優先順序載入.如果使用者自己編寫一個名爲java.lang.Object的類,放到自己的Classpath中,沒有這種優先順序保證,應用程式類載入器就把這個當做Object載入到了記憶體中,從而會引發一片混亂。而憑藉這種雙親委派機制 機製,先一路向上委託,啓動類載入器去找的時候,就把正確的Object載入到了記憶體中,後面再載入自行編寫的Object的時候,是不會載入執行的。
雙親委派原則歸納總結:
1.可以避免重複載入,父類別已經載入了,子類就不需要再次載入。
2.提高系統的安全性,很好的解決了各個類載入器的基礎類的統一問題,如果不使用該種方式,那麼使用者可以隨意定義類載入器來載入核心api,會帶來相關隱患。
1、一般來說,當survivor區不夠大或者佔用量達到50%,就會把一些物件放到老年區。通過設定合理的eden區,survivor區及使用率,可以將年輕物件儲存在年輕代,從而避免full GC,使用-Xmn設定年輕代的大小。
2、對於佔用記憶體比較多的大物件,一般會選擇在老年代分配記憶體。如果在年輕代給大物件分配記憶體,年輕代記憶體不夠了,就要在eden區移動大量物件到老年代,然後這些移動的物件可能很快消亡,因此導致full GC。通過設定參數:-XX:PetenureSizeThreshold=1000000,單位爲B,標明物件大小超過1M時,在老年代(tenured)分配記憶體空間。
3、一般情況下,年輕物件放在eden區,當第一次GC後,如果物件還存活,放到survivor區,此後,每GC一次,年齡增加1,當物件的年齡達到閾值,就被放到tenured老年區。這個閾值可以同構-XX:MaxTenuringThreshold設定。如果想讓物件留在年輕代,可以設定比較大的閾值。
4、設定最小堆和最大堆:-Xmx和-Xms穩定的堆大小堆垃圾回收是有利的,獲得一個穩定的堆大小的方法是設定-Xms和-Xmx的值一樣,即最大堆和最小堆一樣,如果這樣子設定,系統在執行時堆大小理論上是恆定的,穩定的堆空間可以減少GC次數,因此,很多伺服器端都會將這兩個參數設定爲一樣的數值。穩定的堆大小雖然減少GC次數,但是增加每次GC的時間,因爲每次GC要把堆的大小維持在一個區間內。
5、一個不穩定的堆並非毫無用處。在系統不需要使用大記憶體的時候,壓縮堆空間,使得GC每次應對一個較小的堆空間,加快單次GC次數。基於這種考慮,JVM提供兩個參數,用於壓縮和擴充套件堆空間。
(1)-XX:MinHeapFreeRatio 參數用於設定堆空間的最小空閒比率。預設值是40,當堆空間的空閒記憶體比率小於40,JVM便會擴充套件堆空間。
(2)-XX:MaxHeapFreeRatio 參數用於設定堆空間的最大空閒比率。預設值是70, 當堆空間的空閒記憶體比率大於70,JVM便會壓縮堆空間。
(3)當-Xmx和-Xmx相等時,上面兩個參數無效。
6、通過增大吞吐量提高系統效能,可以通過設定並行垃圾回收收集器。
(1)-XX:+UseParallelGC:年輕代使用並行垃圾回收收集器。這是一個關注吞吐量的收集器,可以儘可能的減少垃圾回收時間。
(2)-XX:+UseParallelOldGC:設定老年代使用並行垃圾回收收集器。
7、嘗試使用大的記憶體分頁:使用大的記憶體分頁增加CPU的記憶體定址能力,從而系統的效能。-XX:+LargePageSizeInBytes 設定記憶體頁的大小。
8、使用非佔用的垃圾收集器。-XX:+UseConcMarkSweepGC老年代使用CMS收集器降低停頓。
9、-XXSurvivorRatio=3,表示年輕代中的分配比率:survivor:eden = 2:3
10、JVM效能調優的工具:
(1)jps(Java Process Status):輸出JVM中執行的進程狀態資訊(現在一般使用jconsole)
(2)jstack:檢視java進程內執行緒的堆疊資訊。
(3)jmap:用於生成堆轉存快照
(4)jhat:用於分析jmap生成的堆轉存快照(一般不推薦使用,而是使用Ecplise Memory Analyzer)
大多數人對於GC的直觀感受是,飄忽不定,它執行的時間是不確定的,就算手動呼叫System.gc()也不見得會執行。但其實不盡然,GC作爲一個守護執行緒,它的優先順序是隨着記憶體使用情況不斷變化的,會在可用記憶體低到一定程度後自動呼叫。
基本GC演算法主要是標記-清除演算法,複製演算法,標記-整理演算法。
1.標記-清除演算法 其實在JVM中沒怎麼露臉,但它是現代GC演算法的基礎。通過可達性分析,將存活的物件打上標記,然後對全部物件進行掃描,將沒有標記的物件清除掉.這種演算法會有一個問題,清除廢棄物件後,釋放的記憶體並不是連續的,而是一個個記憶體碎片。這對於後續JVM分配記憶體並不是很好,如果需要一塊較大的連續記憶體就沒有辦法將這些碎片利用起來,並且它需要遍歷所有的物件,清除沒有標記的,這種效能消耗很大。
2.複製演算法,一般應用於新生代,這也是爲什麼新生代要設計成一個Eden,兩個Survivor區的原因。所有物件都在Eden建立出來,每次gc就會把Eden和其中一個正在使用的Survivor區中存活的物件複製到另外一個沒有使用的Survivor區。然後清除掉原來記憶體區的所有物件,也就是廢棄的物件。每次gc都這樣操作,始終留一個Survivor區不使用。這種演算法的好處在於不會殘留記憶體碎片,方便記憶體管理,但是需要預留一塊記憶體,並且效能消耗是根據存活物件多少而來的,不適用於存活物件較多的情況。
3.標記-整理演算法,是標記-清除演算法的升級版,一般用於老年代。它將標記存活的物件統一移到記憶體的某一端,然後將邊界外的空間清空。這樣既不會佔着一塊記憶體作爲備用,也不會存在記憶體碎片無法有效利用。但是由於要遍歷存活的物件,還有重新存活物件的參照地址,所以效率要低於複製演算法。
4.分代回收演算法
正如我們前面瞭解到的,新生代和老年代各自的情況不同,直接把某種演算法套用在兩個區上,可能效果並不理想。而現在商業虛擬機器的GC都是採用的分代回收演算法,不同的堆分割區採用不同的演算法進行回收。
5.Minor GC和Full GC
在說這兩種回收的區別之前,我們先來說一個概念,「Stop-The-World」。
如字面意思,每次垃圾回收的時候,都會將整個JVM暫停,回收完成後再繼續。如果一邊增加廢棄物件,一邊進行垃圾回收,完成工作似乎就變得遙遙無期了。
而一般來說,我們把新生代的回收稱爲Minor GC,Minor意思是次要的,新生代的回收一般回收很快,採用複製演算法,造成的暫停時間很短。而Full GC一般是老年代的回收,病伴隨至少一次的Minor GC,新生代和老年代都回收,而老年代採用標記-整理演算法,這種GC每次都比較慢,造成的暫停時間比較長,通常是Minor GC時間的10倍以上。
所以很明顯,我們需要儘量通過Minor GC來回收記憶體,而儘量少的觸發Full GC。畢竟系統執行一會兒就要因爲GC卡住一段時間,再加上其他的同步阻塞,整個系統給人的感覺就是又卡又慢。
好了,以上內容就是我對JVM進行的原理解析和模組梳理,如果你看完之後感覺有所收益,希望可以幫忙點個贊,我會更有動力繼續進行技術分享。我是雲客姑蘇,一個有態度懂生活的程式設計師!