面試常問點:深刻剖析JVM的那些事

文章較長,主要講解了JVM的整個流程,其次介紹Dalvik與JVM的區別及ARThtml

Class文件結構 -> JVM內存模型 -> 類加載器 -> 類加載過程 -> 類的引用方式 -> 內存分配策略 -> GC -> 對象的引用類型 -> 類卸載java

先前知識算法

衆所周知java是一種跨平臺的語言,但實際上跨平臺的並非java而是JVM。數組

JVM(Java Virtual Machine)是一種虛擬機,用來將由java文件編譯成的class字節碼文件再編譯成機器語言,供機器識別。有了JVM中間人的存在就不須要直接與操做系統打交道,且不一樣的操做系統有不一樣的JVM,因而就屏蔽了操做系統間的差別,從而使java成爲跨平臺語言。緩存

DVM又是什麼?安全

Dalvik Virtual Machine簡稱DVM也是一種虛擬機,是專門爲Android平臺開發的,它與JVM是有差異的。bash

Dalvik基於寄存器,而JVM 基於棧。性能有很大的提高。基於寄存器的虛擬機對於更大的程序來講,在它們編譯的時候,花費的時間更短。微信

寄存器的概念數據結構

寄存器是中央處理器內的組成部分。寄存器是有限存貯容量的高速存貯部件,它們可用來暫存指令、數據和位址。在中央處理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序計數器(PC),在中央處理器的算術及邏輯部件中,包含的寄存器有累加器(ACC)多線程

棧的概念

棧是線程獨有的,保存其運行狀態和局部自動變量的(因此多線程中局部變量都是相互獨立的,不一樣於類變量)。棧在線程開始的時候初始化(線程的Start方法,初始化分配棧),每一個線程的棧互相獨立。每一個函數都有本身的棧,棧被用來在函數之間傳遞參數。操做系統在切換線程的時候會自動的切換棧,就是切換SS/ESP寄存器。棧空間不須要在高級語言裏面顯式的分配和釋放。

JVM

java的使用流程:

  • 一、編寫.java文件

  • 二、編譯成.class

    • a、打包成.jar(Java Archive) .war(Web Archive)使用
    • b、命令行則直接使用.class

其實.jar和.war是.class文件的壓縮包,其中還包含了不一樣的配置文件,使用時經過類加載器取其內部的.class字節碼文件加載到JVM。

Class文件結構

JVM接收的最初數據是class字節碼文件,由.java文件編譯產生。並非只有java語言能夠編譯成class文件,其餘語言也是能夠(Scala、Groovy)。

class文件是由8位爲一組的字節爲基本單位,構成的二進制文件,爲何是二進制文件呢?

  • 一、機器語言爲二進制,因此使用便捷;
  • 二、佔用空間小,3.1415927用文本文件存儲須要將各個位轉成ASCII碼再存儲需佔用9字節,二進制文件存儲只需四字節;
  • 三、存儲數據精確不會丟失。

結構如上圖,最上方爲起始位,內容包含了.java文件的信息。

類型

class文件中只有兩種類型:無符號數和表

無符號數爲基本類型,有:u一、u二、u四、u8,數字表明字節數。無符號數能夠表明數字、索引引用、數量值,或者按照UTF-8編碼構成字符串值

表則是由基本類型和表構成的類型,屬於組合類型

magic
文件最初的4個字節,稱爲魔數,是計算機識別文件類型的依據,不一樣於感官,.word .png .avi這種經過擴展名識別文件類型的方式,計算機識別多數文件是經過文件頭部的魔數。

這種作法的優勢在於安全性,文件的擴展名能夠人爲隨意的修改,也許並不會形成文件的不可用(早年間的「圖種」一詞不知多少人有經歷),也可能形成文件不可用,但文件的類型在文件建立之初就被賦予魔數的話,就能夠大限度的保證文件的安全性。

version
表示此.class的版本信息,有minor_version和major_version兩種類型共佔了4字節。

不一樣版本Java有不一樣的特性,產生的class結構也會不一樣,JVM經過識別版本從而肯定是否可識別此文件。JVM是向下兼容的,若是.class版本太高則不能運行(Unsupported major.minor version **)。

constant
constant_pool爲常量池,用來存放常量,constant_pool_count爲池中的計數。

constant_pool的索引從1開始,當指針不想引用此constant_pool時則將指針指向0,此操做簡單(賦值永遠比刪除簡單)。

常量池中有兩大類常量類型:字面量和符號引用

  • 一、字面量爲java中的基本數據類型
  • 二、符號引用:以一組符號來描述所引用的目標,引用的目標並不必定已經加載到內存中,在類加載過程的解析階段JVM將常量池內的符號引用替換爲直接引用。類型有:
    • 一、CONSTANT_Class_info 類和接口的全限定名(該名稱在全部類型中惟一標識該類型)
    • 二、CONSTANT_Fieldref_info 字段的名稱和描述符
    • 三、CONSTANT_Methodref_info 方法的名稱和描述符

常量池中每一項常量都是一個表

講解:

class A{
    int i=9;
    int b=new B();
}

class B{
}
複製代碼

編譯時會產生A.class和B.class,此時A.class有兩個常量9和B。JVM加載A.class時,將因爲常量9屬於字面量即基本數據類型,直接放入常量池。

到常量B時,因爲常量B不屬於字面量即基本數據類型,因此此時產生一個符號引用來表明常量B。

等到A.class加載到了解析階段,須要將符號引用改成直接引用,但找不到符號引用B的直接引用,

在使用階段,因爲A對象主動引用了B類,因此JVM經過類加載器開始加載B.class(一樣的加載步驟),並建立了B對象,並將符號引用B改成B對象的直接引用。

access
access_flags即java中的類修飾符

類的身份信息
一個類要有類名,關係要有extends和implement。java中類是單繼承,因此除Object外全部類都有一個父類,而接口則能夠有多實現。

this_class是這個類的全限定名
super_class是這個類父類的全限定名
interfaces是這個類實現接口的集合
interfaces_count是這個類實現接口的數量

fields
fields_count表示fields表中的數量

fields是表結構用來存放字段,字段即爲類中聲明的變量。字段包括了類級變量或實例級變量,static修飾符爲判斷依據。

public static final transient String str = "Hello World";
複製代碼

一個字段包含的信息有:

  • 一、做用域(public、private、protected修飾符)
  • 二、類級變量仍是實例級變量(static修飾符)
  • 三、可變性(final)
  • 四、併發可見性(volatile修飾符,是否強制從主內存讀寫)
  • 五、能否序列化(transient修飾符)
  • 六、字段數據類型(基本類型、對象、數組)
  • 七、字段名稱(str)

一個字段有多種修飾符,每種修飾符只有兩種狀態:有、沒有,因此採用標誌位來表示最爲合理。字段的其餘信息,叫什麼名字、被定義爲何數據類型,這些都是沒法固定的,因此引用常量池中的常量來描述。

access_flags:修飾符

name_index:簡單名稱
指變量名,存放在常量池。例如字段str的簡單名稱「str」。

descriptor_index:描述符
描述字段的類型

例子:
java.lang.String[][] —— [[Ljava/lang/String
int[] —— [I
String s —— Ljava/lang/String

attributes:屬性集合,以用於描述某些場景專有的信息。

上面的類型只定義了變量信息,那變量的初始賦值操做呢?

賦值操做是將常量賦值給變量,常量有字面量和符號引用,字面量會在常量池中,符號引用依據狀況會在解析或使用階段改成直接引用。

字段賦值的時機:
a:對於非靜態的field字段的賦值將會出如今實例構造方法()中
b:對於靜態的field字段,有兩個選擇:

一、在類構造方法()中進行;
 二、使用ConstantValue屬性進行賦值
編譯器對於靜態field字段的初始化賦值策略:

若是final和static同時修飾一個字段,而且這個字段是基本類型或者String類型的,
那麼編譯器在編譯這個字段的時候,會在對應的field_info結構體中增長一個ConstantValue類型的結構體,在賦值的時候使用這個ConstantValue進行賦值;

若是該field字段並無被final修飾,或者不是基本類型或者String類型,那麼將在類構造方法中賦值。

對於全局變量的值是被編譯在構造器中賦值的

https://www.cnblogs.com/straybirds/p/8331687.html
複製代碼

methods
methods_count表示methods表中的數量

methods是表結構用來存放方法,表結構和字段的表結構一致

因爲部分關鍵字相對於變量和方法是有區別的

例子:
int indexOf(char[] source,int sourceOffset,int sourceCount,char[] targetOffset,int targetCount,int fromIndex) —— ([CII[CII)I

方法內部的代碼存到什麼地方了?

attributes:屬性表
在字段表和方法表中都有屬性表

屬性表所能識別的屬性有

方法中到具體代碼就存放在方法表中屬性表的Code屬性中

參考 https://blog.csdn.net/sinat_37138973/article/details/54378263
複製代碼

在瞭解了字節碼文件的結構後,JVM要想使用此文件,首先要將其加載到內存,那內存結構是怎樣的呢?

JVM內存模型

  • 一、PC
    與CPU中的PC不一樣,CPU中的PC是記錄即將執行的下條指令的地址,而JVM中記錄的是正在執行的虛擬機字節碼指令的地址,且執行native方法時PC爲空
  • 二、虛擬機棧
    每一個方法被執行的時候都會建立一個棧幀,用於存儲局部變量表、操做棧、動態連接、方法出口等信息。每個方法被調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。
    若是線程請求的棧深度大於虛擬機所容許的深度,將拋出StackOverflowError 異常;若是虛擬機棧能夠動態擴展當擴展時沒法申請到足夠的內存時會拋出OutOfMemoryError 異常。
  • 三、本地方法棧
    爲虛擬機使用到的Native方法服務
    本地方法棧區域也會拋出StackOverflowError 和OutOfMemoryError異常。
  • 四、方法區
    用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。當方法區沒法知足內存分配需求時,將拋出OutOfMemoryError 異常。
  • 五、堆
    存放對象實例,若是在堆中沒有內存完成實例分配,而且堆也沒法再擴展時,將會拋出OutOfMemoryError 異常。
參考 https://www.cnblogs.com/dingyingsi/p/3760447.html
複製代碼

在知道了class字節碼文件結構和JVM內存模型後,須要一個過程將字節碼文件加載到內存。

類加載器

負責將字節碼文件載入到內存,

BootstrapClassLoader – JRE/lib/rt.jar
ExtensionClassLoader – JRE/lib/ext或者java.ext.dirs指向的目錄
ApplicationClassLoader – CLASSPATH環境變量, 由-classpath或-cp選項定義,或者是JAR中的Manifest的classpath屬性定義

從上至下依次爲父子關係,並非繼承關係。

機制

  • 一、委託機制
    當加載B.class時,請求首先發到ApplicationClassLoader,ApplicationClassLoader看都不看就交給父親ExtensionClassLoader,ExtensionClassLoader也是看都不看就交給父親BootstrapClassLoader,BootstrapClassLoader爲始祖了,因而在本身的管轄區內查找看有沒有B.class,有就加載沒有就告訴兒子ExtensionClassLoader,你本身處理,ExtensionClassLoader收到父親的信息後在本身的管轄區內查找B.class,有就加載沒有就告訴兒子ApplicationClassLoader,你本身處理,ApplicationClassLoader收到父親的信息後在本身的管轄區內查找B.class,有就加載沒有就ClassNotFoundException。
  • 二、可見性機制
    子類加載器能夠看到父類加載器加載的類
  • 三、單一性機制
    因委託機制的關係,一個類(惟一的全限定名)只能被一個類加載器加載一次

加載方式

  • 一、顯式加載
    經過class.forname()等方法,顯式加載須要的類

  • 二、隱式加載
    程序在運行過程當中當碰到經過new等方式生成對象時,隱式調用類裝載器加載對應的類到jvm中

自定義類加載器

以上三種類加載器,在某些場景下就不適用。因爲以上三種類加載器都是加載指定位置的class,當加載異地加密的class時就沒法使用,此時須要自定義類加載器,加載指定位置的class到內存並在執行解密後使用。

有了class字節碼文件結構、JVM內存模型和類加載器這三個部分的初識,接着就是三個獨立部分合做的場景:類加載過程

類加載過程

類加載器將字節碼文件載入JVM內存的過程

  • 一、加載
    主要是獲取定義此類的二進制字節流,並將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構,最後在Java堆中生成一個表明這個類的java.lang.Class對象做爲方法區這些數據的訪問入口。
    類加載器參與的階段。
  • 二、驗證
    確保Class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。主要驗證過程包括:文件格式驗證(魔數、版本號),元數據驗證(類關係),字節碼驗證(數據流、控制流)以及符號引用驗證(引用可達)。
  • 三、準備
    正式爲類變量(static變量)分配內存並設置類變量初始值的階段。關於準備階段爲類變量設置零值的惟一例外就是當這個類變量同時也被final修飾,那麼在編譯時,就會直接爲這個常量賦上目標值。
  • 四、解析
    解析時虛擬機將常量池中的符號引用替換爲直接引用。
  • 五、初始化
    初始化階段是執行類構造器()方法的過程。類構造器()方法是由編譯器自動收藏類中的全部類變量的賦值動做和靜態語句塊(static塊)中的語句合併產生。
    當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化。
    虛擬機會保證一個類的()方法在多線程環境中被正確加鎖和同步。
參考 https://www.cnblogs.com/dooor/p/5289994.html
複製代碼

一個項目、jar包、war包中有數百成千上萬的字節碼文件,一個字節碼文件只能被加載一次(類加載器的委託機制),JVM是一次性加載所有文件的嗎?確定不是,具體的實現由不一樣的JVM自由發揮,但對於初始化階段JVM有明確要求(被引用),天然初始化以前的階段也必須完成。

類的引用方式

主動引用
一、當使用new關鍵字實例化對象時,當讀取或者設置一個類的靜態字段(被final修飾的除外)時,以及當調用一個類的靜態方法時(好比構造方法就是靜態方法),若是類未初始化,則需先初始化。
二、經過反射機制對類進行調用時,若是類未初始化,則需先初始化。
三、當初始化一個類時,若是其父類未初始化,先初始化父類。
四、用戶指定的執行主類(含main方法的那個類)在虛擬機啓動時會先被初始化。

被動引用
除了上面這4種方式,全部引用類的方式都不會觸發初始化,稱爲被動引用。如:
經過子類引用父類的靜態字段,不會致使子類初始化;
經過數組定義來引用類,不會觸發此類的初始化;
引用類的靜態常量不會觸發定義常量的類的初始化,由於常量在編譯階段已經被放到常量池中了。

參考 https://blog.csdn.net/zcxwww/article/details/51330327
複製代碼

初始化的對象會被放在什麼地方呢?

內存分配策略

兩個存儲位置:本地線程緩存TLAB和堆

新對象產生時首先檢查本地線程是否開啓了緩存,是則存儲在TLAB,不然去堆中尋找位置。

堆又分了:Eden、兩個Survivor、Tenured共4個區,Eden與Survivor大小比是8:1,Eden和Survivor稱爲新生代,Tenured稱爲老年代(JDK8已經沒有持久代了)

當新對象產生時,存放在Eden,當Eden放不下時觸發Minor GC,將Eden中存活的對象複製到一Survivor中。繼續存放對象到Eden,當Eden放不下時觸發Minor GC,將Eden和非空閒Survivor中存活的對象複製到空閒Survivor中,往復操做。每通過一次Minor GC,對象的年齡加1,當對象年齡達到閥值(默認15)進入Tenured。若是在Minor GC期間發現存活對象沒法放入空閒的Survivor區,則會經過空間分配擔保機制使對象提早進入Tenured。若是在Survivor空間中的相同年齡的全部對象大小的總和大於Survivor空間的一半,年齡大於和等於該年的對象就能夠直接進入老年代,無需等到指定的閥值。

空間分配擔保機制:
在執行Minor GC前, VM會首先檢查Tenured是否有足夠的空間存放新生代尚存活對象,因爲新生代使用複製收集算法,爲了提高內存利用率,只使用了其中一個Survivor做爲輪換備份,所以當出現大量對象在Minor GC後仍然存活的狀況時,就須要老年代進行分配擔保,讓Survivor沒法容納的對象直接進入老年代,但前提是老年代須要有足夠的空間容納這些存活對象。但存活對象的大小在實際完成GC前是沒法明確知道的,所以Minor GC前,VM會先首先檢查老年代連續空間是否大於新生代對象總大小或歷次晉升的平均大小,若是條件成立, 則進行Minor GC,不然進行Full GC(讓老年代騰出更多空間)。然而取歷次晉升的對象的平均大小也是有必定風險的,若是某次Minor GC存活後的對象突增,遠遠高於平均值的話,依然可能致使擔保失敗(Handle Promotion Failure,老年代也沒法存放這些對象了),此時就只好在失敗後從新發起一次Full GC(讓老年代騰出更多空間)。

分代的惟一理由就是優化GC性能,讓GC在固定區域工做。

GC

  • 一、Minor GC
    在年輕代(Eden和Survivor)中執行的GC
  • 二、Major GC
    在老年代(Tenured)中執行的GC
  • 三、Full GC
    清理整個堆空間包括年輕代和老年代

垃圾回收最重要的一點是如何判斷對象爲垃圾?

可達性分析算法

經過一系列稱爲GC Roots的對象做爲起點,而後向下搜索,搜索所走過的路徑稱爲引用鏈/Reference Chain,當一個對象到GC Roots沒有任何引用鏈相連時,即該對象不可達,也就說明此對象是不可用的,如圖:Object五、六、7雖然互有關聯,但它們到GC Roots是不可達的,所以也會被斷定爲可回收的對象。

回收時的回收算法:

分代
根據對象存活週期的不一樣將內存劃分爲幾塊,如JVM中的新生代、老年代,這樣就能夠根據各年代特色分別採用最適當的GC算法:

在新生代:每次垃圾收集都能發現大批對象已死,只有少許存活。所以選用複製算法,只須要付出少許存活對象的複製成本就能夠完成收集。

在老年代:由於對象存活率高、沒有額外空間對它進行分配擔保,就必須採用「標記—清理」或「標記—整理」算法來進行回收,沒必要進行內存複製,且直接騰出空閒內存。

新生代-複製算法

該算法的核心是將可用內存按容量劃分爲大小相等的兩塊,每次只用其中一塊,當這一塊的內存用完,就將還存活的對象複製到另一塊上面,而後把已使用過的內存空間一次清理掉。

這使得每次只對其中一塊內存進行回收,分配也就不用考慮內存碎片等複雜狀況,實現簡單且運行高效。

但因爲新生代中的98%的對象都是生存週期極短的,所以並不需徹底按照1:1的比例劃分新生代空間,因此新生代劃分爲一塊較大的Eden區和兩塊較小的Survivor區。

老年代-標記清除算法
該算法分爲「標記」和「清除」兩個階段:首先標記出全部須要回收的對象(可達性分析), 在標記完成後統一清理掉全部被標記的對象。


該算法會有如下兩個問題:
一、效率問題:標記和清除過程的效率都不高;
二、空間問題:標記清除後會產生大量不連續的內存碎片,空間碎片太多可能會致使在運行過程當中須要分配較大對象時沒法找到足夠的連續內存而不得不提早觸發另外一次垃圾收集。

老年代-標記整理算法

標記清除算法會產生內存碎片問題,而複製算法須要有額外的內存擔保空間,因而針對老年代的特色,又有了標記整理算法。標記整理算法的標記過程與標記清除算法相同,但後續步驟再也不對可回收對象直接清理,而是讓全部存活的對象都向一端移動,而後清理掉端邊界之外的內存。

分區

將整個堆空間劃分爲連續的不一樣小區間,每一個小區間獨立使用,獨立回收。這樣作的好處是能夠控制一次回收多少個小區間。

在相同條件下,堆空間越大,一次GC耗時就越長,從而產生的停頓也越長。爲了更好地控制GC產生的停頓時間,將一塊大的內存區域分割爲多個小塊,根據目標停頓時間,每次合理地回收若干個小區間(而不是整個堆),從而減小一次GC所產生的停頓。

參考 http://www.importnew.com/23035.html
複製代碼

對象逃逸(點到爲止)

本該銷燬的對象,逃到了它處。

public class A {
    public static Object obj;
    public void globalVariableEscape() {  // 給全局變量賦值,發生逃逸
        obj = new Object();//new的對象本該在棧幀出棧時銷燬,但被外部static引用致使進入方法區常量池
    }
    public Object methodEscape() {  // 方法返回值,發生逃逸
        return new Object();//new的對象本該在棧幀出棧時銷燬,但被外部方法或線程引用,致使對象只能在外部方法棧幀出棧或線程銷燬時被清理
    }
    public void instanceEscape() {  // 實例引用發生逃逸
        b = new B(this); //(示意而已,並不許確)新建B對象時引用了A對象,除B使用外,其他無引用A,此時本能夠回收A,但B卻引用致使沒法回收。循環引用就是A在引用B,致使互相引用都不能被回收。
    }
}

public class B {
    public static Object obj;
    public void instance(A a) {  // 引用傳入的實例
        obj = a;
    }
}
複製代碼

對象的引用類型(強軟弱虛)

JVM中真正將一個對象判死刑至少須要經歷兩次標記過程:

第一個過程,可達性分析算法
第二個過程,判斷這個對象是否須要執行finalize()方法。

第一次GC時,對象在經歷了可達性分析算法被標記後,若對象重寫了finalize()方法且沒被執行過則會被放入F-Queue隊列中,不然回收。
第二次GC時,JVM會有一個優先級比較低的線程去執行隊列中對象的finalize()方法,執行只是觸發finalize()方法並不會確保方法必定完成,防止死循環或異常等狀況致使對象不可被回收,這時第二次標記完成對象被回收。

只有當對象存在引用鏈鏈接GC Roots時才確保不會被回收,即對象爲強引用。那麼有些對象,咱們但願在內存充足的狀況下不要回收,在內存不足的時候再將其回收掉。若是隻有強引用,那這個對象永遠都不會被回收。因而有了軟引用、弱引用、虛引用的概念。

  • 一、強引用
    即便OOM也不會被回收
  • 二、軟引用
    內存不足時纔會被回收
  • 三、弱引用
    只要GC就會被回收
  • 四、虛引用
    惟一的做用就是監聽被回收
參考 https://blog.csdn.net/huachao1001/article/details/51547290
複製代碼

類卸載

卸載須要知足三個條件:
一、該類全部的實例已經被回收
二、加載該類的ClassLoder已經被回收
三、該類對應的java.lang.Class對象沒有被引用

JVM自帶的根類加載器、擴展類加載器和系統類加載器,JVM自己會始終引用這些類加載器,所以條件2不會造成。

而這些類加載器則會始終引用它們所加載的類對象,所以條件3也不會造成。

惟一會被卸載的類只有自定義的類加載器加載的類。

Dalvik(DVM)

爲何大篇幅講JVM,由於Dalvik虛擬機是Google按照JVM虛擬機規範定製的虛擬機,所應用的可能是處理能力、內存、和存儲等處理能力受限的設備,更符合移動設備的環境要求。

架構
JVM基於棧架構,Dalvik基於寄存器架構,所以讀寫速度較快

空間
可執行程序的字節碼不一樣
JVM:java -> class -> jar
DVM:java -> class -> dex -> apk

jar由多個class構成,而dex是由多個class合併構成,消除了數據冗餘節省了空間,但合併後方法數變多,產生了方法數受限(65535)的問題。

沙盒
Dalvik虛擬機容許在內存中建立多個實例,以隔離不一樣的應用程序。這樣,當一個應用程序在本身的進程中崩潰後,不會影響其它進程的運行。

ART

在Dalvik下,應用每次運行都須要經過即時編譯器(JIT)將字節碼轉換爲機器碼,即每次都要編譯加運行,因此啓動時間長。

ART則在應用安裝時就預編譯字節碼到機器語言,因此安裝過程較Dalvik時間長存儲空間佔用更大,但應用每次運行時不須要編譯減小了CPU的負擔,啓動時間短。

原創做者:s1991721
原文連接:https://www.jianshu.com/u/0790f0629fc6
歡迎關注個人微信公衆號「碼農突圍」,分享Python、Java、大數據、機器學習、人工智能等技術,關注碼農技術提高•職場突圍•思惟躍遷,20萬+碼農成長充電第一站,陪有夢想的你一塊兒成長。

相關文章
相關標籤/搜索