JAVA 註解的基本原理

YangAM發表於2019-03-03

以前,『XML』是各大框架的青睞者,它以鬆耦合的方式完成了框架中幾乎所有的配置,但是隨著專案越來越龐大,『XML』的內容也越來越複雜,維護成本變高。

於是就有人提出來一種標記式高耦合的配置方式,『註解』。方法上可以進行註解,類上也可以註解,欄位屬性上也可以註解,反正幾乎需要配置的地方都可以進行註解。

關於『註解』和『XML』兩種不同的配置模式,爭論了好多年了,各有各的優劣,註解可以提供更大的便捷性,易於維護修改,但耦合度高,而 XML 相對於註解則是相反的。

追求低耦合就要拋棄高效率,追求效率必然會遇到耦合。本文意不再辨析兩者誰優誰劣,而在於以最簡單的語言介紹註解相關的基本內容。

註解的本質

「java.lang.annotation.Annotation」介面中有這麼一句話,用來描述『註解』。

The common interface extended by all annotation types

所有的註解型別都繼承自這個普通的介面(Annotation)

這句話有點抽象,但卻說出了註解的本質。我們看一個 JDK 內建註解的定義:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {

}
複製程式碼

這是註解 @Override 的定義,其實它本質上就是:

public interface Override extends Annotation{
    
}
複製程式碼

沒錯,註解的本質就是一個繼承了 Annotation 介面的介面。有關這一點,你可以去反編譯任意一個註解類,你會得到結果的。

一個註解準確意義上來說,只不過是一種特殊的註釋而已,如果沒有解析它的程式碼,它可能連註釋都不如。

而解析一個類或者方法的註解往往有兩種形式,一種是編譯期直接的掃描,一種是執行期反射。反射的事情我們待會說,而編譯器的掃描指的是編譯器在對 java 程式碼編譯位元組碼的過程中會檢測到某個類或者方法被一些註解修飾,這時它就會對於這些註解進行某些處理。

典型的就是註解 @Override,一旦編譯器檢測到某個方法被修飾了 @Override 註解,編譯器就會檢查當前方法的方法簽名是否真正重寫了父類的某個方法,也就是比較父類中是否具有一個同樣的方法簽名。

這一種情況只適用於那些編譯器已經熟知的註解類,比如 JDK 內建的幾個註解,而你自定義的註解,編譯器是不知道你這個註解的作用的,當然也不知道該如何處理,往往只是會根據該註解的作用範圍來選擇是否編譯進位元組碼檔案,僅此而已。

元註解

『元註解』是用於修飾註解的註解,通常用在註解的定義上,例如:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {

}
複製程式碼

這是我們 @Override 註解的定義,你可以看到其中的 @Target,@Retention 兩個註解就是我們所謂的『元註解』,『元註解』一般用於指定某個註解生命週期以及作用目標等資訊。

JAVA 中有以下幾個『元註解』:

  • @Target:註解的作用目標
  • @Retention:註解的生命週期
  • @Documented:註解是否應當被包含在 JavaDoc 文件中
  • @Inherited:是否允許子類繼承該註解

其中,@Target 用於指明被修飾的註解最終可以作用的目標是誰,也就是指明,你的註解到底是用來修飾方法的?修飾類的?還是用來修飾欄位屬性的。

@Target 的定義如下:

image

我們可以通過以下的方式來為這個 value 傳值:

@Target(value = {ElementType.FIELD})
複製程式碼

被這個 @Target 註解修飾的註解將只能作用在成員欄位上,不能用於修飾方法或者類。其中,ElementType 是一個列舉型別,有以下一些值:

  • ElementType.TYPE:允許被修飾的註解作用在類、介面和列舉上
  • ElementType.FIELD:允許作用在屬性欄位上
  • ElementType.METHOD:允許作用在方法上
  • ElementType.PARAMETER:允許作用在方法引數上
  • ElementType.CONSTRUCTOR:允許作用在構造器上
  • ElementType.LOCAL_VARIABLE:允許作用在本地區域性變數上
  • ElementType.ANNOTATION_TYPE:允許作用在註解上
  • ElementType.PACKAGE:允許作用在包上

@Retention 用於指明當前註解的生命週期,它的基本定義如下:

image

同樣的,它也有一個 value 屬性:

@Retention(value = RetentionPolicy.RUNTIME
複製程式碼

這裡的 RetentionPolicy 依然是一個列舉型別,它有以下幾個列舉值可取:

  • RetentionPolicy.SOURCE:當前註解編譯期可見,不會寫入 class 檔案
  • RetentionPolicy.CLASS:類載入階段丟棄,會寫入 class 檔案
  • RetentionPolicy.RUNTIME:永久儲存,可以反射獲取

@Retention 註解指定了被修飾的註解的生命週期,一種是隻能在編譯期可見,編譯後會被丟棄,一種會被編譯器編譯進 class 檔案中,無論是類或是方法,乃至欄位,他們都是有屬性表的,而 JAVA 虛擬機器也定義了幾種註解屬性表用於儲存註解資訊,但是這種可見性不能帶到方法區,類載入時會予以丟棄,最後一種則是永久存在的可見性。

剩下兩種型別的註解我們日常用的不多,也比較簡單,這裡不再詳細的進行介紹了,你只需要知道他們各自的作用即可。@Documented 註解修飾的註解,當我們執行 JavaDoc 文件打包時會被儲存進 doc 文件,反之將在打包時丟棄。@Inherited 註解修飾的註解是具有可繼承性的,也就說我們的註解修飾了一個類,而該類的子類將自動繼承父類的該註解。

JAVA 的內建三大註解

除了上述四種元註解外,JDK 還為我們預定義了另外三種註解,它們是:

  • @Override
  • @Deprecated
  • @SuppressWarnings

@Override 註解想必是大家很熟悉的了,它的定義如下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
複製程式碼

它沒有任何的屬性,所以並不能儲存任何其他資訊。它只能作用於方法之上,編譯結束後將被丟棄。

所以你看,它就是一種典型的『標記式註解』,僅被編譯器可知,編譯器在對 java 檔案進行編譯成位元組碼的過程中,一旦檢測到某個方法上被修飾了該註解,就會去匹對父類中是否具有一個同樣方法簽名的函式,如果不是,自然不能通過編譯。

@Deprecated 的基本定義如下:

image

依然是一種『標記式註解』,永久存在,可以修飾所有的型別,作用是,標記當前的類或者方法或者欄位等已經不再被推薦使用了,可能下一次的 JDK 版本就會刪除。

當然,編譯器並不會強制要求你做什麼,只是告訴你 JDK 已經不再推薦使用當前的方法或者類了,建議你使用某個替代者。

@SuppressWarnings 主要用來壓制 java 的警告,它的基本定義如下:

image

它有一個 value 屬性需要你主動的傳值,這個 value 代表一個什麼意思呢,這個 value 代表的就是需要被壓制的警告型別。例如:

public static void main(String[] args) {
    Date date = new Date(2018, 7, 11);
}
複製程式碼

這麼一段程式碼,程式啟動時編譯器會報一個警告。

Warning:(8, 21) java: java.util.Date 中的 Date(int,int,int) 已過時

而如果我們不希望程式啟動時,編譯器檢查程式碼中過時的方法,就可以使用 @SuppressWarnings 註解並給它的 value 屬性傳入一個引數值來壓制編譯器的檢查。

@SuppressWarning(value = "deprecated")
public static void main(String[] args) {
    Date date = new Date(2018, 7, 11);
}
複製程式碼

這樣你就會發現,編譯器不再檢查 main 方法下是否有過時的方法呼叫,也就壓制了編譯器對於這種警告的檢查。

當然,JAVA 中還有很多的警告型別,他們都會對應一個字串,通過設定 value 屬性的值即可壓制對於這一類警告型別的檢查。

自定義註解的相關內容就不再贅述了,比較簡單,通過類似以下的語法即可自定義一個註解。

public @interface InnotationName{
    
}
複製程式碼

當然,自定義註解的時候也可以選擇性的使用元註解進行修飾,這樣你可以更加具體的指定你的註解的生命週期、作用範圍等資訊。

註解與反射

上述內容我們介紹了註解使用上的細節,也簡單提到,「註解的本質就是一個繼承了 Annotation 介面的介面」,現在我們就來從虛擬機器的層面看看,註解的本質到底是什麼。

首先,我們自定義一個註解型別:

image

這裡我們指定了 Hello 這個註解只能修飾欄位和方法,並且該註解永久存活,以便我們反射獲取。

之前我們說過,虛擬機器規範定義了一系列和註解相關的屬性表,也就是說,無論是欄位、方法或是類本身,如果被註解修飾了,就可以被寫進位元組碼檔案。屬性表有以下幾種:

  • RuntimeVisibleAnnotations:執行時可見的註解
  • RuntimeInVisibleAnnotations:執行時不可見的註解
  • RuntimeVisibleParameterAnnotations:執行時可見的方法引數註解
  • RuntimeInVisibleParameterAnnotations:執行時不可見的方法引數註解
  • AnnotationDefault:註解類元素的預設值

給大家看虛擬機器的這幾個註解相關的屬性表的目的在於,讓大家從整體上構建一個基本的印象,註解在位元組碼檔案中是如何儲存的。

所以,對於一個類或者介面來說,Class 類中提供了以下一些方法用於反射註解。

  • getAnnotation:返回指定的註解
  • isAnnotationPresent:判定當前元素是否被指定註解修飾
  • getAnnotations:返回所有的註解
  • getDeclaredAnnotation:返回本元素的指定註解
  • getDeclaredAnnotations:返回本元素的所有註解,不包含父類繼承而來的

方法、欄位中相關反射註解的方法基本是類似的,這裡不再贅述,我們下面看一個完整的例子。

首先,設定一個虛擬機器啟動引數,用於捕獲 JDK 動態代理類。

-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true

然後 main 函式。

image

我們說過,註解本質上是繼承了 Annotation 介面的介面,而當你通過反射,也就是我們這裡的 getAnnotation 方法去獲取一個註解類例項的時候,其實 JDK 是通過動態代理機制生成一個實現我們註解(介面)的代理類。

我們執行程式後,會看到輸出目錄裡有這麼一個代理類,反編譯之後是這樣的:

image
image

代理類實現介面 Hello 並重寫其所有方法,包括 value 方法以及介面 Hello 從 Annotation 介面繼承而來的方法。

而這個關鍵的 InvocationHandler 例項是誰?

AnnotationInvocationHandler 是 JAVA 中專門用於處理註解的 Handler, 這個類的設計也非常有意思。

image

這裡有一個 memberValues,它是一個 Map 鍵值對,鍵是我們註解屬性名稱,值就是該屬性當初被賦上的值。

image
image

而這個 invoke 方法就很有意思了,大家注意看,我們的代理類代理了 Hello 介面中所有的方法,所以對於代理類中任何方法的呼叫都會被轉到這裡來。

var2 指向被呼叫的方法例項,而這裡首先用變數 var4 獲取該方法的簡明名稱,接著 switch 結構判斷當前的呼叫方法是誰,如果是 Annotation 中的四大方法,將 var7 賦上特定的值。

如果當前呼叫的方法是 toString,equals,hashCode,annotationType 的話,AnnotationInvocationHandler 例項中已經預定義好了這些方法的實現,直接呼叫即可。

那麼假如 var7 沒有匹配上這四種方法,說明當前的方法呼叫的是自定義註解位元組宣告的方法,例如我們 Hello 註解的 value 方法。這種情況下,將從我們的註解 map 中獲取這個註解屬性對應的值。

其實,JAVA 中的註解設計個人覺得有點反人類,明明是屬性的操作,非要用方法來實現。當然,如果你有不同的見解,歡迎留言探討。

最後我們再總結一下整個反射註解的工作原理:

首先,我們通過鍵值對的形式可以為註解屬性賦值,像這樣:@Hello(value = “hello”)。

接著,你用註解修飾某個元素,編譯器將在編譯期掃描每個類或者方法上的註解,會做一個基本的檢查,你的這個註解是否允許作用在當前位置,最後會將註解資訊寫入元素的屬性表。

然後,當你進行反射的時候,虛擬機器將所有生命週期在 RUNTIME 的註解取出來放到一個 map 中,並建立一個 AnnotationInvocationHandler 例項,把這個 map 傳遞給它。

最後,虛擬機器將採用 JDK 動態代理機制生成一個目標註解的代理類,並初始化好處理器。

那麼這樣,一個註解的例項就建立出來了,它本質上就是一個代理類,你應當去理解好 AnnotationInvocationHandler 中 invoke 方法的實現邏輯,這是核心。一句話概括就是,通過方法名返回註解屬性值


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。

image

相關文章