【JVM故事】一個Java位元組碼檔案的誕生記
萬字長文,完全虛構。
(一)
組裡來了個實習生,李大胖面完之後,覺得水平一般,但還是留了下來,為什麼呢?各自猜去吧。
李大胖也在心裡開導自己,學生嘛,不能要求太高,只要肯上進,慢慢來。就稱呼為小白吧。
小白每天來的很早,走的很晚,都在用功學習,時不時也向別人請教。只是好像天資差了點。
都快一週了,才能寫些“簡單”的程式碼,一個註解,一個介面,一個類,都來看看吧:
public @interface Health {
String name() default "";
}
public interface Fruit {
String getName();
void setName(String name);
int getColor();
void setColor(int color);
}
@Health(name = "健康水果")
public class Apple implements Fruit {
private String name;
private int color;
private double weight = 0.5;
@Override
public String getName() {
return name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public int getColor() {
return color;
}
@Override
public void setColor(int color) {
this.color = color;
}
public double weight() {
return weight;
}
public void weight(double weight) {
this.weight = weight;
}
}
與周圍人比起來,小白進步很慢,也許是自己不夠聰明,也許是自己不適合幹這個,小白好像有點動搖了。
這幾天,小白明顯沒有一開始那麼上進了,似乎有點想放棄,這不,趴在桌子上竟然睡著了。
(二)
在夢中,小白來到一個奇怪又略顯陰森的地方,眼前有一個破舊的小房子,從殘缺不全的門縫裡折射出幾束光線。
小白有些害怕,但還是鎮定了下,深呼吸幾口,徑直朝著小房子走去。
小白推開門,屋裡沒有人。只有一個“機器”在桌子旁大口大口“吃著”東西,背後也不時的“拉出”一些東西。
小白很好奇,就湊了上去,準備仔細打量一番。
“你要幹嘛,別影響我工作”。突然冒出一句話,把小白嚇了一大跳,慌忙後退三步,媽呀,心都快蹦出來了。
“你是誰呀?”,驚慌中小白說了句話。
“我是編譯器”,哦,原來這個機器還會說話,小白這才緩了過來。
“編譯器”,小白好像聽說過,但一時又想不起,於是猜測到。
“網上評論留言裡說的小編是不是就是你啊”?
“你才是呢”,編譯器白了一眼,沒好聲氣的說到。
要不是看在長得還行的份上,早就把你趕走了,編譯器心想。
“哦,我想起來了,編譯器嘛,就是編譯程式碼的那個東西”,小白恍然大悟到。
“請注意你的言詞,我不是個東西,哦,不對,我是個東西,哦,好像也不對,我。。我。。”,編譯器自己也快暈了。
編譯器一臉的無奈,遇上這樣的人,今天我認栽了。
小白才不管呢,心想,今天我竟然見到了編譯器,我得好好請教請教他。
那編譯器會幫助她嗎?
(三)
小白再次走上前來,定睛一看,才看清楚,編譯器吃的是Java原始碼,拉的是class(位元組碼)檔案。
咦,為啥這個程式碼這麼熟悉呢,不就是我剛剛寫的那些。“停,停,快停下來了”。編譯器被小白叫停了。
“你又要幹嘛啊”?編譯器到。
“嘻嘻,這個程式碼是我寫的,我想看看它是怎麼被編譯的”,小白到。
編譯器看了看這個程式碼,這麼“簡單”,她絕對是個菜鳥。哎,算了,還是讓她看看吧。
不過編譯器又到,“整個編譯過程是非常複雜的,想要搞清楚裡面的門道是不可能的,今天也就只能看個熱鬧了”。
“編譯後的內容都是二進位制資料,再通俗點說,就是一個長長的位元組陣列(byte[])”,編譯器繼續說,“通常把它寫入檔案,就是class檔案了”。
“但這不是必須的,也可以通過網路傳到其它地方,或者儲存在記憶體中,用完之後就丟棄”。
“哇,還可以這樣”,小白有些驚訝。編譯器心想,你是山溝裡出來的,沒見過世面,大驚小怪。
繼續到,“從資料結構上講,陣列就是一段連續的空間,是‘沒有結構’的,就像一個線段一樣,唯一能做的就是按索引訪問”。
小白到,“編譯後的內容一定很繁多,都放到一個數組裡面,怎麼知道什麼東西都在哪呢?不都亂套了嘛”。
編譯器覺得小白慢慢上道了,心裡有一絲安慰,至少自己的講解不會完全白費。於是繼續到。
“所以JVM的那些大牛們早就設計好了位元組碼的格式,而且還把它們放入到了一個位元組數組裡面”。
小白很好奇到,“那是怎麼實現的呢”?
“其實也沒有太高深的內容,既然陣列是按位置的,那就規定好所有內容的先後順序,一個接一個往數組裡放唄”。
“如果內容的長度是固定(即定長)的,那最簡單,直接放入即可”。
“如果內容長度是不固定(即變長)的,也很簡單,在內容前用一到兩個位元組存一下內容的長度不就OK了”。
(四)
“位元組碼的前4個位元組必須是一個固定的數字,它的十進位制是3405691582,大部分人更熟悉的是它的十六進位制,0xCAFEBABE”。
“通常稱之為魔術數字(Magic),它主要是用來區分檔案型別的”,編譯器到。
“副檔名(俗稱字尾名)不是用來區分檔案型別的嗎”?小白說到,“如.java是Java檔案,.class是位元組碼檔案”。
“副檔名確實可以區分,但大部分是給作業系統用的,或給人看到。如我們看到.mp3時知道是音訊、.mp4是知道是視訊、.txt是文字檔案”。
“作業系統可以用副檔名來關聯開啟它的軟體,比如.docx就會用word來開啟,而不會用文字檔案”。編譯器繼續到。
“還有一個問題就是副檔名可以很容易被修改,比如把一個.java手動改為.class,此時讓JVM來載入這個假的class檔案會怎樣呢”?
“那JVM先讀取開頭4個位元組,發現它不是剛剛提到的那個魔數,說明它不是合法的class檔案,就直接拋異常唄”,小白說到。
“很好,真是孺子可教”,編譯器說道,“不過還有一個問題,不知你是否注意到?4個位元組對應Java的int型別,int型別的最大值是2147483647”。
“但是魔數的值已經超過了int的最大值,那怎麼放得下呢,難道不會溢位嗎”?
“確實啊,我怎麼沒發現呢,那它到底是怎麼放的呢”?小白到。
“其實說穿了不值得一提,JVM是把它當作無符號數對待的。而Java是作為有符號數對待的。無符號數的最大值基本上是有符號數最大值的兩倍”。
“接下來的4個位元組是版本號,不同版本的位元組碼格式可能會略有差異,其次在執行時會校驗,如JDK8編譯後的位元組碼是不能放到JDK7上執行的”。
“這4個位元組中的前2個是次(minor)版本,後2個是主(major)版本”。編譯器繼續到,“比如我現在用的JDK版本是1.8.0_211,那次版本就是0,主版本就是52”。
“所以前8個位元組的內容是,0xCAFEBABE,0,52,它們並不是原始碼裡的內容”。
Magic [getMagic()=0xcafebabe]
MinorVersion [getVersion()=0]
MajorVersion [getVersion()=52]
(五)
當編譯器讀到原始碼中的public class的時候,然後就就去檢視一個表格,如下圖:
自顧自的說著,“public對應的是ACC_PUBLIC,值為0x0001,class預設就是,然後又讀ACC_SUPER的值0x0020”。
“最後把它倆合起來(按位或操作),0x0001 | 0x0020 => 0x0021,然後把這個值存起來,這就是這個類的訪問控制標誌”。
小白這次算是開了眼界了,只是還有一事不明,“這個ACC_SUPER是個什麼鬼”?
編譯器解釋到,“這是歷史遺留問題,它原本表達在呼叫父類方法時會特殊處理,不過現在已經不再管它了,直接忽略”。
接著讀到了Apple,它是類名。編譯器首先要獲取類的全名,org.cnt.java.Apple。
然後對它稍微轉換一下形式,變成了,org/cnt/java/Apple,“這就是類名在位元組碼中的表示”。
編譯器發現這個Apple類沒有顯式繼承父類,表明它繼承自Object類,於是也獲取它的全名,java/lang/Object。
接著讀到了implements Fruit,說明該類實現了Fruit介面,也獲取全名,org/cnt/java/Fruit。
小白說到,“這些比較容易理解,全名中把點號(.)替換為正斜線(/)肯定也是歷史原因了。但是這些資訊如何存到數組裡呢”?
“把點號替換為正斜線確實是歷史原因”,編譯器繼續到,“這些字串雖然都是類名或介面名,但本質還是字串,類名、介面名只是賦予它的意義而已”。
“除此之外,像欄位名、方法名也都是字串,同理,欄位名、方法名也是賦予它的意義。所以字串是一種基本的資料,需要得到支援”。
“除了字串之外,還有整型數字,浮點數字,這些也是基本的資料,也需要得到支援”。
因此,設計者們就設計出了以下幾種型別,如圖:
“左邊是型別名稱,方便理解,右邊是對應的值,用於儲存”,編譯器繼續到。
“這裡的Integer/Long/Float/Double和Utf8都是具體儲存資料用的,表示整型數/浮點數和字串。其它的型別大都是對字串的引用,並賦予它一定的意義”。
“所以類名首先被儲存為一個字串,也就是Utf8,它的值對應的是1”。編譯器接著到,“由於字串是一個變長的,所以就先用兩個位元組儲存字串的長度,接著跟上具體的字串內容”。
所以字串的結構就是這樣,如圖:
“類名字串的儲存資料為,1、18、org/cnt/java/Apple。第一個位元組為1,表明是Utf8型別,第2、3兩個位元組儲存18,表示字串長度是18,接著儲存真正的字串。所以共用去1 + 2 + 18 => 21個位元組”。
“父類名字串儲存為,1、16、java/lang/Object。共用去19個位元組”。
“介面名字串儲存為,1、18、org/cnt/java/Fruit。共用去21個位元組”。
小白聽的不住點頭,編譯器喘口氣,繼續講解。
“字串存好後,就該賦予它們意義了,在後續的操作中肯定涉及到對這些字串的引用,所以還要給每個字串分配一個編號”。
如Apple為#2,即2號,Object為#4,Fruit為#6。
“由於這三個字串都是類名或介面名,按照設計規定應該使用Class表示,對應的值為7,然後再指定一個字串的編號即可”。
因此類或介面的表示如下圖:
“先用1個位元組指明是類(介面),然後再用2個位元組儲存一個字串的編號。整體意思很直白,就是把這個編號的字串當作類名或介面名”。
“類就表示為,7、#2。7表示是Class,#2表示類名稱那個字串的儲存編號。共用去3個位元組”。
“父類就表示,7、#4。共用去3個位元組。介面就表示為,7、#6。共用去3個位元組”。
其實這三個Class也分別給它們一個編號,方便別的地方再引用它們。
(六)
“其實上面這些內容都是常量,它們都位於常量池中,它們的編號就是自己在常量池中的索引”。編譯器說到。
“常量池很多人都知道,起碼至少是聽說過。但絕大多數人對它並不十分熟悉,因為很少有人見過它”。
編譯器繼續到,“今天你可算是來著了”,說著就把小白寫的類編譯後生成的常量池擺到了桌子上。
“這是什麼東西啊,這麼多,又很奇怪”,小白說到,這也是她第一次見。
ConstantPoolCount [getCount()=46]
ConstantPool [
#0 = null
#1 = ConstantClass [getNameIndex()=2, getTag()=7]
#2 = ConstantUtf8 [getLength()=18, getString()=org/cnt/java/Apple, getTag()=1]
#3 = ConstantClass [getNameIndex()=4, getTag()=7]
#4 = ConstantUtf8 [getLength()=16, getString()=java/lang/Object, getTag()=1]
#5 = ConstantClass [getNameIndex()=6, getTag()=7]
#6 = ConstantUtf8 [getLength()=18, getString()=org/cnt/java/Fruit, getTag()=1]
#7 = ConstantUtf8 [getLength()=4, getString()=name, getTag()=1]
#8 = ConstantUtf8 [getLength()=18, getString()=Ljava/lang/String;, getTag()=1]
#9 = ConstantUtf8 [getLength()=5, getString()=color, getTag()=1]
#10 = ConstantUtf8 [getLength()=1, getString()=I, getTag()=1]
#11 = ConstantUtf8 [getLength()=6, getString()=weight, getTag()=1]
#12 = ConstantUtf8 [getLength()=1, getString()=D, getTag()=1]
#13 = ConstantUtf8 [getLength()=6, getString()=<init>, getTag()=1]
#14 = ConstantUtf8 [getLength()=3, getString()=()V, getTag()=1]
#15 = Con