Java 類加載機制(阿里)-何時初始化類


   什么是類加載器
   類加載器與類的”相同“判斷
   類加載器種類
  雙親委派模型
  類加載過程
  自定義類加載器
  JAVA熱部署實現

什么是類加載器

負責讀取 Java 字節代碼,並轉換成java.lang.Class類的一個實例;

類加載器與類的”相同“判斷

類加載器除了用於加載類外,還可用於確定類在Java虛擬機中的唯一性。

即便是同樣的字節代碼,被不同的類加載器加載之后所得到的類,也是不同的。

通俗一點來講,要判斷兩個類是否“相同”,前提是這兩個類必須被同一個類加載器加載,否則這個兩個類不“相同”。
這里指的“相同”,包括類的Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法、instanceof關鍵字等判斷出來的結果。 

類加載器種類

啟動類加載器,Bootstrap ClassLoader,加載JACA_HOME\lib,或者被-Xbootclasspath參數限定的類
擴展類加載器,Extension ClassLoader,加載\lib\ext,或者被java.ext.dirs系統變量指定的類
應用程序類加載器,Application ClassLoader,加載ClassPath中的類庫
自定義類加載器,通過繼承ClassLoader實現,一般是加載我們的自定義類 

雙親委派模型

類加載器 Java 類如同其它的 Java 類一樣,也是要由類加載器來加載的;除了啟動類加載器,每個類都有其父類加載器(父子關系由組合(不是繼承)來實現);

所謂雙親委派是指每次收到類加載請求時,先將請求委派給父類加載器完成(所有加載請求最終會委派到頂層的Bootstrap ClassLoader加載器中),如果父類加載器無法完成這個加載(該加載器的搜索范圍中沒有找到對應的類),子類嘗試自己加載。


雙親委派好處

  • 避免同一個類被多次加載;
  • 每個加載器只能加載自己范圍內的類;
類加載過程

類加載分為三個步驟:加載連接初始化

如下圖 , 是一個類從加載到使用及卸載的全部生命周期,圖片來自參考資料;

加載

根據一個類的全限定名(如cn.edu.hdu.test.HelloWorld.class)來讀取此類的二進制字節流到JVM內部;

將字節流所代表的靜態存儲結構轉換為方法區的運行時數據結構(hotspot選擇將Class對象存儲在方法區中,Java虛擬機規范並沒有明確要求一定要存儲在方法區或堆區中)

轉換為一個與目標類型對應的java.lang.Class對象;

連接

驗證

驗證階段主要包括四個檢驗過程:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證;

准備

為類中的所有靜態變量分配內存空間,並為其設置一個初始值(由於還沒有產生對象,實例變量將不再此操作范圍內);

解析

將常量池中所有的符號引用轉為直接引用(得到類或者字段、方法在內存中的指針或者偏移量,以便直接調用該方法)。這個階段可以在初始化之后再執行。

初始化

  在連接的准備階段,類變量已賦過一次系統要求的初始值,而在初始化階段,則是根據程序員自己寫的邏輯去初始化類變量和其他資源,舉個例子如下:

    public static int value1  = 5;
    public static int value2  = 6;
    static{
        value2 = 66;
    }

在准備階段value1和value2都等於0;

在初始化階段value1和value2分別等於5和6;

  • 所有類變量初始化語句和靜態代碼塊都會在編譯時被前端編譯器放在收集器里頭,存放到一個特殊的方法中,這個方法就是<clinit>方法,即類/接口初始化方法,該方法只能在類加載的過程中由JVM調用;
  • 編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量;
  • 如果超類還沒有被初始化,那么優先對超類初始化,但在<clinit>方法內部不會顯示調用超類的<clinit>方法,由JVM負責保證一個類的<clinit>方法執行之前,它的超類<clinit>方法已經被執行。
  • JVM必須確保一個類在初始化的過程中,如果是多線程需要同時初始化它,僅僅只能允許其中一個線程對其執行初始化操作,其余線程必須等待,只有在活動線程執行完對類的初始化操作之后,才會通知正在等待的其他線程。(所以可以利用靜態內部類實現線程安全的單例模式)
  • 如果一個類沒有聲明任何的類變量,也沒有靜態代碼塊,那么可以沒有類<clinit>方法;

何時觸發初始化

  1. 為一個類型創建一個新的對象實例時(比如new、反射、序列化)
  2. 調用一個類型的靜態方法時(即在字節碼中執行invokestatic指令)
  3. 調用一個類型或接口的靜態字段,或者對這些靜態字段執行賦值操作時(即在字節碼中,執行getstatic或者putstatic指令),不過用final修飾的靜態字段除外,它被初始化為一個編譯時常量表達式
  4. 調用JavaAPI中的反射方法時(比如調用java.lang.Class中的方法,或者java.lang.reflect包中其他類的方法)
  5. 初始化一個類的派生類時(Java虛擬機規范明確要求初始化一個類時,它的超類必須提前完成初始化操作,接口例外)
  6. JVM啟動包含main方法的啟動類時。 

 自定義類加載器

 要創建用戶自己的類加載器,只需要繼承java.lang.ClassLoader類,然后覆蓋它的findClass(String name)方法即可,即指明如何獲取類的字節碼流。

