天天看點

1.Java記憶體區域與記憶體溢出異常

概述:Java與C++之間有一堵由記憶體動态配置設定和垃圾收集技術所圍成的”高牆”,牆外面的人想進去,牆裡面的人卻像出來,對于從事C、C++程式開發的開發人員來說,在記憶體管理領域,他們需要配置設定記憶體,同時回收記憶體。對于Java程式員來說,在虛拟機自動記憶體管理機制的幫助下,不再需要為每一個new操作去寫配對的delete/free代碼,不容易出現記憶體洩露和記憶體溢出的問題,由虛拟機管理記憶體這一切看起來都很美好。不過,也正是因為 Java程式員把記憶體控制的權力交給Java虛拟機,一旦出現記憶體洩露和溢出方面的問題,如果不了解虛拟機是怎樣使用記憶體的,那麼排查錯誤會成為一項異常艱難的工作。

運作時資料區域

介紹:Java虛拟機在執行Java程式的過程中會把它所管理的記憶體劃分為若幹個不同的資料區域。這些區域都有各自的用途,以及建立和銷毀的時間,有的區域随着虛拟機程序的啟動而存在,有些區域則依賴使用者線程的啟動和結束而建立和銷毀。根據Java虛拟機規範的規定,Java虛拟機所管理的記憶體将會包括以下幾個運作時資料區域,如下圖所示。

1.Java記憶體區域與記憶體溢出異常

程式計數器:

介紹: 程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看作是目前線程所執行的位元組碼的行号訓示器。在虛拟機的概念模型裡(僅是概念模型,各種虛拟機可能會通過一些更高效的方式去實作),位元組碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。

由于Java虛拟機的多線程是通過線程輪流切換并配置設定處理器執行時間的方式來實作的,在任何一個确定的時刻,一個處理器(對于多核處理器來說是一個核心)都隻會執行一條線程中的指令。是以,為了線程切換後能恢複到正确的執行位置,每條線程都需要有一個獨立的程式計數器,各條線程之間的計數器互不影響,獨立存儲,我們稱這類記憶體區域為”線程私有”的記憶體。

如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛拟機位元組碼指令的位址;如果正在執行的是Native方法,這個計數器值則為空(Undefined)。此記憶體區域是唯一一個在Java虛拟機規範中沒有規定任何OutOfMemoryError情況的區域。

Java虛拟機棧

介紹:與程式計數器一樣,Java虛拟機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。虛拟機棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動态連結、方法出口等資訊。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛拟機棧中入棧到出棧的過程。

局部變量表存放了編譯器可知的各種基本資料類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等用于對象本身,可能是一個指向對象起始的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和returnAddress類型(指向了一條位元組碼指令的位址)。

其中64位長度的了long和double類型的資料會占用2個局部變量空間(Slot),其餘的資料類型隻占用1個。局部變量表所需的記憶體空間在編譯期間完成配置設定,當進入一個方法時,這個方法需要在幀中配置設定多大的局部變量空間是完全确定的,在方法運作期間不會改變局部變量表的大小。

在Java虛拟機規範中,對這個區域規定了兩種異常狀況:如果線程請求的棧深度大于虛拟機所允許的深度,将抛出StackOverflowError異常;如果虛拟機棧可以動态擴充(目前大部分的Java虛拟機都可動态擴充,隻不過Java虛拟機規範中也允許固定長度的虛拟機棧),如果擴充時無法申請到足夠的記憶體,将會抛出OutOfMemoryError異常。

本地方法棧

介紹:本地方法棧(Native Method Stack)與虛拟機棧所發揮的作用是非常相似的,它們之間的差別不過是虛拟機棧為虛拟機執行Java方法(也就是位元組碼)服務,而本地方法棧則為虛拟機使用到的Native方法(簡單地講,一個Native Method就是一個java調用非java代碼的接口)服務。在虛拟機規範中對本地方法棧中方法使用的語言、使用方式與資料結構并沒有強制規定,是以具體的虛拟機可以自由實作它。甚至有的虛拟機(譬如Sun HotSpot虛拟機)直接就把本地方法棧和虛拟機棧合二唯一。與虛拟機棧一樣,本地方法棧區域也會抛出StackOverflowError和OutOfMemeoryError異常。

Java堆

