面試必問 Java類加載機制和類加載器

1. 類加載機制

所謂類加載機制就是JVM虛擬機把Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,形成虛擬機可以直接使用的Jav類型,即Java.lang.Class。

2. 類加載的過程

類加載的過程主要有裝載(Load)、鏈接(Link)、初始化(Initialize)

2.1 裝載(Load)

類的加載指的是將類的.class文件中的二進制數據讀入到內存中,將其放在運行時數據區的方法區內,然後在堆區創建一個java.lang.Class對象,用來封裝類在方法區內的數據結構。類的加載的最終產品是位於堆區中的Class對象,Class對象封裝了類在方法區內的數據結構,並且向Java程序員提供了訪問方法區內的數據結構的接口。
在這裏插入圖片描述
類加載器並不需要等到某個類被“首次主動使用”時再加載它,JVM規範允許類加載器在預料某個類將要被使用時就預先加載它,如果在預先加載的過程中遇到了.class文件缺失或存在錯誤,類加載器必須在程序首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程序主動使用,那麼類加載器就不會報告錯誤。

加載.class文件的方式

  • 從本地系統中直接加載
  • 通過網絡下載.class文件
  • 從zip,jar等歸檔文件中加載.class文件
  • 從專有數據庫中提取.class文件
  • 將Java源文件動態編譯爲.class文件

2.2 鏈接(Link)

鏈接這一過程又可以分爲驗證(Validate)、準備(Prepare)、解析(Resolve)三個階段

  • 驗證(Validate)
    保證被加載類的正確性。其主要包括四種驗證,文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證。

  • 準備(Prepare)
    爲類的靜態變量分配內存,並將其初始化爲默認值

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中分配。對於該階段有以下幾點需要注意

  1. 這時候進行內存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨着對象一塊分配在Java堆中。
  2. 這裏所設置的初始值通常情況下是數據類型默認的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予的值。

假設一個類變量的定義爲:public static int value = 3;
那麼變量value在準備階段過後的初始值爲0,而不是3,因爲這時候尚未開始執行任何Java方法,而把value賦值爲3的putstatic指令是在程序編譯後,存放於類構造器()方法之中的,所以把value賦值爲3的動作將在初始化階段纔會執行。

這裏還需要注意以下幾點

  1. 對基本數據類型來說,對於類變量(static)和全局變量,如果不顯式地對其賦值而直接使用,則系統會爲其賦予默認的零值,而對於局部變量來說,在使用前必須顯式地爲其賦值,否則編譯時不通過。
  2. 對於同時被static和final修飾的常量,必須在聲明的時候就爲其顯式地賦值,否則編譯時不通過;而只被final修飾的常量則既可以在聲明時顯式地爲其賦值,也可以在類初始化時顯式地爲其賦值,總之,在使用前必須爲其顯式地賦值,系統不會爲其賦予默認零值。
  3. 對於引用數據類型reference來說,如數組引用、對象引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會爲其賦予默認的零值,即null。
  4. 如果在數組初始化時沒有對數組中的各元素賦值,那麼其中的元素將根據對應的數據類型而被賦予默認的零值
  5. 如果類字段的字段屬性表中存在ConstantValue屬性,即同時被final和static修飾,那麼在準備階段變量value就會被初始化爲ConstValue屬性所指定的值。假設上面的類變量value被定義爲: public static final int value = 3;,編譯時Javac將會爲value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值爲3。我們可以理解爲static final常量在編譯期就將其結果放入了調用它的類的常量池中
  • 解析(Resolve)
    解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。符號引用就是一組符號來描述目標,可以是任何字面量。直接引用就是直接指向目標的指針、相對偏移量或一個間接定位到目標的句柄。

2.3 初始化

對類的靜態變量,靜態代碼塊執行初始化操作。準備階段和初始化階段看似有點矛盾,其實是不矛盾的,如果類中有語句:private static int a = 10,它的執行過程是這樣的,首先字節碼文件被加載到內存後,先進行鏈接的驗證這一步驟,驗證通過後準備階段,給a分配內存,因爲變量a是static的,所以此時a等於int類型的默認初始值0,即a=0,然後到解析,到初始化這一步驟時,才把a的真正的值10賦給a,此時a=10。

JVM負責對類進行初始化,主要對類變量進行初始化。在Java中對類變量進行初始值設定有兩種方式:

  1. 聲明類變量是指定初始值,也就是直接給類別量一個值
  2. 使用靜態代碼塊爲類變量指定初始值

初始化,主要是執行類的類構造器< clinit>()方法,JVM會將類中的靜態代碼塊和靜態變量的賦值語句放在該方法裏面。

JVM初始化步驟

1、假如這個類還沒有被加載和鏈接,則程序先加載並鏈接該類

2、假如該類的直接父類還沒有被初始化,則先初始化其直接父類

3、假如類中有初始化語句,則系統依次執行這些初始化語句

類初始化時機:只有當對類的主動使用的時候纔會導致類的初始化,類的主動使用包括以下六種:

– 創建類的實例,也就是new的方式

– 訪問某個類或接口的靜態變量,或者對該靜態變量賦值

– 調用類的靜態方法

– 反射(如Class.forName(“com.shengsiyuan.Test”))

– 初始化某個類的子類,則其父類也會被初始化

– Java虛擬機啓動時被標明爲啓動類的類(Java Test),直接使用java.exe命令來運行某個主類

