JVM&GC詳解


1.JVM簡介

  • JVM是java的核心和基礎,在java編譯器和os平台之間的虛擬處理器。它是一種利用軟件方法實現的抽象的計算機基於下層的操作系統和硬件平台,可以在上面執行java的字節碼程序。
  • java編譯器只要面向JVM,生成JVM能理解的代碼或字節碼文件。Java源文件經編譯成字節碼程序,通過JVM將每一條指令翻譯成不同平台機器碼,通過特定平台運行。

運行過程

  • Java語言寫的源程序通過Java編譯器,編譯成與平台無關的‘字節碼程序’(.class文件,也就是0,1二進制程序),然后在OS之上的Java解釋器中解釋執行。
  • C++以及Fortran這類編譯型語言都會通過一個靜態的編譯器將程序編譯成CPU相關的二進制代碼。
  • PHP以及Perl這列語言則是解釋型語言,只需要安裝正確的解釋器,它們就能運行在任何CPU之上。當程序被執行的時候,程序代碼會被逐行解釋並執行。
  • 1、編譯型語言的優缺點:
    • 速度快:因為在編譯的時候它們能夠獲取到更多的有關程序結構的信息,從而有機會對它們進行優化。
    • 適用性差:它們編譯得到的二進制代碼往往是CPU相關的,在需要適配多種CPU時,可能需要編譯多次。
  • 2、解釋型語言的優缺點:
    • 適應性強:只需要安裝正確的解釋器,程序在任何CPU上都能夠被運行
    • 速度慢:因為程序需要被逐行翻譯,導致速度變慢。同時因為缺乏編譯這一過程,執行代碼不能通過編譯器進行優化。
  • 3、Java的做法是找到編譯型語言和解釋性語言的一個中間點:
    • Java代碼會被編譯:被編譯成Java字節碼,而不是針對某種CPU的二進制代碼。
    • Java代碼會被解釋:Java字節碼需要被java程序解釋執行,此時,Java字節碼被翻譯成CPU相關的二進制代碼。
    • JIT編譯器的作用:在程序運行期間,將Java字節碼編譯成平台相關的二進制代碼。正因為此編譯行為發生在程序運行期間,所以該編譯器被稱為Just-In-Time編譯器。

2.JVM結構

  • java是基於一門虛擬機的語言,所以了解並且熟知虛擬機運行原理非常重要。

2.1 方法區

  • 方法區,Method Area, 對於習慣在HotSpot虛擬機上開發和部署程序的開發者來說,很多人願意把方法區稱為“永久代”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot虛擬機的設計團隊選擇把GC分代收集擴展至方法區,或者說使用永久代來實現方法區而已。對於其他虛擬機(如BEA JRockit、IBM J9等)來說是不存在永久代的概念的。
  • 主要存放已被虛擬機加載的類信息、常量、靜態變量、即編譯器編譯后的代碼等數據(比如spring 使用IOC或者AOP創建bean時,或者使用cglib,反射的形式動態生成class信息等)。

注意:JDK 6 時,String等字符串常量的信息是置於方法區中的,但是到了JDK 7 時,已經移動到了Java堆。所以,方法區也好,Java堆也罷,到底詳細的保存了什么,其實沒有具體定論,要結合不同的JVM版本來分析。

異常

  • 當方法區無法滿足內存分配需求時,將拋出OutOfMemoryError。
  • 運行時常量池溢出:比如一直往常量池加入數據,就會引起OutOfMemoryError異常。

類信息

  1. 類型全限定名。
  2. 類型的直接超類的全限定名(除非這個類型是java.lang.Object,它沒有超類)。
  3. 類型是類類型還是接口類型。
  4. 類型的訪問修飾符(public、abstract或final的某個子集)。
  5. 任何直接超接口的全限定名的有序列表。
  6. 類型的常量池。
  7. 字段信息。
  8. 方法信息。
  9. 除了常量以外的所有類(靜態)變量。
  10. 一個到類ClassLoader的引用。
  11. 一個到Class類的引用。

2.1.1 常量池

2.1.1.1 Class文件中的常量池
  • 在Class文件結構中,最頭的4個字節用於存儲Megic Number,用於確定一個文件是否能被JVM接受,再接着4個字節用於存儲版本號,前2個字節存儲次版本號,后2個存儲主版本號,再接着是用於存放常量的常量池,由於常量的數量是不固定的,所以常量池的入口放置一個U2類型的數據(constant_pool_count)存儲常量池容量計數值。
  • 常量池主要用於存放兩大類常量:字面量(Literal)和符號引用量(Symbolic References),字面量相當於Java語言層面常量的概念,如文本字符串,聲明為final的常量值等,符號引用則屬於編譯原理方面的概念,包括了如下三種類型的常量:
    • 類和接口的全限定名
    • 字段名稱和描述符
    • 方法名稱和描述符
