JVM詳解1.Java內存模型


博客地址:https://spiderlucas.github.io
備用地址:http://spiderlucas.coding.me

1.1 基礎知識

1.1.1 一些基本概念

JDK(Java Development Kit):Java語言、Java虛擬機、Java API類庫
JRE(Java Runtime Environment):Java虛擬機、Java API類庫
JIT(Just In Time):Java虛擬機內置JIT編譯器,將字節碼編譯成本機機器代碼。
OpenJDK:OpenJDK是基於Oracle JDK基礎上的JDK的開源版本,但由於歷史原因缺少了部分(不太重要)的代碼。Sun JDK > SCSL > JRL > OpenJDK
JCP組織(Java Community Process):由Java開發者以及被授權者組成,負責維護和發展技術規範、參考實現(RI)、技術兼容包。

1.1.2 編譯JDK

參見《深入理解Java虛擬機》1.6節
走進JVM之一 自己編譯openjdk源碼

1.2 Java內存模型

1.2.1 運行時數據區域

運行時數據區域
根據Java虛擬機規範(Java SE7)的規定,JVM的內存包括以下幾個運運行時數據區域:

程序計數器
  • 程序計數器(Program Counter Register)是一塊較小的內存空間,他可以看作是當前線程所執行的字節碼的行號指示器
  • 在虛擬機的概念模型裏(僅是概念模型,各種虛擬機可能會通過一些更高效的方式去實現),字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
  • 程序計數器是線程私有的,每條線程都有一個獨立的獨立的程序計數器,各條線程之間計數器互不影響。
  • 如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的Native方法,這個計數器值則爲空(Undefined)。此內存區域是唯一一個在Java虛擬機規範中沒有規定任何OutOfMemoryError情況的區域。
Java虛擬機棧
  • Java虛擬機棧是線程私有的,他的生命週期與線程相同。
  • 虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame),用於包含局部變量表、操作數棧、動態鏈接、方法出口等信息。每個方法從調用到執行完成這個過程,就對應這一個棧幀在虛擬機棧中的入棧到出棧的過程。
  • 局部變量表存放了編譯期可知的各種基本數據類型和對象引用(reference類型,他不等同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此相關的位置)和returnAddress類型(指向了一條字節碼指令的地址) 。
  • 其中64位長度的long和double類型會佔用2個局部變量空間,其餘的數據類型只會佔用1個局部變量空間。局部變量表所需的內存空間在編譯期間完成內存分配。當進入一個方法時,這個方法需要在幀中分配多大的內存空間是完全確定的,在方法運行期間不會改變局部變量表的大小
  • 在Java虛擬機規範中,對這個區域規定了兩種異常狀態:如果線程請求的棧的深度大於虛擬機允許的深度,將拋出StackOverFlowError異常(棧溢出);如果虛擬機棧可以動態擴展(現在大部分Java虛擬機都可以動態擴展,只不過Java虛擬機規範中也允許固定長度的java虛擬機棧),如果擴展時無法申請到足夠的內存空間,就會拋出OutOfMemoryError異常(沒有足夠的內存)。
本地方法棧
  • 本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用是非常相似的,他們之間的區別不過是虛擬機棧爲虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的本地Native方法服務。
  • 在虛擬機規範中對本地方法棧中的使用方法、語言、數據結構並沒有強制規定,因此具體的虛擬機可以自由實現它。甚至有的虛擬機(例如Sun HotSpot虛擬機)直接就把本地方法棧和虛擬機棧合二爲一。
  • 本地方法棧也會拋出StackOverFlowError和OutOfmMemoryError異常。
Java堆
  • Java堆(Java Heap)是Java虛擬機管理內存中的最大一塊。
  • Java堆是所有線程共享的一塊內存管理區域。
  • 此內存區域唯一目的就是存放對象的實例,幾乎所有對象實例都在堆中分配內存。這一點在Java虛擬機規範中的描述是:所有對象實例以及數組都要在堆上分配,但是隨着JIT編譯器的發展與逃逸技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也不是變的那麼“絕對”了。
  • Java堆是垃圾回收器管理的主要區域,因此很多時候也被稱爲GC堆(Garbage Collected Heap)。
  • 內存回收的角度來看,由於現在收集器基本都採用分代收集算法,所以Java堆中還可以細分爲:新生代和年老代。再在細緻一點的劃分可以分爲:Eden空間、From Survivor空間、To Survivor空間等。
  • 內存分配的角度來看,線程共享的Java堆中可能劃分出多個線程私有的分配緩衝區。
  • 不過無論如何劃分,都與存放內容無關,無論哪個區域存放的都是對象實例。進一步劃分的目的是爲了更好的回收內存,或者更快的分配內存。
  • Java堆可以處在物理上不連續的內存空間,只要邏輯上是連續的即可。在實現上既可以實現成固定大小,也可以是可擴展的大小,不過當前主流的虛擬機都是按照可擴展來實現的(通過-Xmx和-Xms控制)。
  • 如果在堆中沒有內存實例完成分配,並且堆也無法在擴展時將會拋出OutOfMemoryError異常。
