1. 程式人生 > >深入理解JVM總結-JDK各版本、JVC記憶體分配及溢位異常

深入理解JVM總結-JDK各版本、JVC記憶體分配及溢位異常

第一部分 走近JAVA

第一章 走近Java

1.JDK1.5版本改動非常大,加入了自動裝箱、泛型、動態註解、列舉、可變長引數以及遍歷迴圈等。

    JDK1.6提供動態語言支援,提供API編譯,且JVM中改進了鎖與同步、垃圾收集以及類載入等的演算法。

   JDK1.7提供新的G1收集器、加強對非Java語言的呼叫支援(目前未完全定型)、升級類載入架構等。

  JDK1.8增加Lambda表示式、Jigsaw(未實現)。Lambda用來進行函數語言程式設計。

不採用Lambda的老方法:
Runnable runnable1=new Runnable(){
@Override
public void run(){
System.out.println("RunningwithoutLambda");
}
};
使用Lambda:
Runnable runnable2=()->{
System.out.println("RunningfromLambda");
};

2.目前Java程式在64位虛擬機器上執行需要付出較大的額外代價:一是記憶體問題,由於指標膨脹和各種資料型別對齊補白等原因,64位系統上通常比32位多消耗10%~30%記憶體。其次測試來看,64位虛擬機器的執行速度幾乎全面落後於32位大概15%左右的效能差距。但在J2EE方面,企業級應用經常使用超過4GB的記憶體,因此對於64位虛擬機器的需求非常迫切。

第二部分 自動記憶體管理機制

第二章 Java記憶體區域與記憶體溢位異常


上圖為JVM執行時資料區,其中方法區和堆由所有執行緒共享的資料區,其餘的為執行緒隔離的資料區。

1.程式計數器(Program Counter Register),一塊較小的記憶體空間,可看作是當前執行緒所執行的位元組碼的行號指示器

。位元組碼直譯器的工作就是改變這個計數器的值來選擇下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

在任一確定時刻,一個處理器(或多核的一個核心)都只會執行一條執行緒中的指令。同時為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要一個相互之間並不影響的獨立的程式計數器,獨立儲存,這類記憶體區域為“執行緒私有”的記憶體。

若執行緒是正在執行Java方法,則計數器記錄的是正在執行的JVM位元組碼指令的地址;若正在執行的是native方法,則計數器值為空。

此記憶體區域是唯一一個在JVM規範中沒有任何規定OutOfMemoryError情況的區域。

2.Java虛擬機器棧(JVM Stack):執行緒私有的,生命週期與執行緒相同。JVM Stack描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀,用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。方法的呼叫到執行完的過程即為入棧到出棧的過程。區域性變量表存放的是各種基本資料型別、物件的引用和returnAddress型別(指向了一條位元組碼指令的地址)。其中long與double佔兩個區域性變數空間。其餘的只佔一個。區域性變量表的空間在編譯時就完成了分配,進入方法時在幀中的空間分配是確定的,在方法執行期間不會改變區域性變量表的大小。若執行緒請求棧深度大於JVM允許的深度,則丟擲StackOverflowError異常,若JVM Stack可擴充套件,卻無法申請到足夠的記憶體時,拋OutOfMemoryError異常。

3.本地方法棧(Native Method Stack):類似於虛擬機器棧。區別是本地方法棧為JVM使用到的native方法服務,而JVM Stack為Java方法,即位元組碼服務。也會丟擲虛擬機器棧的異常。

4.Java堆(Java Heap):JVM記憶體管理最大的一塊。執行緒共享。JVM啟動時建立唯一目的就是存放物件例項以及陣列Java堆是垃圾回收管理的主要區域。也被稱為GC堆(Garbage Collected Heap)。由於現在收集器基本上都採用分代回收演算法,因此Java堆又可以分為年輕代和年老代以及持久代。

