Java類加載過程


類加載過程已經是老生常談模式了,我曾在講解tomcat的書中、在Java基礎類書、JVM的書、在Spring框架類的書中、以及各種各樣的博客和推文中見過,我雖然看了又忘了,但總體還是有些了解,曾經自以為這不是什么大不了的過程。但時間總會教你做人,看得越多,越覺得以前理解不足。

此筆記記錄,虛擬機中Java類加載的過程,各個過程發生的時機,具體做了什么事情,例如,在方法區或者堆分配了哪些內存,創建了哪些常量等。由於Java文件會預先編譯,得到class文件,虛擬機的類加載,是對class文件進行的操作,所以不可避免的涉及到class文件的解讀,只有知道class文件中有什么,虛擬機才能加載對應的內容。

一、class文件介紹

​ 不可能完全解讀class文件,《虛擬機規范第二版》花了一百多頁寫class文件,這是class的核心,如果要完全理解,可能還得去復習復習編譯原理,詞法分析語法分析代碼生成之類的。

1.1 文件結構

文件結構定義:u1 = 1個字節,u2 = 2個字節,u4 = 4個字節,u8 = 8個字節;

ClassFile {
  u4                     magic;                                // 魔法數
  u2                     minor_version;                        // 副版本號
  u2                     major_version;					               // 主版本號
  u2                     constant_pool_count;                  // 常量池計數,從1開始計數
  cp_info                constant_pool[constant_pool_count-1]; // 常量池[常量數量]
  u2				     access_flags;     										 // 訪問標志
  u2				     this_class;													 // 類索引
  u2				     super_class;													 // 父類索引
  u2				     interfaces_count; 										 // 接口計數器
  u2                     interfaces[interfaces_count]          // 接口表
  u2				     fields_count;												 // 字段計數器
  field_info             fields[fields_count];								 // 字段表
  u2                     methods_count;												 // 方法計數器
  method_info            methods[methods_count]; 							 // 方法表
  u2                     attributes_count;										 // 屬性計數器
  attribute_info         attributes[attributes_count];				 // 屬性表
}

根據這個表,一個class文件的16進制文件就可以讀取了。此處要注意幾點

  • 常量池的數量【常量池計數-1】,是因為常量池計數從1開始,而不是從0開始。其他的方法,屬性之類的是從0開始
  • 字段表和屬性表的區別:
    • 字段表:記錄的是類級別以及實例級別(不包含父類字段,方法內字段)的字段信息,作用域(public、private)、修飾符(static)、final、字段名等等...
    • 屬性表:保存的是class的屬性,讓虛擬機能夠正確解讀class文件。例如:Exception屬性指出方法拋出的受檢查異常、Signature屬性記錄范型信息(讓程序員可以通過反射讀到正確類型)、Deprecated屬性標記類、接口、方法或字段已經過期。Java虛擬機(Java虛擬機規范 Java SE 8 版)自帶23個屬性(Java虛擬機規范 Java SE 7 版只描述了21個屬性),並且支持自定義屬性。

1.2 簡單示例讀取class文件

代碼

/**
 * @Author: dhcao
 * @Version: 1.0
 */
public class ClassTest {

    public static final String abc = "ccc";

    private static String def = "fff";

    public String getAbcdef(){
      return abc + def;
    }
}

編譯為ClassTest.class

直接使用subline或者其他軟件打開此二進制文件

解讀:根據class文件結構解讀

  • ca fa ba be : u4 ,魔法數。看到此魔法數開頭的文件就意味着這是個java編譯后的class文件
  • 00 00 00 34:u2 + u2,副版本號 + 主版本號。根據以下對照表,是jdk1.8
