Java 進(jìn)階之字節(jié)碼剖析
前言
你好,我是坤哥。
從今天起我打算整一個(gè) Java 系列的進(jìn)階基礎(chǔ)文章,萬(wàn)丈高樓平地起,打好基礎(chǔ)我們才能走得更好,舉個(gè)例子,之前我在武哥的 Kafka 文章中看到這樣的一句話「除此之外,頁(yè)緩存(pageCache)還有一個(gè)巨大的優(yōu)勢(shì)。用過(guò) Java 的人都知道:如果不用頁(yè)緩存,而是用 JVM 進(jìn)程中的緩存,對(duì)象的內(nèi)存開(kāi)銷(xiāo)非常大(通常是真實(shí)數(shù)據(jù)大小的幾倍甚至更多)」,如果你不了解 Java 對(duì)象的表示,看到這樣的話會(huì)一臉懵逼:對(duì)象的開(kāi)銷(xiāo)到底有多巨大,反過(guò)來(lái)看,如果你掌握了 Java 中的對(duì)象布局,GC,NIO 等原理,理解這些框架的原理及其設(shè)計(jì)思路就不是什么難事。
另一個(gè)讓我下決心寫(xiě)這個(gè)系列的原因是經(jīng)常有一些讀者問(wèn)一些學(xué)習(xí)路線的事,之前我寫(xiě)過(guò)一些大綱,但沒(méi)有從點(diǎn)的層面展開(kāi),所以這次準(zhǔn)備從點(diǎn)的思路來(lái)將各個(gè)知識(shí)點(diǎn)細(xì)細(xì)道來(lái),然后再整理成 pdf,這樣之后如果有人再問(wèn)起,直接把這個(gè) pdf 扔給他們就完事了 ^_^
每個(gè)系列都會(huì)以圖文并茂的方式來(lái)講解,做到深入淺出,舉個(gè)例子,上面我們說(shuō)了對(duì)象的開(kāi)銷(xiāo)很大,到底有多大呢,我會(huì)用圖解的方式來(lái)帶你一步步分析,看完后相信你會(huì)明白為什么 int[128][2] ,int[256] 這兩個(gè)數(shù)組看起來(lái)一樣,但實(shí)際上前者比后者多了 246% 的額外開(kāi)銷(xiāo),再比如我們都知道 Eden 區(qū)或 tenured(老年代區(qū))滿了會(huì)觸發(fā) yong gc 或 old gc,不過(guò)導(dǎo)致 gc 停頓時(shí)間過(guò)長(zhǎng)的原因其實(shí)有挺多的,如果你看完我總結(jié)的這些通用的思路,相信你就能根據(jù)這套理論來(lái)快速地排查問(wèn)題了,這個(gè)系列干貨很多,相信對(duì)提升大家的 Java 內(nèi)功有不少幫助,記得得文末點(diǎn)贊支持一下哦 ^_^
Java 系列大綱如下:
本篇我們先來(lái)學(xué)習(xí)下字節(jié)碼 ,畢竟這是 Java 能跨平臺(tái)的根本原因,而且通過(guò)了解字節(jié)碼也可以徹底揭開(kāi) JVM 運(yùn)行程序的秘密,整體會(huì)用問(wèn)答的形式來(lái)講解。
能否簡(jiǎn)單介紹一下 Java 的特性
Java 是一門(mén)面向?qū)ο?,靜態(tài)類型的語(yǔ)言,具有跨平臺(tái)的特點(diǎn),與 C,C++ 這些需要手動(dòng)管理內(nèi)存,編譯型的語(yǔ)言不同,它是解釋型的,具有跨平臺(tái)和自動(dòng)垃圾回收的特點(diǎn),那么它的跨平臺(tái)到底是怎么實(shí)現(xiàn)的呢?
我們知道計(jì)算機(jī)只能識(shí)別二進(jìn)制代碼表示的機(jī)器語(yǔ)言,所以不管用的什么高級(jí)語(yǔ)言,最終都得翻譯成機(jī)器語(yǔ)言才能被 CPU 識(shí)別并執(zhí)行,對(duì)于 C++這些編譯型語(yǔ)言來(lái)說(shuō)是直接一步到位轉(zhuǎn)為相應(yīng)平臺(tái)的可執(zhí)行文件(即機(jī)器語(yǔ)言指令),而對(duì) Java 來(lái)說(shuō),則首先由編譯器將源文件編譯成字節(jié)碼,再在運(yùn)行時(shí)由虛擬機(jī)(JVM)解釋成機(jī)器指令來(lái)執(zhí)行,我們可以看下下圖:
也就是說(shuō) Java 的跨平臺(tái)其實(shí)是通過(guò)先生成字節(jié)碼,再由針對(duì)各個(gè)平臺(tái)實(shí)現(xiàn)的 JVM 來(lái)解釋執(zhí)行實(shí)現(xiàn)的,JVM 屏蔽了 OS 的差異,我們知道 Java 工程都是以 Jar 包分發(fā)(一堆 class 文件的集合體)部署的,這就意味著 jar 包可以在各個(gè)平臺(tái)上運(yùn)行(由相應(yīng)平臺(tái)的 JVM 解釋執(zhí)行即可),這就是 Java 能實(shí)現(xiàn)跨平臺(tái)的原因所在。
這也是為什么 JVM 能運(yùn)行 Scala、Groovy、Kotlin 這些語(yǔ)言的原因,并不是 JVM 直接來(lái)執(zhí)行這些語(yǔ)言,而是這些語(yǔ)言最終都會(huì)生成符合 JVM 規(guī)范的字節(jié)碼再由 JVM 執(zhí)行,不知你是否注意到,使用字節(jié)碼也利用了計(jì)算機(jī)科學(xué)中的分層理念,通過(guò)加入字節(jié)碼這樣的中間層,有效屏蔽了與上層的交互差異。
JVM 是怎么執(zhí)行字節(jié)碼的
在此之前我們先來(lái)看下 JVM 的整體內(nèi)存結(jié)構(gòu),對(duì)其有一個(gè)宏觀的認(rèn)識(shí),然后再來(lái)看 JVM 是如何執(zhí)行字節(jié)碼的。
JVM 內(nèi)存結(jié)構(gòu)
JVM 在內(nèi)存中主要分為「?!?「堆」,「非堆」以及 JVM 自身,堆主要用來(lái)分配類實(shí)例和數(shù)組,非堆包括「方法區(qū)」、「JVM內(nèi)部處理或優(yōu)化所需的內(nèi)存(如JIT編譯后的代碼緩存)」、每個(gè)類結(jié)構(gòu)(如運(yùn)行時(shí)常數(shù)池、字段和方法數(shù)據(jù))以及方法和構(gòu)造方法的代碼。
我們主要關(guān)注棧,我們知道線程是 cpu 調(diào)度的最小單位,在 JVM 中一旦創(chuàng)建一個(gè)線程,就會(huì)為其分配一個(gè)線程棧,線程會(huì)調(diào)用一個(gè)個(gè)方法,每個(gè)方法都會(huì)對(duì)應(yīng)一個(gè)個(gè)的棧幀壓到線程棧里,JVM 中的棧內(nèi)存結(jié)構(gòu)如下:
JVM 棧內(nèi)存結(jié)構(gòu)
至此我們總算接近 JVM 執(zhí)行的真相了,JVM 是以棧幀為單位執(zhí)行的,棧幀由以下四個(gè)部分組成:
- 返回值
- 局部變量表(Local Variables):存儲(chǔ)方法用到的本地變量
- 動(dòng)態(tài)鏈接:在字節(jié)碼中,所有的變量和方法都是以符號(hào)引用的形式保存在 class 文件的常量池中的,比如一個(gè)方法調(diào)用另外的方法,是通過(guò)常量池中指向方法的符號(hào)引用來(lái)表示的,動(dòng)態(tài)鏈接的作用就是為了將這些符號(hào)引用轉(zhuǎn)換為調(diào)用方法的直接引用,這么說(shuō)可能有人還是不理解,所以我們先執(zhí)行一下 javap -verbose Demo.class命令來(lái)查看一下字節(jié)碼中的常量池是咋樣的
注意:以上只列出了常量池中的部分符號(hào)引用
可以看到 Object 的 init 方法是由 #4.#16 表示的,而 #4 又指向了 #19,#19 表示 Object,#16 又指向了 #7.#8,#7 指向了方法名,#8 指向了 ()V(表示方法的返回值為 void,且無(wú)方法參數(shù)),字節(jié)碼加載后,會(huì)把類信息加載到元空間(Java 8 以后)中的方法區(qū)中,動(dòng)態(tài)鏈接會(huì)把這些符號(hào)引用替換為調(diào)用方法的直接引用,如下圖示:
那為什么要提供動(dòng)態(tài)鏈接呢,通過(guò)上面這種方式繞了好幾個(gè)彎才定位到具體的執(zhí)行方法,效率不是低了很多嗎,其實(shí)主要是為了支持 Java 的多態(tài),比如我們聲明一個(gè) Father f = new Son()這樣的變量,但執(zhí)行 f.method() 的時(shí)候會(huì)綁定到 son 的 method(如果有的話),這就是用到了動(dòng)態(tài)鏈接的技術(shù),在運(yùn)行時(shí)才能定位到具體該調(diào)用哪個(gè)方法,動(dòng)態(tài)鏈接也稱為后期綁定,與之相對(duì)的是靜態(tài)鏈接(也稱為前期綁定),即在編譯期和運(yùn)行期對(duì)象的方法都保持不變,靜態(tài)鏈接發(fā)生在編譯期,也就是說(shuō)在程序執(zhí)行前方法就已經(jīng)被綁定,java 當(dāng)中的方法只有final、static、private和構(gòu)造方法是前期綁定的。而動(dòng)態(tài)鏈接發(fā)生在運(yùn)行時(shí),幾乎所有的方法都是運(yùn)行時(shí)綁定的。
舉個(gè)例子來(lái)看看兩者的區(qū)別,一目了解。
- class Animal{
- public void eat(){
- System.out.println("動(dòng)物進(jìn)食");
- }
- }
- class Cat extends Animal{
- @Override
- public void eat() {
- super.eat();//表現(xiàn)為早期綁定(靜態(tài)鏈接)
- System.out.println("貓進(jìn)食");
- }
- }
- public class AnimalTest {
- public void showAnimal(Animal animal){
- animal.eat();//表現(xiàn)為晚期綁定(動(dòng)態(tài)鏈接)
- }
- }
- 操作數(shù)棧(Operand Stack):程序主要由指令和操作數(shù)組成,指令用來(lái)說(shuō)明這條操作做什么,比如是做加法還是乘法,操作數(shù)就是指令要執(zhí)行的數(shù)據(jù),那么指令怎么獲取數(shù)據(jù)呢,指令集的架構(gòu)模型分為基于棧的指令集架構(gòu)和基于寄存器的指令集架構(gòu)兩種,JVM 中的指令集屬于前者,也就是說(shuō)任何操作都是用棧來(lái)管理,基于棧指令可以更好地實(shí)現(xiàn)跨平臺(tái),棧都是是在內(nèi)存中分配的,而寄存器往往和硬件掛鉤,不同的硬件架構(gòu)是不一樣的,不利于跨平臺(tái),當(dāng)然基于棧的指令集架構(gòu)缺點(diǎn)也很明顯,基于棧的實(shí)現(xiàn)需要更多指令才能完成(因?yàn)闂V皇且粋€(gè)FILO結(jié)構(gòu),需要頻繁壓棧出棧),而寄存器是在CPU的高速緩存區(qū),相較而言,基于棧的速度要慢不少,這也是為了跨平臺(tái)而做出的一點(diǎn)性能犧牲,畢竟魚(yú)和熊掌不可兼得。
Java 字節(jié)碼技術(shù)簡(jiǎn)介
注意線程中還有一個(gè)「PC 程序計(jì)數(shù)器」,是每個(gè)線程獨(dú)有的,記錄著當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器,也就是指向下一條指令的地址,也就是將執(zhí)行的指令代碼。由執(zhí)行引擎讀取下一條指令。我們先來(lái)看下看一下字節(jié)碼長(zhǎng)啥樣。假設(shè)我們有以下 Java 代碼:
- package com.mahai;
- public class Demo {
- private int a = 1;
- public static void foo() {
- int a = 1;
- int b = 2;
- int c = (a + b) * 5;
- }
- }
執(zhí)行 javac Demo.java 后可以看到其字節(jié)碼如下:
字節(jié)碼是給 JVM 看的,所以我們需要將其翻譯成人能看懂的代碼,好在 JDK 提供了反解析工具 javap ,可以根據(jù)字節(jié)碼反解析出 code 區(qū)(匯編指令)、本地變量表、異常表和代碼行偏移量映射表、常量池等信息。我們執(zhí)行以下命令來(lái)看下根據(jù)字節(jié)碼反解析的文件長(zhǎng)啥樣(更詳細(xì)的信息可以執(zhí)行 javap -verbose 命令,在本例中我們重點(diǎn)關(guān)注 Code 區(qū)是如何執(zhí)行的,所以使用了 javap -c 來(lái)執(zhí)行。
- javap -c Demo.class
轉(zhuǎn)換成這種形式可讀性強(qiáng)了很多,那么aload_0,invokespecial 這些表示什么含義呢, javap 是怎么根據(jù)字節(jié)碼來(lái)解析出這些指令出來(lái)的呢!
首先我們需要明白什么是指令,指令=操作碼+操作數(shù),操作碼表示這條指令要做什么,比如加減乘除,操作數(shù)即操作碼操作的數(shù),比如 1+ 2 這條指令,操作碼其實(shí)是加法,1,2 為操作數(shù),在 Java 中每個(gè)操作碼都由一個(gè)字節(jié)表示,每個(gè)操作碼都有對(duì)應(yīng)類似 aload_0,invokespecial,iconst_1 這樣的助記符,有些操作碼本來(lái)就包含著操作數(shù),比如字節(jié)碼 0x04 對(duì)應(yīng)的助記符為 iconst_1, 表示 將 int 型 1 推送至棧頂,這些操作碼就相當(dāng)于指令,而有些操作碼需要配合操作數(shù)才能形成指令,如字節(jié)碼 0x10 表示 bipush,后面需要跟著一個(gè)操作數(shù),表示 將單字節(jié)的常量值(-128~127)推送至棧頂。以下為列出的幾個(gè)字節(jié)碼與助記符示例:
字節(jié)碼 | 助記符 | 表示含義 |
---|---|---|
0x04 | iconst_1 | 將int型1推送至棧頂 |
0xb7 | invokespecial | 調(diào)用超類構(gòu)建方法, 實(shí)例初始化方法, 私有方法 |
0x1a | iload_0 | 將第一個(gè)int型本地變量推送至棧頂 |
0x10 | bipush | 將單字節(jié)的常量值(-128~127)推送至棧頂 |
至此我們不難明白 javap 的作用了,它主要就是找到字節(jié)碼對(duì)應(yīng)的的助記符然后再展示在我們面前的,我們簡(jiǎn)單看下上述的默認(rèn)構(gòu)造方法是如何根據(jù)字節(jié)碼映射成助記符并最終呈現(xiàn)在我們面前的:
最左邊的數(shù)字是 Code 區(qū)中每個(gè)字節(jié)的偏移量,這個(gè)是保存在 PC 的程序計(jì)數(shù)中的,比如如果當(dāng)前指令指向 1,下一條就指向 4。
另外大家不難發(fā)現(xiàn),在源碼中其實(shí)我們并沒(méi)有定義默認(rèn)構(gòu)造函數(shù),但在字節(jié)碼中卻生成了,而且你會(huì)發(fā)現(xiàn)我們?cè)谠创a中定義了private int a = 1;但這個(gè)變量賦值的操作卻是在構(gòu)造方法中執(zhí)行的(下文會(huì)分析到),這就是理解字節(jié)碼的意義:它可以反映 JVM 執(zhí)行程序的真正邏輯,而源碼只是表象,要深入分析還得看字節(jié)碼!
接下來(lái)我們就來(lái)瞧一瞧構(gòu)造方法對(duì)應(yīng)的指令是如何執(zhí)行的,首先我們來(lái)看一下在 JVM 中指令是怎么執(zhí)行的。
- 首先 JVM 會(huì)為每個(gè)方法分配對(duì)應(yīng)的局部變量表,可以認(rèn)為它是一個(gè)數(shù)組,每個(gè)坑位(我們稱為 slot)為方法中分配的變量,如果是實(shí)例方法,這些局部變量可以是 this, 方法參數(shù),方法里分配的局部變量,這些局部變量的類型即我們熟知的 int,long 等八大基本,還有引用,返回地址,每個(gè) slot 為 4 個(gè)字節(jié),所以像 Long , Double 這種 8 個(gè)字節(jié)的要占用 2 個(gè) slot, 如果這個(gè)方法為實(shí)例方法,則第一個(gè) slot 為 this 指針, 如果是靜態(tài)方法則沒(méi)有 this 指針。
- 分配好局部變量表后,方法里如果涉及到賦值,加減乘除等操作,那么這些指令的運(yùn)算就需要依賴于操作數(shù)棧了,將這些指令對(duì)應(yīng)的操作數(shù)通過(guò)壓棧,彈棧來(lái)完成指令的執(zhí)行。
比如有 int i = 69 這樣的指令,對(duì)應(yīng)的字碼節(jié)指令如下:
- 0:bipush 69
- 2:istore_0
其在內(nèi)存中的操作過(guò)程如下:
可以看到主要分兩步:第一步首先把 69 這個(gè) int 值壓棧,然后再?gòu)棗?,?69 彈出放到局部變量表 i 對(duì)應(yīng)的位置,istore_0 表示彈棧,將其從操作數(shù)棧中彈出整型數(shù)字存儲(chǔ)到本地變量中,0 表示本地變量在局部變量表的第 0 個(gè) slot。
理解了上面這個(gè)操作,我們?cè)賮?lái)看一下默認(rèn)構(gòu)造函數(shù)對(duì)應(yīng)的字節(jié)碼指令是如何執(zhí)行的:
首先我們需要先來(lái)理解一下上面幾個(gè)指令:
- aload_0:從局部變量表中加載第 0 個(gè) slot 中的對(duì)象引用到操作數(shù)棧的棧頂,這里的 0 表示第 0 個(gè)位置,也就是 this。
- invokespecial:用來(lái)調(diào)用構(gòu)造函數(shù),但也可以用于調(diào)用同一個(gè)類中的 private 方法, 以及 可見(jiàn)的超類方法,在此例中表示調(diào)用父類的構(gòu)造器(因?yàn)?#1 符號(hào)引用指向?qū)?yīng)的 init 方法)。
- iconst_1:將 int 型 1推送至棧頂。
- putfield:它接受一個(gè)操作數(shù),這個(gè)操作數(shù)引用的是運(yùn)行時(shí)常量池里的一個(gè)字段,在這里這個(gè)字段是 a。賦給這個(gè)字段的值,以及包含這個(gè)字段的對(duì)象引用,在執(zhí)行這條指令的時(shí)候,都會(huì)從操作數(shù)棧頂上 pop 出來(lái)。前面的 aload_0 指令已經(jīng)把包含這個(gè)字段的對(duì)象(this)壓到操作數(shù)棧上了,而后面的 iconst_1 又把 1 壓到棧里。最后 putfield 指令會(huì)將這兩個(gè)值從棧頂彈出。執(zhí)行完的結(jié)果就是這個(gè)對(duì)象的 a 這個(gè)字段的值更新成了 1。
接下來(lái)我們來(lái)詳細(xì)解釋以上以上助記符代表的含義:
- 第一條命令 aload_0,表示從局部變量表中加載第 0 個(gè) slot 中的對(duì)象引用到操作數(shù)棧的棧頂,也就是將 this 加載到棧頂,如下:
- 第二步 invokespecial #1,表示彈棧并且執(zhí)行 #1 對(duì)應(yīng)的方法,#1 代表的含義可以從旁邊的解釋(# Method java/lang/Object."":()V)看出,即調(diào)用父類的初始化方法,這也印證了那句話:子類初始化時(shí)會(huì)從初始化父類
- 之后的命令 aload_0,iconst_1,putfied #2 圖解如下:
可能有人有些奇怪,上述 6: putfield #2命令中的 #2 怎么就代表 Demo 的私有成員 a 了,這就涉及到字節(jié)碼中的常量池概念了,我們執(zhí)行 javap -verbose path/Demo.class 可以看到這些字面量代表的含義,#1,#2 這種數(shù)字形式的表示形式也被稱為符號(hào)引用,程序運(yùn)行期會(huì)將符號(hào)引用轉(zhuǎn)換為直接引用。
由此可知 #2 代表 Demo 類的 a 屬性,如下:
從最終的葉子節(jié)點(diǎn)可以看出 #2 最終代表的是 Demo 類中類型為 int(I 代表 int 代表 int 類型),名稱為 a 的變量。
我們?cè)賮?lái)用動(dòng)圖看一下 foo 的執(zhí)行流程,相信你現(xiàn)在能理解其含義了。
唯一需要注意的此例中的 foo 是個(gè)靜態(tài)方法,所以局部變量區(qū)是沒(méi)有 this 的。
相信你不難發(fā)現(xiàn) JVM 執(zhí)行字節(jié)碼的流程與 CPU 執(zhí)行機(jī)器碼步驟如出一轍,都經(jīng)歷了「取指令」,「譯碼」,「執(zhí)行」,「存儲(chǔ)計(jì)算結(jié)果」這四步,首先程序計(jì)數(shù)器指向下一條要執(zhí)行的指令,然后 JVM 獲取指令,由本地執(zhí)行引擎將字節(jié)碼操作數(shù)轉(zhuǎn)成機(jī)器碼(譯碼)執(zhí)行,執(zhí)行后將值存儲(chǔ)到局部變量區(qū)(存儲(chǔ)計(jì)算結(jié)果)中。
最后關(guān)于字節(jié)碼我推薦兩款工具:
- 一個(gè)是 Hex Fiend,一款很好的十六進(jìn)制編輯器,可以用來(lái)查看編輯字節(jié)碼
- 一款是 Intellij Idea 的插件 jclasslib Bytecode viewer,能為你展示 javap -verbose 命令對(duì)應(yīng)的常量池,接口, Code 等數(shù)據(jù),非常的直觀,對(duì)于分析字節(jié)碼非常有幫忙,如下:
本文轉(zhuǎn)載自微信公眾號(hào)「碼海」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系碼海公眾號(hào)。