天天看點

深入了解Java虛拟機 讀書筆記虛拟機執行子系統

記憶體區域

程式計數器

目前線程所執行的位元組碼的行号訓示器.

位元組碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令.

每條線程都需要有一個獨立的程式計數器.

如果線程正在執行的是一個 Java 方法,這個計數器記錄的是正在執行的虛拟機位元組碼指令的位址;

如果正在執行的是 Native 方法,這個計數器值則為空(Undefined).

Java虛拟機棧

Java虛拟機棧(Java Virtual Machine Stacks)線程私有的,它的生命周期與線程相同.

每個方法在執行的同時都會建立一個棧幀(Stack Frame)用于存儲 局部變量表、操作數棧、動态連結、方法出口 等資訊.

每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛拟機棧中入棧到出棧的過程.

如果線程請求的棧深度大于虛拟機所允許的深度,将抛出 StackOverflowError 異常;

如果虛拟機棧可以動态擴充(目前大部分的Java虛拟機都可動态擴充,隻不過Java虛拟機規範中也允許固定長度的虛拟機棧),如果擴充時無法申請到足夠的記憶體,就會抛出 OutOfMemoryError 異常.

局部變量表

局部變量表存放了編譯期可知的各種基本資料類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等同于對象本身,可能是一個指向對象起始位址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和returnAddress類型(指向了一條位元組碼指令的位址).

其中64位長度的 long 和 double 類型的資料會占用2個局部變量空間(Slot),其餘的資料類型隻占用1個.

局部變量表所需的記憶體空間在編譯期間完成配置設定,當進入一個方法時,這個方法需要在幀中配置設定多大的局部變量空間是完全确定的,在方法運作期間不會改變局部變量表的大小.

本地方法棧

執行 Native 方法.

Hot Spot 虛拟機 直接就把本地方法棧和虛拟機棧合二為一.

會抛出 StackOverflowError 和 OutOfMemoryError 異常.

Java堆

Java堆是被所有線程共享的一塊記憶體區域,在虛拟機啟動時建立.

Java虛拟機規範中的描述是 : 所有的

對象執行個體 以及 數組

都要在堆上配置設定.

随着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上配置設定、标量替換 優化技術将會導緻一些微妙的變化發生,所有的對象都配置設定在堆上也漸漸變得不是那麼"絕對"了.

Java堆中還可以細分為 : 新生代和老年代

再細緻一點的有Eden空間、From Survivor空間、To Survivor空間等

從記憶體配置設定的角度來看,線程共享的Java堆中可能劃分出多個線程私有的配置設定緩沖區(Thread Local Allocation Buffer,TLAB)

如果在堆中沒有記憶體完成執行個體配置設定,并且堆也無法再擴充時,将會抛出 OutOfMemoryError 異常.

方法區

各個線程共享的記憶體區域,它用于存儲已被虛拟機加載的

類資訊、常量、靜态變量、即時編譯器編譯後的代碼

等資料

HotSpot用永久代來實作方法區

JDK 1.7的HotSpot中,已經把原本放在永久代的字元串常量池移出.

當方法區無法滿足記憶體配置設定需求時,将抛出 OutOfMemoryError 異常.

運作時常量池

運作時常量池(Runtime Constant Pool)是方法區的一部分.

Class檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有一項資訊是常量池(Constant Pool Table),

用于存放編譯期生成的各種字面量和符号引用

,這部分内容将在類加載後進入方法區的運作時常量池中存放.

運作時常量池相對于Class檔案常量池的另外一個重要特征是具備動态性,Java語言并不要求常量一定隻有編譯期才能産生,也就是并非預置入Class檔案中常量池的内容才能進入方法區運作時常量池,

運作期間也可能将新的常量放入池中

,這種特性被開發人員利用得比較多的便是String類的intern()方法.

當常量池無法再申請到記憶體時會抛出 OutOfMemoryError 異常

String.intern()字元串常量池,1.6在方法區,1.7在堆裡

String.intern()是一個Native方法,

它的作用是 : 如果字元串常量池中已經包含一個等于此 String對象的字元串,則傳回代表池中這個字元串的String對象;

否則,将此 String對象包含的字元串添加到常量池中,并且傳回此String對象的引用.

intern用來傳回常量池中的某字元串,如果常量池中已經存在該字元串,則直接傳回常量池中該對象的引用。否則,在常量池中加入該對象,然後 傳回引用。

在jdk1.7之前,字元串常量存儲在方法區的PermGen Space。在jdk1.7之後,字元串常量重新被移到了堆中

String.intern方法在JDK6和JDK7的差別

産生差異的原因是 :

在 JDK 1.6中 String.intern()方法會把首次遇到的字元串執行個體複制到永久代中,傳回的也是永久代中這個字元串執行個體的引用,

而由 StringBuilder建立的字元串執行個體在 Java堆上,是以必然不是同一個引用,将傳回 false.

而 JDK 1.7的 String.intern()實作不會再複制執行個體,隻是在常量池中記錄首次出現的執行個體引用,

是以 intern()傳回的引用和由 StringBuilder建立的那個字元串執行個體是同一個.

直接記憶體(Direct Memory)

NIO使用Native函數庫直接配置設定堆外記憶體,然後通過一個存儲在Java堆中的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作.這樣能在一些場景中顯著提高性能,因為避免了在Java堆和Native堆中來回複制資料.

對象

對象記憶體配置設定的線程安全問題

一種是對配置設定記憶體空間的動作進行同步處理——實際上虛拟機

采用CAS 配上 失敗重試

的方式保證更新操作的原子性;

另一種是

把記憶體配置設定的動作 按照線程 劃分在不同的空間之中進行

,即每個線程在Java堆中預先配置設定一小塊記憶體,稱為

本地線程配置設定緩沖(Thread Local Allocation Buffer,TLAB)

.哪個線程要配置設定記憶體,就在哪個線程的TLAB上配置設定,隻有TLAB用完并配置設定新的TLAB時,才需要同步鎖定.虛拟機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定.

對象建立流程

new指令

->

檢查這個指令的參數是否能在常量池中定位到一個類的符号引用,并且檢查這個符号引用代表的類是否已被加載、解析和初始化過.

->

若未加載,則執行相應的類加載過程.

->

為新生對象配置設定記憶體,對象所需記憶體的大小在類加載完成後便可完全确定.

1.堆記憶體配置設定的算法(如CMS, G1);

2.堆記憶體配置設定的線程安全問題,CAS或TLAB解決

->

将配置設定到的記憶體空間都初始化為零值(不包括對象頭).

->

對對象進行必要的設定,

例如這個對象是哪個類的執行個體、如何才能找到類的中繼資料資訊、對象的哈希碼、對象的GC分代年齡等資訊.這些資訊存放在對象的對象頭(Object Header)之中.

根據虛拟機目前的運作狀态的不同,如是否啟用偏向鎖等,對象頭會有不同的設定方式

->

執行

<init>

方法

對象的記憶體布局

對象在記憶體中存儲的布局可以分為3塊區域 : 對象頭(Header)、執行個體資料(Instance Data) 和 對齊填充(Padding)

對象頭

對象頭包括兩部分資訊,

第一部分 用于

存儲對象自身的運作時資料,即"Mark Word"

.

用于存儲如 哈希碼(HashCode)、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳 等,

這部分資料的長度在32位和64位的虛拟機(未開啟壓縮指針)中分别為 32bit 和 64bit.

對象頭的另外一部分 是

類型指針,即對象指向它的類中繼資料的指針

,虛拟機通過這個指針來确定這個對象是哪個類的執行個體.

執行個體資料

執行個體資料部分是對象真正存儲的有效資訊,也是在程式代碼中所定義的各種類型的字段内容.

對象的通路定位

Java棧中的本地變量表(引用) -> 指向Java堆中的對象執行個體資料

Java堆中的對象執行個體資料中的 對象類型資料的指針 -> 指向方法區中的對象類型資料

方法區OOM

CGLib這類位元組碼技術對類進行增強時,增強的類越多,就需要越大的方法區來保證動态生成的Class可以加載入記憶體.

直接記憶體溢出

由DirectMemory導緻的記憶體溢出,一個明顯的特征是在Heap Dump檔案中不會看見明顯的異常,

若發現OOM之後Dump檔案很小,而程式中又直接或間接使用了NIO,那就可以考慮檢查一下是不是這方面的原因.

垃圾回收

程式計數器、虛拟機棧、本地方法棧3個區域随線程而生,随線程而滅.

棧中的棧幀随着方法的進入和退出而有條不紊地執行着出棧和入棧操作.每一個棧幀中配置設定多少記憶體基本上是在類結構确定下來時就已知的,方法結束或者線程結束時,記憶體自然就跟随着回收了.不需要過多考慮記憶體回收問題.

而Java堆和方法區則不一樣,一個接口中的多個實作類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們隻有在程式處于運作期間時才能知道會建立哪些對象,這部分記憶體的配置設定和回收都是動态的,垃圾收集器所關注的是這部分記憶體.

可達性分析算法

通過一系列的稱為"GC Roots"的對象作為起始點,從這些節點開始向下搜尋,

搜尋所走過的路徑稱為引用鍊(Reference Chain),

當一個對象到GC Roots沒有任何引用鍊相連(用圖論的話來說,即從GC Roots到這個對象不可達)時,則證明此對象是不可用的

引用分類

強引用(Strong Reference)

Object obj = new Object(),隻要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象

軟引用(Soft Reference)

系統将要發生記憶體溢出異常之前,将會把這些對象列進回收範圍之中進行第二次回收.

如果這次回收還沒有足夠的記憶體,才會抛出記憶體溢出異常

弱引用(Weak Reference)

被弱引用關聯的對象隻能生存到下一次垃圾收集發生之前.當垃圾收集器工作時,無論目前記憶體是否足夠,都會回收掉隻被弱引用關聯的對象

