你真的懂 Java 的記憶體管理和引用型別嗎?

developerHaoz發表於2018-01-29

前言

對於 Java 程式設計師來說,在 Java 虛擬機器自動記憶體管理機制的幫助下,不再需要為每一個 new 操作去寫對應的 delete/free 程式碼,不容易出現記憶體洩露和記憶體溢位的問題。不過,也正是因為 Java 程式設計師把記憶體控制的權力交給了 Java 虛擬機器,一旦出現記憶體洩露和記憶體溢位的問題,如果不瞭解虛擬機器是怎樣使用記憶體的,那麼排查錯誤將會非常艱難。

本文將會對 Java 的記憶體管理以及四種引用型別,做一個總結。

一、Java 記憶體管理


Java 記憶體管理就是物件的分配和釋放問題。在 Java 中,記憶體的分配是由「程式」完成的,而記憶體的釋放是由 Java 垃圾回收器(GC)完成的,這種方式確實簡化了程式設計師的工作,但也同時加重了 JVM 的工作。這也是 Java 程式執行速度較慢的原因之一。

為了能夠正確釋放物件,GC 必須監控每一個物件的執行狀態,包括物件的申請、引用、被引用、賦值等,監控物件狀態是為了更加準確地、及時地釋放物件,而釋放物件的根本原則就是該物件不再被引用。

1、Java 記憶體分配策略

Java 程式執行時的記憶體分配策略有三種,分別是靜態分配、棧式分配和堆式分配,三種方式所使用的記憶體空間分別是靜態儲存區(方法區)、棧區和堆區。

  • 靜態儲存區(方法區):主要存放靜態變數。這塊「記憶體」在程式編譯時就已經分配好了,並且在程式整個執行期間都存在。

  • 棧區:當方法被執行時,方法體內的區域性變數(包括基礎資料型別、物件的引用)都在棧上建立,並在方法執行結束時。這些區域性變數所持有的記憶體將會自動被釋放。因為棧記憶體分配運算內建於處理器的指令集中,效率很高,但是分配的記憶體容量有限。

  • 堆區:又稱動態記憶體分配,通常就是指程式執行時直接 new 出來的記憶體,也就是物件的例項,這部分「記憶體」在不使用時將會被 Java 垃圾回收器來負責回收。

下面通過一個例子,來詳細說明一下:

public class Sample {
    int s1 = 0;
    Sample mSample1 = new Sample();

    public void method() {
        int s2 = 1;
        Sample mSample2 = new Sample();
    }
}

Sample mSample3 = new Sample();
複製程式碼

Sample 類的區域性變數 s2 和引用變數 mSample2 都是存在於棧中,但 mSample2 指向的物件是存在於堆上的。mSample3 指向的物件實體存放於堆上,包括這個物件的所有成員變數 s1 和 mSample1,但它的引用變數是存在於棧中的。

結論:

  • 區域性變數的基本資料型別和引用儲存於棧中,引用的物件實體儲存在堆中 —— 因為他們屬於方法中的變數,生命週期隨方法而結束

  • 成員變數全部儲存於堆中(包括基本資料型別,引用和引用的物件實體)—— 因為它們屬於類,類物件終究是要被 new 出來使用的

2、Java 垃圾回收器

在 Java 堆和靜態儲存區(方法區)中,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,垃圾回收器所關注的便是這部分的記憶體。

2.1 判斷物件是否存活的方法

在堆裡面存放著 Java 世界中幾乎所有的物件例項,垃圾收集器在對堆進行回收前,第一件事就是要確定這些物件之中哪些還「存活」著,哪些已經「死去」。

引用計數演算法

給物件新增一個引用計數器,每當有一個地方引用它時,計數器就加 1,當引用失效 時,就減 1。任何時刻計數器為 0 的物件就是不可能再被使用的。

引用計數演算法的實現比較簡單,判定效率也很高,在大部分情況下它都是一個不錯的演算法。但是,至少主流的 Java 虛擬機器裡面沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。

可達性分析演算法

在主流的商用程式語言(Java、C#)的主流實現中,都是稱通過可達性分析來判定物件是否存活的。這個演算法的基本思想就是通過一系列的稱為「GC Roots」的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到 GC Roots 沒有任何引用鏈相連時,則證明此物件是不可用的。

在 Java 語言中,可作為 GC Roots 的物件包括下面幾種:

  • 虛擬機器棧(棧幀中的本地變數表)中引用的物件
  • 方法區中類靜態屬性引用的物件
  • 方法區中常量引用的物件
  • 本地方法棧中 JNI(即一般說的 Native 方法)引用的物件

2.2 垃圾收集演算法

2.2.1 標記 — 清除演算法

最基礎的收集演算法就是「標記 — 清除」(Mark - Sweep)演算法,如同它的名字一樣,演算法分為「標記」和「清除」兩個階段:

  • 標記出所有需要回收的物件

  • 在標記完成後統一回收所有被標記的物件

之所以說它是最基礎的收集演算法,是因為後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。它的主要不足主要有兩個:

  • 效率問題,標記和清除兩個過程的效率都不高

  • 空間問題,標記清除之後會產生大量不連續的記憶體碎片

記憶體碎片太多,可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

2.2.2 複製演算法

為了解決效率問題,一種稱為「複製」的收集演算法出現了,它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。

這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。只是這種演算法的代價是將記憶體縮小為原來的一半。

2.2.3 標記 — 整理演算法

複製演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費 50 % 的空間,就需要有額外的空間進行擔保,以應對被使用的記憶體中所有物件都 100% 存活的極端情況,所以在老年代一般不能直接選用這種演算法。

根據老年代的特點,提出了另一種「標記 — 整理」演算法,標記過程仍然與「標記 — 清理」演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。

2.2.4 分代收集演算法

當前商業虛擬機器的垃圾收集都採用「分代收集」演算法,這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊,一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。

在新生代中,每次垃圾收集都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集,而老年代中因為物件存活率高、沒有額外空間對它進行擔保,就必須採用「標記 — 清理」或者「標記 — 整理」演算法來回收。

二、Java 的引用型別


在 JDK 1.2 以前,Java 中引用的定義很傳統:如果 reference 型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。一個物件在這種定義下只有被引用或沒有被引用兩種狀態,對於描述一些「食之無味,棄之可惜」的物件就顯得無能為力了。

我們希望能描述這樣一類物件:當記憶體空間還足夠時,則能保留在記憶體之中,如果記憶體空間在進行垃圾回收後還是非常緊張,則可以拋棄這些物件,很多系統的快取功能都符合這樣的應用場景。

在 JDK 1.2 之後,Java 對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4 中,這四種引用強度一次逐漸減弱

  • 強引用:指在程式程式碼之中普遍存在的,類似 Object obj = new Object() 這類的引用,只要強引用還存在,垃圾回收器「永遠」不會回收掉被引用的物件

  • 軟引用:用來描述一些還有用但並非必需的物件。對於軟引用關聯著的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常

  • 弱引用:用來描述非必須物件的,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集之前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件

  • 虛引用:也被稱為幽靈引用或幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的 唯一目的就是能在這個物件被收集器回收時收到一個系統通知。

最後,用一張圖總結下它們之間的區別:

你真的懂 Java 的記憶體管理和引用型別嗎?


參考資料

《深入理解 Java 虛擬機器》

相關文章