年輕代:是所有新物件產生的地方。年輕代被分為3個部分——Eden區和兩個Survivor區(From和to)當Eden區被物件填滿時,就會執行Minor GC。並把所有存活下來的物件轉移到其中一個survivor區(假設為from區)。Minor GC同樣會檢查存活下來的物件,並把它們轉移到另一個survivor區(假設為to區)。這樣在一段時間內,總會有一個空的survivor區。經過多次GC週期後,仍然存活下來的物件會被轉移到年老代記憶體空間。通常這是在年輕代有資格提升到年老代前通過設定年齡閾值來完成的。需要注意,Survivor的兩個區是對稱的,沒先後關係,from和to是相對的。
年老代:在年輕代中經歷了N次回收後仍然沒有被清除的物件,就會被放到年老代中,可以說他們都是久經沙場而不亡的一代,都是生命週期較長的物件。對於年老代和永久代,就不能再採用像年輕代中那樣搬移騰挪的回收演算法,因為那些對於這些回收戰場上的老兵來說是小兒科。通常會在老年代記憶體被佔滿時將會觸發Full GC,回收整個堆記憶體。
持久代:用於存放靜態檔案,比如java類、方法等。持久代對垃圾回收沒有顯著的影響。

Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上連續即可。若堆中沒有記憶體分配空間且無法擴充套件時,將拋OutOfMemoryError異常。

5.方法區(Method Area):執行緒共享的。用於儲存已被JVM載入的類資訊(類名,訪問修飾符,欄位描述,方法描述等)、常量、靜態變數、即時編譯器編譯後的程式碼等資料。(非堆)不需要連續的記憶體,可選擇固定大小或可擴充套件,還可以選擇不實現垃圾收集這部分割槽域的記憶體回收目標主要是針對常量池的回收以及對型別的解除安裝,但回收效果一般,尤其是型別的解除安裝條件非常苛刻。方法區記憶體無法滿足需求時,丟擲OutOfMemoryError異常。

6.執行時常量池(Runtime Constant Pool):方法區的一部分。常量池,用於存放class檔案編譯期生成的各種字面量和符號引用除了符號引用以外,直接引用也會儲存在常量池中。常量池另一個特徵是具備動態性,執行期新的常量也可以進入放入常量池中,如string類的intern()方法等。

7.直接記憶體(Direct Memory):不是JVM執行時資料區的一部分,也不是JVM規範中定義的記憶體區域,但也在被頻繁使用,也可能導致OutOfMemoryError異常出現。JDK1.4新產生的new Input/Output引入的基於通道與緩衝區的I/O方式,可以使用native函式庫直接分配堆外記憶體,通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作。本地直接記憶體不會受到Java堆的大小限制,但會受到本機總記憶體(RAM/SWAP區或分頁檔案)大小以及處理器定址空間的限制。-Xmx設定不合理也會導致OutOfMemoryError異常出現。

8.JVM中物件的建立、佈局與訪問

    8.1 物件的建立

①檢測物件是否載入完畢

JVM通過new先去檢查是否在常量池中有對應的符號引用,且檢查這個符號引用所代表的類是否被載入、解析和初始化過。沒有的話,則必須執行相應的類載入過程。

為物件分配堆記憶體

類載入檢測通過之後,JVM為新生物件分配記憶體。類載入後物件所需記憶體即確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來

分配方法分為“指標碰撞”和“空閒列表。假設堆記憶體絕對規整,用過的放一邊,空閒的放一邊,中間為指標進行分隔。指標碰撞即指標向空閒的一方移動大小為物件的記憶體空間。假設堆記憶體不完全規整,使用過的和空閒的交錯,JVM必須維護一個列表記錄哪些是空閒的,在分配時找到足夠大的空間分配給物件例項,並更新列表的記錄。這種分配方式是“空閒列表”。

Java堆是否規整由垃圾收集器是否帶有壓縮整理功能決定。

例如:Serial/ParNew等帶compact過程的收集器是指標碰撞,而CMS這種基於Mark-Sweep演算法的收集器採用空閒列表。

除如何劃分可用空間之外,還有另外一個需要考慮的問題是物件建立在虛擬機器中是非常 頻繁的行為,即使是僅僅修改一個指標

所指向的位置,在併發情況下也並不是執行緒安全的,可能出現正在給物件A分配記憶體,指標還沒來得及修改,物件B又同時使用了原

來的指標來分配記憶體的情況。解決方法:一、堆分配記憶體空間的動作進行同步處理;二是把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體(本地執行緒分配緩衝Thread Local Allocation Buffer,TLAB,解決執行緒不安全的問題)。哪個執行緒要分配,就在哪個執行緒的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。

