深入理解 Java 虛擬機(二)Java內(nèi)存區(qū)域與內(nèi)存溢出異常

Java內(nèi)存區(qū)域與內(nèi)存溢出異常

1.概述

  • 對于 Java 的開發(fā)者來說,在虛擬機的自動內(nèi)存管理機制的幫助下,不再需要為每一個 new 操作去寫配對的 delete / free 代碼,這樣不容易出現(xiàn)內(nèi)存泄露和內(nèi)存溢出的問題,只要全權(quán)交給虛擬機去處理。不過,也正是因為這樣,一旦出現(xiàn)內(nèi)存泄露和溢出方面的問題,如果不了解虛擬機是怎樣使用內(nèi)存的,那么排查錯誤將會異常艱難。
  • 所以我們只有了解了虛擬機的各個區(qū)域、各個區(qū)域的作用、服務對象等,才能在遇到內(nèi)存問題時去解決這些問題。

2.運行時數(shù)據(jù)區(qū)域

Java 虛擬機在執(zhí)行 Java 程序的過程中會把它所管理的內(nèi)存劃分為若干個不同的數(shù)據(jù)區(qū)域。這些區(qū)域都有各自的用途,以及創(chuàng)建和銷毀時間。根據(jù) 《Java 虛擬機規(guī)范》的規(guī)定,Java 虛擬機所管理的內(nèi)存將會包括以下幾個運行時數(shù)據(jù)區(qū)域。

運行時數(shù)據(jù)區(qū)

2.1 - 程序計數(shù)器(Program Counter Register)

  • 概述:該區(qū)域是一塊較小的內(nèi)存空間,它可以看作是當前線程所執(zhí)行的字節(jié)碼的 行號指示器
  • 作用:通過改變計數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令。(分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復等)基礎功能都依賴與其完成。
  • 特點:
    1.線程私有:因為 Java 虛擬機的多線程是通過 線程輪流切換分配處理器執(zhí)行時間 來實現(xiàn)的,在某一時刻,只會執(zhí)行一條線程。因此,為了線程切換后能恢復到正確的執(zhí)行位置,每條線程都需要有一個獨立的程序計數(shù)器。
    2.無內(nèi)存溢出:如果線程正在執(zhí)行的是一個 Java 方法,這個計數(shù)器記錄的是正在 執(zhí)行的虛擬機字節(jié)碼指令的地址;如果正在執(zhí)行的是 Native 方法,這個計數(shù)器值則為空(Undefined)。此內(nèi)存區(qū)域是唯一一個在 Java 虛擬機程序規(guī)范中沒有規(guī)定任何 OutOfMemoryError 情況的區(qū)域。

2.2 - Java 虛擬機棧(Java Virtual Machine Stacks)

2.2.1 - Java 虛擬機棧
  • 概述:描述 Java 方法執(zhí)行的內(nèi)存模型,每個方法從調(diào)用直至執(zhí)行的過程,對應著一個 棧幀 在虛擬機棧中入棧到出棧的過程。
  • 作用:存儲局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口等信息。
  • 特點:
    1.線程私有。
    2.生命周期與線程相同。
2.2.2 - 局部變量表
  • 概述:存放了編譯期間可知的各種基本數(shù)據(jù)類型(8種)、對象引用、returnAddress 類型(指向一條字節(jié)碼指令的地址)。
  • 占用空間:64位長度的 longdouble 類型占用 2 個局部變量空間(Slot),其余數(shù)據(jù)類型只占用 1 個。
  • 分配時機:在編譯期間完成分配,當進入一個方法時,這個方法所需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。

我們經(jīng)常將 Java 內(nèi)存分為堆內(nèi)存(Heap)和棧內(nèi)存(Stack),這種分法中所指的棧就是 Java 虛擬機棧,或者說是虛擬機棧中 局部變量表 部分。

2.2.3 - 對象引用
  • 概述:reference 類型,它不等同于對象本身,可能是一個指向?qū)ο笃鹗嫉刂返囊弥羔槪部赡苁侵赶蛞粋€代表對象的句柄或其他與此對象相關(guān)的位置。
2.2.4 - 異常
異常類型 發(fā)生條件
StackOverflowError 線程請求的棧深度大于虛擬機所允許的深度時拋出該異常。
OutOfMemoryError 無法申請到足夠的內(nèi)存時拋出該異常。

2.3 - 本地方法棧(Native Method Stack)

  • 概述:與虛擬機棧類似,是為虛擬機使用到的 Native 方法服務的內(nèi)存區(qū)域。
  • 區(qū)別:
  • 虛擬機棧:為虛擬機執(zhí)行 Java 方法(字節(jié)碼)服務。
  • 本地方法棧:為虛擬機使用到的 Native 方法服務。
  • 異常:與虛擬機棧一致。

在虛擬機規(guī)范中對本地方法棧中方法使用的語言、使用方式與數(shù)據(jù)結(jié)構(gòu)并沒有強制規(guī)定,因此具體的虛擬機可以自由實現(xiàn)它。甚至有的虛擬機(e.g. Sun HotSpot VM)直接將本地方法棧與虛擬機棧合二為一。

2.4 - Java 堆(Java Heap)

  • 概述:對于大多數(shù)應用來說,該區(qū)域是 Java 虛擬機所管理的內(nèi)存中最大的一塊區(qū)域。
  • 作用:此區(qū)域唯一的目的就是存放對象實例。
  • 特點:
    1.被所有線程共享。
    2.在虛擬機啟動時創(chuàng)建。
  • 異常
異常類型 發(fā)生條件
StackOverflowError
OutOfMemoryError 在堆中沒有內(nèi)存來完成實例分配,且堆無法再擴展時,拋出該異常。
  • 內(nèi)存:Java 堆可以處于物理上不連續(xù)的內(nèi)存空間中,只要邏輯上是連續(xù)的即可。在實現(xiàn)時,既可以實現(xiàn)成固定大小的,也可以是可擴展的,當前主流的虛擬機都是按照可擴展來實現(xiàn)的(通過 -Xmx-Xms 控制)。

Reminde ??
隨著 JIT 編譯器的發(fā)展與逃逸分析技術(shù)成熟,棧上分配標量替換 等優(yōu)化技術(shù)將會導致一些微妙的變化發(fā)生,所有的對象都分配在堆上也變得不那么絕對了。

2.5 - 方法區(qū)(Method Area)

  • 概述:Java 虛擬機規(guī)范將方法區(qū)描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的是與 Java 堆區(qū)分開。
  • 作用:存儲已被虛擬機加載的(類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼)等數(shù)據(jù)。
  • 特點:線程共享。
  • 異常
異常類型 發(fā)生條件
StackOverflowError
OutOfMemoryError 當方法區(qū)無法滿足內(nèi)存分配需求時,拋出該異常。
  • 內(nèi)存:Java 虛擬機規(guī)范對方法區(qū)的限制非常寬松,除了和 Java 堆一樣不需要連續(xù)的內(nèi)存空間和可以選擇固定大小或者可擴展外,可以選擇不實現(xiàn)垃圾收集

相對而言,垃圾收集行為在這個區(qū)域是比較少出現(xiàn)的,這個區(qū)域的內(nèi)存回收目標主要是針對 常量池的回收類型的卸載

2.6 - 運行時常量池(Runtime Constant Pool)

  • 概述:方法區(qū)的一部分。Class 文件中除了有類的(版本、字段、方法、接口)等描述信息外,還有一項信息就是常量池。
  • 作用:用于存放編譯器生成的各種 字面量符號引用
  • 動態(tài)性:Java 語言并不要求常量池一定只有編譯期才能產(chǎn)生,也就是并非預置入 Class 文件中常量池的內(nèi)容后才能進入方法區(qū)的運行時常量池,運行期間也可以將新的常量放入池中,這種特性用的比較廣泛的便是 String 類的 intern() 方法。
  • 異常
異常類型 發(fā)生條件
StackOverflowError
OutOfMemoryError 因為是方法區(qū)的一部分,所以受到方法區(qū)內(nèi)存的限制,當常量池無法再申請到內(nèi)存時拋出該異常。

Java 虛擬機對 Class 文件的每一部分(包括常量池)的格式都有嚴格規(guī)定,每一個字節(jié)用于存儲哪種數(shù)據(jù)類型必須符合規(guī)范上的要求才會被虛擬機認可、裝載和執(zhí)行,但對于運行時常量池,Java 虛擬機規(guī)范沒有做任何細節(jié)的要求,不同的提供商實現(xiàn)虛擬機可以按照自己的需求來實現(xiàn)這個內(nèi)存區(qū)域。一般來說,除了保存 Class 文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在該區(qū)域中。

