面試官:說說什麼是泛型的型別擦除?

先看一道常見的面試題,下面的程式碼的執行結果是什麼?

public static void main(String[] args) { List list1=new ArrayList(); List list2=new ArrayList(); System。out。println(list1。getClass()==list2。getClass());}

首先,我們知道

getClas

方法獲取的是物件執行時的類(Class),那麼這個問題也就可以轉化為

ArrayList

ArrayList

的物件在執行時對應的Class是否相同?

我們直接揭曉答案,執行上面的程式碼,程式會列印

true

,說明雖然在程式碼中聲明瞭具體的泛型,但是兩個List物件對應的Class是一樣的,對它們的型別進行列印,結果都是:

class java。util。ArrayList

也就是說,雖然

ArrayList

ArrayList

在編譯時是不同的型別,但是在編譯完成後都被編譯器簡化成了

ArrayList

,這一現象,被稱為泛型的

型別擦除

(Type Erasure)。泛型的本質是引數化型別,而型別擦除使得型別引數只存在於編譯期,在執行時,

jvm

是並不知道泛型的存在的。

那麼為什麼要進行泛型的型別擦除呢?查閱的一些資料中,解釋說型別擦除的主要目的是避免過多的建立類而造成的執行時的過度消耗。試想一下,如果用

List

表示一個型別,再用

List

表示另一個型別,以此類推,無疑會引起型別的數量爆炸。

在對型別擦除有了一個大致的瞭解後,我們再看看下面的幾個問題。

型別擦除做了什麼?

上面我們說了,編譯完成後會對泛型進行型別擦除,如果想要眼見為實,實際看一下的話應該怎麼辦呢?那麼就需要對編譯後的位元組碼檔案進行反編譯了,這裡使用一個輕量級的小工具

Jad

