先測試一番,全對的就走人
//題目一
class Parent1{
public static String parent1 = "hello parent1";
static { System.out.println("Parent1 靜態程式碼塊"); }
}
class Children1 extends Parent1{
public static String children1 = "hello children1";
static {System.out.println("Children1 靜態程式碼塊");}
}
//----------------------------------------------------------------
//題目二
class GrandParent2{
static { System.out.println("GrandParent2靜態程式碼塊"); }
}
class Parent2 extends GrandParent2{
public static String parent2="hello parent2";
static{ System.out.println("Parent2 靜態程式碼塊");}
}
class Children2 extends Parent2{
public static String children2 ="hello children2";
static{ System.out.println("Children2 靜態程式碼塊");}
}
//----------------------------------------------------------------
//題目三
class GrandParent3{
static { System.out.println("GrandParent3靜態程式碼塊"); }
}
class Parent3 extends GrandParent3{
public final static String parent3="hello parent3";
static{ System.out.println("Parent3 靜態程式碼塊");}
}
class Children3 extends Parent3{
public static String children3 ="hello children3";
static{ System.out.println("Children3 靜態程式碼塊");}
}
//測試
public class ClassLoaderTest {
public static void main(String[] args) {
//測試一的輸出
System.out.println(Children1.children1);
System.out.println("-------------------------------");
//測試二的輸出
System.out.println(Children2.parent2);
System.out.println("--------------------------------");
//測試三的輸出
System.out.println(Children3.parent3);
}
//你認為輸出什麼呢
}
答案如下
Parent1 靜態程式碼塊
Children1 靜態程式碼塊
hello children1
GrandParent2靜態程式碼塊
Parent2 靜態程式碼塊
hello parent2
hello parent3
如果看清到這裡,你的回答和結果一致,那麼你真的懂了,可以轉載給他人了,如果出乎你的意料,請認真看完。
什麼是類載入(或者初始化)
Java原始碼經過編譯之後轉換成class檔案,在系統執行期間當需要某個類的時候,如果記憶體中還沒該class檔案,那麼JVM需要對這個類的class檔案進行載入,連線,初始化,JVM通常會連續完成這三步,這個過程叫做類的載入或者初始化, 類從磁碟載入到記憶體必須經歷這三個階段的。
重點是:類的載入都是在程式執行期間完成的,這提供了無限可能,意味著你可以在某個階段對類的位元組碼進行修改,JVM也確實提供了這樣的功能。
類的載入並不是物件的建立,類的載入是在為物件建立前做一些資訊準備。
類的生命週期
我們明白了什麼是類的載入,那麼從類的載入到最後類的解除安裝成為類在JVM中的宣告週期,這個生命週期總共包含了七個階段:我畫一張圖,如下,我們逐個分析一下類的生命週期的每一步。
這是類的生命週期的,但它不總是按照這個固定的流程進行的,我們先知道這個就行,後面再說。
載入
類的載入指的是把class檔案從磁碟讀入記憶體中,將其放入後設資料區域並且建立一個Class物件,放入堆中,Class物件是類載入的最終產品,Class物件並不是new出來的物件。
後設資料區域儲存的資訊:
- 這個型別的完整有效名
- 這個型別的直接父類完整有效名
- 這個型別的修飾符(public final abstract等)
- 這個型別的直接介面的列表
Class物件中包含的如下資訊,這也是我們能夠透過Class物件獲取類的很多資訊的原因:
- 類的方法程式碼,方法名,欄位等
- 類的返回值
- 類的訪問許可權
載入class檔案有很多種方式,可以從磁碟上讀取,可以從網路上讀取,可以從zip等歸檔檔案中讀取,可以從資料庫中讀取
驗證
驗證的目的是驗證class檔案的正確性,是否能夠被當前JVM虛擬機器執行,主要包含了一些部分驗證,驗證非常重要,但不是必須的(正常情況下都是正確的)
檔案格式驗證:比如JDK8載入的是JDK6下編譯的class檔案,這肯定不行。
後設資料驗證:確保位元組碼描述資訊符合Java語言規範的要求,你理解為校驗外殼,比如類中是否實現了介面的所有方法。
位元組碼驗證:確定程式語義執行是合法的,校驗內在,校驗方法體,防止位元組碼執行過程中危害JVM虛擬機器。
符合引用驗證:其對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,比如:符號引用中的類、欄位、方法的訪問性是否可被當前類訪問,透過全限定名,是否能找到對應的類。
準備(重點)
驗證完成之後,JVM就開始為類變數(靜態變數) 分配記憶體,設定初始化值, 記住兩點
- 不會為成員變數分配記憶體的。
- 初始化值是指JVM預設的指,不是程式中指定的值。
看如下程式碼,你就明白了:
//類變數,初始化值是 null, 不是123
public static String s1 = "123"
//成員變數
public String s2 = "456"
但有一個特殊,如果一個類變數是final修飾的常量,那麼在準備階段就會被賦值為程式中指定的值,如下程式碼,初始值是123
//初始值是123,不是null
public static final String s1 = "123"
為什麼會這樣呢?兩行程式碼的區別在於final,final在Java中代表著不可變,不能賦值了之後重新賦值,所以一開始就必須賦值為使用者想要的預設值,而不是Java語言的預設值。而不是final修時的變數有可能在之後發生變化,所以就先賦值為Java語言的預設值。
解析
解析階段主要是將常量池中的符號引用轉換為直接引用,解析動作主要包含類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用。
符號引用包括什麼呢?
- 類和方法的全限定名
- 欄位的名稱和描述符
- 方法的名稱和描述符,
直接引用是是什麼呢?一個指向目標的指標地址或者控制程式碼。
舉個例子如下:
// 123 是一個符號引用,123所對應的記憶體中的地址是一個直接引用。
public static final String s1 = "123"
常量池是什麼呢?,常量池包含好多種,字串常量池,class常量池,執行時常量池,這裡指的是class常量池。我們寫的每一個Java類被編譯後,就會形成一份class檔案,class檔案中除了包含類的版本、欄位、方法、介面等描述資訊外,還有一項資訊就是常量池,用於存放編譯器生成的各種字面量和符號引用,每個class檔案都有一個class常量池。
比如解析階段,找不到某個欄位就丟擲NoSuchFieldError,同理NoSuchMethodError
初始化(重點)
初始化階段使用者定義的Java程式碼才會真正開始執行,一般來說當首次主動使用某個類的時候就會對該類初始化,初始化某個類時也會初始化這個類的父類,這裡的首次主動使用,大家要理解清楚了,第二次使用時不會初始化的。類的初始化其實就是執行類構造器的過程,這個不是我們程式碼定義的構造方法。
下面列舉了JVM初始化類的時機:
- 建立物件時(比如:new Person())
- 訪問類變數時
- 呼叫類的靜態方法時
- 反射載入某個類是(Class.forName("....."))
- Java虛擬機器啟動時被標明為啟動類的類(單測時),Main方法的類。
初始化時類變數會被賦予真正的值,也就是開發人員在程式碼中定義的值,也會執行靜態程式碼塊。
JVM初始化類的步驟:
- 若該類還沒有被載入和連線,則程式先載入並連線該類
- 若該類的父類還沒有初始化,則先初始化該類的夫類
- 若該類中有靜態程式碼塊,則系統依次執行這些程式碼塊
上面提到了首次主動使用時初始化類,那麼就有被動使用,被動使用是什麼意思呢?比如說透過子類引用父類的靜態欄位,那麼子類會初始化嗎?答案是不會的,所以下面測試的子類的靜態程式碼塊是不會執行的。
class Parent4{
public final static String parent4="hello parent4";
}
class Children4 extends Parent4{
static{ System.out.println("Children4 靜態程式碼塊");}
}
public class ClassLoaderTest {
public static void main(String[] args) {
//測試四的輸出
System.out.println(Children4.parent4);
}
}
再說一個點解析時有提到常量池的概念,在經過初始化後,類就被載入到記憶體中去了,這個時候jvm就會將class常量池中的內容存放到執行時常量池中,執行時常量池也是每個類都有一個。在解析階段,會把符號引用替換為直接引用,解析的過程會去查詢字串常量池,以保證執行時常量池所引用的字串與字串常量池中是一致的
上面還有一個關鍵字一般來說,那麼不一般呢?類載入器並不需要等到某個類被首次主動使用時再載入它,JVM規範允許類載入器在預料某個類將要被使用時就預先載入它.
使用
使用就比較簡單了,JVM初始化完成後,就開始按照順尋執行使用者程式碼了。
解除安裝
類解除安裝有個前提,就是class的引用是空的,要麼程式中手動置為空,要麼程式退出時JVM銷燬class物件,然後JVM退出。只要class引用不存在,那麼這個類就可以回收了。
你自己可以試驗一下,寫一個classload類載入器,寫一個Test測試類,實際測試一下,我的測試程式碼如下:
public class ClassTest {
public static void main(String[] args){
ClassLoaderMy classLoader = new ClassLoaderMy();
classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
Class clazz = classLoader.findClass("jvm.Test類中有一個靜態程式碼塊。");
Object obj = clazz.newInstance();
System.out.println("1:"+clazz.hashCode());
obj=null;
System.out.println("2:"+clazz.hashCode());
classLoader = null;
System.out.println("3:"+clazz.hashCode());
clazz = null;
System.out.println("此時 obj classloader clazz 都為空了");
classLoader = new ClassLoaderMy();
classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
clazz = classLoader.findClass("jvm.Test");
System.out.println("4:"+clazz.hashCode());
obj = clazz.newInstance();
}
//列印結果如下,看之前你猜一猜。Test類中有一個靜態程式碼塊。
}
初始化了
1:1775282465
2:1775282465
3:1775282465
此時 obj classloader clazz 都為空了
4:1267032364
初始化了
最終結果你會發現,前三個hashcode的值是一樣的,第四個的值發生了變化,說明class檔案被解除安裝了後重新載入生成了新的class物件,否則,同一個物件的hashcode是不會發生變化的,而且Test類的靜態程式碼塊執行了兩遍,完整程式碼地址如下:
https://github.com/sunpengwei1992/java_common/tree/master/src/jvm
我畫了一張圖,方便大家更好的理解,如下,當左邊的三個變數都指向為null時,最右邊的後設資料區域的代表Class物件的Test二進位制資料就會被解除安裝,當下次使用時就會被重新載入,初始化等。
但是,注意了 由JVM自帶的類載入器載入的類,在JVM生命週期中,始終不會被解除安裝,
JVM自帶的類載入器包括根類載入器,擴充套件類載入器,系統類載入器,這些回頭單聊。
解密測試題目
接下來我們聊一聊一開始的測試題,其實看到這裡,想必大家都明白了吧,還是說一說。
第一個不用講了,都會。
第二題:子類Children2,父類Parent2, 祖父類GrandParent2,我們透過Chidlren2列印父類Parent2的靜態變數,類載入時,發現有父類存在,逐層往上載入,那麼Parent2和GrandParent2都會被載入,所以Parent2和GrandParent2的靜態程式碼塊都會被執行,而Children2就不會被載入了,因為不符合首次主動使用的條件。
第三題:同樣的道理,只是Parent3和GrandParent3的靜態程式碼塊為什麼沒執行呢,因為Parent3的靜態變數是final型別的,在準備階段就已經完成了,不需要再逐層往上載入了.
提一下介面的載入
當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,當真正用到父介面的時候才會載入該介面,如下程式碼,執行main方法,Parent5介面是不會被載入的,parent5變數也是不會被初始化的。
interface Parent5{
public final static String parent5 = "hello parent5";
}
interface Children5 extends Parent5{
public final static String children5 = "hello children5";
}
public static void main(String[] args) {
System.out.println(Children5.children5);
}
表格整理一下流程
本作品採用《CC 協議》,轉載必須註明作者和本文連結