1. 程式人生 > >Java型別資訊(Class物件)與反射機制-1

Java型別資訊(Class物件)與反射機制-1

RRTI的概念以及Class物件作用

RTTI(Run-Time Type Identification)執行時型別識別,對於這個詞一直是 C++ 中的概念,至於Java中出現RTTI的說法則是源於《Thinking in Java》一書,其作用是在執行時識別一個物件的型別和類的資訊。

這裡分兩種:

1.傳統的”RTTI”,它假定我們在編譯期已知道了所有型別(在沒有反射機制建立和使用類物件時,一般都是編譯期已確定其型別,如new物件時該類必須已定義好);

2.反射機制,它允許我們在執行時發現和使用型別的資訊。

在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物件),那為什麼需要這樣一個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物件的載入及其獲取方式

Class物件的載入

所有的類都是在對其第一次使用時動態載入到JVM中的,當程式建立第一個對類的靜態成員引用時,就會載入這個被使用的類(實際上載入的就是這個類的位元組碼檔案),包含new(建構函式也是類的靜態方法)。

Java程式在它們開始執行之前並非被完全載入到記憶體的,類載入器首先會檢查這個類的Class物件是否已被載入(類的例項物件建立時依據Class物件中型別資訊完成的),如果沒有,預設的類載入器就會先根據類名查詢.class檔案,在這個類的位元組碼檔案被載入時,它們必須接受相關驗證,以確保其沒有被破壞並且不包含不良Java程式碼(這是java的安全機制檢測),完全沒有問題後就會被動態載入到記憶體中,此時相當於Class物件也就被載入記憶體了,同時也就可以被用來建立這個類的所有例項物件。

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

從結果來看,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類從來沒有被載入過)。

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

從輸出結果來看,可以發現

1.通過字面常量獲取方式獲取Initable類的Class物件並沒有觸發Initable類的初始化,這點也驗證了前面的分析;

2.同時發現呼叫Initable.static final變數時也沒有觸發初始化,這是因為static final屬於編譯期靜態常量,在編譯階段通過常量傳播優化的方式將Initable類的常量static final儲存到了一個稱為NotInitialization類的常量池中,在以後對Initable類常量static final的引用實際都轉化為對NotInitialization類對自身常量池的引用,所以在編譯期後,對編譯期常量的引用都將在NotInitialization類的常量池獲取,這也就是引用編譯期靜態常量不會觸發Initable類初始化的重要原因。但在之後呼叫了Initable.staticFinal2變數後就觸發了Initable類的初始化,注意staticFinal2雖然被static和final修飾,但其值在編譯期並不能確定,因此staticFinal2並不是編譯期常量,使用該變數必須先初始化Initable類。

3.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的新增的動態語言支援,其關鍵特徵是它的型別檢查的主體過程是在執行期而不是編譯期進行的,這是一個比較大點的話題,這裡暫且打住)