想要成為一名出色的Java架構師,必須要徹底了解Java的一個重要的特點那就JVM
動力節點Java學院寄語
前些天面試了阿裡的實習生,問到關于Dalvik虛拟機能不能執行class檔案,我當時的回答是不能,但是它執行的是class轉換的dex檔案。
當面試官繼續問,為什麼不能執行class檔案時,我卻隻能回答Dalvik虛拟機内部的優化原因,卻不能正确回答具體的原因。
其實周志明的這本書就有回答:Dakvik并不是一個Java虛拟機,它沒有遵循Java虛拟機規範,不能執行Java的class檔案,使用的是寄存器架構而不是JVM中常見的棧架構,但是它與Java又有着千絲萬縷的關系,它執行的dex檔案可以通過class檔案轉化而來。
其實在大學期間,就有接觸過《深入了解Java虛拟機》,但是一直以來都沒去仔細研讀,現在回頭想想實在是覺得可惜!研一期間花了不少時間研讀,現在準備找工作了,發現好多内容看了又忘。索性寫一篇文章,把這本書的知識點做一個總結。當然了,如果你想看比較詳細的内容,可以翻看《深入了解Java虛拟機》。
JVM記憶體區域
我們在編寫程式時,經常會遇到OOM(out of Memory)以及記憶體洩漏等問題。為了避免出現這些問題,我們首先必須對JVM的記憶體劃分有個具體的認識。
JVM将記憶體主要劃分為:方法區、虛拟機棧、本地方法棧、堆、程式計數器。JVM運作時資料區如下:

程式計數器
程式計數器是線程私有的區域,很好了解嘛~,每個線程當然得有個計數器記錄目前執行到那個指令。
占用的記憶體空間小,可以把它看成是目前線程所執行的位元組碼的行号訓示器。如果線程在執行Java方法,這個計數器記錄的是正在執行的虛拟機位元組碼指令位址;如果執行的是Native方法,這個計數器的值為空(Undefined)。
此記憶體區域是唯一一個在Java虛拟機規範中沒有規定任何OutOfMemoryError情況的區域。
Java虛拟機棧
與程式計數器一樣,Java虛拟機棧也是線程私有的。其生命周期與線程相同。如何了解虛拟機棧呢?本質上來講,就是個棧。
裡面存放的元素叫棧幀,棧幀好像很複雜的樣子,其實它很簡單!它裡面存放的是一個函數的上下文,具體存放的是執行的函數的一些資料。
執行的函數需要的資料無非就是局部變量表(儲存函數内部的變量)、操作數棧(執行引擎計算時需要),方法出口等等。
執行引擎每調用一個函數時,就為這個函數建立一個棧幀,并加入虛拟機棧。換個角度了解,每個函數從調用到執行結束,其實是對應一個棧幀的入棧和出棧。
注意這個區域可能出現的兩種異常:一種是StackOverflowError,目前線程請求的棧深度大于虛拟機所允許的深度時,會抛出這個異常。制造這種異常很簡單:将一個函數反複遞歸自己,最終會出現棧溢出錯誤(StackOverflowError)。另一種異常是OutOfMemoryError異常,當虛拟機棧可以動态擴充時(目前大部分虛拟機都可以),如果無法申請足夠多的記憶體就會抛出OutOfMemoryError,如何制作虛拟機棧OOM呢,參考一下代碼:
1. public void stackLeakByThread(){
2. while(true){
3. new Thread(){
4. public void run(){
5. while(true){
6. }
7. }
8. }.start()
9. }
10. }
複制
這段代碼有風險,可能會導緻作業系統假死,請謹慎使用~~~
本地方法棧
本地方法棧與虛拟機棧所發揮的作用很相似,他們的差別在于虛拟機棧為執行Java代碼方法服務,而本地方法棧是為Native方法服務。
與虛拟機棧一樣,本地方法棧也會抛出StackOverflowError和OutOfMemoryError異常。
Java堆
Java堆可以說是虛拟機中最大一塊記憶體了。它是所有線程所共享的記憶體區域,幾乎所有的執行個體對象都是在這塊區域中存放。
當然,睡着JIT編譯器的發展,所有對象在堆上配置設定漸漸變得不那麼“絕對”了。
Java堆是垃圾收集器管理的主要區域。由于現在的收集器基本上采用的都是分代收集算法,所有Java堆可以細分為:新生代和老年代。
在細緻分就是把新生代分為:Eden空間、From Survivor空間、To Survivor空間。當堆無法再擴充時,會抛出OutOfMemoryError異常。
方法區
方法區存放的是類資訊、常量、靜态變量等。
方法區是各個線程共享區域,很容易了解,我們在寫Java代碼時,每個線程度可以通路同一個類的靜态變量對象。
由于使用反射機制的原因,虛拟機很難推測那個類資訊不再使用,是以這塊區域的回收很難。
另外,對這塊區域主要是針對常量池回收,值得注意的是JDK1.7已經把常量池轉移到堆裡面了。同樣,當方法區無法滿足記憶體配置設定需求時,會抛出OutOfMemoryError。
制造方法區記憶體溢出,注意,必須在JDK1.6及之前版本才會導緻方法區溢出,原因後面解釋,執行之前,可以把虛拟機的參數-XXpermSize和-XX:MaxPermSize限制方法區大小。
1. List list =new ArrayList();
2. int i =0;
3. while(true){
4. list.add(String.valueOf(i).intern());
5. }
複制
運作後會抛出java.lang.OutOfMemoryError:PermGen space異常。
解釋一下,String的intern()函數作用是如果目前的字元串在常量池中不存在,則放入到常量池中。
上面的代碼不斷将字元串添加到常量池,最終肯定會導緻記憶體不足,抛出方法區的OOM。
下面解釋一下,為什麼必須将上面的代碼在JDK1.6之前運作。
我們前面提到,JDK1.7後,把常量池放入到堆空間中,這導緻intern()函數的功能不同,具體怎麼個不同法,且看看下面代碼:
1. String str1 =new StringBuilder("hua").append("chao").toString();
2. System.out.println(str1.intern()==str1);
3. String str2=new StringBuilder("ja").append("va").toString();
4. System.out.println(str2.intern()==str2);
複制
這段代碼在JDK1.6和JDK1.7運作的結果不同。JDK1.6結果是:false,false ,JDK1.7結果是true, false。
原因是:JDK1.6中,intern()方法會吧首次遇到的字元串執行個體複制到常量池中,傳回的也是常量池中的字元串的引用,而StringBuilder建立的字元串執行個體是在堆上面,是以必然不是同一個引用,傳回false。在JDK1.7中,intern不再複制執行個體,常量池中隻儲存首次出現的執行個體的引用,是以intern()傳回的引用和由StringBuilder建立的字元串執行個體是同一個。為什麼對str2比較傳回的是false呢?這是因為,JVM中内部在加載類的時候,就已經有"java"這個字元串,不符合“首次出現”的原則,是以傳回false。
垃圾回收(GC)
JVM的垃圾回收機制中,判斷一個對象是否死亡,并不是根據是否還有對象對其有引用,而是通過可達性分析。
對象之間的引用可以抽象成樹形結構,通過樹根(GC Roots)作為起點,從這些樹根往下搜尋,搜尋走過的鍊稱為引用鍊,當一個對象到GC Roots沒有任何引用鍊相連時,則證明這個對象是不可用的,該對象會被判定為可回收的對象。
那麼那些對象可作為GC Roots呢?主要有以下幾種:
1.虛拟機棧(棧幀中的本地變量表)中引用的對象。
2.方法區中類靜态屬性引用的對象。
3.方法區中常量引用的對象
4.本地方法棧中JNI(即一般說的Native方法)引用的對象。
另外,Java還提供了軟引用和弱引用,這兩個引用是可以随時被虛拟機回收的對象,我們将一些比較占記憶體但是又可能後面用的對象,比如Bitmap對象,可以聲明為軟引用貨弱引用。
但是注意一點,每次使用這個對象時候,需要顯示判斷一下是否為null,以免出錯。
三種常見的垃圾收集算法
1.标記-清除算法
首先,通過可達性分析将可回收的對象進行标記,标記後再統一回收所有被标記的對象,标記過程其實就是可達性分析的過程。
這種方法有2個不足點:效率問題,标記和清除兩個過程的效率都不高;另一個是空間問題,标記清除之後會産生大量的不連續的記憶體碎片。
2.複制算法
為了解決效率問題,複制算法是将記憶體分為大小相同的兩塊,每次隻使用其中一塊。當這塊記憶體用完了,就将還存活的對象複制到另一塊記憶體上面。
然後再把已經使用過的記憶體一次清理掉。這使得每次隻對半個區域進行垃圾回收,記憶體配置設定時也不用考慮記憶體碎片情況。
但是,這代價實在是讓人無法接受,需要犧牲一般的記憶體空間。研究發現,大部分對象都是“朝生夕死”,是以不需要安裝1:1比例劃分記憶體空間,而是将記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden空間和一塊Survivor空間,預設比例為Eden:Survivor=8:1.新生代區域就是這麼劃分,每次執行個體在Eden和一塊Survivor中配置設定,回收時,将存活的對象複制到剩下的另一塊Survivor。
這樣隻有10%的記憶體會被浪費,但是帶來的效率卻很高。當剩下的Survivor記憶體不足時,可以去老年代記憶體進行配置設定擔保。
如何了解配置設定擔保呢,其實就是,記憶體不足時,去老年代記憶體空間配置設定,然後等新生代記憶體緩過來了之後,把記憶體歸還給老年代,保持新生代中的Eden:Survivor=8:1.另外,兩個Survivor分别有自己的名稱:From Survivor、To Survivor。二者身份經常調換,即有時這塊記憶體與Eden一起參與配置設定,有時是另一塊。因為他們之間經常互相複制。
3.标記-整理算法
标記整理算法很簡單,就是先标記需要回收的對象,然後把所有存活的對象移動到記憶體的一端。這樣的好處是避免了記憶體碎片。
類加載機制
類從被加載到虛拟機記憶體開始,到解除安裝出記憶體為止,整個生命周期包括:加載、驗證、準備、解析、初始化、使用和解除安裝七個階段。
其中加載、驗證、準備、初始化、和解除安裝這5個階段的順序是确定的。而解析階段不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java的運作時綁定。
關于初始化:JVM規範明确規定,有且隻有5中情況必須執行對類的初始化(加載、驗證、準備自然再此之前要發生):
1.遇到new、getstatic、putstatic、invokestatic,如果類沒有初始化,則必須初始化,這幾條指令分别是指:new新對象、讀取靜态變量、設定靜态變量,調用靜态函數。
2.使用java.lang.reflect包的方法對類進行反射調用時,如果類沒初始化,則需要初始化
3.當初始化一個類時,如果發現父類沒有初始化,則需要先觸發父類初始化。
4.當虛拟機啟動時,使用者需要制定一個執行的主類(包含main函數的類),虛拟機會先初始化這個類。
5.但是用JDK1.7啟的動态語言支援時,如果一個MethodHandle執行個體最後解析的結果是REF_getStatic、REF_putStatic、Ref_invokeStatic的方法句柄時,并且這個方法句柄所對應的類沒有進行初始化,則要先觸發其初始化。
另外要注意的是:通過子類來引用父類的靜态字段,不會導緻子類初始化:
1. public class SuperClass{
2. public static int value=123;
3. static{
4. System.out.printLn("SuperClass init!");
5. }
6. }
7. public class SubClass extends SuperClass{
8. static{
9. System.out.println("SubClass init!");
10. }
11. }
12. public class Test{
13. public static void main(String[] args){
14. System.out.println(SubClass.value);
15. }
16. }
複制
最後隻會列印:SuperClass init!
對應靜态變量,隻有直接定義這個字段的類才會被初始化,是以通過子類類引用父類中定義的靜态變量隻會觸發父類初始化而不會觸發子類初始化。
通過數組定義來引用類,不會觸發此類的初始化:
1. public class Test{
2. public static void main(String[] args){
3. SuperClass[] sca=new SuperClass[10];
4. }
5. }
複制
常量會在編譯階段存入調用者的常量池,本質上并沒有直接引用到定義常量的類,是以不會觸發定義常量的類初始化,示例代碼如下:
1. public class ConstClass{
2. public static final String HELLO_WORLD="hello world";
3. static {
4. System.out.println("ConstClass init!");
5. }
6. }
7. public class Test{
8. public static void main(String[] args){
9. System.out.print(ConstClass.HELLO_WORLD);
10. }
11. }
複制
上面代碼不會出現ConstClass init!
加載
加載過程主要做以下3件事
1.通過一個類的全限定名稱來擷取此類的二進制流
2.強這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構
3.在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料通路入口。
驗證
這個階段主要是為了確定Class檔案位元組流中包含資訊符合目前虛拟機的要求,并且不會出現危害虛拟機自身的安全。
準備
準備階段是正式為類變量配置設定記憶體并設定類變量初始值的階段,這些變量所使用的記憶體都在方法區中配置設定。首先,這個時候配置設定記憶體僅僅包括類變量(被static修飾的變量),而不包括執行個體變量。執行個體變量會在對象執行個體化時随着對象一起配置設定在java堆中。其次這裡所說的初始值“通常情況下”是資料類型的零值,假設一個類變量定義為
1. public static int value=123;
那變量value在準備階段後的初始值是0,而不是123,因為還沒有執行任何Java方法,而把value指派為123是在程式編譯後,存放在類構造函數()方法中。
解析
解析階段是把虛拟機中常量池的符号引用替換為直接引用的過程。
初始化
類初始化時類加載的最後一步,前面類加載過程中,除了加載階段使用者可以通過自定義類加載器參與以外,其餘動作都是虛拟機主導和控制。到了初始化階段,才是真正執行類中定義Java程式代碼。
準備階段中,變量已經賦過一次系統要求的初始值,而在初始化階段,根據程式員通過程式制定的主觀計劃初始化類變量。初始化過程其實是執行類構造器()方法的過程。
()方法是由編譯器自動收集類中所有類變量的指派動作和靜态語句塊中的語句合并産生的。收集的順序是按照語句在源檔案中出現的順序。靜态語句塊中隻能通路定義在靜态語句塊之前的變量,定義在它之後的變量可以指派,但不能通路。如下所示:
1. public class Test{
2. static{
3. i=0;
4. System.out.print(i);
5. }
6. static int i=1;
7. }
複制
()方法與類構造函數(或者說執行個體構造器())不同,他不需要顯式地調用父類構造器,虛拟機會保證子類的()方法執行之前,父類的()已經執行完畢。
類加載器
關于自定義類加載器,和雙親委派模型,這裡不再提,寫了幾個小時了,該洗洗睡了~
動力節點Java架構師班深度剖析Java底層原理,熱門技術深入探讨,前沿技術深入解讀,大項目實戰重構,從0到1做架構,從全局思維出發,帶你把控大型項目中别人忽略的重要細節節點,站在巨人肩膀上學習架構師,帶你領會架構師不一樣的視野