2.1.1.2 運行時常量池
  • CLass文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池,用於存放編譯器生成的各種字面量和符號引用,這部分內容將在類加載后進入方法區的運行時常量池中存放。
  • 運行時常量池相對於CLass文件常量池的另外一個重要特征是具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入CLass文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用比較多的就是String類的intern()方法。
2.1.1.3 常量池的好處
  • 常量池是為了避免頻繁的創建和銷毀對象而影響系統性能,其實現了對象的共享。
  • 例如字符串常量池,在編譯階段就把所有的字符串文字放到一個常量池中。
    • (1)節省內存空間:常量池中所有相同的字符串常量被合並,只占用一個空間。
    • (2)節省運行時間:比較字符串時,比equals()快。對於兩個引用變量,只用判斷引用是否相等,也就可以判斷實際值是否相等。

雙等號==的含義

  • 基本數據類型之間應用雙等號,比較的是他們的數值。
  • 復合數據類型(類)之間應用雙等號,比較的是他們在內存中的存放地址。
2.1.1.4 基本類型的包裝類和常量池
  • java中基本類型的包裝類的大部分都實現了常量池技術,即

      Byte,Short,Integer,Long,Character,Boolean
    
  • 這5種包裝類默認創建了數值[-128,127]的相應類型的緩存數據,但是超出此范圍仍然會去創建新的對象。 兩種浮點數類型的包裝類Float,Double並沒有實現常量池技術。

Integer與常量池

Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
 
System.out.println("i1=i2   " + (i1 == i2));
System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
System.out.println("i1=i4   " + (i1 == i4));
System.out.println("i4=i5   " + (i4 == i5));
System.out.println("i4=i5+i6   " + (i4 == i5 + i6));  
System.out.println("40=i5+i6   " + (40 == i5 + i6));
 
 
i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
  • 解釋:
    • (1)Integer i1=40;Java在編譯的時候會直接將代碼封裝成Integer i1=Integer.valueOf(40);,從而使用常量池中的對象。
    • (2)Integer i4 = new Integer(40);這種情況下會創建新的對象。
    • (3)語句i4 == i5 + i6,因為+這個操作符不適用於Integer對象,首先i5和i6進行自動拆箱操作,進行數值相加,即i4 == 40。然后Integer對象無法與數值進行直接比較,所以i4自動拆箱轉為int值40,最終這條語句轉為40 == 40進行數值比較。
String與常量池

String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
  
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";
String str4 = str1 + str2;
System.out.println(str3 == str4);//false
  
String str5 = "string";
System.out.println(str3 == str5);//true
  • 解釋:
    • (1)new String("abcd")是在常量池中拿對象,"abcd"是直接在堆內存空間創建一個新的對象。只要使用new方法,便需要創建新的對象。
    • (2)連接表達式 + ,只有使用引號包含文本的方式創建的String對象之間使用“+”連接產生的新對象才會被加入字符串池中。
    • 對於所有包含new方式新建對象(包括null)的“+”連接表達式,它所產生的新對象都不會被加入字符串池中。

public static final String A; // 常量A
public static final String B;    // 常量B
static {  
   A = "ab";  
   B = "cd";  
}  
public static void main(String[] args) {  
// 將兩個常量用+連接對s進行初始化  
String s = A + B;  
String t = "abcd";  
if (s == t) {  
    System.out.println("s等於t,它們是同一個對象");  
  } else {  
    System.out.println("s不等於t,它們不是同一個對象");  
  }  
}
  • 解釋:
    • s不等於t,它們不是同一個對象。
    • A和B雖然被定義為常量,但是它們都沒有馬上被賦值。在運算出s的值之前,他們何時被賦值,以及被賦予什么樣的值,都是個變數。因此A和B在被賦值之前,性質類似於一個變量。那么s就不能在編譯期被確定,而只能在運行時被創建了。

String s1 = new String("xyz"); //創建了幾個對象?
  • 解釋:
    • 考慮類加載階段和實際執行時。
      • (1)類加載對一個類只會進行一次。”xyz”在類加載時就已經創建並駐留了(如果該類被加載之前已經有”xyz”字符串被駐留過則不需要重復創建用於駐留的”xyz”實例)。駐留的字符串是放在全局共享的字符串常量池中的。
      • (2)在這段代碼后續被運行的時候,”xyz”字面量對應的String實例已經固定了,不會再被重復創建。所以這段代碼將常量池中的對象復制一份放到heap中,並且把heap中的這個對象的引用交給s1 持有。
      • 這條語句創建了2個對象。

public static void main(String[] args) {
String s1 = new String("計算機");
String s2 = s1.intern();
String s3 = "計算機";
System.out.println("s1 == s2? " + (s1 == s2));
System.out.println("s3 == s2? " + (s3 == s2));
}
s1 == s2? false
s3 == s2? true
  • 解釋:
    • String的intern()方法會查找在常量池中是否存在一份equal相等的字符串,如果有則返回該字符串的引用,如果沒有則添加自己的字符串進入常量池。

2.2 堆

  • Heap(堆)是JVM的內存數據區。
  • 一個虛擬機實例只對應一個堆空間,堆是線程共享的。堆空間是存放對象實例的地方,幾乎所有對象實例都在這里分配。堆也是垃圾收集器管理的主要區域(也被稱為GC堆)。堆可以處於物理上不連續的內存空間中,只要邏輯上相連就行。
  • Heap 的管理很復雜,每次分配不定長的內存空間,專門用來保存對象的實例。在Heap 中分配一定的內存來保存對象實例,實際上也只是保存對象實例的屬性值,屬性的類型和對象本身的類型標記等,並不保存對象的方法(方法是指令,保存在Stack中)。而對象實例在Heap中分配好以后,需要在Stack中保存一個4字節的Heap 內存地址,用來定位該對象實例在Heap 中的位置,便於找到該對象實例。

異常

  • 堆中沒有足夠的內存進行對象實例分配時,並且堆也無法擴展時,會拋出OutOfMemoryError異常。

Java棧

  • Stack(棧)是JVM的內存指令區。
  • 描述的是java方法執行的內存模型:每個方法被執行的時候都會同時創建一個棧幀,用於存放局部變量表(基本類型、對象引用)、操作數棧、方法返回、常量池指針等信息。 由編譯器自動分配釋放, 內存的分配是連續的。Stack的速度很快,管理很簡單,並且每次操作的數據或者指令字節長度是已知的。所以Java 基本數據類型,Java 指令代碼,常量都保存在Stack中。
  • 虛擬機只會對棧進行兩種操作,以幀為單位的入棧和出棧。Java棧中的每個幀都保存一個方法調用的局部變量、操作數棧、指向常量池的指針等,且每一次方法調用都會創建一個幀,並壓棧。

異常

  • 如果一個線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常, 比如遞歸調用。
  • 如果線程生成數量過多,無法申請足夠多的內存時,則會拋出OutOfMemoryError異常。比如tomcat請求數量非常多時,設置最大請求數。

2.3.1 棧幀

  • 棧幀由三部分組成:局部變量區、操作數棧、幀數據區。

2.4 堆與棧

2.4.1 堆與棧里存什么

  • 1)堆中存的是對象。棧中存的是基本數據類型和堆中對象的引用。一個對象的大小是不可估計的,或者說是可以動態變化的,但是在棧中,一個對象只對應了一個4btye的引用。
  • 2)為什么不把基本類型放堆中呢?因為其占用的空間一般是1~8個字節——需要空間比較少,而且因為是基本類型,所以不會出現動態增長的情況——長度固定,因此棧中存儲就夠了,如果把他存在堆中是沒有什么意義的。可以這么說,基本類型和對象的引用都是存放在棧中,而且都是幾個字節的一個數,因此在程序運行時,他們的處理方式是統一的。但是基本類型、對象引用和對象本身就有所區別了,因為一個是棧中的數據一個是堆中的數據。最常見的一個問題就是,Java中參數傳遞時的問題。
  • 3)Java中的參數傳遞時傳值呢?還是傳引用?程序運行永遠都是在棧中進行的,因而參數傳遞時,只存在傳遞基本類型和對象引用的問題。不會直接傳對象本身。

int a = 0; //全局初始化區
 
char p1; //全局未初始化區
 
main(){
 
  int b; //棧
 
  char s[] = "abc"; //棧
 
  char p2; //棧
 
  char p3 = "123456"; //123456\0在常量區,p3在棧上。
 
  static int c =0; //全局(靜態)初始化區
 
  p1 = (char *)malloc(10); //堆
 
  p2 = (char *)malloc(20); //堆
 
}

2.4.2 堆內存與棧內存的區別

  • 申請和回收方式不同:棧上的空間是自動分配自動回收的,所以棧上的數據的生存周期只是在函數的運行過程中,運行后就釋放掉,不可以再訪問。而堆上的數據只要程序員不釋放空間,就一直可以訪問到,不過缺點是一旦忘記釋放會造成內存泄露。
  • 碎片問題:對於棧,不會產生不連續的內存塊;但是對於堆來說,不斷的new、delete勢必會產生上面所述的內部碎片和外部碎片。
  • 申請大小的限制:棧是向低地址擴展的數據結構,是一塊連續的內存的區域。棧頂的地址和棧的最大容量是系統預先規定好的,如果申請的空間超過棧的剩余空間,就會產生棧溢出;對於堆,是向高地址擴展的數據結構,是不連續的內存區域。堆的大小受限於計算機系統中有效的虛擬內存。由此可見,堆獲得的空間比較靈活,也比較大。
  • 申請效率的比較:棧由系統自動分配,速度較快。但程序員是無法控制的;堆:是由new分配的內存,一般速度比較慢,而且容易產生內存碎片,不過用起來最方便。

3.JIT編譯器

  1. JIT編譯器是JVM的核心。它對於程序性能的影響最大。
  2. CPU只能執行匯編代碼或者二進制代碼,所有程序都需要被翻譯成它們,然后才能被CPU執行。
  3. C++以及Fortran這類編譯型語言都會通過一個靜態的編譯器將程序編譯成CPU相關的二進制代碼。
  4. PHP以及Perl這列語言則是解釋型語言,只需要安裝正確的解釋器,它們就能運行在任何CPU之上。當程序被執行的時候,程序代碼會被逐行解釋並執行。
  5. 編譯型語言的優缺點:
    • 速度快:因為在編譯的時候它們能夠獲取到更多的有關程序結構的信息,從而有機會對它們進行優化。
    • 適用性差:它們編譯得到的二進制代碼往往是CPU相關的,在需要適配多種CPU時,可能需要編譯多次。
  6. 解釋型語言的優缺點:
    • 適應性強:只需要安裝正確的解釋器,程序在任何CPU上都能夠被運行
    • 速度慢:因為程序需要被逐行翻譯,導致速度變慢。同時因為缺乏編譯這一過程,執行代碼不能通過編譯器進行優化。
  7. Java的做法是找到編譯型語言和解釋性語言的一個中間點:
    • Java代碼會被編譯:被編譯成Java字節碼,而不是針對某種CPU的二進制代碼。
    • Java代碼會被解釋:Java字節碼需要被java程序解釋執行,此時,Java字節碼被翻譯成CPU相關的二進制代碼。
    • JIT編譯器的作用:在程序運行期間,將Java字節碼編譯成平台相關的二進制代碼。正因為此編譯行為發生在程序運行期間,所以該編譯器被稱為Just-In-Time編譯器。

HotSpot 編譯

  • HotSpot VM名字也體現了JIT編譯器的工作方式。在VM開始運行一段代碼時,並不會立即對它們進行編譯。在程序中,總有那么一些“熱點”區域,該區域的代碼會被反復的執行。而JIT編譯器只會編譯這些“熱點”區域的代碼。
  • 這么做的原因在於:
    • 編譯那些只會被運行一次的代碼性價比太低,直接解釋執行Java字節碼反而更快。
    • JVM在執行這些代碼的時候,能獲取到這些代碼的信息,一段代碼被執行的次數越多,JVM也對它們愈加熟悉,因此能夠在對它們進行編譯的時候做出一些優化。
  • 在HotSpot VM中內嵌有兩個JIT編譯器,分別為Client Compiler和Server Compiler,但大多數情況下我們簡稱為C1編譯器和C2編譯器。開發人員可以通過如下命令顯式指定Java虛擬機在運行時到底使用哪一種即時編譯器,如下所示:

-client:指定Java虛擬機運行在Client模式下,並使用C1編譯器;
-server:指定Java虛擬機運行在Server模式下,並使用C2編譯器。
  • 除了可以顯式指定Java虛擬機在運行時到底使用哪一種即時編譯器外,默認情況下HotSpot VM則會根據操作系統版本與物理機器的硬件性能自動選擇運行在哪一種模式下,以及采用哪一種即時編譯器。簡單來說,C1編譯器會對字節碼進行簡單和可靠的優化,以達到更快的編譯速度;而C2編譯器會啟動一些編譯耗時更長的優化,以獲取更好的編譯質量。不過在Java7版本之后,一旦開發人員在程序中顯式指定命令“-server”時,缺省將會開啟分層編譯(Tiered Compilation)策略,由C1編譯器和C2編譯器相互協作共同來執行編譯任務。不過在早期版本中,開發人員則只能夠通過命令“-XX:+TieredCompilation”手動開啟分層編譯策略。

  • 總結

    • Java綜合了編譯型語言和解釋性語言的優勢。
    • Java會將類文件編譯成為Java字節碼,然后Java字節碼會被JIT編譯器選擇性地編譯成為CPU能夠直接運行的二進制代碼。
    • 將Java字節碼編譯成二進制代碼后,性能會被大幅度提升。

4.類加載機制

  • Java虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的加載機制。
  • 類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期包括了:加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(using)、和卸載(Unloading)七個階段。其中驗證、准備和解析三個部分統稱為連接(Linking),這七個階段的發生順序如下圖所示:
  • 如上圖所示,加載、驗證、准備、初始化和卸載這五個階段的順序是確定的,類的加載過程必須按照這個順序來按部就班地開始,而解析階段則不一定,它在某些情況下可以在初始化階段后再開始。
  • 類的生命周期的每一個階段通常都是互相交叉混合式進行的,通常會在一個階段執行的過程中調用或激活另外一個階段。

4.1 類加載的時機

主動引用

  • 一個類被主動引用之后會觸發初始化過程(加載,驗證,准備需再此之前開始)
    • 1)遇到new、get static、put static或invoke static這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令最常見的Java代碼場景是:使用new關鍵字實例化對象時、讀取或者設置一個類的靜態字段(被final修飾、已在編譯器把結果放入常量池的靜態字段除外)時、以及調用一個類的靜態方法的時候。
    • 2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
    • 3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要觸發父類的初始化。
    • 4)當虛擬機啟動時,用戶需要指定一個執行的主類(包含main()方法的類),虛擬機會先初始化這個類。
    • 5)當使用jdk7+的動態語言支持時,如果java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發器 初始化。

被動引用

  • 一個類如果是被動引用的話,該類不會觸發初始化過程
    • 1)通過子類引用父類的靜態字段,不會導致子類初始化。對於靜態字段,只有直接定義該字段的類才會被初始化,因此當我們通過子類來引用父類中定義的靜態字段時,只會觸發父類的初始化,而不會觸發子類的初始化。
    • 2)通過數組定義來引用類,不會觸發此類的初始化。
    • 3)常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。

4.2 類加載過程

1、加載

  • 在加載階段,虛擬機需要完成以下三件事情:
    • 1)通過一個類的全限定名稱來獲取定義此類的二進制字節流。
    • 2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
    • 3)在java堆中生成一個代表這個類的java.lang.Class對象,作為方法區這些數據的訪問入口。
  • 相對於類加載過程的其他階段,加載階段是開發期相對來說可控性比較強,該階段既可以使用系統提供的類加載器完成,也可以由用戶自定義的類加載器來完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式。

2、驗證

  • 驗證的目的是為了確保Class文件中的字節流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證。
    • 1)文件格式的驗證:驗證字節流是否符合Class文件格式的規范,並且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節流能正確地解析並存儲
      於方法區之內。經過該階段的驗證后,字節流才會進入內存的方法區中進行存儲,后面的三個驗證都是基於方法區的存儲結構進行的。
    • 2)元數據驗證:對類的元數據信息進行語義校驗(其實就是對類中的各數據類型進行語法校驗),保證不存在不符合Java語法規范的元數據信息。
    • 3)字節碼驗證:該階段驗證的主要工作是進行數據流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會做出危害虛擬機安全的行為。
    • 4)符號引用驗證:這是最后一個階段的驗證,它發生在虛擬機將符號引用轉化為直接引用的時候(解析階段中發生該轉化,后面會有講解),主要是對類自身以外的信息(常量池中的各種符號引用)進行匹配性的校驗。

3、准備

  • 准備階段是正式為類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中進行分配。
    • 注:
      • 1)這時候進行內存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨着對象一塊分配在Java堆中。
      • 2)這里所設置的初始值通常情況下是數據類型默認的零值(如0、0L、、false等),而不是被在Java代碼中被顯式地賦予的值。

4、解析

  • 解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。

符號引用(Symbolic Reference):

-符號引用以一組符號來描述所引用的目標,符號引用可以是任何形式的字面量,符號引用與虛擬機實現的內存布局無關,引用的目標並不一定已經在內存中。

