Java架構師成長之道之JVM科學優調

想要成爲一名出色的Java架構師,必需要完全瞭解Java的一個重要的特色那就JVMjava

動力節點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作架構,從全局思惟出發,帶你把控大型項目中別人忽略的重要細節節點,站在巨人肩膀上學習架構師,帶你領會架構師不同的視野安全

相關文章
相關標籤/搜索