Java對象一定分配在堆上嗎?

最近在看 Java 虛擬機方面的資料,以備工作中的不時之需。首先我先拋出一個我自己想的面試題,然後再引出後面要介紹的知識點如逃逸分析、標量替換、棧上分配等知識點

面試題

Java 對象一定分配在堆上嗎?

自己先思考下,再往下閱讀效果更佳哦!

分析

我們都知道 Java 對象一般分配在堆上,而堆空間又是所有線程共享的。瞭解 NIO 庫的朋友應該知道還有一種是堆外內存也叫直接內存。直接內存是直接向操作系統申請的內存區域,訪問直接內存的速度一般會優於堆內存。直接內存的大小不直接受 Xmx 設定的值限制,但是在使用的時候也要注意,畢竟系統內存有限,堆內存和直接內存的總和依然還是會受操作系統的內存限制的。

通過上面的分析,大家也知道了,Java 對象除了可以分配在堆上,還可以直接分配在堆外內存中。但這點不是我今天想討論的,我想和大家聊聊棧上分配,說到棧上分配就不得不先說下逃逸分析

逃逸分析

逃逸分析是是一種動態確定指針動態範圍的靜態分析,它可以分析在程序的哪些地方可以訪問到指針。

換句話說,逃逸分析的目的是判斷對象的作用域是否有可能逃出方法體

判斷依據有兩個

  1. 對象是否被存入堆中(靜態字段或堆中對象的實例字段)

  2. 對象是否被傳入未知代碼中(方法的調用者和參數)

我們來分析下這兩個依據

對於第一點對象是否被存入堆中,我們知道堆內存是線程共享的,一旦對象被分配在堆中,那所有線程都可以訪問到該對象,這樣即時編譯器就追蹤不到所有使用到該對象的地方了,這樣的對象就屬於逃逸對象,如下所示

  1. public class Escape {
  2.     private static User u;
  3.     public static void alloc() {
  4.         u = new User(1"baiya");
  5.     }
  6. }

User 對象屬於類 Escape 的成員變量,該對象是可能被所有線程訪問的,所以會發生逃逸

第二點是對象是否被傳入未知代碼中,Java 的即時編譯器是以方法爲單位進行編譯,即時編譯器會把方法中未被內聯的方法當成未知代碼,所以無法判斷這個未知方法的方法調用會不會將調用者或參數放到堆中,所以認爲方法的調用者和參數是逃逸的,如下所示

  1. public class Escape {
  2.     private static User u; 
  3.     public static void alloc(User user) {
  4.         u = user;
  5.     }
  6. }

方法 alloc 的參數 user 被賦值給類 Escape 的成員變量 u,所以也會被所有線程訪問,也是會發生逃逸的。

棧上分配

棧上分配是 Java 虛擬機提供的一種優化技術,該技術的基本思想是可以將線程私有的對象打散,分配到棧上,而非堆上。那分配到棧上有什麼好處呢?
我們知道棧中的變量會在方法調用結束後自動銷燬,所以省掉了 jvm 進行垃圾回收,進而可以提高系統的性能

棧上分配是要基於逃逸分析標量替換實現的

我們通過一個具體的例子來驗證下非逃逸分析的對象確實是分配到了棧上

  1. public class OnStack {
  2.     public static void alloc() {
  3.         User user = new User(1"baiya");
  4.     }
  5.     public static void main(String[] args) {
  6.         long start = Instant.now().toEpochMilli();
  7.         for (int i = 0; i < 100_000_000; i++) {
  8.             alloc();
  9.         }
  10.         long end = Instant.now().toEpochMilli();
  11.         System.out.println("耗時:" + (end - start));
  12.     }
  13. }

上面的代碼是循環 1 億次執行 alloc 方法創建 User 對象,每個 User 對象佔用約 16 bytes(怎麼計算的下面會說) 空間,創建 1 億次,所以如果 User 都是在堆上分配的話則需要 1.5G 的內存空間。如果我們設置堆空間小於這個數,應該會發生 gc,如果設置的特別小,應該會發生大量的 gc。

我們用下面的參數執行上述代碼

-server -Xmx10m -Xms10m -XX:+DoEscapeAnalysis  -XX:+PrintGCDetails -XX:+EliminateAllocations

其中 -server 是開啓 server 模式,逃逸分析需要 server 模式的支持

-Xmx10 -Xms10m,設置堆內存是 10m,遠小於 1.5G

-XX:+DoEscapeAnalysis 開啓逃逸分析

-XX:+PrintGCDetails 如果發生 gc,打印 gc 日誌

-XX:+EliminateAllocations 開啓標量替換,允許把對象打散分配在棧上,比如 User 對象,它有兩個屬性 id 和 name,可以把他們看成獨立的局部變量分別進行分配

配置好 jvm 參數後,執行代碼,查看結果可知執行了 3 次 gc,耗時 10 毫秒,可以推斷出 User 對象並未全部分配到堆上,而是把絕大多數分配到了棧上,分配在堆上的好處是方法結束後自動釋放對應的內存,是一種優化手段。

棧上分配

我們上面說了棧上分配依賴逃逸分析和標量替換,那麼我們可以破壞其中任意一個條件,去掉逃逸分析就可以通過 -XX:-DoEscapteAnalysis 或者關閉標量替換 -XX:-EliminateAllocations 再去執行上述代碼,觀察執行情況,發現發生了大量的 gc,並且耗時 3182 毫秒,執行時間遠遠高於上面的 10 毫秒,所以可以推測出並未執行棧上分配的優化手段

堆上分配

計算 User 對象佔用空間大小

對象由四部分構成

  1. 對象頭:記錄一個對象的實例名字、ID和實例狀態。

    普通對象佔用 8 bytes,數組佔用 12 bytes (8 bytes 的普通對象頭 +  4 bytes 的數組長度)

  2. 基本類型

    boolean,byte  佔用 1 byte

    char,short       佔用 2 bytes

    int,float            佔用 4 bytes

    long,double     佔用 8 bytes

  3. 引用類型:每個引用類型佔用 4 bytes

  4. 填充物:以 8 的倍數計算,不足 8 的倍數會自動補齊

我們上面的 User 對象有兩個屬性,一個 int 類型的 id 佔用 4 bytes,一個引用類型的 name 佔用 4bytes,在加上 8 bytes 的對象頭,正好是 16 bytes

總結

關於虛擬機的知識點還有很多而且也比較重要,如果懂對寫優質代碼、優化性能、排查問題等都是錦上添花,比如逃逸分析,即時編譯器會根據逃逸分析的結果進行優化,如鎖消除以及標量替換。感興趣的朋友可以自己查查資料學習下。通過這個棧上分配的例子,以後我們寫代碼時,把可以不逃逸的對象寫進方法體中,這樣就會被編譯器優化,提升性能。而且也知道了上面面試題的答案,就是 Java 中的對象並不一定分配在堆上,也可能分配在棧上

參考資料

  1. 《實戰Java虛擬機》

  2. 《深入理解Java虛擬機》

  3. https://zh.wikipedia.org/wiki/%E9%80%83%E9%80%B8%E5%88%86%E6%9E%90

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章