JDK版本號 Class版本號 10進制 16進制
1.1 45.0 00 00 00 2D
1.2 46.0 00 00 00 2E
1.3 47.0 00 00 00 2F
1.4 48.0 00 00 00 30
1.5 49.0 00 00 00 31
1.6 50.0 00 00 00 32
1.7 51.0 00 00 00 33
1.8 52.0 00 00 00 34
  • 00 27:u2, 常量池計數器。十六進制27 = 十進制39,從1開始計數,代表有38項常量

  • 后續常量的解析過於復雜,不提了....

    • 常量共有14種,每種常量都有自己的結構。下面看看第一個常量0a 00 0a 00 1b
    • 0a :代表此常量為類型CONSTANT_Methodref_info(此結構意味着后續u4都屬於它),它代表方法的符號引用,也就是方法名。
    • 00 0a:(0a = 10)指向常量池中聲明方法的類描述符CONSTANT_Class_info的索引。
    • 00 1b:(1b = 27)指向常量池中名稱以及類型描述符CONSTANT_NameAndType的索引。
    • 以上,第一個常量表示的是方法名,它的具體內容在常量表中,它由類名和方法名和類型組合而成。
  • 貌似還可以解析第二個常量 07 00 1c

    • 07:代表此常量為類型CONSTANT_Class_info(此結構意味着后續還有u2),它代表類或者接口的符號引用,也就是類名或者接口名(不僅僅指本類或本接口,其他的也一樣)。
    • 00 1c:指向常量池中第28(1c = 28)項常量。

反編譯該class文件javap -verbose ClassTest

第一部分:常量池部分

Constant pool:
   #1 = Methodref          #10.#27        // java/lang/Object."<init>":()V
   #2 = Class              #28            // java/lang/StringBuilder
   #3 = Methodref          #2.#27         // java/lang/StringBuilder."<init>":()V
   #4 = Class              #29            // org/relax/jvm/demo/ls/ClassTest
   #5 = String             #30            // ccc
   #6 = Methodref          #2.#31         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #7 = Fieldref           #4.#32         // org/relax/jvm/demo/ls/ClassTest.def:Ljava/lang/String;
   #8 = Methodref          #2.#33         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #9 = String             #34            // fff
  #10 = Class              #35            // java/lang/Object
  #11 = Utf8               abc
  #12 = Utf8               Ljava/lang/String;
  #13 = Utf8               ConstantValue
  #14 = Utf8               def
  #15 = Utf8               <init>
  #16 = Utf8               ()V
  #17 = Utf8               Code
  #18 = Utf8               LineNumberTable
  #19 = Utf8               LocalVariableTable
  #20 = Utf8               this
  #21 = Utf8               Lorg/relax/jvm/demo/ls/ClassTest;
  #22 = Utf8               getAbcdef
  #23 = Utf8               ()Ljava/lang/String;
  #24 = Utf8               <clinit>
  #25 = Utf8               SourceFile
  #26 = Utf8               ClassTest.java
  #27 = NameAndType        #15:#16        // "<init>":()V
  #28 = Utf8               java/lang/StringBuilder
  #29 = Utf8               org/relax/jvm/demo/ls/ClassTest
  #30 = Utf8               ccc
  #31 = NameAndType        #36:#37        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #32 = NameAndType        #14:#12        // def:Ljava/lang/String;
  #33 = NameAndType        #38:#23        // toString:()Ljava/lang/String;
  #34 = Utf8               fff
  #35 = Utf8               java/lang/Object
  #36 = Utf8               append
  #37 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #38 = Utf8               toString

  • 可以看到一共38項常量,從二進制class文件讀取的也一樣
  • 第一項常量是方法名,分別指向第10和第27項常量,組合起來的值。
  • 第二項常量是類或者接口名,他指向第28項常量。那是一個StringBuilder類名。

二、類加載步驟

類文件(class文件)是Java編譯器編譯之后的結果,它遵循的是編譯原理。類加載,是指JVM將class文件加載到虛擬機的過程。只有將class文件加載到虛擬機,才能夠使用該class。

2.1 類加載過程