2.7 - 直接內(nèi)存(Direct Memory)

  • 概述:并不是虛擬機運行時數(shù)據(jù)區(qū)的一部分,也不是 Java 虛擬機規(guī)范中定義的內(nèi)存區(qū)域。但是這部分內(nèi)存也被頻繁地使用,而且也可能導致 OutOfMemoryError 異常出現(xiàn)。
  • 作用:在 JDK1.4 中新加入了 NIO(New Input/Output) 類,引入了一種基于通道(Channel)與緩沖區(qū)(Buffer)的 I/O 方式,它可以使用 Native 函數(shù)庫直接分配堆外內(nèi)存,然后通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊內(nèi)存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回復制數(shù)據(jù)。
  • 異常
異常類型 發(fā)生條件
StackOverflowError
OutOfMemoryError 受到物理內(nèi)存限制,動態(tài)擴展時無法申請到內(nèi)存時拋出該異常。

顯然,本機直接內(nèi)存的分配不會受到 Java 堆大小的限制,但是,既然是內(nèi)存,肯定還是會受到本機內(nèi)存(包括 RAM 以及 SWAP 區(qū)或者分頁大小)大小以及處理器尋址空間的限制,當各個內(nèi)存區(qū)域總和大于物理內(nèi)存限制(包括物理和操作系統(tǒng)級的限制)時會出現(xiàn)異常。


3.HotSpot 虛擬機對象探秘

這一部分內(nèi)容將以 HotSpot 虛擬機和常用的內(nèi)存區(qū)域 Java 堆為例,闡述對象分配、布局和訪問的全過程。

3.1 - 對象的創(chuàng)建

  • 概述:Java 是一門面向?qū)ο蟮木幊陶Z言,在 Java 程序運行過程中無時無刻都有對象被創(chuàng)建出來。在語言層面上,創(chuàng)建對象通常僅僅是一個 new 關(guān)鍵字而已,而在虛擬機中對象的創(chuàng)建則分為以下幾個步驟。
3.1.1 - 類加載
  • 概述:虛擬機遇到一條 new 指令時,首先將去檢查指令參數(shù)是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執(zhí)行相應的類加載過程。
3.1.2 - 分配內(nèi)存
  • 概述:在類加載檢查通過后,接下來虛擬機將為新生對象分配內(nèi)存。對象所需內(nèi)存的大小在類加載完成后便可以完全確定,為對象分配空間的任務等同于把一塊確定大小的內(nèi)存從 Java 堆中劃分出來。
  • 分配方式:
    1.指針碰撞(Bump the Pointer):假設 Java 堆中內(nèi)存是絕對規(guī)整的,所有用過的內(nèi)存都放在一邊,空閑的內(nèi)存放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內(nèi)存就僅僅是把哪個指針向空閑那邊挪動一段與對象大小相等的距離。
    2.空閑列表(Free List):如果 Java 堆中的內(nèi)存不是規(guī)整的,已使用的內(nèi)存和空閑的內(nèi)存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內(nèi)存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄。

選擇哪種分配方式由 Java 堆是否規(guī)整決定,而 Java 堆是否規(guī)整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用 Serial、ParNew 等帶 Compact 過程的收集器時,系統(tǒng)采用的分配算法是指針碰撞,而使用 CMS 這種基于 Mark-Sweep 算法的收集器時,通常采用空閑列表。

3.1.3 - 同步控制
  • 概述:對象創(chuàng)建在虛擬機中是非常頻繁的行為,即使是僅僅修改一個指針所指向的位置,在并發(fā)情況下也并不是線程安全的,可能出現(xiàn)正在給對象 A 分配地址,指針還沒來得及修改,對象 B 又同時使用了原來的指針來分配內(nèi)存的情況。解決這個問題有兩種方案。
  • 方案一:對分配內(nèi)存空間的動作進行同步處理,虛擬機采用 CAS 配上失敗重試 的方式保證更新操作的原子性。
  • 方案二:將內(nèi)存分配的動作按照線程劃分在不同的空間中進行,每個線程在 Java 堆中預先分配一小塊內(nèi)存,稱為 本地線程分配緩沖(Thread Local Allocation Buffer, TLAB) 。哪個線程需要分配內(nèi)存,就在哪個線程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 時,才需要同步鎖定。通過 -XX:+/-UseTLAB 參數(shù)設定是否使用 TLAB。
