一篇文章帶你真正領略java虛擬機的類加載機制

前言:本篇博文結合了《深入理解java虛擬機》(第二版),以及張龍的 “深入理解JVM虛擬機”(B站視頻版)以及本人所看的各種其他書籍,及一些java面試題目之中介紹到的類加載機制部分,從底層全面講起來,真正的能夠理解這些過程,當然寫出來也是對學習情況的一種輸出的過程。

虛擬機的類加載機制

首先既然講到了虛擬機的類加載機制,我們當然就是想知道的第一點就是——什麼是類加載?

什麼是?

什麼是虛擬機的類加載機制: 把描述類的數據從Class文件加載到內存,並對數據進行校檢轉換解析和初始化,最終形成可以被虛擬機直接使用的java類型。 就是被稱爲 虛擬機的類加載機制。

怎麼做到?

過程
在這裏插入圖片描述
分爲五個大的步驟:加載 連接 初始化 使用 卸載
但是其中 連接又分爲三個步驟: 驗證 準備 解析

如何具體實現?

加載

加載是Java虛擬機中類加載中的第一個過程:
注意: 加載是類加載五個大步驟中的第一個小的步驟,不要弄混淆
關於加載 虛擬機會做以下的三件事情

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

注意: 但是關於第一點的獲取二進制字節流就有多種的方法:

  1. 在本地系統中直接加載
  2. 過網絡下載 .class 文件
  3. 通過 壓縮 zip jar,進行加載

加載完成之後: 虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區之中的數據存儲格式由虛擬機自行定義。

連接

連接分爲三個小的步驟來分步執行,這裏一一進行講解

驗證(連接階段的第一步)

這一步所做的工作很少:目的是爲了確保class文件的字節流中包含的信息符合當前虛擬機的要求,並不會危害虛擬機自身的安全
分爲一下的四個步驟:

  • 類文件的結構檢查
  • 語義檢查 (例如一個類不能是abstract 和final)
  • 字節碼驗證
  • 二進制兼容性的驗證

準備(連接階段的第二步)

這個階段相對之下還是比較重要的:

正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的的內存都將在方法區中進行分配
但是這個時候 要注意的是 這個時候進行內存分配僅僅包括類變量(被static修飾的變量,而不包括實例變量)賦初始值: 並不是我們認爲賦予的初始值,是根據類型所指定的初始零值。這個時候分配的都是給定變量的零值。
下圖是一些類型的變量所給定的零值。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-M201HzcV-1582691215600)(en-resource://database/1214:1)]
舉個栗子

public static int value=123;

在準備階段 會爲其賦予的是 0,而不是123 ,想要獲取到123 我們認爲給定的值,必須是在程序被編譯以後纔會有,存放在類構造器中才會執行。

解析(連接階段的第三步)

解析階段就是虛擬機將常量池內的符號引用替換成直接引用的過程
此時我們不禁會有疑問,什麼是符號引用,什麼又是直接引用呢。

  • 符號引用
    符號引用與虛擬機實現的佈局無關,引用的目標並不一定要已經加載到內存中。各種虛擬機實現的內存佈局可以各不相同,但是它們能接受的符號引用必須是一致的,因爲符號用的字面量形式明確定義在 Java 虛擬機規範的 Class 文件格式中
  • 直接引用
    直接引用可以是指向目標的指針,相對偏移量或是一個能間接定位到目標的句柄。如果有了直接引用,那引用的目標必定已經在內存中存在。

初始化

初始化算是類加載的最後一步,(爲什麼是類加載的最後一步:因爲對於後面的使用就是我們的調用的各種過程,已經不需要再做過多的介紹內容
在前面的各個步驟中除了加載階段用戶可以自定義的使用自己編寫的類加載器,其餘的階段都是自發進行性的。到了初始化階段 纔開始執行用戶所定義的java代碼。

前面講到了在準備階段中,系統會爲變量賦予了最開始的零值,在初始化階段就會根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源。其實初始化階段就是執行類構造器的階段<clinit>()
<clinit>() 方法是由編譯器自動收集類中的所有類變量的賦值動作,和靜態語句塊(static{}})中的語句合併產生的。

什麼時候初始化?

我們在上面已經完成了對基礎信息的理解與掌握。下面開始學習什麼時候一個類會初始化成功?這裏就要提及到主動使用被動使用
並且所有的虛擬機的實現 必須是每個類或接口 被java程序 首次主動使用 時候纔是會初始化。
主動使用 :

  • 創建類的實例 new
  • 訪問某個類或接口的靜態變量,或者對該靜態變量賦值。
  • 調用類的靜態方法
  • 反射
  • 初始化一個類的子類 在初始化一個類的子類 表示對父類的主動使用
  • 被標記爲啓動類 (包含main方法。)
被動使用

注意的是除了以上的情況下其他的方法,都被列爲被動使用 都不會初始化 ,即使已經執行完了初始化之前的步驟但是也不會初始化。

初始化步驟
  • 假如說這個類還沒有被加載和連接,那就先進行加載和連接。因爲任何類的三個步驟 都是 加載 連接 初始化。
  • 假如說這個類還有一個父類 那就先對這個類的父類進行初始化 (對於接口的類型 不進行初始化處理)
  • 假如說類存在初始化語句,那就依次執行這個初始化語句。
  • 一個父接口 不會因爲他的子接口 或者實現類的初始化而初始化 只用當程序首次使用特定接口的靜態變量時,纔會導致接口的初始化。
  • 在這裏插入圖片描述
    如下圖表示了類與接口的不同,在下面也會進行一一講解。

既然已經講到了初始化步驟時候,這裏就要講到final關鍵字:

final

表示常量在編譯的階段 這個常量就會被存入到 調用這個常量的方法所在的類的常量池中 所以就相當於會被放入到一個類的常量池中
可能這句話聽起來有點拗口,在下面的栗子中我們可以得知的是對於parent_3的 str1來說,其就會被存放到main方法的常量池中。
但是在本質上 調用類並沒有直接引用到定義常量的類 就是 說 在一個類中 引用另外一個類中的 final 時候 並不會對其類中的靜態代碼塊進行對應的初始化 (這個時候 兩則之前就沒有任何的關係 所以 就可以將class文件就算刪除 也是可以的)
栗子

  public static void main(String[] args) {
// 1        System.out.println(Parent_3.str1);
//  2       System.out.println(Parent_3.str);
    }
}
class  Parent_3{

    public static final String  str1="dd";
    public static  final  String  str=UUID.randomUUID().toString();
    static {
        System.out.println("parent static code ");
    }

此時對於上面的輸出:
在使用第一條打印語句的時候 只會打印出: dd。
在使用第二條打印語句的時候,會出現:
parent static code e39877fd-0bce-472c-a70d-320c9707f8bf
以上例子說明的是 在我們調用(因爲被final修飾時候 被稱爲 常量的) str 與 str1時候 由於 str1是一個編譯器就可以知道的常量,所以在調用時候 ,編譯期就知道其值,就會把它放到調用類的常量池中,這個時候 就不會對類Parent_3進行初始化。此時靜態代碼塊也就不會執行。但是在調用 str時候 由於 在編譯期間無法知道其值,是一個運行期常量,所以要對被調用類進行初始化才能夠知道其值,所以可以對靜態代碼塊進行打印輸出。

關於接口的基本特點

在前面講到的:一個父接口 不會因爲他的子接口 或者實現類的初始化而初始化 只用當程序首次使用特定接口的靜態變量時,纔會導致接口的初始化
在調用一個接口的時候,若是一個接口繼承自一個父類的接口。此時,若是刪除父類的接口,並不會產生問題,說明在接口類型中,調用子類的時候並不會對父類進行一個初始化。
這是爲什麼呢:
是因爲:對於一個接口來說時候 其中的值都是 public static final 類型的常量。前面我們有對final類型進行一個講解,就是說,會存放到調用類的常量池中。所以此時並不會執行初始化,也是原由。
此時若是把子類中的類型改成UUID類型的時候,刪除class 文件就會出現問題.
這說明了,靜態類型的時候 會在main函數中進行調用時候 加載到 常量池中。若是 UUID類型時候 就需要在運行期才能夠 知道其值,運行期時候就需要有其原class文件 所以 在使用到UUID,並且刪除掉class 時候 就會出現編譯的異常。但是在只有真正的使用到父類的時候 (例如應用父類中的常量時候) 纔會真正的初始化。


前面講解了final關鍵字和接口的相關問題,下面舉一個栗子來真實類在加載和初始化時候的特性。
栗子

public class Test4 {
    public static void main(String[] args) {
      Single single= Single.getSingle();
        System.out.println("A"+Single.a);
        System.out.println(Single.b);
    }
}
class  Single{
  public   static  int a=1;

    private static  Single single=new Single();
  private  Single(){
      a++;
      b++;
      System.out.println(a);
      System.out.println(b);
    }
    public   static  int b=0;
  public  static  Single getSingle(){
      return  single;
  }
}

最後打印的結果是
2 1 A2 0
解釋:由於前面提到的 第一個步驟是:連接+ 加載 + 初始化
在加載裏面還是會有 三個步驟其中有一個就是準備的過程 其目的
爲類的靜態變量賦初值,分配空間等
所以在開始時候 的準備中 會先進行一輪的初始化 int類型會變成0, string類型是 null 。所以第一輪的時候 a是系統初始化 0 new類型是null b也是 0 這個是準備階段。在執行階段時候 a賦值 1 調用過程中會調用到構造函數 此時 會對 a++ 和b++ 。執行到此時候 並沒有 我們人爲的對b 進行賦值 所以 此時的打印是 2,1 然後 執行到下面 時候 我們人爲賦值重新賦初值時候 ,又重新變成了 0 所以最後的打印是 0。
這個過程就深刻演示了在準備階段和初始化是什麼樣的過程。

初始化時機

類的卸載

栗子
例如說 一個類被加載 ,連接 初始化後,她的生命週期就開始了。當代碼這個類的Class文件不再被引用,即不可以觸及時候,Class對象就會結束生命週期,這個類在方法區內的數據也會被卸載,從而結束這個類的生命週期,就是說一個類什麼時候結束生命週期,取決於它所代表的Class對象什麼時候結束生命週期。
由java虛擬機自帶的類加載器所加載的類在虛擬機的生命週期中,始終不會被卸載。在後面我們介紹的: 根類加載器,擴展類加載器,和系統類加載器。java虛擬機本身會始終引用這個類加載器,而這些類加載器則會始終引用他們所加載的類的class對象,因此這些class對象始終是可觸及的。用戶自己定義的類加載器是可以卸載的


在完成了上面的講解以後,我們對類的記載的過程有了基礎的認知,關於 類和接口的問題是重總之中,兩者之前的不同也是在面試時候常常問到的問題。下面我們開始更加細緻的講解。

類加載器

什麼是類加載器:前面講到加載時候的第一件事:“通過一個類的全限定名來獲取描述此類的二進制字節流”但是這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何取獲取所需要的類。這個動作代碼模塊稱爲類加載器

類加載器:並不需要等到某一個類首次主動使用時候纔會加載它
(此時就想到了一個例子: 是說一個子類和一個父類,在其中都有靜態代碼塊 但是對於靜態代碼塊而言 只用對類進行初始化使用到時候 纔會被使用到 main函數中 使用子類調用父類的變量(這裏因爲 子類是繼承自父類的 所以可以使用其中的變量)但是打印出來的是 父類的靜態代碼塊 此時 對子類並沒有初始化 但是不代表沒有進行加載 關於類的加載而是 是會被加載的)所以有了以下的定義:
對於 jvm來說 運行類加載器在預料某個類將要被使用之前就會先對其進行一個預先加載,但是如果遇到了什麼錯誤,此時也並不一定會報錯,必須在程序首次使用該類的時候 纔會報錯誤。
對於類加載器而言:
從虛擬機的角度來說有兩種類加載器:

  1. 啓動類加載器是虛擬機的自身一部分;
  2. 所有其他的類加載器(都有java語言進行編寫)。
    除了根類加載器之外,其餘的類加載器都有且只有一個父加載器。
    但是在開發角度來說 就會有以下的三種類加載器:
  • 啓動類加載器:
  • 擴展類加載器
  • 應用程序類加載器
  • 自己定義的類加載器(屬於用戶自定義的加載器)
類加載器各個用途

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-u7SMamOb-1582691215604)(en-resource://database/1216:1)]

類加載器的種類

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-nZLvPCtS-1582691215605)(en-resource://database/1230:1)]

類與類加載器之間的關係

前面講到了類加載器,但是類加載器並不只是對類加載這個功能,還有更多的功能。對於每一個類,都需要由加載她的類加載器和這個類本身來一起確定其在java虛擬機中的唯一性。對於每一個類加載器 都擁有自己獨立的命名空間。這個時候結合給的例子說,只有兩個類是由同一個類加載的前提下才能說其是否相等,進行比較。否則儘管說這兩個類來源於同一個class文件,但是也必定不相等。


在講述完類加載器以後,我們可能還需要了解一下命名空間的作用:在自定義類記載器時候會出現的一個問題,也是在面試時候比較容易考到的地方。

命名空間

什麼是?

  • 每個類加載器都有自己的命名空間,命名空間由該加載器及所有父加載器所加載的類組成
  • 在同一個命名空間中,不會出現類的完整名字(包括類的包名) 相同的像個類。
  • 在不同的命名空間中,有可能會出現類的完整名字(包括類的包名) 相同的兩個類
    理解
    前面已經介紹過了關於命名空間的問題,若是使用父類的加載器進行加載 會從類路徑的class文件中加載,此時若是new了兩個對象 但是對於使用了父類加載器的時候,前一次加載以後 後一次的就會直接調用 就屬於在同一個命名空間之下。
    但是若是使用自己所定義的類加載器 由於new出來了兩個對象,就會產生兩個不同的命名空間,也會產生不同的類。 這但是若是新的對象指定了前一個對象作爲其父類加載器時候,產生的就是相同的hashcode,因爲父類加載器在前面加載過,在後面就不會重複加載,而是在其的基礎上再進行一遍調用。

以上是對類加載器有了基礎的認知,其實類加載器之中的知識要在具體的實戰中才會得以顯示,下面我們來介紹一下一個重要的機制,來具體也更加深刻認知類加載器。

雙親委派機制

什麼是?

什麼是雙親委派機制:如果一個類加載器收到了類加載的請求,她首先不會自己嘗試去加載這個類,而是把這個請求委派給父類加載器去完成,每一個層序的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啓動類加載器中,只用當父加載器反饋自己無法完成這個加載請求(在搜索範圍彙總沒有找到所需要的類)時候,子加載器纔會嘗試着自己去加載。(這裏的類加載器之間的父子關係一般不會以繼承的關係,而是使用組合關係來複用父加載器的代碼)
爲什麼要使用?
使用這個模式來組織類加載器之間的關係,有一個顯而易見的好處就是java類隨着它的類加載器一起具備了一種帶有優先級的層次關係。例如我們的java.lang.Object,存在方 rt.jar中無論是哪個類加載器要加載這個類,最後實現的結果是都會委託給模型最頂端的啓動類加載器進行加載,因此Object類在程序的各個類記載器環境中都是同一個類,否則自行加載時候 最基礎的體系結構就不能夠得到保證。
好處

  • 可以確保java核心庫的類型安全
    前面提到了會引用 java.lang.Object 時候,也是就說 在運行期若是使用自己所定義的類進行加載,就會存在多個版本的java.lang.Object,而且是不兼容,不可見的。
  • 提供了java核心類庫不會被自定義的類所替代
  • 不同的類加載器可以爲相同(binary name) 的類創建額外的命名空間,相同名稱的類可以並存在java虛擬機中,只需要不同的類加載器來加載他們即可。不同類加載器所加載的類之間是不兼容的。這就相當於在java虛擬機內部創建了一個又一個相互隔離的Java類空間。
    如何實現雙親委派模型的呢
    實現在 java.lang.ClassLoader中的loaderclass()方法中:首先檢查是否已經被加載,若沒有加載則調用父加載器的loadClass() 方法,若父加載器爲空,則默認使用啓動類加載器作爲父加載器。如果父類加載失敗,,拋出 ClassNotFoundException異常後,再調用自己的findClass() 方法進行加載。
    優點(面試)
    能夠提高軟件系統的安全性。因爲在此機制下,用戶之定義的類加載器不可能加載應該由父加載器加載的可靠類。從而防止不可靠甚至惡意的diamante代替由父加載器加載的可靠代碼。例如java.lang.Object 類總是由根類加載器加載,其他任何用戶定義的類加載器都不可能加載含有惡意代碼的java.lang.Object類。

前面我們介紹到了雙親委派機制的好處所在,但是其不是一個強制性的約束模型而是說java設計者推薦給我們使用的類加載的實現方式,在Java的世界中大部分的類加載器都遵循這個模型。但是有時候也會出現不適用的情況,這個時候推出了線程上下文加載器。

雙親委派機制(不適用情況)

在我們的雙親委託模型中,類加載器是由下至上的,即下層的類加載器會委託上層加載器進行加載,但是對於 SPI來說,有些接口卻是Java核心庫所提供的的,而Java核心庫是由啓動類記載器進行加載的,而這些接口的實現卻是由不容的jar(包,廠商提供的),Java的啓動了加載器是不會加載來源於其他的jar包中的信息,這樣雙親委託模型就無法滿足SPI的要求。

爲了解決的是在有得時候 不適用的情況下:線程上下文加載器(ThreadContestClassLoader)這個類加載器可以通過java.lang.Thread類的setContextClassLoaser() 方法進行設置,如果創建線程時還未設置,它將會從父線程彙總繼承一個,如果在應用程序的全局範圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。

爲什麼會出現線程上下文加載器

是因爲舉個例子“對於jDBC我們都有學習和了解,JDBC是一個標準,對於很多的數據庫廠商,例如MySQLDB2等都會根據這個標準來進行自己的實現,既然是一個標準,那麼這些原生的標準和類庫都是存在於JDK中的,很多的接口都是有廠商來實現”這個時候,這些接口都肯定是由我們根類加載器進行加載實現的。
我們要是想來直接使用廠商提供給我們的實現,就需要把廠商給的實現放置在類的應用的ClassPath下面,此時就不會由啓動類來進行加載,這也是加載器的加載範圍限制,這個時候 這些實現,就需要用到系統類加載器(關於這一點爲什麼會使用到系統類加載器可以去看類加載器的各個不同的使用場景),或是應用類加載器來進行加載,因爲這些加載器纔回去掃描當前的classPath
。這個時候 就會出現一個問題“例如有一個Connection 接口 這個接口由啓動類進行加載,但是具體實現由系統或是應用加載。父加載加載的接口看不到子加載器實現的類或是接口(關於這一點是需要記憶的),這個時候 例如一個接口想要去調用自己的實現,但是由於加載自己(接口)的是父親加載器,加載實現的是兒子加載器,所以根本就不可能讀到相關的信息。 這個時候 對於就算是將實現放入到ClassPath下也不能夠應用”所以因運而生了(不得以爲止)產生了線程上下文加載器

但是對於線程上下文加載器:父ClassLoader可以使用當前線程Thread.currentthread().getContextClassLoader()所指定的ClassLoader加載的類

結語

以上就是對jvm虛擬機學習過程中類加載器的具體學習,涵蓋了目前來說具體的知識點,也帶有本人自己的理解色彩,有什麼錯誤地方,不合適的地方,還希望大家能指出。

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