【JAVA進階架構師指南】之三:深入瞭解類載入機制

来源:https://www.cnblogs.com/wukongbubai/archive/2020/03/24/12556571.html
-Advertisement-
Play Games

前言 在上一篇文章中,我們知道了JVM的記憶體劃分,其中在說到方法區的時候說到方法區中存放的信息包括[已被JVM載入的類信息,常量,靜態變數,即時編譯的代碼等],整個方法區其實就和類載入有關. 類載入過程 類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入、驗證、準備、解析、初 ...


前言

  在上一篇文章中,我們知道了JVM的記憶體劃分,其中在說到方法區的時候說到方法區中存放的信息包括[已被JVM載入的類信息,常量,靜態變數,即時編譯的代碼等],整個方法區其實就和類載入有關.

類載入過程

  類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入、驗證、準備、解析、初始化、使用和卸載七個階段.它們開始的順序如下圖所示:
file
  而類載入是指的前五個階段,在這五個階段中,載入、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它既可能在初始化前開始(靜態分派),也可能初始化階段之後開始(動態分派)。另外註意這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或激活另一個階段。

靜態分派和動態分派

  我們都知道,JAVA語言三大特性是封裝、繼承和多態,其中多態的底層實現原理就是靜態分派和動態分派機制.那麼什麼是靜態分派和動態分派呢?

靜態分派

  所謂靜態分派,指的是方法在程式真正執行前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的.換句話說,調用目標在編譯器進行編譯時就必須確定下來,這類方法的調用稱為解析.符合靜態分派的方法有靜態方法、私有方法、實例構造器和父類方法四類,它們在類載入時就會把符號引用解析為該方法的直接引用,對應的過程發生在類解析階段.典型的應用是對應於多態中的方法重載:

class Human{  
}    
class Man extends Human{  
}  
class Woman extends Human{  
}  

public class StaticPai{  

    public void say(Human hum){  
        System.out.println("I am human");  
    }  
    public void say(Man hum){  
        System.out.println("I am man");  
    }  
    public void say(Woman hum){  
        System.out.println("I am woman");  
    }  

    public static void main(String[] args){  
        Human man = new Man();  
        Human woman = new Woman();  
        StaticPai sp = new StaticPai();  
        sp.say(man);  
        sp.say(woman);  
    }  
}  

  很明顯,答案會是:

 I am human
 I am human

  以上結果應該不難分析,首先Human man = new Man()中,我們把Human稱為靜態類型,Man稱為實際類型,由於編譯器在方法重載時是通過參數的靜態類型而不是實際類型作為判定依據的,因此執行結果如上,這就是靜態分派最典型的應用.

動態分派

  所謂動態分派,是指程式在運行期根據實際類型確定方法執行版本的過程,該過程發生在類初始化階段,典型的應用是對應於多態中的方法重寫:

class Eat{  
}  
class Drink{  
}  

class Father{  
    public void doSomething(Eat arg){  
        System.out.println("爸爸在吃飯");  
    }  
    public void doSomething(Drink arg){  
        System.out.println("爸爸在喝水");  
    }  
}  

class Child extends Father{  
    public void doSomething(Eat arg){  
        System.out.println("兒子在吃飯");  
    }  
    public void doSomething(Drink arg){  
        System.out.println("兒子在喝水");  
    }  
}  

public class SingleDoublePai{  
    public static void main(String[] args){  
        Father father = new Father();  
        Father child = new Child();  
        father.doSomething(new Eat());  
        child.doSomething(new Drink());  
    }  
}  

  運行結果會是:

爸爸在吃飯
兒子在喝水

  至於虛擬機是如何實現動態分派的,從位元組碼指令來說是invokevirtual指令,具體的實現就不再贅述了.講完了靜態分派和動態分派,讓我們再來詳細描述一下類載入過程中每個階段所做的工作.

載入

  載入是類載入過程的第一個階段,在載入階段,虛擬機需要完成以下三件事情:
  1、通過一個類的全限定名來獲取其定義的二進位位元組流(class文件本質上是一組二進位位元組流).
  2、將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構.
  3、在Java堆中生成一個代表這個類的Class對象作為對方法區中這些數據的訪問入口.
  說到載入就不得不提類載入器,在java語言中的類載入器有以下幾種:
file
  當一個類載入器收到了類載入的請求後,它首先不會自己去嘗試載入這個類,而是把請求委托給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜索範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類,我們將這種工作流程稱之為類載入器的雙親委派模型.

  雙親委派模型的好處是類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,這對於保證Java程式的穩定運作很重要.我們都說java語言是面向對象的語言,一切皆對象,所有類的父類都是Object類,這是為什麼呢?就是因為Object類是頂層啟動類載入器載入的(jre\lib下的rt.jar),所有子類在嘗試載入Obejct父類的時候都會委托給啟動類載入器,保證了所有類的父類都是同一個Object對象!

  另外載入階段也是開發人員可控性最強的階段,我們可以自定義載入器載入類,也可以使用JDK提供的載入器來動態載入代碼,比較典型的是JDBC創建資料庫連接的時候,都要調用Class.forName()方法來載入驅動類,另外我們也可以在代碼中調用this.getClass().getClassLoader()方法來載入類,需要註意的是這樣獲取的類載入器是應用程式類載入器.

驗證

  驗證的目的是為了確保Class文件中的位元組流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全.不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、位元組碼驗證和符號引用驗證.

準備

  準備階段是正式為類變數分配記憶體並設置類變數初始值的階段,這些記憶體都將在方法區中分配.
對於該階段有以下幾點需要註意:
  1、這時候進行記憶體分配的僅包括類變數(static),而不包括實例變數,實例變數會在對象實例化時隨著對象一塊分配在Java堆中.
  2、這裡所設置的初始值通常情況下是數據類型預設的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予的值.
  下表列出了Java中所有基本數據類型以及reference類型的預設零值:
file
  另外需要註意的是,對於常量,在該階段會直接賦值為代碼中設定的值.

解析

  解析階段是虛擬機將常量池中的符號引用轉化為直接引用的過程.所謂符號引用,可以大致歸結為以下三種:
  類和介面的全限定名(即帶有包名的Class名,如:org.lxh.test.TestClass)
  欄位的名稱和描述符(private、static等描述符)
  方法的名稱和描述符(private、static等描述符)

  而所謂直接引用,簡單理解就是直接指向引用對象,我們知道,主流有兩種訪問對象地址的方式,直接指針和句柄,因此這裡的直接引用既可以是直接指針,也可以是句柄.

初始化

  初始化是類載入過程的最後一步,到了此階段,才真正開始執行類中定義的Java程式代碼.在準備階段,類變數已經被賦過一次各數據類型的預設零值,而在初始化階段,則是根據程式員通過程式指定的值去初始化類變數和其他資源,或者可以從另一個角度來表達:初始化階段是執行類構造器clinit()方法的過程.這裡簡單說明下clinit()方法的執行規則:
  1.clinit()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句中可以賦值,但是不能訪問.

  2.clinit()方法與實例構造器init()方法(類的構造函數)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的clinit()方法執行之前,父類的clinit()方法已經執行完畢.因此,在虛擬機中第一個被執行的clinit()方法的類肯定是java.lang.Object。

  3.clinit()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成clinit()方法。

  4.介面中不能使用靜態語句塊,但仍然有類變數(final static)初始化的賦值操作,因此介面與類一樣會生成clinit()方法.但是介面與類不同的是:執行介面的clinit()方法不需要先執行父介面的clinit()方法,只有當父介面中定義的變數被使用時,父介面才會被初始化.另外,介面的實現類在初始化時也一樣不會執行介面的clinit()方法.

  5.虛擬機會保證一個類的clinit()方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的clinit()方法,其他線程都需要阻塞等待,直到活動線程執行clinit()方法完畢.如果在一個類的clinit()方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應用中這種阻塞往往是很隱蔽的.

  其中對我們最有幫助的也是需要我們記憶的是第一點,明白了這一點,就能清晰的知道變數的載入順序,再結合類初始化的規則,就能清晰的明白一個類的載入過程,那麼java類什麼時候需要初始化?

Java類什麼時候需要初始化

  Java虛擬機規範中並沒有強制要求什麼情況下需要開始類載入過程.但是對於初始化階段,虛擬機規範則嚴格規定了有且僅有5種情況必須立即對類進行“初始化”(而載入,驗證,準備自然需要在此之前開始):
  1.遇到new,getstatic,putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化.生成這四條指令單最常見的Java代碼場景是:使用new關鍵字實例化對象的時候,讀取或設置一個類的靜態欄位(被final修飾,已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及調用一個類的靜態方法的時候.

  2.使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化.

  3.當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化.

  4.當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類.

  5.當使用JDK1.7的動態語言支持時,如果一個Java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic,REF_outStatic,REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化.

  那麼讓我們來看一個實際的例子來理解類載入機制:

class Father{
    public static int a = 10;
    static {
        a= 20;
        System.out.println("觸發了father的載入");
    }
}

class Children extends Father{
    public static int b = a;
    static {
        System.out.println("觸發了children的載入");
    }
}

public class TestClassLoad {

    public static void main(String[] args) {
        System.out.println(Children.b);
    }
}

  讓我們來分析一下這段代碼的載入過程:
  1.首先,根據 java類什麼時候需要初始化中的規則第4條(當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類),虛擬機會先初始化TestClassLoad類.

  2.main方法中列印Children.b的值,根據 java類什麼時候需要初始化中的規則第1條(這裡是遇到getstatic位元組碼時),要觸發Children類的初始化.

  3.再根據java類什麼時候需要初始化中的規則第3條(當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化),會先觸發Father類的初始化.

  4.觸發Father類的初始化,根據類載入過程的準備階段,首先會為靜態變數a分配記憶體並且設置預設值,從上文我們知道預設值為0,再執行到初始化階段,根據clinit()方法的執行規則1,根據代碼順序會先將a設置為10,再執行static靜態代碼塊中的語句,將a賦值為20,並且列印日誌:觸發father的載入.

  5.繼續執行chidren的載入,此時按照我上述的分析過程,最後得到的結果是會將b的值賦值為20,因此最終的執行結果應該是:

觸發了father的載入
觸發了children的載入
20

  讓我們執行來驗證一下:
file
  結果和我們分析的結果一樣,說明我們的分析過程是完全正確的.假如我們在main方法中列印Children.a的值會如何?再假如我們把Father類中的靜態變數和static代碼塊的順序交換一下結果又如何呢?感興趣的童鞋可以自己分析下.

總結

  閱讀完本篇文章後,我想童鞋們應該明白了以下幾點:
  1.類載入過程.
  2.多態的底層實現:靜態分派和動態分派.
  3.java是如何保證所有類的父類都是同一個Object類的(雙親委派模型).
  4.java類什麼時候需要初始化
  其實,童鞋們回憶一下,是否曾經出去找工作筆試的時候,經常會遇到各種父類子類的static賦值,然後問我們最後的執行結果是什麼的啊?如果我們沒有弄明白類載入機制,就算答對了也是蒙對的,在看完了這篇文章弄明白了類載入機制之後,童鞋們是否有一種拍大腿叫絕的感覺:啊,我終於弄明白類載入機制了,以後筆試再遇到類似的題,媽媽再也不怕我蒙錯了!因為經過我們的分析,完全能得出準確的正確答案!

  本文我們深入瞭解了類載入機制,下一篇文章,讓我們來學習GC,敬請期待!

  如果覺得博主寫的不錯,歡迎關註博主微信公眾號,博主會不定期分享技術乾貨!
file

本文由博客一文多發平臺 OpenWrite 發佈!


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 一、Bootstrap Table列寬拖動的方法 Bootstrap Table可拖動列,需要用到它的Resizable擴展插件 需要引入 bootstrap-table擴展插件 bootstrap-table-resizable.js 這個插件還依賴於colResizable v1.6(下載地址: ...
  • 組件化的思想 將一個頁面中的處理邏輯放在一起,處理起來就會很複雜,不利於後續管理和擴展。 如果將頁面拆分成一個個小的功能塊,每個功能塊實現自己的內容,之後對頁面的管理和維護就變得很容易了。 註冊組件的基本步驟 1.創建組件構造器 2.註冊組件 3.使用組件 //創建組件構造器對象 const cpn ...
  • 事件註冊單個事件註冊語法: $('div').click(function () { 處理的事情 })$('div').click(function () { $(this).css('backgroundColor', 'red') });$('div').mouseenter(function ... ...
  • 前端是什麼? 工作流程為從UI處得到原型圖或者效果圖,在項目(網站、微信公眾號、小程式、WEBAPP)中還原圖片效果,然後與後臺進行各種數據交互。 目前的前端市場整體還是處於迅速發展期,市場對於前端的需求也一直比較大。市場對於中高級的前端工程師需求更加迫切,所以就算入了前端的門,也需要不斷的提升自己 ...
  • 在頁面中我們經常會使用到 checkbox 和 radio,但是,部分瀏覽器顯示的效果並不能讓我們滿意,因此想通過一種方式,更改其樣式。 這裡使用到的就是使用 label,結合 before 實現樣式的更改。 完整代碼: 效果圖: 這樣相比之前就好看那麼一點點,如果引入字體圖標,對 css 稍作更改 ...
  • 1.有關於屬性的操作 "項目源碼" 所謂的屬性操作就是操作一系列的元素的屬性,value啦class啦 .......特別是有關於input的操作是非常重要的。 為了完成後續有關於框架的高級騷操作,我們現在先來學習一下,jQuery的常用屬性操作的三種 prop(),attr(),data() (1 ...
  • 通過VueRouter實例的push()操作,可以進行路由跳轉,對於<router-link/>組件來說,它綁定的是click事件,最後也是通過執行push()方法來進行路由跳轉的。 對於push()方法來說,一共可以傳入三種形式的參數: 字元串形式,值為路勁 含有name的對象形式,可以搭配par ...
  • 說到數據上報,很多人第一印象是直接點對點的上報數據,優點是簡單直接省事,但是缺點很明顯,侵入性太強,業務中會摻雜很多非業務的事情。當然這裡的簡單省事是短暫的,如果業務以及開發完了,後面追加數據上報功能,再按照這個模式,將帶來空前的壓力,代碼基本上要重新寫、測試。這個任務量也許會很大,因為一時的省事為 ...
一周排行
    -Advertisement-
    Play Games
  • 下麵是一個標準的IDistributedCache用例: public class SomeService(IDistributedCache cache) { public async Task<SomeInformation> GetSomeInformationAsync (string na ...
  • 這個庫提供了在啟動期間實例化已註冊的單例,而不是在首次使用它時實例化。 單例通常在首次使用時創建,這可能會導致響應傳入請求的延遲高於平時。在註冊時創建實例有助於防止第一次Request請求的SLA 以往我們要在註冊的時候實例單例可能會這樣寫: //註冊: services.AddSingleton< ...
  • 最近公司的很多項目都要改單點登錄了,不過大部分都還沒敲定,目前立刻要做的就只有一個比較老的項目 先改一個試試手,主要目標就是最短最快實現功能 首先因為要保留原登錄方式,所以頁面上的改動就是在原來登錄頁面下加一個SSO登錄入口 用超鏈接寫的入口,頁面改造後如下圖: 其中超鏈接的 href="Staff ...
  • Like運算符很好用,特別是它所提供的其中*、?這兩種通配符,在Windows文件系統和各類項目中運用非常廣泛。 但Like運算符僅在VB中支持,在C#中,如何實現呢? 以下是關於LikeString的四種實現方式,其中第四種為Regex正則表達式實現,且在.NET Standard 2.0及以上平... ...
  • 一:背景 1. 講故事 前些天有位朋友找到我,說他們的程式記憶體會偶發性暴漲,自己分析了下是非托管記憶體問題,讓我幫忙看下怎麼回事?哈哈,看到這個dump我還是非常有興趣的,居然還有這種游戲幣自助機類型的程式,下次去大玩家看看他們出幣的機器後端是不是C#寫的?由於dump是linux上的程式,剛好win ...
  • 前言 大家好,我是老馬。很高興遇到你。 我們為 java 開發者實現了 java 版本的 nginx https://github.com/houbb/nginx4j 如果你想知道 servlet 如何處理的,可以參考我的另一個項目: 手寫從零實現簡易版 tomcat minicat 手寫 ngin ...
  • 上一次的介紹,主要圍繞如何統一去捕獲異常,以及為每一種異常添加自己的Mapper實現,並且我們知道,當在ExceptionMapper中返回非200的Response,不支持application/json的響應類型,而是寫死的text/plain類型。 Filter為二方包異常手動捕獲 參考:ht ...
  • 大家好,我是R哥。 今天分享一個爽飛了的面試輔導 case: 這個杭州兄弟空窗期 1 個月+,面試了 6 家公司 0 Offer,不知道問題出在哪,難道是杭州的 IT 崩盤了麽? 報名面試輔導後,經過一個多月的輔導打磨,現在成功入職某上市公司,漲薪 30%+,955 工作制,不咋加班,還不捲。 其他 ...
  • 引入依賴 <!--Freemarker wls--> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.30</version> </dependency> ...
  • 你應如何運行程式 互動式命令模式 開始一個互動式會話 一般是在操作系統命令行下輸入python,且不帶任何參數 系統路徑 如果沒有設置系統的PATH環境變數來包括Python的安裝路徑,可能需要機器上Python可執行文件的完整路徑來代替python 運行的位置:代碼位置 不要輸入的內容:提示符和註 ...