Java是否可以棧上分配物件記憶體? 為什麼?

鹹魚不思議發表於2019-03-02

  在說java的物件分配記憶體所在位置前,我們先來看看C++的物件分配是怎樣的。
C++例項化物件的方式有兩種:

  • 直接定義物件,物件被分配在方法棧的本地變數棧上,生命週期與方法棧一致,方法退出時物件被自動銷燬。
  • 通過new關鍵字在堆上分配物件,物件要使用者手動銷燬。
#include <iostream>
using namespace std;

class ClassA {
private:
    int arg;
public:
     ClassA(int a): arg(a) {
         cout << "ClassA(" << arg << ")" << endl;
    }

    ~ClassA(){
         cout << "~ClassA(" << arg << ")" << endl;
    }
};

int main() {
    ClassA ca1(1); //直接定義物件
    ClassA* ca2 = new ClassA(2); //使用new關鍵字
    return 0;
}
複製程式碼

輸出結果:

ClassA(1)
ClassA(2)
~ClassA(1)
複製程式碼

  直接定義物件的方式會將物件記憶體分配在棧上,因此main函式退出後會執行ClassA的虛構函式,該物件被回收。而使用new例項化的物件記憶體分配在堆上,物件在main函式退出後不會執行虛構函式。
  C++中,記憶體可以被分配到棧上或者堆記憶體中。
  那麼java是否也是這樣呢,如果java在必要的時候也是把物件分配到棧上,從而自動銷燬物件,那必然能減少一些垃圾回收的開銷(java的垃圾回收需要進行標記整理等一系列耗時操作),同時也能提高執行效率(棧上儲存的資料有很大的概率會被虛擬機器分配至物理機器的高速暫存器中儲存)。雖然,這些細節都是針對JVM而言的,對於開發者而言似乎不太需要關心。
  然而,我還是很好奇。

寫一段不怎麼靠譜的程式碼來觀察Java的輸出結果:

public class ClassA{
     public int arg;
     public ClassA(int arg) {
         this.arg = arg;
     }

     @Override
     protected void finalize() throws Throwable {
         System.out.println("物件即將被銷燬: " + this + "; arg = " + arg);
         super.finalize();
     }
 }
 
 
 public class TestCase1 {
     public static ClassA getClassA(int arg) {
         ClassA a = new ClassA(arg);
         System.out.println("getA() 方法內:" + a);
         return a;
     }
 
     public static void foo() {
         ClassA a = new ClassA(2);
         System.out.println("foo() 方法內:" + a);
     }
 
 
     public static void main(String[] args) {
         ClassA classA = getClassA(1);
         System.out.println("main() 方法內:" + classA);
 
         foo();
     }
 
 }
複製程式碼

輸出結果:

getA() 方法內:com.rhythm7.A@29453f44
main() 方法內:com.rhythm7.A@29453f44
foo() 方法內:com.rhythm7.A@5cad8086
複製程式碼

  執行完getA()方法後,getA()方法內例項化的classA物件例項a被返回並賦值給main方法內的classA。
接著執行foo()方法,方法內部例項化一個classA物件,但只是輸出其HashCode,沒有返回其物件。
  結果是兩個物件都沒有執行finalize()方法。
  如果我們強制使用System.gc()來通知系統進行垃圾回收,結果如何?

public static void main(String[] args) {
    A a = getA(1);
    System.out.println("main() 方法內:" + a);
    foo();
    System.gc();
}
複製程式碼

輸出結果

getA() 方法內:com.rhythm7.A@29453f44
main() 方法內:com.rhythm7.A@29453f44
foo() 方法內:com.rhythm7.A@5cad8086
物件即將被銷燬: com.rhythm7.A@5cad8086; arg = 2
複製程式碼

  這說明,需要通知垃圾回收器進行進行垃圾回收才能回收方法foo()內例項化的物件。
所以,可以肯定foo()內例項化的物件不會跟隨foo()方法的出棧而銷燬,也就是foo()方法內例項化的區域性物件不會是分配在棧上的。

查閱相關資料,發現JVM的確存在一個 “逃逸分析” 的概念。
內容大概如下:
  逃逸分析是目前Java虛擬機器中比較前沿的優化技術,它並不是直接優化程式碼的手段,而是為其他優化手段提供依據的分析技術。
逃逸分析的主要作用就是分析物件作用域。
  當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他方法中,這種行為就叫做 方法逃逸。甚至該物件還可能被外部執行緒訪問到,例如賦值被類變數或可以在其他執行緒中訪問的例項變數,稱為 執行緒逃逸
  通過逃逸分析技術可以判斷一個物件不會逃逸到方法或者執行緒之外。根據這一特點,就可以讓這個物件在棧上分配記憶體,物件所佔用的記憶體空間就可以隨幀棧出棧而銷燬。在一般應用中,不會逃逸的區域性物件所佔比例很大,如果能使用棧上分配,那麼大量的物件就會隨著方法的結束而自動銷燬了,垃圾收集系統的壓力就會小很多。
  除此之外,逃逸分析的作用還包括 標量替換同步消除 ;
   標量替換 指:若一個物件被證明不會被外部訪問,並且這個物件可以被拆解成若干個基本型別的形式,那麼當程式真正執行的時候可以不建立這個物件,而是採用直接建立它的若干個被這個方法所使用到的成員變數來代替,將物件拆分後,除了可以讓物件的成員變數在棧上分配和讀寫之外,還可以為後續進一步的優化手段創造條件。
   同步消除 指:若一個變數被證明不會逃逸出執行緒,那麼這個變數的讀寫就肯定不會出現競爭的情況,那麼對這個變數實施的同步措施也就可以消除掉。
   說了逃逸分析的這些作用,那麼Java虛擬機器是否有對物件做逃逸分析呢?

  答案是否。

  關於逃逸分析的論文在1999年就已經發表,但直到Sun JDK 1.6才實現了逃逸分析,而且直到現在這項優化尚未足夠成熟,仍有很大的改進餘地。不成熟的原因主要是不能保證逃逸分析的效能收益必定高於它的消耗。因為逃逸分析本身就是一個高耗時的過程,假如分析的結果是沒有幾個不逃逸的物件,那麼這個分析所花費時候比優化所減少的時間更長,這是得不償失的。
  所以目前虛擬機器只能採用不那麼準確,但時間壓力相對較小的演算法來完成逃逸分析。還有一點是,基於逃逸分析的一些優化手段,如上面提到的“棧上分配”,由於HotSpot虛擬機器目前的實現方式導致棧上分配實現起來比較複雜,因此在HotSpot中暫時還沒有做這項優化
事實上,在java虛擬機器中,有一句話是這麼寫的:

The heap is the runtime data area from which memory for all class instances and arrays is allocated。
堆是所有的物件例項以及陣列分配記憶體的執行時資料區域。

  所以,忘掉Java棧上分配物件記憶體的想法吧,至少在目前的HotSpot中是不存在的。也就是說Java的物件分配只在堆上。

PS: 如果有需要,並且確認對程式執行有益,使用者可以使用引數-XX:+DoEscapeAnalysis來手動開啟逃逸分析,開啟之後可以通過引數-XX:+PrintEscapeAnalysis來檢視分析結果。

相關文章