天天看點

探索 Java 記憶體管理機制,面試别被問住了

目錄

  1. 什麼是記憶體?
  2. 什麼是 Java 記憶體模型?
  3. 什麼是 JVM?
  4. JVM 是怎麼劃分記憶體的?
  5. 棧幀中的資料有什麼用?
  6. 什麼是可達性算法?
  7. Java 中有哪幾種引用?
  8. 什麼是垃圾回收器?
  9. 參考文獻

前言

這篇文章是我自己回顧和再學習 Java 記憶體管理相關知識的過程中整理出來的。

整理的目的是讓我自己能對 Java 記憶體管理相關的知識的認識更全面一些,分享的目的是希望大家也能從這些知識中得到一些啟發。

1. 什麼是記憶體?

記憶體是計算機中重要的部件之一,是與 CPU 進行溝通的橋梁,是 CPU 能直接尋址的存儲空間,由半導體器件制成。

如果說資料是商品,那硬碟就是商店的倉庫,記憶體就是商店的貨架,倉庫裡的商品你是不能直接買的,你隻能買貨架上的商品。

每一個程式中使用的記憶體區域相當于是不同的貨架,當一個貨架上需要擺放的商品超過這個貨架所能容納的最大值,就會出現放不下的情況,也就是記憶體溢出。

2. 什麼是 JVM?

JVM(Java 虛拟機)是 Java Virtual Machine 的縮寫,它是一個虛構出來的計算機,通過在實際的計算機上仿真模拟各種計算機功能來實作的。

JVM 有自己的硬體架構,如處理器、堆棧、寄存器等,還有對應分指令系統。

假如一個程式使用的記憶體區域是一個貨架,那 JVM 就相當于是一個淘寶店鋪,它不是真實存在的貨架,但它和真實貨架一樣可以上架和下架商品,而且上架的商品數量也是有限的。

假如貨架是在深圳,那 JVM 的平台無關性就相當于是客人可以在各個地方購買你在淘寶上釋出的商品,不是隻有在深圳才能購買貨架上的商品。

3. 什麼是 Java 記憶體模型?

Java 記憶體模型的主要目标是定義程式中各個變量的通路規則,也就是在虛拟機中将變量存儲到記憶體,以及從記憶體中取出變量這樣的底層細節。

下面我們就來看下 Java 記憶體模型的具體介紹。

3.1 主記憶體與工作記憶體

Java 記憶體模型規定了所有的變量都存儲在主記憶體(Main Memory)中,每條線程有自己的工作記憶體(Working Memory),線程的工作記憶體中儲存了線程使用到的變量的記憶體副本。

線程對變量副本的所有操作都必須在工作記憶體中進行,不能直接讀寫主記憶體中的變量。

不同線程之間無法直接通路其他線程工作記憶體中的變量,線程間變量值的傳遞都要通過主記憶體來完成。

探索 Java 記憶體管理機制,面試别被問住了

3.2 執行引擎

所謂執行引擎,就是一個運算器,能夠識别輸入的指令,并根據輸入的指令執行一套特定的邏輯,最終輸出特定的結果

執行引擎對于 JVM 的作用就像是 CPU 對于實體機器的作用,都可以識别指令,并且根據指令完成特定的運算。

3.3 主記憶體與工作記憶體的互動操作

Java 記憶體模型中定義了 8 種操作來完成主記憶體與工作記憶體之間具體的互動協定,虛拟機實作時必須保證每一種操作都是原子、不可再分的。

這 8 種操作又可分為作用于主記憶體的和作用于工作記憶體的操作。

3.3.1 作用于主記憶體的操作

  1. lock(鎖定)

    作用于主記憶體的變量,它把一個變量辨別為一條線程獨占的狀态。

  2. unlock(解鎖)

    作用于主記憶體的變量,它把一個處于鎖定狀态的變量釋放出來,釋放後的變量才能被其他線程鎖定。

  3. read(讀取)

    作用于主記憶體的變量,它把一個變量的值從主記憶體傳輸到線程的工作記憶體中,以便 load 時使用。

  4. write(寫入)

    作用于主記憶體的變量,它把 store 操作從工作記憶體中得到的變量值放入主記憶體的變量中。

