大話+圖說:Java位元組碼指令——只為讓你懂
前言
隨著Java開發技術不斷被推到新的高度,對於Java程式設計師來講越來越需要具備對更深入的基礎性技術的理解,比如Java位元組碼指令。不然,可能很難深入理解一些時下的新框架、新技術,盲目一味追新也會越來越感乏力。
本文既不求照本宣科,亦不求炫技或著文立說,僅力圖以最簡明、最形象生動的方式,結合例子與實戰,讓小白也能搞懂這門看似複雜的技術概念。
單刀直入
閒言碎語不要講,先表一表,什麼是Java位元組碼指令?簡而言之,Java位元組碼指令就是Java虛擬機器能夠聽得懂、可執行的指令,可以說是Jvm層面的組合語言,或者說是Java程式碼的最小執行單元。
有點Java基礎的人一定都知道,javac命令會將Java原始檔編譯成位元組碼檔案,即.class檔案,其中就包含了大量的位元組碼指令。因此可以將javac命令理解為一個翻譯命令,將原始檔翻譯成Jvm可以執行的指令。
那麼最直觀的探究方法莫過於直接對比翻譯前後的內容。
具體如何對比呢?就不得不用到Java為我們一直默默提供的一項利器,javap命令,它可以解析位元組碼,將位元組碼內部邏輯以可讀的方式呈現出來。為了緊貼實戰,我們直接在新建的Java工程裡,寫這樣一個UserServiceImpl類,裡面包含幾個由簡單到複雜的方法,以及一個名為serviceType的屬性:
如圖,以上方法,複雜度由低到高依次為:getServiceType<setServiceType<genToken<login(以及一個例項程式碼塊),後面我也會按照這個順序解讀其位元組碼指令的執行邏輯。
下面我們編譯工程,然後在下圖所示的目錄(gradle編譯工程)找到該類的位元組碼檔案:
cd到這個路徑下,執行javap命令:
javap -v -p UserServiceImpl
就可以觀看到翻譯版的Java位元組碼的胴體了!這裡的-v意思是囉嗦模式,會輸出全面的位元組碼資訊,而-p是指涵蓋所有成員。原位元組碼資訊輸出內容較多,基於本文的目標,取其一方法的內容,整理如下圖:
方法1,getServiceType():
這個getServiceType的方法應該是再簡單不過的Java程式碼,翻譯成位元組碼後也變成了三行,我們先來簡單推理一下:第一句,aload_0不知所云,索性略過;第二行,getfield應該可以讀懂,後面這個#8似乎是他的引數(實際上是對常量池的引用),//後面註釋的內容是javap給我們加上的,意思應該是#2的指向是"Field serviceType:Ljava/lang/String;"這個內容。
所以getfield這一行就是取出serviceType這個欄位嘍,so easy。areturn肯定就是return的意思,a的含義也先略過不表。總之就是取出serviceType欄位然後return嘍。
那麼現在的問題就是aload_0是什麼意思了,看似多餘,但仔細思考一下,似乎之前給getfield指令傳入了“Field serviceType:Ljava/lang/String;”這樣一個並不完整的引數,其後半部分的“Ljava/lang/String;”僅僅表示這個serviceType欄位的型別是String,也就是說,整個引數裡沒有說是取的誰的serviceType欄位啊!究竟是get誰的feild呢?
由此可以想到:aload操作一定是在為getfield指令準備了一個主體。
實際上,再結合下面的區域性變量表,aload_0中的0正是區域性變量表裡的Slot 0的含義。意思是將區域性變量表裡的Slot 0的東西壓入運算元棧,這個Slot 0裡的東西name正是this,也就是UserServiceImpl的例項,即getfield的主體。
大戲上演
好了,對於小白同學有些陌生的概念來了,啥是運算元棧?啥是區域性變量表?
其實這兩個東西理解好了,關於虛擬機器指令就懂了一大半了。
那麼,不妨刪繁就簡,由易入難,先講一個這樣的故事,故事起名叫:
Java方法之創世紀
話說Jvm大帝是神之旨意的履行者(Jvm大帝就是虛擬機器,神就是開發者,神之旨意是開發者寫好並編譯後的位元組碼...),當Jvm大帝帶領Java世界執行進入了一個新的方法後,會為這個方法在棧記憶體大陸上創造兩個重要的領域:區域性變量表和運算元棧。
要有棧。要有表。神說。
依照神之旨意,jvm大帝創造的區域性變量表裡一般會包含this指標(針對例項方法,靜態方法當然無此)、方法的所有傳入引數和方法中所開闢的本地變數。
那麼運算元棧是幹嘛用的呢?
我們再引入另外一個比喻,如果把執行Java方法理解為拍戲,那麼區域性變量表裡的各個區域性變數就是這部戲的核心主角,或者說領銜主演,而運算元棧正是這部戲的舞臺。所謂運算元棧搭臺,區域性變數唱戲,是也。那麼aload_0就是告訴Jvm導演(大帝已淪落為導演),請0號演員this同志登臺(壓棧),演後邊的本子。
當然了,這個比喻並不完全恰當,因為運算元棧並不是“舞臺”的結構,而是棧的結構。但是這個比喻可以很好地說明區域性變量表和運算元棧之間的關係,以及aload_0的作用。
下面我們用一張圖來演示一下getServiceType這個小劇本橋段所導演的故事:
好吧這部劇雖然短的可憐,但已經基本把指令、運算元棧和區域性變量表三者的關係演繹了出來。
值得注意的是,getfield這條指令對運算元棧進行了複合操作,其流程可以示意如下圖:
後面我們將要接觸到的許多指令都如此,指令內部執行了彈出—>處理—>壓回的流程。
下面我們就來分析一個相對複雜一點的方法,setServiceType(String),如下圖:
這裡我們看到,變化主要有,指令多了一行,多進行了一次aload,getfield變成了putfield,areturn變成了return,僅此而已。另外領銜主演也就是區域性變量表裡多了一位,也就是方法的傳入引數serviceType字串物件了。其情節如下:
這裡,putfield只彈出棧內的運算元,而沒有向運算元棧壓回任何資料,而且執行putfield之前,棧內元素的位置也必須符合“值在上,主體在下”要求。
而最後的return僅表示方法結束,而不會像areturn一樣返回棧頂元素。這也印證了setServiceType(String)方法沒有返回引數。
融會貫通
相信有了以上的講解,大家對指令、運算元棧、區域性變量表三者的運作關係有了一定認識,為了後邊能夠分析更復雜的方法,這裡必須概括性地講解一下更多的Java位元組碼指令。雖然Java位元組碼指令非常多,但其實常用的不外乎幾個類別,先從這幾個常用類別入手理解,便可漸入佳境。
關於位元組碼指令的分類,可以從兩個維度進行:一是指令的功能,二是指令操作的資料型別。我們先從功能說起,指令主要可以分為如下幾類:
- 儲存和載入類指令:主要包括load系列指令、store系列指令和ldc、push系列指令,主要用於在區域性變量表、運算元棧和常量池三者之間進行資料排程;(關於常量池前面沒有特別講解,這個也很簡單,顧名思義,就是這個池子裡放著各種常量,好比片場的道具庫)
- 物件操作指令(建立與讀寫訪問):比如我們剛剛的putfield和getfield就屬於讀寫訪問的指令,此外還有putstatic/getstatic,還有new系列指令,以及instanceof等指令。
- 運算元棧管理指令:如pop和dup,他們只對運算元棧進行操作。
- 型別轉換指令和運算指令:如add/div/l2i等系列指令,實際上這類指令一般也只對運算元棧進行操作。
- 控制跳轉指令:這類裡包含常用的if系列指令以及goto類指令。
- 方法呼叫和返回指令:主要包括invoke系列指令和return系列指令。這類指令也意味這一個方法空間的開闢和結束,即invoke會喚醒一個新的java方法小宇宙(新的棧和區域性變量表),而return則意味著這個宇宙的結束回收。
如下圖,展示了各類指令的作用:
再從另外一個維度,即指令操作的資料型別來講:指令開頭或尾部的一些字母,就往往表明了它所能操作的資料型別:
a對應物件,表示指令操作物件性資料,比如aload和astore、areturn等等。
i對應整形。也就有iload,istore等i系列指令。
f對應浮點型。
l對應long,b對應byte,d對應double,c對應char。
另外地,ia對應int array,aa對應object array,da對應double array。不在一一贅述。
瞭解了以上內容,我們再去看最後幾個方法,應該就會容易理解很多了。
下面我們就直搗黃龍genToken這個方法(圖中的顏色暗示了指令和方法呼叫之間的關係):
這個過程簡單解讀如下:
1.new一個StringBuilder物件(在堆記憶體中開闢空間),並將其引用入棧,用於實現加號連線字串功能(相當於C++中的運算子過載);
2.dup複製棧頂的剛剛放入的引用,再次壓棧,這時棧裡有兩個重複的內容,深度為2;
3.呼叫並彈出棧頂StringBuilder引用物件的<init>方法,棧深度為1;
4.(綠色部分)呼叫UUID.randomUUID()靜態方法,結果壓棧後彈出呼叫String的toString方法,再壓棧,棧深度為2;
5.(黃色部分)將"-"和""字元壓棧,此時棧深度為4,彈出(棧頂3個元素)呼叫replace方法,結果壓棧,深度為2;
6.呼叫StringBuilder物件的append方法,結果壓棧,深度為1;
7.(藍色部分)將引數user壓棧並呼叫hashCode方法,結果壓棧,深度為2;
8.呼叫StringBuilder物件的append方法(此處和上面的append呼叫共同完成了加號功能,在圖中為紅色部分),結果壓棧,深度為1,再呼叫toString方法後結果壓棧,深度為1;
9.areturn返回棧頂物件。
再看這個包含if跳轉的方法login:
如上圖,圖中已經說明的比較全面了,不再贅述。值得一提的是,Java的這種基於棧結構的指令,在設計上有一種非常簡潔的美感,指令與指令之間並沒有較重的依賴,每條指令僅僅與運算元棧等領域內的資料發生關係,充滿著某種平衡與秩序感。因此也必須注意,幾乎每條指令的執行都有其前提,比如在invokevirtual或invokespecial指令執行前,必須保證運算元棧內提前按順序壓入好所需的運算元,否則就會發生問題。
關於最複雜的onCreate方法,就不再囉嗦解讀了,讀者可以前往我的github上的對應demo repo,進入tutorial分支,拉取原始碼和教程資源,或者自己寫demo體驗這一完整過程。
地址:https://github.com/BryanSharp...
後話
關於實戰,一是可以學習使用強大開源工具ASM.jar;二是,可以參考本人的另一篇文章:Java位元組碼修改神器HiBeaver:黑掉你的SDK以及一次Android位元組碼插樁實戰,利用hibeaver這個助手,開發者可以非常靈活地對位元組碼進行修改,插入指令,hook程式碼,甚至建立一些簡單的AOP框架,對於Java位元組碼學習大有裨益。
hibeaver完全開源,github專案地址:https://github.com/BryanSharp...
祝玩的愉快!
本文如有不妥之處,歡迎交流指正。
另外,本文為了儘可能地簡明生動、直入核心,簡化了很多概念和細節,讀者須知實際情況的更為複雜。但相信在理解了本文以後,就可以抓住Java位元組碼指令的核心理念,也就算扣開虛擬機器學習的大門並可以開始讀書精進了。下面盜圖一張(後有出處),可作拓展:
連結:http://blog.csdn.net/luanloui...
from; https://segmentfault.com/a/1190000008606277