將一個class文件(不管是文件還是二進制...只要是class格式)加載到虛擬機,最后移出虛擬機,通常認為有以上步驟,即類的生命周期。對於大多數時候,我們並不關注卸載過程,將“Using、使用”之前對類等處理,統稱為“類加載”。所以也有描述類加載為3個主要過程(這也是虛擬機規范定義的過程):加載 --- 連接 --- 初始化

  • 說明一:區分“加載(Loading)”和“類加載”。很明顯,當我們定義類加載為“加載、連接、初始化”就知道,“加載(Loading)”只是整個“類加載”過程的一部分。

  • 說明二:Resolution、解析。解析過程是連接的一部分,但是並不一定發生在“初始化”之前。虛擬機規范並沒有要求“解析”一定發生在“初始化之前”,虛擬機可根據不同情況,進行不同處理。

  • 說明三:加載、驗證、准備、初始化、卸載。這五個階段是有序的,但是....其界限並非是先加載,加載完畢之后開始驗證,驗證完畢之后開始准備之類的。此有序,只是“開始時間有序”,即:加載開始時間一定在驗證開始之前。但加載和驗證可以有交集。

    我理解,加載的整個過程,應該是覆蓋了“連接”。

2.1.1 Loading、加載

  • 過程定義:獲取類的二進制字節流,並在方法區建立其數據結構,生成java.lang.Class對象。(這個過程可能覆蓋了“連接”)

  • 關於類的二進制字節流來源

    • 可以是編譯之后的.class文件
    • 可以是zip、jar、war格式包
    • 可以是運行時由動態代理生成的二進制流
    • 可以是其他能夠被解析到二進制流,jsp等模板
    • 可以是遠程調用獲取到的二進制流
  • 類加載器

    • 是類加載的工具。負責將二進制讀進虛擬機並按class規范處理,是加載類的類。
    • 同一份class二進制文件,只有被同一個類加載,才能確定唯一性。不同的類加載器加載同一份class文件,這是2個類。在使用instanseof 和equals比較,都會得到false。
    • 其他不是重點...
  • 過程一:非數組類

    • JVM使用類加載器讀取一個二進制流:loadClass()方法。
  • 過程二:數組類String[] aa = xxx

    • 我們知道數組是一串連續的內存地址。從這個定義上來看,我們就知道,這不是類加載器可以控制的。數組類的創建是由JVM直接創建的。

    • 數組的每個元素(對象)依然要靠類加載器創建。也就是說,數組本身由JVM創建並分配內存,但是其元素依然依靠類加載器加載。

  • 在加載時,可能拋出以下異常:

    • ClassNotFoundException:類加載器未找到類所對應的描述

    • LinkageError:是否已被該類加載器加載過

    • ClassFormatError:格式檢查失敗。此過程也可算是“驗證”一部分。檢查以下幾項

      • 前4個字節(u4)必須是魔法數cafababe。
      • 能夠辨識的屬性,都具備正確的長度。
      • 文件內容不能缺失,不能有多余字節。
      • 常量池必須符合約束(各個常量表的格式)。
    • unsupportedClassVersionError:檢測到class版本號不被JVM支持

    • noClassDefError:class文件與類名描述不符

    • IncompatibleClassChangeError:接口被類繼承時拋出

    • ClassCircularityError:類的父類是自己時

    由上可見,加載本身也含有一定程度上的校驗,不可能啥都加載。所以加載發生在驗證開始之前, 但並非一定是結束在驗證開始之前。

2.1.2 Verifition、驗證

​ 在讀入了二進制流之后,驗證就開始了,驗證的目的是保證Class文件的字節流包含的信息符合JVM的要求,並且不會危害JVM的安全。

​ 那么需要驗證哪些呢,在《虛擬機規范 Java SE 8》中,章節目錄4.10詳細講解了JVM加載class文件需要進行的校驗,根本目的還是保證class文件的正確性和安全性。

  • 文件格式驗證(參照前文:格式檢查)。

  • 元數據驗證

    • 是否有父類、是否繼承了final、是否實現了接口的所有方法等等。
  • 字節碼驗證

    • 這個實在是多,包括指令是否正確,指令是否越界,映射是否正確等等。
  • 符號引用驗證

    • 對類型進行匹配性校驗,是否private的方法只能被當前類訪問、通過全限定名是否找到對應的類等等。
  • 靜態約束:一系列用來定義文件是否編排良好的約束