3.3.2 作用于工作記憶體的操作

  1. load(載入)

    作用于工作記憶體的變量,它把 read 操作從主記憶體中得到的變量值放入工作記憶體的變量副本中。

  2. use(使用 )

    作用于工作記憶體的變量,它把一個工作記憶體中一個變量的值傳遞給執行引擎,每當虛拟機遇到一個需要使用的變量的值的位元組碼執行時會執行這個操作。

  3. assign(指派)

    作用于工作記憶體的變量,它把一個執行引擎接收到的值賦給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼執行時執行這個操作。

  4. store(存儲)

    作用于工作記憶體的變量,它把工作記憶體中的一個變量的值傳送到主記憶體中,以便随後的 write 操作使用。

4. JVM 是怎麼劃分記憶體的?

JVM 在執行 Java 程式的過程中會把它管理的記憶體分為若幹個資料區域,而這些區域又可以分為線程私有的資料區域和線程共享的資料區域。

探索 Java 記憶體管理機制,面試别被問住了

4.1 線程私有的資料區域

4.1.1 程式計數器

程式計數器有下面兩個特點。

  • 較小

    程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是目前線程執行的位元組碼的行号訓示器。

  • 線程私有

    為了線程切換後能恢複到正确的執行位置,每條線程都有一個私有的程式計數器。

  • 無異常

    程式計數器是唯一一個在 Java 虛拟機規範中沒有規定任何 OOM 情況的區域。

4.1.2 虛拟機棧

虛拟機棧可以說是 Java 方法棧,它有下面三個特點。

  • 描述方法執行

    虛拟機棧描述的是 Java 方法執行的記憶體模型,每個方法在執行時都會建立一個棧幀(Stack Frame),棧幀用于存儲局部變量表、操作數棧、動态連結、方法出口等資訊。

    一個方法從調用到執行完成的過程,對應着一個棧幀在虛拟機棧中入棧到出棧的過程。

    關于棧幀在第 5 大節會有一個更多的介紹。

  • 線程私有

    與程式計數器一樣,Java 虛拟機棧也是線程私有的,它的生命周期與線程相同。

  • 異常

    在 Java 虛拟機規範中,對虛拟機棧規定了下面兩種異常。

    1. StackOverflowError

      當執行 Java 方法時會進行壓棧的操作,在棧中會儲存局部變量、操作數棧和方法出口等資訊。

      JVM 規定了棧的最大深度,如果線程請求執行方法時棧的深度大于規定的深度,就會抛出棧溢出異常 StackOverflowError。

    2. OutOfMemoryError

      如果虛拟機在擴充時無法申請到足夠的記憶體,就會抛出記憶體溢出異常 OutOfMemoryError。

4.1.3 本地方法棧

本地方法棧(Native Method Stack)的作用與虛拟機棧非常相似,它有下面兩個特點。

  • 為 Native 方法服務

    本地方法棧與虛拟機棧的差別是虛拟機棧為 Java 方法服務,而本地方法棧為 Native 方法服務。

  • 異常

    與虛拟機棧一樣,本地方法棧也會抛出 StackOverflowError 和 OutOfMemoryError 異常。

4.2 所有線程共享的資料區域

4.2.1 Java 堆

Java 堆(Java Heap)也就是執行個體堆,它用于存放我們建立的對象執行個體,它有下面幾個特點。

  • 最大

    對于大多數應用來說,Java 堆是 JVM 管理的記憶體中最大的一塊記憶體區域。

  • 線程共享

    Java 堆是所有線程共享的一塊記憶體區域,在虛拟機啟動時建立。

  • 存放執行個體

    堆的唯一作用就是存放對象執行個體,幾乎所有的對象執行個體都是在這裡配置設定記憶體。

  • GC

    堆是垃圾收集器管理的主要區域,是以有時也叫 GC 堆。

4.2.2 方法區

方法區(Method Area)存儲的是已被虛拟機加載的資料,它有下面幾個特點。

  • 線程共享

    方法區和堆一樣,是所有線程共享的記憶體區域。

  • 存儲的資料類型
    • 類資訊
    • 常量
    • 靜态變量
    • 即時編譯器編譯後的代碼
  • 異常

    方法區的大小決定了系統可以儲存多少個類,如果系統定義了太多的類,導緻方法區溢出,虛拟機同樣會抛出記憶體溢出異常 OutOfMemoryError。

