1. 程式人生 > >Java-類的生命周期淺析

Java-類的生命周期淺析

子接口 c編程 機會 java test static 編譯器 pac 變量聲明 數據位

簡述:Java虛擬機為Java程序提供運行時環境,其中一項重要的任務就是管理類和對象的生命周期。類的生命周期。類的生命周期從類被加載、連接和初始化開始,到類被卸載結束。當類處於生命周期中時,它的二級制數據位於方法區內,在堆區中還會有一個相應的描述這個類的Class對象(當Java程序使用任何一個類時,系統都會為之創建一個java.lang.Class對象)。只有當類處於生命周期中時,Java程序才能使用它,比如調用類的靜態成員或者創建類的實例。

一、Java虛擬機及程序的生命周期

當通過java命令運行一個Java程序時,就啟動了一個Java虛擬機進程。Java虛擬機進程從啟動到終止的過程,稱為Java虛擬機的生命周期。在以下情況中,Java虛擬機將結束生命周期。

01.程序正常執行結束;

02.程序在執行中因為出現異常或錯誤而異常終止;

03.執行了System.exit()或者Runtime.getRuntime().exit();

04.由於操作系用出現錯誤而導致Java虛擬機進程終止;

當Java虛擬機處於生命周期中時,它的總任務就是運行Java程序。Java程序從開始運行帶終止的過程稱為程序的生命周期,它和Java虛擬機的生命周期的一致的。

當Java程序運行結束時,JVM進程結束,在進程在內存中的狀態將全部丟失。下面通過一個小例子來說明。

public class A {
    public static int a = 5;
}
public
class ATest1 { public static void main(String[] args) { A.a++; System.out.println(A.a);//6 } }
public class ATest2 {

    public static void main(String[] args) {
        System.out.println(A.a);//5
    }

}

觀察輸出結果,原因就是ATest1和ATest2是兩次運行JVM進程,第一次運行結束後它對A類所做的修改將全部丟失;第二次運行JVM將再次初始化A類。

一些初學者會認為A類中的a是靜態成員變量,同一個類中所有實例的靜態變量共享同一塊內存區,錯誤的認為第二次運行會輸出被第一次改變後的結果。但實際上兩次運行Java程序處於兩個不同的JVM進程中,故兩個JVM之間不會共享數據。

二、類的加載、連接和初始化

當Java程序需要使用某個類時,Java虛擬機會確保這個類已經被加載、連接和初始化。其中連接過程又包括驗證、準備和解析這三個子步驟。這些步驟必須嚴格按照以下順序執行。

01.加載:查找並加載類的二進制數據;

加載具體是指把類的.class文件中的二進制數據讀入到內存中,把它存放在運行時數據區的方法區內,然後再堆區創建一個java.lang.Class對象,用來封裝類在方法區內的數據結構。

Java虛擬機能夠通過不同的類加載器從不同來源加載類的二進制數據,包括以下幾種:

001.從本地文件系統中加載class文件,也是最常見的方式;

002.從JAR、ZIP或者其他類型的歸檔文件中提取class文件(例如使用JDBC編程時用到的數據庫驅動類就是放在JAR文件中,JVM可以從JAR文件中直接加載所需的class文件);

003.通過網絡加載class文件;

004.把一個Java源文件動態的編譯為class文件,並執行加載;

類加載的最終產品是位於運行時數據區的堆區的Class對象。Class對象封裝了類在方法區內的數據結構,並且向Java程序提供了訪問類在方法區內的數據結構對的接口。

技術分享圖片

類的加載是由類加載器完成的,類加載器通常由JVM提供,這些類加載器也是所有程序運行的基礎,JVM所提供的這些類加載器通常稱之為系統類加載器。除此之外,開發者可以通過繼承ClassLoader基類來創建自定義的類加載器。

類加載器並不需要等到某個類被“首次主動使用”時再加載它,JVM規範允許類加載器在預料某個類將要被使用時就預先加載它。如果在預先加載過程中遇到class文件缺失或者存在錯誤,類加載器必須等到首次使用該類時才報錯(拋出一個LinkageError錯誤),如果這個類一直沒有被程序主動使用,那麽該類加載器不會報錯。

02.連接:包括驗證、準備和解析類的二進制數據;

001.驗證:確保被加載類的正確性

當類被加載後,就進入驗證階段。連接就是把已經讀入到內存中的類的二進制數據合並到JVM運行時環境中去。連接的第一步是類的驗證,其目的是保證被加載的類由正確的內部結構,並且與其他類協調一致。如果JVM檢查到錯誤,那麽就會拋出相應的Error對象。

疑問:由Java編譯器生成的Java類的二進制數據肯定是正確的,為什麽還要進行類的驗證呢?

因為JVM不知道某個特定的class文件到底是如何創建的、從哪來的,這個class文件可能是由正常的Java編譯器生成的,也有可能是惡意創建的(通過惡意class文件破壞JVM運行時環境),類的驗證能提高程序的健壯性,確保程序被安全的執行。

類的驗證主要包括以下內容:

類文件的結構檢查:確保類文件遵從Java類文件的固定格式。

語義檢查:確保類本身符合Java語言的語法規定(例如final類型的類沒有子類,final類型的方法沒有被覆寫)。

字節碼驗證:確保字節碼流可以被JVM安全的執行。字節碼流代表Java方法(包括靜態方法和實例方法),它是由被稱作操作碼的單元字節指令組成的序列,每一個操作碼後都跟著一個或多個操作數。字節碼驗證步驟會檢查每個操作碼是否合法,即是否有著合法的操作數。

二進制兼容的檢查:確保相互引用的類之間協調一致。例如在A類的的a方法中調用B類的b方法。JVM在驗證A類時,會檢查在方法區是否存在B類的b方法,如果不存在(當A類和B類的版本不兼容,就會出現這種問題),就會拋出NoSuchMethodError錯誤。

002.準備:為類的靜態變量分配內存,並將其初始化為默認值

在準備階段,JVM為類的靜態變量分配內存,並設置默認的初始值。例如如下情況:

public class Demo {
    public static int a = 1;
    public static long b;
    
    static {
        b = 2;
    }
}

在準備階段,將為int類型的靜態變量a分配4個字節的內存空間並賦予默認值為0;為long類型的靜態變量b分配8個字節的內存空間並賦予默認值0。

003.解析:把類中的符號引用轉換成直接引用

在解析階段,JVM會把二進制數據中的 符號引用替換為直接引用。例如在A類的a方法中調用B

類的b方法。

public class A {
    
    B b = new B();
    public void a() {
        b.b();//這行代碼在A類的二進制數據中表示為符號引用
    }
    
}

在A類的二進制數據中,包含了一個對B類b()方法的符號引用,它由b()方法的全名和相關描述組成。在解析階段,JVM將這個符號引用替換成為一個指針,該指針指向B類b()方法在方法區內的內存位置,這個指針就是直接引用。

03.初始化:給類的靜態變量賦予正確的初始值;

在初始化階段,JVM執行類初始化語句,為類的靜態變量賦予初始值。在程序中,靜態變量的初始化有兩種途徑:一是在靜態變量的聲明處進行初始化;二是在靜態代碼塊中進行初始化。

如下代碼,a和b都被顯式的初始化,而c沒有沒顯式的初始化,它將報紙默認值0。

public class A {
    
    private static int a = 1;//在變量聲明處初始化
    public static long b;
    public static long c;
    
    static {
        b = 1;//在靜態代碼塊中初始化
    }
    
}

在本文中,如果未加特別說明,類的靜態變量都是指不能作為 編譯時常量的靜態變量。Java編譯器和虛擬機對 編譯時常量有特殊的處理方式,具體可參參考下文中 類的初始化時機。

靜態變量的聲明語句,以及靜態代碼塊都被看作類初始化語句,JVM會按照初始化語句在類文件中的的書寫順序依次執行它們。

Java虛擬機初始化一個類包含以下步驟:

1.假如這個類還沒有被加載和連接,那麽先進行加載和連接。

2.假如類中存在直接父類,並且這個父類還沒有被初始化,那麽就先初始化直接父類。

3.假如類中存在初始化語句,那麽就依次執行。

當初始化一個類的直接父類時,也需要重復以上步驟,這會確保當程序主動使用一個類時,這個類以及它的所有父類(包括直接父類和間接父類)都已經被初始化。程序中第一個被初始化的類是Object類。

在類或接口被加載的時機上,Java虛擬機規範給實現提供的一定的靈活性,但是又嚴格定義了初始化的時機,所有的Java虛擬機實現必須在每個類或接口被Java程序“首次主動使用”時才初始化它們。Java程序對類的使用可分為兩種:主動使用和被動使用,在下面的類的初始化時機進行詳細闡述。

三、類的初始化時機

JVM只有在程序首次主動使用一個類或接口時才會初始化它。只有以下6種方式被看作程序對類或接口的主動使用。

01.創建類的實例。包括new關鍵字來創建,或者通過反射、克隆及反序列化方式來創建實例。

02.調用類的靜態方法。

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

04.使用反射機制來創建某個類或接口對應的java.lang.Class對象。例如Class.forName("Test")操作,如果系統還未初始化Test類,這波操作會導致該Test類被初始化,並返回Test類對應的java.lang.Class對象。

05.初始化一個類的子類,該子類所有的父類都會被初始化。

06.JVM啟動時被標明為啟動類的類(直接使用java.exe命令運行某個主類)。例如對於“java Test”命令,Test類就是啟動類(主類),JVM會先初始化這個主類。

除了以上6種情況,其他方式都被看作成是 被動使用,不會導致類的初始化。下面通過接個例子來驗證:

public class A {
    
    public static final int a = 2*3;//a為編譯時常量
    public static final String str = "haha";//str為編譯時常量
    public static final int b = (int)(Math.random()*5);//b不是編譯時常量
    
    static {
        System.out.println("init A");
    }
    
}

“宏變量”:

1.對於final類型的靜態變量,如果在編譯時就能計算出變量的取值,那麽這種變量看作 編譯時常量。Java程序中對類的編譯時常量的使用,被看作是對類的被動使用,不會導致類的初始化。

上面例子的因為編譯時能計算出a為6,所以程序訪問A.a時,是對A類的被動使用,不會導致A類初始化。

public class Test {
    public static void main(String[] args) {
        System.out.println(A.a); 
    }
}

在Test測試類中運行程序:控制臺只打印出6,並沒有打印靜態代碼塊中的 init A。

當Java編譯器生成A類的class文件時,他不會在main()方法的字節碼流中保存一個表示“A.a”的符號引用,而是直接在字節碼流中嵌入常量值6。因此當程序訪問A.a時,客觀上無須初始化A類。(當JVM加載並連接A類時,不會在方法區內為它的編譯時常量a分配內存。)

2.對於final類型的靜態變量,如果在編譯時不能計算出變量的取值,那麽程序對類的這種變量的使用,被看作是對類的主動使用,會導致類的初始化

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

訪問A類中不是編譯時常量的b,控制臺會打印出 init A 4。

這波操作JVM會初始化A類,使得變量b在方法區內擁有特定的內存和初始值。

3.當JVM初始化一個類時,要求它的所有父類都已經初始化完畢,但是這條規則並不適用於接口。

01.在初始化一個類時,並不會先初始化它所實現的接口。

02.在初始化一個接口時,並不會先初始化它的父接口。

因此,一個父接口不會因為它的子接口或者實現類被初始化而初始化,只有當程序首次使用特定接口的靜態變量時,才導致該接口的初始化。

4.只有當程序訪問的靜態變量或靜態方法的確在當前類或接口中定義時,才可看作是對類或接口的主動使用。觀察下面例子:

class Father{//父類
    static int a = 1;
    static {
        System.out.println("init father");
    }
    static void method() {
        System.out.println("father method");
    }
}
class Son extends Father {
    static {
        System.out.println("init son");
    }
}
public class Demo {
    public static void main(String[] args) {
        System.out.println(Son.a); //僅僅初始化父類Father
        Son.method();
    }
}

控制臺結果:

init father
1
father method

5.調用ClassLoader類的loadClass()方法加載一個類,該方法只是加載該類,並不是對類的主動使用,不導致類的初始化。使用Class.forName()靜態方法才會導致強制初始化該類。

package cn.lifecycle;
class A {
    static {
        System.out.println("init A");
    }
}
public class B {
    public static void main(String[] args) throws Exception  {
        ClassLoader loader = ClassLoader.getSystemClassLoader();//獲取系統類加載器
        Class objClass = loader.loadClass("cn.lifecycle.A");//加載A
        System.out.println("after load A");
        System.out.println("before init A");
        objClass = Class.forName("cn.lifecycle.A");//初始化A
    }
}

控制臺結果:

after load A
before init A
init A

未完...

Java-類的生命周期淺析