3. clinit方法

類初始化方法clinit:JVM通過Classload進行類型加載時,如果在加載時需要進行類的初始化操作時,則會調用類型、的初始化方法。 clinit方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句中可以賦值,但是不能訪問。

  1. clinit方法對於類或接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那麼編譯器可以不爲這個類生成clinit方法。
  2. 接口中不能使用靜態語句塊,但仍然有類變量(final static)初始化的賦值操作,因此接口與類一樣會生成clinit方法。但是接口魚類不同的是:執行接口的clinit方法不需要先執行父接口的clinit方法,只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也一樣不會執行接口的clinit方法。
  3. 虛擬機會保證一個類的clinit方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的clinit方法,其他線程都需要阻塞等待,直到活動線程執行clinit方法完畢。如果在一個類的clinit方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應用中這種阻塞往往是很隱蔽的。

說到 clinit方法,就不得不說一下對象實例化方法init。

對象實例化方法init:Java對象在被創建時,會進行實例化操作,給成員變量賦值。該部分操作封裝在init方法中,並且子類的init方法中會首先對父類init方法的調用。

clinit 方法和init 方法的區別

  • init和clinit方法執行時機不同
  • init是對象構造器方法,也就是說在程序執行new 一個對象調用該對象類的 constructor 方法時纔會執行init方法,而clinit是類構造器方法,也就是在jvm進行類加載—–驗證—-解析—–初始化,中的初始化階段jvm會調用clinit方法。
  • init和clinit方法執行目的不同
  • init是instance實例構造器,對非靜態變量解析初始化,而clinit是class類構造器對靜態變量,靜態代碼塊進行初始化
  • clinit 和init方法的數量不同
  • 編譯器最多隻爲一個類生成一個clinit方法,如果類中沒有靜態成員或者代碼塊的話,就不有clint方法。而init方法,類中一個構造函數就對應一個init方法

4. 類加載器

類加載器負責加載所有的類,其爲所有被載入內存中的類生成一個java.lang.Class實例對象。一旦一個類被加載如JVM中,同一個類就不會被再次載入了。正如一個對象有一個唯一的標識一樣,一個載入JVM的類也有一個唯一的標識。在Java中,一個類用其全限定類名(包括包名和類名)作爲標識;但在JVM中,一個類用其全限定類名和其類加載器作爲其唯一標識。例如,如果在pg的包中有一個名爲Person的類,被類加載器ClassLoader的實例kl負責加載,則該Person類對應的Class對象在JVM中表示爲(Person.pg.kl)。這意味着兩個類加載器加載的同名類:(Person.pg.kl)和(Person.pg.kl2)是不同的、它們所加載的類也是完全不同、互不兼容的。

JVM預定義有三種類加載器,當一個 JVM啓動的時候,Java開始使用如下三種類加載器:

  • 啓動類加載器(Bootstrap ClassLoader):負責加載存放在JDK\jre\lib(JDK代表JDK的安裝目錄,下同)下,或被-Xbootclasspath參數指定的路徑中的,並且能被虛擬機識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader加載)。啓動類加載器是由C++實現的,沒有對應的Java對象,因此在Java中只能用null代替。

  • 擴展類加載器(Extension ClassLoader):負責加載java平臺中擴展功能的一些jar包,包括JDK/jre/lib/*.jar 或 -Djava.ext.dirs指定目錄下的jar包。,開發者可以直接使用擴展類加載器。

  • 應用程序類加載器(Application ClassLoader):負責加載用戶類路徑(ClassPath)所指定的類,開發者可以直接使用該類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

  • 自定義類加載器 Custom ClassLoader: 通過繼承java.lang.ClassLoader根據自身需要自定義ClassLoader,如tomcat、jboss都會根據j2ee規範自行實現ClassLoader。

5. 雙親委派模型

幾種類加載器的層次關係如下圖所示
在這裏插入圖片描述

這種層次關係稱爲類加載器的雙親委派模型。我們把每一層上面的類加載器叫做當前層類加載器的父加載器,當然,它們之間的父子關係並不是通過繼承關係來實現的,而是使用組合關係來複用父加載器中的代碼。該模型在JDK1.2期間被引入並廣泛應用於之後幾乎所有的Java程序中,但它並不是一個強制性的約束模型,而是Java設計者們推薦給開發者的一種類的加載器實現方式。

雙親委派模型的工作流程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委託給父加載器去完成,依次向上,因此,所有的類加載請求最終都應該被傳遞到頂層的啓動類加載器中,只有當父加載器在它的搜索範圍中沒有找到所需的類時,即無法完成該加載,子加載器纔會嘗試自己去加載該類。

雙親委派機制的優勢:採用雙親委派模式的是好處是Java類隨着它的類加載器一起具備了一種帶有優先級的層次關係,通過這種層級關可以避免類的重複加載,當父親已經加載了該類時,就沒有必要子ClassLoader再加載一次。其次是考慮到安全因素,java核心api中定義類型不會被隨意替換,假設通過網絡傳遞一個名爲java.lang.Integer的類,通過雙親委託模式傳遞到啓動類加載器,而啓動類加載器在覈心Java API發現這個名字的類,發現該類已被加載,並不會重新加載網絡傳遞的過來的java.lang.Integer,而直接返回已加載過的Integer.class,這樣便可以防止核心API庫被隨意篡改。

參考:

如有不足之處,歡迎指正,謝謝!

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