3.1.4 - 初始化
  • 概述:內(nèi)存分配完成后,虛擬機需要將分配到的內(nèi)存空間都初始化為零值(不包括對象頭),如果使用 TLAB,這一過程就可以提前至 TLAB 分配時進行。
  • 作用:保證對象的實例字段在 Java 代碼中可以不賦初值就直接使用,程序能訪問到這些字段的數(shù)據(jù)類型對應的零值。
3.1.5 - 對象頭(Object Header)
  • 概述:接下來,虛擬機要為對象頭數(shù)據(jù)進行設置。(e.g. 對象的實例類、類的元數(shù)據(jù)信息的地址、對象的哈希碼、對象的 GC 分代年齡)
3.1.6 - init
  • 概述:在上面步驟完成后,從虛擬機的角度來看,一個新的對象已經(jīng)產(chǎn)生了,但從 Java 程序的角度來看,對象的創(chuàng)建才剛剛開始,<init> 方法還沒有被執(zhí)行,所有的字段還為零值。 一般來說,執(zhí)行 new 指令之后會接著執(zhí)行 <init> 方法,將對象按照我們的意愿進行初始化,這樣一個真正的對象才算完全產(chǎn)生。

3.2 - 對象的內(nèi)存布局

在 HotSpot 虛擬機中,對象在內(nèi)存中存儲的布局可以分為以下 3 塊區(qū)域。

3.2.1 - 對象頭(Header)

HotSpot 虛擬機的對象頭包括兩部分信息,存儲自身的運行時數(shù)據(jù)的(Mark Word)類型指針。

第一部分:Mark Word
  • 概述:用于存儲對象自身的運行時數(shù)據(jù),如(HashCode、GC 分代年齡、鎖狀態(tài)標志、線程持有鎖、偏向線程ID、偏向時間戳),這部分數(shù)據(jù)的長度在 32 位和 64 位的虛擬機中(未開啟壓縮指針)分別為 32bit 和 64bit。
  • 內(nèi)存:對象需要存儲的運行時數(shù)據(jù)很多,其實已經(jīng)超出了 32位、64位 Bitmap 結(jié)構(gòu)所能記錄的限度,但是對象頭信息是與對象自身定義的數(shù)據(jù)無關(guān)的額外存儲成本,考慮到虛擬機的空間效率,Mark Word 被設計成一個 非固定的數(shù)據(jù)結(jié)構(gòu) 以便在極小的空間內(nèi)存儲盡量多的信息,它會根據(jù)對象的狀態(tài)復用自己的存儲空間。
  • HotSpot 虛擬機對象頭 Mark Word 表如下 ??
存儲內(nèi)容 標志位 狀態(tài)
對象哈希碼、對象分代年齡 01 未鎖定
指向鎖記錄的指針 00 輕量級鎖定
指向重量級鎖的指針 10 膨脹(重量級鎖定)
空(不需要記錄信息) 11 GC 標記
偏向線程 ID、偏向時間戳、對象分代年齡 01 可偏向
第二部分:類型指針
  • 概述:即對象指向它的類元數(shù)據(jù)的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

Reminder ??
并不是所有的虛擬機實現(xiàn)都必須在對象數(shù)據(jù)上保留類型指針,換句話說,查找對象的元數(shù)據(jù)并不一定要經(jīng)過對象本身。

  • 數(shù)組對象:如果對象是一個 Java 數(shù)組,那在對象頭中還必須有一塊用于記錄數(shù)組長度的數(shù)據(jù),因為虛擬機可以通過普通 Java 對象的元數(shù)據(jù)信息確定 Java 對象的大小,但是從數(shù)組的元數(shù)據(jù)中卻無法確定數(shù)組的大小。
3.2.2 - 實例數(shù)據(jù)(Instance Data)
  • 概述:這部分是對象真正存儲的有效信息,也是在程序代碼中所定義的各種類型字段內(nèi)容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。
  • 存儲順序:這部分的存儲順序會受到虛擬機 分配策略參數(shù)(FieldsAllocationStyle) 和字段在 Java 源碼中定義順序的影響。HotSpot 虛擬機默認的分配策略為 longs/doubles => ints => shorts/chars => bytes/booleans => oops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的字段總是被分配到一起。在滿足這個前提條件的情況下,在父類中定義的變量會出現(xiàn)在子類之前。如果 CompactFields 參數(shù)值為 true(默認為 true),那么子類之中較窄的變量也可能會插入到父類變量的空隙之中。