虛引用(Phantom Reference)

能在這個對象被收集器回收時收到一個系統通知

回收方法區

即HotSpot虛拟機中的永久代

主要回收兩部分内容 :

廢棄常量和無用的類

判斷常量池中廢棄常量的方式:沒有引用在引用這個常量(如字元串常量)

判斷方法區中無用的類的方式:

該類所有的執行個體都已經被回收,也就是Java堆中不存在該類的任何執行個體.

加載該類的ClassLoader已經被回收.

該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射通路該類的方法.

垃圾收集算法

“标記-清除”(Mark-Sweep)算法

首先标記出所有需要回收的對象,在标記完成後統一回收所有被标記的對象

不足有兩個 :

一個是效率問題,标記和清除兩個過程的效率都不高;

另一個是空間問題,标記清除之後會産生大量不連續的

記憶體碎片

,空間碎片太多可能會導緻以後在程式運作過程中需要配置設定較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作.

複制算法

将可用記憶體按容量劃分為大小相等的兩塊,每次隻使用其中的一塊.

當這一塊的記憶體用完了,就将還存活着的對象複制到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉.

可用于回收新生代

将記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間

HotSpot虛拟機預設 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),隻有10%的記憶體會被"浪費".

标記-整理算法

根據老年代的特點,“标記-整理”(Mark-Compact)算法

标記過程仍然與"标記-清除"算法一樣 : 首先标記出所有需要回收的對象,

但後續步驟不是直接對可回收對象進行清理,而是

讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體.

分代收集算法

根據對象存活周期的不同将記憶體劃分為幾塊.

一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最适當的收集算法.

在新生代中,每次垃圾收集時都發現有大批對象死去,隻有少量存活,那就選用複制算法,隻需要付出少量存活對象的複制成本就可以完成收集.

而老年代中因為對象存活率高、沒有額外空間對它進行配置設定擔保,就必須使用"标記—清理"或者"标記—整理"算法來進行回收.

HotSpot算法實作

可達性分析對執行時間的敏感還展現在GC停頓上,

因為這項分析工作必須在一個能確定一緻性的快照中進行,

這裡"一緻性"的意思是指在整個分析期間整個執行系統看起來就像被當機在某個時間點上,

不可以出現分析過程中對象引用關系還在不斷變化的情況,

該點不滿足的話分析結果準确性就無法得到保證.

這點是導緻GC進行時必須停頓所有Java執行線程(Sun将這件事情稱為"Stop The World")的其中一個重要原因

OopMap的資料結構,在OopMap的協助下,HotSpot可以快速且準确地完成GC Roots枚舉

安全點

程式執行時并非在所有地方都能停頓下來開始GC,隻有在到達安全點時才能暫停

安全點的標明基本上是以程式"是否具有讓程式長時間執行的特征"為标準進行標明的

長時間執行"的最明顯特征就是指令序列複用,例如方法調用、循環跳轉、異常跳轉等,是以具有這些功能的指令才會産生Safepoint.

對于Sefepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這裡不包括執行JNI調用的線程)都"跑"到最近的安全點上再停頓下來.

這裡有兩種方案可供選擇 :

搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)

搶先式中斷: 不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢複線程,讓它"跑"到安全點上.現在幾乎沒有虛拟機實作采用搶先式中斷來暫停線程進而響應GC事件.

主動式中斷: 當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設定一個标志,各個線程執行時主動去輪詢這個标志,發現中斷标志為真時就自己中斷挂起.輪詢标志的地方和安全點是重合的,另外再加上建立對象需要配置設定記憶體的地方.

安全區域

安全區域是指在一段代碼片段之中,引用關系不會發生變化.在這個區域中的任意地方開始GC都是安全的.我們也可以把Safe Region看做是被擴充了的Safepoint.

垃圾收集器

深入了解Java虛拟機 讀書筆記虛拟機執行子系統

Serial收集器

單線程的收集器

它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束.

ParNew收集器

ParNew收集器其實就是Serial收集器的多線程版本

新生代采取 複制算法 暫定所有使用者線程進行垃圾回收

老年代采取 标記-整理算法 暫停所有使用者線程進行垃圾回收

Parallel Scavenge收集器

Parallel Scavenge收集器是一個新生代收集器,它也是使用複制算法的收集器,又是并行的多線程收集器

Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,

CMS等收集器的關注點是盡可能地縮短垃圾收集時使用者線程的停頓時間,而Parallel Scavenge收集器的目标則是達到一個可控制的吞吐量(Throughput).

所謂吞吐量就是CPU用于運作使用者代碼的時間與CPU總消耗時間的比值,即吞吐量=運作使用者代碼時間/(運作使用者代碼時間+垃圾收集時間),虛拟機總共運作了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%.

停頓時間越短就越适合需要與使用者互動的程式,良好的響應速度能提升使用者體驗,而高吞吐量則可以高效率地利用CPU時間,盡快完成程式的運算任務,主要适合在背景運算而不需要太多互動的任務.

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用"标記-整理"算法.

