《深入理解java虚拟机》第2章 Java内存区域与内存溢出异常
Java與C++之間有一堵由內(nèi)存動(dòng)態(tài)分配和垃圾收集技術(shù)所圍成的“高墻”,墻外面的人想進(jìn)去,墻里面的人卻想出來。
2.1 概述
https://blog.csdn.net/q5706503/article/details/84640762
對(duì)于從事C、C++程序開發(fā)的開發(fā)人員來說,在內(nèi)存管理領(lǐng)域,他們既是擁有最高權(quán)力的“皇帝”又是從事最基礎(chǔ)工作的“勞動(dòng)人民"一既擁有每一 個(gè)對(duì)象的“所有權(quán)”,又擔(dān)負(fù)著每一個(gè)對(duì)象生命開始到終結(jié)的維護(hù)責(zé)任。對(duì)于Java程序員來說,在虛擬機(jī)自動(dòng)內(nèi)存管理機(jī)制的幫助下,不再需要為每一個(gè)new操作去寫配對(duì)的de/free代碼,不容易出現(xiàn)內(nèi)存泄漏和內(nèi)存溢出問題,由虛擬機(jī)管理內(nèi)存這一切看起來都很美好。不過,也正是因?yàn)镴ava程序員把內(nèi)存控制的權(quán)力交給了Java虛擬機(jī),一旦出現(xiàn)內(nèi)存泄漏和溢出方面的問題,如果不了解虛擬機(jī)是怎樣使用內(nèi)存的,那么排查錯(cuò)誤將會(huì)成為一項(xiàng)異常艱難的工作。
本章是第二部分的第1章,筆者將從概念上介紹Java虛擬機(jī)內(nèi)存的各個(gè)區(qū)域,講解這些區(qū)域的作用、服務(wù)對(duì)象以及其中可能產(chǎn)生的問題,這是翻越虛擬機(jī)內(nèi)存管理這堵圍墻的第一步。
2.2運(yùn)行時(shí)數(shù)據(jù)區(qū)域
Java虛擬機(jī)在執(zhí)行Java程序的過程中會(huì)把它所管理的內(nèi)存劃分為若千個(gè)不同的數(shù)據(jù)區(qū)域。這些區(qū)域都有各自的用途,以及創(chuàng)建和銷毀的時(shí)間,有的區(qū)域隨著虛擬機(jī)進(jìn)程的啟動(dòng)而存在,有些區(qū)域則依賴用戶線程的啟動(dòng)和結(jié)束而建立和銷毀。根據(jù)《Java 虛擬機(jī)規(guī)范(Java SE 7版》的規(guī)定,Java 虛擬機(jī)所管理的內(nèi)存將會(huì)包括以下幾個(gè)運(yùn)行時(shí)數(shù)據(jù)區(qū)域,如圖2-1所示。
2.2.1程序計(jì)數(shù)器
程序計(jì)數(shù)器(Program Counter Register)是一塊較小的內(nèi)存空間,它可以看作是當(dāng)前線程所執(zhí)行的字節(jié)碼的行號(hào)指示器。在虛擬機(jī)的概念模型里( 僅是概念模型,各種虛擬機(jī)可能會(huì)通過一些更高效的方式去實(shí)現(xiàn)),字節(jié)碼解釋器工作時(shí)就是通過改變這個(gè)計(jì)數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復(fù)等基礎(chǔ)功能都需要依賴這個(gè)計(jì)數(shù)器來完成。
由于Java虛擬機(jī)的多線程是通過線程輪流切換并分配處理器執(zhí)行時(shí)間的方式來實(shí)現(xiàn)的,在任何一個(gè)確定的時(shí)刻,一個(gè)處理器(對(duì)于多核處理器來說是一個(gè)內(nèi)核) 都只會(huì)執(zhí)行一條線程中的指令。因此,為了線程切換后能恢復(fù)到正確的執(zhí)行位置,每條線程都需要有一個(gè)獨(dú)立的程序計(jì)數(shù)器,各條線程之間計(jì)數(shù)器互不影響,獨(dú)立存儲(chǔ),我們稱這類內(nèi)存區(qū)域?yàn)椤熬€程私有”的內(nèi)存。如果線程正在執(zhí)行的是-個(gè)Java方法,這個(gè)計(jì)數(shù)器記錄的是正在執(zhí)行的虛擬機(jī)字節(jié)碼指令的地址;如果正在執(zhí)行的是Native方法,這個(gè)計(jì)數(shù)器值則為空(Undefined)。 此內(nèi)存區(qū)域是唯一一個(gè)在 Java虛擬機(jī)規(guī)范中沒有規(guī)定任何OutOfMemoryError情況的區(qū)域。
2.2.2 Java 虛擬機(jī)棧
與程序計(jì)數(shù)器-樣, Java虛擬機(jī)棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。虛擬機(jī)棧描述的是Java方法執(zhí)行的內(nèi)存模型:每個(gè)方法在執(zhí)行的同時(shí)都會(huì)創(chuàng)建一個(gè)棧幀(Stack Frame9)用于存儲(chǔ)局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接、方法出口等信息。每一個(gè)方法從調(diào)用直至執(zhí)行完成的過程,就對(duì)應(yīng)著一個(gè)棧幀在虛擬機(jī)棧中人棧到出棧的過程。?
經(jīng)常有人把Java內(nèi)存區(qū)分為堆內(nèi)存(Heap)和棧內(nèi)存(Stack), 這種分法比較粗糙,Java內(nèi)存區(qū)域的劃分實(shí)際上遠(yuǎn)比這復(fù)雜。這種劃分方式的流行只能說明大多數(shù)程序員最關(guān)注的、與對(duì)象內(nèi)存分配關(guān)系最密切的內(nèi)存區(qū)域是這兩塊。其中所指的“堆”筆者在后面會(huì)專門講述,而所指的“棧”就是現(xiàn)在講的虛擬機(jī)棧,或者說是虛擬機(jī)棧中局部變量表部分。
局部變量表存放了編譯期可知的各種基本數(shù)據(jù)類型(boolean、 byte、 char、 short、 int、float、long、 double)、 對(duì)象引用(reference 類型,它不等同于對(duì)象本身,可能是一個(gè)指向?qū)ο笃鹗嫉刂返囊弥羔?#xff0c;也可能是指向一個(gè)代表對(duì)象的句柄或其他與此對(duì)象相關(guān)的位置)和returnAddress類型(指向了一條字節(jié)碼指令的地址)。
其中64位長度的long和double類型的數(shù)據(jù)會(huì)占用2個(gè)局部變量空間(Slot), 其余的數(shù)據(jù)類型只占用1個(gè)。局部變量表所需的內(nèi)存空間在編譯期間完成分配,當(dāng)進(jìn)入一個(gè)方法時(shí),,這個(gè)方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運(yùn)行期間不會(huì)改變局部變量表的大小。
在Java虛擬機(jī)規(guī)范中,對(duì)這個(gè)區(qū)域規(guī)定了兩種異常狀況:如果線程請(qǐng)求的棧深度大于虛擬機(jī)所允許的深度,將拋出StackOverflowError異常;如果虛擬機(jī)棧可以動(dòng)態(tài)擴(kuò)展(當(dāng)前大部分的Java虛擬機(jī)都可動(dòng)態(tài)擴(kuò)展,只不過Java虛擬機(jī)規(guī)范中也允許固定長度的虛擬機(jī)棧),如果擴(kuò)展時(shí)無法申請(qǐng)到足夠的內(nèi)存,就會(huì)拋出OutOfMemoryError異常。
2.2.3本 地方法棧
本地方法棧(Native Method Stack)與虛擬機(jī)棧所發(fā)揮的作用是非常相似的,它們之間的區(qū)別不過是虛擬機(jī)棧為虛擬機(jī)執(zhí)行Java方法(也就是字節(jié)碼)服務(wù),而本地方法棧則為虛擬機(jī)使用到的Native方法服務(wù)。在虛擬機(jī)規(guī)范中對(duì)本地方法棧中方法使用的語言、使用方式與數(shù)據(jù)結(jié)構(gòu)并沒有強(qiáng)制規(guī)定,因此具體的虛擬機(jī)可以自由實(shí)現(xiàn)它。甚至有的虛擬機(jī)(譬如Sun HotSpot虛擬機(jī))直接就把本地方法棧和虛擬機(jī)棧合二為一。與虛擬機(jī)棧一樣, 本地方法棧區(qū)域也會(huì)拋出StackOverflowError和OutOfMemoryError異常。
2.2.4 Java堆
對(duì)于大多數(shù)應(yīng)用來說,Java 堆(Java Heap)是Java虛擬機(jī)所管理的內(nèi)存中最大的一塊。Java堆是被所有線程共享的一塊內(nèi)存區(qū)域,在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建。此內(nèi)存區(qū)域的唯- - 目的就是存放對(duì)象實(shí)例,幾乎所有的對(duì)象實(shí)例都在這里分配內(nèi)存。這一點(diǎn)在Java虛擬機(jī)規(guī)范中的描述是:所有的對(duì)象實(shí)例以及數(shù)組都要在堆上分配e,但是隨著JIT編譯器的發(fā)展與逃逸分析技術(shù)逐漸成熟,棧上分配、標(biāo)量替換優(yōu)化技術(shù)將會(huì)導(dǎo)致一些微妙的變化發(fā)生,所有的對(duì)象都分配在堆上也漸漸變得不是那么“絕對(duì)”了。Java堆是垃圾收集器管理的主要區(qū)域,因此很多時(shí)候也被稱做“GC堆”(Garbage Collected Heap,幸好國內(nèi)沒翻譯成“垃圾堆”)。從內(nèi)存回收的角度來看,由于現(xiàn)在收集器基本都采用分代收集算法,所以Java堆中還可以細(xì)分為:新生代和老年代:再細(xì)致一點(diǎn)的有Eden空間、From Survivor空間、To Survivor空間等。從內(nèi)存分配的角度來看,線程共享的Java堆中可能劃分出多個(gè)線程私有的分配緩沖區(qū)(Thread Local Allocation Buffer, TLAB)。不過無論如何劃分,都與存放內(nèi)容無關(guān),無論哪個(gè)區(qū)域,存儲(chǔ)的都仍然是對(duì)象實(shí)例,進(jìn)一步劃分的目的是為了更好地回收內(nèi)存,或者更快地分配內(nèi)存。在本章中,我們僅僅針對(duì)內(nèi)存區(qū)域的作用進(jìn)行討論,Java 堆中的上述各個(gè)區(qū)域的分配、回收等細(xì)節(jié)將是第3章的主題。
根據(jù)Java虛擬機(jī)規(guī)范的規(guī)定,Java 堆可以處于物理上不連續(xù)的內(nèi)存空間中,只要邏輯上是連續(xù)的即可,就像我們的磁盤空間- -樣。在實(shí)現(xiàn)時(shí),既可以實(shí)現(xiàn)成固定大小的,也可以是可擴(kuò)展的,不過當(dāng)前主流的虛擬機(jī)都是按照可擴(kuò)展來實(shí)現(xiàn)的(通過-Xmx和-Xms控制)。如果在堆中沒有內(nèi)存完成實(shí)例分配,并且堆也無法再擴(kuò)展時(shí),將會(huì)拋出OutOfMemoryError異常。
2.2.5方法區(qū)
方法區(qū)(MethodArea)與Java堆一樣, 是各個(gè)線程共享的內(nèi)存區(qū)域,它用于存儲(chǔ)已被虛擬機(jī)加載的類信息、常量、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù)。雖然Java虛擬機(jī)_規(guī)范把方法區(qū)描述為堆的一個(gè)邏輯部分,但是它卻有一個(gè)別名叫做Non-Heap (非堆),目的應(yīng)該是與Java堆區(qū)分開來。
對(duì)于習(xí)慣在HotSpot虛擬機(jī)上開發(fā)、部署程序的開發(fā)者來說,很多人都更愿意把方法區(qū)稱為“永久代”(Permanent Generation),本質(zhì)上兩者并不等價(jià),僅僅是因?yàn)镠otSpot虛擬機(jī)的設(shè)計(jì)團(tuán)隊(duì)選擇把GC分代收集擴(kuò)展至方法區(qū),或者說使用永久代來實(shí)現(xiàn)方法區(qū)而已,這樣HotSpot的垃圾收集器可以像管理Java堆一樣管理這部分內(nèi)存,能夠省去專門為方法區(qū)編寫內(nèi)存管理代碼的工作。對(duì)于其他虛擬機(jī)(如BEA JRockit、IBM J9等)來說是不存在永久代的概念的。原則上,如何實(shí)現(xiàn)方法區(qū)屬于虛擬機(jī)實(shí)現(xiàn)細(xì)節(jié),不受虛擬機(jī)規(guī)范約束,但使用永久代來實(shí)現(xiàn)方法區(qū),現(xiàn)在看來并不是一個(gè)好主意,因?yàn)檫@樣更容易遇到內(nèi)存溢出問題(永久代有-XX:MaxPermSize的上限,J9 和JRockit只要沒有觸碰到進(jìn)程可用內(nèi)存的上限,例如32位系統(tǒng)中的4GB,就不會(huì)出現(xiàn)問題),而且有極少數(shù)方法(例如String.intern())會(huì)因這個(gè)原因?qū)е虏煌摂M機(jī)下有不同的表現(xiàn)。因此,對(duì)于HotSpot虛擬機(jī),根據(jù)官方發(fā)布的路線圖信息,現(xiàn)在也有放棄永久代并逐步改為采用Native Memory來實(shí)現(xiàn)方法區(qū)的規(guī)劃了e,在目前已經(jīng)發(fā)布的JDK 1.7 的HotSpot中,已經(jīng)把原本放在永久代的字符串常量池移出。
Java虛擬機(jī)規(guī)范對(duì)方法區(qū)的限制非常寬松,除了和Java堆一樣不需要連續(xù)的內(nèi)存和可以選擇固定大小或者可擴(kuò)展外,還可以選擇不實(shí)現(xiàn)垃圾收集。相對(duì)而言,垃圾收集行為在這個(gè)區(qū)域是比較少出現(xiàn)的,但并非數(shù)據(jù)進(jìn)人了方法區(qū)就如永久代的名字-樣“永久”存在了。這區(qū)域的內(nèi)存回收目標(biāo)主要是針對(duì)常量池的回收和對(duì)類型的卸載,一般來說,這個(gè)區(qū)域的回收“成績"比較難以令人滿意,尤其是類型的卸載,條件相當(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異常。
2.2.6運(yùn)行時(shí)常量池
運(yùn)行時(shí)常量池(Runtime Constant 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ī)定,每一個(gè)字節(jié)用于存儲(chǔ)哪種數(shù)據(jù)都必須符合規(guī)范上的要求才會(huì)被虛擬機(jī)認(rèn)可、裝載和執(zhí)行,但對(duì)于運(yùn)行時(shí)常量池,Java虛擬機(jī)規(guī)范沒有做任何細(xì)節(jié)的要求,不同的提供商實(shí)現(xiàn)的虛擬機(jī)可以按照自己的需要來實(shí)現(xiàn)這個(gè)內(nèi)存區(qū)域。不過,一般來說, 除了保存Class文件中描述的符號(hào)引用外,還會(huì)把翻譯出來的直接引用也存儲(chǔ)在運(yùn)行時(shí)常量池中。運(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異常。
2.2.7直 接內(nèi)存
直接內(nèi)存(DirectMemory)并不是虛擬機(jī)運(yùn)行時(shí)數(shù)據(jù)區(qū)的一部分,也不是Java虛擬機(jī)規(guī)范中定義的內(nèi)存區(qū)域。但是這部分內(nèi)存也被頻繁地使用,而且也可能導(dǎo)致OutOfMemoryError異常出現(xiàn),所以我們放到這里一起講解。在JDK 1.4 中新加入了NIO (New Input/Output)類,引入了一種基于通道(Channel)?與緩沖區(qū)(Buffer) 的I/O方式,它可以使用Native函數(shù)庫直接分配堆外內(nèi)存,然后通過一個(gè)存儲(chǔ)在Java堆中的DirectByteBuffer對(duì)象作為這塊內(nèi)存的引用進(jìn)行操作。這樣能在一些場景中顯著提高性能,因?yàn)楸苊饬嗽贘ava堆和Native堆中來回復(fù)制數(shù)據(jù)。顯然,本機(jī)直接內(nèi)存的分配不會(huì)受到Java堆大小的限制,但是,既然是內(nèi)存,肯定還是:
會(huì)受到本機(jī)總內(nèi)存(包括RAM以及SWAP區(qū)或者分頁文件)大小以及處理器尋址空間的限制。服務(wù)器管理員在配置虛擬機(jī)參數(shù)時(shí),會(huì)根據(jù)實(shí)際內(nèi)存設(shè)置-Xmx等參數(shù)信息,但經(jīng)常忽略直接內(nèi)存,使得各個(gè)內(nèi)存區(qū)域總和大于物理內(nèi)存限制(包括物理的和操作系統(tǒng)級(jí)的限制),從而導(dǎo)致動(dòng)態(tài)擴(kuò)展時(shí)出現(xiàn)OutOfMemoryError異常。
2.3HotSpot虛擬機(jī)對(duì)象探秘
介紹完Java虛擬機(jī)的運(yùn)行時(shí)數(shù)據(jù)區(qū)之后,我們大致知道了虛擬機(jī)內(nèi)存的概況,讀者了解了內(nèi)存中放了些什么后,也許就會(huì)想更進(jìn)一步了解這些虛擬機(jī)內(nèi)存中的數(shù)據(jù)的其他細(xì)節(jié),譬如它們是如何創(chuàng)建、如何布局以及如何訪問的。對(duì)于這樣涉及細(xì)節(jié)的問題,必須把討論范圍限定在具體的虛擬機(jī)和集中在某-一個(gè)內(nèi)存區(qū)域上才有意義。基于實(shí)用優(yōu)先的原則,筆者以常用的虛擬機(jī)HotSpot和常用的內(nèi)存區(qū)域Java堆為例,深人探討HotSpot虛擬機(jī)在Java堆中對(duì)象分配、布局和訪問的全過程。
2.3.1對(duì)象的創(chuàng)建
Java是一門面向?qū)ο蟮木幊陶Z言,在Java程序運(yùn)行過程中無時(shí)無刻都有對(duì)象被創(chuàng)建出來。在語言層面上,創(chuàng)建對(duì)象(例如克隆、反序列化)通常僅僅是一個(gè) new關(guān)鍵字而已,而在虛擬機(jī)中,對(duì)象(文中討論的對(duì)象限于普通Java對(duì)象,不包括數(shù)組和Class對(duì)象等)的創(chuàng)建又是怎樣-個(gè)過程呢?虛擬機(jī)遇到一條new指令時(shí),首先將去檢查這個(gè)指令的參數(shù)是否能在常量池中定位到一個(gè)類的符號(hào)引用,并且檢查這個(gè)符號(hào)引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執(zhí)行相應(yīng)的類加載過程,本書第7章將探討這部分內(nèi)容的細(xì)節(jié)。
在類加載檢查通過后,接下來虛擬機(jī)將為新生對(duì)象分配內(nèi)存。對(duì)象所需內(nèi)存的大小在類加載完成后便可完全確定(如何確定將在2.3.2節(jié)中介紹),為對(duì)象分配空間的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來。假設(shè)Java堆中內(nèi)存是絕對(duì)規(guī)整的,所有用過的內(nèi)存都放在一邊, 空閑的內(nèi)存放在另一邊,中間放著一個(gè)指針作為分 界點(diǎn)的指示器,那所分配內(nèi)存就僅僅是把那個(gè)指針向空閑空間那邊挪動(dòng)一段與對(duì)象大小相等的距離,這種分配方式稱為“指針碰撞”(BumpthePointer)。如果Java堆中的內(nèi)存并不是規(guī)整的,已使用的內(nèi)存和空閑的內(nèi)存相互交錯(cuò),那就沒有辦法簡單地進(jìn)行指針碰撞了,虛擬機(jī)就必須維護(hù)-一個(gè)列表,記錄上哪些內(nèi)存塊是可用的,在分配的時(shí)候從列表中找到一塊足夠大的空間劃分給對(duì)象實(shí)例,并更新列表上的記錄,這種分配方式稱為“空閑列表”(Free List)。選擇哪種分配方式由Java堆是否規(guī)整決定,而Java堆是否規(guī)整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用Serial、ParNew 等帶Compact過程的收集器時(shí),系統(tǒng)采用的分配算法是指針碰撞,而使用CMS這種基于Mark Sweep算法的收集器時(shí),通常采用空閑列表。除如何劃分可用空間之外,還有另外一個(gè)需要考慮的問題是對(duì)象創(chuàng)建在虛擬機(jī)中是非常頻繁的行為,即使是僅僅修改一個(gè)指針?biāo)赶虻奈恢?#xff0c;在并發(fā)情況下也并不是線程安全的,可能出現(xiàn)正在給對(duì)象A分配內(nèi)存,指針還沒來得及修改,對(duì)象B又同時(shí)使用了原來的指針來分配內(nèi)存的情況。解決這個(gè)問題有兩種方案,一種 是對(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用完并分配新的FLAB時(shí),才需要同步鎖定。虛擬機(jī)是否使用TLAB,可以通過-XX:+/-UseTLAB參數(shù)來設(shè)定。內(nèi)存分配完成后,虛擬機(jī)需要將分配到的內(nèi)存空間都初始化為零值(不包括對(duì)象頭),如果使用TLAB,這一工作過程也可以提前至TLAB分配時(shí)進(jìn)行。這一步操作保證了對(duì)象的實(shí)例字段在Java 代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數(shù)據(jù)類型所對(duì)應(yīng)的零值。接下來,虛擬機(jī)要對(duì)對(duì)象進(jìn)行必要的設(shè)置,例如這個(gè)對(duì)象是哪個(gè)類的實(shí)例、如何才能找到類的元數(shù)據(jù)信息、對(duì)象的哈希碼、對(duì)象的GC分代年齡等信息。這些信息存放在對(duì)象的對(duì)象頭(Object Header)之中。根據(jù)虛擬機(jī)當(dāng)前的運(yùn)行狀態(tài)的不同,如是否啟用偏向鎖等,對(duì)象頭會(huì)有不同的設(shè)置方式。關(guān)于對(duì)象頭的具體內(nèi)容,稍后再做詳細(xì)介紹。在上面工作都完成之后,從虛擬機(jī)的視角來看,一個(gè)新的對(duì)象已經(jīng)產(chǎn)生了,但從Java程序的視角來看,對(duì)象創(chuàng)建術(shù)剛剛開始一<init> 方法還沒有執(zhí)行,所有的字段都還為零。所以,一般來說(由字節(jié)碼中是否跟隨invokespecial指令所決定),執(zhí)行new指令之后會(huì)接著執(zhí)行<init>方法,把對(duì)象按照程序員的意愿進(jìn)行初始化,這樣一個(gè)真正可用的對(duì)象才算完全產(chǎn)生出來。
下面的代碼清單2=1是HotSpot虛擬機(jī)bytecodeInterpreter.cpp中的代碼片段(這個(gè)解釋器實(shí)現(xiàn)很少有機(jī)會(huì)實(shí)際使用,因?yàn)榇蟛糠制脚_(tái),上都使用模板解釋器;當(dāng)代碼通過JIT編譯器執(zhí)行時(shí)差異就更大了。不過,這段代碼用于了解HotSpot的運(yùn)作過程是沒有什么問題的)。
代碼清單2-1 HotSpot 解釋器的代碼片段
//確保常量池中存放的是已解釋的類
?
2.3.2對(duì)象的內(nèi)存布局
在HotSpot虛擬機(jī)中,對(duì)象在內(nèi)存中存儲(chǔ)的布局可以分為3塊區(qū)域:對(duì)象頭( Header)、實(shí)例數(shù)據(jù)(Instance Data)和對(duì)齊填充( Padding).HotSpot虛擬機(jī)的對(duì)象頭包括兩部分信息,第一部分用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),如哈希碼(HashCode)、 GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時(shí)間戳等,這部分?jǐn)?shù)據(jù)的長度在32位和64位的虛擬機(jī)(未開啟壓縮指針)中分別為32bit和64bit,官方稱它為“Mark Word"。對(duì)象需要存儲(chǔ)的運(yùn)行時(shí)數(shù)據(jù)很多,其實(shí)已經(jīng)超出了32位、64位Bitmap結(jié)構(gòu)所能記錄的限度,但是對(duì)象頭信息是與對(duì)象自身定義的數(shù)據(jù)無關(guān)的額外存儲(chǔ)成本,考慮到虛擬機(jī)的空間效率,MarkWord被設(shè)計(jì)成一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存儲(chǔ)盡量多的信息,它會(huì)根據(jù)對(duì)象的狀態(tài)復(fù)用自己的存儲(chǔ)空間。例如:在32位的HotSpot虛擬機(jī)中,如果對(duì)象處于未被鎖定的狀態(tài)下,那么Mark Word的32bit空間中的25bit用于存儲(chǔ)對(duì)象哈希碼,4bit 用于存儲(chǔ)對(duì)象分代年齡,2bit 用于存儲(chǔ)鎖標(biāo)志位,1bit 固定為0,而在其他狀態(tài)(輕量級(jí)鎖定、重量級(jí)鎖定、GC標(biāo)記、可偏向)下對(duì)象的存儲(chǔ)內(nèi)容見表2-1
對(duì)象頭的另外- - 部分是類型指針,即對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過這個(gè)指針來確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。并不是所有的虛擬機(jī)實(shí)現(xiàn)都必須在對(duì)象數(shù)據(jù)上保留類型指針,換句話說,查找對(duì)象的元數(shù)據(jù)信息并不一一定要經(jīng)過對(duì)象本身,這點(diǎn)將在2.3.3節(jié)討論。另外,如果對(duì)象是-一個(gè)Java數(shù)組,那在對(duì)象頭中還必須有一塊用于記錄數(shù)組長度的數(shù)據(jù),因?yàn)樘摂M機(jī)可以通過普通Java對(duì)象的元數(shù)據(jù)信息確定Java對(duì)象的大小,但是從數(shù)組的元數(shù)據(jù)中卻無法確定數(shù)組的大小。?
代碼清單2-2為HotSpot虛擬機(jī)markOop.cpp中的代碼(注釋)片段,它描述了32bit下Mark Word的存儲(chǔ)狀態(tài)。
接下來的實(shí)例數(shù)據(jù)部分是對(duì)象真正存儲(chǔ)的有效信息,也是在程序代碼中所定義的各種類型的字段內(nèi)容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的存儲(chǔ)順序會(huì)受到虛擬機(jī)分配策略參數(shù)(FieldsAllocationStyle)和字段在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ì)齊填充并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。由于HotSpot VM的自動(dòng)內(nèi)存管理系統(tǒng)要求對(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.3對(duì)象的訪問定位
建立對(duì)象是為了使用對(duì)象,我們的Java程序需要通過棧上的reference數(shù)據(jù)來操作堆上的具體對(duì)象。由于reference類型在Java虛擬機(jī)規(guī)范中只規(guī)定了一個(gè)指向?qū)ο蟮囊?#xff0c;并沒有定義這個(gè)引用應(yīng)該通過何種方式去定位、訪問堆中的對(duì)象的具體位置,所以對(duì)象訪問方式也是取決于虛擬機(jī)實(shí)現(xiàn)而定的。目前主流的訪問方式有使用句柄和直接指針兩種。如果使用句柄訪問的話,那么Java堆中將會(huì)劃分出一塊內(nèi)存來作為句柄池,reference中存儲(chǔ)的就是對(duì)象的句柄地址,而句柄中包含了對(duì)象實(shí)例數(shù)據(jù)與類型數(shù)據(jù)各自的具體地址信息,如圖2-2所示。
如果使用直接指針訪問,那么Java堆對(duì)象的布局中就必須考慮如何放置訪問類型數(shù)據(jù)的相關(guān)信息,而reference中存儲(chǔ)的直接就是對(duì)象地址,如圖2-3所示。?
?這兩種對(duì)象訪問方式各有優(yōu)勢,使用句柄來訪問的最大好處就是reference中存儲(chǔ)的是穩(wěn)定的句柄地址,在對(duì)象被移動(dòng)(垃圾收集時(shí)移動(dòng)對(duì)象是非常普遍的行為)時(shí)只會(huì)改變句柄中的實(shí)例數(shù)據(jù)指針,而reference本身不需要修改。使用直接指針訪問方式的最大好處就是速度更快,它節(jié)省了一次指針定位的時(shí)間開銷,由于對(duì)象的訪問在Java中非常頻繁,因此這類開銷積少成多后也是--項(xiàng)非常可觀的執(zhí)行成本。就本書討論的主要虛擬機(jī)Sun HotSpot 而言,它是使用第二種方式進(jìn)行對(duì)象訪問的,但從整個(gè)軟件開發(fā)的范圍來看,各種語言和框架使用句柄來訪問的情況也十分常見。
2.4實(shí)戰(zhàn): OutOfMtemoryError 異常
在Java虛擬機(jī)規(guī)范的描述中,除了百 程序計(jì)數(shù)器外,虛擬機(jī)內(nèi)存的其他幾個(gè)運(yùn)行時(shí)區(qū)域都有發(fā)生OutOfMemoryError (下文稱OOM)異常的可能,本節(jié)將通過若干實(shí)例來驗(yàn)證異常發(fā)生的場景(代碼清單2-3~代碼清單2-9的幾段簡單代碼),并且會(huì)初步介紹幾個(gè)與內(nèi)存相關(guān)的最基本的虛擬機(jī)參數(shù)。本節(jié)內(nèi)容的目的有兩個(gè):第-一,通過代碼驗(yàn)證Java虛擬機(jī)規(guī)范中描述的各個(gè)運(yùn)行時(shí)區(qū)域
存儲(chǔ)的內(nèi)容;第二,希望讀者在工作中遇到實(shí)際的內(nèi)存溢出異常時(shí),能根據(jù)異常的信息快速判斷是哪個(gè)區(qū)域的內(nèi)存溢出,知道偉么樣的代碼可能會(huì)導(dǎo)致這些區(qū)域內(nèi)存溢出,以及出現(xiàn)這些異常后該如何處理。下文代碼的開頭都注釋了執(zhí)行時(shí)所需要設(shè)置的虛擬機(jī)啟動(dòng)參數(shù)(注釋中“VM Args"后面跟著的參數(shù)),這些參數(shù)對(duì)實(shí)驗(yàn)的結(jié)果有直接影響,讀者調(diào)試代碼的時(shí)候千萬不要忽略。如果讀者使用控制臺(tái)命令來執(zhí)行程序,那直接跟在Java命令之后書寫就可以。如果讀者使用Eclipse IDE,則可以參考圖2-4在Debug/Run頁簽中的設(shè)置。
下文的代碼都是基于Sun公司的HotSpot虛擬機(jī)運(yùn)行的,對(duì)于不同公司的不同版本的虛擬機(jī),參數(shù)和程序運(yùn)行的結(jié)果可能會(huì)有所差別。
2.4.1 Java 堆溢出
Java堆用于存儲(chǔ)對(duì)象實(shí)例,只要不斷地創(chuàng)建對(duì)象,并且保證GC Roots 到對(duì)象之間有可達(dá)路徑來避免垃圾回收機(jī)制清除這些對(duì)象,那么在對(duì)象數(shù)量到達(dá)最大堆的容量限制后就會(huì)產(chǎn)生內(nèi)存溢出異常。代碼清單2-3中代碼限制Java堆的大小為20MB,不可擴(kuò)展(將堆的最小值-Xms參數(shù)與最大值_Xmx參數(shù)設(shè)置為- -樣即可避免堆自動(dòng)擴(kuò)展),通過參數(shù)-X:+HeapDumpOnOutOfMemoryEror可
以讓虛擬機(jī)在出現(xiàn)內(nèi)存溢出異常時(shí)Dump出當(dāng)前的內(nèi)存堆轉(zhuǎn)儲(chǔ)快照以便事后進(jìn)行分析。
?
Java堆內(nèi)存的OutOfMemoryError異常是實(shí)際應(yīng)用中最常見的內(nèi)存溢出異常情況。出現(xiàn)Java堆內(nèi)存 溢出時(shí),異常堆棧信息“java.lang.OutOfMemoryError”會(huì)跟隨進(jìn)一步提示“Java heap space”。
要解決這個(gè)內(nèi)存區(qū)域的異常,常規(guī)的處理方法是首先通過內(nèi)存映像分析工具(如Eclipse Memory Analyzer)對(duì)Dump出來的堆轉(zhuǎn)儲(chǔ)快照進(jìn)行分析。第一步首先應(yīng)確認(rèn)內(nèi)存中導(dǎo)致OOM的對(duì)象是否是必 要的,也就是要先分清楚到底是出現(xiàn)了內(nèi)存泄漏(Memory Leak)還是內(nèi)存溢出(Memory Overflow)。圖2-5顯示了使用Eclipse Memory Analyzer打開的堆轉(zhuǎn)儲(chǔ)快照文件。
如果是內(nèi)存泄漏,可進(jìn)一步通過工具查看泄漏對(duì)象到GC Roots的引用鏈,找到泄漏對(duì)象是通過怎 樣的引用路徑、與哪些GC Roots相關(guān)聯(lián),才導(dǎo)致垃圾收集器無法回收它們,根據(jù)泄漏對(duì)象的類型信息 以及它到GC Roots引用鏈的信息,一般可以比較準(zhǔn)確地定位到這些對(duì)象創(chuàng)建的位置,進(jìn)而找出產(chǎn)生內(nèi) 存泄漏的代碼的具體位置。
如果不是內(nèi)存泄漏,換句話說就是內(nèi)存中的對(duì)象確實(shí)都是必須存活的,那就應(yīng)當(dāng)檢查Java虛擬機(jī) 的堆參數(shù)(-Xmx與-Xms)設(shè)置,與機(jī)器的內(nèi)存對(duì)比,看看是否還有向上調(diào)整的空間。再從代碼上檢查 是否存在某些對(duì)象生命周期過長、持有狀態(tài)時(shí)間過長、存儲(chǔ)結(jié)構(gòu)設(shè)計(jì)不合理等情況,盡量減少程序運(yùn) 行期的內(nèi)存消耗。
以上是處理Java堆內(nèi)存問題的簡略思路,處理這些問題所需要的知識(shí)、工具與經(jīng)驗(yàn)是后面三章的 主題,后面我們將會(huì)針對(duì)具體的虛擬機(jī)實(shí)現(xiàn)、具體的垃圾收集器和具體的案例來進(jìn)行分析,這里就先 暫不展開。
?
2.4.2 虛擬機(jī)棧和本地方法棧溢出
由于HotSpot虛擬機(jī)中并不區(qū)分虛擬機(jī)棧和本地方法棧,因此對(duì)于HotSpot來說,-Xoss參數(shù)(設(shè)置 本地方法棧大小)雖然存在,但實(shí)際上是沒有任何效果的,棧容量只能由-Xss參數(shù)來設(shè)定。關(guān)于虛擬 機(jī)棧和本地方法棧,在《Java虛擬機(jī)規(guī)范》中描述了兩種異常:
1)如果線程請(qǐng)求的棧深度大于虛擬機(jī)所允許的最大深度,將拋出StackOverflowError異常。
2)如果虛擬機(jī)的棧內(nèi)存允許動(dòng)態(tài)擴(kuò)展,當(dāng)擴(kuò)展棧容量無法申請(qǐng)到足夠的內(nèi)存時(shí),將拋出 OutOfMemoryError異常。
《Java虛擬機(jī)規(guī)范》明確允許Java虛擬機(jī)實(shí)現(xiàn)自行選擇是否支持棧的動(dòng)態(tài)擴(kuò)展,而HotSpot虛擬機(jī) 的選擇是不支持?jǐn)U展,所以除非在創(chuàng)建線程申請(qǐng)內(nèi)存時(shí)就因無法獲得足夠內(nèi)存而出現(xiàn) OutOfMemoryError異常,否則在線程運(yùn)行時(shí)是不會(huì)因?yàn)閿U(kuò)展而導(dǎo)致內(nèi)存溢出的,只會(huì)因?yàn)闂H萘繜o法 容納新的棧幀而導(dǎo)致StackOverflowError異常。
為了驗(yàn)證這點(diǎn),我們可以做兩個(gè)實(shí)驗(yàn),先將實(shí)驗(yàn)范圍限制在單線程中操作,嘗試下面兩種行為是 否能讓HotSpot虛擬機(jī)產(chǎn)生OutOfMemoryError異常:
·使用-Xss參數(shù)減少棧內(nèi)存容量。
結(jié)果:拋出StackOverflowError異常,異常出現(xiàn)時(shí)輸出的堆棧深度相應(yīng)縮小。
·定義了大量的本地變量,增大此方法幀中本地變量表的長度。
結(jié)果:拋出StackOverflowError異常,異常出現(xiàn)時(shí)輸出的堆棧深度相應(yīng)縮小。
首先,對(duì)第一種情況進(jìn)行測試,具體如代碼清單2-4所示。
代碼清單2-4 虛擬機(jī)棧和本地方法棧測試(作為第1點(diǎn)測試程序)
對(duì)于不同版本的Java虛擬機(jī)和不同的操作系統(tǒng),棧容量最小值可能會(huì)有所限制,這主要取決于操 作系統(tǒng)內(nèi)存分頁大小。譬如上述方法中的參數(shù)-Xss128k可以正常用于32位Windows系統(tǒng)下的JDK 6,但 是如果用于64位Windows系統(tǒng)下的JDK 11,則會(huì)提示棧容量最小不能低于180K,而在Linux下這個(gè)值則 可能是228K,如果低于這個(gè)最小限制,HotSpot虛擬器啟動(dòng)時(shí)會(huì)給出如下提示:
?我們繼續(xù)驗(yàn)證第二種情況,這次代碼就顯得有些“丑陋”了,為了多占局部變量表空間,筆者不得 不定義一長串變量,具體如代碼清單2-5所示。
代碼清單2-5 虛擬機(jī)棧和本地方法棧測試(作為第2點(diǎn)測試程序)
?運(yùn)行結(jié)果:
stack length:5675 Exception in thread "main" java.lang.StackOverflowError at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:27) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28) ……后續(xù)異常堆棧信息省略實(shí)驗(yàn)結(jié)果表明:無論是由于棧幀太大還是虛擬機(jī)棧容量太小,當(dāng)新的棧幀內(nèi)存無法分配的時(shí)候, HotSpot虛擬機(jī)拋出的都是StackOverflowError異常。可是如果在允許動(dòng)態(tài)擴(kuò)展棧容量大小的虛擬機(jī) 上,相同代碼則會(huì)導(dǎo)致不一樣的情況。譬如遠(yuǎn)古時(shí)代的Classic虛擬機(jī),這款虛擬機(jī)可以支持動(dòng)態(tài)擴(kuò)展 棧內(nèi)存的容量,在Windows上的JDK 1.0.2運(yùn)行代碼清單2-5的話(如果這時(shí)候要調(diào)整棧容量就應(yīng)該改 用-oss參數(shù)了),得到的結(jié)果是:
stack length:3716 java.lang.OutOfMemoryError at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:27) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28) at org.fenixsoft.oom. JavaVMStackSOF.leak(JavaVMStackSOF.java:28) ……后續(xù)異常堆棧信息省略可見相同的代碼在Classic虛擬機(jī)中成功產(chǎn)生了OutOfMemoryError而不是StackOver-flowError異 常。如果測試時(shí)不限于單線程,通過不斷建立線程的方式,在HotSpot上也是可以產(chǎn)生內(nèi)存溢出異常 的,具體如代碼清單2-6所示。但是這樣產(chǎn)生的內(nèi)存溢出異常和棧空間是否足夠并不存在任何直接的關(guān) 系,主要取決于操作系統(tǒng)本身的內(nèi)存使用狀態(tài)。甚至可以說,在這種情況下,給每個(gè)線程的棧分配的 內(nèi)存越大,反而越容易產(chǎn)生內(nèi)存溢出異常。
原因其實(shí)不難理解,操作系統(tǒng)分配給每個(gè)進(jìn)程的內(nèi)存是有限制的,譬如32位Windows的單個(gè)進(jìn)程 最大內(nèi)存限制為2GB。HotSpot虛擬機(jī)提供了參數(shù)可以控制Java堆和方法區(qū)這兩部分的內(nèi)存的最大值,
那剩余的內(nèi)存即為2GB(操作系統(tǒng)限制)減去最大堆容量,再減去最大方法區(qū)容量,由于程序計(jì)數(shù)器 消耗內(nèi)存很小,可以忽略掉,如果把直接內(nèi)存和虛擬機(jī)進(jìn)程本身耗費(fèi)的內(nèi)存也去掉的話,剩下的內(nèi)存 就由虛擬機(jī)棧和本地方法棧來分配了。因此為每個(gè)線程分配到的棧內(nèi)存越大,可以建立的線程數(shù)量自 然就越少,建立線程時(shí)就越容易把剩下的內(nèi)存耗盡,代碼清單2-6演示了這種情況。
代碼清單2-6 創(chuàng)建線程導(dǎo)致內(nèi)存溢出異常
?
注意 重點(diǎn)提示一下,如果讀者要嘗試運(yùn)行上面這段代碼,記得要先保存當(dāng)前的工作,由于在 Windows平臺(tái)的虛擬機(jī)中,Java的線程是映射到操作系統(tǒng)的內(nèi)核線程上[1],無限制地創(chuàng)建線程會(huì)對(duì)操 作系統(tǒng)帶來很大壓力,上述代碼執(zhí)行時(shí)有很高的風(fēng)險(xiǎn),可能會(huì)由于創(chuàng)建線程數(shù)量過多而導(dǎo)致操作系統(tǒng) 假死。
出現(xiàn)StackOverflowError異常時(shí),會(huì)有明確錯(cuò)誤堆棧可供分析,相對(duì)而言比較容易定位到問題所 在。如果使用HotSpot虛擬機(jī)默認(rèn)參數(shù),棧深度在大多數(shù)情況下(因?yàn)槊總€(gè)方法壓入棧的幀大小并不是 一樣的,所以只能說大多數(shù)情況下)到達(dá)1000~2000是完全沒有問題,對(duì)于正常的方法調(diào)用(包括不能 做尾遞歸優(yōu)化的遞歸調(diào)用),這個(gè)深度應(yīng)該完全夠用了。但是,如果是建立過多線程導(dǎo)致的內(nèi)存溢 出,在不能減少線程數(shù)量或者更換64位虛擬機(jī)的情況下,就只能通過減少最大堆和減少棧容量來換取 更多的線程。這種通過“減少內(nèi)存”的手段來解決內(nèi)存溢出的方式,如果沒有這方面處理經(jīng)驗(yàn),一般比 較難以想到,這一點(diǎn)讀者需要在開發(fā)32位系統(tǒng)的多線程應(yīng)用時(shí)注意。也是由于這種問題較為隱蔽,從 JDK 7起,以上提示信息中“unable to create native thread”后面,虛擬機(jī)會(huì)特別注明原因可能是“possibly out of memory or process/resource limits reached”。
2.4.3 方法區(qū)和運(yùn)行時(shí)常量池溢出
由于運(yùn)行時(shí)常量池是方法區(qū)的一部分,所以這兩個(gè)區(qū)域的溢出測試可以放到一起進(jìn)行。前面曾經(jīng) 提到HotSpot從JDK 7開始逐步“去永久代”的計(jì)劃,并在JDK 8中完全使用元空間來代替永久代的背景 故事,在此我們就以測試代碼來觀察一下,使用“永久代”還是“元空間”來實(shí)現(xiàn)方法區(qū),對(duì)程序有什么 實(shí)際的影響。
String::intern()是一個(gè)本地方法,它的作用是如果字符串常量池中已經(jīng)包含一個(gè)等于此String對(duì)象的 字符串,則返回代表池中這個(gè)字符串的String對(duì)象的引用;否則,會(huì)將此String對(duì)象包含的字符串添加 到常量池中,并且返回此String對(duì)象的引用。在JDK 6或更早之前的HotSpot虛擬機(jī)中,常量池都是分配 在永久代中,我們可以通過-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可間接限制其 中常量池的容量,具體實(shí)現(xiàn)如代碼清單2-7所示,請(qǐng)讀者測試時(shí)首先以JDK 6來運(yùn)行代碼
代碼清單2-7 運(yùn)行時(shí)常量池導(dǎo)致的內(nèi)存溢出異常
/** * VM Args:-XX:PermSize=6M * -XX:MaxPermSize=6M * @author zzm */ public class RuntimeConstantPoolOOM { public static void main(String[] args) { // 使用Set保持著常量池引用,避免Full GC回收常量池行為 Set<String> set = new HashSet<String>(); // 在short范圍內(nèi)足以讓6MB的PermSize產(chǎn)生OOM了 short i = 0; while (true) { set.add(String.valueOf(i++).intern()); } } }
?從運(yùn)行結(jié)果中可以看到,運(yùn)行時(shí)常量池溢出時(shí),在OutOfMemoryError異常后面跟隨的提示信息 是“PermGen space”,說明運(yùn)行時(shí)常量池的確是屬于方法區(qū)(即JDK 6的HotSpot虛擬機(jī)中的永久代)的 一部分。
而使用JDK 7或更高版本的JDK來運(yùn)行這段程序并不會(huì)得到相同的結(jié)果,無論是在JDK 7中繼續(xù)使 用-XX:MaxPermSize參數(shù)或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize參數(shù)把方法區(qū)容量同 樣限制在6MB,也都不會(huì)重現(xiàn)JDK 6中的溢出異常,循環(huán)將一直進(jìn)行下去,永不停歇[1]。出現(xiàn)這種變 化,是因?yàn)樽訨DK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版 本,限制方法區(qū)的容量對(duì)該測試用例來說是毫無意義的。這時(shí)候使用-Xmx參數(shù)限制最大堆到6MB就能 夠看到以下兩種運(yùn)行結(jié)果之一,具體取決于哪里的對(duì)象分配時(shí)產(chǎn)生了溢出:
關(guān)于這個(gè)字符串常量池的實(shí)現(xiàn)在哪里出現(xiàn)問題,還可以引申出一些更有意思的影響,具體見代碼 清單2-8所示。
代碼清單2-8 String.intern()返回引用的測試
?這段代碼在JDK 6中運(yùn)行,會(huì)得到兩個(gè)false,而在JDK 7中運(yùn)行,會(huì)得到一個(gè)true和一個(gè)false。產(chǎn) 生差異的原因是,在JDK 6中,intern()方法會(huì)把首次遇到的字符串實(shí)例復(fù)制到永久代的字符串常量池 中存儲(chǔ),返回的也是永久代里面這個(gè)字符串實(shí)例的引用,而由StringBuilder創(chuàng)建的字符串對(duì)象實(shí)例在 Java堆上,所以必然不可能是同一個(gè)引用,結(jié)果將返回false。
而JDK 7(以及部分其他虛擬機(jī),例如JRockit)的intern()方法實(shí)現(xiàn)就不需要再拷貝字符串的實(shí)例 到永久代了,既然字符串常量池已經(jīng)移到Java堆中,那只需要在常量池里記錄一下首次出現(xiàn)的實(shí)例引 用即可,因此intern()返回的引用和由StringBuilder創(chuàng)建的那個(gè)字符串實(shí)例就是同一個(gè)。而對(duì)str2比較返 回false,這是因?yàn)椤癹ava”[2]這個(gè)字符串在執(zhí)行String-Builder.toString()之前就已經(jīng)出現(xiàn)過了,字符串常量 池中已經(jīng)有它的引用,不符合intern()方法要求“首次遇到”的原則,“計(jì)算機(jī)軟件”這個(gè)字符串則是首次 出現(xiàn)的,因此結(jié)果返回true。
我們?cè)賮砜纯捶椒▍^(qū)的其他部分的內(nèi)容,方法區(qū)的主要職責(zé)是用于存放類型的相關(guān)信息,如類 名、訪問修飾符、常量池、字段描述、方法描述等。對(duì)于這部分區(qū)域的測試,基本的思路是運(yùn)行時(shí)產(chǎn) 生大量的類去填滿方法區(qū),直到溢出為止。雖然直接使用Java SE API也可以動(dòng)態(tài)產(chǎn)生類(如反射時(shí)的 GeneratedConstructorAccessor和動(dòng)態(tài)代理等),但在本次實(shí)驗(yàn)中操作起來比較麻煩。在代碼清單2-8里 筆者借助了CGLib[3]直接操作字節(jié)碼運(yùn)行時(shí)生成了大量的動(dòng)態(tài)類。
值得特別注意的是,我們?cè)谶@個(gè)例子中模擬的場景并非純粹是一個(gè)實(shí)驗(yàn),類似這樣的代碼確實(shí)可 能會(huì)出現(xiàn)在實(shí)際應(yīng)用中:當(dāng)前的很多主流框架,如Spring、Hibernate對(duì)類進(jìn)行增強(qiáng)時(shí),都會(huì)使用到 CGLib這類字節(jié)碼技術(shù),當(dāng)增強(qiáng)的類越多,就需要越大的方法區(qū)以保證動(dòng)態(tài)生成的新類型可以載入內(nèi)存。另外,很多運(yùn)行于Java虛擬機(jī)上的動(dòng)態(tài)語言(例如Groovy等)通常都會(huì)持續(xù)創(chuàng)建新類型來支撐語 言的動(dòng)態(tài)性,隨著這類動(dòng)態(tài)語言的流行,與代碼清單2-9相似的溢出場景也越來越容易遇到。
代碼清單2-9 借助CGLib使得方法區(qū)出現(xiàn)內(nèi)存溢出異常
方法區(qū)溢出也是一種常見的內(nèi)存溢出異常,一個(gè)類如果要被垃圾收集器回收,要達(dá)成的條件是比 較苛刻的。在經(jīng)常運(yùn)行時(shí)生成大量動(dòng)態(tài)類的應(yīng)用場景里,就應(yīng)該特別關(guān)注這些類的回收狀況。這類場 景除了之前提到的程序使用了CGLib字節(jié)碼增強(qiáng)和動(dòng)態(tài)語言外,常見的還有:大量JSP或動(dòng)態(tài)產(chǎn)生JSP 文件的應(yīng)用(JSP第一次運(yùn)行時(shí)需要編譯為Java類)、基于OSGi的應(yīng)用(即使是同一個(gè)類文件,被不同 的加載器加載也會(huì)視為不同的類)等。
在JDK 8以后,永久代便完全退出了歷史舞臺(tái),元空間作為其替代者登場。在默認(rèn)設(shè)置下,前面 列舉的那些正常的動(dòng)態(tài)創(chuàng)建新類型的測試用例已經(jīng)很難再迫使虛擬機(jī)產(chǎn)生方法區(qū)的溢出異常了。不過 為了讓使用者有預(yù)防實(shí)際應(yīng)用里出現(xiàn)類似于代碼清單2-9那樣的破壞性的操作,HotSpot還是提供了一 些參數(shù)作為元空間的防御措施,主要包括:
·-XX:MaxMetaspaceSize:設(shè)置元空間最大值,默認(rèn)是-1,即不限制,或者說只受限于本地內(nèi)存 大小。
·-XX:MetaspaceSize:指定元空間的初始空間大小,以字節(jié)為單位,達(dá)到該值就會(huì)觸發(fā)垃圾收集 進(jìn)行類型卸載,同時(shí)收集器會(huì)對(duì)該值進(jìn)行調(diào)整:如果釋放了大量的空間,就適當(dāng)降低該值;如果釋放 了很少的空間,那么在不超過-XX:MaxMetaspaceSize(如果設(shè)置了的話)的情況下,適當(dāng)提高該值。
·-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空間剩余容量的百分比,可 減少因?yàn)樵臻g不足導(dǎo)致的垃圾收集的頻率。類似的還有-XX:Max-MetaspaceFreeRatio,用于控制最 大的元空間剩余容量的百分比。?
[1] 正常情況下是永不停歇的,如果機(jī)器內(nèi)存緊張到連幾MB的Java堆都擠不出來的這種極端情況就不 討論了。 [2] 它是在加載sun.misc.Version這個(gè)類的時(shí)候進(jìn)入常量池的。本書第2版并未解釋java這個(gè)字符串此前是 哪里出現(xiàn)的,所以被批評(píng)“挖坑不填了”(無奈地?cái)偸?#xff09;。如讀者感興趣是如何找出來的,可參考RednaxelaFX的知乎回答(https://www.zhihu.com/question/51102308/answer/124441115)。 [3] CGLib開源項(xiàng)目:http://cglib.sourceforge.net/。
2.4.4 本機(jī)直接內(nèi)存溢出
直接內(nèi)存(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize參數(shù)來指定,如果不 去指定,則默認(rèn)與Java堆最大值(由-Xmx指定)一致,代碼清單2-10越過了DirectByteBuffer類直接通 過反射獲取Unsafe實(shí)例進(jìn)行內(nèi)存分配(Unsafe類的getUnsafe()方法指定只有引導(dǎo)類加載器才會(huì)返回實(shí) 例,體現(xiàn)了設(shè)計(jì)者希望只有虛擬機(jī)標(biāo)準(zhǔn)類庫里面的類才能使用Unsafe的功能,在JDK 10時(shí)才將Unsafe 的部分功能通過VarHandle開放給外部使用),因?yàn)殡m然使用DirectByteBuffer分配內(nèi)存也會(huì)拋出內(nèi)存溢 出異常,但它拋出異常時(shí)并沒有真正向操作系統(tǒng)申請(qǐng)分配內(nèi)存,而是通過計(jì)算得知內(nèi)存無法分配就會(huì) 在代碼里手動(dòng)拋出溢出異常,真正申請(qǐng)分配內(nèi)存的方法是Unsafe::allocateMemory()。
代碼清單2-10 使用unsafe分配本機(jī)內(nèi)存
?
?由直接內(nèi)存導(dǎo)致的內(nèi)存溢出,一個(gè)明顯的特征是在Heap Dump文件中不會(huì)看見有什么明顯的異常 情況,如果讀者發(fā)現(xiàn)內(nèi)存溢出之后產(chǎn)生的Dump文件很小,而程序中又直接或間接使用了 DirectMemory(典型的間接使用就是NIO),那就可以考慮重點(diǎn)檢查一下直接內(nèi)存方面的原因了。
2.5 本章小結(jié)
到此為止,我們明白了虛擬機(jī)里面的內(nèi)存是如何劃分的,哪部分區(qū)域、什么樣的代碼和操作可能 導(dǎo)致內(nèi)存溢出異常。雖然Java有垃圾收集機(jī)制,但內(nèi)存溢出異常離我們并不遙遠(yuǎn),本章只是講解了各 個(gè)區(qū)域出現(xiàn)內(nèi)存溢出異常的原因,下一章將詳細(xì)講解Java垃圾收集機(jī)制為了避免出現(xiàn)內(nèi)存溢出異常都 做了哪些努力。
總結(jié)
以上是生活随笔為你收集整理的《深入理解java虚拟机》第2章 Java内存区域与内存溢出异常的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【译】BINDER TRANSACTIO
- 下一篇: 【译】BINDER - ANALYSIS