方法區
  • 方法區是各個線程共享的內存區域
  • 方法區用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。
  • 雖然Java虛擬機規範把方法區描述爲堆的一部分,但是他還有個別名叫做Non-heap(非堆),目的應該是與Java堆區分開來。
  • 根據Java虛擬機規範的規定,當方法區無法滿足內存分配需求時,將拋出OutOfMemoryError 異常。
  • Java虛擬機規範對方法區的限制非常寬鬆,除了和Java堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。相對而言,垃圾收集在這個區域是比較少出現的,但並非數據進入了方法區就如永久代的名字一樣永久存在了。這區域的內存回收目標重要是針對常量池的回收和類型的卸載,一般來說這個內存區域的回收‘成績’比較難以令人滿意。尤其是類型的卸載條件非常苛刻,但是這部分的回收確實是必要的。在Sun公司的bug列表中,曾出現過的若干個嚴重的bug就是由於低版本的HotSpot虛擬機對此區域未完成回收導致的內存溢出。
注意:方法區與永久代
  • 對於習慣在HotSpot虛擬機上開發、部署程序的開發者來說,很多人都更願意把方法區稱爲“永久代”(Permanent Generation),本質上兩者並不等價
  • 僅僅是因爲HotSpot虛擬機的設計團隊選擇把GC分代收集擴展至方法區,或者說使用永久代來實現方法區而已,這樣HotSpot的垃圾收集器可以像管理Java堆一樣管理這部分內存,能夠省去專門爲方法區編寫內存管理代碼的工作。
  • 對於其他虛擬機(如 BEA JRockit、IBM J9 等)來說是不存在永久代的概念的。
  • 對於HotSpot虛擬機,根據官方發佈的路線圖信息,現在也有放棄永久代並逐步改爲採用Native Memory來實現方法區的規劃了,在目前已經發布的JDK1.7的HotSpot中,已經把原本放在永久代的字符串常量池移出。
運行時常量池

(見1.2.2)

直接內存
  • 直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致 OutOfMemoryError 異常出現。
  • 在 JDK 1.4 中新加入了 NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的 I/O 方式,它可以使用Native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的 DirectByteBuffer 對象作爲這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因爲避免了在 Java 堆和 Native 堆中來回複製數據。
  • 顯然,本機直接內存的分配不會受到 Java 堆大小的限制。但是,既然是內存,肯定還是會受到本機總內存(包括 RAM 以及 SWAP 區或者分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置 -Xms 等參數信息,但經常忽略直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操作系統級的限制),從而導致動態擴展時出現 OutOfMemoryError 異常。

1.2.2 常量池

Class常量池
  • Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用。
  • 字面量(Literal):文本字符串(如String str = "SpiderLucas"SpiderLucas就是字面量)、八種基本類型的值(如int i = 00就是字面量)、被聲明爲final的常量等;
  • 符號引用(Symbolic References):類和方法的全限定名、字段的名稱和描述符、方法的名稱和描述符。
  • 每個class文件都有一個class常量池。
字符串常量池
  • 參考資料來源:Java中的常量池徹底弄懂字符串常量池等相關問題Java中String字符串常量池
  • 字符串常量池中的字符串只存在一份。
  • 字符串常量池(String Constant Pool)是存儲Java String的一塊內存區域,在JDK 6之前是放置於方法區的,在JDK 7之後放置於堆中。
  • 在HotSpot中實現的字符串常量池功能的是一個StringTable類,它是一個Hash表,默認值大小長度是1009;這個StringTable在每個HotSpot VM的實例只有一份,被所有的類共享。字符串常量由一個一個字符組成,放在了StringTable上。
  • StringTable的長度:在JDK 6中,StringTable的長度是固定的,因此如果放入String Pool中的String非常多,就會造成hash衝突,導致鏈表過長,當調用String#intern()時會需要到鏈表上一個一個找,從而導致性能大幅度下降;在JDK 7中,StringTable的長度可以通過參數指定:-XX:StringTableSize=1024
  • 字符串常量池中存放的內容:在JDK 6及之前版本中,String Pool裏放的都是字符串常量;JDK 7中,由於String#intern()發生了改變,因此String Pool中也可以存放放於堆內的字符串對象的引用。
intern() 函數
  • 在JDK 6中,intern()的處理是先判斷字符串常量是否在字符串常量池中,如果存在直接返回該常量;如果沒有找到,則將該字符串常量加入到字符串常量區,也就是在字符串常量區建立該常量。
  • 在JDK 7中,intern()的處理是先判斷字符串常量是否在字符串常量池中,如果存在直接返回該常量,如果沒有找到,說明該字符串常量在堆中,則處理是把堆區該對象的引用加入到字符串常量池中,以後別人拿到的是該字符串常量的引用,實際存在堆中
字符串常量池案例
        String s1 = new String("Spider"); // s1 -> 堆
        // 該行代碼創建了幾個對象
        // 兩個對象(不考慮對象內部的對象):首先創建了一個字符串常量池的對象,然後創建了堆裏的對象
        s1.intern(); // 字符串常量池中存在"Spider",直接返回該常量
        String s2 = "Spider"; // s2 -> 字符串字符串常量池
        System.out.println(s1 == s2); // false

        String s3 = new String("Str") + new String("ing"); // s3 -> 堆
        // 該行代碼創建了幾個對象?
        // 反編譯後的代碼:String s3 = (new StringBuilder()).append(new String("Str")).append(new String("ing")).toString();
        // 六個對象(不考慮對象內部的對象):兩個字符串常量池的對象"Str"和"ing",兩個堆的對象"Str"和"ing",一個StringBuilder,一個toString方法創建的new String對象
        s3.intern(); // 字符串常量池中沒有,在JDK 7中以後會把堆中該對象的引用放在字符串常量池中(JDK 6中創建一個jdk1.6中會在字符串常量池中建立該常量)
        String s4 = "String"; // s4 -> 堆(JDK 6:s4 -> 字符串字符串常量池)
        System.out.println(s3 == s4); // true(JDK6 false)

        String s5 = "AAA";
        String s6 = "BBB";
        String s7 = "AAABBB"; // s7 -> 字符串常量池
        String s8 = s5 + s6; // s8 -> 堆(原因就是如上字符串+的重載)
        String s9 = "AAA" + "BBB"; // JVM會對此代碼進行優化,直接創建字符串常量池
        System.out.println(s7 == s8); // false
        System.out.println(s7 == s9); // true(都指向字符串常量池)
方法區與運行時常量池
  • 運行時常量池(Runtime Constant Pool)是方法區的一部分。
  • Class常量池的內容將在類加載後進入方法區的運行時常量池中存放
  • Java虛擬機對Class文件每一部分(自然也包括常量池)的格式都有嚴格規定,每一個字節用於存儲哪種數據都必須符合規範的要求才會被虛擬機認可、裝載和執行,但對於運行時常量池,Java 虛擬機規範沒有做任何細節的要求,不同的提供商實現的虛擬機可以按照自己的需要來實現這個內存區域。
  • 運行時常量池相對於Class文件常量池的另外一個重要特徵是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是並非預置如Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是 String類的intern()方法。
  • 既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

1.3 HotSpot中的對象

1.3.1 對象的創建

new一個對象的全部流程
  1. 從常量池中查找該類的符號引用,並且檢查該符號引用代表的類是否被加載、解析、初始化。如果類已經被加載,則跳轉至3;否則跳轉至2。
  2. 執行類的加載過程。
  3. 爲新對象分配內存空間:由於對象所需要內存大小在類加載完成時可以確定,所以可以直接從Java堆中劃分一塊確定大小的內存。
  4. 把分配的內存空間都初始化爲零值(不包括對象頭),如果使用TLAB則該操作可以提前至TLAB中,這是爲了保證對象的字段都被初始爲默認值。
  5. 執行init方法,按照程序員的意願進行初始化。
對象分配內存空間詳解
  1. 指針碰撞:如果堆內存是規整,已經分配和爲分配的內存有一個指針作爲分界點,那麼只需要將指針向空閒內存移動即可。
  2. 空閒列表:如果內存是不規整的,虛擬機需要維護一個列表,記錄那些內存塊是可用的。在分配的時候從足夠大的空間劃分給對象,並更新該列表。
  3. Java堆是否規整取決於GC是否會壓縮整理,Serial、ParNew等帶Compact過程的收集器,分配算法是指針碰撞;是用CMS這種基於Mark-Sweep算法的收集器時,分配算法是空閒列表。
分配內存的併發問題

無論是指針碰撞還是空閒列表,都有可能因爲併發而產生問題,解決方法有兩種:

  1. 對分配內存空間的動作進行同步處理——實際上JVM採用CAS(Compare And Swap)配上失敗重試的方式保證更新操作的原子性。
  2. 把內存分配的動作按照線程劃分在不同的空間,每個線程在Java堆中預先分配一小塊內存,成爲本地緩衝內存(Tread Local Allocation Buffer,TLAB)。哪個線程需要分配內存,就在哪個線程的TLAB上分配,只有TLAB用完了,才需要同步鎖定。可以通過-XX:+/-UseTLAB參數來設定。
CAS原理
一個CAS方法包含三個參數CAS(V,E,N)。V表示要更新的變量,E表示預期的值,N表示新值。只有當V的值等於E時,纔會將V的值修改爲N。如果V的值不等於E,說明已經被其他線程修改了,當前線程可以放棄此操作,也可以再次嘗試次操作直至修改成功。基於這樣的算法,CAS操作即使沒有鎖,也可以發現其他線程對當前線程的干擾(臨界區值的修改),並進行恰當的處理。

1.3.2 對象的內存佈局

在HotSpot虛擬機中,對象在內存中的存儲佈局可以分爲3部分:對象頭(Object Header)、實例數據(Instance Data)、對齊填充(Padding)。

對象頭第一部分
  1. 對象頭包括兩部分信息,第一部分用於存儲對象自身的運行時數據, 如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等,這部分數據的長度在32位和64位的虛擬機(暫不考慮開啓壓縮指針的場景)中分別爲32個和64個Bits,官方稱它爲“Mark Word”。
  2. 對象需要存儲的運行時數據很多,其實已經超出了32、64位所能記錄的限度,但是對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘量多的信息,原理是它會根據對象的狀態複用自己的存儲空間
  3. 例如在32位的HotSpot虛擬機中對象未被鎖定的狀態下,Mark Word的32個Bits空間中的25Bits用於存儲對象哈希碼(HashCode),4Bits用於存儲對象分代年齡,2Bits用於存儲鎖標誌位,1Bit固定爲0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容如下表所示。  

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倍),因此當對象實例數據部分沒有對齊的話,就需要通過對齊填充來補全。

1.3.3 對象的訪問定位

建立對象是爲了使用對象,我們的Java程序需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在Java虛擬機規範裏面只規定了是一個指向對象的引用,並沒有定義這個引用應該通過什麼種方式去定位、訪問到堆中的對象的具體位置,對象訪問方式也是取決於虛擬機實現而定的。

對象的兩種訪問定位方式

主流的訪問方式有使用句柄和直接指針兩種。

  1. 句柄訪問:Java堆中將會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據的具體各自的地址信息,如下圖所示。
  2. 直接指針:Java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址,如下圖所示。

通過句柄訪問對象
通過直接指針訪問對象

兩種方式比較
  • 句柄訪問的優勢:reference中存儲的是穩定句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不需要被修改。
  • 直接指針的優勢:最大的好處就是速度更快,它節省了一次指針定位的時間開銷,由於對象訪問的在Java中非常頻繁,因此這類開銷積小成多也是一項非常可觀的執行成本。
  • HotSpot虛擬機:它是使用第二種方式進行對象訪問。
  • 但在整個軟件開發的範圍來看,各種語言、框架中使用句柄來訪問的情況也十分常見。

1.4 OOM異常分類

1.4.1 堆溢出

Java堆用於存儲對象實例,只要不斷創建對象,並且保證GC Roots到對象之間有可達路徑來避免GC,那麼在對象數量到達最大堆容量限制之後便會產生堆溢出。

/**
 * VM args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * 1.將堆的最小值-Xms與最大值-Xmx參數設置爲一樣可以避免堆自動擴展
 * 2.通過參數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機出現內存異常時Dump當前堆內存堆轉儲快照
 * 3.快照位置默認爲user.dir
 */
public class HeapOOM {
    static class OOMObject {}
    public static void main(String[] args) {
        // 保留引用,防止GC
        List<OOMObject> list = new ArrayList<>();
        for (;;) {
            list.add(new OOMObject());
        }
    }
}
// 運行結果
// java.lang.OutOfMemoryError: Java heap space
// Dumping heap to java_pid72861.hprof ...
// Heap dump file created [27888072 bytes in 0.086 secs]
堆轉儲快照

