你不得不掌握的 JVM 記憶體管理

晗寶發表於2020-07-08

Java 引以為豪的就是它的自動記憶體管理機制。相比於 C++的手動記憶體管理、複雜難以理解的指標等,Java 程式寫起來就方便的多。
然而這種呼之即來揮之即去的記憶體申請和釋放方式,自然也有它的代價。為了管理這些快速的記憶體申請釋放操作,就必須引入一個池子來延遲這些記憶體區域的回收操作。
我們常說的記憶體回收,就是針對這個池子的操作。我們把上面說的這個池子,叫作堆,可以暫時把它看成一個整體。

JVM 記憶體佈局

Java 程式的資料結構是非常豐富的。其中的內容,舉一些例子:
靜態成員變
動態成員變數
區域變數
短小緊湊的物件宣告
龐大複雜的記憶體申請

我們先看一下 JVM 的記憶體佈局。隨著 Java 的發展,記憶體佈局一直在調整之中。比如,Java 8 及之後的版本,徹底移除了持久代,而使用 Metaspace 來進行替代。這也表示著 -XX:PermSize 和 -XX:MaxPermSize 等引數調優,已經沒有了意義。但大體上,比較重要的記憶體區域是固定的。
Cgq2xl4VrjWAPqAuAARqnz6cigo666.png
JVM 記憶體區域劃分如圖所示,從圖中我們可以看出:

  • JVM 堆中的資料是共享的,是佔用記憶體最大的一塊區域。
  • 可以執行位元組碼的模組叫作執行引擎。
  • 執行引擎線上程切換時怎麼恢復?依靠的就是程式計數器。
  • JVM 的記憶體劃分與多執行緒是息息相關的。像我們程式中執行時用到的棧,以及本地方法棧,它們的維度都是執行緒。
  • 本地記憶體包含後設資料區和一些直接記憶體。

虛擬機器棧

Java 虛擬機器棧是基於執行緒的。哪怕你只有一個 main() 方法,也是以執行緒的方式執行的。線上程的生命週期中,參與計算的資料會頻繁地入棧和出棧,棧的生命週期是和執行緒一樣的。

棧裡的每條資料,就是棧幀。在每個 Java 方法被呼叫的時候,都會建立一個棧幀,併入棧。一旦完成相應的呼叫,則出棧。所有的棧幀都出棧後,執行緒也就結束了。每個棧幀,都包含四個區域:

  • 區域性變數表
  • 運算元棧
  • 動態連線
  • 返回地址

我們的應用程式,就是在不斷操作這些記憶體空間中完成的。
Cgq2xl4VrjWABK2qAATDn4DQbvE629.png

本地方法棧是和虛擬機器棧非常相似的一個區域,它服務的物件是 native 方法。你甚至可以認為虛擬機器棧和本地方法棧是同一個區域,這並不影響我們對 JVM 的瞭解。

這裡有一個比較特殊的資料型別叫作 returnAdress。因為這種型別只存在於位元組碼層面,所以我們平常打交道的比較少。對於 JVM 來說,程式就是儲存在方法區的位元組碼指令,而 returnAddress 型別的值就是指向特定指令記憶體地址的指標。
CgpOIF4VrjWAZvMCAAB9Uu8GKww546.png

  • 這裡有一個兩層的棧。第一層是棧幀,對應著方法;第二層是方法的執行,對應著運算元。注意千萬不要搞混了。
  • 你可以看到,所有的位元組碼指令,其實都會抽象成對棧的入棧出棧操作。執行引擎只需要傻瓜式的按順序執行,就可以保證它的正確性。

程式計數器

既然是執行緒,就代表它在獲取 CPU 時間片上,是不可預知的,需要有一個地方,對執行緒正在執行的點位進行緩衝記錄,以便在獲取 CPU 時間片時能夠快速恢復。

程式計數器是一塊較小的記憶體空間,它的作用可以看作是當前執行緒所執行的位元組碼的行號指示器。這裡面存的,就是當前執行緒執行的進度。下面這張圖,能夠加深大家對這個過程的理解。
Cgq2xl4VrjaANruFAAQKxZvgfSs652.png
可以看到,程式計數器也是因為執行緒而產生的,與虛擬機器棧配合完成計算操作。程式計數器還儲存了當前正在執行的流程,包括正在執行的指令、跳轉、分支、迴圈、異常處理等。

我們可以看一下程式計數器裡面的具體內容。下面這張圖,就是使用 javap 命令輸出的位元組碼。大家可以看到在每個 opcode 前面,都有一個序號。就是圖中紅框中的偏移地址,你可以認為它們是程式計數器的內容。
CgpOIF4VrjaAQSVlAAB8U3OQQR8670.jpg

