JVM里的符號引用如何存儲?

學習JVM時看到符號引用,但我還是感到疑惑。

符號引用究竟是什麼(變數名)?

與直接引用有什麼關係?

它是如何存儲的?

它佔用的slot是怎樣的數據結構?

謝謝。


嗯,題主這個問題恐怕是很多初學者都會有的。主要是「符號引用」和「直接引用」這倆概念看起來很「空洞」,不結合一個實際實現來看的話理解起來不形象。

==================================================

先看Class文件里的「符號引用」。

考慮這樣一個Java類:

public class X {
public void foo() {
bar();
}

public void bar() { }
}

它編譯出來的Class文件的文本表現形式如下:

Classfile /private/tmp/X.class
Last modified Jun 13, 2015; size 372 bytes
MD5 checksum 8abb9cbb66266e8bc3f5eeb35c3cc4dd
Compiled from "X.java"
public class X
SourceFile: "X.java"
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#16 // java/lang/Object."&":()V
#2 = Methodref #3.#17 // X.bar:()V
#3 = Class #18 // X
#4 = Class #19 // java/lang/Object
#5 = Utf8 &
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 LocalVariableTable
#10 = Utf8 this
#11 = Utf8 LX;
#12 = Utf8 foo
#13 = Utf8 bar
#14 = Utf8 SourceFile
#15 = Utf8 X.java
#16 = NameAndType #5:#6 // "&":()V
#17 = NameAndType #13:#6 // bar:()V
#18 = Utf8 X
#19 = Utf8 java/lang/Object
{
public X();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."&":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LX;

public void foo();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokevirtual #2 // Method bar:()V
4: return
LineNumberTable:
line 3: 0
line 4: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LX;

public void bar();
flags: ACC_PUBLIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this LX;
}

可以看到Class文件里有一段叫做「常量池」,裡面存儲的該Class文件里的大部分常量的內容。

來考察foo()方法里的一條位元組碼指令:

1: invokevirtual #2 // Method bar:()V

這在Class文件中的實際編碼為:

[B6] [00 02]

其中0xB6是invokevirtual指令的操作碼(opcode),後面的0x0002是該指令的操作數(operand),用於指定要調用的目標方法。

這個參數是Class文件里的常量池的下標。那麼去找下標為2的常量池項,是:

#2 = Methodref #3.#17 // X.bar:()V

這在Class文件中的實際編碼為(以十六進位表示,Class文件里使用高位在前位元組序(big-endian)):

[0A] [00 03] [00 11]

其中0x0A是CONSTANT_Methodref_info的tag,後面的0x0003和0x0011是該常量池項的兩個部分:class_index和name_and_type_index。這兩部分分別都是常量池下標,引用著另外兩個常量池項。

順著這條線索把能傳遞引用到的常量池項都找出來,會看到(按深度優先順序排列):

#2 = Methodref #3.#17 // X.bar:()V
#3 = Class #18 // X
#18 = Utf8 X
#17 = NameAndType #13:#6 // bar:()V
#13 = Utf8 bar
#6 = Utf8 ()V

把引用關係畫成一棵樹的話:

#2 Methodref X.bar:()V
/
#3 Class X #17 NameAndType bar:()V
| /
#18 Utf8 X #13 Utf8 bar #6 Utf8 ()V

標記為Utf8的常量池項在Class文件中實際為CONSTANT_Utf8_info,是以略微修改過的UTF-8編碼的字元串文本。

這樣就清楚了對不對?

由此可以看出,Class文件中的invokevirtual指令的操作數經過幾層間接之後,最後都是由字元串來表示的。這就是Class文件里的「符號引用」的實態:帶有類型(tag) / 結構(符號間引用層次)的字元串。

==================================================

然後再看JVM里的「直接引用」的樣子。

這裡就不拿HotSpot VM來舉例了,因為它的實現略複雜。讓我們看個更簡單的實現,Sun的元祖JVM——Sun JDK 1.0.2的32位x86上的做法。

請先參考另一個回答里講到Sun Classic VM的部分:為什麼bs虛函數表的地址(int*)(bs)與虛函數地址(int*)*(int*)(bs) 不是同一個? - RednaxelaFX 的回答

Sun Classic VM:(以32位Sun JDK 1.0.2在x86上為例)

HObject ClassObject
-4 [ hdr ]
--&> +0 [ obj ] --&> +0 [ ... fields ... ]
+4 [ methods ]
methodtable ClassClass
&> +0 [ classdescriptor ] --&> +0 [ ... ]
+4 [ vtable[0] ] methodblock
+8 [ vtable[1] ] --&> +0 [ ... ]
... [ vtable... ]

(請留心閱讀上面鏈接里關於虛方法表與JVM的部分。Sun的元祖JVM也是用虛方法表的喔。)

元祖JVM在做類載入的時候會把Class文件的各個部分分別解析(parse)為JVM的內部數據結構。例如說類的元數據記錄在ClassClass結構體里,每個方法的元數據記錄在各自的methodblock結構體里,等等。

在剛載入好一個類的時候,Class文件里的常量池和每個方法的位元組碼(Code屬性)會被基本原樣的拷貝到內存里先放著,也就是說仍然處於使用「符號引用」的狀態;直到真的要被使用到的時候才會被解析(resolve)為直接引用。

假定我們要第一次執行到foo()方法里調用bar()方法的那條invokevirtual指令了。

此時JVM會發現該指令尚未被解析(resolve),所以會先去解析一下。

通過其操作數所記錄的常量池下標0x0002,找到常量池項#2,發現該常量池項也尚未被解析(resolve),於是進一步去解析一下。

通過Methodref所記錄的class_index找到類名,進一步找到被調用方法的類的ClassClass結構體;然後通過name_and_type_index找到方法名和方法描述符,到ClassClass結構體上記錄的方法列表裡找到匹配的那個methodblock;最終把找到的methodblock的指針寫回到常量池項#2里。

也就是說,原本常量池項#2在類載入後的運行時常量池裡的內容跟Class文件里的一致,是:

[00 03] [00 11]

(tag被放到了別的地方;小細節:剛載入進來的時候數據仍然是按高位在前位元組序存儲的)

而在解析後,假設找到的methodblock*是0x45762300,那麼常量池項#2的內容會變為:

[00 23 76 45]

(解析後位元組序使用x86原生使用的低位在前位元組序(little-endian),為了後續使用方便)

這樣,以後再查詢到常量池項#2時,裡面就不再是一個符號引用,而是一個能直接找到Java方法元數據的methodblock*了。這裡的methodblock*就是一個「直接引用」

解析好常量池項#2之後回到invokevirtual指令的解析。

回顧一下,在解析前那條指令的內容是:

[B6] [00 02]

而在解析後,這塊代碼被改寫為:

[D6] [06] [01]

其中opcode部分從invokevirtual改寫為invokevirtual_quick,以表示該指令已經解析完畢。

原本存儲操作數的2位元組空間現在分別存了2個1位元組信息,第一個是虛方法表的下標(vtable index),第二個是方法的參數個數。這兩項信息都由前面解析常量池項#2得到的methodblock*讀取而來。

也就是:

invokevirtual_quick vtable_index=6, args_size=1

這裡例子里,類X對應在JVM里的虛方法表會是這個樣子的:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: X.bar:()V

所以JVM在執行invokevirtual_quick要調用X.bar()時,只要順著對象引用查找到虛方法表,然後從中取出第6項的methodblock*,就可以找到實際應該調用的目標然後調用過去了。

假如類X還有子類Y,並且Y覆寫了bar()方法,那麼類Y的虛方法表就會像這樣:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: Y.bar:()V

於是通過vtable_index=6就可以找到類Y所實現的bar()方法。

所以說在解析/改寫後的invokevirtual_quick指令里,虛方法表下標(vtable index)也是一個「直接引用」的表現。

關於這種「_quick」指令的設計,可以參考遠古的JVM規範第1版的第9章。這裡有一份拷貝:http://www.cs.miami.edu/~burt/reference/java/language_vm_specification.pdf

在現在的HotSpot VM里,圍繞常量池、invokevirtual的解析(再次強調是resolve)的具體實現方式跟元祖JVM不一樣,但是大體的思路還是相通的。

HotSpot VM的運行時常量池有ConstantPool和ConstantPoolCache兩部分,有些類型的常量池項會直接在ConstantPool里解析,另一些會把解析的結果放到ConstantPoolCache里。以前發過一帖有簡易的圖解例子,可以參考:請問,jvm實現讀取class文件常量池信息是怎樣呢?

==================================================

由此可見,符號引用通常是設計字元串的——用文本形式來表示引用關係。

而直接引用是JVM(或其它運行時環境)所能直接使用的形式。它既可以表現為直接指針(如上面常量池項#2解析為methodblock*),也可能是其它形式(例如invokevirtual_quick指令里的vtable index)。

關鍵點不在於形式是否為「直接指針」,而是在於JVM是否能「直接使用」這種形式的數據。


符號引用就是字元串,這個字元串包含足夠的信息,以供實際使用時可以找到相應的位置。你比如說某個方法的符號引用,如:「java/io/PrintStream.println:(Ljava/lang/String;)V」。裡面有類的信息,方法名,方法參數等信息。

當第一次運行時,要根據字元串的內容,到該類的方法表中搜索這個方法。運行一次之後,符號引用會被替換為直接引用,下次就不用搜索了。直接引用就是偏移量,通過偏移量虛擬機可以直接在該類的內存區域中找到方法位元組碼的起始位置。

^_^!當然R大說的更正確。


簡單來講就是:符號引用就是先有一個標籤。第一次運行後將這個標籤替換為一個可以直接找到方法具體內存位置的具體值,利用這個具體值可以直接將被調用的方法直接放到虛擬機棧內存。


符號引用就是字元串,常量池utf8_info類型的引用。解析後就成了能直接定位到這個字元串要表示的內容的指針了。比如常量池中字元串String_info,符號引用就是utf8_info類型的常量池引用,而直接引用就是指向內存里的java/lang/String實例對象的指針


簡單地說就是,符號引用存在class文件中的常量池,包括類和介面的全限定名、欄位的名稱和描述符以及方法的名稱和描述符。

jvm載入class的時候就可以憑著這三者進行動態連接,得到具體的內存地址。

佔用的數據結構在常量池項目類型有,例如類或介面的符號引用結構為u1的tag和u2的name_index。

你說的slot是局部變數表分配內存的最小單位,除了double和long的局部變數都是佔用1個slot,這個和class結構中的基本數據結構(無符號數)還不是同一個計量單位。

局部變數表在屬性表中,描述了棧幀與源碼中的局部變數的關聯,偏移量,作用覆蓋範圍的長度。


推薦閱讀:

計算機專業書籍興趣閱讀,應該做習題嗎?
大學期間應不應該讀計算機專業大部頭呢?
如何評價5月28日LeCun等人刊發於Nature的Deep Learning這一論文?
大學裡面,怎樣有效的學習知識?理論和實踐怎麼權衡?
計算機的最底層指令是動態類型(dynamic typing)的還是靜態類型(static typing)的?

TAG:編程 | Java | Java虛擬機JVM | 計算機科學 |