分配到的記憶體空間初始化為零值(不包括物件頭)

保證了對的例項欄位在Java程式碼中可以不賦初始值就可以直接使用,程式能訪問這些欄位的資料型別所對應的零值。

④對物件進行必要的設定(即設定物件頭Object Header)

例如物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。存放在物件頭當中。

通過以上四步,從JVM角度來看,一個新的物件就產生了,但從Java程式來說,物件建立才剛剛開始——<init>方法還沒有執行,所有的欄位均為零。

因此,執行完new之後,會接著執行<init>方法,把物件初始化,這樣一個真正可用的物件才算完全產生。

  8.2物件的佈局

物件在記憶體中的佈局分為3個部分:物件頭Header、例項資料Instance Data和對齊填充Padding

物件頭包含兩部分資訊:用於儲存物件自身的執行時資料以及型別指標

  • 儲存物件自身的執行時資料 
    • 內容舉例: 
      • 雜湊碼HashCode
      • GC分代年齡
      • 鎖狀態標識
      • 執行緒持有的鎖等
    • 長度(未開啟壓縮指標下):32 or 64 位的虛擬機器中分別為 32bit or 64 bit
    • 官方名稱:Mark Word
    • 非固定資料結構: 
      • 考慮到儲存效率,已讓其在及小空間記憶體儲更多資訊
      • 也就是說,在這麼多bit下,哪幾位儲存哪些內容是不定的
  • 型別指標 
    • 作用:物件指向它的類元資料的指標,JVM通過此來確定該物件是哪個類的例項 
      • 並非所有JVM的實現都需要這個指標,也就是物件的元資料查詢並不一定要經過物件本身

注意:對於陣列而言,物件頭中還會記錄陣列的長度。JVM可以通過物件的元資料資訊確定Java物件的大小。

但從陣列物件的元資料中是無法獲取陣列大小的。

例項資料是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。

無論是父類繼承下來的還是子類中定義的

儲存順序(HotSpot) 

  • 策略:同寬度相同的分配到一起
  • 具體分配方式: 
    • longs/doubles
    • ints
    • shorts/chars
    • bytes/booleans
    • oops(Ordinary Object Pointers)
對齊填充並不是必然存在的,也沒啥特別含義,僅僅起著佔位符的作用。JVM要求物件的起始地址必須是8位元組的整數倍, 即物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的1倍或2倍,因此當物件例項資料部分沒有對齊時,就需要通過對 齊填充來補全。 8.3物件的訪問定位 Java程式通過棧上的引用reference資料來操作堆上的具體物件。 ①使用控制代碼 Java堆將劃分一塊記憶體作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自的 具體地址資訊最大的好處是reference中儲存的是穩定的控制代碼地址,在物件被移動(GC過程中)時只會改變控制代碼中的例項資料指標,而 不會改變reference本身。 ②直接指標 Java堆物件的佈局中得考慮如何放置型別資料的相關資訊,而reference中儲存的直接就是物件的地址這種方式的好處在於速度更快,節省了一次指標定位的時間開銷。由於物件訪問十分頻繁,因此這類開銷積少成多後是一項非常可觀的 執行成本。 對於Sun HotSpot虛擬機器來說,主要還是使用第二種方式。 9.記憶體溢位異常OutOfMemoryError 除程式計數器以外都可能發生溢位異常。 9.1 Java堆溢位 只要不斷建立物件,且保證GC Roots到物件之間有可達路徑來避免GC機制清除物件,那麼在物件數量達到最大堆的容量限制後就會產 生記憶體溢位異常。(將堆的最小值與最大值設定為一樣的即不可擴充套件)
import java.util.ArrayList;
import java.util.List;

