1、類載入器與載入的過程
2、類載入子系統的作用
類載入子系統負責從檔案系統或者網路中載入class檔案,class檔案在檔案開頭有特定的檔案表示。
ClassLoader只負責class檔案的載入,至於它是否可以執行,則由Exceution Engine決定。
載入的類資訊存放於一個稱為方法區的記憶體空間。出了類的資訊外,方法區還會存放執行時常量池的資訊,可能還包括字串字面量和數字常量(這部分常量資訊是class檔案中常量池部分的記憶體對映)。
3、類載入器ClassLoader角色
class file 存在本地磁碟上,可以理解為設計師畫在紙上是模板,而最終這個模板在執行是時候是要載入到JVM當中來根據這個檔案例項化出n個一模一樣的例項。
class file 載入到JVM,被稱為DNA後設資料模板,放在方法區。
在.class檔案——JVM——最終成為後設資料,此過程需要一個運輸工具(類載入器),扮演一個快遞員的角色。
4、類的載入過程
按照Java虛擬機器規範,從class檔案到載入到記憶體中的類,到類解除安裝出記憶體為止,它的整個生命週期包括如下7個階段:
分別是載入、(驗證,準備,解析)連結、初始化、使用和解除安裝。
4、載入過程-載入(Loading)
載入:
類的載入指的是將類的.class檔案中的二進位制資料讀取到記憶體中,存放在執行時資料區的方法區中,並建立一個大的Java.lang.Class物件,
用來封裝方法區內的資料結構 在載入類時,Java虛擬機器必須完成以下3件事情:
1-通過一個類的是全限定名獲取定義此類 二進位制位元組流
2-將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構(解析類的二進位制資料流為方法區內的資料結構(Java類模型))
3-在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口
理解:
我們也可以這樣去理解:所謂裝載(載入),簡而言之就是將Java類的位元組碼檔案載入到機器記憶體中,並在記憶體中構建出Java類的原型——類别範本物件
(所謂類别範本物件,其實就是Java類在JVM記憶體中的一個快照。JVM將從位元組碼檔案中解析出的常量池、類欄位、類方法等資訊儲存到類别範本中。
這樣JVM在執行期便能通過類别範本而獲取Java類中的任意資訊,能夠對Java類的成員變數進行遍歷,也能進行Java方法的呼叫
對於類的二進位制資料流,虛擬機器可以通過多種途徑產生或獲得(只要所讀取的位元組碼符合JVM規範即可)
載入位元組碼檔案的方式:
虛擬機器可能通過檔案系統讀入一個class字尾的檔案(最常見)
讀入jar、zip等歸檔資料包,提取類檔案。
事先存放在資料庫中的類的二進位制資料
使用類似於HTTP之類的協議通過網路進行載入
在執行時生成一段Class的二進位制資訊等
Class例項的位置
(類將.class檔案載入至元空間後,會在堆中建立一個Java.lang.Class物件,用來封裝類位於方法區內的資料結構,該Class物件是在載入類的過程中建立的,每個類都對應有一個Class型別的物件)
5、載入過程-連結(Linking)
1-驗證:確保Class檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性.
目的是確保Class檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性,不會危害虛擬機器自身安全。
主要包括四種驗證:檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證。
格式檢查:是否以魔術oxCAFEBABE開頭,主版本和副版本是否在當前Java虛擬機器的支援範圍內,資料中每一項是否都擁有正確的長度等。
2-準備(靜態變數,不能是常量):
為類變數分配記憶體並且設定該類變數的預設初始化值。
這裡不包含用final修飾的static,因為final在編譯的時候就會分配了,準備階段會顯式賦值。
這裡不會為例項變數分配初始化,類變數會分配在方法區中,而例項變數會隨著物件一起分配到Java堆中。
注意:Java並不支援boolean型別,對於boolean型別,內部實現是int,由於int的預設值是0,故對應的,boolean的預設值就是false。
解析:
將常量池中的符號引號轉換為直接引用的過程(簡言之,將類、介面、欄位和方法的符號引用轉為直接引用),
1-虛擬機器在載入Class檔案時才會進行動態連結,也就是說,Class檔案中不會儲存各個方法和欄位的最終記憶體佈局資訊,因此,這些欄位和方法的符號引用不經過轉換是無法直接被虛擬機器使用的。
當虛擬機器執行起來時,需要從常量池中獲得對應的符號引用,再在類載入過程中(初始化階段)將其替換直接引用,並翻譯到具體的記憶體地址中。
2-符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到了記憶體中
3-直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是與虛擬機器實現的記憶體佈局相關的,
同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那說明引用的目標必定已經存在於記憶體之中了。
4-不過Java虛擬機器規範並沒有明確要求解析階段一定要按照順序執行,在HotSpot VM中,載入、驗證、準備和初始化會按照順序有條不紊地執行,
但連結階段中的解析操作往往會伴隨著JVM在執行完初始化之後再執行。
5-符號引號有:類和介面的許可權定名、欄位的名稱和描述符、方法的名稱和描述符。
解釋什麼是符號引號和直接引用?
(1). 教室裡有個空的位子沒坐人,座位上邊牌子寫著小明的座位(符號引用),後來小明進來坐下去掉牌子(符號引用換成直接引用)。
(2). 我們去做菜,看菜譜,步驟都是什麼樣的(這是符號引號),當我們實際上去做,這個過程是直接引用。
(3). 舉例:輸出操作System.out.println()對應的位元組碼:invokevirtual #24 <java/io/PrintStream.println>。
以方法為例,Java虛擬機器為每個類都準備了一張方法表,將其所有的方法都列在表中,當需要呼叫一個類的方法的時候,只要知道這個方法在方法表中的偏移量就可以直接呼叫該方法。
通過解析操作,符號引用就可以轉變為目標方法在類中方法表中的位置,從而使得方法被成功呼叫。
6、載入過程-初始化(Initialization)
1-為類變數賦予正確的初始化值。
2-初始化階段就是執行類構造器方法< clinit >()的過程。此方法不需要定義,是javac編譯器自動收集類中的所有類變數的賦值動作和靜態程式碼快中的語句合併而來。
public class ClassInitTest { private static int num=1; //類變數的賦值動作 //靜態程式碼快中的語句 static{ num=2; number=20; System.out.println(num); //System.out.println(number); 報錯:非法的前向引用 } //Linking之prepare: number=0 -->initial:20-->10 private static int number=10; public static void main(String[] args) { System.out.println(ClassInitTest.num); System.out.println(ClassInitTest.number); } }
3-若該類具有父類,Jvm會保證子類的< clinit >() 執行前,父類的< clinit >() 已經執行完成。clinit 不同於類的構造方法(init) (由父及子,靜態先行)
public class ClinitTest1 { static class Father{ public static int A=1; static{ A=2; } } static class Son extends Father{ public static int B=A; } public static void main(String[] args) { //這個輸出2,則說明父類已經全部載入完畢 System.out.println(Son.B); } }
4-Java編譯器並不會為所有的類都產生<clinit>()
初始化方法。哪些類在編譯為位元組碼後,位元組碼檔案中將不會包含<clinit>()
方法?
一個類中並沒有宣告任何的類變數,也沒有靜態程式碼塊時。
一個類中宣告類變數,但是沒有明確使用類變數的初始化語句以及靜態程式碼塊來執行初始化操作時。
一個類中包含static final修飾的基本資料型別的欄位,這些類欄位初始化語句採用編譯時常量表示式 (如果這個static final 不是通過方法或者構造器,則在連結階段)。
/** * @author TANGZHI * @create 2021-01-01 18:49 * 哪些場景下,java編譯器就不會生成<clinit>()方法 */ public class InitializationTest1 { //場景1:對應非靜態的欄位,不管是否進行了顯式賦值,都不會生成<clinit>()方法 public int num = 1; //場景2:靜態的欄位,沒有顯式的賦值,不會生成<clinit>()方法 public static int num1; //場景3:比如對於宣告為static final的基本資料型別的欄位,不管是否進行了顯式賦值,都不會生成<clinit>()方法 public static final int num2 = 1; }
5-static與final的搭配問題
(使用static + final修飾,且顯示賦值中不涉及到方法或構造器呼叫的基本資料型別或String型別的顯式賦值,是在連結階段的準備環節進行)
/** * @author TANGZHI * @create 2021-01-01 * * 說明:使用static + final修飾的欄位的顯式賦值的操作,到底是在哪個階段進行的賦值? * 情況1:在連結階段的準備環節賦值 * 情況2:在初始化階段<clinit>()中賦值 * 結論: * 在連結階段的準備環節賦值的情況: * 1. 對於基本資料型別的欄位來說,如果使用static final修飾,則顯式賦值(直接賦值常量,而非呼叫方法)通常是在連結階段的準備環節進行 * 2. 對於String來說,如果使用字面量的方式賦值,使用static final修飾的話,則顯式賦值通常是在連結階段的準備環節進行 * * 在初始化階段<clinit>()中賦值的情況: * 排除上述的在準備環節賦值的情況之外的情況。 * 最終結論:使用static + final修飾,且顯示賦值中不涉及到方法或構造器呼叫的基本資料型別或String型別的顯式賦值,是在連結階段的準備環節進行。 */ public class InitializationTest2 { public static int a = 1;//在初始化階段<clinit>()中賦值 public static final int INT_CONSTANT = 10;//在連結階段的準備環節賦值 public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化階段<clinit>()中賦值 public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化階段<clinit>()中賦值 public static final String s0 = "helloworld0";//在連結階段的準備環節賦值 public static final String s1 = new String("helloworld1");//在初始化階段<clinit>()中賦值 public static String s2 = "helloworld2"; public static final int NUM1 = new Random().nextInt(10);//在初始化階段<clinit>()中賦值
6-clinit()的呼叫會死鎖嗎?
虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的()方法,
其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。
正是因為函式<clinit>()帶鎖執行緒安全的,因此,如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個執行緒阻塞,引發死鎖。
並且這種死鎖是很難發現的,因為看起來它們並沒有可用的鎖資訊。
package com.xiaozhi; /** * @author TANGZHI * @create 2021-05-25 */ class StaticA { static { try { Thread.sleep(1000); } catch (InterruptedException e) { } try { Class.forName("com.xiaozhi.StaticB"); } catch (ClassNotFoundException e) { e.printStackTrace(); } System.out.println("StaticA init OK"); } } class StaticB { static { try { Thread.sleep(1000); } catch (InterruptedException e) { } try { Class.forName("com.xiaozhi.StaticA"); } catch (ClassNotFoundException e) { e.printStackTrace(); } System.out.println("StaticB init OK"); } } public class StaticDeadLockMain extends Thread { private char flag; public StaticDeadLockMain(char flag) { this.flag = flag; this.setName("Thread" + flag); } @Override public void run() { try { Class.forName("com.xiaozhi.Static" + flag); } catch (ClassNotFoundException e) { e.printStackTrace(); } System.out.println(getName() + " over"); } public static void main(String[] args) throws InterruptedException { StaticDeadLockMain loadA = new StaticDeadLockMain('A'); loadA.start(); StaticDeadLockMain loadB = new StaticDeadLockMain('B'); loadB.start(); } }
7、載入過程-類的Using(使用)
①. 任何一個型別在使用之前都必須經歷過完整的載入、連結和初始化3個類載入步驟。一旦一個型別成功經歷過這3個步驟之後,便"萬事俱備,只欠東風"就等著開發者使用了。
②. 開發人員可以在程式中訪問和呼叫它的靜態類成員資訊(比如:靜態欄位、靜態方法)或者使用new關鍵字為其建立物件例項。
8、載入過程-類的Unloading(解除安裝)
①. 類、類的載入器、類的例項之間的引用關係
1-在類載入器的內部實現中,用一個Java集合來存放所載入類的引用。另一方面,一個Class物件總是會引用它的類載入器,呼叫Class物件的getClassLoader()方法,就能獲得它的類載入器。
由此可見,代表某個類的Class例項與其類的載入器之間為雙向關聯關係。
2-一個類的例項總是引用代表這個類的Class物件。在Object類中定義了getClass()方法,這個方法返回代表物件所屬類的Class物件的引用。
此外,所有的Java類都有一個靜態屬性class,它引用代表這個類的Class物件。
②. 方法區的垃圾回收
1-方法區的垃圾收集主要回收兩部分內容:常量池中廢棄的常量和不再使用的型別。
2-HotSpot虛擬機器對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收。
3-判定一個常量是否"廢棄”還是相對簡單,而要判定一個型別是否屬於"不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件。
③. 類的解除安裝
1-啟動類載入器載入的型別在整個執行期間是不可能被解除安裝的(jvm和jls規範)。
2-被系統類載入器和擴充套件類載入器載入的型別在執行期間不太可能被解除安裝,因為系統類載入器例項或者擴充套件類的例項基本上在整個執行期間總能直接或者間接的訪問的到,其達到unreachable的可能性極小。
3-開發者自定義的類載入器例項載入的型別只有在很簡單的上下文環境中才能被解除安裝,而且一般還要藉助於強制呼叫虛擬機器的垃圾收集功能才可以做到。
可以預想,稍微複雜點的應用場景中(比如:很多時候使用者在開發自定義類載入器例項的時候採用快取的策略以提高系統效能),被載入的型別在執行期間也是幾乎不太可能被解除安裝的
(至少解除安裝的時間是不確定的)。