它還作為CMS收集器的後備預案,在并發收集發生Concurrent Mode Failure時使用.

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多線程和"标記-整理"算法.

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以擷取最短回收停頓時間為目标的收集器.

響應速度快,希望系統停頓時間最短

基于"标記—清除"算法實作

過程分為4個步驟,包括 :

初始标記(CMS initial mark)

并發标記(CMS concurrent mark)

重新标記(CMS remark)

并發清除(CMS concurrent sweep)

初始标記、重新标記這兩個步驟仍然需要"Stop The World".

初始标記僅僅隻是标記一下GC Roots能直接關聯到的對象,速度很快

并發标記階段就是進行GC RootsTracing的過程

重新标記階段則是為了修正并發标記期間因使用者程式繼續運作而導緻标記産生變動的那一部分對象的标記記錄,這個階段的停頓時間一般會比初始标記階段稍長一些,但遠比并發标記的時間短.

由于整個過程中耗時最長的并發标記和并發清除過程收集器線程都可以與使用者線程一起工作.是以,從總體上來說,CMS收集器的記憶體回收過程是與使用者線程一起并發執行的

深入了解Java虛拟機 讀書筆記虛拟機執行子系統

缺點:

  1. 在并發階段,它雖然不會導緻使用者線程停頓,但是會因為占用了一部分線程(或者說CPU資源)而導緻應用程式變慢,總吞吐量會降低.
  2. CMS收集器無法處理浮動垃圾(Floating Garbage),可能出現"Concurrent Mode Failure"失敗而導緻另一次Full GC的産生.

    由于CMS并發清理階段使用者線程還在運作着,伴随程式運作自然就還會有新的垃圾不斷産生,這一部分垃圾出現在标記過程之後,CMS無法在當次收集中處理掉它們,隻好留待下一次GC時再清理掉.

    這一部分垃圾就稱為"浮動垃圾".也是由于在垃圾收集階段使用者線程還需要運作,那也就還需要預留有足夠的記憶體空間給使用者線程使用,是以CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供并發收集時的程式運作使用.

    要是CMS運作期間預留的記憶體無法滿足程式需要,就會出現一次"Concurrent Mode Failure"失敗,這時虛拟機将啟動後備預案 : 臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了.

  3. CMS是一款基于"标記—清除"算法實作的收集器,收集結束時會有大量空間碎片産生.空間碎片過多時,将會給大對象配置設定帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來配置設定目前對象,不得不提前觸發一次Full GC.CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關參數(預設就是開啟的),用于在CMS收集器頂不住要進行FullGC時開啟記憶體碎片的合并整理過程,記憶體整理的過程是無法并發的.

G1收集器

G1能充分利用多CPU,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過并發的方式讓Java程式繼續執行.

G1從整體來看是基于"标記—整理"算法實作的收集器,從局部(兩個Region之間)上來看是基于"複制"算法實作的,但無論如何,這兩種算法都意味着G1運作期間不會産生記憶體空間碎片,收集後能提供規整的可用記憶體.

Java堆的記憶體布局就與其他收集器有很大差别,它将整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是實體隔離的了,它們都是一部分Region(不需要連續)的集合.

G1收集器之是以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集.G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在背景維護一個優先清單,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由).這種使用Region劃分記憶體空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間内可以擷取盡可能高的收集效率.

GC日志

GC日志開頭的"[GC"和"[Full GC"說明了這次垃圾收集的停頓類型,而不是用來區分新生代GC還是老年代GC的.如果有"Full",說明這次GC是發生了Stop-The-World的

JVM記憶體管理的作用

Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題 :

給對象配置設定記憶體 以及 回收配置設定給對象的記憶體

.

對象配置設定

對象

主要配置設定在新生代的Eden區上

,如果啟動了本地線程配置設定緩沖,将按線程優先在TLAB上配置設定.

少數情況下也可能會直接配置設定在老年代中.

大多數情況下,對象在新生代Eden區中配置設定.當Eden區沒有足夠空間進行配置設定時,虛拟機将發起一次Minor GC.

新生代GC(Minor GC)

: 指發生在新生代的垃圾收集動作,因為Java對象大多都具備朝生夕滅的特性,是以Minor GC非常頻繁,一般回收速度也比較快.

老年代GC(Major GC/Full GC)

: 指發生在老年代的GC,出現了Major GC,經常會伴随至少一次的Minor GC.Major GC的速度一般會比Minor GC慢10倍以上.

大對象直接進入老年代

所謂的大對象是指,需要大量連續記憶體空間的Java對象,最典型的大對象就是那種很長的字元串以及數組.

虛拟機提供了一個-XX:PretenureSizeThreshold參數,令大于這個設定值的對象直接在老年代配置設定.這樣做的目的是避免在

Eden區及兩個Survivor區之間發生大量的記憶體複制.

長期存活的對象将進入老年代

既然虛拟機采用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識别哪些對象應放在新生代,哪些對象應放在老年代中.

