【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
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...