如果要符合雙親委派規范,則重寫findClass方法(用戶自定義類加載邏輯);要破壞的話,重寫loadClass方法(雙親委派的具體邏輯實現)

例子:

package classloader;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

class TestClassLoad {
    @Override
    public String toString() {
        return "類加載成功。";
    }
}
public class PathClassLoader extends ClassLoader {
    private String classPath;

    public PathClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getData(String className) {
        String path = classPath + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
        try {
            InputStream is = new FileInputStream(path);
            ByteArrayOutputStream stream = new ByteArrayOutputStream();
            byte[] buffer = new byte[2048];
            int num = 0;
            while ((num = is.read(buffer)) != -1) {
                stream.write(buffer, 0, num);
            }
            return stream.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }

        return null;
    }



    public static void main(String args[]) throws ClassNotFoundException,
            InstantiationException, IllegalAccessException {
        ClassLoader pcl = new PathClassLoader("D:\\ProgramFiles\\eclipseNew\\workspace\\cp-lib\\bin");
        Class c = pcl.loadClass("classloader.TestClassLoad");//注意要包括包名
        System.out.println(c.newInstance());//打印類加載成功.
    }
}

JAVA熱部署實現

首先談一下何為熱部署(hotswap),熱部署是在不重啟 Java 虛擬機的前提下,能自動偵測到 class 文件的變化,更新運行時 class 的行為。Java 類是通過 Java 虛擬機加載的,某個類的 class 文件在被 classloader 加載后,會生成對應的 Class 對象,之后就可以創建該類的實例。默認的虛擬機行為只會在啟動時加載類,如果后期有一個類需要更新的話,單純替換編譯的 class 文件,Java 虛擬機是不會更新正在運行的 class。如果要實現熱部署,最根本的方式是修改虛擬機的源代碼,改變 classloader 的加載行為,使虛擬機能監聽 class 文件的更新,重新加載 class 文件,這樣的行為破壞性很大,為后續的 JVM 升級埋下了一個大坑。

另一種友好的方法是創建自己的 classloader 來加載需要監聽的 class,這樣就能控制類加載的時機,從而實現熱部署。 

 熱部署步驟:

1、銷毀自定義classloader(被該加載器加載的class也會自動卸載);

2、更新class

3、使用新的ClassLoader去加載class 

卸載

JVM中的Class只有滿足以下三個條件,才能被GC回收,也就是該Class被卸載(unload):

   - 該類所有的實例都已經被GC,也就是JVM中不存在該Class的任何實例。
   - 加載該類的ClassLoader已經被GC。
   - 該類的java.lang.Class 對象沒有在任何地方被引用,如不能在任何地方通過反射訪問該類的方法 

延伸出來問題進行分析:

看到這個題目,很多人會覺得我寫我的java代碼,至於類,JVM愛怎么加載就怎么加載,博主有很長一段時間也是這么認為的。隨着編程經驗的日積月累,越來越感覺到了解虛擬機相關要領的重要性。閑話不多說,老規矩,先來一段代碼吊吊胃口。

public class SSClass
{
    static
    {
        System.out.println("SSClass");
    }
}   
public class SuperClass extends SSClass
{
    static
    {
        System.out.println("SuperClass init!");
    }
 
    public static int value = 123;
 
    public SuperClass()
    {
        System.out.println("init SuperClass");
    }
}
public class SubClass extends SuperClass
{
    static
    {
        System.out.println("SubClass init");
    }
 
    static int a;
 
    public SubClass()
    {
        System.out.println("init SubClass");
    }
}
public class NotInitialization
{
    public static void main(String[] args)
    {
        System.out.println(SubClass.value);
    }
}

 

運行結果:
SSClass
SuperClass init!
123

 

答案答對了嚒?

也許有人會疑問:為什么沒有輸出SubClass init。ok~解釋一下:對於靜態字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。

上面就牽涉到了虛擬機類加載機制。如果有興趣,可以繼續看下去。 

類加載過程

類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括:加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中准備、驗證、解析3個部分統稱為連接(Linking)。如圖所示。

加載、驗證、准備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)。以下陳述的內容都已HotSpot為基准。

加載

在加載階段(可以參考java.lang.ClassLoader的loadClass()方法),虛擬機需要完成以下3件事情:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流(並沒有指明要從一個Class文件中獲取,可以從其他渠道,譬如:網絡、動態生成、數據庫等);
  2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
  3. 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口;

加載階段和連接階段(Linking)的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先后順序。

驗證

驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
驗證階段大致會完成4個階段的檢驗動作:

  1. 文件格式驗證:驗證字節流是否符合Class文件格式的規范;例如:是否以魔術0xCAFEBABE開頭、主次版本號是否在當前虛擬機的處理范圍之內、常量池中的常量是否有不被支持的類型。
  2. 元數據驗證:對字節碼描述的信息進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的信息符合Java語言規范的要求;例如:這個類是否有父類,除了java.lang.Object之外。
  3. 字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。
  4. 符號引用驗證:確保解析動作能正確執行。

驗證階段是非常重要的,但不是必須的,它對程序運行期沒有影響,如果所引用的類經過反復驗證,那么可以考慮采用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

准備

准備階段是正式為類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在堆中。其次,這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:

public static int value=123;

那變量value在准備階段過后的初始值為0而不是123.因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程序被編譯后,存放於類構造器()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。

至於“特殊情況”是指:public static final int value=123,即當類字段的字段屬性是ConstantValue時,會在准備階段初始化為指定的值,所以標注為final之后,value的值在准備階段初始化為123而非0.

解析

解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。

初始化

類初始化階段是類加載過程的最后一步,到了初始化階段,才真正開始執行類中定義的java程序代碼。在准備極端,變量已經付過一次系統要求的初始值,而在初始化階段,則根據程序猿通過程序制定的主管計划去初始化類變量和其他資源,或者說:初始化階段是執行類構造器<clinit>()方法的過程.

<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊static{}中的語句合並產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但是不能訪問。如下:

public class Test
{
    static
    {
        i=0;
        System.out.println(i);//這句編譯器會報錯:Cannot reference a field before it is defined(非法向前應用)
    }
    static int i=1;
}

那么去掉報錯的那句,改成下面:

public class Test
{
    static
    {
        i=0;
//      System.out.println(i);
    }
    static int i=1;
 
    public static void main(String args[])
    {
        System.out.println(i);
    }
}

輸出結果是什么呢?當然是1啦~在准備階段我們知道i=0,然后類初始化階段按照順序執行,首先執行static塊中的i=0,接着執行static賦值操作i=1,最后在main方法中獲取i的值為1。

()方法與實例構造器<init>()方法不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類<init>()方法執行之前,父類的<clinit>()方法方法已經執行完畢,回到本文開篇的舉例代碼中,結果會打印輸出:SSClass就是這個道理。

由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。

<clinit>()方法對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生產<clinit>()方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個線程阻塞,在實際應用中這種阻塞往往是隱藏的。

package jvm.classload;
 
public class DealLoopTest
{
    static class DeadLoopClass
    {
        static
        {
            if(true)
            {
                System.out.println(Thread.currentThread()+"init DeadLoopClass");
                while(true)
                {
                }
            }
        }
    }
 
    public static void main(String[] args)
    {
        Runnable script = new Runnable(){
            public void run()
            {
                System.out.println(Thread.currentThread()+" start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread()+" run over");
            }
        };
 
        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
}

 

 

運行結果:(即一條線程在死循環以模擬長時間操作,另一條線程在阻塞等待)

Thread[Thread-0,5,main] start
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main]init DeadLoopClass

 

需要注意的是,其他線程雖然會被阻塞,但如果執行()方法的那條線程退出()方法后,其他線程喚醒之后不會再次進入()方法。同一個類加載器下,一個類型只會初始化一次。
將上面代碼中的靜態塊替換如下:

static
{
    System.out.println(Thread.currentThread() + "init DeadLoopClass");
    try
    {
        TimeUnit.SECONDS.sleep(10);
    }
    catch (InterruptedException e)
    {
        e.printStackTrace();
    }
}

運行結果:

Thread[Thread-0,5,main] start
Thread[Thread-1,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass (之后sleep 10s)
Thread[Thread-1,5,main] run over
Thread[Thread-0,5,main] run over

 

虛擬機規范嚴格規定了有且只有5中情況(jdk1.7)必須對類進行“初始化”(而加載、驗證、准備自然需要在此之前開始):

  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_putstatic,REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行初始化,則需要先出觸發其初始化。

開篇已經舉了一個范例:通過子類引用付了的靜態字段,不會導致子類初始化。
這里再舉兩個例子。

  1. 通過數組定義來引用類,不會觸發此類的初始化:(SuperClass類已在本文開篇定義)
  2. public class NotInitialization
    {
        public static void main(String[] args)
        {
            SuperClass[] sca = new SuperClass[10];
        }
    }

     

運行結果:(無)

  1. 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化:
    public class ConstClass
    {
        static
        {
            System.out.println("ConstClass init!");
        }
        public static  final String HELLOWORLD = "hello world";
    }
    public class NotInitialization
    {
        public static void main(String[] args)
        {
            System.out.println(ConstClass.HELLOWORLD);
        }
    }

     

運行結果:

hello world

 

參考:類加載機制

參考:Java虛擬機類加載機制


免責聲明!

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



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