jvm的類加載機制

類從被加載到虛擬機內存中開始,到卸載出內存結束,真個生命週期包括了幾個階段:

加載-》驗證-》準備-》解析-》初始化-》使用-》卸載

 

虛擬機規範嚴格規定了有且只有四種情況必須立即對類進行初始化操作

  1. 遇到new,getstatic,putstatic,invokestatic這4條字節碼指令時,如果類沒有進行初始化,必須先初始化,生成這4條指令最常見的java代碼場景是:new一個對象、讀取或者設置一個類的靜態字段(被final修飾,在編譯期把結果放入常量池的靜態字段除外)、調用一個類的靜態方法
  2. java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有初始化,必須觸發初始化
  3. 當初始化一個類時,如果其父類還沒有初始化,則先初始化其父類
  4. 當虛擬機啓動時,需要先初始化一個主類(包含了main方法的類)

 

一、加載

虛擬機規範了加載階段的三件事

  1. 通過一個類的全限定名來獲取定義此類的二進制流
  2. 將這個二進制字節流代表的靜態存儲結構轉化爲方法區的運行時數據結構
  3. 在java堆生成這個類的Class對象,作爲方法區這些數據的訪問入口

虛擬機的規範並沒有非常明確的規定如何來獲取類的二進制字節流,因此加載階段出現了各種花樣

  1. 從zip包讀取,最終成爲了JAR,WAR格式的基礎
  2. 從網絡中獲取
  3. 運行時計算,即動態代理動態生成代理的二進制字節流
  4. 由其他文件生成例如JSP

人們可以通過使用系統提供的類加載器來加載,也可以自定義類加載器來加載

 

二、準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區進行分配,這時候進行內存分配的僅包括類變量,也就是static修飾的變量,初始值通常情況下指的是零值,比如

public static int value = 123;準備階段過後value值爲0,而不是123;特殊情況下,靜態變量被final修飾,那麼在編譯期javac將會爲value生成ConstantValue屬性,在準備階段value就會被初始化爲ConstantValue指定的值

 

三、初始化

初始化階段就是執行類構造器<cinit>()方法的過程,

  1. <cinit>()方法是由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊中的語句合併而成,靜態語句塊中只能訪問定義在靜態語句塊之前的靜態變量,定義在其後的變量,靜態語句塊中可以對其賦值,但不能對其進行訪問,也就是說不能調用其成員變量和方法
  2. <cinit>()方法與類的構造器不同,類的構造器是實例初始化方法,<cinit>()不需要顯式調用父類的<cinit>()方法,虛擬機會在調用子類的<cinit>()之前,首先保證父類<cinit>()已經執行完畢,因此,虛擬機中第一個執行<cinit>()的類就是java.lang.Object
  3. 由於父類的<cinit>()方法首先被調用,因此變量賦值操作也會優先於子類
  4. <cinit>()方法對於類和接口來說不是必須的,如果一個類中沒有靜態代碼塊,也沒有靜態變量賦值操作,那麼編譯器可以不爲該類生成<cinit>()方法
  5. 接口不能使用靜態語句塊,但仍然有變量賦值操作,與類不同的是,接口在執行<cinit>()方法之前不會去執行其父接口的<cinit>()方法,只有當父接口的變量被使用時,父接口才會初始化,同理,接口的實現類在執行<cinit>()之前也不會調用接口的<cinit>()
  6. <cinit>()方法的執行是線程安全的,多線程在執行類初始化時,只會有一個線程會執行<cinit>()方法,其他線程阻塞

 

四、類加載器

對於任意一個類,需要加載這個類的類加載和其本身一同確定類在虛擬機中的唯一性。比較兩個類是否相等,只有在兩個類是被同一類加載器加載的情況下才有意義,否則,即使兩個類是由同一個class文件加載的,只要加載他們的類加載器不同,那麼兩個類必定不相等

雙親委派模型

站在java虛擬機的角度來講,只存在兩種不同的類加載器,一種是啓動類加載器Boostrap,這個類加載器是c++編寫的,隸屬於虛擬機本身,另外一種就是所有其他的類加載器,這些類加載器都是由java編寫,屬於虛擬機之外的加載器,並且全部繼承自抽象類java.lang.ClassLoader

從Java程序員的角度來看,一般會用到三種系統提供的類加載器

  1. Boostrap 前面講了
  2. 擴展類加載器Extension ClassLoader負責加載JAVA_HOME/lib/ext目錄中的,或者被java.ext.dirs系統變量指定的路徑下的所有類庫,開發者可以直接使用擴展類加載器
  3. 應用程序類加載器 Application 由於這個類加載器是由ClassLoader.getSystemClassLoader()方法返回的,所以也稱作是系統類加載器,它負責加載classpath上所指定的類庫,是程序中默認的類加載器

