Java內(nèi)存區(qū)域與內(nèi)存溢出異常
覺得書上有一句話很有意思
Java與C++之間有一堵有內(nèi)存動(dòng)態(tài)分配和垃圾收集技術(shù)所圍成的高墻漾稀,墻外面的人想進(jìn)去抵代,墻里面的人卻想出來
運(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虛擬機(jī)所管理的內(nèi)存將會(huì)包括以下幾個(gè)運(yùn)行時(shí)數(shù)據(jù)區(qū)域
下面將大致介紹Java內(nèi)存區(qū)域的分類和不同以及拋出內(nèi)存溢出異常的原因
內(nèi)存泄漏是造成內(nèi)存溢出的主要原因
程序計(jì)數(shù)器
程序計(jì)數(shù)器是一塊較小的內(nèi)存空間康吵,它可以看作是當(dāng)前線程所執(zhí)行的字節(jié)碼的行號指示器劈榨,字節(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è)處理器都只會(huì)執(zhí)行一條線程中的指令描滔,因此為了線程切換之后恢復(fù)到正確的位置棒妨,每條線程都需要有一個(gè)獨(dú)立的程序計(jì)數(shù)器,各條線程之間計(jì)數(shù)器互不影響含长,獨(dú)立存儲(chǔ)券腔,這類的內(nèi)存區(qū)域稱為"線程私有內(nèi)存"
- 如果線程正在執(zhí)行的是一種Java方法伏穆,這個(gè)計(jì)數(shù)器記錄的是正在執(zhí)行的虛擬機(jī)字節(jié)碼指令的地址
- 如果正在執(zhí)行的是Native方法,這個(gè)計(jì)數(shù)器則為空
這個(gè)內(nèi)存區(qū)域是唯一一個(gè)沒有規(guī)定任何內(nèi)存溢出異常的區(qū)域
Java虛擬機(jī)棧
和程序計(jì)數(shù)器一樣颅眶,Java虛擬機(jī)棧也是線程私有的蜈出,它的生命周期和線程相同。虛擬機(jī)棧描述的是Java方法執(zhí)行的內(nèi)存模型:每個(gè)方法在執(zhí)行的時(shí)候都會(huì)創(chuàng)建一個(gè)棧幀(Stack Frame)用于存儲(chǔ)局部變量表涛酗、操作數(shù)棧铡原、動(dòng)態(tài)鏈接、方法出口等信息商叹。每一個(gè)方法從調(diào)用直至執(zhí)行完成的過程燕刻,就對應(yīng)著一個(gè)棧幀在虛擬機(jī)戰(zhàn)中入棧出棧的過程
這里需要注意,我們說到的這個(gè)Java虛擬機(jī)棧與Java方法棧是兩個(gè)概念剖笙,每當(dāng)一個(gè)方法執(zhí)行的時(shí)候卵洗,會(huì)創(chuàng)建一個(gè)棧幀,棧幀中包含了該方法的操作數(shù)棧和局部變量表弥咪,動(dòng)態(tài)鏈接和出口过蹂,這個(gè)方法的執(zhí)行和退出伴隨著這個(gè)棧幀在Java棧中的入棧和出棧操作
經(jīng)常有人把Java內(nèi)存分為堆內(nèi)存和棧內(nèi)存,這種分法比較粗糙聚至,其中的棧就是我們現(xiàn)在說到的虛擬機(jī)棧酷勺,或者說是虛擬機(jī)棧中的局部變量表部分,局部變量表存放了編譯期可知的各種基本數(shù)據(jù)類型對象引用(他不等同于對象本身扳躬,可能是一個(gè)指向?qū)ο笃鹗嫉刂返囊弥羔槾嗨撸部赡苁侵赶蛞粋€(gè)代表對象的句柄或者其他與此對象相關(guān)的位置)和returnAddress類型(指向一條字節(jié)碼指令的地址)
局部變量表所需的內(nèi)存空間在編譯期就完成分配了,當(dāng)進(jìn)入一個(gè)方法的時(shí)候贷币,這個(gè)方法需要在幀中分配多大的局部變量空間是完全確定的击胜,在方法運(yùn)行期間不會(huì)改變局部變量表的大小
什么是句柄?
Windows是一個(gè)以虛擬內(nèi)存為基礎(chǔ)的操作系統(tǒng)役纹,在這種環(huán)境下偶摔,Windows內(nèi)存管理器經(jīng)常在內(nèi)存中來回移動(dòng)對象,以此來滿足各種應(yīng)用程序的需要字管。對象被移動(dòng)意味著它的地址變化了啰挪。由于地址總是如此變化,所以Windows操作系統(tǒng)為各應(yīng)用程序騰出一些內(nèi)存地址嘲叔,用來專門登記各應(yīng)用對象在內(nèi)存中的地址變化,而這地址(存儲(chǔ)單元的位置)本身是不變的抽活。Windows內(nèi)存管理器在移動(dòng)對象在內(nèi)存中的位置后硫戈,把對象新的地址告知這個(gè)句柄地址來保存。這樣我們只需記住這個(gè)句柄地址就可以間接地知道對象具體在內(nèi)存中的哪個(gè)位置下硕。這個(gè)地址是在對象裝載(Load)時(shí)由系統(tǒng)分配給的,當(dāng)系統(tǒng)卸載時(shí)(Unload)又釋放給系統(tǒng)。
因此分唾,Windows程序中并不是用物理地址來標(biāo)識(shí)一個(gè)內(nèi)存塊沧踏,文件,任務(wù)洗搂,或動(dòng)態(tài)裝入模塊的,相反,WINDOWS API給這些項(xiàng)目分配確定的句柄铸题,并將句柄返回給應(yīng)用程序,然后通過句柄來進(jìn)行操作琢感。
Java虛擬機(jī)規(guī)范規(guī)定
- 如果線程請求的棧深度大于虛擬機(jī)所允許的深度丢间,將拋出棧溢出異常
- 如果虛擬機(jī)棧可以動(dòng)態(tài)擴(kuò)展驹针,如果擴(kuò)展時(shí)不能申請到足夠的內(nèi)存烘挫,就會(huì)拋出內(nèi)存溢出異常
本地方法棧
虛擬機(jī)棧為虛擬機(jī)執(zhí)行Java方法(也就是字節(jié)碼)服務(wù),本地方法棧則為虛擬機(jī)使用到的Native方法服務(wù)柬甥,Sun HotSpot直接把本地方法棧和虛擬機(jī)棧合二為一
本地方法棧同樣也會(huì)拋出內(nèi)存溢出異常
Java堆
Java堆是Java虛擬機(jī)所管理的內(nèi)存中的最大的一部分饮六,Java堆是被所有線程共享的一塊內(nèi)存區(qū)域,在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建苛蒲,次內(nèi)存區(qū)域的唯一目的就是存放對象實(shí)例卤橄,幾乎所有對象實(shí)例都在這里分配內(nèi)存,在Java虛擬機(jī)規(guī)范中說道:所有對象實(shí)例以及數(shù)組都要在堆上進(jìn)行分配
其實(shí)上面的最后一句話不對撤防,在學(xué)習(xí)Java的過程中虽风,一般認(rèn)為new出來的對象都是被分配在堆上的,其實(shí)這個(gè)結(jié)論不完全正確寄月,因?yàn)槭?strong>大部分new出來的對象被分配在堆上辜膝,而不是全部。通過對Java對象分配的過程分析漾肮,可以知道有另外兩個(gè)地方也是可以存放對象的厂抖。這兩個(gè)地方分別是 棧 (涉及逃逸分析相關(guān)知識(shí))和TLAB(Thread Local Allocation Buffer)。我們首先對這兩者進(jìn)行介紹克懊,而后對Java對象分配過程進(jìn)行介紹忱辅。
棧上分配
在JVM中,堆是線程共享的谭溉,因此堆上的對象對于各個(gè)線程都是共享和可見的墙懂,只要持有對象的引用,就可以訪問堆中存儲(chǔ)的對象數(shù)據(jù)扮念。虛擬機(jī)的垃圾收集系統(tǒng)可以回收堆中不再使用的對象损搬,但對于垃圾收集器來說,無論篩選可回收對象,還是回收和整理內(nèi)存都需要耗費(fèi)時(shí)間巧勤。
如果確定一個(gè)對象的作用域不會(huì)逃逸出方法之外嵌灰,那可以將這個(gè)對象分配在棧上,這樣颅悉,對象所占用的內(nèi)存空間就可以隨棧幀出棧而銷毀沽瞭。在一般應(yīng)用中,不會(huì)逃逸的局部對象所占的比例很大剩瓶,如果能使用棧上分配驹溃,那大量的對象就會(huì)隨著方法的結(jié)束而自動(dòng)銷毀了,無須通過垃圾收集器回收儒搭,可以減小垃圾收集器的負(fù)載吠架。
JVM允許將線程私有的對象打散分配在棧上,而不是分配在堆上搂鲫。分配在棧上的好處是可以在函數(shù)調(diào)用結(jié)束后自行銷毀傍药,而不需要垃圾回收器的介入,從而提高系統(tǒng)性能魂仍。
棧上分配的技術(shù)基礎(chǔ):
- 逃逸分析:逃逸分析的目的是判斷對象的作用域是否有可能逃逸出函數(shù)體拐辽。
-
標(biāo)量替換:允許將對象打散分配在棧上,比如若一個(gè)對象擁有兩個(gè)字段擦酌,會(huì)將這兩個(gè)字段視作局部變量進(jìn)行分配俱诸。
1.標(biāo)量和聚合量標(biāo)量即不可被進(jìn)一步分解的量,而JAVA的基本數(shù)據(jù)類型就是標(biāo)量(如:int赊舶,long等基本數(shù)據(jù)類型以及reference類型等)睁搭,標(biāo)量的對立就是可以被進(jìn)一步分解的量,而這種量稱之為聚合量笼平。而在JAVA中對象就是可以被進(jìn)一步分解的聚合量园骆。
2.替換過程通過逃逸分析確定該對象不會(huì)被外部訪問,并且對象可以被進(jìn)一步分解時(shí)寓调,JVM不會(huì)創(chuàng)建該對象锌唾,而會(huì)將該對象成員變量分解若干個(gè)被這個(gè)方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上分配空間夺英。
什么是逃逸
"發(fā)布(Publish)"一個(gè)對象的意思是指晌涕,是對象能夠在當(dāng)前作用域之外的代碼中使用。例如痛悯,將一個(gè)指向該對象的引用保存在其他代碼可以訪問的地方余黎,或者在某一個(gè)非私有的方法中返回該引用,或者將引用傳遞到其他類的方法中
當(dāng)某個(gè)不應(yīng)該發(fā)布的對象被發(fā)布時(shí)载萌,這種情況就被稱為逸出(Escape)驯耻,也叫逃逸
JVM允許將線程私有的對象打散分配在棧上亲族,而不是分配在堆上炒考。分配在棧上的好處是可以在函數(shù)調(diào)用結(jié)束后自行銷毀可缚,而不需要垃圾回收器的介入,從而提高系統(tǒng)性能斋枢。
Java堆是垃圾收集器的主要區(qū)域帘靡,很多時(shí)候也被稱為GC堆,從內(nèi)存回收的角度來看GC基本都采用分代算法瓤帚,所以Java堆中還可以細(xì)分為新生代描姚、老生帶,細(xì)分一下還有Eden空間戈次,F(xiàn)rom Survivor空間轩勘, To Survivor空間
從內(nèi)存分配角度來看,線程共享的Java堆中可能劃分出多個(gè)線程私有的分配緩沖區(qū)怯邪,不過無論如何劃分绊寻,都與存放內(nèi)容無關(guān),無論哪個(gè)區(qū)域悬秉,存儲(chǔ)的都仍然是對象實(shí)例澄步,進(jìn)一步劃分的目的是為了更好地回收內(nèi)存,或者更快的分配內(nèi)存
根據(jù)Java虛擬機(jī)規(guī)范的規(guī)定和泌,Java堆可以處于物理上不連續(xù)的內(nèi)存中村缸,只要邏輯上是連續(xù)的即可,就像我們的磁盤空間一樣武氓,可以是固定大小的梯皿,也可是可擴(kuò)展的
如果在堆中沒有內(nèi)存完成實(shí)例分配,并且堆也無法再進(jìn)行擴(kuò)展的時(shí)候县恕,會(huì)拋出內(nèi)存溢出的異常
方法區(qū)
方法區(qū)與Java堆一樣东羹,是各個(gè)線程共享的內(nèi)存區(qū)域,它用于存儲(chǔ)已被虛擬機(jī)加載的類信息弱睦、常量百姓、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù)况木,雖然Java虛擬機(jī)把它描述為堆的一部分垒拢,但是它有一個(gè)別名叫非堆(Non-heap),在Hot Spot上部署開發(fā)的程序員更愿意把方法區(qū)稱作"永久代"火惊,但是本質(zhì)上兩者不等價(jià)求类,因?yàn)镠ot Spot設(shè)計(jì)團(tuán)隊(duì)把GC同樣設(shè)計(jì)到了方法區(qū)上
相對而言,垃圾收集行為在這個(gè)區(qū)域是比較少出現(xiàn)的屹耐,但并非數(shù)據(jù)進(jìn)入了方法區(qū)就如同永久代的名字一樣"永久"存在了尸疆,這個(gè)區(qū)域的內(nèi)存回收目標(biāo)主要是針對常量池的回收和對類型的卸載,一般來說,這個(gè)區(qū)域的回收成績比較難以令人滿意寿弱,尤其是類型的卸載
方法區(qū)是什么犯眠?有哪些特點(diǎn)?
方法區(qū)是系統(tǒng)分配的一個(gè)內(nèi)存邏輯區(qū)域症革,是用來存儲(chǔ)類型信息的(類型信息可理解為類的描述信息)筐咧。方法區(qū)主要有以下幾個(gè)特點(diǎn):
- 方法區(qū)是線程安全的。由于所有的線程都共享方法區(qū)噪矛,所以量蕊,方法區(qū)里的數(shù)據(jù)訪問必須被設(shè)計(jì)成線程安全的。例如艇挨,假如同時(shí)有兩個(gè)線程都企圖訪問方法區(qū)中的同一個(gè)類残炮,而這個(gè)類還沒有被裝入JVM,那么只允許一個(gè)線程去裝載它缩滨,而其它線程必須等待
- 方法區(qū)的大小不必是固定的势就,JVM可根據(jù)應(yīng)用需要?jiǎng)討B(tài)調(diào)整。同時(shí)楷怒,方法區(qū)也不一定是連續(xù)的蛋勺,方法區(qū)可以在一個(gè)堆(甚至是JVM自己的堆)中自由分配。
- 方法區(qū)也可被垃圾收集鸠删,當(dāng)某個(gè)類不在被使用(不可觸及)時(shí)抱完,JVM將卸載這個(gè)類,進(jìn)行垃圾收集
當(dāng)方法區(qū)無法滿足內(nèi)存分配需求時(shí)刃泡,將拋出內(nèi)存溢出的異常
運(yùn)行時(shí)常量池
運(yùn)行時(shí)常量池是方法區(qū)的一部分巧娱,Class文件中除了有類的版本、字段烘贴、方法禁添、接口等描述信息外,還有一項(xiàng)信息是常量池桨踪,用于存放編譯器生成的各種字面量和符號的引用老翘,這部分內(nèi)容將在類加載后進(jìn)入方法去的運(yùn)行時(shí)常量池中存放
每一個(gè)字節(jié)用于存儲(chǔ)哪種數(shù)據(jù)都必須符合規(guī)范上的要求才會(huì)被虛擬機(jī)認(rèn)可、裝載和執(zhí)行锻离,但對于運(yùn)行時(shí)常量池铺峭,Java虛擬機(jī)規(guī)范沒有做任何細(xì)節(jié)要求,不過一般來說汽纠,除了保存Class文件中描述的符號引用外卫键,還會(huì)把翻譯出來的直接引用也存儲(chǔ)在運(yùn)行時(shí)常量中
運(yùn)行時(shí)常量池相對與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ū)內(nèi)存的限制絮宁,當(dāng)常量池?zé)o法再申請到內(nèi)存的時(shí)候就會(huì)拋出內(nèi)存溢出異常
直接內(nèi)存
直接內(nèi)存并不是虛擬機(jī)運(yùn)行時(shí)數(shù)據(jù)區(qū)的一部分梆暮,也不是Java虛擬機(jī)規(guī)范中定義的內(nèi)存區(qū)域,但是這部分內(nèi)存被頻繁使用羞福,而且也可能導(dǎo)致內(nèi)存溢出異常的出現(xiàn)
在JDK1.4中加入了NIO引入了一種基于管道channel和緩沖區(qū)buffer的I/O方式惕蹄,它可以直接使用Native函數(shù)庫直接分配堆外內(nèi)存(本地方發(fā)棧),然后通過一個(gè)存儲(chǔ)在Java堆中的DriectByteBuffer對象作為這一塊乃村的引用進(jìn)行操作治专,這樣的話,在一些場景能顯著提高性能
雖然本機(jī)直接內(nèi)存不收J(rèn)ava堆大小的限制遭顶,但是既然是內(nèi)存张峰,就會(huì)受到本機(jī)總內(nèi)存大小以及處理器尋址空間的限制,有的時(shí)候根據(jù)實(shí)際內(nèi)存設(shè)置-Xmx等參數(shù)信息棒旗,經(jīng)常忽略直接內(nèi)存喘批,使得各個(gè)內(nèi)存區(qū)域總和大于物理內(nèi)存限制,拋出內(nèi)存溢出的異常(除了堆上和棧上分配的內(nèi)存铣揉,在本地方法椚纳睿可能存在堆上的引用直接內(nèi)存)
虛擬機(jī)對象探秘
HotSpot虛擬機(jī)在Java堆中對對象分配、布局和訪問的全過程
對象的創(chuàng)建(普通Java對象)
虛擬機(jī)遇到一條new指令的時(shí)候逛拱,首先回去檢查這個(gè)指令的參數(shù)是否能在常量池中定位到一個(gè)類的符號引用敌厘,并且檢查這個(gè)符號引用代表的類是否已經(jīng)被加載、解析和初始化過朽合,如果沒有的話那就必須先執(zhí)行相應(yīng)的類加載過程
在類加載檢查通過后俱两,接下來虛擬機(jī)將為新生對象分配內(nèi)存。對象所需內(nèi)存的大小在類加載完成后便可確定
可以想到為對象分配空間的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來
-
假設(shè)Java堆中內(nèi)存是絕對規(guī)整的
所有用過的內(nèi)存放在一邊曹步,沒有使用過的內(nèi)存放在一邊宪彩,中間放置著一個(gè)指針作為分界點(diǎn)的指示器,那所分配內(nèi)存就是僅僅把那個(gè)指針向空閑空間那邊挪動(dòng)一段與對象大小相等的距離讲婚,這種分配方式被稱為指針碰撞(Bump the Pointer) -
如果Java堆中的內(nèi)存并不是工整的
已使用的內(nèi)存空間和未使用的內(nèi)存空間相互交錯(cuò)尿孔,那就沒有辦法簡單地使用指針碰撞這種分配內(nèi)存的方式,虛擬機(jī)就必須維護(hù)一個(gè)列表筹麸,記錄那些內(nèi)存是可用的活合,在分配的時(shí)候從列表中找到一塊足夠大的空間劃分給對象實(shí)例,并更新列表上的記錄竹捉,這種分配方式叫做空閑列表(Free List)
選擇哪種分配方式取決Java堆是否規(guī)整芜辕,而Java堆是否規(guī)整又由所采用的垃圾收集器是否帶有亞索整理功能決定,因此在使用Serial,ParNew等帶Compact過程的收集器時(shí)块差,通常采用指針碰撞侵续,而使用CMS這種基于Mark-Sweep算法的收集器時(shí)倔丈,通常采用的分配算法是空閑列表
分配空間的并發(fā)問題
實(shí)例化一個(gè)對象在JVM中是一個(gè)非常頻繁的操作,如果在多線程情況下只是移動(dòng)一個(gè)指針状蜗,在并發(fā)條件下也并不是線程安全的需五,可能出現(xiàn)正在給對象A分配內(nèi)存,指針還沒來得及修改轧坎,對象B又同事使用了原來的指針來分配內(nèi)存的情況宏邮,解決這個(gè)問題有兩種方案
- 對分配內(nèi)存空間的動(dòng)作進(jìn)行同步處理——實(shí)際上虛擬機(jī)采用CAS配上失敗重試的方式保證更新操作的原子性
- 把內(nèi)存分配的動(dòng)作按照線程劃分在不同的空間中進(jìn)行,即每個(gè)線程在Java堆中預(yù)先分配一小塊內(nèi)存缸血,成為本地線程分配緩沖(TLAB)蜜氨,哪個(gè)線程需要分配內(nèi)存就在哪個(gè)線程自己的TLAB上分配,只有TLAB用完并分配新的TLAB的時(shí)候才需要同步鎖定
內(nèi)存分配完成后捎泻,虛擬機(jī)需要將分配到的內(nèi)存空間都初始化為零值(不包括對象頭)飒炎,如果使用TLAB,這一工作過程也可以提前至TLAB分配時(shí)進(jìn)行笆豁,這一步操作保證了對象實(shí)例字段在Java代碼中可以不賦初始值就可以直接使用郎汪,程序能夠訪問到這些字段的數(shù)據(jù)類型所對應(yīng)的零值
接下來,虛擬機(jī)要對對象進(jìn)行必要的設(shè)置闯狱,例如:這個(gè)對象是哪個(gè)類的實(shí)例煞赢、如何才能找到類的元數(shù)據(jù)信息、對象的哈希碼哄孤、對象的GC分代年齡等信息照筑,這些信息存放在對象的對象頭之中,根據(jù)虛擬機(jī)當(dāng)前運(yùn)行狀態(tài)的不同录豺,如是否使用偏向鎖等朦肘,對象頭會(huì)有不同的設(shè)置方式
上面工作都完成之后,從虛擬機(jī)的角度來看双饥,一個(gè)新的對象就已經(jīng)產(chǎn)生了媒抠,但是從Java程序的視角來看,對象創(chuàng)建才剛剛開始——init方法還沒有執(zhí)行咏花,所有的字段都還為零趴生,所以,一般來說(由字節(jié)碼中是否跟隨invokespecial指令所決定)昏翰,執(zhí)行new指令之后會(huì)接著執(zhí)行init方法苍匆,把對象按照程序員的意愿進(jìn)行初始化,這樣之后才算是生產(chǎn)了一個(gè)完整的對象