為了做到這點,虛拟機給每個對象定義了一個對象年齡(Age)計數器.

如果對象在 Eden 出生并經過第一次 Minor GC 後仍然存活,并且能被 Survivor 容納的話,将被移動到 Survivor 空間中,并且對象年齡設為1.

對象在 Survivor 區中每"熬過"一次 Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲),就将會被晉升到老年代中.對象晉升老年代的年齡門檻值,可以通過參數-XX:MaxTenuringThreshold設定.

動态對象年齡判定

為了能更好地适應不同程式的記憶體狀況,虛拟機并不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,

如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡.

空間配置設定擔保

在發生Minor GC之前,虛拟機會先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確定是安全的.

如果不成立,則虛拟機會檢視HandlePromotionFailure設定值是否允許擔保失敗.

如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大于曆次晉升到老年代對象的平均大小,如果大于,将嘗試着進行一次Minor GC,盡管這次Minor GC是有風險的;

如果小于,或者HandlePromotionFailure設定不允許冒險,那這時也要改為進行一次Full GC.

監控工具

資料包括 :

運作日志、異常堆棧、GC日志、線程快照(threaddump/javacore檔案)、堆轉儲快照(heapdump/hprof檔案)等

名稱 主要作用
JPS JVM Process Status Tool, 顯示指定系統内所有的HotSpot虛拟機程序
jstat JVM Statistics Monitoring Tool,用于收集HotSpot虛拟機各方面的運作資料
jinfo Configuration Info for Java,顯示虛拟機配置資訊
jmap Memory Map for Java,生成虛拟機的記憶體轉儲快照(heapdump檔案)
jhat JVM Heap Dump Browser,用于分析heapdump檔案,它會建立一個HTTP/HTML伺服器,讓使用者能檢視分析結果
jstak Stack Trace for Java,顯示虛拟機的線程快照

可視化工具

JConsole(Java Monitoring and Management Console)是一種基于JMX的可視化監視、管理工具.它管理部分的功能是針對JMX MBean進行管理

VisualVM : 多合一故障處理工具

工具->插件安裝

虛拟機執行子系統

類檔案結構

Java虛拟機隻與"Class檔案"這種特定的二進制檔案格式所關聯,Class檔案中包含了Java虛拟機指令集和符号表以及若幹其他輔助資訊.

任何一個Class檔案都對應着唯一一個類或接口的定義資訊,但反過來說,類或接口并不一定都得定義在檔案裡(譬如類或接口也可以通過類加載器直接生成)

Class檔案是一組以8位位元組為基礎機關的二進制流,各個資料項目嚴格按照順序緊湊地排列在Class檔案之中,中間沒有添加任何分隔符.

當遇到需要占用8位位元組以上空間的資料項時,則會按照高位在前的方式分割成若幹個8位位元組進行存儲.

這種僞結構中隻有兩種資料類型 : 無符号數和表.

無符号數屬于基本的資料類型,以u1、u2、u4、u8來分别代表1個位元組、2個位元組、4個位元組和8個位元組的無符号數.

表是由多個無符号數或者其他表作為資料項構成的複合資料類型,所有表都習慣性地以"_info"結尾.表用于描述有層次關系的複合結構的資料,

整個Class檔案本質上就是一張表

.

魔數與Class檔案的版本

每個Class檔案的頭4個位元組稱為魔數(Magic Number),它的唯一作用是确定這個檔案是否為一個能被虛拟機接受的Class檔案.

緊接着魔數的4個位元組存儲的是Class檔案的版本号 : 第5和第6個位元組是次版本号(Minor Version),第7和第8個位元組是主版本号(Major Version).

高版本的JDK能向下相容以前版本的Class檔案,但不能運作以後版本的Class檔案.

常量池

緊接着主次版本号之後的是常量池入口.

常量池可以了解為Class檔案之中的資源倉庫,它是Class檔案結構中與其他項目關聯最多的資料類型,也是占用Class檔案空間最大的資料項目之一,同時它還是在Class檔案中第一個出現的表類型資料項目.

由于常量池中常量的數量是不固定的,是以在常量池的入口需要放置一項u2類型的資料,代表常量池容量計數值(constant_pool_count)

常量池中主要存放兩大類常量 :

字面量(Literal)和符号引用(Symbolic References).

  1. 字面量比較接近于Java語言層面的常量概念,如文本字元串、聲明為final的常量值等.
  2. 符号引用則屬于編譯原理方面的概念,包括了下面三類常量 :

    類和接口的全限定名(Fully Qualified Name)

字段的名稱和描述符(Descriptor)

方法的名稱和描述符

在Class檔案中不會儲存各個方法、字段的最終記憶體布局資訊,是以這些字段、方法的符号引用不經過運作期轉換的話無法得到真正的記憶體入口位址,也就無法直接被虛拟機使用.

當虛拟機運作時,需要從常量池獲得對應的符号引用,再在類建立時或運作時解析、翻譯到具體的記憶體位址之中.