來進行反編譯(可以從這個地址進行下載:

https://varaneckas。com/jad/

Jad

的使用也很簡單,下載解壓後,把需要反編譯的位元組碼檔案放在目錄下,然後在命令列裡執行下面的命令就可以在同目錄下生成反編譯後的

。java

檔案了:

jad -sjava Test。class

好了,工具準備好了,下面我們就看一下不同情況下的型別擦除。

1、無限制型別擦除

當類定義中的型別引數沒有任何限制時,在型別擦除後,會被直接替換為

Object

。在下面的例子中,

中的型別引數T就全被替換為了

Object

(左側為編譯前的程式碼,右側為透過位元組碼檔案反編譯得到的程式碼):

面試官:說說什麼是泛型的型別擦除?

2、有限制類型擦除

當類定義中的型別引數存在限制時,在型別擦除中替換為型別引數的上界或者下界。下面的程式碼中,經過擦除後

T

被替換成了

Integer

面試官:說說什麼是泛型的型別擦除?

3、擦除方法中的型別引數

比較下面兩邊的程式碼,可以看到在擦除方法中的型別引數時,和擦除類定義中的型別引數一致,無限制時直接擦除為

Object

,有限制時則會被擦除為上界或下界:

面試官:說說什麼是泛型的型別擦除?

反射能獲取泛型的型別嗎?

估計對Java反射比較熟悉小夥伴要有疑問了,反射中的

getTypeParameters

方法可以獲得類、陣列、介面等實體的型別引數,如果型別被擦除了,那麼能獲取到什麼呢?我們來嘗試一下使用反射來獲取型別引數:

System。out。println(Arrays。asList(list1。getClass()。getTypeParameters()));

執行結果如下:

[E]

同樣,如果列印

Map

物件的引數型別:

Map map=new HashMap<>();System。out。println(Arrays。asList(map。getClass()。getTypeParameters()));

最終也只能夠獲取到:

[K, V]

可以看到透過

getTypeParameters

方法只能獲取到泛型的引數佔位符,而不能獲得程式碼中真正的泛型型別。

能在指定型別的List中放入其他型別的物件嗎?

使用泛型的好處之一,就是在編譯的時候能夠檢查型別安全,但是透過上面的例子,我們知道

執行時

是沒有泛型約束的,那麼是不是就意味著,在執行時可以把一個型別的物件能放進另一型別的

List

呢?我們先看看正常情況下,直接呼叫

add

方法會有什麼報錯:

面試官:說說什麼是泛型的型別擦除?

當我們嘗試將

User

型別的物件放入

String

型別的陣列時,泛型的約束會在編譯期間就進行報錯,提示提供的

User

型別物件不適用於

String

型別陣列。那麼既然編譯時不行,那麼我們就在執行時寫入,藉助真正執行的

class

是沒有泛型約束這一特性,使用反射在執行時寫入:

public class ReflectTest { static List list = new ArrayList<>(); public static void main(String[] args) { list。add(“1”); ReflectTest reflectTest =new ReflectTest(); try { Field field = ReflectTest。class。getDeclaredField(“list”); field。setAccessible(true); List list=(List) field。get(reflectTest); list。add(new User()); } catch (Exception e) { e。printStackTrace(); } }}

執行上面的程式碼,不僅在編譯期間可以透過語法檢查,並且也可以正常地執行,我們使用

debug

來看一下陣列中的內容:

面試官:說說什麼是泛型的型別擦除?

可以看到雖然陣列中宣告的泛型型別是

String

,但是仍然成功的放入了

User

型別的物件。那麼,如果我們在程式碼中嘗試取出這個

User

物件,程式還能正常執行嗎,我們在上面程式碼的最後再加上一句:

System。out。println(list。get(1));

再次執行程式碼,程式執行到最後的列印語句時,報錯如下:

面試官:說說什麼是泛型的型別擦除?

異常提示

User

型別的物件無法被轉換成

String

型別,這是否也就意味著,在取出物件時存在強制型別轉換呢?我們來看一下

ArrayList

get

方法的原始碼:

public E get(int index) { rangeCheck(index); return elementData(index);}E elementData(int index) { return (E) elementData[index];}

可以看到,在取出元素時,會將這個元素強制型別轉換成泛型中的型別,也就是說在上面的程式碼中,最後會嘗試強制把

User

物件轉換成

String

型別,在這一階段程式會報錯。透過這一過程,也再次證明了泛型可以對型別安全進行檢測。

型別擦除會引起什麼問題?

下面我們看一個稍微有點複雜的例子,首先宣告一個介面,然後建立一個實現該介面的類:

public interface Fruit { T get(T param);}public class Apple implements Fruit { @Override public Integer get(Integer param) { return param; }}

按照之前我們的理解,在進行型別擦除後,應該是這樣的:

public interface Fruit { Object get(Object param);}public class Apple implements Fruit { @Override public Integer get(Integer param) { return param; }}

但是,如果真是這樣的話那麼程式碼是無法執行的,因為雖然

Apple

類中也有一個

get

方法,但是與介面中的方法引數不一致,也就是說沒有覆蓋介面中的方法。針對這種情況,編譯器會透過新增一個

橋接方法

來滿足語法上的要求,同時保證了基於泛型的多型能夠有效。我們反編譯上面程式碼生成的位元組碼檔案:

面試官:說說什麼是泛型的型別擦除?

可以看到,編譯後的程式碼中生成了兩個

get

方法。引數為

Object

get

方法負責實現

Fruit

介面中的同名方法,然後在實現類中又額外添加了一個引數為

Integer

get

方法,這個方法也就是理論上應該生成的帶引數型別的方法。最終用介面方法呼叫額外新增的方法,透過這種方式構建了介面和實現類的關係,類似於起到了橋接的作用,因此也被稱為橋接方法,最終,透過這種機制保證了泛型情況下的Java多型性。

總結

本文由面試中常見的一道面試題入手,介紹了java中泛型的型別擦除相關知識,透過這一過程,也便於大家理解為什麼平常總是說java中的泛型是一個

偽泛型

,同時也有助於大家認識到java中泛型的一些缺陷。瞭解型別擦除的原因以及原理,相信能夠方便大家在日常的工作中更好的使用泛型。