3.2.3 - 對齊填充(Padding)
  • 概述:不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。
  • 原理:由于 HotSpot VM 的自動內(nèi)存管理系統(tǒng)要求 對象起始地址必須是 8 字節(jié)的整倍數(shù),換句話說,就是對象的大小必須是 8 字節(jié)的整倍數(shù)。而對象頭部分正好是 8 字節(jié)的整倍數(shù)( 1 倍或 2 倍),因此,當對象實例數(shù)據(jù)部分沒有對齊時,就需要通過對齊填充來補全。

3.3 - 對象的訪問定位

  • 概述:建立對象是為了使用對象,我們的 Java 程序需要通過棧上的 reference 數(shù)據(jù)來操作堆上的具體對象。由于 reference 類型在 Java 虛擬機規(guī)范中只規(guī)定了一個指向?qū)ο蟮囊茫]有定義這個引用應該通過何種方式去定位、訪問堆中的對象的具體位置,所以 對象訪問方式也是取決于虛擬機實現(xiàn)而定的。目前主流的訪問方式有兩種。
  • 句柄訪問:Java 堆中將會劃分出一塊內(nèi)存來作為句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數(shù)據(jù)與類型數(shù)據(jù)各自的具體地址信息,如下圖所示??。


    通過句柄訪問對象
  • 直接指針:Java 堆對象的布局中必須考慮如何放置訪問類型數(shù)據(jù)的相關(guān)信息,而 reference 中存儲的直接就是對象地址,如下圖所示??。


    通過直接指針訪問對象
  • 比較:
  • 句柄訪問:使用句柄訪問的最大好處就是 reference 中存儲的是 穩(wěn)定的 句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數(shù)據(jù)指針,而 reference 本身不需要修改。
  • 指針訪問:使用直接訪問最大的好處就是 速度快,它節(jié)省了一次指針定位的時間開銷,由于對象的訪問在 Java 中非常頻繁,因此這類開銷積少成多后也是一項非常可觀的執(zhí)行成本。

Sun HotSpot 使用的是第二種方式進行對象訪問的,但從整個軟件開發(fā)的范圍來看,各種語言和框架使用句柄來訪問的情況也十分常見。


4.實戰(zhàn):OutOfMemoryError 異常

在 Java 虛擬機規(guī)范的描述中,除了程序計數(shù)器外,虛擬機內(nèi)存的其他幾個運行時區(qū)域都有發(fā)生 OutOfMemory(OOM)異常的可能。

  • 目的:
    1.通過代碼驗證 Java 虛擬機規(guī)范中描述的各個運行時區(qū)域的存儲內(nèi)容。
    2.遇到實際的內(nèi)存溢出異常時,能根據(jù)異常的信息快速判斷哪個區(qū)域的內(nèi)存溢出。
    3.了解什么樣的代碼可能會導致這些區(qū)域內(nèi)存溢出,并了解如何處理。

VM Args 設置

  • Eclipse IDE:Debug Configurations => Java Application => YoungGenGC => Arguments 中的 VM arguments 中進行書寫(書寫參數(shù)以 - 開頭,以空格分隔)。
  • 控制臺:直接跟在 Java 命令之后書寫。
  • 本人運行在 Mac 系統(tǒng)下,使用 IDEA 進行配置,步驟如下所示??。


    虛擬機啟動參數(shù).gif

    1.打開 Run Configurations(? + ? + R 選擇 0 )或者(? + ? + A 輸入 run 選擇 run…)。
    2.點擊并打開 VM options。
    3.寫入虛擬機啟動參數(shù)。
    4.Apply 并 Run。

4.1 - Java 堆溢出

  • 概述:Java 堆用于存儲對象實例,只要不斷地創(chuàng)建對象,并且保證 GC Roots 到對象之間有可達路徑 來避免垃圾回收機制清除這些對象,那么在對象數(shù)量到達最大堆的容量限制后就會產(chǎn)生內(nèi)存溢出異常。
  • 測試環(huán)境:
-verbose:gc
-Xms20M // 最小 GC 啟動
-Xmx20M // 最大 GC 啟動
-XX:+PrintGCDetails // 打印設置
-XX:SurvivorRatio=8 // 存活對象比率
  • 測試代碼:
private static class OOMObject {
}
public static void main(String[] args) {
    List<OOMObject> list = new ArrayList<>();
    while (true) {
        list.add(new OOMObject());
    }
} 
  • 運行結(jié)果:
java.lang.OutOfMemoryError: Java heap space
  • 分析:Java 堆內(nèi)存的 OOM 異常是時機應用中常見的內(nèi)存溢出異常情況。當出現(xiàn) Java 堆內(nèi)存溢出時,異常堆棧信息 java.lang.OutOfMemoryError 會跟著進一步提示 Java heap space
  • 解決方式
    1.堆轉(zhuǎn)儲快照:要解決這個區(qū)域的異常,一般的手段是先通過內(nèi)存映像分析工具對 Dump 出來的堆轉(zhuǎn)儲快找進行分析,重點是確認內(nèi)存中的對象是否是必要的,也就是要分清楚到底是出現(xiàn)了內(nèi)存泄露(Memory Leak)還是內(nèi)存溢出(Memory Overflow)。
    2.內(nèi)存泄露:進一步通過工具查看泄露對象到 CG Roots 的引用鏈,于是就能找到內(nèi)存泄露對象是通過怎樣的路徑與 GC Roots 相關(guān)聯(lián)并導致垃圾收集器無法自動回收它們的。掌握了泄露對象的類型信息以及 GC Roots 引用鏈的信息,就可以比較準確地定位出泄露代碼的位置。
    3.內(nèi)存溢出:如果不存在泄露,換句話說,就是內(nèi)存中的對象確實都必須還活著,那就應當檢查虛擬機的堆參數(shù)(-Xmx-Xms),與機器物理內(nèi)存對象看是否還可以調(diào)大,從代碼上檢查是否存在某些對象生命周期過長、持有狀態(tài)過長的情況,嘗試減少程序運行期的內(nèi)存消耗。

4.2 - 虛擬機棧和本地方法棧溢出

  • 概述:由于在 HotSpot 虛擬機中并不區(qū)分虛擬機棧和本地方法棧,因此,對于 HotSpot 來說,雖然 -Xoos 參數(shù)(設置本地方法棧大小)存在,但實際上是無效的,棧容量只由 -Xss 參數(shù)設定。關(guān)于虛擬機棧和本地方法棧,在 Java 虛擬機規(guī)范中描述了兩種異常。
異常類型 發(fā)生條件
StackOverflowError 線程請求的棧深度大于虛擬機所允許的深度時拋出該異常。
OutOfMemoryError 無法申請到足夠的內(nèi)存時拋出該異常。

這里把異常分為兩種情況,看似更加嚴謹,但卻存在一些相互重疊的地方:方棧空間無法繼續(xù)分配時,到底是內(nèi)存太小,還是已使用的棧空間太大,其本質(zhì)上只是對同一件事的兩種描述而已。

4.2.1 - StackOverflowError
  • 測試環(huán)境:在此測試中,將測試范圍限制于單線程中操作。
    1.使用 -Xss 參數(shù)減少棧內(nèi)存容量,結(jié)果拋出 SOF 異常,異常出現(xiàn)時輸出的堆棧深度相應縮小。
    2.定義了大量的本地變量,增大此方法棧中本地變量表長度。結(jié)果拋出 SOF 異常時輸出的堆棧深度相應縮小。
VM Args: -Xss128k   // 棧內(nèi)存容量
  • 測試代碼
// 記錄堆棧深度
private int stackLength = 1;
public void stackLeak() {
    stackLength++;
    stackLeak();
}
public static void main(String[] args) throws Throwable  {
    JavaVMStackSOF oom = new JavaVMStackSOF();
    try {
        oom.stackLeak();
    } catch (Throwable e) {
        System.out.println("Stack length: " + oom.stackLength);
        throw e;
    }
} 
  • 運行結(jié)果
Stack length: 718
Exception in thread "main" java.lang.StackOverflowError
  • 分析:在單個線程下,無論是由于棧幀太大還是虛擬機棧容量太小,當內(nèi)存無法分配的時候,虛擬機拋出的都是 StackOverflowError 異常。但是這樣產(chǎn)生的內(nèi)存溢出異常與棧空間是否足夠大并不存在任何聯(lián)系,或者確切地說,在這種情況下,為每個線程的棧分配的內(nèi)存越大,反而越容易產(chǎn)生內(nèi)存溢出異常。
  • 理解:操作系統(tǒng)分配給每個進程的內(nèi)存是有限的,虛擬機提供了參數(shù)來控制 Java 堆和方法區(qū)的這兩部分內(nèi)存的最大值。剩余的內(nèi)存 -Xms(最大堆容量) -MaxPermSize(最大方法區(qū)容量),程序計數(shù)器消耗內(nèi)存很小,可以忽略不計。如果虛擬機進程本身耗費的內(nèi)存不計算在內(nèi),剩下的內(nèi)存就由虛擬機棧和本地方法棧瓜分了。每個線程分配到的棧容量越大,可以建立的線程數(shù)量自然越少,建立線程時就越容易把剩下的內(nèi)存耗盡。
  • 探索:出現(xiàn) SOF 異常時有錯誤堆棧可以閱讀,相對來說,比較容易找到問題的所在。而且,如果使用虛擬機默認參數(shù),棧深度在大多數(shù)情況下(因為每個方法壓入棧的幀大小并不是一樣的)達到 1000 - 2000 完全沒有問題,對于正常的方法調(diào)用(包括遞歸),這個深度應該完全夠用了。但是,如果建立過多線程導致內(nèi)存溢出,在不能減少線程數(shù)或者更換 64 位虛擬機的情況下,就只能通過 減少最大堆減少棧容量 來換更多的線程。
4.2.2 - OutOfMemoryError
  • 測試環(huán)境
VM Args: -Xss2M // 棧內(nèi)存容量
  • 測試代碼:創(chuàng)建線程導致內(nèi)存溢出異常
private void neverStop() {
    while (true) {
    }
}
// 循環(huán)開啟線程
public void stackLeakByThread() {
    while (true) {
        new Thread(this::neverStop).start();
    }
}
public static void main(String[] args) {
    JavaVMStackOOM oom = new JavaVMStackOOM();
    oom.stackLeakByThread();
}
  • 運行結(jié)果
Exception in thread “main” java.lang.OutOfMemoryError: unable to create new native thread

4.3 - 方法區(qū)和運行時常量池溢出

  • 概述:由于運行時常量池是方法區(qū)的一部分,因此這兩個區(qū)域的溢出測試就放在一起進行。
  • 腦補:String.intern() 是一個 Native 方法,它的作用是:如果字符串常量池中已經(jīng)包含一個等于此 String 對象的字符串,則返回代表池中這個字符串的 String 對象;否則,將此 String 對象包含的字符串添加到常量池中,并且返回此 String 對象的引用。在 JDK1.6 以及之前的版本中,由于常量池分配在永久代內(nèi),我們可以通過 -XX:PermSize-XX:MaxPermSize 限制方法區(qū)的大小名,從而間接限制其中常量池的容量。
4.3.1 - OutOfMemoryError
  • 測試環(huán)境
-XX:PermSize=10M    // 方法區(qū)最小值
-XX:MaxPermSize=10M // 方法區(qū)最大值
  • 測試代碼
public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    long i = 0;
    while (true) {
        list.add(String.valueOf(i++).intern());
    }
}
  • 運行結(jié)果
Exception in thread "main" java.lang.OutOfMemoryError: PermGen spacee
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)
  • 分析:從運行結(jié)果中可以看到,運行時常量池溢出,在 OutOfMemoryError 后面跟隨的提示信息是 PermGen space,說明運行時常量池屬于方法區(qū)(HotSpot 虛擬機中的永久代)的一部分。
4.3.2 - String 常量池測試

使用 JDK1.7 運行這段程序就不會得到相同的結(jié)果,while 循環(huán)將一直進行下去。關(guān)于這個字符串常量池的實現(xiàn)問題,還可以引申出一個更有意思的影響。

  • 測試代碼