方法區又可分為運作時常量池和直接記憶體兩部分。

  1. 運作時常量池

    運作時常量池(Runtime Constant Pool)是方法區的一部分。

    Class 檔案中除了有類的版本、字段、方法和接口等描述資訊,還有一項資訊就是常量池(Constant Pool Table)。

    常量池用于存放編譯期生成的各種字面量和符号引用,這部分内容将在類加載後進入方法區的運作時常量池中存放。

    運作時常量池受到方法區記憶體的限制,當常量池無法再申請到記憶體時會抛出 OutOfMemoryError 異常。

  2. 直接記憶體

    直接記憶體(Direct Memory)有下面幾個特點

    • 在虛拟機資料區外

      直接記憶體不是從虛拟機運作時資料區的一部分,也不是 Java 虛拟機規範中定義的記憶體區域。

    • 直接配置設定

      在 JDK 1.4 中新加入了 NIO(New Input/Output)類,引入了一種基于通道與緩沖區的 I/O 方式,它可以使用 Native 函數庫直接配置設定堆外記憶體,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作,這樣能避免在 Java 堆和 Native 堆中來回複制資料。

    • 受裝置記憶體大小限制

      直接記憶體的配置設定不會受到 Java 堆大小的限制,但是會受到裝置總記憶體(RAM 以及 SWAP 區)大小以及處理器尋址空間的限制。

    • 異常

      直接記憶體的容量預設與 Java 堆的最大值一樣,如果超額申請記憶體,也有可能導緻 OOM 異常出現。

5. 棧幀中的資料有什麼用?

當 Java 程式出現異常時,程式會列印出對應的異常堆棧,通過這個堆棧我們可以知道方法的調用鍊路,而這個調用鍊路就是由一個個 Java 方法棧幀組成的。

我們來看下棧幀中包含的局部變量表、操作數棧、動态連接配接和傳回位址分别有着什麼作用。

探索 Java 記憶體管理機制,面試别被問住了

5.1 局部變量表

局部變量表(Local Variable Table)中的變量隻在目前函數調用中有效,當函數調用結束後,随着函數棧幀的銷毀,局部變量表也會随之銷毀。

局部變量表中存放的編譯期可知的各種資料有如下三種。

  1. 基本資料類型

    如 boolean、char、int 等

  2. 對象引用

    reference 類型,可能是一個指向對象起始位址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置

  3. returnAddress 類型

    指向了一條位元組碼指令的位址。

5.2 操作數棧

操作數棧(Operand Stack)也叫操作棧,它主要用于儲存計算過程的中間結果,同時作為計算過程中臨時變量的存儲空間。

操作數棧也是一個先進後出的資料結構,隻支援入棧和出棧兩種操作。

當一個方法剛開始執行時,操作數棧是空的,在方法執行的過程中,會有各種位元組碼執行往操作數棧中寫入和提取内容,也就是出棧/入棧操作。

比如下面的這張圖中,當調用了虛拟機的 iadd 指令後,它就會在操作數棧中彈出兩個整數并進行加法計算,并将計算結果入棧。

探索 Java 記憶體管理機制,面試别被問住了

5.3 動态連接配接

每個棧幀都包含一個指向運作時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連接配接(Dynamic Linking)。

5.4 方法傳回位址

當一個方法開始執行後,隻有兩種方式可以退出這個方法,一種是正常完成出口,另一種是異常完成出口。

  1. 正常完成出口

    執行引擎遇到任意一個方法傳回的位元組碼指令,這時候可能會有傳回值傳遞給上層的方法調用者。

    是否有傳回值和傳回值的類型将根據遇到哪種方法傳回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。

  2. 異常完成出口

    在方法執行過程中遇到異常,并且這個異常沒有在方法體内得到處理,就會導緻方法退出,這種退出方式稱為異常完成出口(Abrupt Method Invocation Completion)。

    一個方法使用異常完成出口的方式退出,任何值都不會傳回給它的調用者。

無論采用哪種退出方式,在方法退出後,都需要傳回到方法被調用的位置,程式才能繼續執行。

6. 什麼是可達性算法?

