淺析 JVM 中的符號引用與直接引用
前言
在 JVM 的學習過程中,一直會遇到符號引用和直接引用這兩個概念。最近我也查閱了一些資料,有了一些初步的認識,記錄在此與大家分享。文中的內容,主要參考自 JVM裡的符號引用如何儲存? 與 自己動手寫Java虛擬機器。
關於符號引用與直接引用,我們還是用一個例項來分析吧。看下面的 Java 程式碼:
package test; public class Test { public static void main(String[] args) { Sub sub = new Sub(); int a = 100; int d = sub.inc(a); } } class Sub { public int inc(int a) { return a + 2; } }
編譯後使用 javap 分析工具,會得到下面的 Class 檔案內容:
Constant pool: #1 = Methodref #6.#15 // java/lang/Object."<init>":()V #2 = Class #16 // test/Sub #3 = Methodref #2.#15 // test/Sub."<init>":()V #4 = Methodref #2.#17 // test/Sub.inc:(I)I #5 = Class #18 // test/Test #6 = Class #19 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 Test.java #15 = NameAndType #7:#8 // "<init>":()V #16 = Utf8 test/Sub #17 = NameAndType #20:#21 // inc:(I)I #18 = Utf8 test/Test #19 = Utf8 java/lang/Object #20 = Utf8 inc #21 = Utf8 (I)I { public test.Test(); descriptor: ()V Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V Code: stack=2, locals=4, args_size=1 0: new #2 // class test/Sub 3: dup 4: invokespecial #3 // Method test/Sub."<init>":()V 7: astore_1 8: bipush 100 10: istore_2 11: aload_1 12: iload_2 13: invokevirtual #4 // Method test/Sub.inc:(I)I 16: istore_3 17: return }
因為篇幅有限,上面的內容只保留了常量池,和 Code 部分。下面我們主要對 inc 方法的呼叫來進行說明。
符號引用
在 main 方法的位元組碼中,呼叫 inc 方法的指令如下:
13: invokevirtual #4 // Method test/Sub.inc:(I)I
invokevirtual 指令就是呼叫例項方法的指令,後面的運算元 4 是 Class 檔案中常量池的下標,表示用來指定要呼叫的目標方法。我們再來看常量池在這個位置上的內容:
#4 = Methodref #2.#17
這是一個 Methodref 型別的資料,我們再來看看虛擬機器規範中對該型別的說明:
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
這實際上就是一種引用型別,tag 表示了常量池資料型別,這裡固定是 10。class_index 表示了類的索引,name_and_type_index 表示了名稱與型別的索引,這兩個也都是常量池的下標。在 javap 的輸出中,已經將對應的關係列印了出來,我們可以直接的觀察到它都引用了哪些型別:
#4 = Methodref #2.#17 // test/Sub.inc:(I)I
|--#2 = Class #16 // test/Sub
| |--#16 = Utf8 test/Sub
|--#17 = NameAndType #20:#21 // inc:(I)I
| |--#20 = Utf8 inc
| |--#21 = Utf8 (I)I
這裡我們將其表現為樹的形式。可以看到,我們可以得到該方法所在的類,以及方法的名稱和描述符。於是我們根據 invokevirtual 的運算元,找到了常量池中方法對應的 Methodref,進而找到了方法所在的類以及方法的名稱和描述符,當然這些內容最終都是字串形式。
實際上這就是一個符號引用的例子,符號引用也可以理解為像這樣使用文字形式來描述引用關係。
直接引用
符號引用在上面說完了,我們知道符號引用大概就是文字形式表示的引用關係。但是在方法的執行中,只有這樣一串字串,有什麼用呢?方法的本體在哪裡?下面這就是直接引用的概念了,這裡我用自己目前的理解總結一下,直接引用就是通過對符號引用進行解析,來獲得真正的函式入口地址,也就是在執行的記憶體區域找到該方法位元組碼的起始位置,從而真正的呼叫方法。
那麼將符號引用解析為直接引用的過程是什麼樣的呢?我這個小渣渣目前也給不出確定的答案,在 JVM裡的符號引用如何儲存? 裡,RednaxelaFX 大大給出了一個 Sun JDK 1.0.2 的實現;在 自己動手寫Java虛擬機器 中,作者給出了一種用 Go 的簡單實現,下面這裡就來看一下這個簡單一些的實現。在 HotSpot VM 中的實現肯定要複雜得多,這裡還是以大致的學習瞭解為主,以後如果有時間有精力,再去研究一下 OpenJDK 中 HotSpot VM 的實現。
不過不管是哪種實現,肯定要先讀取 Class 檔案,然後將其以某種格式儲存在記憶體中,類的資料會記錄在某個結構體內,方法的資料也會記錄在另外的結構體中,然後將結構體之間相互組合、關聯起來。比如,我們用下面的形式來表達 Class 的資料在記憶體中的儲存形式:
type Class struct {
accessFlags uint16 // 訪問控制
name string // 類名
superClassName string // 父類名
interfaceNames []string // 介面名列表
constantPool *ConstantPool // 該類對應的常量池
fields []*Field // 欄位列表
methods []*Method // 方法列表
loader *ClassLoader // 載入該類的類載入器
superClass *Class // 父類結構體的引用
interfaces []*Class // 各個介面結構體的引用
instanceSlotCount uint // 類中的例項變數數量
staticSlotCount uint // 類中的靜態變數數量
staticVars Slots // 類中的靜態變數的引用列表
initStarted bool // 類是否被初始化
}
類似的,常量池中的方法引用,也要有類似的結構來表示:
type MethodRef struct {
cp *ConstantPool // 常量池
className string // 所在的類名
class *Class // 所在的類的結構體引用
name string // 方法名
descriptor string // 描述符
method *Method // 方法資料的引用
}
回到上面符號解析的例子。當遇到 invokevirtual 指令時,根據後面的運算元,可以去常量池中指定位置取到方法引用的結構體。實際上這個結構體中已經包含了上面看到的各種符號引用,最下面的 method 就是真正的方法資料。類載入到記憶體中時,method 的值為空,當方法第一次呼叫時,會根據符號引用,找到方法的直接引用,並將值賦予 method。從而後面再次呼叫該方法時,只需要返回 method 即可。下面我們看方法的解析過程:
func (self *MethodRef) resolveMethodRef() {
c := self.ResolvedClass()
method := lookupMethod(c, self.name, self.descriptor)
if method == nil {
panic("java.lang.NoSuchMethodError")
}
self.method = method
}
這裡面省略了驗證的部分,包括檢查解析後的方法是否為空、檢查當前類是否可以訪問該方法,等等。首先我們看到,第一步是找到方法對應的類:
func (self *SymRef) ResolvedClass() *Class {
if self.class == nil {
d := self.cp.class
c := d.loader.LoadClass(self.className)
self.class = c
}
return self.class
}
在 MethodRef 結構體中包含對應 class 的引用,如果 class 不為空,則可以直接返回;否則會根據類名,使用當前類的類載入器去嘗試載入這個類。最後將載入好的類引用賦給 MethodRef.class。找到了方法所在的類,下一步就是從類中找到這個方法,也就是方法資料在記憶體中的地址,對應上面的 lookupMethod 方法。查詢時,會遍歷類中的方法列表,這塊在類載入的過程中已經完成,下面是方法資料的結構體:
type Method struct {
accessFlags uint16
name string
descriptor string
class *Class
maxStack uint
maxLocals uint
code []byte
argSlotCount uint
}
這個其實就和 Class 檔案中的 Code 屬性類似,這裡面省略了異常和其他的一些資訊。類載入過程中,會將各個方法的 Code 屬性按照上面的結構儲存在記憶體中,然後將類中所有方法的地址列表儲存在 Class 結構體中。當在 Class 結構體中查詢指定方法時,只需要遍歷方法列表,然後比較方法名和描述符即可:
for c := class; c != nil; c = c.superClass {
for _, method := range c.methods {
if method.name == name && method.descriptor == descriptor {
return method
}
}
}
可以看到,查詢方法會從當前方法查詢,如果找不到,會繼續從父類中查詢。除此以外,還會從實現的介面列表中查詢,程式碼中省略了這部分,還有一些判斷的條件。
最終,如果成功找到了指定方法,就會將方法資料的地址賦給 MethodRef.method,後面對該方法的呼叫只需要直接返回 MethodRef.method 即可。
以上便是 自己動手寫Java虛擬機器 一書中,符號引用解析為直接引用的實現。
總結
本文對 JVM 中的符號引用與直接引用的概念,做了一個簡單的介紹。實際上,這只是我在學習過程中,自己對其的一個簡單理解,與實際的 JVM 實現可能相差甚遠,我自己也只是一知半解,似懂非懂,如果以後有時間有精力,會親自看一下 OpenJDK 的原始碼,研究一下 HotSpot VM 的實現。學無止境,與君共勉。