以下是JProfiler對轉儲快照的分析
最大實例數量
到GC Roots的路徑
對象的依賴關係

內存泄漏與內存溢出
  • 重點:確認內存中的對象是否是必要的,也就是分清楚到底是出現了內存泄漏(Memory Leak)還是內存溢出(Memory Overflow)
  • 內存泄漏:是指程序在申請內存後,無法釋放已申請的內存空間,一次內存泄漏似乎不會有大的影響,但內存泄漏堆積後的後果就是內存溢出。
  • 內存溢出:是指程序在申請內存時,沒有足夠的內存空間供其使用。內存泄漏的堆積最終會導致內存溢出。
內存泄漏的分類(按發生方式來分類)
  1. 常發性內存泄漏:發生內存泄漏的代碼會被多次執行到,每次被執行的時候都會導致一塊內存泄漏。
  2. 偶發性內存泄漏:發生內存泄漏的代碼只有在某些特定環境或操作過程下才會發生。常發性和偶發性是相對的。對於特定的環境,偶發性的也許就變成了常發性的。所以測試環境和測試方法對檢測內存泄漏至關重要。
  3. 一次性內存泄漏:發生內存泄漏的代碼只會被執行一次,或者由於算法上的缺陷,導致總會有一塊僅且一塊內存發生泄漏。比如,在類的構造函數中分配內存,在析構函數中卻沒有釋放該內存,所以內存泄漏只會發生一次。
  4. 隱式內存泄漏:程序在運行過程中不停的分配內存,但是直到結束的時候才釋放內存。嚴格的說這裏並沒有發生內存泄漏,因爲最終程序釋放了所有申請的內存。但是對於一個服務器程序,需要運行幾天,幾周甚至幾個月,不及時釋放內存也可能導致最終耗盡系統的所有內存。
處理方式
  • 如果是內存泄漏:需要找到泄漏對象的類型信息,和對象與GC Roots的引用鏈的信息,分析GC無法自動回收它們的原因。
  • 如果不存在內存泄漏,即內存中的對象的確必須存活:那就應當檢查JVM的參數能否調大;從代碼上檢查是否某些對象生命週期過長、持有狀態時間過長,嘗試減少程序運行期的內存消耗。

1.4.2 棧溢出

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

  • 如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常。
  • 如果虛擬機在擴展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常。

這裏把異常分成兩種情況,看似更加嚴謹,但卻存在着一些互相重疊的地方:當棧空間無法繼續分配時,到底是內存太小,還是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。

StackOverflowError
/**
 * VM args: -Xss256k
 * 1. 設置-Xss參數減小棧內存
 * 2. 死遞歸增大此方法棧中本地變量表的長度
 */
public class SOF {
    int stackLength = 1;

    void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        SOF sof = new SOF();
        try {
            sof.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack Length:" + sof.stackLength);
            throw e;
        }
    }
}
// Stack Length:2006
// Exception in thread "main" java.lang.StackOverflowError
//      at s1.SOF.stackLeak(SOF.java:13)
//      at s1.SOF.stackLeak(SOF.java:13)
多線程導致棧OOM異常
/**
 * VM Args: -Xss20M
 * 通過不斷創建線程的方式產生OOM
 */
public class StackOOM {
    private void dontStop() {
        for (;;) {

        }
    }

    private void stackLeakByThread() {
        for (;;) {
            Thread thread = new Thread(this::dontStop);
            thread.start();
        }
    }

    public static void main(String[] args) {
        new StackOOM().stackLeakByThread();
    }
}

通過不斷創建線程的方式產生OOM異常,但是這樣產生的內存溢出異常與棧空間是否足夠大並不存在任何聯繫。或者準確地說,在這種情況下,爲每個線程的棧分配的內存越大,反而越容易產生內存溢出異常
原因:操作系統分配給每個進程的內存是有限制的,假設操作系統的內存爲2GB,剩餘的內存爲2GB(操作系統限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區容量),程序計數器消耗內存很小,可以忽略掉。如果虛擬機進程本身耗費的內存不計算在內,剩下的內存就由虛擬機棧和本地方法棧“瓜分”了。所以每個線程分配到的棧容量越大,可以建立的線程數量自然就越少,建立線程時就越容易把剩下的內存耗盡。
解決方法:如果是建立過多線程導致的內存溢出,在不能減少線程數或者更換64位虛擬機的情況下,就只能通過“減少內存”的手段來解決內存溢出——減少最大堆和減少棧容量來換取更多的線程

1.4.3 方法區和運行時常量池溢出

由於運行時常量池是方法區的一部分,因此這兩個區域的溢出測試就放在一起進行。方法區用於存放Class的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等,所以對於動態生成類的情況比較容易出現永久代的內存溢出。對於這些區域的測試,基本的思路是運行時產生大量的類去填滿方法區,直到溢出

/**
 * (JDK 8)VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10m
 * (JDK 7之前)VM Args: -XX:PermSize=10M -XX:MaxPermSize=10m
 */
public class MethodAreaOOM {
    static class OOMClass {}

    public static void main(final String[] args) {
        for (;;) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMClass.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }
}
//    Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
//        at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:237)
//        at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
//        at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
//        at com.ankeetc.commons.Main.main(Main.java:28)
方法區溢出場景

方法區溢出也是一種常見的內存溢出異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經常動態生成大量Class的應用中,需要特別注意類的回收狀況。這類場景主要有:

  1. 使用了CGLib字節碼增強,當前的很多主流框架,如Spring、Hibernate,在對類進行增強時,都會使用到CGLib這類字節碼技術,增強的類越多,就需要越大的方法區來保證動態生成的Class可以加載入內存。
  2. 大量JSP或動態產生JSP文件的應用(JSP第一次運行時需要編譯爲Java類)
  3. 基於OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視爲不同的類)等
  4. JVM上的動態語言(例如Groovy等)通常都會持續創建類來實現語言的動態性

1.4.4 本機直接內存溢出

下面代碼越過了DirectByteBuffer類,直接通過反射獲取Unsafe實例進行內存分配(Unsafe類的getUnsafe()方法限制了只有引導類加載器纔會返回實例,也就是設計者希望只有rt.jar中的類才能使用Unsafe的功能)。因爲,雖然使用DirectByteBuffer分配內存也會拋出內存溢出異常,但它拋出異常時並沒有真正向操作系統申請分配內存,而是通過計算得知內存無法分配,於是手動拋出異常,真正申請分配內存的方法是unsafe.allocateMemory()

/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 * DirectMemory容量可通過-XX:MaxDirectMemorySize指定
 * 如果不指定,則默認與Java堆最大值(-Xmx指定)一樣。
 */
public class Main {

    private static final long _1024MB = 1024 * 1024 * 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1024MB);
        }
    }
}
//    Exception in thread "main" java.lang.OutOfMemoryError
//        at sun.misc.Unsafe.allocateMemory(Native Method)
//        at com.ankeetc.commons.Main.main(Main.java:25)
DirectMemory特徵
  • DirectMemory導致的內存溢出,一個明顯的特徵是在Heap Dump文件中不會看見明顯的異常。
  • 如果發現OOM之後Dump文件很小,而程序中又直接或間接使用了NIO,那就可以考慮檢查一下是不是這方面的原因。

1.5 不同版本的JDK

參考資料
關於永久代和方法區
  • 在 HotSpot VM 中 “PermGen Space” 其實指的就是方法區
  • “PermGen Space” 和方法區有本質的區別。前者是 JVM 規範的一種實現(HotSpot),後者是 JVM 的規範。
  • 只有 HotSpot 纔有 “PermGen Space”,而對於其他類型的虛擬機,如 JRockit、J9並沒有 “PermGen Space”。
不同版本JDK總結
  1. JDK 7之後將字符串常量池由永久代轉移到堆中
  2. JDK 8中, HotSpot 已經沒有 “PermGen space”這個區間了,取而代之是一個叫做 Metaspace(元空間) 的東西。
  3. 元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地內存。因此,默認情況下,元空間的大小僅受本地內存限制。
  4. -XX:MetaspaceSize:初始空間大小,達到該值就會觸發垃圾收集進行類型卸載。同時GC會對該值進行調整——如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過MaxMetaspaceSize時,適當提高該值。
  5. -XX:MaxMetaspaceSize:最大空間,默認是沒有限制的。
  6. -XX:MinMetaspaceFreeRatio:在GC之後,最小的Metaspace剩餘空間容量的百分比,減少爲分配空間所導致的垃圾收集
  7. -XX:MaxMetaspaceFreeRatio:在GC之後,最大的Metaspace剩餘空間容量的百分比,減少爲釋放空間所導致的垃圾收集
  8. PermSizeMaxPermSize參數已移除
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章