JVM系列:幾張圖看懂Java字節(jié)碼
作為一個(gè)java程序員,如果你不懂字節(jié)碼的話,你只能算是初級(jí)程序員了。
這可不是聳人聽(tīng)聞。了解字節(jié)碼你才能真正了解包括“動(dòng)態(tài)代理的原理”、“類加載的細(xì)節(jié)過(guò)程”、“重載和重寫是如何實(shí)現(xiàn)的”、“多態(tài)是如何實(shí)現(xiàn)的”、“泛型究竟是什么”等等。
了解這些對(duì)你的工作有實(shí)際的意義:
比如,開(kāi)發(fā)時(shí)碰到問(wèn)題,需要確定一個(gè)jar包是不是最新的(開(kāi)發(fā)時(shí)我們經(jīng)常使用一個(gè)SNAPSHOT版本反復(fù)打包)。
比如,你在工具組,要開(kāi)發(fā)一個(gè)動(dòng)態(tài)修改運(yùn)行中的類的工具。
比如,你碰到大廠高階一些的面試,尤其是偏底層的團(tuán)隊(duì)。
比如,你碰到各種詭異的包括類找不到、方法找不到、java版本錯(cuò)誤無(wú)法解析類等問(wèn)題。
了解字節(jié)碼就仿佛給了你另一雙眼睛和更高緯度的視角,來(lái)應(yīng)對(duì)各種java相關(guān)的工作。
話不多說(shuō),我們這就開(kāi)始。
1、Java代碼從編寫到運(yùn)行
圖片
如圖所示,我們寫好的java代碼會(huì)先編譯成字節(jié)碼,然后JVM會(huì)加載這些字節(jié)碼并執(zhí)行其中的命令,也就是將字節(jié)碼翻譯為機(jī)器碼執(zhí)行。
這里有兩個(gè)重點(diǎn):
1)其他語(yǔ)言寫的代碼,如果可以翻譯成標(biāo)準(zhǔn)的java字節(jié)碼,一樣可以在JVM中運(yùn)行、事實(shí)上,有一些語(yǔ)言已經(jīng)可以這么做了(包括Groovy、JRuby、Scala、Clojure、Kotlin等)。這是字節(jié)碼技術(shù)面向未來(lái)的最大賣點(diǎn),也是JVM的宏大愿景。
2)如果我們改變了編譯出來(lái)字節(jié)碼,其實(shí)就改變了邏輯。不需要去改原來(lái)的java代碼。
2、字節(jié)碼怎么看
下面我們用一個(gè)例子來(lái)看看字節(jié)碼到底是什么,長(zhǎng)啥樣。
public class Calculator {
private static final int offset = 100;
public int add(int x, int y) {
int z = x + y;
int m = z - offset;
return m;
}
}
這段邏輯很簡(jiǎn)單。我定義了一個(gè)Calculator類,它有一個(gè)add方法,就是傳入兩個(gè)數(shù)值參數(shù),把他們相加后再減去一個(gè)固定值offset,然后返回。
我們使用javac命令編譯后,得到如下文件(十六進(jìn)制):
圖片
這個(gè)class人肉讀起來(lái)非常費(fèi)勁。他有其固定的格式,你需要按照“說(shuō)明書”一個(gè)字節(jié)一個(gè)字節(jié)翻譯過(guò)來(lái)才行。
這里我不做詳細(xì)的介紹,看了下圖你基本上就能大致了解了。
圖片
一般我們看編譯后的文件,絕對(duì)不會(huì)直接看十六進(jìn)制的。而是使用javap命令,將其轉(zhuǎn)化為我們更容易看懂的格式。我們來(lái)執(zhí)行下命令:
javap -verbose Calculator.class
得到如下這樣的內(nèi)容:
圖片
不要著急,我來(lái)逐塊分開(kāi)和你講講。
3、字節(jié)碼的含義
我們將字節(jié)碼的內(nèi)容分為三塊來(lái)講:類信息、常量池和方法表。
1)類信息
這里面的內(nèi)容不多,也比較容易理解。
major version: 52說(shuō)明這是一個(gè)使用java8編譯出來(lái)的class。
flags:ACC_PUBLIC表示這個(gè)class是public的。ACC_SUPER在JDK1.2以后編譯出來(lái)的類都會(huì)帶上這個(gè)標(biāo)志,這和JDK1.2以后invokespecial指令的變化有關(guān)。
2)常量池
常量池中保存著非常豐富的信息。包括:方法的符號(hào)引用、類的符號(hào)引用、字段或方法描述符、各種字符常量、各種基礎(chǔ)類型字面量等等。你可以簡(jiǎn)單理解為各種你定義的類名稱、屬性名稱、方法名稱、常量以及系統(tǒng)自身需要的一些字面量,都會(huì)在這里定義。
下圖是Calculator類的常量池?cái)?shù)據(jù),我做了一些標(biāo)注,方便你的理解。
圖片
常量池的第一列是“常量類型”,一共有十幾種。每一種決定了第二列的數(shù)據(jù)格式。我這里就不詳細(xì)貼圖了。
常量池的第二列會(huì)有其他一些常量信息的引用,這些引用往往還是嵌套的。不過(guò)如圖所示,每個(gè)復(fù)雜的常量后都有注解幫你拼接好了。
3)方法表
在Calculator類中,有兩個(gè)方法。其中一個(gè)是我們定義的add方法,另一個(gè)則是默認(rèn)構(gòu)造函數(shù)。
【方法1 - 構(gòu)造函數(shù)】
我們先來(lái)看下默認(rèn)構(gòu)造函數(shù)。和上面一樣,我直接在圖中做了說(shuō)明,方便理解:
圖片
【方法2 - add函數(shù)】
在介紹add方法前,我們要先簡(jiǎn)單介紹下JVM的執(zhí)行引擎。
JVM的執(zhí)行引擎稱之為“基于棧的執(zhí)行引擎”。也就是說(shuō),所有計(jì)算的中間結(jié)果都存在一個(gè)專門的棧中。與之相對(duì)應(yīng)的就是歷史更悠久的經(jīng)典“基于寄存器的操作引擎”。
我們用一個(gè) x + y * z 的例子,來(lái)看下兩種操作引擎的差別。見(jiàn)下圖
圖片
可以看到,完全一樣的代碼,寄存器的每個(gè)指令都需要多個(gè)參數(shù),而基于棧的操作指令只需要一條。這是因?yàn)榧拇嫫餍枰付〝?shù)據(jù)從哪個(gè)寄存器中拿(cpu有十多個(gè)寄存器),但基于棧的操作指令只會(huì)涉及一個(gè)操作數(shù)棧,所以只需要聲明出?;蛘呷霔<纯伞?/p>
下面就是 x + y * z 這條命令對(duì)應(yīng)的字節(jié)碼操作過(guò)程:
圖片
介紹完這個(gè)后,我們就可以看下Calculator類中add方法的字節(jié)碼內(nèi)容了:
圖片
到這里,我們就把字節(jié)碼中主要的三塊內(nèi)容都講完了。
4、字節(jié)碼實(shí)踐:直接修改字節(jié)碼
既然我們了解了字節(jié)碼的結(jié)構(gòu),我們就要實(shí)踐一下直接定位并修改字節(jié)碼,不然的話只是紙上功夫了。
我們寫了個(gè)main函數(shù)來(lái)調(diào)用上面的Calculator類,這里把Main和Calculator類都貼一下:
public class Main {
public static void main(String[] args) {
System.out.println(new Calculator().add(1, 2));
}
}
public class Calculator {
private static final int offset = 100;
public int add(int x, int y) {
int z = x + y;
int m = z - offset;
return m;
}
}
我們編譯并運(yùn)行一下,得到如下結(jié)果:
$ javac com/codingbetterlife/justmylab/utils/*.java
$ java -cp . com.codingbetterlife.justmylab.utils.Main
-97
結(jié)果為 1 + 2 - 100 = -97
下面我來(lái)把a(bǔ)dd方法中的第7行通過(guò)字節(jié)碼改成 int m = z + offset。
圖片
修改后的class我們通過(guò)javap反編譯后能看到,命令已經(jīng)從isub變成了iadd。我們來(lái)運(yùn)行下看看結(jié)果:
$ java -cp . com.codingbetterlife.justmylab.utils.Main
103
這次變成了 1 + 2 + 100 = 103??梢钥吹?,我們并沒(méi)有重新編譯,所以你只要找對(duì)地方修改正確,就可以直接改變代碼邏輯了。
5、結(jié)尾
當(dāng)然,我并不鼓勵(lì)你去修改線上的class。你也看到了,十六進(jìn)制的代碼是非常容易改錯(cuò)的。上面這個(gè)例子更多的是幫你理解java的字節(jié)碼。
看了上面的內(nèi)容,我不知道你是否聯(lián)想到了Spring中的AOP。事實(shí)上Cglib就是通過(guò)直接修改字節(jié)碼的方式來(lái)實(shí)現(xiàn)切面的。這點(diǎn)我相信你準(zhǔn)備面試的時(shí)候肯定已經(jīng)知道了,但是通過(guò)上面的內(nèi)容,我相信你肯定有更深的理解了。
但事實(shí)上,更重要的話題是,要如何方便地修改字節(jié)碼(不直接修改十六進(jìn)制文件),這是我們下面一篇JVM系列文章會(huì)給大家介紹的內(nèi)容。
此外,這些都只是編譯過(guò)程中“耍的小花招”,如何重載一個(gè)運(yùn)行時(shí)的類是更有意思的話題。雖然現(xiàn)在很多熱部署方案存在各種各樣的問(wèn)題,從而大家都還是信賴靜態(tài)編譯后重新部署,但是了解對(duì)運(yùn)行時(shí)類的修改和重載,是極有意義的,尤其是開(kāi)發(fā)過(guò)程中。
本文轉(zhuǎn)載自微信公眾號(hào)「 CodingBetterLife」,作者「 趙志強(qiáng) 」,可以通過(guò)以下二維碼關(guān)注。
轉(zhuǎn)載本文請(qǐng)聯(lián)系「 CodingBetterLife」公眾號(hào)。