一 基礎原理:第02講:大廠面試題:你不得不掌握的 JVM 內存管理

本課時我們主要講解 JVM 的內存劃分以及棧上的執行過程。這塊內容在面試中主要涉及以下這 3 個面試題:

  • JVM 是如何進行內存區域劃分的?
  • JVM 如何高效進行內存管理?
  • 爲什麼需要有元空間,它又涉及什麼問題?

帶着這 3 個問題,我們開始今天的學習,關於內存劃分的知識我希望在本課時你能夠理解就可以,不需要死記硬背,因爲在後面的課時我們會經常使用到本課時學習的內容,也會結合工作中的場景具體問題具體分析,這樣你可以對 JVM 的內存獲得更深刻的認識。

首先,第一個問題:JVM的內存區域是怎麼高效劃分的?這也是一個高頻的面試題。很多同學可能通過死記硬背的方式來應對這個問題,這樣不僅對知識沒有融會貫通在面試中還很容易忘記答案。

爲什麼要問到 JVM 的內存區域劃分呢?因爲 Java 引以爲豪的就是它的自動內存管理機制。相比於 C++的手動內存管理、複雜難以理解的指針等,Java 程序寫起來就方便的多。

然而這種呼之即來揮之即去的內存申請和釋放方式,自然也有它的代價。爲了管理這些快速的內存申請釋放操作,就必須引入一個池子來延遲這些內存區域的回收操作。

我們常說的內存回收,就是針對這個池子的操作。我們把上面說的這個池子,叫作堆,可以暫時把它看成一個整體。

JVM 內存佈局

程序想要運行,就需要數據。有了數據,就需要在內存上存儲。那你可以回想一下,我們的 C++ 程序是怎麼運行的?是不是也是這樣?

Java 程序的數據結構是非常豐富的。其中的內容,舉一些例子:

  • 靜態成員變量
  • 動態成員變量
  • 區域變量
  • 短小緊湊的對象聲明
  • 龐大複雜的內存申請

這麼多不同的數據結構,到底是在什麼地方存儲的,它們之間又是怎麼進行交互的呢?是不是經常在面試的時候被問到這些問題?

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

JVM 內存區域劃分如圖所示,從圖中我們可以看出:

  • JVM 堆中的數據是共享的,是佔用內存最大的一塊區域。
  • 可以執行字節碼的模塊叫作執行引擎。
  • 執行引擎在線程切換時怎麼恢復?依靠的就是程序計數器。
  • JVM 的內存劃分與多線程是息息相關的。像我們程序中運行時用到的棧,以及本地方法棧,它們的維度都是線程。
  • 本地內存包含元數據區和一些直接內存。

一般情況下,只要你能答出上面這些主要的區域,面試官都會滿意的點頭。但如果深挖下去,可能就有同學就比較頭疼了。下面我們就詳細看下這個過程。

虛擬機棧

棧是什麼樣的數據結構?你可以想象一下子彈上膛的這個過程,後進的子彈最先射出,最上面的子彈就相當於棧頂。

我們在上面提到,Java 虛擬機棧是基於線程的。哪怕你只有一個 main() 方法,也是以線程的方式運行的。在線程的生命週期中,參與計算的數據會頻繁地入棧和出棧,棧的生命週期是和線程一樣的。

棧裏的每條數據,就是棧幀。在每個 Java 方法被調用的時候,都會創建一個棧幀,併入棧。一旦完成相應的調用,則出棧。所有的棧幀都出棧後,線程也就結束了。每個棧幀,都包含四個區域:

  • 局部變量表
  • 操作數棧
  • 動態連接
  • 返回地址

我們的應用程序,就是在不斷操作這些內存空間中完成的。

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

這裏有一個比較特殊的數據類型叫作 returnAdress。因爲這種類型只存在於字節碼層面,所以我們平常打交道的比較少。對於 JVM 來說,程序就是存儲在方法區的字節碼指令,而 returnAddress 類型的值就是指向特定指令內存地址的指針。

這部分有兩個比較有意思的內容,面試中說出來會讓面試官眼前一亮。

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

這一點很神奇,也是基礎。我們接下來從線程角度看一下里面的內容。

程序計數器

那麼你設想一下,如果我們的程序在線程之間進行切換,憑什麼能夠知道這個線程已經執行到什麼地方呢?

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

就好比你停下手中的工作,倒了杯茶,然後如何繼續之前的工作?

程序計數器是一塊較小的內存空間,它的作用可以看作是當前線程所執行的字節碼的行號指示器。這裏面存的,就是當前線程執行的進度。下面這張圖,能夠加深大家對這個過程的理解。

可以看到,程序計數器也是因爲線程而產生的,與虛擬機棧配合完成計算操作。程序計數器還存儲了當前正在運行的流程,包括正在執行的指令、跳轉、分支、循環、異常處理等。

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

堆是 JVM 上最大的內存區域,我們申請的幾乎所有的對象,都是在這裏存儲的。我們常說的垃圾回收,操作的對象就是堆。

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

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

由於對象的大小不一,在長時間運行後,堆空間會被許多細小的碎片佔滿,造成空間浪費。所以,僅僅銷燬對象是不夠的,還需要堆空間整理。這個過程非常的複雜,我們會在後面有專門的課時進行介紹。

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

Java 的對象可以分爲基本數據類型和普通對象。

對於普通對象來說,JVM 會首先在堆上創建對象,然後在其他地方使用的其實是它的引用。比如,把這個引用保存在虛擬機棧的局部變量表中。

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

我們上面提到,每個線程擁有一個虛擬機棧。當你在方法體內聲明瞭基本數據類型的對象,它就會在棧上直接分配。其他情況,都是在堆上分配。

注意,像 int[] 數組這樣的內容,是在堆上分配的。數組並不是基本數據類型。

這就是 JVM 的基本的內存分配策略。而堆是所有線程共享的,如果是多個線程訪問,會涉及數據同步問題。這同樣是個大話題,我們在這裏先留下一個懸念。

元空間

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

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

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

Perm 區在 Java 8 中已經被徹底廢除,取而代之的是 Metaspace。原來的 Perm 區是在堆上的,現在的元空間是在非堆上的,這是背景。關於它們的對比,可以看下這張圖。

然後,元空間的好處也是它的壞處。使用非堆可以使用操作系統的內存,JVM 不會再出現方法區的內存溢出;但是,無限制的使用會造成操作系統的死亡。所以,一般也會使用參數 -XX:MaxMetaspaceSize 來控制大小。

方法區,作爲一個概念,依然存在。它的物理存儲的容器,就是 Metaspace。我們將在後面的課時中,再次遇到它。現在,你只需要瞭解到,這個區域存儲的內容,包括:類的信息、常量池、方法數據、方法代碼就可以了。

小結

好了,到這裏本課時的基本內容就講完了,針對這塊的內容在面試中還經常會遇到下面這兩個問題。

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

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

  • 堆、非堆、本地內存,有什麼關係?

關於它們的關係,我們可以看一張圖。在我的感覺裏,堆是軟綿綿的,鬆散而有彈性;而非堆是冰冷生硬的,內存非常緊湊。

大家都知道,JVM 在運行時,會從操作系統申請大塊的堆內內存,進行數據的存儲。但是,堆外內存也就是申請後操作系統剩餘的內存,也會有部分受到 JVM 的控制。比較典型的就是一些 native 關鍵詞修飾的方法,以及對內存的申請和處理。

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

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

總結

JVM 的運行時區域是棧,而存儲區域是堆。很多變量,其實在編譯期就已經固定了。.class 文件的字節碼,由於助記符的作用,理解起來並不是那麼喫力,我們將在課程最後幾個課時,從字節碼層面看一下多線程的特性。

JVM 的運行時特性,以及字節碼,是比較偏底層的知識。本課時屬於初步介紹,有些部分並未深入講解。希望你應該能夠在腦海裏建立一個 Java 程序怎麼運行的概念,以便我們在後面的課時中,提到相應的內存區域時,有個整體的印象。

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