String str1 = new StringBuilder("計算機").append("軟件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
  • 分析:
  • JDK1.6:會得到兩個 false,而在 JDK1.7 中運行,會得到一個 true 和一個 false。產(chǎn)生差異的原因是:是 JDK1.6 中 intern() 方法會把首次遇到的字符串實例復制到永久代中,返回的也是永久代中這個字符串實例的引用,而由 StringBuilder 創(chuàng)建的字符串實例在 Java 堆上,所以必然不是同一個引用,將返回 false
  • JDK1.7:intern() 實現(xiàn)不會再復制實例,只是在常量池中記錄首次出現(xiàn)的實例引用,因此 intern() 返回的引用和由 StringBuilder 創(chuàng)建的那個字符串實例是同一個。對 str2 比較返回 false 是因為 java 這個字符串在執(zhí)行 StringBuilder.toString() 之前已經(jīng)出現(xiàn)過,字符串常量池中已經(jīng)有它的引用了,不符合首次出現(xiàn)的原則,而 計算機軟件 這個字符串是首次出現(xiàn)的,因此返回 true
4.3.3 - 測試設計思路
  • 方法區(qū)用于存放 Class 的相關(guān)信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。對于這些區(qū)域的測試,基本思路就是運行時產(chǎn)生大量的類去填滿方法區(qū),直到溢出。另外的,直接使用 Java SE API 也可以動態(tài)產(chǎn)生類(如反射時的 GeneratedConstorAccessor 和動態(tài)代理等)。
4.3.4 - 總結(jié)
  • 方法區(qū)溢出是一種常見的內(nèi)存溢出異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經(jīng)常動態(tài)生成大量的 Class 的應用中,需要特別注意類的回收情況。這類場景除了上面提到的程序使用了 CGLib 字節(jié)碼增強和動態(tài)語言之外,常見的還有:大量 JSP 或動態(tài)產(chǎn)生 JSP 文件的應用(JSP 第一次運行時需要編譯為 Java 類)、基于 OSGi 應用(即使是同一個類文件,被不同的加載器加載也會視為不同的類)等。

4.4 - 本機直接內(nèi)存溢出

  • 概述:DirectMemory 容量可以通過 -XX:MaxDirectMemorySize 指定,如果不指定,則默認與 Java 堆最大值(-Xmx指定)一樣,下面的測試代碼越過了 DirectByteBuffer 類,直接通過反射獲取 Unsafe 實例進行內(nèi)存分配(Unsafe 類的 getUnsafe() 方法限制了只有引導類加載器才會返回實例,也就是設計者希望只有 rt.jar 中的類才能使用 Unsafe 的功能)。因為,雖然使用 DirectByteBuffer 分配內(nèi)存也會拋出內(nèi)存溢出異常,但它拋出異常時并沒有真正向操作系統(tǒng)申請分配內(nèi)存,而是通過計算得知內(nèi)存無法分配,于是手動拋出異常,真正申請分配內(nèi)存的方法是 unfase.allocateMemory()
  • 測試代碼
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    while (true) {
        unsafe.allocateMemory(_1MB);
    }
} 
  • 運行結(jié)果
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)
  • 分析:由 DirectMemory 導致的內(nèi)存溢出,一個明顯的特征是在 Heap Dump 文件中不會看到明顯的異常,如果發(fā)現(xiàn) OOM 之后 Dump 文件很小,而程序中又直接或間接使用了 NIO,那就可以考慮檢查一下是不是這方面的原因。

悄悄話 ??

  • 和大家說一聲抱歉,這么久才完成了我的第二篇讀書筆記,其中大家可以看到,文字性的內(nèi)容多是一方面,原作者的闡述中是有很大一部分是比較嚴謹性的且全面性的,這也導致了行文中的內(nèi)容分類是比較模糊的,這篇讀書筆記是我在整理過了一遍之后再次按照內(nèi)容分類整理出來的。目的是將原本散碎的知識點根據(jù)不同部分規(guī)整到一起,其中也深深體會到了原作者著書的不宜。
  • 上一篇讀書筆記發(fā)布至今大概一周,每天都能看到閱讀量和點贊數(shù)的提升,這時刻提醒我要繼續(xù)寫出更多、更高質(zhì)量的文章來和大家分享,同時也衷心感謝關(guān)注和支持我的簡友,如果沒有你們我真不知道能不能完成我后續(xù)的文章。
  • 關(guān)于 《深入理解 Java 虛擬機》的寫作最近可能要放緩了,因為馬上要開學了,需要投入到全新的知識的學習中去,但我答應大家一定會在空余時間盡量完成該專題的寫作。還有是之前欠大家的 《JavaSE 成長之路》的部分內(nèi)容我會在這幾天中盡力補全,也請大家繼續(xù)關(guān)注。
  • 之前關(guān)注了我的 JavaSE 成長之路 專題的小伙伴,接下來的幾天我也會將我之前的筆記整理出來陸續(xù)發(fā)布到這個專題之中,希望能和大家多多交流。

彩蛋 ??

  • 最近在拜讀同名一書 《深入理解 Java 虛擬機》并會與大家分享我的讀書筆記 深入理解 Java 虛擬機,有興趣的朋友可以一同交流進步。

如果你覺得我的分享對你有幫助的話,請在下面??隨手點個喜歡 ??,你的肯定才是我最大的動力,感謝。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內(nèi)容