1. 程式人生 > >25、談談JVM記憶體區域的劃分,哪些區域可能發生OutOfMemoryError?

25、談談JVM記憶體區域的劃分,哪些區域可能發生OutOfMemoryError?

目錄

今天我要問你的問題是,談談 JVM 記憶體區域的劃分,哪些區域可能發生 OutOfMemoryError?

典型回答

JAVA的JVM的3個區:堆(heap)、棧(stack)和方法區(method) 

考點分析

知識擴充套件

接下來,我們來看看什麼是 OOM 問題,它可能在哪些記憶體區域發生?

OutOfMemoryError原因分析

一課一練


今天,我將從記憶體管理的角度,進一步探索 Java 虛擬機器(JVM)。垃圾收集機制為我們打理了很多繁瑣的工作,大大提高了開發的效率,但是,垃圾收集也不是萬能的,懂得 JVM 內部的記憶體結構、工作機制,是設計高擴充套件性應用和診斷執行時問題的基礎,也是 Java 工程師進階的必備能力。

今天我要問你的問題是,談談 JVM 記憶體區域的劃分,哪些區域可能發生 OutOfMemoryError?

典型回答

1、Java程式具體執行的過程

由於Java程式是交由JVM執行的,所以我們在談Java記憶體區域劃分的時候事實上是指JVM記憶體區域劃分。在討論JVM記憶體區域劃分之前,先來看一下Java程式具體執行的過程:                                      

  如上圖所示,首先Java原始碼檔案(.java字尾)會被Java編譯器編譯為位元組碼檔案(.class字尾),然後由JVM中的類載入器載入各個類的位元組碼檔案,載入完畢之後,交由JVM執行引擎執行。在整個程式執行過程中,JVM會用一段空間來儲存程式執行期間需要用到的資料和相關資訊,這段空間一般被稱作為Runtime Data Area(執行時資料區),也就是我們常說的JVM記憶體。因此,在Java中我們常常說到的記憶體管理就是針對這段空間進行管理(如何分配和回收記憶體空間)。

  在知道了JVM記憶體是什麼東西之後,下面我們就來討論一下這段空間具體是如何劃分區域的,是不是也像C語言中一樣也存在棧和堆呢?

2、執行時資料區包括哪幾部分?

通常可以把 JVM 記憶體區域分為下面幾個方面,其中,有的區域是以執行緒為單位,而有的區域則是整個 JVM 程序唯一的。

首先,程式計數器(PC,Program Counter Register)。在 JVM 規範中,每個執行緒都有它自己的程式計數器,並且任何時間一個執行緒都只有一個方法在執行,也就是所謂的當前方法。程式計數器會儲存當前執行緒正在執行的 Java 方法的 JVM 指令地址;或者,如果是在執行本地方法,則是未指定值(undefined)。

第二,Java 虛擬機器棧(Java Virtual Machine Stack),早期也叫 Java 棧。每個執行緒在建立時都會建立一個虛擬機器棧,其內部儲存一個個的棧幀(Stack Frame),對應著一次次的 Java 方法呼叫。
 
前面談程式計數器時,提到了當前方法;同理,在一個時間點,對應的只會有一個活動的棧幀,通常叫作當前幀,方法所在的類叫作當前類。如果在該方法中呼叫了其他方法,對應的新的棧幀會被創建出來,成為新的當前幀,一直到它返回結果或者執行結束。JVM 直接對 Java 棧的操作只有兩個,就是對棧幀的壓棧和出棧。

棧幀中儲存著區域性變量表、運算元(operand)棧、動態連結、方法正常退出或者異常退出的定義等。

第三,本地方法棧(Native Method Stack)。它和 Java 虛擬機器棧是非常相似的,支援對本地方法的呼叫,也是每個執行緒都會建立一個。在 Hotspot JVM 中,本地方法棧和 Java 虛擬機器棧是在同一塊兒區域,這完全取決於技術實現的決定,並未在規範中強制。

第四,堆(Heap),它是 Java 記憶體管理的核心區域,用來放置 Java 物件例項,幾乎所有建立的 Java 物件例項都是被直接分配在堆上。堆被所有的執行緒共享,在虛擬機器啟動時,我們指定的“Xmx”之類引數就是用來指定最大堆空間等指標。

理所當然,堆也是垃圾收集器重點照顧的區域,所以堆內空間還會被不同的垃圾收集器進行進一步的細分,最有名的就是新生代、老年代的劃分。

第五,方法區(Method Area)。這也是所有執行緒共享的一塊記憶體區域,用於儲存所謂的元(Meta)資料,例如類結構資訊,以及對應的執行時常量池、欄位、方法程式碼等。

由於早期的 Hotspot JVM 實現,很多人習慣於將方法區稱為永久代(Permanent Generation)。JDK 8 中將永久代移除,同時增加了元資料區(Metaspace)。