在主流的商用程式語言(Java、C# 和 Lisp 等)的主流實作中,都是通過可達性分析(Reachability Analysis)判定對象是否存活的。

這個算法的基本思路就是通過一系列“GC Roots”對象作為起始點,從這些節點開始向下搜尋,搜尋走過的路徑就叫引用鍊。

當一個對象到 GC Roots 沒有任何引用鍊相連時,則證明此對象是不可用的。

比如下圖中的 object5、object6、object7,雖然它們互有關聯,但是它們到 GC Roots 是不可達的,是以它們會被判定為可回收對象。

探索 Java 記憶體管理機制,面試别被問住了

在 Java 中,不同記憶體區域中可作為 GC Roots 的對象包括下面幾種。

  1. 虛拟機棧

    虛拟機棧的棧幀中的局部變量表中引用的對象,比如某個方法正在使用的類字段。

  2. 方法區
    1. 類靜态屬性引用的對
    2. 常量引用的對象
  3. 本地方法棧

    本地方法棧中 Native 方法引用的對象。

7. Java 中有哪幾種引用?

無論是通過引用計數算法判斷對象的引用數量,還是通過可達性分析算法判斷對象的引用鍊是否可達,判定對象是否存活都與引用有關。

在 JDK 1.2 之後,Java 對引用的概念進行了擴充,将引用分為強引用、軟引用、弱引用和虛引用四種,這四種引用強度按順序依次減弱。

7.1 強引用

強引用有下面幾個特點。

  • 普遍存在

    強引用是指代碼中普遍存在的,比如 "Object obj = new Object()" 這類引用。

  • 直接通路

    強引用可以直接通路目标對象。

  • 不會回收

    強引用指向的對象在任何時候都不會被系統回收,虛拟機即使抛出 OOM 異常,也不會回收強引用指向的對象。

    使用 obj = null 不會觸發 GC,但是在下次 GC 的時候這個強引用對象就可以被回收了。

  • OOM 隐患

    強引用可能導緻記憶體洩漏。

7.2 軟引用

軟引用有下面幾個特點。

  • 有用但非必需

    軟引用用于描述一些還有用但非必需的對象。

  • 二次回收

    對于軟引用關聯的對象,在系統即将發生記憶體溢出前,會把這些對象列入回收範圍中進行二次回收。

  • OOM 隐患

    如果二次回收後還沒有足夠的記憶體,就會抛出記憶體溢出異常。

  • SoftReference

    在 JDK 1.2 後,Java 提供了 SoftReference 類來實作軟引用。

7.3 弱引用

弱引用有下面幾個特點。

  • 比軟引用弱

    弱引用的強度比軟引用更弱一些,被弱引用關聯的對象隻能生存到下一次 GC 前。

  • 發現即回收

    在 GC 時,隻要發現弱引用,不管系統堆空間使用情況如何,都會将對象進行回收。

  • 可有可無

    軟引用、弱引用适合儲存可有可無的緩存資料。

  • WeakReference

    JDK 1.2 後,提供了 WeakReference 類來實作弱引用。

7.4 虛引用

虛引用是最弱的一種引用關系,它有以下幾個特點。

  • 無法擷取

    一個對象是否有虛引用的存在,都不會對其生存時間構成影響,也無法通過虛引用取得一個對象執行個體。

  • 收到通知

    為一個對象設定虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。

  • PhatomReference

    在 JDK 1.2 後,提供了 PhantomReference 類來實作虛引用。

8. 什麼是垃圾回收器?

地上有髒東西是不可避免的,但是天天都要掃地又太麻煩了,有沒有什麼辦法可以讓我們不用掃地呢?

掃地機器人就可以幫我們做這件事,而垃圾回收器 GC(Garbage Collector)就相當于是掃地機器人。

我們 Java 開發者不用像 C++ 開發者那樣關心記憶體釋放的問題,但是我們也不能擋着掃地機器人的路。

當我們操作不當導緻某塊記憶體洩漏時,GC 就不能對這塊記憶體進行回收。

GC 可不是個好伺候的主,如果你讓“GC 很忙”,那它就會讓你“應用很卡”。

拿 Android 來說,進行 GC 時,所有線程都要暫停,包括主線程,16ms 是 Android 要求的每幀繪制時間,而當 GC 的時間超過 16ms,就會造成丢幀的情況,也就是界面卡頓。

垃圾回收器回收資源的方式就是垃圾回收算法,下面我們來看下四個主要的垃圾回收算法。

8.1 标記-清除算法

标記-清除算法(Mark-Sweep)相當于是先把貨架上有人買的、沒人買的、空着的商品和位置都記錄下來,然後再把沒人買的商品統一進行下架。

探索 Java 記憶體管理機制,面試别被問住了
  • 工作原理
    • 第一步:标記所有需要回收的對象
    • 第二步:标記完成後,統一回收所有被标記的對象
  • 缺點
    • 效率低

      标記和清除的效率都不高

    • 記憶體碎片

      标記清除後會産生大量不連續的記憶體碎片,記憶體碎片太多會導緻當程式需要配置設定較大的對象時,無法找到足夠的連續記憶體而不得不提前觸發 GC

8.2 複制算法

為了解決效率問題,複制(Copying)收集算法出現了。

探索 Java 記憶體管理機制,面試别被問住了
  • 工作原理

    複制算法把可用記憶體按容量劃分為大小相等的兩塊,每次隻使用其中的一塊。當使用中的這塊記憶體用完了,就把存活的對象複制到另一塊記憶體上,然後把已使用的空間一次清理掉。這樣每次都是對半個記憶體區域進行回收,記憶體配置設定時也不用考慮記憶體碎片等複雜問題。

  • 優點

    複制算法的優點是每次隻對半個記憶體區域進行記憶體回收,配置設定記憶體時也不用考慮記憶體碎片等複雜情況,隻要一動堆頂指針,按順序配置設定記憶體即可。

  • 缺點
    • 浪費空間

      把記憶體縮小一半來使用太浪費空間。

    • 有時效率較低

      在對象存活率高時,要進行較多的複制操作,這時效率就變低了

8.3 标記-整理算法

在複制算法中,如果不想浪費 50% 的空間,就需要有額外的空間進行配置設定擔保,以應對被使用記憶體中所有對象都存活的低端情況,是以養老區不能用這種算法。

根據養老區的特點,有人提出了一種标記-整理(Mark-Compact)算法。

探索 Java 記憶體管理機制,面試别被問住了
  • 工作原理

    标記-整理算法的标記過程與标記-清除算法一樣,但後續步驟是讓所有存活的對象向一端移動,然後直接清理掉邊界外的記憶體。

8.4 分代收集算法

現代商業虛拟機的垃圾回收都采用分代收集(Generational Collection)算法,這種算法會根據對象存活周期的不同将記憶體劃分為幾塊,這樣就可以根據各個區域的特點采用最适當的收集算法。

在新生區,每次垃圾收集都有大批對象死去,隻有少量存活,是以可以用複制算法。

養老區中因為對象存活率高、沒有額外空間對它進行擔保,就必須使用标記-清理或标記-整理算法進行回收。

堆記憶體可分為新生區、養老區和永久存儲區三個區域。

探索 Java 記憶體管理機制,面試别被問住了
  1. 新生區

    新生區(Young Generation Space)是類的誕生、成長和消亡的區域。

    新生區又分為伊甸區(Eden space)、幸存者區(Survivor space)兩部分。

    1. 伊甸區

      大多數情況下,對象都是在伊甸區中配置設定的,當伊甸區沒有足夠的空間進行配置設定時,虛拟機将發起一次 Minor GC。

      Minor GC 是指發生在新生區的垃圾收集動作,Minor GC 非常頻繁,回收速度也比較快。

      當伊甸區的空間用完時,GC 會對伊甸區進行垃圾回收,然後把伊甸區剩下的對象移動到幸存 0 區。

    2. 幸存 0 區

      如果幸存 0 區滿了,GC 會對該區域進行垃圾回收,然後再把該區剩下的對象移動到幸存 1 區。

    3. 幸存 1 區

      如果幸存 1 區滿了,GC 會對該區域進行垃圾回收,然後把幸存 1 區中的對象移動到養老區。

  2. 養老區

養老區(Tenure Generation Space)用于儲存從新生區篩選出來的 Java 對象。

當幸存 1 區移動嘗試對象到養老區,但是發現空間不足時,虛拟機會發起一次 Major GC。

Major GC 的速度一般比 Minor GC 慢 10 倍以上。

大對象會直接進入養老區,比如很大的數字和很長的字元串。

  1. 永久存儲區

    永久存儲區(Permanent Space)是一個常駐記憶體區域,用于存放 JDK 自身攜帶的 Class Interface 中繼資料。

    永久存儲區存儲的是運作環境必需的類資訊,被裝載進該區域的資料是不會被垃圾回收器回收掉的,隻有 JVM 關閉時才會釋放此區域的記憶體。