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ū)域。
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位長度的
long
和double
類型占用 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 虛擬機,有興趣的朋友可以一同交流進步。
如果你覺得我的分享對你有幫助的話,請在下面??隨手點個喜歡 ??,你的肯定才是我最大的動力,感謝。