Java主要有如下部分組成,本文將圍繞下圖作進一步的解釋及分析
前端
上圖中展現的類加載器之間的這種層次關係,稱爲類加載器的雙親委派模型(ParentsDelegation Model)。 雙親委派模型要求除了頂層的啓動類加載器外,其他的類加載器都應當有本身的父類加載器。java
這裏類加載器之間的父子關係通常不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父加載器的代碼。程序員
類加載器的雙親委派模型在JDK 1.2期間被引入並被普遍應用於以後幾乎全部的Java程序中,但它並非一個強制性的約束模型,而是Java設計者推薦給開發者的一種類加載器實現方式。雙親委派模型的工做過程是:若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,算法
所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋本身沒法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試本身去加載。後端
「加載」是類加載過程的一個階段,經過一個類的全限定名來獲取定義此類的二進制字節流。將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構 。數組
在內存中生成一個表明這個類的java.lang.Class對象,做爲方法區這個類的各類數據的訪問入口。
安全
「驗證」是鏈接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。
數據結構
「準備」是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。 這個階段中有兩個容易產生混淆的概念須要強調一下,多線程
首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在Java堆中。併發
其次,這裏所說的初始值「一般狀況」下是數據類型的零值,假設一個類變量的定義爲:
public static int value=123;
那變量value在準備階段事後的初始值爲0而不是123,由於這時候還沒有開始執行任何Java方法,而把value賦值爲123的putstatic指令是程序被編譯後,
存放於類構造器<clinit>()方法之中,因此把value賦值爲123的動做將在初始化階段纔會執行。
「解析」是虛擬機將常量池內的符號引用替換爲直接引用的過程,解析動做主要針對類或接口、 字段、 類方法、 接口方法、 方法類型、 方法句柄和調用點限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、 限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、 限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7種常量類型 [1]。 下面將講 解前面4種引用的解析過程,對於後面3種,與JDK 1.7新增的動態語言支持息息相關,因爲Java語言是一門靜態類型語言,所以在沒有介紹invokedynamic指令的語義以前,沒有辦法將Java語言是一門靜態類型語言,所以在沒有介紹invokedynamic指令的語義以前,沒有辦法將「初始化」是類加載過程的最後一步,前面的類加載過程當中,除了在加載階段用戶應用程序能夠經過自定義類加載器參與以外,其他動做徹底由虛擬機主導和控制。 到了初始化階段,
才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。
執行引擎是Java虛擬機最核心的組成部分之一。 「虛擬機」是一個相對於「物理機」的概念,這兩種機器都有代碼執行能力,其區別是物理機的執行引擎是直接創建在處理器、 硬件、指令集和操做系統層面上的,而虛擬機的執行引擎則是由本身實現的,所以能夠自行制定指令集與執行引擎的結構體系,而且可以執行那些不被硬件直接支持的指令集格式。在Java虛擬機規範中制定了虛擬機字節碼執行引擎的概念模型,這個概念模型成爲各類虛擬機執行引擎的統一外觀(Facade)。 在不一樣的虛擬機實現裏面,執行引擎在執行Java代碼的時候可能會有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種選擇[1],也可能二者兼備,甚至還可能會包含幾個不一樣級別的編譯器執行引擎。但從外觀上看起來,全部的Java虛擬機的執行引擎都是一致的:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果,本章將主要從概念模型的角度來說解虛擬機的方法調用和字節碼執行。
Java語言的「編譯期」實際上是一段「不肯定」的操做過程,由於它多是指一個前端編譯器(其實叫「編譯器的前端」更準確一些)把*.java文件轉變成*.class文件的過程;也多是指虛擬機的後端運行期編譯器(JIT編譯器,Just In Time Compiler)把字節碼轉變成機器碼的過程;還多是指使用靜態提早編譯器(AOT編譯器,Ahead Of Time Compiler)
直接把*.java文件編譯成本地機器代碼的過程。 下面列舉了這3類編譯過程當中一些比較有表明性的編譯器。
解析步驟由圖10-5中的parseFiles()方法(圖10-5中的過程1.1)完成,解析步驟包括了經典程序編譯原理中的詞法分析和語法分析兩個過程。
1.詞法、 語法分析詞法分析是將源代碼的字符流轉變爲標記Token)集合,單個字符是程序編寫過程的最小元素,而標記則是編譯過程的最小元素,關鍵字、 變量名、 字面量、 運算符均可以成爲標記,如「int a=b+2」這句代碼包含了6個標記,分別是int、 a、 =、 b、 +、 2,雖然關鍵字int由3個字符構成,可是它只是一個Token,不可再拆分。 在Javac的源碼中,詞法分析過程由com.sun.tools.javac.parser.Scanner類來實現。
語法分析是根據Token序列構造抽象語法樹的過程,抽象語法樹(Abstract SyntaxTree,AST)是一種用來描述程序代碼語法結構的樹形表示方式,語法樹的每個節點都表明着程序代碼中的一個語法結構(Construct),例如包、 類型、 修飾符、 運算符、 接口、 返回值甚至代碼註釋等均可以是一個語法結構。
語法分析以後,編譯器得到了程序代碼的抽象語法樹表示,語法樹能表示一個結構正確的源程序的抽象,但沒法保證源程序是符合邏輯的。而語義分析的主要任務是對結構上正確的源程序進行上下文有關性質的審查,如進行類型審查。 舉個例子,假設有以下的3個變量定義語句:
int a=1;
boolean b=false;
char c=2;
後續可能出現的賦值運算:
int d=a+c;
int d=b+c;
char d=a+c;
後續代碼中若是出現瞭如上3種賦值運算的話,那它們都能構成結構正確的語法樹,可是隻有第1種的寫法在語義上是沒有問題的,可以經過編譯,其他兩種在Java語言中是不合邏輯的,
沒法編譯(是否合乎語義邏輯必須限定在具體的語言與具體的上下文環境之中才有意義。 如在C語言中,a、 b、 c的上下文定義不變,第二、 3種寫法都是能夠正確編譯)。
許多程序設計語言都提供了條件編譯的途徑,如C、 C++中使用預處理器指示符(#ifdef)來完成條件編譯。 C、 C++的預處理器最初的任務是解決編譯時的代碼依賴關係(如很是經常使用的#include預處理命令),而在Java語言之中並無使用預處理器,由於Java語言自然的編譯方式(編譯器並不是一個個地編譯Java文件,而是將全部編譯單元的語法樹頂級節點輸入到待處理列表後再進行編譯,所以各個文件之間可以互相提供符號信息)無須使用預處理器。 那Java語言是否有辦法實現條件編譯呢?
Java語言固然也能夠進行條件編譯,方法就是使用條件爲常量的if語句。 如如下代碼所示,此代碼中的if語句不一樣於其餘Java代碼,它在編譯階段就會被「運行」,生成的字節碼
之中只包括「System.out.println("block 1");」一條語句,並不會包含if語句及另一個分子中的「System.out.println("block 2");」
1
2
3
4
5
6
|
public
static
void
main(String[]args){
if(true)
{
System.out.println(
"block 1"
);
}
else
{
System.out.println(
"block 2"
);
}
} |
對於大多數應用來講,Java堆(Java Heap)是Java虛擬機所管理的內存中最大的一塊。Java堆是被全部線程共享的一塊內存區域,在虛擬機啓動時建立。 此內存區域的惟一目的就是存放對象實例,幾乎全部的對象實例都在這裏分配內存。 這一點在Java虛擬機規範中的描述是:全部的對象實例以及數組都要在堆上分配[1],可是隨着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換[2]優化技術將會致使一些微妙的變化發生,全部的對象都分配在堆上也漸漸變得不是那麼「絕對」了。
Java堆是垃圾收集器管理的主要區域,所以不少時候也被稱作「GC堆」(GarbageCollected Heap,幸虧國內沒翻譯成「垃圾堆」)。 從內存回收的角度來看,因爲如今收集器基本都採用分代收集算法,因此Java堆中還能夠細分爲:新生代和老年代;再細緻一點的有Eden空間、 From Survivor空間、 To Survivor空間等。 從內存分配的角度來看,線程共享的Java堆中可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。 不過不管如何劃分,都與存放內容無關,不管哪一個區域,存儲的都仍然是對象實例,進一步劃分的目的是爲了更好地回收內存,或者更快地分配內存。
根據Java虛擬機規範的規定,Java堆能夠處於物理上不連續的內存空間中,只要邏輯上是連續的便可,就像咱們的磁盤空間同樣。 在實現時,既能夠實現成固定大小的,也能夠是可擴展的,不過當前主流的虛擬機都是按照可擴展來實現的(經過-Xmx和-Xms控制)。 若是在堆中沒有內存完成實例分配,而且堆也沒法再擴展時,將會拋出OutOfMemoryError異常。
與程序計數器同樣,Java虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命週期與線程相同。 虛擬機棧描述的是Java方法執行的內存模型:每一個方法在執行的同時都會建立一個棧幀(Stack Frame[1])用於存儲局部變量表、 操做數棧、 動態連接、 方法出口等信息。 每個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。
常常有人把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異常。
用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧(Virtual Machine Stack)[1]的棧元素。 棧幀存儲了方法的局部變量表、 操做數棧、動態鏈接和方法返回地址等信息。 每個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。
一個線程中的方法調用鏈可能會很長,不少方法都同時處於執行狀態。 對於執行引擎來講,在活動線程中,只有位於棧頂的棧幀纔是有效的,稱爲當前棧幀(Current StackFrame),與這個棧幀相關聯的方法稱爲當前方法(Current Method)。 執行引擎運行的全部字節碼指令都只針對當前棧幀進行操做,在概念模型上,典型的棧幀結構如圖8-1所示。
常稱爲操做棧,它是一個後入先出(Last In FirstOut,LIFO)棧。 同局部變量表同樣,操做數棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks數據項中。操做數棧的每個元素能夠是任意的Java數據類型,包括long和double。 32位數據類型所佔的棧容量爲1,64位數據類型所佔的棧容量爲2。 在方法執行的任什麼時候候,操做數棧的深度都不會超過在max_stacks數據項中設定的最大值。
當一個方法剛剛開始執行的時候,這個方法的操做數棧是空的,在方法的執行過程當中,會有各類字節碼指令往操做數棧中寫入和提取內容,也就是出棧/入棧操做。
例如,在作算術運算的時候是經過操做數棧來進行的,又或者在調用其餘方法的時候是經過操做數棧來進行參數傳遞的。舉個例子,整數加法的字節碼指令iadd在運行的時候操做數棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會將這兩個int值出棧並相加,而後將相加的結果入棧。
是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。 在Java程序編譯爲Class文件時,就在方法的Code屬性的max_locals數據項中肯定了該方法所須要分配的局部變量表的最大容量。
每一個棧幀都包含一個指向運行時常量池[1]中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程當中的動態鏈接(Dynamic Linking)。經過第6章的講解,咱們知道Class文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用做爲參數。 這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化爲直接引用,這種轉化稱爲靜態解析。另一部分將在每一次運行期間轉化爲直接引用,這部分稱爲動態鏈接。
當一個方法開始執行後,只有兩種方式能夠退出這個方法。 第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱爲調用者),是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱爲正常完成出口(Normal Method Invocation Completion)。
另一種退出方式是,在方法執行過程當中遇到了異常,而且這個異常沒有在方法體內獲得處理,不管是Java虛擬機內部產生的異常,仍是代碼中使用athrow字節碼指令產生的異常,
只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會致使方法退出,這種退出方法的方式稱爲異常完成出口(Abrupt Method Invocation Completion)。 一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。
不管採用何種退出方式,在方法退出以後,都須要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能須要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。
通常來講,方法正常退出時,調用者的PC計數器的值能夠做爲返回地址,棧幀中極可能會保存這個計數器值。 而方法異常退出時,返回地址是要經過異常處理器表來肯定的,棧幀中通常不會保存這部分信息。
方法退出的過程實際上就等同於把當前棧幀出棧,所以退出時可能執行的操做有:恢復上層方法的局部變量表和操做數棧,把返回值(若是有的話)壓入調用者棧幀的操做數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等。
方法區(Method Area)與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來實現方法區
的規劃了[1],在目前已經發布的JDK 1.7的HotSpot中,已經把本來放在永久代的字符串常量池移出,JDK8已經將永久代被替換成元空間,直接使用堆外內存。
Java虛擬機規範對方法區的限制很是寬鬆,除了和Java堆同樣不須要連續的內存和能夠選擇固定大小或者可擴展外,還能夠選擇不實現垃圾收集。 相對而言,垃圾收集行爲在這個區域是比較少出現的,但並不是數據進入了方法區就如永久代的名字同樣「永久」存在了。 這區域的內存回收目標主要是針對常量池的回收和對類型的卸載,通常來講,這個區域的回收「成績」比較難以使人滿意,尤爲是類型的卸載,條件至關苛刻,可是這部分區域的回收確實是必要的。 在Sun公司的BUG列表中,曾出現過的若干個嚴重的BUG就是因爲低版本的HotSpot虛擬機對此區域未徹底回收而致使內存泄漏。根據Java虛擬機規範的規定,當方法區沒法知足內存分配需求時,將拋出OutOfMemoryError異常。
運行時常量池(Runtime Constant Pool)是方法區的一部分。 Class文件中除了有類的版本、 字段、 方法、 接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各類字面量和符號引用,這部份內容將在類加載後進入方法區的運行時常量池中存放。
程序計數器(Program Counter Register)是一塊較小的內存空間,它能夠看做是當前線程所執行的字節碼的行號指示器。 在虛擬機的概念模型裏(僅是概念模型,各類虛擬機可能會經過一些更高效的方式去實現),字節碼解釋器工做時就是經過改變這個計數器的值來選取下一條須要執行的字節碼指令,分支、 循環、 跳轉、 異常處理、 線程恢復等基礎功能都須要依賴這個計數器來完成。
因爲Java虛擬機的多線程是經過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個肯定的時刻,一個處理器(對於多核處理器來講是一個內核)都只會執行一條線程中的指令。所以,爲了線程切換後能恢復到正確的執行位置,每條線程都須要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,咱們稱這類內存區域爲「線程私有」的內存。
若是線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;若是正在執行的是Native方法,這個計數器值則爲空(Undefined)。 此內存區域是惟一一個在Java虛擬機規範中沒有規定任何OutOfMemoryError狀況的區域。
提及垃圾收集(Garbage Collection,GC),大部分人都把這項技術當作Java語言的伴生產物。 事實上,GC的歷史比Java久遠,1960年誕生於MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。
當Lisp還在胚胎時期時,人們就在思考GC須要完成的3件事情:哪些內存須要回收?何時回收?如何回收?
最基礎的收集算法是「標記-清除」(Mark-Sweep)算法,如同它的名字同樣,算法分爲「標記」和「清除」兩個階段:首先標記出全部須要回收的對象,在標記完成後統一回收全部被標記的對象,它的標記過程其實在前一節講述對象標記斷定時已經介紹過了。 之因此說它是最基礎的收集算法,是由於後續的收集算法都是基於這種思路並對其不足進行改進而獲得的。 它的主要不足有兩個:
一個是效率問題,標記和清除兩個過程的效率都不高;另外一個是空間問題,標記清除以後會產生大量不連續的內存碎片,空間碎片太多可能會致使之後在程序運行過程當中須要分配較大對象時,沒法找到足夠的連續內存而不得不提早觸發另外一次垃圾收集動做。 標記—清除算法的執行過程以下圖所示。
爲了解決效率問題,一種稱爲「複製」(Copying)的收集算法出現了,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。 當這一塊的內存用完了,就將還存活着的對象複製到另一塊上面,而後再把已使用過的內存空間一次清理掉。 這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜狀況,只要移動堆頂指針,按順序分配內存便可,實現簡單,運行高效。 只是這種算法的代價是將內存縮小爲了原來的一半,未免過高了一點。 複製算法的執行過程以下圖所示。
複製收集算法在對象存活率較高時就要進行較多的複製操做,效率將會變低。 更關鍵的是,若是不想浪費50%的空間,就須要有額外的空間進行分配擔保,以應對被使用的內存中全部對象都100%存活的極端狀況,因此在老年代通常不能直接選用這種算法。根據老年代的特色,有人提出了另一種「標記-整理」(Mark-Compact)算法,標記過程仍然與「標記-清除」算法同樣,但後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存,「標記-整理」算法的示意圖以下圖所示。
Serial收集器是最基本、 發展歷史最悠久的收集器,曾經(在JDK 1.3.1以前)是虛擬機新生代收集的惟一選擇。 你們看名字就會知道,這個收集器是一個單線程的收集器,但它的「單線程」的意義並不只僅說明它只會使用一個CPU或一條收集線程去完成垃圾收集工做,更重要的是在它進行垃圾收集時,必須暫停其餘全部的工做線程,直到它收集結束。 「StopThe World」這個名字也許聽起來很酷,但這項工做其實是由虛擬機在後臺自動發起和自動完成的,在用戶不可見的狀況下把用戶正常工做的線程所有停掉,這對不少應用來講都是難以接受的。 讀者不妨試想一下,要是你的計算機每運行一個小時就會暫停響應5分鐘,你會有什麼樣的心情?以下圖示意了Serial/Serial Old收集器的運行過程。
ParNew收集器其實就是Serial收集器的多線程版本,除了使用多條線程進行垃圾收集以外,其他行爲包括Serial收集器可用的全部控制參數(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、 收集算法、 Stop The World、 對象分配規則、 回收策略等都與Serial收集器徹底同樣,在實現上,這兩種收集器也共用了至關多的代碼。 ParNew收集器的工做過程以下圖所示。
Parallel Scavenge收集器是一個新生代收集器,它也是使用複製算法的收集器,又是並行的多線程收集器……看上去和ParNew都同樣,那它有什麼特別之處呢?
Parallel Scavenge收集器的特色是它的關注點與其餘收集器不一樣,CMS等收集器的關注點是儘量地縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量
(Throughput)。 所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。
停頓時間越短就越適合須要與用戶交互的程序,良好的響應速度能提高用戶體驗,而高吞吐量則能夠高效率地利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不須要太多交互的任務。
Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
MaxGCPauseMillis參數容許的值是一個大於0的毫秒數,收集器將盡量地保證內存回收花費的時間不超過設定值。 不過你們不要認爲若是把這個參數的值設置得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統把新生代調小一些,收集300MB新生代確定比收集500MB快吧,這也直接致使垃圾收集發生得更頻繁一些,原來10秒收集一次、 每次停頓100毫秒,如今變成5秒收集一次、 每次停頓70毫秒。 停頓時間的確在降低,但吞吐量也降下來了。
GCTimeRatio參數的值應當是一個大於0且小於100的整數,也就是垃圾收集時間佔總時間的比率,至關因而吞吐量的倒數。 若是把此參數設置爲19,那容許的最大GC時間就佔總時間的5%(即1/(1+19)),默認值爲99,就是容許最大1%(即1/(1+99))的垃圾收集時間。
因爲與吞吐量關係密切,Parallel Scavenge收集器也常常稱爲「吞吐量優先」收集器。 除上述兩個參數以外,Parallel Scavenge收集器還有一個參數-XX:+UseAdaptiveSizePolicy值得關注。 這是一個開關參數,當這個參數打開以後,就不須要手工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、 晉升老年代對象年齡(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行狀況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱爲GC自適應的調節策略(GC Ergonomics)[1]。 若是讀者對於收集器運做原來不太瞭解,手工優化存在困難的時候,使用Parallel Scavenge收集器配合自適應調節策略,把內存管理的調優任務交給虛擬機去完成將是一個不錯的選擇。 只須要把基本的內存數據設置好(如-Xmx設置最大堆),而後使用axGCPauseMillis參數(更關注最大停頓時間)或GCTimeRatio(更關注吞吐量)參數給虛擬機設立一個優化目標,那具體細節參數的調節工做就由虛擬機完成了。 自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。 目前很大一部分的Java應用集中在互聯網站或者B/S系統的服務端上,這類應用尤爲重視服務的響應速度,但願系統停頓時間最短,以給用戶帶來較好的體驗。 CMS收集器就很是符合這類應用的需求。從名字(包含「Mark Sweep」)上就能夠看出,CMS收集器是基於「標記—清除」算法實現的,它的運做過程相對於前面幾種收集器來講更復雜一些,整個過程分爲4個步驟,包括:
初始標記(CMS initial mark)
併發標記(CMS concurrent mark)
從新標記(CMS remark)
併發清除(CMS concurrent sweep)
其中,初始標記、 從新標記這兩個步驟仍然須要「Stop The World」。 初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,併發標記階段就是進行GC RootsTracing的過程,而從新標記階段則是爲了修正併發標記期間因用戶程序繼續運做而致使標記產生變更的那一部分對象的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,但遠比並發標記的時間短。
因爲整個過程當中耗時最長的併發標記和併發清除過程收集器線程均可以與用戶線程一塊兒工做,因此,從整體上來講,CMS收集器的內存回收過程是與用戶線程一塊兒併發執行的。 經過下圖能夠比較清楚地
看到CMS收集器的運做步驟中併發和須要停頓的時間。
CMS是一款優秀的收集器,它的主要優勢在名字上已經體現出來了:併發收集、 低停頓,Sun公司的一些官方文檔中也稱之爲併發低停頓收集器(Concurrent Low PauseCollector)。
可是CMS還遠達不到完美的程度,它有如下3個明顯的缺點:
CMS收集器對CPU資源很是敏感。 其實,面向併發設計的程序都對CPU資源比較敏感。在併發階段,它雖然不會致使用戶線程停頓,可是會由於佔用了一部分線程(或者說CPU資源)而致使應用程序變慢,總吞吐量會下降。 CMS默認啓動的回收線程數是(CPU數量+3)/4,也就是當CPU在4個以上時,併發回收時垃圾收集線程很多於25%的CPU資源,而且隨着CPU數量的增長而降低。可是當CPU不足4個(譬如2個)時,CMS對用戶程序的影響就可能變得很大,若是原本CPU負載就比較大,還分出一半的運算能力去執行收集器線程,就可能致使用戶程序的執行速度突然下降了50%,其實也讓人沒法接受。 爲了應付這種狀況,虛擬機提供了一種稱爲「增量式併發收集器」(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器變種,所作的事情和單CPU年代PC機操做系統使用搶佔式來模擬多任務機制的思想同樣,就是在併發標記、 清理的時候讓GC線程、 用戶線程交替運行,儘可能減小GC線程的獨佔資源的時間,這樣整個垃圾收集的過程會更長,但對用戶程序的影響就會顯得少一些,也就是速度降低沒有那麼明顯。實踐證實,增量時的CMS收集器效果很通常,在目前版本中,i-CMS已經被聲明爲「deprecated」,即再也不提倡用戶使用。
CMS收集器沒法處理浮動垃圾(Floating Garbage),可能出現「Concurrent ModeFailure」失敗而致使另外一次Full GC的產生。 因爲CMS併發清理階段用戶線程還在運行着,伴隨程序運行天然就還會有新的垃圾不斷產生,這一部分垃圾出如今標記過程以後,CMS沒法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。 這一部分垃圾就稱爲「浮動垃圾」。也是因爲在垃圾收集階段用戶線程還須要運行,那也就還須要預留有足夠的內存空間給用戶線程使用,所以CMS收集器不能像其餘收集器那樣等到老年代幾乎徹底被填滿了再進行收集,須要預留一部分空間提供併發收集時的程序運做使用。 在JDK 1.5的默認設置下,CMS收集器當老年代使用了68%的空間後就會被激活,這是一個偏保守的設置,若是在應用中老年代增加不是太快,能夠適當調高參數-XX:CMSInitiatingOccupancyFraction的值來提升觸發百分比,以便下降內存回收次數從而獲取更好的性能,在JDK 1.6中,CMS收集器的啓動閾值已經提高至92%。 要是CMS運行期間預留的內存沒法知足程序須要,就會出現一次「Concurrent Mode Failure」失敗,這時虛擬機將啓動後備預案:臨時啓用Serial Old收集器來從新進行老年代的垃圾收集,這樣停頓時間就很長了。 因此說參數-XX:CMSInitiatingOccupancyFraction設置得過高很容易致使大量「Concurrent Mode Failure」失敗,性能反而下降。
還有最後一個缺點,在本節開頭說過,CMS是一款基於「標記—清除」算法實現的收集器,若是讀者對前面這種算法介紹還有印象的話,就可能想到這意味着收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,每每會出現老年代還有很大空間剩餘,可是沒法找到足夠大的連續空間來分配當前對象,不得不提早觸發一次FullGC。 爲了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關參數(默認就是開啓的),用於在CMS收集器頂不住要進行FullGC時開啓內存碎片的合併整理過程,內存整理的過程是沒法併發的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機設計者還提供了另一個參數-XX:CMSFullGCsBeforeCompaction,這個參數是用於設置執行多少次不壓縮的Full GC後,跟着來一次帶壓縮的(默認值爲0,表示每次進入FullGC時都進行碎片整理)。
G1(Garbage-First)收集器是當今收集器技術發展的最前沿成果之一,早在JDK 1.7剛剛確立項目目標,Sun公司給出的JDK 1.7 RoadMap裏面,它就被視爲JDK 1.7中HotSpot虛擬機的一個重要進化特徵。 從JDK 6u14中開始就有Early Access版本的G1收集器供開發人員實驗、 試用,由此開始G1收集器的「Experimental」狀態持續了數年時間,直至JDK 7u4,Sun公司才認爲它達到足夠成熟的商用程度,移除了「Experimental」的標識。
G1是一款面向服務端應用的垃圾收集器。 HotSpot開發團隊賦予它的使命是(在比較長期的)將來能夠替換掉JDK 1.5中發佈的CMS收集器。 與其餘GC收集器相比,G1具有以下特色。
並行與併發:G1能充分利用多CPU、 多核環境下的硬件優點,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓的時間,部分其餘收集器本來須要停頓Java線程執行的GC動做,G1收集器仍然能夠經過併發的方式讓Java程序繼續執行。
分代收集:與其餘收集器同樣,分代概念在G1中依然得以保留。 雖然G1能夠不須要其餘收集器配合就能獨立管理整個GC堆,但它可以採用不一樣的方式去處理新建立的對象和已經存活了一段時間、熬過屢次GC的舊對象以獲取更好的收集效果。
空間整合:與CMS的「標記—清理」算法不一樣,G1從總體來看是基於「標記—整理」算法實現的收集器,從局部(兩個Region之間)上來看是基於「複製」算法實現的,但不管如何,
這兩種算法都意味着G1運做期間不會產生內存空間碎片,收集後能提供規整的可用內存。 這種特性有利於程序長時間運行,分配大對象時不會由於沒法找到連續內存空間而提早觸發下一次GC。可預測的停頓:這是G1相對於CMS的另外一大優點,下降停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能創建可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M毫秒的時間片斷內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已是實時Java(RTSJ)的垃圾收集器的特徵了。
在G1以前的其餘收集器進行收集的範圍都是整個新生代或者老年代,而G1再也不是這樣。 使用G1收集器時,Java堆的內存佈局就與其餘收集器有很大差異,它將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代再也不是物理隔離的了,它們都是一部分Region(不須要連續)的集合。
G1收集器之因此能創建可預測的停頓時間模型,是由於它能夠有計劃地避免在整個Java堆中進行全區域的垃圾收集。 G1跟蹤各個Region裏面的垃圾堆積的價值大小(回收所得到的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據容許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。 這種使用Region劃份內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內能夠獲取儘量高的收集效率。
G1把內存「化整爲零」的思路,理解起來彷佛很容易,但其中的實現細節卻遠遠沒有想象中那樣簡單,不然也不會從2004年Sun實驗室發表第一篇G1的論文開始直到今天(將近10年時間)纔開發出G1的商用版。 筆者以一個細節爲例:把Java堆分爲多個Region後,垃圾收集是否就真的能以Region爲單位進行了?聽起來瓜熟蒂落,再仔細想一想就很容易發現問題所在:Region不多是孤立的。 一個對象分配在某個Region中,它並不是只能被本Region中的其餘對象引用,而是能夠與整個Java堆任意的對象發生引用關係。
那在作可達性斷定肯定對象是否存活的時候,豈不是還得掃描整個Java堆才能保證準確性?這個問題其實並不是在G1中才有,只是在G1中更加突出而已。 在之前的分代收集中,新生代的規模通常都比老年代要小許多,新生代的收集也比老年代要頻繁許多,那回收新生代中的對象時也面臨相同的問題,若是回收新生代時也不得不一樣時掃描老年代的話,那麼Minor GC的效率可能降低很多。在G1收集器中,Region之間的對象引用以及其餘收集器中的新生代與老年代之間的對象引用,虛擬機都是使用Remembered Set來避免全堆掃描的。 G1中每一個Region都有一個與之對應的Remembered Set,虛擬機發現程序在對Reference類型的數據進行寫操做時,會產生一個Write Barrier暫時中斷寫操做,檢查Reference引用的對象是否處於不一樣的Region之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),若是是,便經過CardTable把相關引用信息記錄到被引用對象所屬的Region的Remembered Set之中。 當進行內存回收時,在GC根節點的枚舉範圍中加入Remembered Set便可保證不對全堆掃描也不會有遺漏。若是不計算維護Remembered Set的操做,G1收集器的運做大體可劃分爲如下幾個步驟:
初始標記(Initial Marking)
併發標記(Concurrent Marking)
最終標記(Final Marking)
篩選回收(Live Data Counting and Evacuation)
對CMS收集器運做過程熟悉的讀者,必定已經發現G1的前幾個步驟的運做過程和CMS有不少類似之處。 初始標記階段僅僅只是標記一下GC Roots能直接關聯到的對象,而且修改TAMS(NextTop at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的Region中建立新對象,這階段須要停頓線程,但耗時很短。 併發標記階段是從GC Root開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序併發執行。 而最終標記階段則是爲了修正在併發標記期間因用戶程序繼續運做而致使標記產生變更的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs裏面,最終標記階段須要把Remembered Set Logs的數據合併到Remembered Set中,這階段須要停頓線程,可是可並行執行。 最後在篩選回收階段首先對各個Region的回收價值和成本進行排序,根據用戶所指望的GC停頓時間來制定回收計劃,從Sun公司透露出來的信息來看,這個階段其實也能夠作到與用戶程序一塊兒併發執行,可是由於只回收一部分Region,時間是用戶可控制的,並且停頓用戶線程將大幅提升收集效率。 經過下圖能夠比較清楚地看到G1收集器的運做步驟中併發和須要停頓的階段。