首頁 > 軟體

學弟學妹們,如果你想吃透 Java位元組碼的話,看這篇就好了!(超級硬核,建議收藏)

2021-05-15 15:00:46

同學們好,今天二哥是來還債的,記得先拖到文末點個贊再回來細細的讀,好不好!

最近一段時間,我一直在學習 Java 虛擬機器和位元組碼方面的知識,為的就是有朝一日成為真正牛逼的技術大佬!不知道大家有沒有這種感覺,就是一開始學習程式設計的時候,真心不想看底層的東西,就想直接上來擼程式碼,但時間久了以後,總感覺缺點啥~~~~

於是我開始閱讀《深入理解計算機系統》、《圖解 TCP/IP》、《深入理解 Java 虛擬機器》這些偏底層的書籍,看得煩了,就去刷我之前給大家推薦過的兩個視訊課,《哈佛大學的 CS50》和《電腦科學速成課》,慢慢的,就有一種頓悟的感覺,嗯,這種感覺還是挺舒服的,很容易飄的那種(嘿嘿)。

我之前已經分享過三篇關於 Java 虛擬機器和位元組碼方面的內容,大家可以再溫習一遍。

class 檔案JVM 記憶體區域Java 虛擬機器棧

這三篇的內容還是非常肝的,讀起來也比較輕鬆,但如果你是初學者,讀起來感覺很吃力的話,不要緊,我再來補一篇更全面、更細緻、更通俗的,從另外一個視角切入,完事了可以把這四篇一起新增到收藏夾,以後興致比較高的時候可以再咀嚼下。

01、位元組碼

計算機比較「傻」,只認 0 和 1,這意味著我們編寫的程式碼最終都要編譯成機器碼才能被計算機執行。Java 在誕生之初就提出了一個非常著名的宣傳口號: 「一次編寫,處處運行」。

Write Once, Run Anywhere.

為了這個口號,Java 的親媽 Sun 公司以及其他虛擬機器提供商釋出了許多可以在不同平臺上運行的 Java 虛擬機器,而這些虛擬機器都擁有一個共同的功能,那就是可以載入和執行同一種與平臺無關的位元組碼(Byte Code)。

有了 Java 虛擬機器的幫助,我們編寫的 Java 原始碼不必再根據不同平臺編譯成對應的機器碼了,只需要生成一份位元組碼,然後再將位元組碼檔案交由運行在不同平臺上的 Java 虛擬機器讀取後執行就可以了。

如今的 Java 虛擬機器非常強大,不僅支援 Java 語言,還支援很多其他的程式語言,比如說 Groovy、Scala、Koltin 等等。

來看一段程式碼吧。

publicclassMain{ privateint age =18;publicintgetAge(){ return age;}}

編譯生成 Main.class 檔案後,可以在命令列使用 xxd Main.class 開啟 class 檔案(我用的是 Intellij IDEA,在 macOS 環境下)。

對於這些 16 進位制內容,除了開頭的 cafe babe,剩下的內容大致可以翻譯成:啥玩意啊這…

同學們別慌,就從"cafe babe"說起吧,這 4 個位元組稱之為魔數,也就是說,只有以"cafe babe"開頭的 class 檔案才能被 Java 虛擬機器接受,這 4 個位元組就是位元組碼檔案的身份標識。

目光右移,0000 是 Java 的次版本號,0037 轉化為十進位制是 55,是主版本號,Java 的版本號從 45 開始,每升一個大版本,版本號加 1,大家可以啟動福爾摩斯模式,推理一下。

再往後面就是字元串常量池。《class 檔案》那一篇我是順著十六進位制內容往下分析的,可能初學者看起來比較頭大,這次我們換一種更容易懂的方式。

02、反編譯位元組碼檔案

Java 內建了一個反編譯命令 javap,可以通過 javap -help 瞭解 javap 的基本用法。

OK,我們輸入命令 javap -v -p Main.class 來檢視一下輸出的內容。

Classfile /Users/maweiqing/Documents/GitHub/TechSisterLearnJava/codes/TechSister/target/classes/com/itwanger/jvm/Main.class Last modified 2021年4月15日; size 385 bytes SHA-256 checksum 6688843e4f70ae8d83040dc7c8e2dd3694bf10ba7c518a6ea9b88b318a8967c6 Compiled from "Main.java"public class com.itwanger.jvm.Main minor version: 0 major version: 55 flags: (0x0021) ACC_PUBLIC, ACC_SUPER this_class: #3 // com/itwanger/jvm/Main super_class: #4 // java/lang/Object interfaces: 0, fields: 1, methods: 2, attributes: 1Constant pool: #1 = Methodref #4.#18 // java/lang/Object."<init>":()V #2 = Fieldref #3.#19 // com/itwanger/jvm/Main.age:I #3 = Class #20 // com/itwanger/jvm/Main #4 = Class #21 // java/lang/Object #5 = Utf8 age #6 = Utf8 I #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lcom/itwanger/jvm/Main; #14 = Utf8 getAge #15 = Utf8 ()I #16 = Utf8 SourceFile #17 = Utf8 Main.java #18 = NameAndType #7:#8 // "<init>":()V #19 = NameAndType #5:#6 // age:I #20 = Utf8 com/itwanger/jvm/Main #21 = Utf8 java/lang/Object{ private int age; descriptor: I flags: (0x0002) ACC_PRIVATE public com.itwanger.jvm.Main(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: bipush 18 7: putfield #2 // Field age:I 10: return LineNumberTable: line 6: 0 line 7: 4 LocalVariableTable: Start Length Slot Name Signature 0 11 0 this Lcom/itwanger/jvm/Main; public int getAge(); descriptor: ()I flags: (0x0001) ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field age:I 4: ireturn LineNumberTable: line 9: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/itwanger/jvm/Main;}SourceFile: "Main.java"

睜大眼睛瞧過去,感覺內容挺多的。同學們不要著急,我們來一行一行分析。

第 1 行:

Classfile /Users/maweiqing/Documents/GitHub/TechSisterLearnJava/codes/TechSister/target/classes/com/itwanger/jvm/Main.class

位元組碼檔案的位置。

第 2 行:

Last modified 2021年4月15日; size 385 bytes

位元組碼檔案的修改日期、檔案大小。

第 3 行:

SHA-256 checksum 6688843e4f70ae8d83040dc7c8e2dd3694bf10ba7c518a6ea9b88b318a8967c

位元組碼檔案的 SHA-256 值。

第 4 行:

Compiled from "Main.java"

說明該位元組碼檔案編譯自 Main.java 原始檔。

第 5 行:

public class com.itwanger.jvm.Main

位元組碼檔案的類全名。

第 6 行 minor version: 0,次版本號。

第 7 行 major version: 55,主版本號。

第 8 行:

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

類訪問標記,一共有 8 種。

表明當前類是 ACC_PUBLIC | ACC_SUPER。位運算符 | 的意思是如果相對應位是 0,則結果為 0,否則為 1,所以 0x0001 | 0x0020 的結果是 0x0021(需要轉成二進位制進行運算)。

第 9 行:

this_class: #3 // com/itwanger/jvm/Main

當前類的索引,指向常量池中下標為 3 的常量,可以看得出當前類是 Main 類。

第 10 行:

super_class: #4 // java/lang/Object

父類的索引,指向常量池中下標為 6 的常量,可以看得出當前類的父類是 Object 類。

第 11 行:

interfaces: 0, fields: 1, methods: 2, attributes: 1

當前類有 0 個介面,1 個欄位(age),2 個方法(write()方法和預設的預設構造方法),1 個屬性(該類僅有的一個屬性是 SourceFIle,包含了源碼檔案的資訊)。

03、常量池

接下來是 Constant pool,也就是位元組碼檔案最重要的常量池部分。可以把常量池理解為位元組碼檔案中的資源倉庫,主要存放兩大類資訊。

1)字面量(Literal),有點類似 Java 中的常量概念,比如文字字元串,final 常量等。

2)符號引用(Symbolic References),屬於編譯原理方面的概念,包括 3 種:

類和介面的全限定名(Fully Qualified Name)欄位的名稱和描述符(Descriptor)方法的名稱和描述符

Java 虛擬機器是在載入位元組碼檔案的時候才進行的動態連結,也就是說,欄位和方法的符號引用只有經過運行期轉換後才能獲得真正的記憶體地址。當 Java 虛擬機器運行時,需要從常量池獲取對應的符號引用,然後在類創建或者運行時解析並翻譯到具體的記憶體地址上。

當前位元組碼檔案中一共有 21 個常量,它們之間是有連結的,逐個分析會比較亂,我們採用順藤摸瓜的方式,從上依次往下看,那些被連結的常量我們就點到為止。

注:

# 號後面跟的是索引,索引沒有從 0 開始而是從 1 開始,是因為設計者考慮到,「如果要表達不引用任何一個常量的含義時,可以將索引值設為 0 來表示」(《深入理解 Java 虛擬機器》描述的)。= 號後面跟的是常量的類型,沒有包含字首 CONSTANT_ 和字尾 _info。全文中提到的索引等同於下標,為了靈活描述,沒有做統一。

第 1 個常量:

#1 = Methodref #4.#18 // java/lang/Object."<init>":()V

類型為 Methodref,表明是用來定義方法的,指向常量池中下標為 4 和 18 的常量。

第 4 個常量:

#4 = Class #21 // java/lang/Object

類型為 Class,表明是用來定義類(或者介面)的,指向常量池中下標為 21 的常量。

第 21 個常量:

#21 = Utf8 java/lang/Object

類型為 Utf8,UTF-8 編碼的字元串,值為 java/lang/Object。

第 18 個常量:

#18 = NameAndType #7:#8 // "<init>":()V

類型為 NameAndType,表明是欄位或者方法的部分符號引用,指向常量池中下標為 7 和 8 的常量。

第 7 個常量:

#7 = Utf8 <init>

類型為 Utf8,UTF-8 編碼的字元串,值為 <init>,表明為構造方法。

第 8 個常量:

#8 = Utf8 ()V

類型為 Utf8,UTF-8 編碼的字元串,值為 ()V,表明方法的返回值為 void。

到此為止,第 1 個常量算是摸完了。組合起來的意思就是,Main 類使用的是預設的構造方法,來源於 Object 類。

第 2 個常量:

#2 = Fieldref #3.#19 // com/itwanger/jvm/Main.age:I

類型為 Fieldref,表明是用來定義欄位的,指向常量池中下標為 3 和 19 的常量。

第 3 個常量:

#3 = Class #20 // com/itwanger/jvm/Main

類型為 Class,表明是用來定義類(或者介面)的,指向常量池中下標為 20 的常量。

第 19 個常量:

#19 = NameAndType #5:#6 // age:I

類型為 NameAndType,表明是欄位或者方法的部分符號引用,指向常量池中下標為 5 和 6 的常量。

第 5 個常量:

#5 = Utf8 age

類型為 Utf8,UTF-8 編碼的字元串,值為 age,表明欄位名為 age。

第 6 個常量:

#6 = Utf8 I

類型為 Utf8,UTF-8 編碼的字元串,值為 I,表明欄位的類型為 int。

關於欄位類型的描述符對映表如下圖所示。

到此為止,第 2 個常量算是摸完了。組合起來的意思就是,聲明瞭一個類型為 int 的欄位 age。

04、欄位表集合

欄位表用來描述介面或者類中聲明的變數,包括類變數和成員變數,但不包含聲明在方法中局部變數。

欄位的修飾符一般有:

訪問許可權修飾符,比如 public private protected靜態變數修飾符,比如 staticfinal 修飾符併發可見性修飾符,比如 volatile序列化修飾符,比如 transient

然後是欄位的類型(可以是基本資料類型、陣列和物件)和名稱。

在 Main.class 位元組碼檔案中,欄位表的資訊如下所示。

private int age; descriptor: I flags: (0x0002) ACC_PRIVATE

表明欄位的訪問許可權修飾符為 private,類型為 int,名稱為 age。

欄位的訪問標誌和類的訪問標誌非常類似。

05、方法表集合

方法表用來描述介面或者類中聲明的方法,包括類方法和成員方法,以及構造方法。方法的修飾符和欄位略有不同,比如說 volatile 和 transient 不能用來修飾方法,再比如說方法的修飾符多了 synchronized、native、strictfp 和 abstract。

下面這部分為構造方法,返回類型為 void,訪問標誌為 public。

public com.itwanger.jvm.Main(); descriptor: ()V flags: (0x0001) ACC_PUBLIC

來詳細看一下其中 Code 屬性。

Code: stack=2, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: bipush 18 7: putfield #2 // Field age:I 10: return LineNumberTable: line 6: 0 line 7: 4 LocalVariableTable: Start Length Slot Name Signature 0 11 0 this Lcom/itwanger/jvm/Main;

stack 為最大操作數棧,Java 虛擬機器在運行的時候會根據這個值來分配棧幀的操作數棧深度。locals 為局部變數所需要的儲存空間,單位為槽(slot),方法的參數變數和方法內的局部變數都會儲存在局部變量表中。args_size 為方法的參數個數。

為什麼 stack 的值為 2,locals 的值為 1,args_size 的值為 1 呢? 預設的構造方法不是沒有參數和局部變數嗎?

這是因為有一個隱藏的 this 變數,只要不是靜態方法,都會有一個當前類的物件 this 悄悄的存在著。這就解釋了為什麼 locals 和 args_size 的值為 1 的問題。那為什麼 stack 的值為 2 呢?因為位元組碼指令 invokespecial(呼叫父類的構造方法進行初始化)會消耗掉一個當前類的引用,所以 aload_0 執行了 2 次,也就意味著操作數棧的大小為 2。

關於位元組碼指令,我們後面再詳細介紹。

LineNumberTable,該屬性的作用是描述源碼行號與位元組碼行號(位元組碼偏移量)之間的對應關係。LocalVariableTable,該屬性的作用是描述幀棧中的局部變數與源碼中定義的變數之間的關係。大家仔細看一下,就能看到 this 的影子了。

下面這部分為成員方法 getAge(),返回類型為 int,訪問標誌為 public。

public int getAge(); descriptor: ()I flags: (0x0001) ACC_PUBLIC

理解了構造方法的 Code 屬性後,再看 getAge() 方法的 Code 屬性時,就很容易理解了。

Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field age:I 4: ireturn LineNumberTable: line 9: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/itwanger/jvm/Main;

最大操作數棧為 1,局部變數所需要的儲存空間為 1,方法的參數個數為 1,是因為局部變數只有一個隱藏的 this,並且位元組碼指令中只執行了一次 aload_0。

本來想著這一篇就徹底把 Java 位元組碼給結束掉,沒想到還得再學習一下位元組碼指令,難頂!

其實學習就是這樣,可以橫向擴展,也可以縱向擴展。當我們初學程式設計的時候,特別想多學一點,屬於橫向擴展,當有了一定的程式設計經驗後,想更上一層樓,就需要縱向擴展,不斷深入地學,連根拔起,從而形成自己的知識體系。

無論是從十六進位制的位元組碼角度,還是 jclasslib 圖形化檢視反編譯後的位元組碼的角度,也或者是今天這樣從 javap 反編譯後的角度,都能窺探出一些新的內容來!

初學者一開始接觸位元組碼的時候會感覺比較頭大,沒關係,我當初也是這樣,隨著時間的推移,經驗的積累,慢慢就好了,越往深處鑽,就越能體會到那種「技術我有,雄霸天下」的感覺~

說兩句,馬上秋招了,可以開始準備了。

一定記得刷一刷面試題,背一背八股文,要乖哦,千萬不要抗拒!千萬不要裸面,真的!其實私下裡,很多學弟學妹們都向我哭訴過,說大廠的面試題太難了,有的題出的真的是萬萬沒想到啊(狗頭)。甚至有些中小廠的面試題都很難對答如流(他們的面試官可能看過我這份面試題庫,哈哈哈),有了這份面試題庫後,大家再也不用慌了!

V4.0 《JavaGuide 面試突擊版》來啦!GitHub 上標星 98.1k,幫你成功上岸!

我是一直在悄悄打怪的二哥,希望能和同學們一起,變得更強,更禿(不不不,更帥),既然看到著了,就賞個三連吧,只收藏也不是不可以!

下期見~

,https://blog.csdn.net/qing_gee/article/details/116709729


IT145.com E-mail:sddin#qq.com