2.1.3 Preparation、准備

​ 該階段是非常重要的,在經過前面的階段之后,一個Class文件已經加載到了JVM並驗證了其正確性,那么接下來就需要對Class文件進行處理。

虛擬機規范規定:准備階段的任務是創建類或者接口的靜態字段,並用默認值初始化這些字段。這個階段不會執行任何的虛擬機字節碼指令。

​ 過程

  • 為類變量(static)分配內存。java8以后,運行時常量池分配在堆中。
  • 設置初始值

從開始接觸Java我們就一直被一些看似簡單實際有些意思的題目煩擾,例如:靜態變量,靜態塊的執行順序、父類子類的執行順序、變量賦值時間、方法傳遞的是引用還是值等等亂七八糟的問題。

關於初始值:public static int value = 123; 在准備階段,這段代碼只會得到:value = 0,這是因為int型變量的初始值為0(引用類型初始值為null)。

但是:public static final int value = 123; 在准備階段,這段代碼會得到:value = 123,這是因為final定義常量,其值在編譯時確定。

  • 准備階段是給static賦初始值,為final修飾的常量賦值。

嘗試分析,如何標記常量,以及為它賦值。依然是最上述的代碼段

反編譯該class文件javap -verbose ClassTest

第二部分:編譯碼

{
  public static final java.lang.String abc;
    descriptor: Ljava/lang/String;
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: String ccc

  public org.relax.jvm.demo.ls.ClassTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/relax/jvm/demo/ls/ClassTest;
  
  ....
    .....
    ......
}

以上,為javap的反編譯結果第二部分。我們看源代碼中:

  public static final String abc = "ccc";

編譯之后:
    public static final java.lang.String abc;
    descriptor: Ljava/lang/String;
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: String ccc

重點在於ConstantValue: String ccc。

屬性ConstantValue:如果同時使用了static 和 final,javac編譯器在編譯時就在字段上著明該屬性,並在類加載的准備階段,將該屬性的值,自動賦值給靜態變量。

在最開始定義class格式的時候寫到,文件最后定義的是屬性表,ConstantValue就是屬性表中的屬性。

  • 作用范圍:使用在字段上。
  • 如果flags中含有ACC_STATIC和ACC_FINAL,那么ConstantValue的值將直接賦值給該字段。也就是ccc直接賦值給abc。
  • 強調:虛擬機規范只要求有ACC_STATIC就可以使用ConstantValue,是sun公司的javac編譯器要求同時使用ACC_STATIC和ACC_FINAL
  • 只能限於基本類型和String使用

2.1.4 Resolution、解析

​ 解析這個過程,並沒有嚴格的規定在什么時候發生。解析的作用是將符號引用替換為直接引用的過程,只需要在使用符號之前替換這個符號就行。

  • 符號引用

    • 類和接口的全限定名(Fully Qualified Name)
    • 字段的名稱和描述符(Descriptor)
    • 方法的名稱和描述符
  • 直接引用

    • 直接指向目標的引用
    • 相對偏移量
    • 能間接定位到目標的句柄

​ 以上描述還有些難以理解。說實話,我也不知道怎么解釋了,舉個例子描述(類方法解析):

 public String getAbcdef(){

        int a = 3;
        int c = a + 4;
        return abc + def + c;
    }

執行 javap -verbose ClassTest
  
  --------------------------------------------------------------------------------
  public java.lang.String getAbcdef();
    descriptor: ()Ljava/lang/String;
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_3
         1: istore_1
         2: iload_1
         3: iconst_4
         4: iadd
         5: istore_2
         6: new           #2                  // class java/lang/StringBuilder
         9: dup
        10: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
        13: ldc           #5                  // String ccc
        15: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        18: getstatic     #7                  // Field def:Ljava/lang/String;
        21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: iload_2
        25: invokevirtual #8                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
        28: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        31: areturn
      LineNumberTable:
        line 15: 0
        line 16: 2
        line 17: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      32     0  this   Lorg/relax/jvm/demo/ls/ClassTest;
            2      30     1     a   I
            6      26     2     c   I
	--------------------------------------------------------------------------------

在反編譯中,一直出現的jvm指令:invokevirtual

  • 這是一條方法調用指令,調用對象的實例方法,也是常用的虛方法分派。

解析的目的:

​ 在上文 中,該指令調用的是StringBuilder的append方法(直接字符串相加),虛擬機要求,在執行該條指令(invokevirtual)之前,需要對他們所使用的符號進行解析,也就是對StringBuilder.append進行解析。解析的結果是,該指令能夠正確的找到該方法的入口!

解析過程:(類方法解析)

  • 找到該類的符號引用(StringBuilder):具體過程是,該指令后對應的常量池位置 #6 ,我們找到常量池#6,得到
  #6 = Methodref          #2.#31         // java/lang/StringBuilder.append:

​ 它是一個Methodref(方法常量),這是由#2(java/lang/StringBuilder)和#31(append)組成的。

​ 而#2:

#2 = Class              #28            // java/lang/StringBuilder

​ 它是一個Class(類)。

  • 再找,該類(StringBuilder)中是否有該方法。要求返回值為String,參數為String。
  • 最后返回該方法的引用!

解析的目的是將符號引用轉為能用的直接引用。主要包含以下:

  • 類或接口的解析
  • 字段解析
  • 類方法解析(上文例子)
  • 接口方法解析

2.1.5 Initializaition、初始化

​ Loading、加載階段讀入了Class文件

​ Verifition、驗證階段校驗了其正確性

​ Preparation、准備階段為其開辟了內存空間,並為static屬性賦初始值

​ Resolution、解析階段將符號引用都轉為了直接引用,使得Class中的定義都有了實際意義,不再是一串字面量

​ Initializaition、初始化階段,是類加載的最后一步

也是執行字節碼的過程,也是執行<clinit>()方法的過程

  • <clinit>()方法 和 <init>()方法

    • <clinit>()方法是由編譯器自動收集類中所有類變量的賦值動作(不管是不是靜態)和靜態語言塊(static{})中的語句合並產生的,是類構造器
    • <init>()方法是實例化時執行的構造函數或者說實例構造器。
  • <clinit>() 順序:

    編譯器收集類變量的賦值和靜態塊的語句,是按照Class文件中的語句順序來的,所有如下中,static 變量i 在定義賦值之前就想要輸出,是不行的。

    public class ClassTest {
    
       static {
           i = 0;                    // 可以通過
           System.out.println(i);    // 編譯報錯
       }
       static int i = 1;
    }
    
    
  • 重要:虛擬機會保證父類的<clinit>()方法在子類之前已經執行完畢。所以第一個執行<clinit>()方法的肯定是java.lang.Object。

    這也意味着父類的靜態塊語句在子類靜態塊之前執行

  • <clinit>()方法不是必要的,如果沒有任何靜態塊,也沒有類變量的賦值動作,那么可以不生成<clinit>()方法。

  • 接口雖然無法定義static塊,但是也可以賦值,所以接口也可以有<clinit>()方法。

  • 虛擬機保證在多線程環境下,同一個類加載器中<clinit>()方法只被執行一次。

三、總結

​ 類的加載過程,主要流程如上,但是更多的細節,沒有描述,例如更多的Class文件細節,更多的類加載的內容,更具體的棧與堆的數據結構和分配過程。在后面的筆記中將對這些進行補充。

熟悉的面試題,現在看來也顯然易見!

class Parent {
   static {
       System.out.println("父類靜態塊");
   }

   Parent(){
       System.out.println("父類構造函數");
   }
}

class Sub extends Parent{
    static {
        System.out.println("子類靜態塊");
    }

    Sub(){
        System.out.println("子類構造函數");
    }
}

// 如何輸出...
class Test{
    public static void main(String[] args) {
        new Sub();
    }
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM