讀懂框架設計的靈魂—Java反射機制

飛天小牛肉發表於2021-03-03

? 盡人事,聽天命。博主東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步

? 本文已收錄於 「CS-Wiki」Gitee 官方推薦專案,現已累計 1.5k+ star,致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習

? 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 330+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中


Java 反射機制對於小白來說,真的是一道巨大的坎兒,其他的東西吧,無非就是內容多點,多看看多背背就好了,反射真的就是不管看了多少遍不理解就還是不理解,而且學校裡面的各大教材應該都沒有反射這個章節,有也是一帶而過。說實話,在這篇文章之前,我對反射也並非完全瞭解,畢竟平常開發基本用不到,不過,看完這篇文章相信你對反射就沒啥疑點了。

全文脈絡思維導圖如下:

1. 拋磚引玉:為什麼要使用反射

前文我們說過,介面的使用提高了程式碼的可維護性和可擴充套件性,並且降低了程式碼的耦合度。來看個例子:

首先,我們擁有一個介面 X 及其方法 test,和兩個對應的實現類 A、B:

public class Test {
    
    interface X {
    	public void test();
	}

    class A implements X{
        @Override
        public void test() {
             System.out.println("I am A");
        }
    }

    class B implements X{
        @Override
        public void test() {
            System.out.println("I am B");
    }
}

通常情況下,我們需要使用哪個實現類就直接 new 一個就好了,看下面這段程式碼:

public class Test {    

    ......

	public static void main(String[] args) {
        X a = create1("A");
        a.test();
        X b = create1("B");
        b.test();
    }

    public static X create1(String name){
        if (name.equals("A")) {
            return new A();
        } else if(name.equals("B")){
            return new B();
        }
        return null;
    }

}

按照上面這種寫法,如果有成百上千個不同的 X 的實現類需要建立,那我們豈不是就需要寫上千個 if 語句來返回不同的 X 物件?

我們來看看看反射機制是如何做的:

public class Test {

    public static void main(String[] args) {
		X a = create2("A");
        a.test();
        X b = create2("B");
        b.testReflect();
    }
    
	// 使用反射機制
    public static X create2(String name){
        Class<?> class = Class.forName(name);
        X x = (X) class.newInstance();
        return x;
    }
}

create2() 方法傳入包名和類名,通過反射機制動態的載入指定的類,然後再例項化物件。

看完上面這個例子,相信諸位對反射有了一定的認識。反射擁有以下四大功能:

