大家好佛点,我是那個永遠(yuǎn) 18 歲的老妖怪~噓
從《JVM 內(nèi)存區(qū)域劃分》這篇文章中商乎,大家應(yīng)該 get 到了鞭莽,Java 虛擬機內(nèi)存區(qū)域可以劃分為程序計數(shù)器浅缸、Java 虛擬機棧软吐、本地方法棧和堆现喳。今天呛梆,我們來圍繞其中的一個區(qū)域——Java 虛擬機棧锐涯,深入地展開下。
先說明一下哈填物。這篇文章的標(biāo)題里帶了一個“攜程面試官”纹腌,有標(biāo)題黨的嫌疑。但有一說一滞磺,確實有讀者在上一篇文章里留言說升薯,攜程面試官問他了 Java 虛擬機內(nèi)存方面的知識點,所以今天的標(biāo)題我就“借題發(fā)揮”了击困。
從“相見恨晚”這個詞中涎劈,我估摸著這名讀者在這道面試題前面折戟沉沙了。這么說吧,面試官確實喜歡問 Java 虛擬機方面的知識點责语,因為很能考察出一名應(yīng)聘者的真實功底炮障,所以我打算多寫幾篇這方面的文章,希望能給大家多一點點幫助~
Java 虛擬機以方法作為基本的執(zhí)行單元坤候,“棧幀(Stack Frame)”則是用于支持 Java 虛擬機進(jìn)行方法調(diào)用和方法執(zhí)行的基本數(shù)據(jù)結(jié)構(gòu)胁赢。每一個棧幀中都包含了局部變量表、操作數(shù)棧白筹、動態(tài)鏈接智末、方法返回地址和一些額外的附加信息(比如與調(diào)試、性能手機相關(guān)的信息)徒河。之前的文章里有提到過這些概念系馆,并做了一些簡單扼要的介紹,但我覺得還不夠詳細(xì)顽照,所以這篇重點要來介紹一下棧幀中的這些概念由蘑。
1)局部變量表
局部變量表(Local Variables Table)用來保存方法中的局部變量,以及方法參數(shù)代兵。當(dāng) Java 源代碼文件被編譯成 class 文件的時候尼酿,局部變量表的最大容量就已經(jīng)確定了。
我們來看這樣一段代碼植影。
public class LocalVaraiablesTable {
private void write(int age) {
String name = "沉默王二";
}
}
write()
方法有一個參數(shù) age裳擎,一個局部變量 name。
然后用 Intellij IDEA 的 jclasslib 查看一下編譯后的字節(jié)碼文件 LocalVaraiablesTable.class思币÷瓜欤可以看到 write()
方法的 Code 屬性中,Maximum local variables(局部變量表的最大容量)的值為 3谷饿。
按理說惶我,局部變量表的最大容量應(yīng)該為 2 才對,一個 age博投,一個 name绸贡,為什么是 3 呢?
當(dāng)一個成員方法(非靜態(tài)方法)被調(diào)用時贬堵,第 0 個變量其實是調(diào)用這個成員方法的對象引用恃轩,也就是那個大名鼎鼎的 this。調(diào)用方法 write(18)
黎做,實際上是調(diào)用 write(this, 18)
叉跛。
點開 Code 屬性,查看 LocalVaraiableTable 就可以看到詳細(xì)的信息了蒸殿。
第 0 個是 this筷厘,類型為 LocalVaraiablesTable 對象鸣峭;第 1 個是方法參數(shù) age,類型為整形 int酥艳;第 2 個是方法內(nèi)部的局部變量 name摊溶,類型為字符串 String。
當(dāng)然了充石,局部變量表的大小并不是方法中所有局部變量的數(shù)量之和莫换,它與變量的類型和變量的作用域有關(guān)。當(dāng)一個局部變量的作用域結(jié)束了骤铃,它占用的局部變量表中的位置就被接下來的局部變量取代了拉岁。
來看下面這段代碼。
public static void method() {
// ①
if (true) {
// ②
String name = "沉默王二";
}
// ③
if(true) {
// ④
int age = 18;
}
// ⑤
}
-
method()
方法的局部變量表大小為 1惰爬,因為是靜態(tài)方法喊暖,所以不需要添加 this 作為局部變量表的第一個元素; - ②的時候局部變量有一個 name撕瞧,局部變量表的大小變?yōu)?1陵叽;
- ③的時候 name 變量的作用域結(jié)束;
- ④的時候局部變量有一個 age丛版,局部變量表的大小為 1巩掺;
- ⑤的時候局 age 變量的作用域結(jié)束;
關(guān)于局部變量的作用域硼婿,《Effective Java》 中的第 57 條建議:
將局部變量的作用域最小化锌半,可以增強代碼的可讀性和可維護(hù)性禽车,并降低出錯的可能性寇漫。
在此,我還有一點要提醒大家殉摔。為了盡可能節(jié)省棧幀耗用的內(nèi)存空間州胳,局部變量表中的槽是可以重用的,就像 method()
方法演示的那樣逸月,這就意味著栓撞,合理的作用域有助于提高程序的性能。
局部變量表的容量以槽(slot)為最小單位碗硬,一個槽可以容納一個 32 位的數(shù)據(jù)類型(比如說 int瓤湘,當(dāng)然了,《Java 虛擬機規(guī)范》中沒有明確指出一個槽應(yīng)該占用的內(nèi)存空間大小恩尾,但我認(rèn)為這樣更容易理解)弛说,像 float 和 double 這種明確占用 64 位的數(shù)據(jù)類型會占用兩個緊挨著的槽。
來看下面的代碼翰意。
public void solt() {
double d = 1.0;
int i = 1;
}
用 jclasslib 可以查看到木人,solt()
方法的 Maximum local variables 的值為 4信柿。
為什么等于 4 呢?帶上 this 也就 3 個呀醒第?
查看 LocalVaraiableTable 就明白了渔嚷,變量 i 的下標(biāo)為 3,也就意味著變量 d 占了兩個槽稠曼。
2)操作數(shù)棧
同局部變量表一樣形病,操作數(shù)棧(Operand Stack)的最大深度也在編譯的時候就確定了,被寫入到了 Code 屬性的 maximum stack size 中霞幅。當(dāng)一個方法剛開始執(zhí)行的時候窒朋,操作數(shù)棧是空的,在方法執(zhí)行過程中蝗岖,會有各種字節(jié)碼指令往操作數(shù)棧中寫入和取出數(shù)據(jù)侥猩,也就是入棧和出棧操作。
來看下面這段代碼抵赢。
public class OperandStack {
public void test() {
add(1,2);
}
private int add(int a, int b) {
return a + b;
}
}
OperandStack 類共有 2 個方法欺劳,test()
方法中調(diào)用了 add()
方法,傳遞了 2 個參數(shù)铅鲤。用 jclasslib 可以看到划提,test()
方法的 maximum stack size 的值為 3。
這是因為調(diào)用成員方法的時候會將 this 和所有參數(shù)壓入棧中邢享,調(diào)用完畢后 this 和參數(shù)都會一一出棧鹏往。通過 「Bytecode」 面板可以查看到對應(yīng)的字節(jié)碼指令。
- aload_0 用于將局部變量表中下標(biāo)為 0 的引用類型的變量骇塘,也就是 this 加載到操作數(shù)棧中伊履;
- iconst_1 用于將整數(shù) 1 加載到操作數(shù)棧中;
- iconst_2 用于將整數(shù) 2 加載到操作數(shù)棧中款违;
- invokevirtual 用于調(diào)用對象的成員方法唐瀑;
- pop 用于將棧頂?shù)闹党鰲#?/li>
- return 為 void 方法的返回指令。
再來看一下 add()
方法的字節(jié)碼指令插爹。
- iload_1 用于將局部變量表中下標(biāo)為 1 的 int 類型變量加載到操作數(shù)棧上(下標(biāo)為 0 的是 this)哄辣;
- iload_2 用于將局部變量表中下標(biāo)為 2 的 int 類型變量加載到操作數(shù)棧上;
- iadd 用于 int 類型的加法運算赠尾;
- ireturn 為返回值為 int 的方法返回指令力穗。
操作數(shù)中的數(shù)據(jù)類型必須與字節(jié)碼指令匹配,以上面的 iadd 指令為例气嫁,該指令只能用于整形數(shù)據(jù)的加法運算当窗,它在執(zhí)行的時候,棧頂?shù)膬蓚€數(shù)據(jù)必須是 int 類型的杉编,不能出現(xiàn)一個 long 型和一個 double 型的數(shù)據(jù)進(jìn)行 iadd 命令相加的情況超全。
3)動態(tài)鏈接
每個棧幀都包含了一個指向運行時常量池中該棧幀所屬方法的引用咆霜,持有這個引用是為了支持方法調(diào)用過程中的動態(tài)鏈接(Dynamic Linking)。
來看下面這段代碼嘶朱。
public class DynamicLinking {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("男人哭吧哭吧不是罪");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("山下的女人是老虎");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
大家對 Java 重寫有了解的話蛾坯,應(yīng)該能看懂這段代碼的意思。Man 類和 Woman 類繼承了 Human 類疏遏,并且重寫了 sayHello()
方法脉课。來看一下運行結(jié)果:
男人哭吧哭吧不是罪
山下的女人是老虎
山下的女人是老虎
這個運行結(jié)果很好理解,man 的引用類型為 Human财异,但指向的是 Man 對象倘零,woman 的引用類型也為 Human,但指向的是 Woman 對象戳寸;之后呈驶,man 又指向了新的 Woman 對象。
從面向?qū)ο缶幊痰慕嵌纫呷担瑥亩鄳B(tài)的角度袖瞻,我們對運行結(jié)果是很好理解的,但站在 Java 虛擬機的角度拆吆,它是如何判斷 man 和 woman 該調(diào)用哪個方法的呢聋迎?
用 jclasslib 看一下 main 方法的字節(jié)碼指令。
- 第 1 行:new 指令創(chuàng)建了一個 Man 對象枣耀,并將對象的內(nèi)存地址壓入棧中霉晕。
- 第 2 行:dup 指令將棧頂?shù)闹祻?fù)制一份并壓入棧頂。因為接下來的指令 invokespecial 會消耗掉一個當(dāng)前類的引用捞奕,所以需要復(fù)制一份牺堰。
- 第 3 行:invokespecial 指令用于調(diào)用構(gòu)造方法進(jìn)行初始化。
- 第 4 行:astore_1缝彬,Java 虛擬機從棧頂彈出 Man 對象的引用萌焰,然后將其存入下標(biāo)為 1 局部變量 man 中哺眯。
- 第 5谷浅、6、7奶卓、8 行的指令和第 1一疯、2、3夺姑、4 行類似墩邀,不同的是 Woman 對象。
- 第 9 行:aload_1 指令將第局部變量 man 壓入操作數(shù)棧中盏浙。
- 第 10 行:invokevirtual 指令調(diào)用對象的成員方法
sayHello()
眉睹,注意此時的對象類型為com/itwanger/jvm/DynamicLinking$Human
荔茬。 - 第 11 行:aload_2 指令將第局部變量 woman 壓入操作數(shù)棧中。
- 第 12 行同第 10 行竹海。
注意慕蔚,從字節(jié)碼的角度來看,man.sayHello()
(第 10 行)和 woman.sayHello()
(第 12 行)的字節(jié)碼是完全相同的斋配,但我們都知道孔飒,這兩句指令最終執(zhí)行的目標(biāo)方法并不相同。
究竟發(fā)生了什么呢艰争?
還得從 invokevirtual
這個指令著手坏瞄,看它是如何實現(xiàn)多態(tài)的。根據(jù)《Java 虛擬機規(guī)范》甩卓,invokevirtual 指令在運行時的解析過程可以分為以下幾步:
①鸠匀、找到操作數(shù)棧頂?shù)脑厮赶虻膶ο蟮膶嶋H類型,記作 C逾柿。
②狮崩、如果在類型 C 中找到與常量池中的描述符匹配的方法,則進(jìn)行訪問權(quán)限校驗鹿寻,如果通過則返回這個方法的直接引用睦柴,查找結(jié)束;否則返回java.lang.IllegalAccessError
異常毡熏。
③坦敌、否則,按照繼承關(guān)系從下往上一次對 C 的各個父類進(jìn)行第二步的搜索和驗證痢法。
④狱窘、如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError
異常财搁。
也就是說蘸炸,invokevirtual 指令在第一步的時候就確定了運行時的實際類型,所以兩次調(diào)用中的 invokevirtual 指令并不是把常量池中方法的符號引用解析到直接引用上就結(jié)束了尖奔,還會根據(jù)方法接受者的實際類型來選擇方法版本搭儒,這個過程就是 Java 重寫的本質(zhì)。我們把這種在運行期根據(jù)實際類型確定方法執(zhí)行版本的過程稱為動態(tài)鏈接提茁。
4)方法返回地址
當(dāng)一個方法開始執(zhí)行后淹禾,只有兩種方式可以退出這個方法:
正常退出,可能會有返回值傳遞給上層的方法調(diào)用者茴扁,方法是否有返回值以及返回值的類型根據(jù)方法返回的指令來決定铃岔,像之前提到的 ireturn 用于返回 int 類型,return 用于 void 方法峭火;還有其他的一些毁习,lreturn 用于 long 型智嚷,freturn 用于 float,dreturn 用于 double纺且,areturn 用于引用類型纤勒。
異常退出,方法在執(zhí)行的過程中遇到了異常隆檀,并且沒有得到妥善的處理摇天,這種情況下,是不會給它的上層調(diào)用者返回任何值的恐仑。
無論是哪種方式退出泉坐,在方法退出后,都必須返回到方法最初被調(diào)用時的位置裳仆,程序才能繼續(xù)執(zhí)行腕让。一般來說,方法正常退出的時候歧斟,PC 計數(shù)器的值會作為返回地址纯丸,棧幀中很可能會保存這個計數(shù)器的值,異常退出時則不會静袖。
方法退出的過程實際上等同于把當(dāng)前棧幀出棧觉鼻,因此接下來可能執(zhí)行的操作有:恢復(fù)上層方法的局部變量表和操作數(shù)棧,把返回值(如果有的話)壓入調(diào)用者棧幀的操作數(shù)棧中队橙,調(diào)整 PC 計數(shù)器的值坠陈,找到下一條要執(zhí)行的指令等。
以上部分內(nèi)容參考自周志明老師的《深入理解 Java 虛擬機》捐康,以及好朋友張亞的《深入理解 JVM 字節(jié)碼》仇矾。強烈推薦一下這兩本書。
初學(xué)者一開始學(xué)習(xí) Java 虛擬機的時候可能會感到很枯燥解总,很難懂贮匕,但有了一定的經(jīng)驗積累后,再來學(xué)習(xí)這塊知識就會有一種開竅了的感覺花枫。當(dāng)然了刻盐,Java 虛擬機這塊的知識點是必學(xué)的,因為性能優(yōu)化乌昔、找工作面試隙疚,甚至說提高編程功底都是很亟需的。
四書之一《大學(xué)》中磕道,開篇就提到了一個概念,叫做“格物致知”行冰,意思就是通過研究事物背后的原理來獲取知識溺蕉,深入 Java 虛擬機伶丐、字節(jié)碼等背后深層次的結(jié)構(gòu)和原理來剖析 Java,能讓我們變得更自信疯特,全身彌漫出一種“技術(shù)高手”的光芒~
推薦閱讀:
好家伙哗魂!JVM 內(nèi)存區(qū)域劃分得這么灑脫
我是沉默王二漓雅,點個贊吧录别,讓我們一起成為高手,??邻吞!