Java 虛擬機(jī)負(fù)責(zé)處理內(nèi)存,程序員無須關(guān)心,看起來很美好。然而一旦出現(xiàn)內(nèi)存泄漏或溢出,如果不了解虛擬機(jī)的內(nèi)存管理機(jī)制,那么排查錯(cuò)誤就是一種痛苦的工作。
1 運(yùn)行時(shí)的數(shù)據(jù)區(qū)域
根據(jù) Java 虛擬機(jī)規(guī)范(Java SE 7),Java 虛擬機(jī)把內(nèi)存劃分為以下幾個(gè)不同的數(shù)據(jù)區(qū)域:

- 橫條區(qū):是由所有線程共享的區(qū)域。
- 其他:線程私有(線程隔離)的區(qū)域。
1.1 程序計(jì)數(shù)器
程序計(jì)數(shù)器是一塊較小的區(qū)域,可以認(rèn)為是當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。在虛擬機(jī)概念模型中,字節(jié)碼解析器是通過這個(gè)計(jì)數(shù)器來選取下一條需要執(zhí)行的字節(jié)碼指令的,比如分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復(fù)等基礎(chǔ)操作都需要依賴這個(gè)計(jì)數(shù)器。
Java 虛擬機(jī)的多線程,是通過線程切換來分配處理器的執(zhí)行時(shí)間的。所以在任意時(shí)刻,一個(gè)處理器(多核處理器中指的是一個(gè)內(nèi)核)只會(huì)執(zhí)行一個(gè)線程中的指令。為了線程切換后能夠恢復(fù)到正確的執(zhí)行位置,每個(gè)線程都需要一個(gè)獨(dú)立的程序計(jì)數(shù)器。
程序計(jì)數(shù)器是唯一一個(gè)在規(guī)范中沒有規(guī)定出現(xiàn) OutOfMemoryError 情況的區(qū)域。
1.2 Java 虛擬機(jī)棧
Java 虛擬機(jī)棧也是線程私有的,它的生命周期與線程相同。虛擬機(jī)棧保存的是執(zhí)行 Java 方法的內(nèi)存模型,用于存儲(chǔ)局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接、方法出口等信息。
局部變量表存的是編譯期可知的基本數(shù)據(jù)類型(boolean、byte 等)、對(duì)象引用類型和 returnAddress 類型(指向一條字節(jié)碼指令的地址)。
64 位長(zhǎng)度的 long 和 double 類型的數(shù)據(jù)會(huì)占用 2 個(gè)局部變量空間,其他類型只會(huì)占用一個(gè)。
規(guī)范對(duì)這個(gè)區(qū)域規(guī)定了兩種異常情況:
- 如果線程請(qǐng)求的棧深度大于虛擬機(jī)所允許的深度,會(huì)拋出 StackOverflowError 異常;
- 如果虛擬機(jī)棧可以動(dòng)態(tài)擴(kuò)展(規(guī)范也允許固定長(zhǎng)度的虛擬機(jī)棧),在擴(kuò)展時(shí)無法申請(qǐng)到足夠的內(nèi)存,會(huì)拋出 OutOfMemoryError 異常。
1.3 本地方法棧
本地方法棧是為虛擬機(jī)用到的 Native 方法服務(wù)。規(guī)范沒有對(duì)本地方法棧使用的語言、方法和數(shù)據(jù)結(jié)構(gòu)進(jìn)行強(qiáng)制要求,所以具體的虛擬機(jī)可以自由實(shí)現(xiàn)。甚至有的虛擬機(jī)(HotSpot)直接就把本地方法棧和虛擬機(jī)棧合二為一咯O(∩_∩)O~
本地方法棧與 Java 虛擬機(jī)棧一樣,也會(huì)拋出 StackOverflowError 或 OutOfMemoryError 異常。
1.4 Java 堆
對(duì)大多數(shù)應(yīng)用來說,Java 堆是 Java 虛擬機(jī)所管理的內(nèi)存中最大的一塊。它被所有線程共享,在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建。用于存放對(duì)象實(shí)例。
Java 堆是垃圾回收器主要的管理區(qū)域,因此它又被稱為 “GC 堆”(Garbage Collected Heap)。
根據(jù)規(guī)范,Java 堆可以處于物理上不連續(xù)(邏輯上連續(xù))的內(nèi)存空間中。當(dāng)前主流的虛擬機(jī)是按照可擴(kuò)展的方式實(shí)現(xiàn)的(通過 -Xmx 和 -Xms 控制)。如果堆中沒有內(nèi)存可以分配實(shí)例,而且也無法再擴(kuò)展時(shí),就會(huì)拋出 OutOfMemoryError 異常。
1.5 方法區(qū)
方法區(qū)(Method Area)和 java 堆一樣,是各個(gè)線程共享的內(nèi)存區(qū)域,它用于存儲(chǔ)已被虛擬機(jī)加載的類信息、常量、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù)。雖然 Java 虛擬機(jī)規(guī)范把方法描述為堆的一個(gè)邏輯部分,但是它卻有一個(gè)別名叫做Non-Heap(非堆),目的是為了與 Java 堆區(qū)分開來。
Java 虛擬機(jī)規(guī)范對(duì)方法區(qū)的限制非常寬松, 除了和 Java 堆一樣不需要連續(xù)的內(nèi)存和可以選擇固定大小或者可擴(kuò)展之外, 還可以選擇不實(shí)現(xiàn)垃圾收集。 相對(duì)而言, 垃圾收集行為在這個(gè)區(qū)域是比較少出現(xiàn)的, 但并非數(shù)據(jù)進(jìn)入了方法區(qū)就如永久代的名字一樣“ 永久” 存在了。 這個(gè)區(qū)域的回收“ 成績(jī)” 比較難以令人滿意, 尤其是類型的卸載, 條件相當(dāng)苛刻, 但是這部分區(qū)域的回收確實(shí)是必要的。 在 Sun 公司的 BUG 列表中, 曾出現(xiàn)過的若干個(gè)嚴(yán)重的 BUG 就是由于低版本的 HotSpot 虛擬機(jī)對(duì)此區(qū)域未完全回收而導(dǎo)致內(nèi)存泄漏。
根據(jù) Java 虛擬機(jī)規(guī)范的規(guī)定, 當(dāng)方法區(qū)無法滿足內(nèi)存分配需求時(shí), 將拋出 OutOfMemoryError 異常。
1.6 運(yùn)行時(shí)常量池
運(yùn)行時(shí)常量池(Runtime Costant Pool )是方法區(qū)的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息之外,還有一項(xiàng)信息是常量池(Constant Pool Table),用于存放編譯期生成的各種字面量和符號(hào)引用,這部分內(nèi)容將在類加載后進(jìn)入方法區(qū)的運(yùn)行時(shí)常量池中存放。
Java 虛擬機(jī)對(duì) Class 文件的每一部分的格式都有嚴(yán)格的規(guī)定,但對(duì)于運(yùn)行時(shí)常量池,Java 虛擬機(jī)規(guī)范沒有做任何細(xì)節(jié)的要求。
運(yùn)行時(shí)常量池相對(duì)于 Class 文件常量池的一個(gè)重要特征是具備動(dòng)態(tài)性,Java 語言并不要求常量一定只有在編譯期才能產(chǎn)生,也就是并非預(yù)置入
Class 文件中常量池的內(nèi)容才能進(jìn)入方法區(qū)運(yùn)行時(shí)常量池,運(yùn)行期間也可能將新的變量放入池中,這種特性被開發(fā)人員利用的比較多的便是 String類中的 intern() 方法。
既然運(yùn)行時(shí)常量池是方法區(qū)的一部分,自然受到方法區(qū)內(nèi)存的限制,當(dāng)常量池?zé)o法再申請(qǐng)到內(nèi)存時(shí)會(huì)拋出 OutOfMemoryError 異常。
1.7 直接內(nèi)存
在 JDK1.4 中新加入了 NIO (New Input/Output)類,引入了一種基于通道與緩沖區(qū)的 I/O 方式,他可以使用 Native 函數(shù)庫直接分配堆外內(nèi)存,然后通過一個(gè)存儲(chǔ)在 Java 堆中的 DirectByteBuffer 對(duì)象作為這塊內(nèi)存的引用進(jìn)行操作。這樣能在一些場(chǎng)景中顯著地提高性能,因?yàn)檫@種方法避免了在Java 堆和 Native 堆中來回復(fù)制數(shù)據(jù)。
顯然,本機(jī)直接內(nèi)存的分配不會(huì)受到 Java 堆大小的限制,但是還是會(huì)受到本機(jī)總內(nèi)存大小以及處理器尋址空間的限制。服務(wù)器管理員在配置虛擬機(jī)參數(shù)時(shí),會(huì)根據(jù)實(shí)際內(nèi)存設(shè)置 -Xmx 等參數(shù)信息,但經(jīng)常忽略了直接內(nèi)存,這會(huì)使得各個(gè)內(nèi)存區(qū)域總和大于物理內(nèi)存限制(包括物理的和操作系統(tǒng)級(jí)的限制),從而導(dǎo)致動(dòng)態(tài)擴(kuò)展時(shí)出現(xiàn) OutOfMemoryError 異常。
2 HotSpot 虛擬機(jī)對(duì)象
我們以常用的虛擬機(jī) HotSpot 和常用的內(nèi)存區(qū)域 java 堆為例,來深入探討 HotSpot 虛擬機(jī)在 java 堆中對(duì)象分配、布局和訪問的全過程。
2.1 對(duì)象的創(chuàng)建
虛擬機(jī)在遇到一條 new 指令時(shí),首先將去檢查這個(gè)指令的參數(shù)是否能在常量池中定位到一個(gè)類的符號(hào)引用,并且檢查這個(gè)符號(hào)引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執(zhí)行相應(yīng)的類加載過程。
在類加載檢查通過后,虛擬機(jī)將將為新生對(duì)象分配內(nèi)存,對(duì)象所需的內(nèi)存大小在類加載完成后便可完全確定,為對(duì)象分配空間的任務(wù)等同于把一塊確定大小的內(nèi)存從 Java 堆中劃分出來。分配內(nèi)存的方法有這些:
- 指針碰撞 - 假設(shè) Java 堆中的內(nèi)存是絕對(duì)規(guī)整的,所有用過的內(nèi)存都放在一邊,空閑的內(nèi)存放在另一邊,中間放著一個(gè)指針作為分界點(diǎn)的指示器,那么分配內(nèi)存就僅僅把那個(gè)指針向空閑空間那邊挪動(dòng)一段與對(duì)象大小相等的距離。
- 空閑列表 - 假設(shè) Java 堆中的內(nèi)存不是絕對(duì)規(guī)整的,已使用的內(nèi)存和空閑的內(nèi)存相互交錯(cuò),那么虛擬機(jī)就必須維護(hù)一個(gè)列表,記錄有哪些內(nèi)存塊是可用的,在分配時(shí)從列表中找到一塊足夠大的空間劃分給對(duì)象實(shí)例,更更新列表上的記錄。
選擇哪一種分配方式由 java 堆是否規(guī)整來決定,而 java 堆是否規(guī)整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用 Serial、ParNew 等帶壓縮過程的收集器時(shí),系統(tǒng)采用的分配算法是指針碰撞,而使用 CMS 這種基于 Mark-Sweep 算法的收集器時(shí),則通常采用空閑列表算法。
對(duì)象創(chuàng)建在虛擬機(jī)中是非常頻繁的行為,因此要考慮在并發(fā)情況下保證線程安全。有兩種解決方案:
- 對(duì)分配內(nèi)存空間的動(dòng)作進(jìn)行同步處理 - 實(shí)際上虛擬機(jī)采用 CAS 配上失敗重試的方案來保證更新操作的原子性。
- 把分配內(nèi)存空間的動(dòng)作按照線程劃分在不同的空間之中進(jìn)行,即每個(gè)線程在 java
堆中預(yù)先分配一小塊內(nèi)存,稱為本地線程分配緩沖(Thread Local Allocation Buffer,TLAB)。哪個(gè)線程要分配內(nèi)存,就在哪個(gè)線程的 TLAB 上分配,只有 TLAB
用完并分配新的 TLAB 時(shí),才需要同步鎖定。虛擬機(jī)是否使用 TLAB,可以通過-XX:+/-UseTLAB參數(shù)來設(shè)定。
內(nèi)存分配完成后,虛擬機(jī)需要將分配到的內(nèi)存空間都初始化為零值(不包括對(duì)象頭),如果使用了 TLAB,這一工作也可以提前到 TLAB 分配時(shí)進(jìn)行。這一步操作保證了對(duì)象的實(shí)例字段在 Java 代碼中可以不賦初始值就可以直接使用。
從程序的角度來看,執(zhí)行 new 指令之后還會(huì)接著執(zhí)行 init 方法,把對(duì)象按照編碼者的意愿進(jìn)行初始化。這樣一個(gè)正在可用的對(duì)象才算完全產(chǎn)生出來。
HotSpot 虛擬機(jī)中 new 指令的代碼片段(bytecodeInterpreter.cpp):
CASE(_new): {
u2 index = Bytes::get_Java_u2(pc+1);
constantPoolOop constants = istate->method()->constants();
//確保常量池中存放的是已解釋的類
if (!constants->tag_at(index).is_unresolved_klass()) {
//斷言確保是 klassOop 和 instanceKlassOop
// Make sure klass is initialized and doesn't have a finalizer
oop entry = constants->slot_at(index).get_oop();
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry = (klassOop) entry;
assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
instanceKlass* ik = (instanceKlass*) k_entry->klass_part();
//確保對(duì)象所屬類型已經(jīng)經(jīng)過初始化階段
if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
size_t obj_size = ik->size_helper();//取對(duì)象長(zhǎng)度
oop result = NULL;
// If the TLAB isn't pre-zeroed then we'll have to do it
bool need_zero = !ZeroTLAB;//記錄是否需要將對(duì)象所有字段置零值
if (UseTLAB) {
result = (oop) THREAD->tlab().allocate(obj_size);
}
if (result == NULL) {
need_zero = true;
// Try allocate in shared eden(直接在 eden 中分配對(duì)象)
retry:
HeapWord* compare_to = *Universe::heap()->top_addr();
HeapWord* new_top = compare_to + obj_size;
// cmpxchg 是 x86 中的 CAS 指令(C ++ 方法),這里通過 CAS 指令來分配空間,如果并發(fā)失敗,會(huì)轉(zhuǎn)到 retry 中重試,直到成功分配為止
if (new_top <= *Universe::heap()->end_addr()) {
if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
goto retry;
}
result = (oop) compare_to;
}
}
if (result != NULL) {//如果需要,則為對(duì)象初始化零值
// Initialize object (if nonzero size and need) and then the header
if (need_zero ) {
HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
obj_size -= sizeof(oopDesc) / oopSize;
if (obj_size > 0 ) {
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
if (UseBiasedLocking) {//根據(jù)是否啟用偏向鎖來設(shè)置對(duì)象頭信息
result->set_mark(ik->prototype_header());
} else {
result->set_mark(markOopDesc::prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
// Slow case allocation
CALL_VM(InterpreterRuntime::_new(THREAD, METHOD->constants(), index),
handle_exception);
//將對(duì)象引用入棧,繼續(xù)執(zhí)行下一條指令
SET_STACK_OBJECT(THREAD->vm_result(), 0);
THREAD->set_vm_result(NULL);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
2.2 對(duì)象的內(nèi)存布局
在 HotSpot 虛擬機(jī)中,對(duì)象在內(nèi)存中存儲(chǔ)的布局可以分為 3 塊區(qū)域:對(duì)象頭(Header)、實(shí)例數(shù)據(jù)(Instance Data)和對(duì)齊填充(Padding)。
HotSpot 虛擬機(jī)的對(duì)象頭包括兩部分信息:
- Mark Word - 用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),如哈希碼、GC 分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程 ID、偏向時(shí)間戳等,這部分的數(shù)據(jù)的長(zhǎng)度在 32 位和 64 位的虛擬機(jī)(未開啟壓縮指針)中分別為 32bit 和 64bit。對(duì)象需要存儲(chǔ)的運(yùn)行時(shí)數(shù)據(jù)很多,其實(shí)已經(jīng)超出了 32 位、64 位 Bitmap 結(jié)構(gòu)所能記錄的限度了,但是對(duì)象頭信息是與對(duì)象自身定義的數(shù)據(jù)的概述,所以 Mark Word 被設(shè)計(jì)成一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存儲(chǔ)盡量多的信息,它會(huì)根據(jù)對(duì)象的狀態(tài)復(fù)用自己的存儲(chǔ)空間。以 32 位的 HotSpot 虛擬機(jī)為例:
| 存儲(chǔ)內(nèi)容 | 標(biāo)志位 | 狀態(tài) |
|---|---|---|
| 對(duì)象哈希碼、對(duì)象分代年齡 | 01 | 未鎖定 |
| 指向鎖記錄的指針 | 00 | 輕量級(jí)鎖定 |
| 指向重量級(jí)鎖的指針 | 10 | 重量級(jí)鎖定 |
| 空,不需要記錄的信息 | 11 | GC 標(biāo)記 |
| 偏向指針、偏向時(shí)間戳、對(duì)象分代年齡 | 01 | 可偏向 |
- 類型指針 - 即對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)會(huì)通過這個(gè)指針來確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。并不是所有的虛擬機(jī)實(shí)現(xiàn)都必須在對(duì)象數(shù)據(jù)上保留類型指針,換句話說,查找對(duì)象的元數(shù)據(jù)信息并不一定要經(jīng)過對(duì)象本身。另外,如果對(duì)象是一個(gè) java 數(shù)組,那在對(duì)象頭中還必須有一塊用于記錄數(shù)組長(zhǎng)度的數(shù)據(jù),因?yàn)樘摂M機(jī)可以通過普通 java 對(duì)象的元數(shù)據(jù)信息確定 java 對(duì)象的大小,但是從數(shù)組的元數(shù)據(jù)中無法確定數(shù)組的大小。
markOop.hpp 中的代碼注釋部分,描述了 32 bit 下 Mark Word 的存儲(chǔ)狀態(tài):
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
實(shí)例數(shù)據(jù)部分是對(duì)象真正存儲(chǔ)的有效信息,也是在程序代碼中所定義的各種類型字段內(nèi)容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的存儲(chǔ)順序會(huì)受到虛擬機(jī)分配策略參數(shù)(FieldAllocationStyle)和字段在 java 源碼中定義順序的影響。HotSpot 虛擬機(jī)默認(rèn)的分配策略為 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的字段總是被分配到一起。在滿足這個(gè)前提條件的情況下,在父類中定義的變量會(huì)出現(xiàn)在子類之前。如果 CompactFields 參數(shù)值為 true(默認(rèn)為true),那么子類之中較窄的變量也可能會(huì)插入到父類變量的空隙之中。
第三部分的對(duì)齊填充不是必然存在的,它僅僅起著占位符的作用。因?yàn)?HotSpot VM 的自動(dòng)內(nèi)存管理要求對(duì)象的起始地址必須是 8 字節(jié)的整數(shù)倍,所以對(duì)象的大小必須是 8 字節(jié)的整數(shù)倍。而對(duì)象頭部分剛好是 8 字節(jié)的倍數(shù)(1 倍或 2 倍),所以當(dāng)對(duì)象實(shí)例數(shù)據(jù)部分沒有對(duì)齊時(shí),就需要通過對(duì)齊填充來補(bǔ)全。
2.3 對(duì)象的訪問定位
java 程序需要通過棧上的 reference 數(shù)據(jù)來操作堆上的具體對(duì)象。由于 reference
類型在 java 虛擬機(jī)規(guī)范中只規(guī)定了一個(gè)指向?qū)ο蟮囊茫]有定義這個(gè)引用通過何種方式去定位、訪問堆中的對(duì)象的具體位置,所以對(duì)象訪問方式也是取決于虛擬機(jī)的實(shí)現(xiàn)而定的。目前主流的訪問方式有使用句柄和直接指針兩種方式。
- 句柄 - java 堆中將會(huì)劃分出一塊內(nèi)存來作為句柄池,reference 中存儲(chǔ)的就是對(duì)象的句柄地址,而句柄中包含了對(duì)象實(shí)例數(shù)據(jù)與類型數(shù)據(jù)各自的具體地址信息。

- 直接指針 - 在 reference 中存儲(chǔ)的是對(duì)象地址。

這兩種對(duì)象訪問方式各有優(yōu)勢(shì),使用句柄來訪問的最大好處就是 reference 中存儲(chǔ)的是穩(wěn)定的句柄地址,在對(duì)象被移動(dòng)(垃圾收集時(shí)移動(dòng)對(duì)象是非常普遍的行為)時(shí)只會(huì)改變句柄中的實(shí)例數(shù)據(jù)指針,而 reference 中存儲(chǔ)的對(duì)象的句柄地址不需要修改。
使用直接指針訪問方式的最大好處就是速度更快,它節(jié)省了一次指針定位的時(shí)間開銷。由于對(duì)象的訪問在 Java 中非常頻繁,因此此類開銷積少成多后也是一項(xiàng)非??捎^的執(zhí)行成本。就 Sun HotSpot 而言,它是使用直接指針來訪問對(duì)象的,但從整個(gè)軟件開發(fā)范圍來看,各種語言或框架使用句柄來訪問的情況也十分常見。
3 OutOfMemoryError 異常
這里將通過若干實(shí)例來驗(yàn)證 OutOfMemoryError 異常發(fā)生的場(chǎng)景,并會(huì)初步介紹幾個(gè)與內(nèi)存相關(guān)的最基本的虛擬機(jī)參數(shù)。
我們的目的有兩個(gè):第一,通過代碼驗(yàn)證 Java 虛擬機(jī)規(guī)范中描述的各個(gè)運(yùn)行時(shí)區(qū)域存儲(chǔ)的內(nèi)容;第二,希望開發(fā)者在工作中遇到實(shí)際的內(nèi)存溢出異常時(shí),能根據(jù)異 常的信息快速判斷是哪個(gè)區(qū)域出現(xiàn)內(nèi)存溢出,知道什么樣的代碼可能會(huì)導(dǎo)致這些區(qū)域的內(nèi)存溢出,以及出現(xiàn)這些異常后該如何處理。
下面代碼的開頭都注釋了執(zhí)行時(shí)所需要設(shè)置的虛擬機(jī)啟動(dòng)參數(shù)(“VM options” 后面跟著的參數(shù)),這些參數(shù)對(duì)實(shí)驗(yàn)的結(jié)果有直接影響,所以調(diào)試代碼時(shí),不要忘了哦。

下面的代碼都是基于 JDK1.8 運(yùn)行的,對(duì)于不同公司的不同版本的虛擬機(jī) ,參數(shù)和程序運(yùn)行的結(jié)果可能會(huì)有所差別。
3.1 Java 堆溢出
Java 堆用于存儲(chǔ)對(duì)象實(shí)例,只要不斷地創(chuàng)建對(duì)象,并且保證 GC Roots 到對(duì)象之間有可達(dá)路徑來避免垃圾回收機(jī)制清除這些對(duì)象,那么在對(duì)象數(shù)量到達(dá)最大堆的容量限制后就會(huì)產(chǎn)生內(nèi)存溢出異常。
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
VM options:
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
VM 參數(shù)限制了 Java 堆的大小為 20MB,并且不可擴(kuò)展(將堆的最小值 -Xms 參數(shù)與最大值 -Xmx 參數(shù)設(shè)置為一樣即可避免堆自動(dòng)擴(kuò)展),通過參數(shù)-XX:+HeapDumpOnOutOfMemoryError 可以讓虛擬機(jī)在出現(xiàn)內(nèi)存溢出異常時(shí) Dump 出當(dāng)前的內(nèi)存堆轉(zhuǎn)儲(chǔ)快照以便事后進(jìn)行分析。
運(yùn)行結(jié)果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid8872.hprof ...
Heap dump file created [28637555 bytes in 0.153 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at net.deniro.jvm.HeapOOM.main(HeapOOM.java:20)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:140)
Java 堆內(nèi)存的 OOM 異常是實(shí)際應(yīng)用中常見的內(nèi)存溢出異常。當(dāng)出現(xiàn) Java 堆內(nèi)存溢出時(shí) ,異常堆棧信息 “java.lang.OutOfMemoryError” 會(huì)跟著進(jìn)一步提示“Java heap space”。
要解決這個(gè) Java 堆異常,一般的手段是先通過內(nèi)存映像分析工具(如 Eclipse Memory Analyzer ) 對(duì) Dump 出來的堆轉(zhuǎn)儲(chǔ)快照進(jìn)行分析,重點(diǎn)是確認(rèn)內(nèi)存中的對(duì)象是否是必要的,也就是要先分清楚到底是出現(xiàn)了內(nèi)存泄漏(Memory Leak) 還是內(nèi)存溢出(Memory Overflow) 。 下面顯示了使用 Eclipse Memory Analyzer 打開的堆轉(zhuǎn)儲(chǔ)快照文件。

如果是內(nèi)存泄露,可進(jìn)一步通過工具查看泄露對(duì)象到 GC Roots 的引用鏈。這樣就能找到泄露對(duì)象是通過怎樣的路徑與 GC Roots 相關(guān)聯(lián)并導(dǎo)致垃圾收集器無法自動(dòng)回收它們的問題所在咯。掌握了泄露對(duì)象的類型信息及 GC Roots 引用鏈的信息,就可以比較準(zhǔn)確地定位出泄露代碼的位置。
如果不存在泄露,即內(nèi)存中的對(duì)象確實(shí)都還必須存活著,那就應(yīng)當(dāng)檢查虛擬機(jī)的堆參數(shù)(-Xmx與-Xms ) 與機(jī)器物理內(nèi)存比較,看是否還可以調(diào)大,從代碼上檢查是否存在某些對(duì)象生命周期過長(zhǎng)、持有狀態(tài)時(shí)間過長(zhǎng)的情況,嘗試減少程序運(yùn)行期的內(nèi)存消耗。
以上是處理 Java 堆內(nèi)存問題的基本思路,處理這些問題所需要的知識(shí)、工具與經(jīng)驗(yàn)以后我們會(huì)說到哦O(∩_∩)O~。
3.2 虛擬機(jī)棧和本地方法棧溢出
由于在 HotSpot 虛擬機(jī)中并不區(qū)分虛擬機(jī)棧和本地方法棧,因此,對(duì)于 HotSpot 來 說,雖然 -Xoss 參數(shù) (設(shè)置本地方法棧大小)存在,但實(shí)際上是無效的,棧容量只由 -Xss 參數(shù)設(shè)定。 關(guān)于虛擬機(jī)棧和本地方法棧,在 Java 虛擬機(jī)規(guī)范中描述了兩種異常:
- 如果線程請(qǐng)求的棧深度大于虛擬機(jī)所允許的最大深度,將拋出 StackOverflowError 異常。
- 如果虛擬機(jī)在擴(kuò)展棧時(shí)無法申請(qǐng)到足夠的內(nèi)存空間,則拋出 OutOMemoryError 異常。
這里把異常分成兩種情況,看似更加嚴(yán)謹(jǐn),但卻存在著一些互相重疊的地方:當(dāng)??臻g無法繼續(xù)分配時(shí),到底是內(nèi)存太小,還是已使用的??臻g太大,其本質(zhì)上只不過是對(duì)同一件事情的兩方面的描述而已。
使用 -Xss 參數(shù)減少了棧內(nèi)存容量。結(jié)果會(huì)拋出 StackOverflowError 異常,異常出現(xiàn)時(shí)輸出的堆棧深度相應(yīng)縮小。
VM options: -Xss128k
public class JavaVMStackSOF {
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;
}
}
}
測(cè)試結(jié)果:
stack length:977
Exception in thread "main" java.lang.StackOverflowError
at net.deniro.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:14)
at net.deniro.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
at net.deniro.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:15)
結(jié)果表明:在單個(gè)線程下,無論是由于棧幀(一個(gè)方法中包含的本地變量數(shù))太大還是虛擬機(jī)棧容量(-Xss 參數(shù)減少每個(gè)線程棧內(nèi)存容量)太小,當(dāng)內(nèi)存無法分配的時(shí),虛擬機(jī)拋出的都是 StackOverflowError 異常。
如果測(cè)試時(shí)不限于單線程,通過不斷地建立線程的方式倒是可以產(chǎn)生內(nèi)存溢出異常。但是這樣產(chǎn)生的內(nèi)存溢出異常與棧空間是否足夠大并不存在任何聯(lián)系,或者準(zhǔn)確地說,在這種情況下,為每個(gè)線程的棧分配的內(nèi)存越大,反而越容易產(chǎn)生內(nèi)存溢出異常。
原因不難理解,操作系統(tǒng)分配給每個(gè)進(jìn)程的內(nèi)存是有限制的,譬如 32 位的Windows 限制為 2GB。虛擬機(jī)提供了參數(shù)來控制 Java 堆和方法區(qū)的這兩部分內(nèi)存的最大值。剩余的內(nèi)存為 2GB ( 操作系統(tǒng)限制)減去 Xmx ( 最大堆容量),再減去MaxPermSize (最大方法區(qū)容量),程序計(jì)數(shù)器消耗內(nèi)存很小,可以忽略掉。如果虛擬機(jī)進(jìn)程本身耗費(fèi)的內(nèi)存不計(jì)算在內(nèi),剩下的內(nèi)存就由虛擬機(jī)棧和本地方法棧 “瓜分” 了。每個(gè)線程分配到的棧容量越大,可以建立的線程數(shù)量自然就越少,建立線程時(shí)也就越容易把剩下的內(nèi)存耗盡。
因此開發(fā)者在開發(fā)多線程的應(yīng)用時(shí)要特別注意,出現(xiàn) StackOverflowError 異常時(shí)有錯(cuò)誤的堆棧信息被打印出來,相對(duì)來說,比較容易找到問題的所在。而且 ,如果使用虛擬機(jī)默認(rèn)參數(shù),棧深度在大多數(shù)情況下(因?yàn)槊總€(gè)方法壓入棧的幀大小并不是一樣的)達(dá)到 1000?2000 完全沒有問題,對(duì)于正常的方法調(diào)用(包括遞歸),這個(gè)深度應(yīng)該完全夠用了。但是 ,如果是建立了過多的線程而導(dǎo)致的內(nèi)存溢出,在不能減少線程數(shù)或者更換 64 位虛擬機(jī)的情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。如果沒有這方面的處理經(jīng)驗(yàn),這種通過 “減少內(nèi)存” 的手段來解決內(nèi)存溢出的方式會(huì)比較難以想到哦O(∩_∩)O~
3.3 方法區(qū)和運(yùn)行時(shí)常量池溢出
由于運(yùn)行時(shí)常量池是方法區(qū)的一部分,因此這兩個(gè)區(qū)域的溢出測(cè)試就放在一起進(jìn)行。前面提到 JDK 1.7 開始逐步 “去永久代” ,在此就以測(cè)試代碼觀察一下這件事對(duì)程序的實(shí)際影響。
VM options: -XX:PermSize=10M -XX:MaxPermSize=10M
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
//list 中保持著常量池的引用,避免 Full GC 回收常量池行為
List<String> list = new ArrayList<String>();
//10MB 的 PermSize 在 integer 范圍內(nèi)可以產(chǎn)生內(nèi)存溢出異常咯
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
在 JDK1.8 中,while 循環(huán)將一直進(jìn)行下去。而在 JDK 1.6 及之前的版本中,由于常量池分配在永久代內(nèi),我們可以通過 -XX : PermSize 和 -XX : MaxPermSize 限制方法區(qū)大小,從而間接限制其中的常量池容量。
我們?cè)倏匆欢未a:
public class RuntimeConstantPoolOOM2 {
public static void main(String[] args) {
String str1=new StringBuilder("de").append("niro").toString();
System.out.println(str1.intern()==str1);
String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern()==str2);
}
}
輸出結(jié)果:
true
false
這段代碼在 JDK 1.6 中運(yùn)行,會(huì)得到兩個(gè) false,而在JDK 1.7及之后的版本(這里是 JDK 1.8)中運(yùn)行,會(huì)得到一個(gè) true 和一個(gè) false。產(chǎn)生差異的原因是:在JDK 1.6 中 , intern ( ) 方法會(huì)把首次遇到的字符串實(shí)例復(fù)制到永久代中,返回的也是永久代中這個(gè)字符串實(shí)例的引用,而由 StringBuilder 創(chuàng)建的字符串實(shí)例在 Java 堆上,所以必然不是同一個(gè)引用,所以會(huì)返回 false。而JDK 1.7 (以及部分其他虛擬機(jī) ,例如JRockit) 的 intern ( ) 實(shí)現(xiàn)不會(huì)再復(fù)制實(shí)例,只是在常量池中記錄首次出現(xiàn)的實(shí)例引用,因此 intern( ) 返回的引用和由 StringBuilder()創(chuàng)建的那個(gè)字符串實(shí)例是同一個(gè)。對(duì) str2 比較返回 false 是因?yàn)?“java” 這個(gè)字符串在執(zhí)行 StringBuilder.toString ( ) 之前已經(jīng)出現(xiàn)過,字符串常量池中已經(jīng)有它的引用了,不符合 “首次出現(xiàn)” 的原則 ,而 “deniro” 這個(gè)字符串則是首次出現(xiàn)的,所以返回 true。
3.4 本機(jī)直接內(nèi)存溢出
DirectMemory 容量可通過 -XX : MaxDirectMemorySize 指定,如果不指定,則默認(rèn)與 Java 堆最大值(-Xmx指定)一樣 ,下面的代碼越過了 DirectByteBuffer 類 ,直接通過反射獲取 Unsafe 實(shí)例進(jìn)行內(nèi)存分配【Unsafe 類的 getUnsafe ( ) 方法限制了只有引導(dǎo)類加載器才會(huì)返回實(shí)例,也就是設(shè)計(jì)者希望只有 rt.jar 中的類才能使用Unsafe 的功能】。因?yàn)?,雖然使用 DirectByteBuffer 分配內(nèi)存也會(huì)拋出內(nèi)存溢出異常,但它拋出異常時(shí)并沒有真正向操作系統(tǒng)申請(qǐng)分配內(nèi)存,而是通過計(jì)算得知內(nèi)存無法分配,于是手動(dòng)拋出異常,真正申請(qǐng)分配內(nèi)存的方法 unsafe.allocateMemory ( ) 。
VM options: -Xmx20M -XX:MaxDirectMemorySize=10M
public class DirectMemoryOOM {
public static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
運(yùn)行結(jié)果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at net.deniro.jvm.DirectMemoryOOM.main(DirectMemoryOOM.java:21)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:140)
由于 DirectMemory 導(dǎo)致的內(nèi)存溢出,一個(gè)明顯的特征是在 Heap Dump 文件中不會(huì)看見明顯的異常,如果我們發(fā)現(xiàn)在 OOM 之后的 Dump文件很小,而程序中又直接或間接使用了 NIO,那就可以考慮檢查一下是不是這方面的原因啦O(∩_∩)O~