從 Java 程式碼如何運行聊到 JVM 和對象的創建-分配-定位-布局-垃圾回收

Java 程式碼到底是如何運行的呢?

看下圖理解 Java 程式碼如何運行:

概括一下:程式設計師小張編寫好的 Java 源程式碼文件經過 Java 編譯器編譯成位元組碼文件後,通過類載入器載入到記憶體中,才能被實例化,然後到 Java 虛擬機中解釋執行,最後通過作業系統操作 CPU 執行獲取結果。

具體的 javac 編譯和類載入器過程請見下圖:

本文主要介紹 JVM 記憶體模型參數設置說明對象創建過程解析、初始 GC。下面請大家進入正題吧

JVM 記憶體布局是什麼樣的呢?

簡單的說,共有 5 大塊,它們分別是堆區(Java Heap)、虛擬機棧(Virtual Machine Stacks)、本地方法棧(Native Method Stacks)、元空間(Meta Spaces)、程式計數器(Program Counter Register)。

如下圖所示(先大概了解一下各自區域都存了啥,後面會一一圖文解讀):

按執行緒的共享與私有(執行緒安全)分類:

共享區域:

  • 堆區
  • 元空間

私有區域:

  • 虛擬機棧
  • 本地方法棧
  • 程式計數器

下面從簡單的 JVM 劃分區域開始說起:

程式計數器
  • 佔用的 JVM 記憶體空間較小
  • 每個執行緒生命周期內獨享自己的程式計數器(內部存放的是位元組碼指令的地址引用)
  • 不會發生 OOM

虛擬機棧

  • 內部結構是棧幀,每個方法在執行的時候都會創建一個棧幀,用於存儲局部變數表,操作數棧,動態鏈接,方法返回地址等資訊
  • 某方法在調用另一個方法是通過動態鏈接在常量池中查詢方法的引用,進而完成方法調用
  • 某方法在調用另一個方法的過程,即是一個棧幀在虛擬機中的入棧到出棧的過程
  • 虛擬機中的方法入棧的順序和方法的調用順序是一致的

詳細情況請查看下圖,一目了然:

對於 JVM 中虛擬機棧參數的設置

-Xss :用於設置棧的大小,棧的大小決定了方法調用的深度。

# 設置執行緒棧大小為 512k(以位元組為單位)  -Xss512k

該區域可能出現 StackOverflowException 棧溢出異常。

本地方法棧
  • 和虛擬機棧類似,內部結構是棧幀,每個 Native 方法執行時創建一個棧幀
  • 該部分沒有規定記憶體大小
堆區
  • 存放 Java 對象和數組
  • 虛擬機中存儲空間比較大的區域
  • 可能出現 OOM 異常區域
  • 該區域是 GC 的主要區域,堆區由年輕代和老年代組成,年輕代又分為 Eden 區、S0區(from survivor)、S1 區(to survivor);新生代對應 Minor GC(Young GC),老年代對應 Full GC(Old GC)。
對於 JVM 中堆區參數的設置
# 設置堆區的初始大小  -Xms1024m  # 設置堆區的存儲空間最大值,一般與堆區的初始大小相等  -Xmx1024m  # 設置年輕代堆的大小  -Xmn512m  # 設置如下參數,在出現OOM時進行堆轉儲  -XX:+HeapDumpOnOutOfMemoryError  # 設置以上設置時,需配置以下參數,堆轉儲文件輸出的位置  -XX:HeapDumpPath=/usr/log/java_dump.hprof
方法區與永久代
  • 方法區被所有執行緒共享。採用永久代的方式實現了方法區。
  • jdk 8 以前(不包括 jdk8)存在永久代(Perm區),jdk 8 以後(包括 jdk 8)移除了永久代。如下圖所示。
方法區在不同 JDK 版本的變化

請見下圖:

方法區和元空間的區別

請見下圖:

對於 JVM 中永久代或元空間參數的設置

# jdk1.7 設置永久代記憶體初始大小  -XX:PermSize=512m  # jdk1.7 設置永久代記憶體最大值  -XX:MaxPermSize=512m  # jdk1.8 設置元空間記憶體初始大小  -XX:MetaspaceSize=1024m  # jdk1.8 設置元空間記憶體最大值  -XX:MaxMetaspaceSize=1024m

以 ObjectA a = new ObjectA(); 為例

聊一聊,對象在 JVM 虛擬機中是如何創建的,在什麼地方分配記憶體,又是如何分配的,對象是如何定位的,以及對象的記憶體布局,最後又是如何回收的。

1)對象的創建

先在虛擬機棧創建棧幀,棧幀內創建對象的引用,在方法區進行類的載入,然後去 Java 堆區進行分配記憶體並記憶體初始化,再回到棧幀中初始化對象的數據,完成對象的創建。見下圖:

2)Java 堆記憶體分配過程

想要更好的理解 Java 堆區記憶體分配過程,得先了解記憶體分配方法有哪些,記憶體分配方法分為指針碰撞法空閑列表法

  • 指針碰撞法 支援壓縮整理功能的垃圾回收器 Serial、ParNew 等(Compact 過程),使得已使用的記憶體和未使用的記憶體分開,兩者之間存在一個指針作為分界點指示器。 分配記憶體只需移動指針,分界點指示器向未使用的記憶體一側移動一段與對象大小相等的空間,這種分配記憶體的方法叫做指針碰撞法。如下圖所示:
  • 空閑列表法 基於標記清除(Mark-Sweep)演算法的 CMS 垃圾回收器,其記憶體劃分成網格區(Region),記憶體分配不規整,即已使用的和未使用的記憶體隨機分布,JVM 會維護一個記錄表,用於記錄那些記憶體可用於分配,當需要給對象分配記憶體區域時,尋找一塊足夠大的記憶體空間分配給對象,並更新記錄表,這種分配記憶體的方法叫做空閑列表法。如下圖所示:
Java 堆區對象記憶體分配

JVM 中記憶體分配紛繁複雜,為了防止記憶體分配混亂,需要解決並發問題,解決並發問題有兩種方式:同步處理方式TLAB 方式

  • 同步處理:記憶體分配的動作採用同步機制,JVM 為了增加效率採用了 CAS 方式。

在電腦科學中,比較和交換(Conmpare And Swap)是用於實現多執行緒同步的原子指令。它將記憶體位置的內容與給定值進行比較,只有在相同的情況下,將該記憶體位置的內容修改為新的給定值。這是作為單個原子操作完成的。

  • TLAB 方式:每個執行緒在 Java 堆中預先分配一小塊記憶體,叫做本地執行緒分配緩衝區。TLAB 的全稱是 Thread Local Allocation Buffer。這個 TLAB 和 Java 中的 ThreadLocal 類有點像,每個執行緒獨享執行緒本地變數。 哪個執行緒需要分配記憶體先去各自的 TLAB 中分配,但是這個緩衝區比較小,是為了加速對象的分配。只有在執行緒的 TLAB 用完才會去堆中進行記憶體分配,此時才需要同步機制。如下圖所示:
3)對象的訪問定位
  • 句柄訪問,見下圖所示:

註:句柄池是 Java 堆分配用於存放對象指針的記憶體空間

優點:在垃圾回收的時候對象要經常轉移,這時候只需改變句柄中指向對象實例數據的指針即可(不用修改 reference)。

  • 直接訪問,見下圖所示:

優點:相對於句柄訪問定位的方式,減少了一次指針定位的開銷(也減少了句柄池的存儲空間),HotSpot JVM 實現採用的是直接訪問的方式進行對象訪問定位。

4)對象的記憶體布局

對象的組成:對象頭(對象自身運行時數據和類型指針)、實例數據和對齊填充。可參考這篇文章(記一次生產頻繁出現 Full GC 的 GC日誌圖文詳解)中的第 3 部分關於線上系統 JVM 記憶體估算方法。如下圖所示:

初始 Java GC

這裡只做簡單了解,如果後面有時間會對 JVM 垃圾回收深入分析。

  • 針對上面 Java 創建對象過程的例子。 ObjectA a = new ObjectA();類似這樣創建對象的即是強引用,如果該引用存在,則垃圾回收器就不會回收它。 註:對象引用類型(由強到弱)分為強引用、軟引用、弱引用、虛引用。
  • GC 針對的 JVM 區域 從上面對 JVM 記憶體布局的介紹,發生 GC 主要是針對 Java Heap 區 和 元空間(或方法區)。其他區域都是執行緒私有的,即隨著執行緒的創建而創建,隨著執行緒的銷毀而銷毀。
  • 對於 JVM 中 GC 參數的設置
# 在控制台輸出GC情況  -verbose:gc  # GC日誌輸出  -XX:+PrintGC  # GC日誌詳細輸出  -XX:+PrintGCDetails  # GC輸出時間戳  -XX:+PrintGCDateStamps  # GC日誌輸出指定文件中  -Xloggc:/log/gc.log

小結

從 Java 程式碼如何運行的,聊到 JVM 記憶體布局,虛擬機參數的配置說明,Java 對象的創建(new)過程,包括對象記憶體的堆分配、對象的定位、對象記憶體布局等,以及最後簡單介紹了垃圾回收相關內容。本文以圖文並茂的方式分享,希望加速大家的理解和閱讀體驗,也希望本文能給大家帶來一些小小的收穫。

加油、