Java虛擬機 - 符號引用和直接引用理解


java -- JVM的符號引用和直接引用

 

https://www.zhihu.com/question/50258991

 

在JVM中類加載過程中,在解析階段,Java虛擬機會把類的二級制數據中的符號引用替換為直接引用。

1.符號引用(Symbolic References):

  符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義的定位到目標即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現。符號引用與虛擬機的內存布局無關,引用的目標並不一定加載到內存中。在Java中,一個java類將會編譯成一個class文件。在編譯時,java類並不知道所引用的類的實際地址,因此只能使用符號引用來代替。比如org.simple.People類引用了org.simple.Language類,在編譯時People類並不知道Language類的實際內存地址,因此只能使用符號org.simple.Language(假設是這個,當然實際中是由類似於CONSTANT_Class_info的常量來表示的)來表示Language類的地址。各種虛擬機實現的內存布局可能有所不同,但是它們能接受的符號引用都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。

 
2.直接引用:
 直接引用可以是
(1)直接指向目標的指針(比如,指向“類型”【Class對象】、類變量、類方法的直接引用可能是指向方法區的指針)
(2)相對偏移量(比如,指向實例變量、實例方法的直接引用都是偏移量)
(3)一個能間接定位到目標的句柄
直接引用是和虛擬機的布局相關的,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經被加載入內存中了。
 
RednaxelaFX的解釋:

作者:RednaxelaFX
鏈接:https://www.zhihu.com/question/50258991/answer/120450561
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

我了解了調用函數時符號引用如何轉換為直接引用的,但是對於類變量,實例變量的解析方法還是不太清楚。
符號引用是只包含語義信息,不涉及具體實現的;而解析(resolve)過后的直接引用則是與具體實現息息相關的。所以當談及某個符號引用被resolve成怎樣的直接引用時,必須要結合某個具體實現來討論才行。
查閱資料后很多人說了一個偏移量的問題,那這個偏移量是相對於什么的偏移量呢?

“相對於什么的偏移量”這就正好是上面說的“實現細節”的一部分了。

例如說,HotSpot VM采用的對象模型,在JDK 6 / 7之間就發生過一次變化。

在對象實例方面,HotSpot VM所采用的對象模型是比較直觀的一種:Java引用通過直接指針(direct pointer)或語義上是直接指針的壓縮指針(compressed pointer)來實現;指針指向的是對象的真實起始位置(沒有在負偏移量上放任何數據)。
對象內的布局是:最前面是對象頭,有兩個VM內部字段:_mark 和 _klass。后面緊跟着就是對象的所有實例字段,緊湊排布,繼承深度越淺的類所聲明的字段越靠前,繼承深度越深的類所聲明的字段越靠后。在同一個類中聲明的字段按字段的類型寬度來重排序,對普通Java類默認的排序是:long/double - 8字節、int/float - 4字節、short/char - 2字節、byte/boolean - 1字節,最后是引用類型字段(4或8字節)。每個字段按照其寬度來對齊;最終對象默認再做一次8字節對齊。在類繼承的邊界上如果有因對齊而帶來的空隙的話,可以把子類的字段拉到空隙里。這種排布方式可以讓原始類型字段最大限度地緊湊排布在一起,減少字段間因為對齊而帶來的空隙;同時又讓引用類型字段盡可能排布在一起,減少OopMap的開銷。

關於對象實例的內存布局,以前我在一個演講里講解過,請參考:,第112頁開始。

舉例來說,對於下面的類C,
class A { boolean b; Object o1; } class B extends A { int i; long l; Object o2; float f; } class C extends B { boolean b; } 
它的實例對象布局就是:(假定是64位HotSpot VM,開啟了壓縮指針的話)
-->  +0 [ _mark     ] (64-bit header word)
     +8 [ _klass    ] (32-bit header word, compressed klass pointer)
    +12 [ A.b       ] (boolean, 1 byte)
    +13 [ (padding) ] (padding for alignment, 3 bytes)
    +16 [ A.o1      ] (reference, compressed pointer, 4 bytes)
    +20 [ B.i       ] (int, 4 bytes)
    +24 [ B.l       ] (long, 8 bytes)
    +32 [ B.f       ] (float, 4 bytes)
    +36 [ B.o2      ] (reference, compressed pointer, 4 bytes)
    +40 [ C.b       ] (boolean, 1 byte)
    +41 [ (padding) ] (padding for object alignment, 7 bytes)

所以C類的對象實例大小,在這個設定下是48字節,其中有10字節是為對齊而浪費掉的padding,12字節是對象頭,剩下的26字節是用戶自己代碼聲明的實例字段。

留意到C類里字段的排布是按照這個順序的:對象頭 - Object聲明的字段(無) - A聲明的字段 - B聲明的字段 - C聲明的字段——按繼承深度從淺到深排布。而每個類里面的字段排布順序則按前面說的規則,按寬度來重排序。同時,如果類繼承邊界上有空隙(例如這里A和B之間其實本來會有一個4字節的空隙,但B里正好聲明了一些不寬於4字節的字段,就可以把第一個不寬於4字節的字段拉到該空隙里,也就是 B.i 的位置)。

同時也請留意到A類和C類都聲明了名字為b的字段。它們之間有什么關系?——沒關系。
Java里,字段是不參與多態的。派生類如果聲明了跟基類同名的字段,則兩個字段在最終的實例中都會存在;派生類的版本只會在名字上遮蓋(shadow / hide)掉基類字段的名字,而不會與基類字段合並或令其消失。上面例子特意演示了一下A.b 與 C.b 同時存在的這個情況。

使用 JOL工具可以方便地看到同樣的信息:
$ sudo ~/sdk/jdk1.8.0/Contents/Home/bin/java -Xbootclasspath/a:. -jar ~/Downloads/jol-cli-0.5-full.jar internals C
objc[78030]: Class JavaLaunchHelper is implemented in both /Users/krismo/sdk/jdk1.8.0/Contents/Home/bin/java and /Users/krismo/sdk/jdk1.8.0/Contents/Home/jre/lib/libinstrument.dylib. One of the two will be used. Which one is undefined.
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

C object internals:
 OFFSET  SIZE    TYPE DESCRIPTION                    VALUE
      0     4         (object header)                09 00 00 00 (00001001 00000000 00000000 00000000) (9)
      4     4         (object header)                00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4         (object header)                be 3b 01 f8 (10111110 00111011 00000001 11111000) (-134136898)
     12     1 boolean A.b                            false
     13     3         (alignment/padding gap)        N/A
     16     4  Object A.o1                           null
     20     4     int B.i                            0
     24     8    long B.l                            0
     32     4   float B.f                            0.0
     36     4  Object B.o2                           null
     40     1 boolean C.b                            false
     41     7         (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 3 bytes internal + 7 bytes external = 10 bytes total

所以,對一個這樣的對象模型,實例字段的“偏移量”是從對象起始位置開始算的。對於這樣的字節碼:
  getfield cp#12  // C.b:Z
(這里用cp#12來表示常量池的第12項的意思)
這個C.b:Z的符號引用,最終就會被解析(resolve)為+40這樣的偏移量,外加一些VM自己用的元數據。
這個偏移量加上額外元數據比原本的constant pool index要寬,沒辦法放在原本的constant pool里,所以HotSpot VM有另外一個叫做constant pool cache的東西來存放它們。
在HotSpot VM里,上面的字節碼經過解析后,就會變成:
  fast_bgetfield cpc#5  // (offset: +40, type: boolean, ...)

(這里用cpc#5來表示constant pool cache的第5項的意思)
於是解析后偏移量信息就記錄在了constant pool cache里,getfield根據解析出來的constant pool cache entry里記錄的類型信息被改寫為對應類型的版本的字節碼fast_bgetfield來避免以后每次都去解析一次,然后fast_bgetfield就可以根據偏移量信息以正確的類型來訪問字段了。


然后說說靜態變量(或者有人喜歡叫“類變量”)的情況。
從JDK 1.3到JDK 6的HotSpot VM,靜態變量保存在類的元數據(InstanceKlass)的末尾。而從JDK 7開始的HotSpot VM,靜態變量則是保存在類的Java鏡像(java.lang.Class實例)的末尾。

在HotSpot VM中,對象、類的元數據(InstanceKlass)、類的Java鏡像,三者之間的關系是這樣的:
Java object      InstanceKlass       Java mirror
 [ _mark  ]                          (java.lang.Class instance)
 [ _klass ] --> [ ...          ] <-\              
 [ fields ]     [ _java_mirror ] --+> [ _mark  ]
                [ ...          ]   |  [ _klass ]
                                   |  [ fields ]
                                    \ [ klass  ]

每個Java對象的對象頭里,_klass字段會指向一個VM內部用來記錄類的元數據用的InstanceKlass對象;InsanceKlass里有個_java_mirror字段,指向該類所對應的Java鏡像——java.lang.Class實例。HotSpot VM會給Class對象注入一個隱藏字段“klass”,用於指回到其對應的InstanceKlass對象。這樣,klass與mirror之間就有雙向引用,可以來回導航。
這個模型里,java.lang.Class實例並不負責記錄真正的類元數據,而只是對VM內部的InstanceKlass對象的一個包裝供Java的反射訪問用。

在JDK 6及之前的HotSpot VM里,靜態字段依附在InstanceKlass對象的末尾;而在JDK 7開始的HotSpot VM里,靜態字段依附在java.lang.Class對象的末尾。

假如有這樣的A類:
class A { static int value = 1; } 
那么在JDK 6或之前的HotSpot VM里:
Java object      InstanceKlass       Java mirror
 [ _mark  ]                          (java.lang.Class instance)
 [ _klass ] --> [ ...          ] <-\              
 [ fields ]     [ _java_mirror ] --+> [ _mark  ]
                [ ...          ]   |  [ _klass ]
                [ A.value      ]   |  [ fields ]
                                    \ [ klass  ]

可以看到這個A.value靜態字段就在InstanceKlass對象的末尾存着了。
這個情況我在前面提到的演講稿的第121頁有畫過一張更好看的圖。

而在JDK 7或之后的HotSpot VM里:
Java object      InstanceKlass       Java mirror
 [ _mark  ]                          (java.lang.Class instance)
 [ _klass ] --> [ ...          ] <-\              
 [ fields ]     [ _java_mirror ] --+> [ _mark   ]
                [ ...          ]   |  [ _klass  ]
                                   |  [ fields  ]
                                    \ [ klass   ]
                                      [ A.value ]

可以看到這個A.value靜態字段就在java.lang.Class對象的末尾存着了。

所以對於HotSpot VM的對象模型,靜態字段的“偏移量”就是:
  • JDK 6或之前:相對該類對應的InstanceKlass(實際上是包裝InstanceKlass的klassOopDesc)對象起始位置的偏移量
  • JDK 7或之后:相對該類對應的java.lang.Class對象起始位置的偏移量。

其它細節跟實例字段相似,就不贅述了。

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

好奇的同學可能會關心一下上面說的HotSpot VM里的InstanceKlass和java.lang.Class實例都是放哪里的呢?

在JDK 7或之前的HotSpot VM里,InstanceKlass是被包裝在由GC管理的klassOopDesc對象中,存放在GC堆中的所謂Permanent Generation(簡稱PermGen)中。

從JDK 8開始的HotSpot VM則完全移除了PermGen,改為在native memory里存放這些元數據。新的用於存放元數據的內存空間叫做Metaspace,InstanceKlass對象就存在這里。

至於java.lang.Class對象,它們從來都是“普通”Java對象,跟其它Java對象一樣存在普通的Java堆(GC堆的一部分)里。

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

那么如果不是HotSpot VM,而是別的JVM呢?
——什么可能性都存在。總之“偏移量”什么的全看一個具體的JVM實現的內部各種細節是怎樣的。

例如說,一個JVM完全可以把所有類的所有靜態字段都放在一個大數組里,每新加載一個類就從這個數組里分配一塊空間來放該類的靜態字段。那么此時靜態字段“偏移量”可能直接就是這個靜態字段的地址(假定存放它們的數組不移動的話),或者可能是基於這個數組的起始地址的偏移量。

又例如說,一個JVM在實現對象模型時,可能會讓指針不指向對象真正的開頭,而是指向對象中間的某個位置。例如說,還是HotSpot VM那樣的對象布局,指針可以選擇指向很多種地方都合理:(下面還是假定64位HotSpot VM,開壓縮指針)
  • 指向對象開頭:_mark位於+0,這是HotSpot VM選擇的做法;
  • 指向對象頭的第二個字段:_klass位於+0,_mark位於-8。這種做法在某些架構上或許可以加快通過_klass做vtable dispatch的速度,所以也有合理性;
  • 指向實際字段的開頭:_mark位於-12,_klass位於-4,第一個字段位於+0。這主要就是覺得字段訪問可能是更頻繁的操作,而潛在可能犧牲一點對象頭訪問的速度。

Maxine VM的對象模型就可以在OHM模型和HOM模型之間選擇。所謂OHM就是Origin-Header-Mixed,也就是指針指向對象頭第一個字段的做法;所謂HOM就是Header-Origin-Mixed,也就是指針指向對象頭之后(也就是第一個字段)的做法。
還有更有趣的對象布局方式:雙向布局(bidirectional layout),例如Sable VM所用的布局。一種典型的方案是把引用類型字段全部放在負偏移上,指針指向對象頭,然后原始類型字段全部放在正偏移量上。這樣的好處是GC在掃描對象的引用類型字段時只需要掃描一塊連續的內存,非常方便。

更多對象布局的例子請跳傳送門:為什么bs虛函數表的地址(int*)(&bs)與虛函數地址(int*)*(int*)(&bs) 不是同一個? - RednaxelaFX 的回答

再例如說,舉個極端的例子:前面的討論都是基於“對象實例里的所有數據分配在一塊連續的內存”的假設上。但顯然這不是唯一的實現方式。
一種極端的做法是,對象用鏈表來實現,鏈表上每個節點存放一個字段的值和指向下一個字段的鏈。就像這樣:
typedef union java_value_tag { int32_t int_val; int64_t long_val; /* ... */ object_slot* ref_val; } java_value; typedef struct object_slot_tag { java_value val; struct object_slot_tag* next; } object_slot; 

然后假如一個類有3個字段,那么這個類的實例就有4個這樣的object_slot節點組成的鏈表而構成:對象頭 -> 第一個字段 -> 第二個字段 -> 第三個字段 -> NULL。

誰會這么做(掀桌了!
其實還真有。有些有趣的實現,為了簡化GC堆的實現,便於減少外部碎片的堆積,而可以把GC堆實現為一個object_slot大數組。這里面由於每個單元的數據都必然一樣大,所以可以有效消除外部碎片——代價則是人為的打碎了一個對象的數據的連續性,增加了內部碎片。
當然做這種取舍的實現非常非常少,所以大家沒怎么見過也是正常… >_<


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM