Java中的對象都是在堆上分配的嗎?
- 2020 年 2 月 21 日
- 筆記

作者:LittleMagic
https://www.jianshu.com/p/8377e09971b8
為了防止歧義,可以換個說法:
Java對象實例和數組元素都是在堆上分配記憶體的嗎?
答:不一定。滿足特定條件時,它們可以在(虛擬機)棧上分配記憶體。

JVM記憶體結構很重要,多多複習
這和我們平時的理解可能有些不同。虛擬機棧一般是用來存儲基本數據類型、引用和返回地址的,怎麼可以存儲實例數據了呢?
這是因為Java JIT(just-in-time)編譯器進行的兩項優化,分別稱作逃逸分析(escape analysis)和標量替換(scalar replacement)。

注意看一下JIT的位置
中文維基上對逃逸分析的描述基本準確,摘錄如下:
在編譯程式優化理論中,逃逸分析是一種確定指針動態範圍的方法——分析在程式的哪些地方可以訪問到指針。當一個變數(或對象)在子程式中被分配時,一個指向變數的指針可能逃逸到其它執行執行緒中,或是返回到調用者子程式。
如果一個子程式分配一個對象並返回一個該對象的指針,該對象可能在程式中被訪問到的地方無法確定——這樣指針就成功「逃逸」了。如果指針存儲在全局變數或者其它數據結構中,因為全局變數是可以在當前子程式之外訪問的,此時指針也發生了逃逸。
逃逸分析確定某個指針可以存儲的所有地方,以及確定能否保證指針的生命周期只在當前進程或執行緒中。
簡單來講,JVM中的逃逸分析可以通過分析對象引用的使用範圍(即動態作用域),來決定對象是否要在堆上分配記憶體,也可以做一些其他方面的優化。
關於逃逸分析,大家可以看下這篇文章:面試問我 Java 逃逸分析,瞬間被秒殺了。以下的例子說明了一種對象逃逸的可能性。
static StringBuilder getStringBuilder1(String a, String b) { StringBuilder builder = new StringBuilder(a); builder.append(b); return builder; // builder通過方法返回值逃逸到外部 } static String getStringBuilder2(String a, String b) { StringBuilder builder = new StringBuilder(a); builder.append(b); return builder.toString(); // builder範圍維持在方法內部,未逃逸 }以JDK 1.8為例,可以通過設置JVM參數-XX:+DoEscapeAnalysis、-XX:-DoEscapeAnalysis來開啟或關閉逃逸分析(默認當然是開啟的)。
下面先寫一個沒有對象逃逸的例子。
public class EscapeAnalysisTest { public static void main(String[] args) throws Exception { long start = System.currentTimeMillis(); for (int i = 0; i < 5000000; i++) { allocate(); } System.out.println((System.currentTimeMillis() - start) + " ms"); Thread.sleep(600000); } static void allocate() { MyObject myObject = new MyObject(2019, 2019.0); } static class MyObject { int a; double b; MyObject(int a, double b) { this.a = a; this.b = b; } } }然後通過開啟和關閉DoEscapeAnalysis開關觀察不同。
關閉逃逸分析
~ java -XX:-DoEscapeAnalysis EscapeAnalysisTest 76 ms ~ jmap -histo 26031 num #instances #bytes class name ---------------------------------------------- 1: 5000000 120000000 me.lmagics.EscapeAnalysisTest$MyObject 2: 636 12026792 [I 3: 3097 1524856 [B 4: 5088 759960 [C 5: 3067 73608 java.lang.String 6: 623 71016 java.lang.Class 7: 727 43248 [Ljava.lang.Object; 8: 532 17024 java.io.File 9: 225 14400 java.net.URL 10: 334 13360 java.lang.ref.Finalizer # ......
開啟逃逸分析
~ java -XX:+DoEscapeAnalysis EscapeAnalysisTest 4 ms ~ jmap -histo 26655 num #instances #bytes class name ---------------------------------------------- 1: 592 11273384 [I 2: 90871 2180904 me.lmagics.EscapeAnalysisTest$MyObject 3: 3097 1524856 [B 4: 5088 759952 [C 5: 3067 73608 java.lang.String 6: 623 71016 java.lang.Class 7: 727 43248 [Ljava.lang.Object; 8: 532 17024 java.io.File 9: 225 14400 java.net.URL 10: 334 13360 java.lang.ref.Finalizer # ......
可見,關閉逃逸分析之後,堆上有5000000個MyObject實例,而開啟逃逸分析之後,就只剩下90871個實例了,不管是實例數還是記憶體佔用都只有原來的2%不到。
另外,如果把堆記憶體限制得小一點(比如加上-Xms10m -Xmx10m),並且列印GC日誌(-XX:+PrintGCDetails)的話,關閉逃逸分析還會造成頻繁的GC,開啟逃逸分析就沒有這種情況。這說明逃逸分析確實降低了堆記憶體的壓力。
但是,逃逸分析只是棧上記憶體分配的前提,接下來還需要進行標量替換才能真正實現。
所謂標量,就是指JVM中無法再細分的數據,比如int、long、reference等。相對地,能夠再細分的數據叫做聚合量。
仍然考慮上面的例子,MyObject就是一個聚合量,因為它由兩個標量a、b組成。通過逃逸分析,JVM會發現myObject沒有逃逸出allocate()方法的作用域,標量替換過程就會將myObject直接拆解成a和b,也就是變成了:
static void allocate() { int a = 2019; double b = 2019.0; }可見,對象的分配完全被消滅了,而int、double都是基本數據類型,直接在棧上分配就可以了。所以,在對象不逃逸出作用域並且能夠分解為純標量表示時,對象就可以在棧上分配。
JVM提供了參數-XX:+EliminateAllocations來開啟標量替換,默認仍然是開啟的。顯然,如果把它關掉的話,就相當于禁止了棧上記憶體分配,只有逃逸分析是無法發揮作用的。
在Debug版JVM中,還可以通過參數-XX:+PrintEliminateAllocations來查看標量替換的具體情況。
除了標量替換之外,通過逃逸分析還能實現同步消除
(synchronization elision),當然它與本文的主題無關了。
舉個例子:
private void someMethod() { Object lockObject = new Object(); synchronized (lockObject) { System.out.println(lockObject.hashCode()); } }lockObject這個鎖對象的生命期只在someMethod()方法中,並不存在多執行緒訪問的問題,所以synchronized塊並無意義,會被優化掉:
private void someMethod() { Object lockObject = new Object(); System.out.println(lockObject.hashCode()); }END