Bootstrap -> Extension -> Application -> userClassLoader

這樣的一種層次關係成爲類的雙親委派模型,雙親委派模型要求除了頂層的Boostrap加載器之外,其餘的類加載器都應當有自己的父加載器,這裏類加載器之間的父子關係一般不會使用繼承來實現,而是使用組成的方式來複用父加載器的代碼

雙親委派模型的工作方式:如果一個類加載器收到了一個加載類的請求,它首先不會自己去加載這個類,而是把這個請求委派給父加載器去加載,每一個層次的類加載器都是如此,因此所有的請求最終都會傳送到頂層的Boostrap加載器中,只有當父加載器反饋自己無法加載該類時,子加載器纔會自己嘗試加載

使用雙親委派模型組織類加載器的好處是,java類隨着加載它的類加載器也有了層次優先級,例如Object類,他存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都會委派給Bootstrap進行加載,保證了Object類在各種類加載器的環境中都是同一個類

 

五、執行引擎

棧幀:是java虛擬機數據區中的虛擬機棧的棧元素

棧幀中包含了局部變量表,操作數棧,動態鏈接,方法返回地址

局部變量表中存儲了方法的參數,以及方法中定義的局部變量,在java程序被編譯爲class文件時,就已經確定了最大局部變量表的容量

 

方法調用,不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本(比如我調用一個接口方法,這裏就需要確定執行哪個實現類的方法),class文件編譯的過程,不包含傳統編譯中的連接操作,所有方法在class文件中存儲的都是符號引用,而不是方法在運行時內存佈局的真正入口地址,也就是直接引用,需要在類加載期間或者運行時動態獲取目標方法的直接引用,什麼樣的方法是在加載期間獲取直接引用,什麼樣的方法在運行期間獲取直接引用呢?

當一個方法在編譯期就已經確定運行時不可變,那麼它就會在類加載的時候符合引用轉化爲直接引用,這類方法一般都是靜態方法或者私有方法,別的類無法通過繼承或者別的方式重寫出其他的版本,這類方法的調用成爲解析

與之對應的有四條虛擬機字節碼指令

  1. invokestatic:調用靜態方法
  2. invokespecial:調用實例構造器,私有方法,父類方法
  3. invokevirtual:調用所有的虛方法
  4. invokeinterface:調用接口方法

只要能被1,2兩條指令調用的方法都可以在類加載期間將符號引用轉化爲直接引用,被final修飾的方法不可變編譯期可知,運行期不可變,因此也是在類加載期間轉化直接引用的,解析調用一定是一個靜態的過程,在編譯期就會完全確定,在類加載階段就會將涉及到的符號引用轉化爲直接引用,

 

分派:不同於解析,分派包括靜態分派和動態分派

  • 靜態分派,介紹靜態分派之前我們首先看一下重載,Human Man Woman  三個類,Man和Woman都繼承自Human,Human man = new Man();對於這段代碼的理解,Human我們稱爲變量的靜態類型static type,後面的Man稱爲變量的實際類型,靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅是在使用時發生,變量的本身的靜態類型是不會發生改變的,並且編譯期可知,而實際類型只能在運行期才確定,編譯器在編譯程序的時候並不知道變量的實際類型是什麼

            //實際類型的變化比較好理解

            Human man = new Man();

            man = new Woman();

            //靜態類型的變化

            sr.sayHello((Man) man);

            sr.sayHello((Woman) man);

            由於重載方法只有入參不同,所以使用哪個重載版本就取決於參數的類型和個數,編譯期在重載時是通過參數的靜態類型而不是實際類型作爲判斷依據的,因爲實際類型在編譯期是不可知的,而編譯期需要確定使用哪個重載版本就只能根據靜態類型,所有依賴靜態類型來定位方法執行版本的分派動作,都稱爲靜態分派,最典型的應用就是重載,而對於一些沒有顯式靜態類型的字面量,很多情況下重載版本不是唯一的,往往只能確定一個更合適的版本,例如

char->int->long->float->double->Character->Character實現的接口->Object,這樣的一個重載順序

  • 動態分派