第六,執行時常量池(Run-Time Constant Pool),這是方法區的一部分。如果仔細分析過反編譯的類檔案結構,你能看到版本號、欄位、方法、超類、介面等各種資訊,還有一項資訊就是常量池。Java 的常量池可以存放各種常量資訊,不管是編譯期生成的各種字面量,還是需要在執行時決定的符號引用,所以它比一般語言的符號表儲存的資訊更加寬泛。

 

JAVA的JVM的3個區:堆(heap)、棧(stack)和方法區(method) 

堆區: 
1.儲存的全部是物件,每個物件都包含一個與之對應的class的資訊。(class的目的是得到操作指令) 
2.jvm只有一個堆區(heap)被所有執行緒共享,堆中不存放基本型別和物件引用,只存放物件本身 


棧區: 
1.每個執行緒包含一個棧區,棧中只儲存基礎資料型別的物件和自定義物件的引用(不是物件),物件都存放在堆區中 
2.每個棧中的資料(原始型別和物件引用)都是私有的,其他棧不能訪問。 
3.棧分為3個部分:基本型別變數區、執行環境上下文、操作指令區(存放操作指令)。 


方法區: 
1.又叫靜態區,跟堆一樣,被所有的執行緒共享。方法區包含所有的class和static變數。 
2.方法區中包含的都是在整個程式中永遠唯一的元素,如class,static變數。 

public class AppMain                //執行時, jvm 把appmain的資訊都放入方法區
{
    public static void main(String[] args)  //main 方法本身放入方法區。
    {
        //test1是引用,所以放到棧區裡, Sample是自定義物件應該放到堆裡面
        Sample test1 = new Sample(" 測試1 "); 
        Sample test2 = new Sample(" 測試2 ");

        test1.printName();
        test2.printName();
    }
}
public class Sample        //執行時, jvm 把appmain的資訊都放入方法區
{
    /** 範例名稱 */
    private name;      //new Sample例項後, name 引用放入棧區裡,  name 物件放入堆裡

    /**
     * 構造方法
     */
    public Sample(String name) {
        this.name = name;
    }

    /**
     * 輸出
     */
    public void printName()   //print方法本身放入方法區裡。
    {
        System.out.println(name);
    }
} 

 

考點分析

這是個 JVM 領域的基礎題目,我給出的答案依據的是JVM 規範中執行時資料區定義,這也和大多數書籍和資料解讀的角度類似。

JVM 內部的概念龐雜,對於初學者比較晦澀,我的建議是在工作之餘,還是要去閱讀經典書籍,比如我推薦過多次的《深入理解 Java 虛擬機器》。

今天這一講作為 Java 虛擬機器記憶體管理的開篇,我會側重於:

  •   分析廣義上的 JVM 記憶體結構或者說 Java 程序記憶體結構。
  •   談到 Java 記憶體模型,不可避免的要涉及 OutOfMemory(OOM)問題,那麼在 Java 裡面存在哪些種 OOM 的可能性,分別對應哪個記憶體區域的異常狀況呢?

注意,具體 JVM 的記憶體結構,其實取決於其實現,不同廠商的 JVM,或者同一廠商釋出的不同版本,都有可能存在一定差異。我在下面的分析中,還會介紹 Hotspot JVM 的部分設計變化。

 

知識擴充套件

首先,為了讓你有個更加直觀、清晰的印象,我畫了一個簡單的記憶體結構圖,裡面展示了我前面提到的堆、執行緒棧等區域,並從數量上說明了什麼是執行緒私有,例如,程式計數器、Java 棧等,以及什麼是 Java 程序唯一。另外,還額外劃分出了直接記憶體等區域。

這張圖反映了實際中 Java 程序記憶體佔用,與規範中定義的 JVM 執行時資料區之間的差別,它可以看作是執行時資料區的一個超集。畢竟理論上的視角和現實中的視角是有區別的,規範側重的是通用的、無差別的部分,而對於應用開發者來說,只要是 Java 程序在執行時會佔用,都會影響到我們的工程實踐。

我這裡簡要介紹兩點區別:

  •   直接記憶體(Direct Memory)區域,它就是我在專欄第 12 講中談到的 Direct Buffer 所直接分配的記憶體,也是個容易出現問題的地方。儘管,在 JVM 工程師的眼中,並不認為它是 JVM 內部記憶體的一部分,也並未體現 JVM 記憶體模型中。
  •   JVM 本身是個本地程式,還需要其他的記憶體去完成各種基本任務,比如,JIT Compiler 在執行時對熱點方法進行編譯,就會將編譯後的方法儲存在 Code Cache 裡面;GC 等功能需要執行在本地執行緒之中,類似部分都需要佔用記憶體空間。這些是實現 JVM JIT 等功能的需要,但規範中並不涉及。