直接引用(Direct Reference):

  • 直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現的內存布局相關的,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般都不相同,如果有了直接引用,那引用的目標必定已經在內存中存在。
    • 1)類或接口的解析:判斷所要轉化成的直接引用是對數組類型,還是普通的對象類型的引用,從而進行不同的解析。
    • 2)字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果有,則查找結束;如果沒有,則會按照繼承關系從上往下遞歸搜索該類所實現的各個接口和它們的父接口,還沒有,則按照繼承關系從上往下遞歸搜索其父類,直至查找結束。
    • 3)類方法解析:對類方法的解析與對字段解析的搜索步驟差不多,只是多了判斷該方法所處的是類還是接口的步驟,而且對類方法的匹配搜索,是先搜索父類,再搜索接口。
    • 4)接口方法解析:與類方法解析步驟類似,只是接口不會有父類,因此,只遞歸向上搜索父接口就行了。

5、初始化

  • 類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了加載(Loading)階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼。
  • 初始化階段是執行類構造器 方法的過程。
    • 1) 方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並產生的,編譯器收集的順序由語句在源文件中出現的順序所決定。
    • 2) 方法與類的構造函數不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的 方法執行之前,父類的 方法已經執行完畢,因此在虛擬機中第一個執行的 方法的類一定是java.lang.Object。
    • 3)由於父類的 方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。
    • 4) 方法對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊也沒有對變量的賦值操作,那么編譯器可以不為這個類生成 方法。
    • 5)接口中可能會有變量賦值操作,因此接口也會生成 方法。但是接口與類不同,執行接口的 方法不需要先執行父接口的 方法。只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也不會執行接口的 方法。
    • 6)虛擬機會保證一個類的 方法在多線程環境中被正確地加鎖和同步。如果有多個線程去同時初始化一個類,那么只會有一個線程去執行這個類的 方法,其它線程都需要阻塞等待,直到活動線程執行 方法完畢。如果在一個類的 方法中有耗時很長的操作,那么就可能造成多個進程阻塞。

5.垃圾回收

5.1 按代實現垃圾回收

新生代(Young generation):

  • 絕大多數最新被創建的對象會被分配到這里,由於大部分對象在創建后會很快變得不可到達,所以很多對象被創建在新生代,然后消失。對象從這個區域消失的過程我們稱之為”minor GC“。

  • 新生代中存在一個Eden區和兩個Survivor區。新對象會首先分配在 Eden 中(如果新對象過大,會直接分配在老年代中)。在GC中,Eden 中的對象會被移動到survivor中,直至對象滿足一定的年紀(定義為熬過GC的次數),會被移動到老年代

  • 可以設置新生代和老年代的相對大小。這種方式的優點是新生代大小會隨着整個堆大小動態擴展。參數 -XX:NewRatio 設置老年代與新生代的比例。例如 -XX:NewRatio=8 指定老年代/新生代為8/1. 老年代占堆大小的 7/8 ,新生代占 1/8 .(默認即使1/8)

      例如:-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8
    

老年代(Old generation):

  • 對象沒有變得不可達,並且從新生代中存活下來,會被拷貝到這里。其所占用的空間要比新生代多。也正由於其相對較大的空間,發生在老年代上的GC要比新生代少得多。對象從老年代中消失的過程,可以稱之為“old GC”

永久代(permanent generation):

  • 像一些類的層級信息,方法數據和方法信息(如字節碼,棧和變量大小),運行時常量池(jdk7之后移出永久代),已確定的符號引用和虛方法表等等,它們幾乎都是靜態的並且很少被卸載和回收,在JDK8之前的HotSpot虛擬機中,類的這些“永久的”數據存放在一個叫做永久代的區域。永久代一段連續的內存空間,我們在JVM啟動之前可以通過設置-XX:MaxPermSize的值來控制永久代的大小。但是jdk8之后取消了永久代,這些元數據被移到了一個與堆不相連的本地內存區域 。

5.2 怎樣判斷對象是否已經死亡

引用計數收集算法

  • 用計數是垃圾收集器中的早期策略。在這種方法中,堆中每個對象(不是引用)都有一個引用計數。當一個對象被創建時,且將該對象分配給一個變量,該變量計數設置為1。當任何其它變量被賦值為這個對象的引用時,計數加1(a = b,則b引用的對象+1),但當一個對象的某個引用超過了生命周期或者被設置為一個新值時,對象的引用計數減1。任何引用計數為0的對象可以被當作垃圾收集。當一個對象被垃圾收集時,它引用的任何對象計數減1。
  • 優點:引用計數收集器可以很快的執行,交織在程序運行中。對程序不被長時間打斷的實時環境比較有利。
  • 缺點: 無法檢測出循環引用。如父對象有一個對子對象的引用,子對象反過來引用父對象。這樣,他們的引用計數永遠不可能為0.

可達性分析算法

  • 通過一系列稱為”GC Roots”的對象作為起點,從這些節點開始向下搜索,搜索所有走過的路徑稱為引用鏈,當一個對象到GC Roots沒有任何引用鏈相連時(從GC Roots到此對象不可達),則證明此對象是不可用的。
  • 可作為GC Roots的對象包括:
    • 虛擬機棧中所引用的對象(本地變量表)
    • 方法區中類靜態屬性引用的對象
    • 方法區中常量引用的對象
    • 本地方法棧中JNI引用的對象(Native對象)

5.3 java中的引用

強引用(Strong Reference):

  • 在代碼中普遍存在的,類似”Object obj = new Object”這類引用,只要強引用還在,垃圾收集器永遠不會回收掉被引用的對象

軟引用(Sofe Reference):

  • 有用但並非必須的對象,可用SoftReference類來實現軟引用,在系統將要發生內存溢出異常之前,將會把這些對象列進回收范圍之中進行二次回收。如果這次回收還沒有足夠的內存,才會拋出內存異常異常。

弱引用(Weak Reference):

  • 被弱引用關聯的對象只能生存到下一次垃圾收集發生之前,JDK提供了WeakReference類來實現弱引用。

虛引用(Phantom Reference):

  • 也稱為幽靈引用或幻影引用,是最弱的一種引用關系,JDK提供了PhantomReference類來實現虛引用。

5.4 finalize方法什么作用

  • 對於一個對象來說,在被判斷沒有 GCroots 與其相關聯時,被第一次標記,然后判斷該對象是否應該執行finalize方法(判斷依據:如果對象的finalize方法被復寫,並且沒有執行過,則可以被執行)。如果允許執行那么這個對象將會被放到一個叫F-Query的隊列中,等待被執行。(注意:由於finalize的優先級比較低,所以該對象的的finalize方法不一定被執行,即使被執行了,也不保證finalize方法一定會執行完)

5.5 垃圾收集算法

標記-清除算法:

  • 標記-清除算法采用從根集合進行掃描,對存活的對象進行標記,標記完畢后,再掃描整個空間中未被標記的對象,進行回收。標記-清除算法不需要進行對象的移動,並且僅對不存活的對象進行處理,在存活對象比較多的情況下極為高效,但由於標記-清除算法直接回收不存活的對象,因此會造成內存碎片。

復制算法:

  • 這種收集算法將堆棧分為兩個域,常稱為半空間。每次僅使用一半的空間,JVM生成的新對象則放在另一半空間中。GC運行時,它把可到達對象復制到另一半空間,從而壓縮了堆棧。這種方法適用於短生存期的對象,持續復制長生存期的對象則導致效率降低。並且對於指定大小堆來說,需要兩倍大小的內存,因為任何時候都只使用其中的一半。

標記整理算法:

  • 標記-整理算法采用標記-清除算法一樣的方式進行對象的標記,但在清除時不同,在回收不存活的對象占用的空間后,會將所有的存活對象往一端空閑空間移動,並更新對應的指針。標記-整理算法是在標記-清除算法的基礎上,又進行了對象的移動,因此成本更高,但是卻解決了內存碎片的問題。

分代收集算法:

  • 在上邊三種收集思想中加入了分代的思想。

5.6 Hotspot實現垃圾回收細節

一致性:

  • 在可達性分析期間整個系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關系還在不斷變化的情況。
  • 一致性要求導致GC進行時必須停頓所有Java執行線程。(Stop The World)即使在號稱不會發生停頓的CMS收集器中,枚舉根節點時也是必須停頓的。
  • HotSpot使用的是准確式GC,當執行系統停頓下來后,並不需要一個不漏地檢查完所有執行上下文和全局的引用位置,這是通過一組稱為OopMap的數據結構來達到的。

安全點(Safe Point):

  • 程序只有在到達安全點時才能暫停。安全點的選定標准是“是否具有讓程序長時間執行的特征”。“長時間執行”的最明顯特征就是指令序列的復用,如方法調用、循環跳轉等,具有這些功能的指令才會產生安全點。

讓程序暫停的兩種方式:


* 搶先式中斷(Preemptive Suspension):在GC發生時,主動中斷所有線程,不需要線程執行的代碼主動配合。如果發現有線程中斷的地方不在安全點上,就恢復線程讓它跑到安全點上。(不推薦)
* 主動式中斷(Voluntary Suspension):設一個標志,各個線程主動去輪詢這個標志,遇到中斷則暫停。輪詢地方與安全點重合。

5.7 CMS收集器

特性:

  • CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。CMS收集器就非常符合這類應用的需求。
  • CMS收集器是基於“標記—清除”算法實現的,它的運作過程相對於前面幾種收集器來說更復雜一些,整個過程分為4個步驟:
    • 初始標記(CMS initial mark):初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,需要“Stop The World”。
    • 並發標記(CMS concurrent mark):並發標記階段就是進行GC Roots Tracing的過程。
    • 重新標記(CMS remark):重新標記階段是為了修正並發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短,仍然需要“Stop The World”。
    • 並發清除(CMS concurrent sweep):並發清除階段會清除對象。
  • 由於整個過程中耗時最長的並發標記和並發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS收集器的內存回收過程是與用戶線程一起並發執行的。

優點:

  • CMS是一款優秀的收集器,它的主要優點在名字上已經體現出來了:並發收集、低停頓。

缺點:

  • 1)CMS收集器對CPU資源非常敏感

    • 其實,面向並發設計的程序都對CPU資源比較敏感。在並發階段,它雖然不會導致用戶線程停頓,但是會因為占用了一部分線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。
    • CMS默認啟動的回收線程數是(CPU數量+3)/ 4,也就是當CPU在4個以上時,並發回收時垃圾收集線程不少於25%的CPU資源,並且隨着CPU數量的增加而下降。但是當CPU不足4個(譬如2個)時,CMS對用戶程序的影響就可能變得很大。
  • 2)CMS收集器無法處理浮動垃圾

    • CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。
    • 由於CMS並發清理階段用戶線程還在運行着,伴隨程序運行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之后,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就稱為“浮動垃圾”。
    • 也是由於在垃圾收集階段用戶線程還需要運行,那也就還需要預留有足夠的內存空間給用戶線程使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供並發收集時的程序運作使用。要是CMS運行期間預留的內存無法滿足程序需要,就會出現一次“Concurrent Mode Failure”失敗,這時虛擬機將啟動后備預案:臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。
  • 3)CMS收集器會產生大量空間碎片

    • CMS是一款基於“標記—清除”算法實現的收集器,這意味着收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很大空間剩余,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次Full GC。

5.8 G1收集器

特性:

  • G1(Garbage-First)是一款面向服務端應用的垃圾收集器。HotSpot開發團隊賦予它的使命是未來可以替換掉JDK 1.5中發布的CMS收集器。與其他GC收集器相比,G1具備如下特點。
    • 1)並行與並發
      • G1能充分利用多CPU、多核環境下的硬件優勢,使用多個CPU來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過並發的方式讓Java程序繼續執行。
    • 2)分代收集
      • 與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠采用不同的方式去處理新創建的對象和已經存活了一段時間、熬過多次GC的舊對象以獲取更好的收集效果。
    • 3)空間整合
      • 與CMS的“標記—清理”算法不同,G1從整體來看是基於“標記—整理”算法實現的收集器,從局部(兩個Region之間)上來看是基於“復制”算法實現的,但無論如何,這兩種算法都意味着G1運作期間不會產生內存空間碎片,收集后能提供規整的可用內存。這種特性有利於程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發下一次GC。
    • 4)可預測的停頓
      • 這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。
  • 在G1之前的其他收集器進行收集的范圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的內存布局就與其他收集器有很大差別,它將整個Java堆划分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。
  • G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計划地避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region里面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在后台維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region划分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內可以獲取盡可能高的收集效率。

執行過程:

  • G1收集器的運作大致可划分為以下幾個步驟:
    • 1)初始標記(Initial Marking):初始標記階段僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序並發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。
    • 2)並發標記(Concurrent Marking):並發標記階段是從GC Root開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序並發執行。
    • 3)最終標記(Final Marking):最終標記階段是為了修正在並發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs里面,最終標記階段需要把Remembered Set Logs的數據合並到Remembered Set中,這階段需要停頓線程,但是可並行執行。
    • 4)篩選回收(Live Data Counting and Evacuation):篩選回收階段首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計划,這個階段其實也可以做到與用戶程序一起並發執行,但是因為只回收一部分Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。

何時會拋出OutOfMemoryException,並不是內存被耗空的時候才拋出
    * JVM98%的時間都花費在內存回收
    * 每次回收的內存小於2%


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM