日常工作中,我們直接接觸Class文件的時間可能不多执赡,但這不代表了解了Class文件就用處不大镰踏。本文將試圖回答三個問題,Class文件中字符串的最大長度是多少搀玖、Java存在尾遞歸調(diào)用優(yōu)化嗎余境?、類的初始化順序是怎樣的灌诅?芳来。與直接給出答案不同,我們試圖從Class文件中找出這個答案背后的道理猜拾。我們一一來看一下即舌。
Class文件中字符串的最大長度是多少?
在class文件中挎袜,字符串是被存儲在常量池中顽聂,更進一步來講肥惭,它使用一種UTF-8格式的變體來存儲一個常量字符,其存儲結(jié)構(gòu)如下:
CONSTANT_Utf8_info {
u1 tag;//值為CONSTANT_Utf8_info(1)
u2 length;//字節(jié)的長度
u1 bytes[length]//內(nèi)容
}
可以看到CONSTANT_Utf8_info中使用了u2類型來表示長度紊搪,當(dāng)我最開始接觸到這里的時候蜜葱,就在想一個問題,如果我聲明了一個超過u2長度(65536)的字符串耀石,是不是就無法編譯了牵囤。我們來做個實現(xiàn)。
字符串太長就不貼出來滞伟,直接貼出在終端上使用javac命令編譯后的結(jié)果:
果然揭鳞,編譯報錯了,看來class文件的確無法存儲超過65536字節(jié)的字符串梆奈。
如果事情到這里為止野崇,并沒有太大意思了,但后來我發(fā)現(xiàn)了一個有趣的事情亩钟。下面的這段代碼在eclipse中是可以編譯過的:
public class LongString {
public static void main(String[] args){
String s = a long long string...;
System.out.println(s);
}
}
這不科學(xué)乓梨,更不符合我們的認(rèn)知。eclipse搞了什么名堂径荔?我們拖出class文件看一看:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: new #16 // class java/lang/StringBuilder
3: dup
4: ldc #18
6: invokespecial #20 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: ldc #23 // String
11: invokevirtual #25 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: invokevirtual #29 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
17: invokevirtual #33 // Method java/lang/String.intern:()Ljava/lang/String;
20: astore_1
21: getstatic #38 // Field java/lang/System.out:Ljava/io/PrintStream;
24: aload_1
25: invokevirtual #44 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
28: return
LineNumberTable:
line 10: 0
line 3212: 21
line 3213: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 args [Ljava/lang/String;
21 8 1 STR Ljava/lang/String;
可以看到督禽,上面的超長字符串被eclipse截成兩半,#18和#23总处, 然后通過StringBuilder拼接成完整的字符串狈惫。awesome!
但是鹦马,如果我們不是在函數(shù)中聲明了一個巨長的字符串胧谈,而是在類中直接聲明:
public class LongString {
public static final String STR = a long long string...;
}
Eclipse會直接進行錯誤提示:
具體關(guān)于在上面兩個字符串的初始化時機我們會在第三點里進行闡述,但理論上在類中直接聲明也是可以像在普通函數(shù)中一樣進行優(yōu)化荸频。具體的原因我們就不得而知了菱肖。不過這提醒我們的是在Class文件中,和字符串長度類似的還有類中繼承接口的個數(shù)旭从、方法數(shù)稳强、字段數(shù)等等,它們都是存在個數(shù)由上限的和悦。
Java存在尾遞歸調(diào)用優(yōu)化嗎退疫?
回答這個問題之前,我們需要了解什么是尾遞歸呢鸽素?借用維基百科中的回答:
- 調(diào)用自身函數(shù)(Self-called)褒繁;
- 計算僅占用常量棧空間(Stack Space)
用更容易理解的話來講馍忽,尾遞歸調(diào)用就是函數(shù)最后的語句是調(diào)用自身棒坏,但調(diào)用自己的時候燕差,已經(jīng)不再需要上一個函數(shù)的環(huán)境了。所以并非所有的遞歸都屬于尾遞歸坝冕,它需要通過上述的規(guī)則來編寫遞歸代碼徒探。和普通的遞歸相比,尾遞歸即使遞歸調(diào)用數(shù)萬次喂窟,它的函數(shù)棧也僅為常數(shù)刹帕,不會出現(xiàn)Stack Overflow
異常。
那么java中存在尾遞歸優(yōu)化嗎谎替?這個回答現(xiàn)在是否定的,到目前的Java8為止蹋辅,Java仍然是不支持尾遞歸的钱贯。
但最近class家族的一位成員kotlin
是號稱支持尾遞歸調(diào)用的,那么它是怎么實現(xiàn)的呢侦另?我們通過遞歸實現(xiàn)一個功能來對比Java
與Kotlin
之間生成的字節(jié)碼的差別秩命。
我們來實現(xiàn)一個對兩個整數(shù)的開區(qū)間內(nèi)所有整數(shù)求和的功能。函數(shù)聲明如下:
int sum(int start, int end , int acc)
參數(shù)start為起始值褒傅,參數(shù)end為結(jié)束值弃锐,參數(shù)acc為累加值(調(diào)用時傳入0,用于遞歸使用)殿托。如sum(2,4,0)會返回9霹菊。我們分別用Java
與Kotlin
來實現(xiàn)這個函數(shù)。
Java:
public static int sum(int start, int end , int acc){
if(start > end){
return acc;
}else{
return sum(start + 1, end, start + acc);
}
}
Koklin:
tailrec fun sum(start: Int, end: Int, acc: Int): Int{
if (start > end){
return acc
} else{
return sum(start+1, end, start + acc)
}
}
我們對這兩個文件編譯生成的class文件中的sum函數(shù)進行分析:
Java生成的sum函數(shù)字節(jié)碼如下:
我們提取主要信息支竹,在第14個命令上旋廷,sum函數(shù)又遞歸的調(diào)用了sum函數(shù)自己。此時礼搁,還沒有調(diào)用到第17條命令ireturn來退出函數(shù)饶碘,所以,函數(shù)棧會進行累加馒吴,如果遞歸次數(shù)過多扎运,就難免不會發(fā)生Stack Overflow
異常了。
我們再來看一下Kotlin
中sum函數(shù)的字節(jié)碼是怎樣的:
可以看到饮戳,在上面的sum函數(shù)中并沒有存在對sum自身的調(diào)用豪治,而取而代之的是,是第17條的goto命令莹捡。所以鬼吵,Kotlin
尾遞歸背后的黑魔法就是將遞歸改成循環(huán)結(jié)構(gòu)。上面的代碼翻譯成我們?nèi)菀桌斫獾拇a就是如下形式:
public int sum(int start, int end , int acc){
for(;;){
if(start > end){
return acc;
}else{
acc = start + acc;
start = start + 1;
}
}
}
通過上述的分析我們可以看到篮赢,遞歸是通過轉(zhuǎn)化為循環(huán)來降低內(nèi)存的占用齿椅。但這并不意味著寫遞歸就是很差的編程習(xí)慣琉挖。在Java
這種面向?qū)ο蟮恼Z言中我們更傾向于將遞歸改成循環(huán),而在Haskell
這類函數(shù)式編程語言中是將循環(huán)都改為了遞歸涣脚。在思想上并沒有優(yōu)劣之分示辈,只是解決問題的思維上的差異而已,具體表現(xiàn)就是落實到具體語言上對這兩種方法的支持程度不同而已(Java沒有尾遞歸遣蚀,Haskell沒有for矾麻、while循環(huán))。
類的初始化順序是怎樣的芭梯?
這個問題對于正在找工作的人可能比較有感覺险耀,起碼當(dāng)時我在畢業(yè)準(zhǔn)備面試題時就遇到了這個問題,并且也機械的記憶了答案玖喘。不過我們更期待的是這個答案背后的理論依據(jù)是什么甩牺。我們嘗試從class文件中找到答案。來看這樣的一段代碼:
public class InitialOrderTest {
public static String staticField = " StaticField";
public String fieldFromMethod = getStrFromMethod();
public String fieldFromInit = " InitField";
static {
System.out.println( "Call Init Static Code" );
System.out.println( staticField );
}
{
System.out.println( "Call Init Block Code" );
System.out.println( fieldFromInit );
System.out.println( fieldFromMethod );
}
public InitialOrderTest()
{
System.out.println( "Call Constructor" );
}
public String getStrFromMethod(){
System.out.println("Call getStrFromMethod Method");
return " MethodField" ;
}
public static void main( String[] args )
{
new InitialOrderTest();
}
}
它運行后的結(jié)果是什么呢累奈?結(jié)果如下:
我們來一一來看一下它的class文件中的內(nèi)容贬派,首先是有一個static方法區(qū):
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: ldc #14 // String StaticField
2: putstatic #15 // Field staticField:Ljava/lang/String;
5: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #16 // String Call Init Static Code
10: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
16: getstatic #15 // Field staticField:Ljava/lang/String;
19: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
22: return
Java編譯器在編譯階段會將所有static的代碼塊收集到一起,形成一個特殊的方法澎媒,這個方法的名字叫做<clinit>
, 這個名字容易讓我們聯(lián)想到構(gòu)造函數(shù)的名稱叫做<init>
搞乏,但與構(gòu)造函數(shù)不同,這個方法在Java層中是調(diào)用不到的戒努,并且请敦,這個函數(shù)是在這個類被加載時,由虛擬機進行調(diào)用储玫。注意的是冬三,是類被加載,而不是類被初始化成實例缘缚。所以勾笆,靜態(tài)代碼塊的加載優(yōu)先于普通的代碼塊,也優(yōu)先于構(gòu)造函數(shù)桥滨。這屬于虛擬機規(guī)定的范疇窝爪,我們不做更深入的探討。
在Class文件中齐媒,是沒有為普通方法區(qū)開辟類似于<clinit>
這種方法的蒲每,而是將所有普通方法區(qū)的代碼都合并到了構(gòu)造函數(shù)中,我們直接來看構(gòu)造函數(shù):
public InitialOrderTest();
descriptor: ()V
flags: 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: aload_0
6: invokevirtual #2 // Method getStr:()Ljava/lang/String;
9: putfield #3 // Field field:Ljava/lang/String;
12: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
15: aload_0
16: getfield #3 // Field field:Ljava/lang/String;
19: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
22: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
25: ldc #6 // String Init Block
27: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
33: ldc #7 // String Constructor
35: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
38: return
通過分析構(gòu)造函數(shù)喻括,我們就可以對一個實例初始化的順序一清二楚邀杏,首先,0,1在構(gòu)造函數(shù)中調(diào)用了父類的構(gòu)造函數(shù)望蜡,接著唤崭,4、5脖律、6谢肾、9為成員變量進行賦值,25小泉、27在執(zhí)行實例的代碼塊芦疏,最后,33微姊、35才是執(zhí)行我們Java文件中編寫的構(gòu)造函數(shù)的代碼酸茴。這樣,一個普通類的初始化順序大致如下:
靜態(tài)代碼按照順序初始化 -> 父類構(gòu)造函數(shù) -> 變量初始化 -> 實例代碼塊 -> 自身構(gòu)造函數(shù)