堆 

Cgq2xl4VrjaAXnuQAANJIXDvNhI844.png
堆是 JVM 上最大的記憶體區域,我們申請的幾乎所有的物件,都是在這裡儲存的。我們常說的垃圾回收,操作的物件就是堆。

堆空間一般是程式啟動時,就申請了,但是並不一定會全部使用。

隨著物件的頻繁建立,堆空間佔用的越來越多,就需要不定期的對不再使用的物件進行回收。這個在 Java 中,就叫作 GC(Garbage Collection)。

由於物件的大小不一,在長時間執行後,堆空間會被許多細小的碎片佔滿,造成空間浪費。所以,僅僅銷燬物件是不夠的,還需要堆空間整理。這個過程非常的複雜。

那一個物件建立的時候,到底是在堆上分配,還是在棧上分配呢?這和兩個方面有關:物件的型別和在 Java 類中存在的位置。

Java 的物件可以分為基本資料型別和普通物件。

對於普通物件來說,JVM 會首先在堆上建立物件,然後在其他地方使用的其實是它的引用。比如,把這個引用儲存在虛擬機器棧的區域性變數表中。

對於基本資料型別來說(byte、short、int、long、float、double、char),有兩種情況。

我們上面提到,每個執行緒擁有一個虛擬機器棧。當你在方法體內宣告瞭基本資料型別的物件,它就會在棧上直接分配。其他情況,都是在堆上分配。

注意,像 int[] 陣列這樣的內容,是在堆上分配的。陣列並不是基本資料型別。
CgpOIF4VrjaAaILrAANJIXDvNhI630.png
這就是 JVM 的基本的記憶體分配策略。而堆是所有執行緒共享的,如果是多個執行緒訪問,會涉及資料同步問題。

元空間

關於元空間,我們還是以一個非常高頻的面試題開始:“為什麼有 Metaspace 區域?它有什麼問題?”

說到這裡,你應該回想一下類與物件的區別。物件是一個活生生的個體,可以參與到程式的執行中;類更像是一個模版,定義了一系列屬性和操作。那麼你可以設想一下。我們前面生成的 A.class,是放在 JVM 的哪個區域的?

想要問答這個問題,就不得不提下 Java 的歷史。在 Java 8 之前,這些類的資訊是放在一個叫 Perm 區的記憶體裡面的。更早版本,甚至 String.intern 相關的執行時常量池也放在這裡。這個區域有大小限制,很容易造成 JVM 記憶體溢位,從而造成 JVM 崩潰。

Perm 區在 Java 8 中已經被徹底廢除,取而代之的是 Metaspace。原來的 Perm 區是在堆上的,現在的元空間是在非堆上的,這是背景。關於它們的對比,可以看下這張圖。
Cgq2xl4VrjaAIlgaAAJKReuKXII670.png
然後,元空間的好處也是它的壞處。使用非堆可以使用作業系統的記憶體,JVM 不會再出現方法區的記憶體溢位;但是,無限制的使用會造成作業系統的死亡。所以,一般也會使用引數 -XX:MaxMetaspaceSize 來控制大小。

方法區,作為一個概念,依然存在。它的物理儲存的容器,就是 Metaspace。現在,只需要瞭解到,這個區域儲存的內容,包括:類的資訊、常量池、方法資料、方法程式碼就可以了。

小結

  • 我們常說的字串常量,存放在哪呢?

由於常量池,在 Java 7 之後,放到了堆中,我們建立的字串,將會在堆上分配。

  • 堆、非堆、本地記憶體,有什麼關係?

關於它們的關係,我們可以看一張圖。在我的感覺裡,堆是軟綿綿的,鬆散而有彈性;而非堆是冰冷生硬的,記憶體非常緊湊。
CgpOIF4VrjaAOSx2AAJgrvself8711.png
大家都知道,JVM 在執行時,會從作業系統申請大塊的堆內記憶體,進行資料的儲存。但是,堆外記憶體也就是申請後作業系統剩餘的記憶體,也會有部分受到 JVM 的控制。比較典型的就是一些 native 關鍵詞修飾的方法,以及對記憶體的申請和處理。

在 Linux 機器上,使用 top 或者 ps 命令,在大多數情況下,能夠看到 RSS 段(實際的記憶體佔用),是大於給 JVM 分配的堆記憶體的。

如果你申請了一臺系統記憶體為 2GB 的主機,可能 JVM 能用的就只有 1GB,這便是一個限制。

總結

JVM 的執行時區域是棧,而儲存區域是堆。很多變數,其實在編譯期就已經固定了。

相關文章