  • 在執行時(動態編譯)獲知任意一個物件所屬的類。
  • 在執行時構造任意一個類的物件。
  • 在執行時獲知任意一個類所具有的成員變數和方法。
  • 在執行時呼叫任意一個物件的方法和屬性。

上述這種動態獲取資訊、動態呼叫物件的方法的功能稱為 Java 語言的反射機制。

2. 理解 Class 類

要想理解反射,首先要理解 Class 類,因為 Class 類是反射實現的基礎。

在程式執行期間,JVM 始終為所有的物件維護一個被稱為執行時的型別標識,這個資訊跟蹤著每個物件所屬的類的完整結構資訊,包括包名、類名、實現的介面、擁有的方法和欄位等。可以通過專門的 Java 類訪問這些資訊,這個類就是 Class 類。我們可以把 Class 類理解為類的型別,一個 Class 物件,稱為類的型別物件,一個 Class 物件對應一個載入到 JVM 中的一個 .class 檔案

在通常情況下,一定是先有類再有物件。以下面這段程式碼為例,類的正常載入過程是這樣的:

import java.util.Date; // 先有類

public class Test {
    public static void main(String[] args) {
        Date date = new Date(); // 後有物件
        System.out.println(date);
    }
}

首先 JVM 會將你的程式碼編譯成一個 .class 位元組碼檔案,然後被類載入器(Class Loader)載入進 JVM 的記憶體中,同時會建立一個 Date 類的 Class 物件存到堆中(注意這個不是 new 出來的物件,而是類的型別物件)。JVM 在建立 Date 物件前,會先檢查其類是否載入,尋找類對應的 Class 物件,若載入好,則為其分配記憶體,然後再進行初始化 new Date()

需要注意的是,每個類只有一個 Class 物件,也就是說如果我們有第二條 new Date() 語句,JVM 不會再生成一個 DateClass 物件,因為已經存在一個了。這也使得我們可以利用 == 運算子實現兩個類物件比較的操作:

System.out.println(date.getClass() == Date.getClass()); // true

OK,那麼在載入完一個類後,堆記憶體的方法區就產生了一個 Class 物件,這個物件就包含了完整的類的結構資訊,我們可以通過這個 Class 物件看到類的結構,就好比一面鏡子。所以我們形象的稱之為:反射。

說的再詳細點,再解釋一下。上文說過,在通常情況下,一定是先有類再有物件,我們把這個通常情況稱為 “正”。那麼反射中的這個 “反” 我們就可以理解為根據物件找到物件所屬的類(物件的出處)

Date date = new Date();
System.out.println(date.getClass()); // "class java.util.Date"

通過反射,也就是呼叫了 getClass() 方法後,我們就獲得了 Date 類對應的 Class 物件,看到了 Date 類的結構,輸出了 Date 物件所屬的類的完整名稱,即找到了物件的出處。當然,獲取 Class 物件的方式不止這一種。

3. 獲取 Class 類物件的四種方式

Class 類的原始碼可以看出,它的建構函式是私有的,也就是說只有 JVM 可以建立 Class 類的物件,我們不能像普通類一樣直接 new 一個 Class 物件。

我們只能通過已有的類來得到一個 Class 類物件,Java 提供了四種方式:

第一種:知道具體類的情況下可以使用

Class alunbarClass = TargetObject.class;

但是我們一般是不知道具體類的,基本都是通過遍歷包下面的類來獲取 Class 物件,通過此方式獲取 Class 物件不會進行初始化。

第二種:通過 Class.forName() 傳入全類名獲取

Class alunbarClass1 = Class.forName("com.xxx.TargetObject");

這個方法內部實際呼叫的是 forName0

第 2 個 boolean 參數列示類是否需要初始化,預設是需要初始化。一旦初始化,就會觸發目標物件的 static 塊程式碼執行,static 引數也會被再次初始化。

第三種:通過物件例項 instance.getClass() 獲取

Date date = new Date();
Class alunbarClass2 = date.getClass(); // 獲取該物件例項的 Class 類物件

第四種:通過類載入器 xxxClassLoader.loadClass() 傳入類路徑獲取

class clazz = ClassLoader.LoadClass("com.xxx.TargetObject");

通過類載入器獲取 Class 物件不會進行初始化,意味著不進行包括初始化等一些列步驟,靜態塊和靜態物件不會得到執行。這裡可以和 forName 做個對比。

4. 通過反射構造一個類的例項

上面我們介紹了獲取 Class 類物件的方式,那麼成功獲取之後,我們就需要構造對應類的例項。下面介紹三種方法,第一種最為常見,最後一種大家稍作了解即可。

① 使用 Class.newInstance

舉個例子:

Date date1 = new Date();
Class alunbarClass2 = date1.getClass();
Date date2 = alunbarClass2.newInstance(); // 建立一個與 alunbarClass2 具有相同類型別的例項

建立了一個與 alunbarClass2 具有相同類型別的例項。

需要注意的是,newInstance 方法呼叫預設的建構函式(無參建構函式)初始化新建立的物件。如果這個類沒有預設的建構函式, 就會丟擲一個異常

② 通過反射先獲取構造方法再呼叫

由於不是所有的類都有無參建構函式又或者類構造器是 private 的,在這樣的情況下,如果我們還想通過反射來例項化物件,Class.newInstance 是無法滿足的。

此時,我們可以使用 ConstructornewInstance 方法來實現,先獲取建構函式,再執行建構函式。

從上面程式碼很容易看出,Constructor.newInstance 是可以攜帶引數的,而 Class.newInstance 是無參的,這也就是為什麼它只能呼叫無參建構函式的原因了。

大家不要把這兩個 newInstance 方法弄混了。如果被呼叫的類的建構函式為預設的建構函式,採用Class.newInstance() 是比較好的選擇, 一句程式碼就 OK;如果需要呼叫類的帶參建構函式、私有建構函式等, 就需要採用 Constractor.newInstance()

Constructor.newInstance 是執行建構函式的方法。我們來看看獲取建構函式可以通過哪些渠道,作用如其名,以下幾個方法都比較好記也容易理解,返回值都通過 Cnostructor 型別來接收。

批量獲取建構函式

1)獲取所有"公有的"構造方法

public Constructor[] getConstructors() { }

2)獲取所有的構造方法(包括私有、受保護、預設、公有)

public Constructor[] getDeclaredConstructors() { }

單個獲取建構函式

1)獲取一個指定引數型別的"公有的"構造方法

public Constructor getConstructor(Class... parameterTypes) { }

2)獲取一個指定引數型別的"構造方法",可以是私有的,或受保護、預設、公有

public Constructor getDeclaredConstructor(Class... parameterTypes) { }

舉個例子:

package fanshe;

public class Student {
	//(預設的構造方法)
	Student(String str){
		System.out.println("(預設)的構造方法 s = " + str);
	}
	// 無參構造方法
	public Student(){
		System.out.println("呼叫了公有、無參構造方法執行了。。。");
	}
	// 有一個引數的構造方法
	public Student(char name){
		System.out.println("姓名:" + name);
	}
	// 有多個引數的構造方法
	public Student(String name ,int age){
		System.out.println("姓名:"+name+"年齡:"+ age);//這的執行效率有問題,以後解決。
	}
	// 受保護的構造方法
	protected Student(boolean n){
		System.out.println("受保護的構造方法 n = " + n);
	}
	// 私有構造方法
	private Student(int age){
		System.out.println("私有的構造方法年齡:"+ age);
	}
}

----------------------------------
    
public class Constructors {
	public static void main(String[] args) throws Exception {
		// 載入Class物件
		Class clazz = Class.forName("fanshe.Student");
        
		// 獲取所有公有構造方法
		Constructor[] conArray = clazz.getConstructors();
		for(Constructor c : conArray){
			System.out.println(c);
		}
        
		// 獲取所有的構造方法(包括:私有、受保護、預設、公有)
		conArray = clazz.getDeclaredConstructors();
		for(Constructor c : conArray){
			System.out.println(c);
		}
        
		// 獲取公有、無參的構造方法
        // 因為是無參的構造方法所以型別是一個null,不寫也可以:這裡需要的是一個引數的型別,切記是型別
		// 返回的是描述這個無參建構函式的類物件。
		Constructor con = clazz.getConstructor(null);
		Object obj = con.newInstance(); // 呼叫構造方法
		
		// 獲取私有構造方法
		con = clazz.getDeclaredConstructor(int.class);
		System.out.println(con);
		con.setAccessible(true); // 為了呼叫 private 方法/域 我們需要取消安全檢查
		obj = con.newInstance(12); // 呼叫構造方法
	}
}

③ 使用開源庫 Objenesis

Objenesis 是一個開源庫,和上述第二種方法一樣,可以呼叫任意的建構函式,不過封裝的比較簡潔:

public class Test {
    // 不存在無參建構函式
    private int i;
    public Test(int i){
        this.i = i;
    }
    public void show(){
        System.out.println("test..." + i);
    }
}

------------------------
    
public static void main(String[] args) {
        Objenesis objenesis = new ObjenesisStd(true);
        Test test = objenesis.newInstance(Test.class);
        test.show();
    }

使用非常簡單,Objenesis 由子類 ObjenesisObjenesisStd實現。詳細原始碼此處就不深究了,瞭解即可。

5. 通過反射獲取成員變數並使用

和獲取建構函式差不多,獲取成員變數也分批量獲取和單個獲取。返回值通過 Field 型別來接收。

