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