今天,我將從內存管理的角度,進一步探索 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 虛擬機棧是在同一塊兒區域,這完全取決于技術實現的決定,并未在規范中強制。
## 考點分析
這是個 JVM 領域的基礎題目,我給出的答案依據的是[JVM 規范](https://docs.oracle.com/javase/specs/jvms/se9/html/jvms-2.html#jvms-2.5)中運行時數據區定義,這也和大多數書籍和資料解讀的角度類似。
JVM 內部的概念龐雜,對于初學者比較晦澀,我的建議是在工作之余,還是要去閱讀經典書籍,比如我推薦過多次的《深入理解 Java 虛擬機》。
今天這一講作為 Java 虛擬機內存管理的開篇,我會側重于:
* 分析廣義上的 JVM 內存結構或者說 Java 進程內存結構。
* 談到 Java 內存模型,不可避免的要涉及 OutOfMemory(OOM)問題,那么在 Java 里面存在哪些種 OOM 的可能性,分別對應哪個內存區域的異常狀況呢?
注意,具體 JVM 的內存結構,其實取決于其實現,不同廠商的 JVM,或者同一廠商發布的不同版本,都有可能存在一定差異。我在下面的分析中,還會介紹 Oracle Hotspot JVM 的部分設計變化。
## 知識擴展
首先,為了讓你有個更加直觀、清晰的印象,我畫了一個簡單的內存結構圖,里面展示了我前面提到的堆、線程棧等區域,并從數量上說明了什么是線程私有,例如,程序計數器、Java 棧等,以及什么是 Java 進程唯一。另外,還額外劃分出了直接內存等區域。

這張圖反映了實際中 Java 進程內存占用,與規范中定義的 JVM 運行時數據區之間的差別,它可以看作是運行時數據區的一個超集。畢竟理論上的視角和現實中的視角是有區別的,規范側重的是通用的、無差別的部分,而對于應用開發者來說,只要是 Java 進程在運行時會占用,都會影響到我們的工程實踐。
我這里簡要介紹兩點區別:
* 直接內存(Direct Memory)區域,它就是我在[專欄第 12 講](http://time.geekbang.org/column/article/8393)中談到的 Direct Buffer 所直接分配的內存,也是個容易出現問題的地方。盡管,在 JVM 工程師的眼中,并不認為它是 JVM 內部內存的一部分,也并未體現 JVM 內存模型中。
* JVM 本身是個本地程序,還需要其他的內存去完成各種基本任務,比如,JIT Compiler 在運行時對熱點方法進行編譯,就會將編譯后的方法儲存在 Code Cache 里面;GC 等功能需要運行在本地線程之中,類似部分都需要占用內存空間。這些是實現 JVM JIT 等功能的需要,但規范中并不涉及。
如果深入到 JVM 的實現細節,你會發現一些結論似乎有些模棱兩可,比如:
* Java 對象是不是都創建在堆上的呢?
我注意到有一些觀點,認為通過[逃逸分析](https://en.wikipedia.org/wiki/Escape_analysis),JVM 會在棧上分配那些不會逃逸的對象,這在理論上是可行的,但是取決于 JVM 設計者的選擇。據我所知,Oracle Hotspot JVM 中并未這么做,這一點在逃逸分析相關的[文檔](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/performance-enhancements-7.html#escapeAnalysis)里已經說明,所以可以明確所有的對象實例都是創建在堆上。
* 目前很多書籍還是基于 JDK 7 以前的版本,JDK 已經發生了很大變化,Intern 字符串的緩存和靜態變量曾經都被分配在永久代上,而永久代已經被元數據區取代。但是,Intern 字符串緩存和靜態變量并不是被轉移到元數據區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:對象實例都是分配在堆上。
接下來,我們來看看什么是 OOM 問題,它可能在哪些內存區域發生?
首先,OOM 如果通俗點兒說,就是 JVM 內存不夠用了,javadoc 中對[OutOfMemoryError](https://docs.oracle.com/javase/9/docs/api/java/lang/OutOfMemoryError.html)的解釋是,沒有空閑內存,并且垃圾收集器也無法提供更多內存。
這里面隱含著一層意思是,在拋出 OutOfMemoryError 之前,通常垃圾收集器會被觸發,盡其所能去清理出空間,例如:
* 我在[專欄第 4 講](http://time.geekbang.org/column/article/6970)的引用機制分析中,已經提到了 JVM 會去嘗試回收軟引用指向的對象等。
* 在[java.nio.BIts.reserveMemory()](http://hg.openjdk.java.net/jdk/jdk/file/9f62267e79df/src/java.base/share/classes/java/nio/Bits.java)方法中,我們能清楚的看到,System.gc() 會被調用,以清理空間,這也是為什么在大量使用 NIO 的 Direct Buffer 之類時,通常建議不要加下面的參數,畢竟是個最后的嘗試,有可能避免一定的內存不足問題。
~~~
-XX:+DisableExplictGC
~~~
當然,也不是在任何情況下垃圾收集器都會被觸發的,比如,我們去分配一個超大對象,類似一個超大數組超過堆的最大值,JVM 可以判斷出垃圾收集并不能解決這個問題,所以直接拋出 OutOfMemoryError。
從我前面分析的數據區的角度,除了程序計數器,其他區域都有可能會因為可能的空間不足發生 OutOfMemoryError,簡單總結如下:
* 堆內存不足是最常見的 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,這個已經[專欄第 11 講](http://time.geekbang.org/column/article/8369)介紹過。
今天是 JVM 內存部分的第一講,算是我們先進行了熱身準備,我介紹了主要的內存區域,以及在不同版本 Hotspot JVM 內部的變化,并且分析了各區域是否可能產生 OutOfMemoryError,以及 OOME 發生的典型情況。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?今天的思考題是,我在試圖分配一個 100M bytes 大數組的時候發生了 OOME,但是 GC 日志顯示,明明堆上還有遠不止 100M 的空間,你覺得可能問題的原因是什么?想要弄清楚這個問題,還需要什么信息呢?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?