批量獲取

1)獲取所有公有的欄位

public Field[] getFields() { }

2)獲取所有的欄位(包括私有、受保護、預設的)

public Field[] getDeclaredFields() { }

單個獲取

1)獲取一個指定名稱的公有的欄位

public Field getField(String name) { }

2)獲取一個指定名稱的欄位,可以是私有、受保護、預設的

public Field getDeclaredField(String name) { }

獲取到成員變數之後,如何修改它們的值呢?

set 方法包含兩個引數:

  • obj:哪個物件要修改這個成員變數
  • value:要修改成哪個值

舉個例子:

package fanshe.field;

public class Student {
	public Student(){
        
	}
	
	public String name;
	protected int age;
	char sex;
	private String phoneNum;
	
	@Override
	public String toString() {
		return "Student [name=" + name + ", age=" + age + ", sex=" + sex
				+ ", phoneNum=" + phoneNum + "]";
	}
}

----------------------------------
    
public class Fields {
    public static void main(String[] args) throws Exception {
        // 獲取 Class 物件
        Class stuClass = Class.forName("fanshe.field.Student");
        // 獲取公有的無參建構函式
        Constructor con = stuClass.getConstructor();
		
		// 獲取私有構造方法
		con = clazz.getDeclaredConstructor(int.class);
		System.out.println(con);
		con.setAccessible(true); // 為了呼叫 private 方法/域 我們需要取消安全檢查
		obj = con.newInstance(12); // 呼叫構造方法
        
        // 獲取所有公有的欄位
        Field[] fieldArray = stuClass.getFields();
        for(Field f : fieldArray){
            System.out.println(f);
        }

         // 獲取所有的欄位 (包括私有、受保護、預設的)
        fieldArray = stuClass.getDeclaredFields();
        for(Field f : fieldArray){
            System.out.println(f);
        }

        // 獲取指定名稱的公有欄位
        Field f = stuClass.getField("name");
        Object obj = con.newInstance(); // 呼叫建構函式,建立該類的例項
        f.set(obj, "劉德華"); // 為 Student 物件中的 name 屬性賦值


        // 獲取私有欄位
        f = stuClass.getDeclaredField("phoneNum");
        f.setAccessible(true); // 暴力反射,解除私有限定
        f.set(obj, "18888889999"); // 為 Student 物件中的 phoneNum 屬性賦值
    }
}

6. 通過反射獲取成員方法並呼叫

同樣的,獲取成員方法也分批量獲取和單個獲取。返回值通過 Method 型別來接收。

批量獲取

1)獲取所有"公有方法"(包含父類的方法,當然也包含 Object 類)

public Method[] getMethods() { }

2)獲取所有的成員方法,包括私有的(不包括繼承的)

public Method[] getDeclaredMethods() { }

單個獲取

獲取一個指定方法名和引數型別的成員方法:

public Method getMethod(String name, Class<?>... parameterTypes)

獲取到方法之後該怎麼呼叫它們呢?

invoke 方法中包含兩個引數:

  • obj:哪個物件要來呼叫這個方法
  • args:呼叫方法時所傳遞的實參

舉個例子:

package fanshe.method;
 
public class Student {
	public void show1(String s){
		System.out.println("呼叫了:公有的,String引數的show1(): s = " + s);
	}
	protected void show2(){
		System.out.println("呼叫了:受保護的,無參的show2()");
	}
	void show3(){
		System.out.println("呼叫了:預設的,無參的show3()");
	}
	private String show4(int age){
		System.out.println("呼叫了,私有的,並且有返回值的,int引數的show4(): age = " + age);
		return "abcd";
	}
}