如果深入到 JVM 的實現細節,你會發現一些結論似乎有些模稜兩可,比如:

  •   Java 物件是不是都建立在堆上的呢?

我注意到有一些觀點,認為通過逃逸分析,JVM 會在棧上分配那些不會逃逸的物件,這在理論上是可行的,但是取決於 JVM 設計者的選擇。據我所知,Hotspot JVM 中並未這麼做,這一點在逃逸分析相關的文件裡已經說明,所以可以明確所有的物件例項都是建立在堆上。

  •   目前很多書籍還是基於 JDK 7 以前的版本,JDK 已經發生了很大變化,Intern 字串的快取和靜態變數曾經都被分配在永久代上,而永久代已經被元資料區取代。但是,Intern 字串快取和靜態變數並不是被轉移到元資料區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:物件例項都是分配在堆上。

 

接下來,我們來看看什麼是 OOM 問題,它可能在哪些記憶體區域發生?

首先,OOM 如果通俗點兒說,就是 JVM 記憶體不夠用了,java doc 中對OutOfMemoryError的解釋是,沒有空閒記憶體,並且垃圾收集器也無法提供更多記憶體。

這裡面隱含著一層意思是,在丟擲 OutOfMemoryError 之前,通常垃圾收集器會被觸發,盡其所能去清理出空間,例如:

  •   我在專欄第 4 講的引用機制分析中,已經提到了 JVM 會去嘗試回收軟引用指向的物件等。
  •   在java.nio.BIts.reserveMemory() 方法中,我們能清楚的看到,System.gc() 會被呼叫,以清理空間,這也是為什麼在大量使用 NIO 的 Direct Buffer 之類時,通常建議不要加下面的引數,畢竟是個最後的嘗試,有可能避免一定的記憶體不足問題。
-XX:+DisableExplictGC

當然,也不是在任何情況下垃圾收集器都會被觸發的,比如,我們去分配一個超大物件,類似一個超大陣列超過堆的最大值,JVM 可以判斷出垃圾收集並不能解決這個問題,所以直接丟擲 OutOfMemoryError

 

OutOfMemoryError原因分析

從我前面分析的資料區的角度,除了程式計數器,其他區域都有可能會因為可能的空間不足發生 OutOfMemoryError,簡單總結如下:

  •   堆記憶體不足是最常見的 OOM 原因之一,丟擲的錯誤資訊是“java.lang.OutOfMemoryError:Java heap pace”,原因可能千奇百怪,例如,可能存在記憶體洩漏問題;也很有可能就是堆的大小不合理,比如我們要處理比較可觀的資料量,但是沒有顯式指定 JVM 堆大小或者指定數值偏小;或者出現 JVM 處理引用不及時,導致堆積起來,記憶體無法釋放等
  •   而對於 Java 虛擬機器棧和本地方法棧,這裡要稍微複雜一點。如果我們寫一段程式不斷的進行遞迴呼叫,而且沒有退出條件,就會導致不斷地進行壓棧。類似這種情況,JVM 實際會丟擲 StackOverFlowError;當然,如果 JVM 試圖去擴充套件棧空間的的時候失敗,則會丟擲 OutOfMemoryError。
  •   對於老版本的 JDK,因為永久代的大小是有限的,並且 JVM 對永久代垃圾回收(如,常量池回收、解除安裝不再需要的型別)非常不積極,所以當我們不斷新增新型別的時候,永久代出現 OutOfMemoryError 也非常多見,尤其是在執行時存在大量動態型別生成的場合;類似 Intern 字串快取佔用太多空間,也會導致 OOM  問題。對應的異常資訊,會標記出來和永久代相關:“java.lang.OutOfMemoryError: PermGen space”。
  •   隨著元資料區的引入,方法區記憶體已經不再那麼窘迫,所以相應的 OOM 有所改觀,出現  OOM,異常資訊則變成了:“java.lang.OutOfMemoryError: Metaspace”。
  •   直接記憶體不足,也會導致 OOM,這個已經專欄第 11 講介紹過。


今天是 JVM 記憶體部分的第一講,算是我們先進行了熱身準備,我介紹了主要的記憶體區域,以及在不同版本 Hotspot JVM 內部的變化,並且分析了各區域是否可能產生 OutOfMemoryError,以及 OOME 發生的典型情況。

 

一課一練

今天的思考題是,我在試圖分配一個 100M bytes 大陣列的時候發生了 OOME,但是 GC 日誌顯示,明明堆上還有遠不止 100M 的空間,你覺得可能問題的原因是什麼?想要弄清楚這個問題,還需要什麼資訊呢?

答:堆記憶體100M 包含了新生代(eden+s0+1)和老年代,大物件一般分配在老年代,那麼最有可能在分配過程中老年代的空間不足。