介紹: 對于大多數應用來說,Java堆(Java Heap)是Java虛拟機所管理的記憶體中最大的一塊。Java堆是被所有線程共享的一塊記憶體區域,在虛拟機啟動時建立。此記憶體區域的唯一目的就是存放對象執行個體,幾乎所有的對象執行個體都在這裡配置設定記憶體。這一點在Java虛拟機規範中的描述是:所有的對象執行個體以及數組都要在堆上配置設定,但是随着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上配置設定、标量替換優化技術将會導緻一些微妙的變化發生,所有的對象都配置設定在堆上也漸漸變得不是那麼”絕對”了。

Java堆是垃圾收集器管理的主要區域,是以很多時候也被成為”GC堆”(Garbage Collected Heap)。從記憶體回收的角度來看,由于現在收集器基本都采用分代收集算法,是以Java堆中還可以細分為:新生代和老年代;再細緻一點的有Eden空間、From Survivor空間、To Survivor空間等。從記憶體配置設定的角度來看,線程共享的Java堆中可能劃分出多個線程私有的配置設定緩沖區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放無關,無論哪個區域,存儲的都仍然是對象執行個體,進一步劃分的目的是為了更好地回收記憶體,或者更快地配置設定記憶體。

根據Java虛拟機規範的規定,Java堆可以處于實體上不連續的記憶體空間中,隻要邏輯上是連續的即可,就像我們的磁盤空間一樣。在實作時,既可以實作成固定大小的,也可以是可擴充的,不過目前主流虛拟機都是按照可擴充來實作的(通過-Xmx和-Xms控制)。如果在堆中沒有記憶體完成執行個體配置設定,并且堆也無法再擴充時,将會抛出OutOfMemoryError異常。

方法區

介紹:方法區(Method Area)與Java堆一樣,是各個線程共享的記憶體區域,它用于存儲已被虛拟機加載的類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料。雖然Java虛拟機規範把方法區描述為堆的一個邏輯部分,但是它卻有一個别名叫做Non-Heap(非堆),目的應該是與Java堆區分開的。

對于習慣在HotSpot虛拟機上開發、部署程式的開發者來說,很多人都更願意把方法區稱為“永久代”(PermanentGeneration),本質上兩者并不等價,僅僅是因為HotSpot虛拟機的設計團隊選擇把GC分代收集擴充至方法區,或者說使用永久代來實作方法區而已,這樣HotSpot的垃圾收集器可以像管理Java堆一樣管理這部分記憶體,能夠省去專門為方法區編寫記憶體管理代碼的工作。對于其他虛拟機(如BEA JRockit、IBM J9等)來說是不存在永久代的概念的。原則上,如何實作方法區屬于虛拟機實作細節,不受虛拟機規範限制,但使用永久代來實作方法區,現在看來并不是一個好主意,因為這樣更容易遇到記憶體溢出問題(永久代有-XX:MaxPermSize的上限,J9和JRockit隻要沒有觸碰到程序可用記憶體的上限,例如32位系統中的4GB,就不會出現問題),而且有極少數方法(例如String.intern())會因這個原因導緻不同虛拟機下有不同的表現。是以,對于HotSpot虛拟機,根據官方釋出的路線圖資訊,現在也有放棄永久代并逐漸改為采用Native Memory來實作方法區的規劃了[1],在目前已經釋出的JDK 1.7的HotSpot中,已經把原本放在永久代的字元串常量池移出。

Java虛拟機規範對方法區的限制非常寬松,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充外,還可以選擇不實作垃圾收集。相對而言,垃圾收集行為在這個區域是比較少出現的,但并非資料進入了方法區就如永久代的名字一樣”永久”存在了。這區域的記憶體回收目标主要是針對常量池的回收和對類型的解除安裝,一般來說,這個區域的回收”成績”比較難以令人滿意,尤其是類型的解除安裝,條件相當苛刻,但是這部分區域的回收确實是必要的。在Sun公司的BUG清單中,曾出現過的若幹個嚴重的BUG就是由于低版本的HotSpot虛拟機對此區域未完全回收而導緻記憶體洩露。根據Java虛拟機規範的規定,當方法區無法滿足記憶體配置設定需求時,将抛出OutOfMemoryError異常。

運作時常量池

介紹: 運作時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用于存放編譯器生成的各種字面量和符号引用,這部分内容将在類加載後進入方法區的運作時常量池中存放。

Java虛拟機對Class檔案每一部分(自然也包括常量池)的格式都有嚴格規定,每一個位元組用于存儲那種資料都必須符合規範上的要求才會被虛拟機認可、裝載和執行,但對于運作時常量池,Java虛拟機規範沒有做任何細節的要求,不用的提供商實作的虛拟機可以按照自己的需要來實作這個記憶體區域。不過,一般來說,除了儲存Class檔案中描述的符号引用外,還會把翻譯出來的直接引用也存儲在運作時常量池中。

運作時常量池相對于Class檔案常量池的另外一個重要特征是具備動态性,Java語言并不要求常量一定隻有編譯器才能産生,也就是并非預置入Class檔案中常量池的内容才能進入方法區運作時常量池,運作期間也可能将新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。

既然運作時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會抛出OutOfMemoryError異常。

直接記憶體

介紹: 直接記憶體(Direct Memory)并不是虛拟機運作時資料區的一部分,也不是Java虛拟機規範中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導緻OutOfMemoryError異常出現。

在JDK1.4中新加入NIO(New Input/Output)類,引入了一種基于通道(Channel)與緩沖區(Buffer)的I/O方式,它可以使用Native函數庫直接配置設定堆外記憶體,然後通過一個存儲在Java堆中的DirectByteBuffer對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在Java堆中和Native堆中來回複制資料。

顯然,本機直接記憶體的配置設定不會受到Java堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體(包括RAM以及SWAP區或者分頁檔案)大小以及處理器尋址空間的限制。伺服器管理者在配置虛拟機參數時,會根據實際記憶體設定-Xmx等參數資訊,但經常忽略直接記憶體,使得各個記憶體區域總和大于實體記憶體限制(包括實體的和作業系統級的限制),進而導緻動态擴充時出現OutOfMemoryError異常。

虛拟機中對象的建立

介紹: java是一門面向對象的語言,在Java程式運作過程中無時無刻都有對象被建立出來。在語言的層面上,建立對象(例如克隆、反序列化)通常僅僅是一個new關鍵字而已,而在虛拟機中,對象(讨論的對象僅限于普通的Java對象,不包括數組和Class對象等)的建立又是怎樣的一個過程呢?

虛拟機遇到一條new指令時,首先将去檢查這個指令的參數是否能在常量池中定位到一個類的符号引用,并且檢查這個符号引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。

在類加載檢查通過後,接下來虛拟機将為新生對象配置設定記憶體。對象所需記憶體的大小在類加載完成後便可完全确定,為對象配置設定空間的任務等同于把一塊确定大小的記憶體從Java堆中劃分出來。假設Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閑的記憶體放在另一邊,中間放着一個指針作為分界點的訓示器,那所配置設定的記憶體就僅僅是把那個指針向空閑的空間那邊挪動一段與對象大小相等的距離,這種配置設定方式稱為“指針碰撞“(Bump the Pointer)。如果Java堆中的記憶體并不是規整的,已使用的記憶體和空閑的記憶體互相交錯,那就沒有辦法簡單地進行指針碰撞了,虛拟機就必須維護一個清單,記錄上哪些記憶體塊是可用的,在配置設定的時候從清單中找到一塊足夠大的空間劃分給對象執行個體,并更新清單上的記錄,這種配置設定方式稱為“空閑清單”(Free List)。選擇哪種配置設定方式由Java堆是否規整決定,而Java堆是否規整又又所采用的的垃圾收集器是否帶有壓縮整理功能決定。是以,在使用Serial、ParNew等帶Compact過程的收集器時,系統采用的配置設定方法是指針碰撞,而使用CMS這種基于Mark-Sweep算法的收集器時,通常采用空閑清單。

除如何劃分可用空間之外,還有另外一個需要考慮的問題是對象建立在虛拟機中是非常頻繁的行為,即使是僅僅修改一個指針所指向的位置,在并發情況下也并不是線程安全的,可能出現正在給A對象配置設定記憶體,指針還沒有來得及修改,對象B又同時使用了原來的指針來配置設定記憶體的情況。解決這個問題有兩種方案,一種是對配置設定記憶體空間的動作進行同步處理——實際上虛拟機采用CAS配上失敗重試的方式保證更新操作的原子性;另一種是把記憶體配置設定的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先配置設定一小塊記憶體,稱為本地線程配置設定緩沖(Thread Local Allocation Buffer, TLAB)。哪個線程要配置設定記憶體,就在哪個線程的TLAB上配置設定,隻有TLAB用完并配置設定新的TLAB時,才需要同步鎖定。虛拟機是否使用TLAB,可以通過-XX:+/-UserTLAB參數來設定。

記憶體配置設定完成後,虛拟機需要将配置設定的記憶體空間都初始化為零值(不包括對象頭),如果使用TLAB,這一工作過程也可以提前至TLAB配置設定時進行。這一步操作保證了對象執行個體字段在Java代碼中可以不賦初始值就直接使用,程式能通路到這些字段的資料類型所對應的零值。

接下來,虛拟機要對對象進行必要的設定,例如這個對象是哪個類的執行個體、如何才能找到類的中繼資料資訊、對象的哈希碼、對象的GC的分代年齡等資訊。這些資訊存放在對象的對象頭(Object Header)之中。根據虛拟機目前的運作狀态的不同,如是否啟用偏向鎖等,對象頭會有不同的設定方式。

在上面的工作都完成之後,從虛拟機的視角來看,一個新的對象已經産生了,但從Java程式的視角來看,對象建立才剛剛開始——方法還沒有執行,所有的字段都還為零。是以,一般來說(由位元組碼中是否跟随invokespecial指令所決定),執行new指令之後會接着執行方法,把對象按照程式員的意願進行初始化,這樣一個真正可用的對象才算完全産生出來。

對象的記憶體布局

說明: 在HotSpot虛拟機中,對象在記憶體中存儲的布局可以分為3塊區域:對象頭(Header)、執行個體資料(Instance Data)和對齊填充(Padding)。

對象頭

HotSpot虛拟機的對象頭包括兩部分資訊,第一部分用于存儲對象自身的運作時資料,如哈希嗎(HashCode)、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分資料的長度在32位和64位的虛拟機(未開啟壓縮指針)中分别為32bit和64bit,官方稱它為“Mark Word”。對象需要存儲的運作時資料很多,其實已經超過了32位、64位Bitmap結構所能記錄的限度,但是對象頭資訊是與對象自身定義的資料無關的額外存儲成本,考慮到虛拟機的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體存儲盡量多的資訊,它會根據對象的狀态複用自己的存儲空間。

對象頭的另外一部分是類型指針,即對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體。并不是所有的虛拟機實作都必須在對象資料上保留類型指針,換句話說,查找對象的中繼資料資訊并不一定要經過對象本身。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用于記錄數組長度的資料,因為虛拟機可以通過普通Java對象的中繼資料資訊确定Java對象的大小,但是從資料的中繼資料中卻無法确定數組的大小。

執行個體資料

執行個體資料部分是對象真正存儲的有效資訊,也是在程式代碼中所定義的各種類型的字段内容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的存儲順序會受到虛拟機配置設定政策參數(FieldsAllocationStyle)和字段在Java源碼中定義順序的影響。HotSpot虛拟機預設的配置設定政策為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從配置設定政策中可以看出,相同寬度的字段總是被配置設定到一起。在滿足這個前提條件的情況下,在父類中定義的變量會出現在子類之前。如果CompactFields參數值為true(預設為true),那麼子類之中較窄的變量也可能會插入到父類變量的空隙之前。

對齊填充

對其填充并不是必然存在的,也沒有特别的含義,它僅僅起着占位符的作用。由于HotSpot VM的自動記憶體管理系統要求對象起始位址必須是8位元組的整數倍,換句話說,就是對象的大小必須是8位元組的倍數。而對象頭部分正好是8位元組的倍數(1倍或者2倍),是以,當對象執行個體資料部分沒有對齊時,就需要通過對齊填充來補全。

對象的通路定位

介紹:建立對象是為了使用對象,我們的Java程式需要通過棧上的reference資料來操作堆上的具體對象。由于reference類型在Java虛拟機規範中隻規定了一個指向對象的引用,并沒有定義這個引用應該通過何種方式去定位、通路堆中的對象的具體位置,是以對象通路方式也是取決于虛拟機實作而定的。

使用句柄通路

如果使用句柄通路的話,那麼Java堆中将會劃分出一塊記憶體來作為句柄池,reference中存儲的就是對象的句柄位址,而句柄中包含了對象執行個體資料與類型資料各自的具體位址資訊。

優點:使用句柄來通路的最大好處就是reference中存儲的是穩定的句柄位址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時隻會改變句柄中的執行個體資料指針,而reference本身不需要修改。

1.Java記憶體區域與記憶體溢出異常

直接使用指針通路

如果使用直接指針通路,那麼Java堆對象的布局就必須考慮如何放置通路類型資料的相關資訊,而reference中存儲的直接就是對象位址。

優點:使用直接指針通路方式的最大好處就是速度最快,它節省了一次指針定位的時間開銷,由于對象的通路在Java中非常頻繁,是以這類開銷積少成多後也是一項非常可觀的執行成本。對于Sun HotSpot而言,它是使用直接指針通路方式進行對象通路的,但從整個軟體開發的範圍來看,各種語言和架構使用句柄來通路的情況也十分常見。

1.Java記憶體區域與記憶體溢出異常

實戰:OutOfMemoryError異常

介紹:在Java虛拟機規範的描述中,除了程式計數器外,虛拟機記憶體的其他幾個運作時區域都有發生OutOfMemoryError(OOM)異常的可能,下面将通過幾個執行個體來驗證異常發生的場景。

目的:第一,通過代碼驗證Java虛拟機規範中描述的各個運作時區域存儲的内容; 第二,在工作中遇到實際的記憶體溢出異常時,能根據異常的資訊快速判斷是哪個區域的記憶體溢出,知道什麼樣的代碼可能會導緻這些區域記憶體溢出,以及出現這些異常後該如何處理。

Java堆溢出

介紹:Java堆用于存儲對象執行個體,隻要不斷地建立對象,并且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那麼在對象數量到達最大堆的容量限制後就會産生記憶體溢出異常。

Java堆記憶體溢出異常測試(代碼限制Java堆的大小為20MB,不可擴充(将堆的最小值-Xms參數與最大值-Xmx參數設定為一樣即可避免堆自動擴充),通過參數-XX:+HeapDumpOnOutOfMemoryError可以讓虛拟機在出現記憶體溢出異常時Dump出目前的記憶體堆轉儲快照以便事後進行分析)

import java.util.ArrayList;
import java.util.List;

/**
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOnMemoryError
 */
public class HeapOOM {
    static class OOMObject{

    }
    public static void main(String[] args){
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true){
            list.add(new OOMObject());
        }
    }
}
           

結果:

1.Java記憶體區域與記憶體溢出異常

分析:Java堆記憶體的OOM異常時實際應用中常見的記憶體溢出異常情況。當出現Java堆記憶體溢出時,異常堆棧資訊“java.lang.OutOfMemoryError”會跟着進一步提示“Java heap space”。要解決這個區域的異常,一般的手段是先通過記憶體映像分析工具(MemoryAnalyzer)對Dump出來的堆轉儲快照進行分析,重點是确認記憶體中的對象是否是必要的,也就是要先厘清楚到底是出現了記憶體洩漏(Memory Leak)還是記憶體溢出(Memory Overflow)。

如果是記憶體洩露,可進一步通過工具檢視洩露對象是通過怎樣的路徑與GC Roots相關聯并導緻垃圾收集器無法自動回收它們的。掌握了洩露對象的類型資訊及GC Roots引用鍊的資訊,就可以比較準确地定位出洩露代碼的位置。

如果不存在洩露,換句話說,就是記憶體中的對象确實都還必須存活着,那就應當檢查虛拟機的堆參數(-Mmx與-Xms),與機器實體記憶體對比看是否還可以調大,從代碼上檢查是否存在某些對象生命周期過長、持有狀态時間過長的情況,嘗試減少程式運作期的記憶體消耗。

虛拟機棧和本地方法棧溢出

介紹:由于在HotSpot虛拟機中并不區分虛拟機棧和本地方法棧,是以,對于HotSpot來說,雖然-Xoss(設定本地方法棧大小)存在,但實際上是無效的,棧容量隻由-Xss參數設定。關于虛拟機棧和本地方法棧,在Java虛拟機規範中描述了兩種異常:

1.如果線程請求的棧深度大于虛拟機所允許的最大深度,将抛出StackOverflowError異常。

2.如果虛拟機在擴充棧時無法申請到足夠的記憶體空間,則抛出OutOfMemoryError異常。

虛拟機棧和本地方法棧溢出異常測試(使用-Xss參數減少棧記憶體容量。結果:抛出StackOverflowError異常,異常出現時輸出的堆棧深度相應縮小。定義了大量的本地變量,增大此方法幀中本地變量表的長度。結果:抛出StackOverflowError異常,異常出現時輸出的堆棧深度相應縮小。)

/**
 * VM Args:-Xss128k
 */
public class JavaVMStackSOF {
    private int stackLength = ;
    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;
        }
    }
}
           

結果:

1.Java記憶體區域與記憶體溢出異常

分析: 實作結果表明:在單個線程下,無論是由于棧幀太大還是虛拟機棧容量太小,當記憶體無法配置設定的時候,虛拟機抛出的都是StackOverflowError異常。

作業系統配置設定給每個程序的記憶體是有限制的,譬如32位的Windows限制為2GB。 虛拟機提供了參數來控制Java堆和方法區的這兩部分記憶體的最大值。剩餘的記憶體為2GB(作業系統限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區容量),程式計數器消耗記憶體很小,可以忽略掉。如果虛拟機程序本身消耗的記憶體不計算在内,剩下的記憶體就由虛拟機棧和本地方法棧“瓜分”了。每個線程配置設定到的棧容量越大,可以建立的線程數量自然就減少,建立線程時就越容易把剩下的記憶體耗盡。

出現StackOverflowError異常時有錯誤堆棧可以閱讀,相對來說,比較容易找到問題的所在。而且,如果使用虛拟機預設參數,棧深度在大多數情況下(因為每個方法壓入棧的幀大小并不是一樣的,是以隻能說在大多數情況下)達到1000~2000完全沒有問題,對于正常的方法調用(包括遞歸),這個深度應該完全夠用了。但是如果是建立多線程導緻的記憶體溢出,在不能減少線程數或者更換64位虛拟機的情況下,就隻能通過減少最大堆和減少棧容量來換取更多的線程。如果沒有這方面的處理經驗,這種通過“減少記憶體”的手段來解決記憶體溢出的方式會比較難以想到。

方法區和運作時常量池溢出

分析:方法區用于存放Class的相關資訊,如類名、通路修飾符、常量池、字段描述、方法描述等。對于這些區域的測試,基本的思路是運作時産生大量的類去填滿方法區,直到溢出。雖然直接使用Java SE API也可以動态産生類(如反射時GeneratedConstructorAccessor和動态代理等),但在本次實驗中操作起來比較麻煩。可借助CGLib直接操作位元組碼運作時生成了大量的動态類。

值得特别注意的是,我們在這個例子中模拟的場景并非純粹是一個實驗,這樣的應用經常會出現在實際應用中:目前的很多主流架構,Spring、Hibernate,在對類進行增強時,都會使用到CGLib這類位元組碼技術,增強的類越多,就需要越大的方法區來保證動态生成的Class可以加載入記憶體。另外,JVM上的動态語言(例如Groovy等)通常都會持續建立類來實作語言的動态性,随着這類語言的流行,也越來越容易遇到相似的溢出場景。

方法區溢出也是一種常見的記憶體溢出異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經常動态生成大量Class的應用中,需要特别注意類的回收狀況。這類場景除了上面提到的程式使用了CGLib位元組碼增強和動态語言之外,常見的還有:大量JSP或動态産生JSP檔案的應用(JSP第一次運作時需要編譯為Java類)、基于OSGi的應用(即使是同一個類檔案,被不同的加載器加載也會視為不同的類)等。

本機直接記憶體溢出

介紹:DirectMemory容量可以通過-XX: MaxDirectMemorySize指定,如果不指定,則預設與Java堆最大值(-Xmx指定)一樣。

做個試驗(一下代碼越過了DirectByteBuffer類,直接通過反射擷取Unsafe執行個體進行記憶體配置設定(Unsafe類的getUnsafe()方法限制了隻有引導類加載器才會傳回執行個體,也就是設計者希望隻有rt.jar中的類才能使用Unsafe的功能)。因為,雖然使用DirectByteBuffer配置設定記憶體也會抛出記憶體溢出異常,但它抛出異常時并沒有真正向作業系統申請配置設定記憶體,而是通過計算得知記憶體無法配置設定,于是手動抛出異常,真正申請配置設定記憶體的方法是unsafe.allocateMemory()):

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
 * VM Args: -Xmx20M-XX: MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
    private static final int _1MB = *;
    public static void main(String[] args) throws Exception{
        Field unsafeField = Unsafe.class.getDeclaredFields()[];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true){
            unsafe.allocateMemory(_1MB);
        }
    }
}
           

結果:

1.Java記憶體區域與記憶體溢出異常

分析: 由DirectMemory導緻的記憶體溢出,一個明顯的特征是在Heap Dump檔案中不會看見明顯的異常,如果發現OOM之後Dump檔案很小,而程式中有直接或間接的使用了NIO,那就可以考慮檢查一下是不是這方面的原因。

本章總結

通過本章的内容,我們明白了虛拟機中的記憶體時如何劃分的,哪部分區域、什麼樣的代碼和操作可能導緻記憶體溢出異常。雖然Java有垃圾收集機制,但記憶體溢出異常離我們仍然并不遙遠。