-------------------------------------------
public class MethodClass {
	public static void main(String[] args) throws Exception {
		// 獲取 Class物件
		Class stuClass = Class.forName("fanshe.method.Student");
        // 獲取公有的無參建構函式
        Constructor con = stuClass.getConstructor();
        
		// 獲取所有公有方法
		stuClass.getMethods();
		Method[] methodArray = stuClass.getMethods();
		for(Method m : methodArray){
			System.out.println(m);
		}
        
		// 獲取所有的方法,包括私有的
		methodArray = stuClass.getDeclaredMethods();
		for(Method m : methodArray){
			System.out.println(m);
		}
        
		// 獲取公有的show1()方法
		Method m = stuClass.getMethod("show1", String.class);
		System.out.println(m);
		Object obj = con.newInstance(); // 呼叫建構函式,例項化一個 Student 物件
		m.invoke(obj, "小牛肉");
		
		// 獲取私有的show4()方法
		m = stuClass.getDeclaredMethod("show4", int.class);
		m.setAccessible(true); // 解除私有限定
		Object result = m.invoke(obj, 20);
		System.out.println("返回值:" + result);
	}
}

7. 反射機制優缺點

優點: 比較靈活,能夠在執行時動態獲取類的例項。

缺點

1)效能瓶頸:反射相當於一系列解釋操作,通知 JVM 要做的事情,效能比直接的 Java 程式碼要慢很多。

2)安全問題:反射機制破壞了封裝性,因為通過反射可以獲取並呼叫類的私有方法和欄位。

8. 反射的經典應用場景

反射在我們實際程式設計中其實並不會直接大量的使用,但是實際上有很多設計都與反射機制有關,比如:

  • 動態代理機制
  • 使用 JDBC 連線資料庫
  • Spring / Hibernate 框架(實際上是因為使用了動態代理,所以才和反射機制有關)

為什麼說動態代理使用了反射機制,下篇文章會給出詳細解釋。

JDBC 連線資料庫

在 JDBC 的操作中,如果要想進行資料庫的連線,則必須按照以下幾步完成:

  • 通過 Class.forName() 載入資料庫的驅動程式 (通過反射載入)
  • 通過 DriverManager 類連線資料庫,引數包含資料庫的連線地址、使用者名稱、密碼
  • 通過 Connection 介面接收連線
  • 關閉連線
public static void main(String[] args) throws Exception {  
        Connection con = null; // 資料庫的連線物件  
        // 1. 通過反射載入驅動程式
        Class.forName("com.mysql.jdbc.Driver"); 
        // 2. 連線資料庫  
        con = DriverManager.getConnection(
            "jdbc:mysql://localhost:3306/test","root","root"); 
        // 3. 關閉資料庫連線
        con.close(); 
}

Spring 框架

反射機制是 Java 框架設計的靈魂,框架的內部都已經封裝好了,我們自己基本用不著寫。典型的除了Hibernate 之外,還有 Spring 也用到了很多反射機制,最典型的就是 Spring 通過 xml 配置檔案裝載 Bean(建立物件),也就是 Spring 的 IoC,過程如下:

  • 載入配置檔案,獲取 Spring 容器
  • 使用反射機制,根據傳入的字串獲得某個類的 Class 例項
// 獲取 Spring 的 IoC 容器,並根據 id 獲取物件
public static void main(String[] args) {
    // 1.使用 ApplicationContext 介面載入配置檔案,獲取 spring 容器
    ApplicationContext ac = new ClassPathXmlApplicationContext("spring.xml");
    // 2. 使用反射機制,根據這個字串獲得某個類的 Class 例項
    IAccountService aService = (IAccountService) ac.getBean("accountServiceImpl");
    System.out.println(aService);
}

另外,Spring AOP 由於使用了動態代理,所以也使用了反射機制,這點我會在 Spring 的系列文章中詳細解釋。

? References

? 關注公眾號 | 飛天小牛肉,即時獲取更新

  • 博主東南大學碩士在讀,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(資料結構 + 演算法 + 計算機網路 + 資料庫 + 作業系統 + Linux)、Java 基礎和麵試指南的相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。希望大家多多支援哦,和小牛肉一起成長 ?
  • 並推薦個人維護的開源教程類專案: CS-Wiki(Gitee 推薦專案,現已累計 1.5k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ ?
  • 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 330+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中。

相關文章