public class Test4 {
	/*VM args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
	 * */
	static class OOMObject{
		
	}
	public static void main(String[] args) {
		List list=new ArrayList();
		while(true){
			list.add(new OOMObject());
		}
	}
}
解決一般是通過記憶體映像分析工具分析到底是記憶體洩漏還是記憶體溢位。 若是洩漏,可檢視洩漏物件到GC Roots的引用鏈,可以比較準確的定位出洩漏程式碼的位置。 若是溢位,即檢查虛擬機器堆引數,是否可以適當調大,從程式碼上可以檢查是否存在某些物件生命週期過長、持有時間太長,嘗試減少 執行時期記憶體消耗。 ===================================================================== 所謂記憶體洩露就是指一個不再被程式使用的物件或變數一直被佔據在記憶體中。
產生原因:長生命週期的物件持有短生命週期物件的引用就很可能發生記憶體洩露,儘管短生命週期物件已經不再需要,但是因為長生 命週期物件持有它的引用而導致不能被回收。通俗地說,就是程式設計師可能建立了一個物件,以後一直不再使用這個物件,這個物件卻一直 被引用,即這個物件無用但是卻無法被垃圾回收器回收的,這就是java中可能出現記憶體洩露的情況。
發生場景:1,快取系統,我們載入了一個物件放在快取中(例如放在一個全域性map物件中),然後一直不再使用它,這個物件一直被緩 存引用,但卻不再被使用。
2,如果一個外部類的例項物件的方法返回了一個內部類的例項物件,這個內部類物件被長期引用了,即使那個外部類例項物件不再被 使用,但由於內部類持有外部類的例項物件,這個外部類物件將不會被垃圾回收,這也會造成記憶體洩露。
3,當一個物件被儲存進HashSet集合中以後,就不能修改這個物件中的那些參與計算雜湊值的欄位了,否則,物件修改後的雜湊值與 最初儲存進HashSet集合中時的雜湊值就不同了,在這種情況下,即使在contains方法使用該物件的當前引用作為的引數去HashSet集合中 檢索物件,也將返回找不到物件的結果,這也會導致無法從HashSet集合中單獨刪除當前物件,造成記憶體洩露。
注意:由於Java 使用有向圖的方式進行垃圾回收管理,可以消除引用迴圈的問題,例如有兩個物件,相互引用,只要它們和根程序不可達 的,那麼GC也是可以回收它們的。例如物件A和物件B相互引用對方,GC照樣可以回收
=========================================================== 9.2 虛擬機器棧和本地方法棧溢位 如果執行緒請求棧的深度大於JVM最大深度,則丟擲棧溢位異常;若JVM在擴充套件時無法申請到足夠的空間,則丟擲記憶體溢位異常。 在單個執行緒下,記憶體無法分配是,虛擬機器丟擲的都是棧溢位異常。 每個執行緒分配的棧容量越大,可以建立的執行緒數量就越少,建立執行緒時就越容易把剩下的記憶體耗盡。 JVM預設深度為1000-2000,一般來說夠用。 若是多執行緒情況下無法減少執行緒數,就只能通過減少最大堆和減少棧容量來換取更多的執行緒。 VM args:-Xss128k(虛擬機器棧和本地方法棧) VM args: -Xss2M(可以設大點,建立執行緒導致溢位) 9.3方法區和執行時常量池溢位 執行時常量池是方法區的一部分。 String.intern()是一個native方法,表示如果字串常量池中已經包含一個等於此String的字串,則返回代表池中這個字串的物件; 否則將此字串新增到常量池中,並返回此string字串物件的引用。 由於常量池分配在永久代中,可以通過-XX:PermSize和-XX:MaxPermSize來限制方法區大小,從而間接限制其中常量池的容量VM args:-XX:PermSize=10M-XX:MaxPermSize=10M 在方法區,一個類要被回收判定的條件是十分苛刻的。在經常動態生成大量class的應用中,需要特別注意類的回收情況。如大量JSP等 9.4本地直接記憶體溢位 通過-XX:MaxDirectMemorySize來指定。若不指定,則預設和Java堆最大值一樣。 本地直接記憶體溢位拋異常時,並沒有真正向作業系統申請分配記憶體,而是通過計算機得知記憶體無法分配,於是手動丟擲,真正申請分配 記憶體的方法時unsafe.allocateMemory()。 本地直接記憶體溢位的明顯特徵是在Heap Dump檔案中不會看見明顯的異常,如果異常之後Dump小了,而程式又直接或間接使用NIO, 則可以考慮是否是本地直接記憶體溢位。