(點選上方公眾號,可快速關註)
來源:Ruheng,
blog.saymagic.cn/2017/07/01/class-common-question.html
日常工作中,我們直接接觸Class檔案的時間可能不多,但這不代表瞭解了Class檔案就用處不大。本文將試圖回答三個問題,Class檔案中字串的最大長度是多少、Java存在尾遞迴呼叫最佳化嗎?、類的初始化順序是怎樣的?。與直接給出答案不同,我們試圖從Class檔案中找出這個答案背後的道理。我們一一來看一下。
Class檔案中字串的最大長度是多少?
在上篇文章中我們提到,在class檔案中,字串是被儲存在常量池中,更進一步來講,它使用一種UTF-8格式的變體來儲存一個常量字元,其儲存結構如下:
CONSTANT_Utf8_info {
u1 tag;//值為CONSTANT_Utf8_info(1)
u2 length;//位元組的長度
u1 bytes[length]//內容
}
可以看到CONSTANT_Utf8_info中使用了u2型別來表示長度,當我最開始接觸到這裡的時候,就在想一個問題,如果我宣告了一個超過u2長度(65536)的字串,是不是就無法編譯了。我們來做個實現。
字串太長就不貼出來,直接貼出在終端上使用javac命令編譯後的結果:
果然,編譯報錯了,看來class檔案的確無法儲存超過65536位元組的字串。
如果事情到這裡為止,並沒有太大意思了,但後來我發現了一個有趣的事情。下麵的這段程式碼在eclipse中是可以編譯過的:
public class LongString {
public static void main(String[] args){
String s = a long long string…;
System.out.println(s);
}
}
這不科學,更不符合我們的認知。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.”
“:(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!
但是,如果我們不是在函式中宣告了一個巨長的字串,而是在類中直接宣告:
public class LongString {
public static final String STR = a long long string…;
}
Eclipse會直接進行錯誤提示:
具體關於在上面兩個字串的初始化時機我們會在第三點裡進行闡述,但理論上在類中直接宣告也是可以像在普通函式中一樣進行最佳化。具體的原因我們就不得而知了。不過這提醒我們的是在Class檔案中,和字串長度類似的還有類中繼承介面的個數、方法數、欄位數等等,它們都是存在個數由上限的。
Java存在尾遞迴呼叫最佳化嗎?
回答這個問題之前,我們需要瞭解什麼是尾遞迴呢?借用維基百科中的回答:
-
呼叫自身函式(Self-called);
-
計算僅佔用常量棧空間(Stack Space)
用更容易理解的話來講,尾遞迴呼叫就是函式最後的陳述句是呼叫自身,但呼叫自己的時候,已經不再需要上一個函式的環境了。所以並非所有的遞迴都屬於尾遞迴,它需要透過上述的規則來編寫遞迴程式碼。和普通的遞迴相比,尾遞迴即使遞迴呼叫數萬次,它的函式棧也僅為常數,不會出現Stack Overflow異常。
那麼java中存在尾遞迴最佳化嗎?這個回答現在是否定的,到目前的Java8為止,Java仍然是不支援尾遞迴的。
但最近class家族的一位成員kotlin是號稱支援尾遞迴呼叫的,那麼它是怎麼實現的呢?我們透過遞迴實現一個功能來對比Java 與Kotlin之間生成的位元組碼的差別。
我們來實現一個對兩個整數的開區間內所有整數求和的功能。函式宣告如下:
int sum(int start, int end , int acc)
引數start為起始值,引數end為結束值,引數acc為累加值(呼叫時傳入0,用於遞迴使用)。如sum(2,4,0)會傳回9。我們分別用Java 與Kotlin來實現這個函式。
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函式進行分析:
Java生成的sum函式位元組碼如下:
我們提取主要資訊,在第14個命令上,sum函式又遞迴的呼叫了sum函式自己。此時,還沒有呼叫到第17條命令ireturn來退出函式,所以,函式棧會進行累加,如果遞迴次數過多,就難免不會發生Stack Overflow異常了。
我們再來看一下Kotlin中sum函式的位元組碼是怎樣的:
可以看到,在上面的sum函式中並沒有存在對sum自身的呼叫,而取而代之的是,是第17條的goto命令。所以,Kotlin尾遞迴背後的黑魔法就是將遞迴改成迴圈結構。上面的程式碼翻譯成我們容易理解的程式碼就是如下形式:
public int sum(int start, int end , int acc){
for(;;){
if(start > end){
return acc;
}else{
acc = start + acc;
start = start + 1;
}
}
}
透過上述的分析我們可以看到,遞迴是透過轉化為迴圈來降低記憶體的佔用。但這並不意味著寫遞迴就是很差的程式設計習慣。在Java這種面向物件的語言中我們更傾向於將遞迴改成迴圈,而在Haskell這類函式式程式語言中是將迴圈都改為了遞迴。在思想上並沒有優劣之分,只是解決問題的思維上的差異而已,具體表現就是落實到具體語言上對這兩種方法的支援程度不同而已(Java沒有尾遞迴,Haskell沒有for、while迴圈)。
類的初始化順序是怎樣的?
這個問題對於正在找工作的人可能比較有感覺,起碼當時我在畢業準備面試題時就遇到了這個問題,並且也機械的記憶了答案。不過我們更期待的是這個答案背後的理論依據是什麼。我們嘗試從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();
}
}
它執行後的結果是什麼呢?結果如下:
我們來一一來看一下它的class檔案中的內容,首先是有一個static方法區:
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的程式碼塊收集到一起,形成一個特殊的方法,這個方法的名字叫做
在Class檔案中,是沒有為普通方法區開闢類似於
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.”
“:()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
透過分析建構式,我們就可以對一個實體初始化的順序一清二楚,首先,0,1在建構式中呼叫了父類的建構式,接著,4、5、6、9為成員變數進行賦值,25、27在執行實體的程式碼塊,最後,33、35才是執行我們Java檔案中編寫的建構式的程式碼。這樣,一個普通類的初始化順序大致如下:
靜態程式碼按照順序初始化 -> 父類建構式 -> 變數初始化 -> 實體程式碼塊 -> 自身建構式
看完本文有收穫?請轉發分享給更多人
關註「ImportNew」,提升Java技能