常量池中每一項常量都是一個表

用 javap 檢視 Class 檔案能看到 常量池資料.

通路标志

在常量池結束之後,緊接着的兩個位元組代表通路标志(access_flags),這個标志用于識别一些類或者接口層次的通路資訊,

包括 : 這個 Class 是類還是接口;是否定義為 public 類型;是否定義為 abstract 類型;如果是類的話,是否被聲明為 final 等.

類索引、父類索引與接口索引集合

類索引(this_class)和父類索引(super_class)都是一個u2類型的資料,而接口索引集合(interfaces)是一組u2類型的資料的集合,Class檔案中由這三項資料來确定這個類的繼承關系.

類索引用于确定這個類的全限定名,父類索引用于确定這個類的父類的全限定名.

由于Java語言不允許多重繼承,是以父類索引隻有一個,除了java.lang.Object之外,所有的Java類都有父類,是以除了java.lang.Object外,所有Java類的父類索引都不為0.接口索引集合就用來描述這個類實作了哪些接口,這些被實作的接口将按implements語句(如果這個類本身是一個接口,則應當是extends語句)後的接口順序從左到右排列在接口索引集合中.

字段表集合

字段表(field_info)用于描述接口或者類中聲明的變量.

字段(field)包括類級變量以及執行個體級變量,但不包括在方法内部聲明的局部變量.

我們可以想一想在Java中描述一個字段可以包含什麼資訊?可以包括的資訊有 : 字段的作用域(public、private、protected修飾符)、是執行個體變量還是類變量(static修飾符)、可變性(final)、并發可見性(volatile修飾符,是否強制從主記憶體讀寫)、可否被序列化(transient修飾符)、字段資料類型(基本類型、對象、數組)、字段名稱.

方法表集合

Class檔案存儲格式中對方法的描述與對字段的描述幾乎采用了完全一緻的方式,方法表的結構如同字段表一樣,依次包括了通路标志(access_flags)、名稱索引(name_index)、描述符索引(descriptor_index)、屬性表集合(attributes)幾項

屬性表集合

屬性表(attribute_info)在前面的講解之中已經出現過數次,在Class檔案、字段表、方法表都可以攜帶自己的屬性表集合,以用于描述某些場景專有的資訊.

位元組碼指令

Java虛拟機的指令由一個位元組長度的、代表着某種特定操作含義的數字(稱為操作碼,Opcode)以及跟随其後的零至多個代表此操作所需參數(稱為操作數,Operands)而構成.

缺點: 由于限制了Java虛拟機操作碼的長度為一個位元組(即0~255),這意味着指令集的操作碼總數不可能超過256條

優點: 用一個位元組來代表操作碼,也是為了盡可能獲得短小精幹的編譯代碼.

虛拟機類加載機制

在Java語言裡面,類型的加載、連接配接和初始化過程都是在程式運作期間完成的,這種政策雖然會令類加載時稍微增加一些性能開銷,但是會為Java應用程式提供高度的靈活性,Java裡天生可以動态擴充的語言特性就是依賴運作期動态加載和動态連接配接這個特點實作的.

類加載的時機

類從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期包括 :

加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)7個階段

.

其中驗證、準備、解析3個部分統稱為連接配接(Linking)

深入了解Java虛拟機 讀書筆記虛拟機執行子系統

類加載時機:由虛拟機實作決定.

類初始化的時機

  1. 使用new關鍵字執行個體化對象的時候、讀取或設定一個類的靜态字段(被final修飾、已在編譯期把結果放入常量池的靜态字段除外)的時候,以及調用一個類的靜态方法的時候.
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化.
  3. 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化.
  4. 當虛拟機啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛拟機會先初始化這個主類.

    等等…

接口的初始化差別

當一個類在初始化時,要求其父類全部都已經初始化過了,

但是一個接口在初始化時,

并不要求其父接口全部都完成了初始化

,隻有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化.

類加載的過程

Java虛拟機中類加載的全過程,也就是

加載、驗證、準備、解析和初始化

這5個階段所執行的具體動作

加載

在加載階段(加載階段是類加載的一個階段),虛拟機需要完成以下3件事情 :

  1. 通過一個類的全限定名來擷取定義此類的二進制位元組流.
  2. 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構.
  3. 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口.
加載流程

數組類本身不通過類加載器建立,它是由Java虛拟機直接建立的.但數組類的元素類型(Element Type,指的是數組去掉所有次元的類型)最終是要靠類加載器去建立.

非數組類的加載,既可以使用系統提供的引導類加載器來完成,也可以由使用者自定義的類加載器去完成.

加載階段完成後,虛拟機外部的二進制位元組流就按照虛拟機所需的格式存儲在方法區之中.

然後在記憶體中執行個體化一個java.lang.Class類的對象(并沒有明确規定是在Java堆中,HotSpot的Class對象存放在方法區裡面),這個對象将作為程式通路方法區中的這些類型資料的外部接口.

加載過程中擷取Class二進制流

Java虛拟機規範沒有指明二進制位元組流要從一個Class檔案中擷取,準确地說是根本沒有指明要從哪裡擷取、怎樣擷取.

從ZIP包中讀取,這很常見,最終成為日後JAR、EAR、WAR格式的基礎.

從網絡中擷取,這種場景最典型的應用就是Applet.

運作時計算生成,這種場景使用得最多的就是動态代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來為特定接口生成形式為"*$Proxy"的代理類的二進制位元組流.

由其他檔案生成,典型場景是JSP應用,即由JSP檔案生成對應的Class類.

驗證

驗證是連接配接階段的第一步,這一階段的目的是為了確定Class檔案的位元組流中包含的資訊符合目前虛拟機的要求,并且不會危害虛拟機自身的安全.

驗證階段大緻上會完成下面4個階段的檢驗動作 :

檔案格式驗證、中繼資料驗證、位元組碼驗證、符号引用驗證

.

  1. 檔案格式驗證

    驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理.

  2. 中繼資料驗證

    對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求

  3. 位元組碼驗證
  4. 符号引用驗證

準備

準備階段是正式為類變量配置設定記憶體并設定類變量初始值的階段,這些變量所使用的記憶體都将在方法區中進行配置設定.

這時候進行記憶體配置設定的僅包括類變量(被static修飾的變量),而不包括執行個體變量,執行個體變量将會在對象執行個體化時随着對象一起配置設定在Java堆中.

這裡所說的初始值"通常情況"下是資料類型的零值,

如 public static int value=123; 那變量value在準備階段過後的初始值為0而不是123,

因為這時候尚未開始執行任何Java方法,而把value指派為123的putstatic指令是程式被編譯後,存放于類構造器

<clinit>()

方法之中,是以把value指派為123的動作将在初始化階段才會執行

若是 public static final int value = 123; 則此時已經初始化為123

解析

解析階段是虛拟機将常量池内的符号引用替換為直接引用的過程

符号引用(Symbolic References) : 符号引用以一組符号來描述所引用的目标

直接引用(Direct References) : 直接引用可以是直接指向目标的指針 等

初始化

到了初始化階段,才真正開始執行類中定義的Java程式代碼(或者說是位元組碼)

在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程式員通過程式制定的主觀計劃去初始化類變量和其他資源

或者可以從另外一個角度來表達 : 初始化階段是執行類構造器<clinit>()方法的過程

<clinit>()

方法是由編譯器自動收集類中的所有類變量的指派動作和靜态語句塊(static{}塊)中的語句合并産生的,編譯器收集的順序是由語句在源檔案中出現的順序所決定的

<clinit>()

方法與類的構造函數(或者說執行個體構造器

<init>()

方法)不同,它不需要顯式地調用父類構造器,虛拟機會保證在子類的

<clinit>()

方法執行之前,父類的

<clinit>()

方法已經執行完畢.是以在虛拟機中第一個被執行的

<clinit>()

方法的類肯定是java.lang.Object

由于父類的

<clinit>()

方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作

<clinit>()

方法對于類或接口來說并不是必需的,如果一個類中沒有靜态語句塊,也沒有對變量的指派操作,那麼編譯器可以不為這個類生成

<clinit>()

方法

接口中不能使用靜态語句塊,但仍然有變量初始化的指派操作,是以接口與類一樣都會生成

<clinit>()

方法.但接口與類不同的是,執行接口的

<clinit>()

方法不需要先執行父接口的

<clinit>()

方法.隻有當父接口中定義的變量使用時,父接口才會初始化.另外,接口的實作類在初始化時也一樣不會執行接口的

<clinit>()

方法.

虛拟機會保證一個類的

<clinit>()

方法在多線程環境中被正确地加鎖、同步,如果多個線程同時去初始化一個類,那麼隻會有一個線程去執行這個類的

<clinit>()

方法,其他線程都需要阻塞等待,直到活動線程執行

<clinit>()

方法完畢.如果在一個類的

<clinit>()

方法中有耗時很長的操作,就可能造成多個程序阻塞

其他線程雖然會被阻塞,但如果執行

<clinit>()

方法的那條線程退出

<clinit>()

方法後,其他線程喚醒之後不會再次進入

<clinit>()

方法.同一個類加載器下,一個類型隻會初始化一次

類加載器

虛拟機設計團隊把類加載階段中的"

通過一個類的全限定名來擷取描述此類的二進制位元組流

“這個動作放到Java虛拟機外部去實作,以便讓應用程式自己決定如何去擷取所需要的類.實作這個動作的代碼子產品稱為"類加載器”.

比較兩個類是否"相等",隻有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class檔案,被同一個虛拟機加載,隻要加載它們的類加載器不同,那這兩個類就必定不相等.

這裡所指的"相等",包括代表類的Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的傳回結果,也包括使用instanceof關鍵字做對象所屬關系判定等情況

雙親委派模型

從Java虛拟機的角度來講,隻存在兩種不同的類加載器 :

一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實作,是虛拟機自身的一部分;

另一種就是所有其他的類加載器,這些類加載器都由Java語言實作,獨立于虛拟機外部,并且全都繼承自抽象類java.lang.ClassLoader.

深入了解Java虛拟機 讀書筆記虛拟機執行子系統

應用程式類加載器(Application ClassLoader),它負責加載使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程式中沒有自定義過自己的類加載器,一般情況下這個就是程式中預設的類加載器.

雙親委派模型的工作過程是 : 如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求(它的搜尋範圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載.

雙親委派模型的破壞
  1. JNDI現在已經是Java的标準服務,

    它的代碼

    由啟動類加載器去加載

    (在JDK 1.3時放進去的rt.jar),

    JNDI的目的就是對資源進行集中管理和查找,它需要調用由獨立廠商實作并部署在應用程式的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代碼

    ,但啟動類加載器不可能"認識"這些代碼啊!那該怎麼辦?

為了解決這個問題,Java設計團隊隻好引入了一個不太優雅的設計 :

線程上下文類加載器(Thread Context ClassLoader)

.這個類加載器可以通過java.lang.Thread類的setContextClassLoaser()方法進行設定,如果建立線程時還未設定,它将會從父線程中繼承一個,如果在應用程式的全局範圍内都沒有設定過的話,那這個類加載器預設就是應用程式類加載器.

有了線程上下文類加載器,就可以做一些"舞弊"的事情了,JNDI服務使用這個線程上下文類加載器去加載所需要的SPI代碼,也就是父類加載器請求子類加載器去完成類加載的動作,這種行為實際上就是打通了雙親委派模型的層次結構來逆向使用類加載器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情.Java中所有涉及SPI的加載動作基本上都采用這種方式,例如JNDI、JDBC、JCE、JAXB和JBI等.

  1. OSGi

    OSGi實作子產品化熱部署的關鍵則是它自定義的類加載器機制的實作.每一個程式子產品(OSGi中稱為Bundle)都有一個自己的類加載器,當需要更換一個Bundle時,就把Bundle連同類加載器一起換掉以實作代碼的熱替換.

在OSGi環境下,類加載器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加複雜的網狀結構.

虛拟機位元組碼執行引擎

運作時棧幀結構

棧幀(Stack Frame)是用于支援虛拟機進行方法調用和方法執行的資料結構.

棧幀存儲了方法的局部變量表、操作數棧、動态連接配接和方法傳回位址等資訊.

每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛拟機棧裡面從入棧到出棧的過程.

執行引擎運作的所有位元組碼指令都隻針對目前棧幀進行操作.

局部變量表

局部變量表(Local Variable Table)是一組變量值存儲空間,用于存放方法參數和方法内部定義的局部變量.

操作數棧

當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種位元組碼指令往操作數棧中寫入和提取内容,也就是出棧/入棧操作.

例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的.

動态連接配接

每個棧幀都包含一個指向運作時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連接配接(Dynamic Linking).

Class檔案的常量池中存有大量的符号引用,位元組碼中的方法調用指令就以常量池中指向方法的符号引用作為參數.

這些符号引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜态解析.

另外一部分将在每一次運作期間轉化為直接引用,這部分稱為動态連接配接.

方法傳回位址

當一個方法開始執行後,隻有兩種方式可以退出這個方法.

第一種方式是執行引擎遇到任意一個方法傳回的位元組碼指令,這時候可能會有傳回值傳遞給上層的方法調用者(調用目前方法的方法稱為調用者),是否有傳回值和傳回值的類型将根據遇到何種方法傳回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion).

另外一種退出方式是,在方法執行過程中遇到了異常,并且這個異常沒有在方法體内得到處理,無論是Java虛拟機内部産生的異常,還是代碼中使用athrow位元組碼指令産生的異常,隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion).一個方法使用異常完成出口的方式退出,是不會給它的上層調用者産生任何傳回值的.

無論采用何種退出方式,在方法退出之後,都需要傳回到方法被調用的位置,程式才能繼續執行,方法傳回時可能需要在棧幀中儲存一些資訊,用來幫助恢複它的上層方法的執行狀态.

一般來說,方法正常退出時,調用者的PC計數器的值可以作為傳回位址,棧幀中很可能會儲存這個計數器值.而方法異常退出時,傳回位址是要通過異常處理器表來确定的,棧幀中一般不會儲存這部分資訊.

方法退出的過程實際上就等同于把目前棧幀出棧,是以退出時可能執行的操作有 : 恢複上層方法的局部變量表和操作數棧,把傳回值(如果有的話)壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等.

程式編譯與代碼優化

早期(編譯期)優化

前端編譯器 把*.java檔案轉變成*.class檔案的過程(javac)

虛拟機的後端運作期編譯器(JIT編譯器,Just In Time Compiler)把位元組碼轉變成機器碼的過程

靜态提前編譯器(AOT編譯器,Ahead Of Time Compiler)直接把*.java檔案編譯成本地機器代碼的過程