靜態分派與重載由密切的聯繫,動態分派和多態性的另外一個重要體現重寫同樣有密切聯繫,因爲編譯期是無法確定變量的實際類型的,所以動態分派顯然是運行期的操作,編譯期只會在方法調用的地方加一個invokevirtual指令,等到運行的時候,做出相應的操作

  1. 找出操作數棧頂的第一個元素指向的對象的實際類型,記作C
  2. 如果C中找到了和常量池中符號引用(描述符和方法的簡單名稱)相同的方法,則進行訪問權限檢驗,如果通過返回方法的直接引用,查找結束,不通過則拋非法訪問異常
  3. C中沒有找到和常量池中符號引用相同的方法,則按照繼承關係自下往上依次搜索
  4. 如果最終都沒有找到,則拋出java.lang.AbstractMethodError

由於invokevirtual指令第一步確定的就是變量的實際類型,因此兩次調用Invokevirtual指令的時候都會把常量池中的符號引用轉化到不同的直接引用上,這個過程就是重寫的本質.

 

六、基於棧的字節碼解釋執行引擎

java虛擬機的執行引擎在執行java代碼的時候有兩種方式,一種是解釋執行,通過解釋器執行,另一種是即時編譯,通過即時編譯器產生本地代碼執行

無論是解釋還是編譯,無論是虛擬機還是物理機,大部分的程序代碼到物理機的目標代碼或虛擬機能執行的指令集之前,都需

要經過幾個步驟

即時編譯器會將頻繁執行的代碼直接編譯爲本地代碼緩存起來,下次調用時就可以直接執行而不用逐條解釋,加快執行速度

 

總結:

jvm虛擬機可以分爲三大部分,

  1. 類加載器
  2. 字節碼執行引擎
  3. 運行時數據區

jvm是基於棧的指令集,而不是基於寄存器的指令集,舉個例子,1+1,基於棧的指令集 iconst 1, iconst 1 iadd, istore_0,意思是兩次將1壓入棧中,iadd彈出棧進行加法計算後重新入棧,然後將結果保留到局部變量表0的slot位置,而基於寄存器的指令集是  mov eax ,1  add  eax, 1,mov指令將1放入寄存器eax中,然後將1和eax的值進行加法計算,結果放入eax中,很明顯寄存器的指令少,運行速度快,少了很多入棧出棧的操作。但是基於寄存器的指令集與硬件強關聯無法做到可移植,不符合java的理念,而基於棧的指令集就不用考慮底層對於寄存器的操作,可以做到可移植性,並且指令緊湊,一個字節對應一條指令,不需要考慮空間分配,但是由於入棧出棧頻繁,意味着內存的交互很多,因此速度會比寄存器指令集執行慢很多

 

java的逃逸分析:一個方法返回了一個方法中產生的新對象時,外部如果調用這個方法就有可能對該對象做出修改,所以我們就說這個對象逃逸了,如果這個方法中新產生的對象不會當作返回值返回出去,那麼這個對象就沒有逃逸,這樣就可以使用標量替換在棧中分配內存,而不會在堆中爲其分配內存。

public static StringBuffer craeteStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb; //sb逃逸出去了
}
 
public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();  //sb在方法內部消化,沒有逃逸
}

具體的逃逸分析可以https://blog.csdn.net/w372426096/article/details/80938788

注:加入一些我個人的思考,衆所周知,程序的執行是由CPU控制器取指執行,機器指令是CPU硬件決定的一些列0 1代碼,這種代碼晦澀難記因此,開發人員不可能直接編寫0 1代碼來完成程序,所以彙編語言就出現了,他使用了一些容易記憶和理解的指令來代替機器指令,並且一一對應,所以開發人員可以使用彙編語言對計算機進行一些操作,但是由於彙編指令和機器指令一一對應所以也會導致彙編程序非常龐大冗餘,所以我們需要將指令進行抽象,創造出更加容易編寫的語言,所以c語言也就誕生了,C語言規定了語法詞法等等規則,通過編譯器可以將源代碼轉化爲彙編指令,然後再通過彙編器將彙編指令轉爲機器指令,通過鏈接庫函數以及啓動函數(也就是和操作系統的接口)從而變爲可執行的指令集,java語言是運行在jvm虛擬機上的,jvm虛擬機與操作系統之間通過一些列的接口進行通信,java程序不用考慮這一層的原理,面向對象設計更加簡單易懂,java程序要運行,首先需要編譯器javac將java程序編譯爲class文件,再經過jvm虛擬機中的解釋器逐條翻譯爲機器指令進行執行(JIT即時編譯器不在這裏做描述),因此運行速度會比c語言慢很多,但是卻易於編寫。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章