歡迎光臨
每天分享高質量文章

幾個關於 Class 檔案的問題分析

(點選上方公眾號,可快速關註)


來源: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的程式碼塊收集到一起,形成一個特殊的方法,這個方法的名字叫做, 這個名字容易讓我們聯想到建構式的名稱叫做,但與建構式不同,這個方法在Java層中是呼叫不到的,並且,這個函式是在這個類被載入時,由虛擬機器進行呼叫。註意的是,是類被載入,而不是類被初始化成實體。所以,靜態程式碼塊的載入優先於普通的程式碼塊,也優先於建構式。這屬於虛擬機器規定的範疇,我們不做更深入的探討。

在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技能

贊(0)

分享創造快樂