參考文獻:深入理解Java類型信息(Class對象)與反射機制
一、RRTI的概念以及Class對象作用
認識Class對象之前,先來了解一個概念,RTTI(Run-Time Type Identification)運行時類型識別,其作用是在運行時識別一個對象的類型和類的信息;
這里分兩種:傳統的”RRTI”,它假定我們在編譯期已知道了所有類型(在沒有反射機制創建和使用類對象時,一般都是編譯期已確定其類型,如new對象時該類必須已定義好),另外一種是反射機制,它允許我們在運行時發現和使用類型的信息。在Java中用來表示運行時類型信息的對應類就是Class類,Class類也是一個實實在在的類,存在於JDK的java.lang包中,其部分源碼如下:
public final class Class<T> implements java.io.Serializable,GenericDeclaration,Type, AnnotatedElement { private static final int ANNOTATION= 0x00002000; private static final int ENUM = 0x00004000; private static final int SYNTHETIC = 0x00001000; private static native void registerNatives(); static { registerNatives(); } /* * Private constructor. Only the Java Virtual Machine creates Class objects.(私有構造,只能由JVM創建該類) * This constructor is not used and prevents the default constructor being * generated. */ private Class(ClassLoader loader) { // Initialize final field for classLoader. The initialization value of non-null // prevents future JIT optimizations from assuming this final field is null. classLoader = loader; }
Class類被創建后的對象就是Class對象,注意,Class對象表示的是自己手動編寫類的類型信息,比如創建一個Shapes類,那么,JVM就會創建一個Shapes對應Class類的Class對象,該Class對象保存了Shapes類相關的類型信息。實際上在Java中每個類都有一個Class對象,每當我們編寫並且編譯一個新創建的類就會產生一個對應Class對象並且這個Class對象會被保存在同名.class文件里(這里的class對象與下面要講jvm加載的class對象不一樣,個人理解是被JVM加載生成Class對象前的二進制格式的Class對象);
那為什么需要這樣一個Class對象呢?是這樣的,當我們new一個新對象或者引用靜態成員變量時,Java虛擬機(JVM)中的類加載器子系統會將對應Class對象加載到JVM中,然后JVM再根據這個類型信息相關的Class對象創建我們需要實例對象或者提供靜態變量的引用值。需要特別注意的是,手動編寫的每個class類,無論創建多少個實例對象,在JVM中都只有一個Class對象,即在內存中每個類有且只有一個相對應的Class對象,挺拗口,通過下圖理解(內存中的簡易現象圖):
到這我們也就可以得出以下幾點信息:
- Class類也是類的一種,與class關鍵字是不一樣的。
- 手動編寫的類被編譯后會產生一個Class對象,其表示的是創建的類的類型信息,而且這個Class對象保存在同名.class的文件中(字節碼文件),比如創建一個Shapes類,編譯Shapes類后就會創建其包含Shapes類相關類型信息的Class對象,並保存在Shapes.class字節碼文件中。
- 每個通過關鍵字class標識的類,在內存中有且只有一個與之對應的Class對象來描述其類型信息,無論創建多少個實例對象,其依據的都是用一個Class對象。
- Class類只存私有構造函數,因此對應Class對象只能有JVM創建和加載
- Class類的對象作用是運行時提供或獲得某個對象的類型信息,這點對於反射技術很重要(關於反射稍后分析)。
二、Class對象的加載及其獲取方式
1.Class對象的加載
前面我們已提到過,Class對象是由JVM加載的,那么其加載時機是?實際上所有的類都是在對其第一次使用時動態加載到JVM中的,當程序創建第一個對類的靜態成員引用時,就會加載這個被使用的類(實際上加載的就是這個類的字節碼文件);注意,使用new操作符創建類的新實例對象也會被當作對類的靜態成員的引用(構造函數也是類的靜態方法),由此看來Java程序在它們開始運行之前並非被完全加載到內存的,其各個部分是按需加載;
所以在使用該類時,類加載器首先會檢查這個類的Class對象是否已被加載(類的實例對象創建時依據Class對象中類型信息完成的),如果還沒有加載,默認的類加載器就會先根據類名查找.class文件(編譯后Class對象被保存在同名的.class文件中),在這個類的字節碼文件被加載時,它們必須接受相關驗證,以確保其沒有被破壞並且不包含不良Java代碼(這是java的安全機制檢測),完全沒有問題后就會被動態加載到內存中,此時相當於Class對象也就被載入內存了(畢竟.class字節碼文件保存的就是Class對象),同時也就可以被用來創建這個類的所有實例對象。
下面通過一個簡單例子來說明Class對象被加載的時機問題(例子引用自Thinking in Java):
package com.zejian; class Candy { static { System.out.println("Loading Candy"); } } class Gum { static { System.out.println("Loading Gum"); } } class Cookie { static { System.out.println("Loading Cookie"); } } public class SweetShop { public static void print(Object obj) { System.out.println(obj); } public static void main(String[] args) { print("inside main"); new Candy(); print("After creating Candy"); try { Class.forName("com.zejian.Gum"); } catch(ClassNotFoundException e) { print("Couldn't find Gum"); } print("After Class.forName(\"com.zejian.Gum\")"); new Cookie(); print("After creating Cookie"); } }
在上述代碼中,每個類Candy、Gum、Cookie都存在一個static語句,這個語句會在類第一次被加載時執行,這個語句的作用就是告訴我們該類在什么時候被加載,執行結果:
inside main Loading Candy After creating Candy Loading Gum After Class.forName("com.zejian.Gum") Loading Cookie After creating Cookie Process finished with exit code 0
從結果來看,new一個Candy對象和Cookie對象,構造函數將被調用,屬於靜態方法的引用,Candy類的Class對象和Cookie的Class對象肯定會被加載,畢竟Candy實例對象的創建依據其Class對象。比較有意思的是 Class.forName("com.zejian.Gum");
其中forName方法是Class類的一個static成員方法,記住所有的Class對象都源於這個Class類,因此Class類中定義的方法將適應所有Class對象。這里通過forName方法,我們可以獲取到Gum類對應的Class對象引用。從打印結果來看,調用forName方法將會導致Gum類被加載(前提是Gum類從來沒有被加載過)。
2.Class對象的獲取方式
Class.forName方法
通過上述的案例,我們也就知道Class.forName()方法的調用將會返回一個對應類的Class對象,因此如果我們想獲取一個類的運行時類型信息並加以使用時,可以調用Class.forName()方法獲取Class對象的引用,這樣做的好處是無需通過持有該類的實例對象引用而去獲取Class對象,如下的第2種方式是通過一個實例對象獲取一個類的Class對象,其中的getClass()是從頂級類Object繼承而來的,它將返回表示該對象的實際類型的Class對象引用。
public static void main(String[] args) { try{ //通過Class.forName獲取Gum類的Class對象 Class clazz=Class.forName("com.zejian.Gum"); System.out.println("forName=clazz:"+clazz.getName()); }catch (ClassNotFoundException e){ e.printStackTrace(); } //通過實例對象獲取Gum的Class對象 Gum gum = new Gum(); Class clazz2=gum.getClass(); System.out.println("new=clazz2:"+clazz2.getName()); }
注意調用forName方法時需要捕獲一個名稱為ClassNotFoundException的異常,因為forName方法在編譯器是無法檢測到其傳遞的字符串對應的類是否存在的,只能在程序運行時進行檢查,如果不存在就會拋出ClassNotFoundException異常。
Class字面常量
在Java中存在另一種方式來生成Class對象的引用,它就是Class字面常量,如下:
//字面常量的方式獲取Class對象 Class clazz = Gum.class;
這種方式相對前面兩種方法更加簡單,更安全。因為它在編譯器就會受到編譯器的檢查同時由於無需調用forName方法效率也會更高,因為通過字面量的方法獲取Class對象的引用不會自動初始化該類。更加有趣的是字面常量的獲取Class對象引用方式不僅可以應用於普通的類,也可以應用用接口,數組以及基本數據類型,這點在反射技術應用傳遞參數時很有幫助,關於反射技術稍后會分析,由於基本數據類型還有對應的基本包裝類型,其包裝類型有一個標准字段TYPE,而這個TYPE就是一個引用,指向基本數據類型的Class對象,其等價轉換如下,一般情況下更傾向使用.class的形式,這樣可以保持與普通類的形式統一。
boolean.class = Boolean.TYPE; char.class = Character.TYPE; byte.class = Byte.TYPE; short.class = Short.TYPE; int.class = Integer.TYPE; long.class = Long.TYPE; float.class = Float.TYPE; double.class = Double.TYPE; void.class = Void.TYPE;
前面提到過,使用字面常量的方式獲取Class對象的引用不會觸發類的初始化,這里我們可能需要簡單了解一下類加載的過程,如下:
- 加載:類加載過程的一個階段:通過一個類的完全限定查找此類字節碼文件,並利用字節碼文件創建一個Class對象
- 鏈接:驗證字節碼的安全性和完整性,准備階段正式為靜態域分配存儲空間,注意此時只是分配靜態成員變量的存儲空間,不包含實例成員變量,如果必要的話,解析這個類創建的對其他類的所有引用。
- 初始化:類加載最后階段,若該類具有超類,則對其進行初始化,執行靜態初始化器和靜態初始化成員變量。
由此可知,我們獲取字面常量的Class引用時,觸發的應該是加載階段,因為在這個階段Class對象已創建完成,獲取其引用並不困難,而無需觸發類的最后階段初始化。下面通過小例子來驗證這個過程:
import java.util.*; class Initable { //編譯期靜態常量 static final int staticFinal = 47; //非編期靜態常量 static final int staticFinal2 = ClassInitialization.rand.nextInt(1000); static { System.out.println("Initializing Initable"); } } class Initable2 { //靜態成員變量 static int staticNonFinal = 147; static { System.out.println("Initializing Initable2"); } } class Initable3 { //靜態成員變量 static int staticNonFinal = 74; static { System.out.println("Initializing Initable3"); } } public class ClassInitialization { public static Random rand = new Random(47); public static void main(String[] args) throws Exception { //字面常量獲取方式獲取Class對象 Class initable = Initable.class; System.out.println("After creating Initable ref"); //不觸發類初始化 System.out.println(Initable.staticFinal); //會觸發類初始化 System.out.println(Initable.staticFinal2); //會觸發類初始化 System.out.println(Initable2.staticNonFinal); //forName方法獲取Class對象 Class initable3 = Class.forName("Initable3"); System.out.println("After creating Initable3 ref"); System.out.println(Initable3.staticNonFinal); } }
執行結果:
After creating Initable ref 47 Initializing Initable 258 Initializing Initable2 147 Initializing Initable3 After creating Initable3 ref 74
從輸出結果來看,可以發現,通過字面常量獲取方式獲取Initable類的Class對象並沒有觸發Initable類的初始化,這點也驗證了前面的分析,同時發現調用Initable.staticFinal變量時也沒有觸發初始化,這是因為staticFinal屬於編譯期靜態常量,在編譯階段通過常量傳播優化的方式將Initable類的常量staticFinal存儲到了一個稱為NotInitialization類的常量池中,在以后對Initable類常量staticFinal的引用實際都轉化為對NotInitialization類對自身常量池的引用,所以在編譯期后,對編譯期常量的引用都將在NotInitialization類的常量池獲取,這也就是引用編譯期靜態常量不會觸發Initable類初始化的重要原因。但在之后調用了Initable.staticFinal2變量后就觸發了Initable類的初始化,注意staticFinal2雖然被static和final修飾,但其值在編譯期並不能確定,因此staticFinal2並不是編譯期常量,使用該變量必須先初始化Initable類。Initable2和Initable3類中都是靜態成員變量並非編譯期常量,引用都會觸發初始化。至於forName方法獲取Class對象,肯定會觸發初始化,這點在前面已分析過。到這幾種獲取Class對象的方式也都分析完,ok~,到此這里可以得出小結論:
- 獲取Class對象引用的方式3種,通過繼承自Object類的getClass方法,Class類的靜態方法forName以及字面常量的方式”.class”。
- 其中實例類的getClass方法和Class類的靜態方法forName都將會觸發類的初始化階段,而字面常量獲取Class對象的方式則不會觸發初始化。
- 初始化是類加載的最后一個階段,也就是說完成這個階段后類也就加載到內存中(Class對象在加載階段已被創建),此時可以對類進行各種必要的操作了(如new對象,調用靜態成員等),注意在這個階段,才真正開始執行類中定義的Java程序代碼或者字節碼。
關於類加載的初始化階段,在虛擬機規范嚴格規定了有且只有5種場景必須對類進行初始化:
- 使用new關鍵字實例化對象時、讀取或者設置一個類的靜態字段(不包含編譯期常量)以及調用靜態方法的時候,必須觸發類加載的初始化過程(類加載過程最終階段)。
- 使用反射包(java.lang.reflect)的方法對類進行反射調用時,如果類還沒有被初始化,則需先進行初始化,這點對反射很重要。
- 當初始化一個類的時候,如果其父類還沒進行初始化則需先觸發其父類的初始化。
- 當Java虛擬機啟動時,用戶需要指定一個要執行的主類(包含main方法的類),虛擬機會先初始化這個主類
- 當使用JDK 1.7 的動態語言支持時,如果一個java.lang.invoke.MethodHandle 實例最后解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄對應類沒有初始化時,必須觸發其初始化(這點看不懂就算了,這是1.7的新增的動態語言支持,其關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期進行的,這是一個比較大點的話題,這里暫且打住)