秋春招總結之JVM系列全解

2020-08-13 15:25:28

文章目錄

JVM

Java記憶體區域

基礎認知 :JVM 是可執行 Java 程式碼的假想計算機 ,並不是真實存在的。它包括一套位元組碼指令集、一組暫存器、一個棧、一個垃圾回收,堆 和 一個儲存方法域。JVM 是執行在操作系統之上的,它與硬體並沒有直接的互動。

主要的記憶體區域(見下圖)

JVM 記憶體區域主要分爲執行緒私有區域【程式計數器、虛擬機器棧、本地方法區】、執行緒共用區域【JAVA 堆、方法區】、直接記憶體。

執行緒私有區域

對於生命週期的考量要理解清楚。

執行緒私有數據區域生命週期與執行緒相同, 依賴使用者執行緒的啓動/結束 而 建立/銷燬(在 Hotspot VM 內, 每個執行緒都與操作系統的本地執行緒直接對映, 因此這部分記憶體區域的存/否跟隨本地執行緒的生/死對應)。

執行緒公有區域

執行緒共用區域隨虛擬機器的啓動/關閉而建立/銷燬。

程式計數器(執行緒私有)

是一塊比較小的記憶體單元。``是當前執行緒所執行的位元組碼的行號指示器`,每條執行緒都要有一個獨立的程式計數器,這類記憶體也稱爲「執行緒私有」的記憶體。

虛擬機器棧(執行緒私有)

是描述java方法執行的記憶體模型,每個方法在執行的同時都會建立一個棧幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態鏈接、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應着一個棧幀在虛擬機器棧中入棧到出棧的過程。**棧幀( Frame)**是用來儲存數據和部分過程結果的數據結構,同時也被用來處理動態鏈接(Dynamic Linking)、 方法返回值和異常分派( Dispatch Exception)。棧幀隨着方法呼叫而建立,隨着方法結束而銷燬

本地方法棧(執行緒私有)

本地方法區和 Java Stack 作用類似, 區別是虛擬機器棧爲執行 Java 方法服務, 而本地方法棧則爲Native 方法服務。

堆(heap-執行緒共用)執行時數據區

是被執行緒共用的一塊記憶體區域,建立的物件和陣列都儲存在 Java 堆記憶體中,也是垃圾收集器進行垃圾收集的最重要的記憶體區域。

由於現代 VM 採用分代收集演算法, 因此 Java 堆從 GC 的角度還可以細分爲: 新生代(Eden 區From Survivor 區To Survivor 區)和老年代。

方法區

即我們常說的永久代(Permanent Generation), 用於儲存被 JVM 載入的類資訊常數靜態變數、即時編譯器編譯後的程式碼等數據. HotSpot VM把GC分代收集擴充套件至方法區, 即使用Java

態變數即時編譯器編譯後的程式碼等數據. HotSpot VM把GC分代收集擴充套件至方法區, 即使用Java堆的永久代來實現方法區, 這樣 HotSpot 的垃圾收集器就可以像管理 Java 堆一樣管理這部分記憶體, 而不必爲方法區開發專門的記憶體管理器

JVM 執行時記憶體

因爲我們在進行Java的GC的時候都是在堆上進行回收,同時從GC的角度細分來看,可以分爲新生代(Eden 區, from Survivor 區,和To Survivor區)和老年代

新生代

是用來存放新生的物件。一般佔據堆的 1/3 空間。由於頻繁建立物件,所以新生代會頻繁觸發MinorGC 進行垃圾回收。新生代又分爲 Eden 區ServivorFrom、ServivorTo 三個區。

Eden 區

Java 新物件的出生地(如果新建立的物件佔用記憶體很大,則直接分配到老代)。當 Eden 區記憶體不夠的時候就會觸發 MinorGC,對新生代區進行一次垃圾回收。

Servivor from

上一次GC的倖存者,作爲這一次的GC的被掃描者。

Servivor To

保留了一次MinorGC過程中的倖存者。

MinorGC的過程

採用到複製演算法。

  1. eden,servicorFrom 複製到ServicorTo,年齡+1

    首先,把 Eden 和 ServivorFrom 區域中存活的物件複製到 ServicorTo 區域(如果有物件的年齡以及達到了老年的標準,則賦值到老年代區),同時把這些物件的年齡+1(如果 ServicorTo 不夠位置了就放到老年區);

  2. 清空eden,ServivorFrom

    然後,清空 Eden 和 ServicorFrom 中的物件;

  3. 進行Servicor 和 ServicotFrom 相互交換

​ 最後,ServicorTo 和 ServicorFrom 互換,原 ServicorTo 成爲下一次 GC 時的 ServicorFrom區。

老年代

主要存放應用程式中生命週期長的記憶體物件。

老年代的物件比較穩定,所以 MajorGC 不會頻繁執行。在進行 MajorGC 前一般都先進行了一次 MinorGC,使得有新生代的物件晉身入老年代,導致空間不夠用時才觸發。

當無法找到足夠大的連續空間分配給新建立的較大物件時也會提前觸發一次 MajorGC 進行垃圾回收騰出空間。 MajorGC 採用標記清除演算法:首先掃描一次所有老年代,標記出存活的物件,然後回收沒有標記的物件。

MajorGC 的耗時比較長,因爲要掃描再回收。MajorGC 會產生記憶體碎片,爲了減少記憶體損耗,我們一般需要進行合併或者標記出來方便下次直接分配。當老年代也滿了裝不下的時候,就會拋出 OOM(Out of Memory)異常。

永久代(元數據)

指的時記憶體永久儲存的區域,主要存放Class 和Meta(元數據)的資訊,Class在被載入的時候被放入到永久區域,和存放範例的區域不同,GC 不會在主程式執行期對永久區域進行清理。所以這也導致了永久代的區域會隨着載入的 Class 的增多而脹滿,最終拋出 OOM 異常。

在Java8 中,永久代已經被移除,被一個稱爲「元數據區」(元空間)的區域所取代。元空間的本質和永久代類似,元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制。

垃圾回收與演算法:

哪些需要進行確定是垃圾

在完成垃圾回收之前,我們需要判斷哪些的物件是需要進行回收的–對於那些已經死去的物件我們需要進行回收。但是如何判斷物件是都已經死去,有兩種的方法。

參照計數演算法(Reference Counting)

給物件新增一個參照計數器,當有一個地方參照它的時候,計數器加一。當參照失效的時候,計數器減一,任何時刻計數器爲0的物件就是不可能再被參照的。此時就可以對其進行回收處理。此方法卻不能解決物件回圈參照的問題:
回圈參照的栗子
開始有一個方法A和一個方法B,開始時候 有物件對A進行參照,也有一些棧方法什麼的對B進行參照,且兩則之間有相互參照的關係。後來其餘的參照不再工作,這兩個就互相參照,此時參照計數器的值也不是0,但是對於外部來說,這兩個方法以及不具有任何的價值,但是就是不能夠被回收掉,就是回圈參照的問題。

根搜尋演算法。

在java中 使用根搜尋演算法判斷是否存活。
基本思路: 就是通過一系列的稱爲「GC Roots」的點作爲起始進行向下搜尋,當一個物件到GC Roots 沒有任何參照鏈相連,就證明此物件是不可用的。
但是問題來了什麼是 GC Roots:

GC Roots

以下的參照我們可以稱作其爲:

  • 在VM (幀中的本地變數)中的參照
  • 方法區中的靜態參照。
  • JNI(即一般說的Native方法)中的參照

在講述完判斷是否是垃圾的方法以後,下面 下麪我們開始要做的就是 對其進行回收。

垃圾回收演算法(四種)

1. 標記清除

兩個階段: 標記- 清除
首先 標記所有需要回收的物件(就是在前面進行過判斷的「已死的物件」),然後回收所有需要回收的物件。
缺點

  • 效率都不太高
  • 會產生大量的不連續記憶體碎片,空間碎片太多,會導致後續使用中由於無法找到足夠的連續記憶體而提前觸發另一次的垃圾蒐集 搜集動作。
    GC的次數越多 碎片化情況就會越加嚴重。

從圖中我們就可以發現,該演算法最大的問題是記憶體碎片化嚴重,後續可能發生大物件不能找到可利用空間的問題。

2. 複製演算法

爲了解決 Mark-Sweep 演算法記憶體碎片化的缺陷而被提出的演算法。按記憶體容量將記憶體劃分爲等大小的兩塊。每次只使用其中一塊,當這一塊記憶體滿後將尚存活的物件複製到另一塊上去,把已使用的記憶體清掉,如圖:

這種演算法雖然實現簡單,記憶體效率高,不易產生碎片,但是最大的問題是可用記憶體被壓縮到了原本的一半。且存活物件增多的話,Copying 演算法的效率會大大降低。

3. 標記整理演算法

結合了以上兩個演算法,爲了避免缺陷而提出。標記階段和 Mark-Sweep 演算法相同,標記後不是清理物件,而是將存活物件移向記憶體的一端。然後清除端邊界外的物件。如圖:

4. 分代收集演算法

分代收集法是目前大部分 JVM 所採用的方法,其核心思想是根據物件存活的不同生命週期將記憶體劃分爲不同的域,一般情況下將 GC 堆劃分爲老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特點是每次垃圾回收時只有少量物件需要被回收,新生代的特點是每次垃圾回收時都有大量垃圾需要被回收,因此可以根據不同區域選擇不同的演算法。

新生代與複製演算法

目前大部分 JVM 的 GC 對於新生代都採取 Copying 演算法,因爲新生代中每次垃圾回收都要回收大部分物件,即要複製的操作比較少(這也是在面試中常常問道的爲什麼新生代就要用複製),但通常並不是按照 1:1 來劃分新生代。一般將新生代劃分爲一塊較大的 Eden 空間和兩個較小的 Survivor 空間(From Space, To Space),每次使用Eden 空間和其中的一塊 Survivor 空間,當進行回收時,將該兩塊空間中還存活的物件複製到另一塊 Survivor 空間中。

老年代與標記整理演算法

而老年代因爲每次只回收少量物件,因而採用 Mark-Compact 演算法。

  1. JAVA 虛擬機器提到過的處於方法區的永生代(Permanet Generation),它用來儲存 class 類,常數,方法描述等。對永生代的回收主要包括廢棄常數和無用的類。

  2. 物件的記憶體分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放物件的那一塊),少數情況會直接分配到老生代。

  3. 當新生代的 Eden Space 和 From Space 空間不足時就會發生一次 GC,進行 GC 後,Eden Space 和 From Space 區的存活物件會被挪到 To Space,然後將 Eden Space 和 From Space 進行清理。

  4. 如果 To Space 無法足夠儲存某個物件,則將這個物件儲存到老生代。

  5. 在進行 GC 後,使用的便是 Eden Space 和 To Space 了,如此反覆 反復回圈。

  6. 當物件在 Survivor 區躲過一次 GC 後,其年齡就會+1。預設情況下年齡到達 15 的物件會被移到老生代中

Java中的四種參照型別

強參照

在 Java 中最常見的就是強參照,把一個物件賦給一個參照變數,這個參照變數就是一個強參照。當一個物件被強參照變數參照時,它處於可達狀態,它是不可能被垃圾回收機制 機製回收的,即使該物件以後永遠都不會被用到 JVM 也不會回收。因此強參照是造成 Java 記憶體漏失的主要原因之一。

軟參照

軟參照需要用 SoftReference 類來實現,對於只有軟參照的物件來說,當系統記憶體足夠時它不會被回收,當系統記憶體空間不足時它會被回收。軟參照通常用在對記憶體敏感的程式中。

弱參照

弱參照需要用 WeakReference 類來實現,它比軟參照的生存期更短,對於只有弱參照的物件來說,只要垃圾回收機制 機製一執行,不管 JVM 的記憶體空間是否足夠,總會回收該物件佔用的記憶體。

虛參照

虛參照需要 PhantomReference 類來實現,它不能單獨使用,必須和參照佇列聯合使用。虛參照的主要作用是跟蹤物件被垃圾回收的狀態。

垃圾收集器

Serial 垃圾收集器(單執行緒,複製演算法)

Serial 是最基本的垃圾收集器。Serial 是一個單執行緒的收集器,它不但只會使用一個 CPU 或一條執行緒去完成垃圾收集工作,並且在進行垃圾收集的同時,必須暫停其他所有的工作執行緒,直到垃圾收集結束。

Serial 垃圾收集器雖然在收集垃圾過程中需要暫停所有其他的工作執行緒,但是它簡單高效,對於限定單個 CPU 環境來說,沒有執行緒互動的開銷,可以獲得最高的單執行緒垃圾收集效率,因此 Serial垃圾收集器依然是 java 虛擬機器執行在 Client 模式下預設的新生代垃圾收集器。

ParNew 垃圾收集器(Serial + 多執行緒)

ParNew 垃圾收集器其實是 Serial 收集器的多執行緒版本,也使用複製演算法,除了使用多執行緒進行垃圾收集之外,其餘的行爲和 Serial 收集器完全一樣。ParNew 垃圾收集器在垃圾收集過程中同樣也要暫停所有其他的工作執行緒。ParNew 收集器預設開啓和 CPU 數目相同的執行緒數,可以通過

-XX:ParallelGCThreads 參數來限制垃圾收集器的執行緒數。

【Parallel:平行的】ParNew雖然是除了多執行緒外和Serial 收集器幾乎完全一樣,但是ParNew垃圾收集器是很多 java虛擬機器執行在 Server 模式下新生代的預設垃圾收集器

Parallel Scavenge 收集器(多執行緒複製演算法,高效)

Parallel Scavenge 收集器也是一個新生代垃圾收集器,同樣使用複製演算法,也是一個多執行緒的垃圾收集器,它重點關注的是程式達到一個可控制的吞吐量(Thoughput,CPU 用於執行使用者程式碼的時間/CPU 總消耗時間,即吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間)),高吞吐量可以最高效率地利用 CPU 時間,儘快地完成程式的運算任務,主要適用於在後臺運算而不需要太多互動的任務。自適應調節策略也是 ParallelScavenge 收集器與ParNew收集器的一個重要區別

Serial Old 收集器(單執行緒標記整理演算法)

Serial Old 是 Serial 垃圾收集器年老代版本,它同樣是個單執行緒的收集器,使用標記-整理演算法,這個收集器也主要是執行在 Client 預設的 java 虛擬機器預設的年老代垃圾收集器。

在 Server 模式下,主要有兩個用途:

  1. 在 JDK1.5 之前版本中與新生代的 Parallel Scavenge 收集器搭配使用。

  2. 作爲年老代中使用 CMS 收集器的後備垃圾收集方案。

Parallel Old 收集器(多執行緒標記整理演算法)

Parallel Old 收集器是Parallel Scavenge的年老代版本,使用多執行緒的標記-整理演算法,在 JDK1.6纔開始提供。

在 JDK1.6 之前,新生代使用 ParallelScavenge收集器只能搭配年老代的 Serial Old 收集器,只能保證新生代的吞吐量優先,無法保證整體的吞吐量,Parallel Old正是爲了在年老代同樣提供吞吐量優先的垃圾收集器,如果系統對吞吐量要求比較高,可以優先考慮新生代Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。

CMS 垃圾回收器

(Concurrent Mark Sweep)CMS垃圾回收器顧名思義是一種以獲取最短回收停頓時間爲目標的收集器。就是說 想要停頓的時間更少,也是因爲很大一部分的Java應用集中在網際網路或者B/S系統的伺服器上,這樣的伺服器很需要響應速度,給使用者較好的體驗,其有四個步驟:

初始標記:

僅僅是標記與GC Roots能夠直接關聯到的物件,速度很快

併發標記

就是進行Gc RootsTracing的過程

重新標記

是爲了修正在併發標記期間因使用者程式的繼續運作而導致標記參數變動的哪一步分物件的標記記錄,也比並發標記的時間要短。

併發清除

由於整個過程中耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以,從整體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

缺點:

  1. 對CPU資源非常敏感,實際上面向併發設計的程式都對CPU資源比較敏感。在 併發執行的階段,雖然不會導致使用者執行緒停頓,但是會因爲佔用了一部分執行緒,從而導致應用程式變慢,總吞吐量會降低。
  2. 無法處理浮動垃圾。因爲CMS併發清理階段使用者的執行緒還在執行,就會導致有新的垃圾不斷產生,CMS無法在當次處理掉,只好等待以一次的GC,這一部分的垃圾叫做浮動垃圾。
  3. 是基於標記-清楚,演算法。所以就會產生大量的空間碎片,就會導致雖然老年代還有很大的空間,但是無法找到足夠大的連續空間來分配當前物件,就會導致不得不 提前觸發一次 FUll GC。

前面介紹到的幾種垃圾回收器,或多或少的都是有延續之前的思想,複製,標記清除,整理,或是eden /to survivor/ from survivor 等,但是對於 G1 垃圾收集器來說 。對之前的禁錮都有了一定的推翻,使用了全新的模式,也是在未來的時間裏,將要推到最前端的垃圾回收器。

G1 垃圾回收器

G1演算法將堆劃分爲若幹個區域( Region後面會具體講到),它仍然屬於分代收集器。不過,這些區域的一部分包含新生代,新生代的垃圾收集依然採用暫停所有應用執行緒的方式,將存活物件拷貝到老年代或者Survivor空間。老年代也分成很多區域,G1收集器通過將物件從一個區域複製到另外一個區域,完成了清理工作。這就意味着,在正常的處理過程中,G1完成了堆的壓縮(至少是部分堆的壓宿),這樣也就不會有CMS記憶體碎片問題的存在

執行模式:會在Young GC 和Mixed GC 之間不斷進行切換的處理,同時定期地做全域性併發標記,若是在進行垃圾回收地速度趕不上建立速度地時候下會使用到Full GC(Serial GC)

注意: 當在進行Mixed GC地時候趕不上物件產生地速度時候就退化爲Full GC(這也是我們需要進行調優地地方)

出現的初衷: 在未來可以替換掉CMS收集器
在進行G1的講解之前,首先來了解G1與其相關的概念:

基礎概念:

吞吐量:在一個指定的時間內,最大化一個應用的工作量
例如一個使用下面 下麪的方式來衡量一個系統吞吐量的好壞:

  • 在一個小時內同一個事務(或者說是任務)完成的次數(tps)
  • 數據庫一個小時可以完成多少次查詢。
    對於關注吞吐量的系統,對於我們來說卡頓在日常是可以接受的,因爲對於我們來說,關注一個系統關注的是長時間的大量任務的執行能力,單詞的響應並不值得我們去追求和考慮、

響應能力: 一個程式或者是系統能否及時響應,多久完成響應。比如:在解決到一個請求時候需要多久能夠完成這個請求。

簡介:設計目標

是一個面向伺服器端的垃圾收集器,適用於多核處理器,大記憶體容量的伺服器端系統。能夠滿足短時間GC停頓的同時達到一個較高的吞吐量。

  • 與應用執行緒同時工作,幾乎不需要stop the world (與 CMS類似)
  • 整理剩餘空間時候,不會產生記憶體碎片(前面我們講到了是對於 CMS來說 只能再 Full GC 時候,用 stop the world)但是對於 G1來說,一是沒有記憶體碎片,但是也不用等到 Full gc。對於CMS的full gs 時候,無論是新生代,還是老年代,都是會進行全部的垃圾回收,此時就需要執行緒進行等待,這個也是會搶佔cpu的地方。(在所有的垃圾收集器的設計中,都會避免出現 full GC 的情況。)
  • 停頓時間更加可控 說的是對於 g1來說,我們可以在啓動的時候 設定一個停頓的時間,例如和cms進行比較的時候,cms在進行full gc 時停頓的時間是不能夠控制的,有時候就算是停頓很長的時間,也是沒有辦法的,但是對於g1 來說 我們設定一個時間,就算是有很多的垃圾需要回收時候,g1也會先進性一個評估,評估大概需要多久,最後 回收的也只是在對應時間差不多的空間大小,等到下一次的再度回收,可以控制。與堆的設計相關,看來也是比較重要的一部分知識啊。
  • 不犧牲系統吞吐量: 指的是說 前面我們也有講到了 cms的不好的地方,就還是說 在併發執行階段,雖然說 不會使使用者執行緒停止但是也是會佔用一部分的執行緒,會使得應用程式變慢。
  • gc 不要求額外的記憶體空間 (CMS需要預留空間 儲存 浮動垃圾)這裏說明什麼是浮動垃圾,就是說 對於cms來說 ,在執行的時候,使用者的執行緒還是在執行的,開始任務不是垃圾,不會進行回收,但是後來變成了垃圾,需要回收時候,此時的cms沒有能夠認爲是垃圾。也是比較好的地方對於 cms來說

G1與CMS的優勢。

  • 在壓縮空間有優勢:
  • 記憶體分割區region 不再固定。
  • 各個代不需要指定大小
  • 控制時間 控制垃圾收集時間,避免雪崩
  • 在回收記憶體以後,會立馬進行合併記憶體的操作,但是cms要進行stop the word
  • G1 可以在yong 但是cms只能老年代。
  • 一個是複製,一個是 標記整理,不會有內碎片產生。
  • 同比較 parallel Scavenge和 parallel Old 比較時候 parallel 會對整個區域做整理,此時的時間停頓比較長
  • 前面講到了會根據使用者設定的停頓時間,會智慧評估,回收哪幾個的時候可以滿足使用者的設定

前言:本篇博文結合了《深入理解java虛擬機器》(第二版),以及張龍的 「深入理解JVM虛擬機器」(B站視訊版)以及本人所看的各種其他書籍,及一些java面試題目之中介紹到的類載入機制 機製部分,從底層全面講起來,真正的能夠理解這些過程,當然寫出來也是對學習情況的一種輸出的過程。

虛擬機器的類載入機制 機製

首先既然講到了虛擬機器的類載入機制 機製,我們當然就是想知道的第一點就是——什麼是類載入?

什麼是?

什麼是虛擬機器的類載入機制 機製: 把描述類的數據從Class檔案載入到記憶體,並對數據進行校檢轉換解析和初始化,最終形成可以被虛擬機器直接使用的java型別。 就是被稱爲 虛擬機器的類載入機制 機製。

怎麼做到?

過程

分爲五個大的步驟:載入 連線 初始化 使用 解除安裝
但是其中 連線又分爲三個步驟: 驗證 準備 解析

如何具體實現?

載入

載入是Java虛擬機器中類載入中的第一個過程:
注意: 載入是類載入五個大步驟中的第一個小的步驟,不要弄混淆
關於載入 虛擬機器會做以下的三件事情

  • 通過一個類的全限定名來獲取定義此類的二進制位元組流:
  • 將這個位元組流所代表的靜態儲存結構轉化爲方法區的執行時數據結構。
  • 在記憶體中生成一個代表這個類的java.lang.Class 物件,作爲方法區這個類的各種數據的存取入口。

注意: 但是關於第一點的獲取二進制位元組流就有多種的方法:

  1. 在本地系統中直接載入
  2. 過網路下載 .class 檔案
  3. 通過 壓縮 zip jar,進行載入

載入完成之後: 虛擬機器外部的二進制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區之中的數據儲存格式由虛擬機器自行定義。

連線

連線分爲三個小的步驟來分步執行,這裏一一進行講解

驗證(連線階段的第一步)

這一步所做的工作很少:目的是爲了確保class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並不會危害虛擬機器自身的安全
分爲一下的四個步驟:

  • 類檔案的結構檢查
  • 語意檢查 (例如一個類不能是abstract 和final)
  • 位元組碼驗證
  • 二進制相容性的驗證

準備(連線階段的第二步)

這個階段相對之下還是比較重要的:

正式爲類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的的記憶體都將在方法區中進行分配
但是這個時候 要注意的是 這個時候進行記憶體分配僅僅包括類變數(被static修飾的變數,而不包括範例變數)賦初始值: 並不是我們認爲賦予的初始值,是根據型別所指定的初始零值。這個時候分配的都是給定變數的零值。
下圖是一些型別的變數所給定的零值。

舉個栗子

public static int value=123;

在準備階段 會爲其賦予的是 0,而不是123 ,想要獲取到123 我們認爲給定的值,必須是在程式被編譯以後纔會有,存放在類構造器中纔會執行。

解析(連線階段的第三步)

解析階段就是虛擬機器將常數池內的符號參照替換成直接參照的過程
此時我們不禁會有疑問,什麼是符號參照,什麼又是直接參照呢。

  • 符號參照
    符號參照與虛擬機器實現的佈局無關,參照的目標並不一定要已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號參照必須是一致的,因爲符號用的字面量形式明確定義在 Java 虛擬機器規範的 Class 檔案格式中
  • 直接參照
    直接參照可以是指向目標的指針,相對偏移量或是一個能間接定位到目標的控制代碼。如果有了直接參照,那參照的目標必定已經在記憶體中存在。

初始化

初始化算是類載入的最後一步,(爲什麼是類載入的最後一步:因爲對於後面的使用就是我們的呼叫的各種過程,已經不需要再做過多的介紹內容
在前面的各個步驟中除了載入階段使用者可以自定義的使用自己編寫的類載入器,其餘的階段都是自發進行性的。到了初始化階段 纔開始執行使用者所定義的java程式碼。

前面講到了在準備階段中,系統會爲變數賦予了最開始的零值,在初始化階段就會根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源。其實初始化階段就是執行類構造器的階段<clinit>()
<clinit>() 方法是由編譯器自動收集類中的所有類變數的賦值動作,和靜態語句塊(static{}})中的語句合併產生的。

什麼時候初始化?

我們在上面已經完成了對基礎資訊的理解與掌握。下面 下麪開始學習什麼時候一個類會初始化成功?這裏就要提及到主動使用被動使用
並且所有的虛擬機器的實現 必須是每個類或介面 被java程式 首次主動使用 時候纔是會初始化。
主動使用 :

  • 建立類的範例 new
  • 存取某個類或介面的靜態變數,或者對該靜態變數賦值。
  • 呼叫類的靜態方法
  • 反射
  • 初始化一個類的子類 在初始化一個類的子類 表示對父類別的主動使用
  • 被標記爲啓動類 (包含main方法。)
被動使用

注意的是除了以上的情況下其他的方法,都被列爲被動使用 都不會初始化 ,即使已經執行完了初始化之前的步驟但是也不會初始化。

初始化步驟
  • 假如說這個類還沒有被載入和連線,那就先進行載入和連線。因爲任何類的三個步驟 都是 載入 連線 初始化。
  • 假如說這個類還有一個父類別 那就先對這個類的父類別進行初始化 (對於介面的型別 不進行初始化處理)
  • 假如說類存在初始化語句,那就依次執行這個初始化語句。
  • 一個父介面 不會因爲他的子介面 或者實現類的初始化而初始化 只用當程式首次使用特定介面的靜態變數時,纔會導致介面的初始化。
  • **如下圖表示了類與介面的不同,在下面 下麪也會進行一一講解。**

既然已經講到了初始化步驟時候,這裏就要講到final關鍵字:

final

表示常數在編譯的階段 這個常數就會被存入到 呼叫這個常數的方法所在的類的常數池中 所以就相當於會被放入到一個類的常數池中
可能這句話聽起來有點拗口,在下面 下麪的栗子中我們可以得知的是對於parent_3的 str1來說,其就會被存放到main方法的常數池中。
但是在本質上 呼叫類並沒有直接參照到定義常數的類 就是 說 在一個類中 參照另外一個類中的 final 時候 並不會對其類中的靜態程式碼塊進行對應的初始化 (這個時候 兩則之前就沒有任何的關係 所以 就可以將class檔案就算刪除 也是可以的)
栗子

  public static void main(String[] args) {
// 1        System.out.println(Parent_3.str1);
//  2       System.out.println(Parent_3.str);
    }
}
class  Parent_3{

    public static final String  str1="dd";
    public static  final  String  str=UUID.randomUUID().toString();
    static {
        System.out.println("parent static code ");
    }

此時對於上面的輸出:
在使用第一條列印語句的時候 只會列印出: dd。
在使用第二條列印語句的時候,會出現:
parent static code e39877fd-0bce-472c-a70d-320c9707f8bf
以上例子說明的是 在我們呼叫(因爲被final修飾時候 被稱爲 常數的) str 與 str1時候 由於 str1是一個編譯器就可以知道的常數,所以在呼叫時候 ,編譯期就知道其值,就會把它放到呼叫類的常數池中,這個時候 就不會對類Parent_3進行初始化。此時靜態程式碼塊也就不會執行。但是在呼叫 str時候 由於 在編譯期間無法知道其值,是一個執行期常數,所以要對被呼叫類進行初始化才能 纔能夠知道其值,所以可以對靜態程式碼塊進行列印輸出。

關於介面的基本特點

在前面講到的:一個父介面 不會因爲他的子介面 或者實現類的初始化而初始化 只用當程式首次使用特定介面的靜態變數時,纔會導致介面的初始化
在呼叫一個介面的時候,若是一個介面繼承自一個父類別的介面。此時,若是刪除父類別的介面,並不會產生問題,說明在介面型別中,呼叫子類的時候並不會對父類別進行一個初始化。
這是爲什麼呢:
是因爲:對於一個介面來說時候 其中的值都是 public static final 型別的常數。前面我們有對final型別進行一個講解,就是說,會存放到呼叫類的常數池中。所以此時並不會執行初始化,也是原由。
此時若是把子類中的型別改成UUID型別的時候,刪除class 檔案就會出現問題.
這說明了,靜態型別的時候 會在main函數中進行呼叫時候 載入到 常數池中。若是 UUID型別時候 就需要在執行期才能 纔能夠 知道其值,執行期時候就需要有其原class檔案 所以 在使用到UUID,並且刪除掉class 時候 就會出現編譯的異常。但是在只有真正的使用到父類別的時候 (例如應用父類別中的常數時候) 纔會真正的初始化。


前面講解了final關鍵字和介面的相關問題,下面 下麪舉一個栗子來真實類在載入和初始化時候的特性。
栗子

public class Test4 {
    public static void main(String[] args) {
      Single single= Single.getSingle();
        System.out.println("A"+Single.a);
        System.out.println(Single.b);
    }
}
class  Single{
  public   static  int a=1;

    private static  Single single=new Single();
  private  Single(){
      a++;
      b++;
      System.out.println(a);
      System.out.println(b);
    }
    public   static  int b=0;
  public  static  Single getSingle(){
      return  single;
  }
}

最後列印的結果是
2 1 A2 0
解釋:由於前面提到的 第一個步驟是:連線+ 載入 + 初始化
在載入裏面還是會有 三個步驟其中有一個就是準備的過程 其目的
爲類的靜態變數賦初值,分配空間等
所以在開始時候 的準備中 會先進行一輪的初始化 int型別會變成0, string型別是 null 。所以第一輪的時候 a是系統初始化 0 new型別是null b也是 0 這個是準備階段。在執行階段時候 a賦值 1 呼叫過程中會呼叫到建構函式 此時 會對 a++ 和b++ 。執行到此時候 並沒有 我們人爲的對b 進行賦值 所以 此時的列印是 2,1 然後 執行到下面 下麪 時候 我們人爲賦值重新賦初值時候 ,又重新變成了 0 所以最後的列印是 0。
這個過程就深刻演示了在準備階段和初始化是什麼樣的過程。

初始化時機

類的解除安裝

栗子
例如說 一個類被載入 ,連線 初始化後,她的生命週期就開始了。當程式碼這個類的Class檔案不再被參照,即不可以觸及時候,Class物件就會結束生命週期,這個類在方法區內的數據也會被解除安裝,從而結束這個類的生命週期,就是說一個類什麼時候結束生命週期,取決於它所代表的Class物件什麼時候結束生命週期。
由java虛擬機器自帶的類載入器所載入的類在虛擬機器的生命週期中,始終不會被解除安裝。在後面我們介紹的: 根類載入器,擴充套件類載入器,和系統類載入器。java虛擬機器本身會始終參照這個類載入器,而這些類載入器則會始終參照他們所載入的類的class物件,因此這些class物件始終是可觸及的。使用者自己定義的類載入器是可以解除安裝的


在完成了上面的講解以後,我們對類的記載的過程有了基礎的認知,關於 類和介面的問題是重總之中,兩者之前的不同也是在面試時候常常問到的問題。下面 下麪我們開始更加細緻的講解。

類載入器

什麼是類載入器:前面講到載入時候的第一件事:「通過一個類的全限定名來獲取描述此類的二進制位元組流」但是這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何取獲取所需要的類。這個動作程式碼模組稱爲類載入器

類載入器:並不需要等到某一個類首次主動使用時候纔會載入它
(此時就想到了一個例子: 是說一個子類和一個父類別,在其中都有靜態程式碼塊 但是對於靜態程式碼塊而言 只用對類進行初始化使用到時候 纔會被使用到 main函數中 使用子類呼叫父類別的變數(這裏因爲 子類是繼承自父類別的 所以可以使用其中的變數)但是列印出來的是 父類別的靜態程式碼塊 此時 對子類並沒有初始化 但是不代表沒有進行載入 關於類的載入而是 是會被載入的)所以有了以下的定義:
對於 jvm來說 執行類載入器在預料某個類將要被使用之前就會先對其進行一個預先載入,但是如果遇到了什麼錯誤,此時也並不一定會報錯,必須在程式首次使用該類的時候 纔會報錯誤。
對於類載入器而言:
從虛擬機器的角度來說有兩種類載入器:

  1. 啓動類載入器是虛擬機器的自身一部分;
  2. 所有其他的類載入器(都有java語言進行編寫)。
    除了根類載入器之外,其餘的類載入器都有且只有一個父載入器。
    但是在開發角度來說 就會有以下的三種類載入器:
  • 啓動類載入器:
  • 擴充套件類載入器
  • 應用程式類載入器
  • 自己定義的類載入器(屬於使用者自定義的載入器)
類載入器各個用途

類載入器的種類

類與類載入器之間的關係

前面講到了類載入器,但是類載入器並不只是對類載入這個功能,還有更多的功能。對於每一個類,都需要由載入她的類載入器和這個類本身來一起確定其在java虛擬機器中的唯一性。對於每一個類載入器 都擁有自己獨立的名稱空間。這個時候結合給的例子說,只有兩個類是由同一個類載入的前提下才能 纔能說其是否相等,進行比較。否則儘管說這兩個類來源於同一個class檔案,但是也必定不相等。


在講述完類載入器以後,我們可能還需要瞭解一下名稱空間的作用:在自定義類記載器時候會出現的一個問題,也是在面試時候比較容易考到的地方。

名稱空間

什麼是?

  • 每個類載入器都有自己的名稱空間,名稱空間由該載入器及所有父載入器所載入的類組成
  • 在同一個名稱空間中,不會出現類的完整名字(包括類的包名) 相同的像個類。
  • 在不同的名稱空間中,有可能會出現類的完整名字(包括類的包名) 相同的兩個類
    理解
    前面已經介紹過了關於名稱空間的問題,若是使用父類別的載入器進行載入 會從類路徑的class檔案中載入,此時若是new了兩個物件 但是對於使用了父類別載入器的時候,前一次載入以後 後一次的就會直接呼叫 就屬於在同一個名稱空間之下。
    但是若是使用自己所定義的類載入器 由於new出來了兩個物件,就會產生兩個不同的名稱空間,也會產生不同的類。 這但是若是新的物件指定了前一個物件作爲其父類別載入器時候,產生的就是相同的hashcode,因爲父類別載入器在前面載入過,在後面就不會重複載入,而是在其的基礎上再進行一遍呼叫。

以上是對類載入器有了基礎的認知,其實類載入器之中的知識要在具體的實戰中纔會得以顯示,下面 下麪我們來介紹一下一個重要的機制 機製,來具體也更加深刻認知類載入器。

雙親委派機制 機製

什麼是?

什麼是雙親委派機制 機製:如果一個類載入器收到了類載入的請求,她首先不會自己嘗試去載入這個類,而是把這個請求委派給父類別載入器去完成,每一個層序的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啓動類載入器中,只用當父載入器反饋自己無法完成這個載入請求(在搜尋範圍彙總沒有找到所需要的類)時候,子載入器纔會嘗試着自己去載入。(這裏的類載入器之間的父子關係一般不會以繼承的關係,而是使用組合關係來複用父載入器的程式碼)
爲什麼要使用?
使用這個模式來組織類載入器之間的關係,有一個顯而易見的好處就是java類隨着它的類載入器一起具備了一種帶有優先順序的層次關係。例如我們的java.lang.Object,存在方 rt.jar中無論是哪個類載入器要載入這個類,最後實現的結果是都會委託給模型最頂端的啓動類載入器進行載入,因此Object類在程式的各個類記載器環境中都是同一個類,否則自行載入時候 最基礎的體系結構就不能夠得到保證。
好處

  • 可以確保java核心庫的型別安全
    前面提到了會參照 java.lang.Object 時候,也是就說 在執行期若是使用自己所定義的類進行載入,就會存在多個版本的java.lang.Object,而且是不相容,不可見的。
  • 提供了java核心類庫不會被自定義的類所替代
  • 不同的類載入器可以爲相同(binary name) 的類建立額外的名稱空間,相同名稱的類可以並存在java虛擬機器中,只需要不同的類載入器來載入他們即可。不同類載入器所載入的類之間是不相容的。這就相當於在java虛擬機器內部建立了一個又一個相互隔離的Java類空間。
    如何實現雙親委派模型的呢
    實現在 java.lang.ClassLoader中的loaderclass()方法中:首先檢查是否已經被載入,若沒有載入則呼叫父載入器的loadClass() 方法,若父載入器爲空,則預設使用啓動類載入器作爲父載入器。如果父類別載入失敗,,拋出 ClassNotFoundException異常後,再呼叫自己的findClass() 方法進行載入。
    優點(面試)
    能夠提高軟件系統的安全性。因爲在此機制 機製下,使用者之定義的類載入器不可能載入應該由父載入器載入的可靠類。從而防止不可靠甚至惡意的diamante代替由父載入器載入的可靠程式碼。例如java.lang.Object 類總是由根類載入器載入,其他任何使用者定義的類載入器都不可能載入含有惡意代碼的java.lang.Object類。

前面我們介紹到了雙親委派機制 機製的好處所在,但是其不是一個強制性的約束模型而是說java設計者推薦給我們使用的類載入的實現方式,在Java的世界中大部分的類載入器都遵循這個模型。但是有時候也會出現不適用的情況,這個時候推出了執行緒上下文載入器。

雙親委派機制 機製(不適用情況)

在我們的雙親委託模型中,類載入器是由下至上的,即下層的類載入器會委託上層載入器進行載入,但是對於 SPI來說,有些介面卻是Java核心庫所提供的的,而Java核心庫是由啓動類記載器進行載入的,而這些介面的實現卻是由不容的jar(包,廠商提供的),Java的啓動了載入器是不會載入來源於其他的jar包中的資訊,這樣雙親委託模型就無法滿足SPI的要求。

爲了解決的是在有得時候 不適用的情況下:執行緒上下文載入器(ThreadContestClassLoader)這個類載入器可以通過java.lang.Thread類的setContextClassLoaser() 方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒彙總繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。

爲什麼會出現執行緒上下文載入器

是因爲舉個例子「對於jDBC我們都有學習和瞭解,JDBC是一個標準,對於很多的數據庫廠商,例如MySQLDB2等都會根據這個標準來進行自己的實現,既然是一個標準,那麼這些原生的標準和類庫都是存在於JDK中的,很多的介面都是有廠商來實現」這個時候,這些介面都肯定是由我們根類載入器進行載入實現的。
我們要是想來直接使用廠商提供給我們的實現,就需要把廠商給的實現放置在類的應用的ClassPath下面 下麪,此時就不會由啓動類來進行載入,這也是載入器的載入範圍限制,這個時候 這些實現,就需要用到系統類載入器(關於這一點爲什麼會使用到系統類載入器可以去看類載入器的各個不同的使用場景),或是應用類載入器來進行載入,因爲這些載入器纔回去掃描當前的classPath
。這個時候 就會出現一個問題「例如有一個Connection 介面 這個介面由啓動類進行載入,但是具體實現由系統或是應用載入。父載入載入的介面看不到子載入器實現的類或是介面(關於這一點是需要記憶的),這個時候 例如一個介面想要去呼叫自己的實現,但是由於載入自己(介面)的是父親載入器,載入實現的是兒子載入器,所以根本就不可能讀到相關的資訊。 這個時候 對於就算是將實現放入到ClassPath下也不能夠應用」所以因運而生了(不得以爲止)產生了執行緒上下文載入器

但是對於執行緒上下文載入器:父ClassLoader可以使用當前執行緒Thread.currentthread().getContextClassLoader()所指定的ClassLoader載入的類