天天看點

JVM 記憶體區域的劃分

前言

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

本篇博文的重點是,談談 JVM 記憶體區域的劃分,哪些區域可能發生 OutOfMemoryError?

概述

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

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

第二,Java 虛拟機棧(Java Virtual Machine Stack),早期也叫 Java 棧。每個線程在建立時都會建立一個虛拟機棧,其内部儲存一個個的棧幀(Stack Frame),對應着一次次的 Java 方法調用。

前面談程式計數器時,提到了目前方法;同理,在一個時間點,對應的隻會有一個活動的棧幀,通常叫作目前幀,方法所在的類叫作目前類。如果在該方法中調用了其他方法,對應的新的棧幀會被建立出來,成為新的目前幀,一直到它傳回結果或者執行結束。JVM 直接對 Java 棧的操作隻有兩個,就是對棧幀的壓棧和出棧。

棧幀中存儲着局部變量表、操作數(operand)棧、動态連結、方法正常退出或者異常退出的定義等。

第三,堆(Heap),它是 Java 記憶體管理的核心區域,用來放置 Java 對象執行個體,幾乎所有建立的 Java 對象執行個體都是被直接配置設定在堆上。堆被所有的線程共享,在虛拟機啟動時,我們指定的 “Xmx” 之類參數就是用來指定最大堆空間等名額。

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

第四,方法區(Method Area)。這也是所有線程共享的一塊記憶體區域,用于存儲所謂的元(Meta)資料,例如類結構資訊,以及對應的運作時常量池、字段、方法代碼等。

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

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

第六,本地方法棧(Native Method Stack)。它和 Java 虛拟機棧是非常相似的,支援對本地方法的調用,也是每個線程都會建立一個。在 Oracle Hotspot JVM 中,本地方法棧和 Java 虛拟機棧是在同一塊兒區域,這完全取決于技術實作的決定,并未在規範中強制。

正文

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

JVM 記憶體區域的劃分

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

這裡簡要介紹兩點差別:

  • 直接記憶體(Direct Memory)區域,它就是Direct Buffer 所直接配置設定的記憶體,也是個容易出現問題的地方。盡管,在 JVM 工程師的眼中,并不認為它是 JVM 内部記憶體的一部分,也并未展現 JVM 記憶體模型中。
  • JVM 本身是個本地程式,還需要其他的記憶體去完成各種基本任務,比如,JIT Compiler 在運作時對熱點方法進行編譯,就會将編譯後的方法儲存在 Code Cache 裡面;GC 等功能需要運作在本地線程之中,類似部分都需要占用記憶體空間。這些是實作 JVM JIT 等功能的需要,但規範中并不涉及。

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

  • Java 對象是不是都建立在堆上的呢?

我注意到有一些觀點,認為通過逃逸分析,JVM 會在棧上配置設定那些不會逃逸的對象,這在理論上是可行的,但是取決于 JVM 設計者的選擇。據我所知,Oracle Hotspot JVM 中并未這麼做,是以可以明确所有的對象執行個體都是建立在堆上。

  • 目前很多書籍還是基于 JDK 7 以前的版本,JDK 已經發生了很大變化,Intern 字元串的緩存和靜态變量曾經都被配置設定在永久代上,而永久代已經被中繼資料區取代。但是,Intern 字元串緩存和靜态變量并不是被轉移到中繼資料區,而是直接在堆上配置設定,是以這一點同樣符合前面一點的結論:對象執行個體都是配置設定在堆上。

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

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

  • 在 java.nio.BIts.reserveMemory() 方法中,我們能清楚的看到,System.gc() 會被調用,以清理空間,這也是為什麼在大量使用 NIO 的 Direct Buffer 之類時,通常建議不要加下面的參數,畢竟是個最後的嘗試,有可能避免一定的記憶體不足問題。
-XX:+DisableExplicitGC      
  • 堆記憶體不足是最常見的 OOM 原因之一,抛出的錯誤資訊是 “java.lang.OutOfMemoryError:Java heap space”,原因可能千奇百怪,例如,可能存在記憶體洩漏問題;也很有可能就是堆的大小不合理,比如我們要處理比較可觀的資料量,但是沒有顯式指定 JVM 堆大小或者指定數值偏小;或者出現 JVM 處理引用不及時,導緻堆積起來,記憶體無法釋放等。
  • 而對于 Java 虛拟機棧和本地方法棧,這裡要稍微複雜一點。如果我們寫一段程式不斷的進行遞歸調用,而且沒有退出條件,就會導緻不斷地進行壓棧。類似這種情況,JVM 實際會抛出 StackOverFlowError;當然,如果 JVM 試圖去擴充棧空間的的時候失敗,則會抛出 OutOfMemoryError。
  • 對于老版本的 Oracle JDK,因為永久代的大小是有限的,并且 JVM 對永久代垃圾回收(如,常量池回收、解除安裝不再需要的類型)非常不積極,是以當我們不斷添加新類型的時候,永久代出現 OutOfMemoryError 也非常多見,尤其是在運作時存在大量動态類型生成的場合;類似 Intern 字元串緩存占用太多空間,也會導緻 OOM 問題。對應的異常資訊,會标記出來和永久代相關:“java.lang.OutOfMemoryError: PermGen space”。
  • 随着中繼資料區的引入,方法區記憶體已經不再那麼窘迫,是以相應的 OOM 有所改觀,出現 OOM,異常資訊則變成了:“java.lang.OutOfMemoryError: Metaspace”。
  • 直接記憶體不足,也會導緻 OOM