深入淺出 Java 虛擬機 是你通往高級 Java 開發的必經之路

乾貨來咯

前言:

今天要給大家分享的是Java虛擬機的一些硬貨知識,文章不錯的話記得給我點給個關注哦,私信我可以獲取更多的java資料。

**第一章 JVM 內存模型
**
Java 虛擬機(Java Virtual Machine=JVM)的內存空間分爲五個部分,分別是:

程序計數器
Java 虛擬機棧
本地方法棧

方法區。
下面對這五個區域展開深入的介紹。

1.1 程序計數器

1.1.1 什麼是程序計數器?

程序計數器是一塊較小的內存空間,可以把它看作當前線程正在執行的字節碼的行號指示器。也就是說,程序計數器裏面記錄的是當前線程正在執行的那一條字節碼指令的地址。

注:但是,如果當前線程正在執行的是一個本地方法,那麼此時程序計數器爲空。

1.1.2 程序計數器的作用

程序計數器有兩個作用:

字節碼解釋器通過改變程序計數器來依次讀取指令,從而實現代碼的流程控制,如:順序執行、選擇、循環、異常處理。
在多線程的情況下,程序計數器用於記錄當前線程執行的位置,從而當線程被切換回來的時候能夠知道該線程上次運行到哪兒了。
1.1.3 程序計數器的特點

是一塊較小的存儲空間
線程私有。每條線程都有一個程序計數器。
是唯一一個不會出現OutOfMemoryError的內存區域。
生命週期隨着線程的創建而創建,隨着線程的結束而死亡。
1.2 Java虛擬機棧(JVM Stack)

1.2.1 什麼是Java虛擬機棧?

Java虛擬機棧是描述Java方法運行過程的內存模型。

Java虛擬機棧會爲每一個即將運行的Java方法創建一塊叫做“棧幀”的區域,這塊區域用於存儲該方法在運行過程中所需要的一些信息,這些信息包括:

局部變量表
存放基本數據類型變量、引用類型的變量、returnAddress類型的變量。
操作數棧
動態鏈接
方法出口信息

當一個方法即將被運行時,Java虛擬機棧首先會在Java虛擬機棧中爲該方法創建一塊“棧幀”,棧幀中包含局部變量表、操作數棧、動態鏈接、方法出口信息等。當方法在運行過程中需要創建局部變量時,就將局部變量的值存入棧幀的局部變量表中。

當這個方法執行完畢後,這個方法所對應的棧幀將會出棧,並釋放內存空間。

注意:人們常說,Java的內存空間分爲“棧”和“堆”,棧中存放局部變量,堆中存放對象。

這句話不完全正確!這裏的“堆”可以這麼理解,但這裏的“棧”只代表了Java虛擬機棧中的局部變量表部分。真正的Java虛擬機棧是由一個個棧幀組成,而每個棧幀中都擁有:局部變量表、操作數棧、動態鏈接、方法出口信息。

1.2.2 Java 虛擬機棧的特點

(1)局部變量表的創建是在方法被執行的時候,隨着棧幀的創建而創建。而且,局部變量表的大小在編譯時期就確定下來了,在創建的時候只需分配事先規定好的大小即可。此外,在方法運行的過程中局部變量表的大小是不會發生改變的。

(2)Java 虛擬機棧會出現兩種異常:StackOverFlowError 和 OutOfMemoryError。

a) StackOverFlowError:
若Java虛擬機棧的內存大小不允許動態擴展,那麼當線程請求棧的深度超過當前Java虛擬機棧的最大深度的時候,就拋出StackOverFlowError異常。
b) OutOfMemoryError:
若Java虛擬機棧的內存大小允許動態擴展,且當線程請求棧時內存用完了,無法再動態擴展了,此時拋出OutOfMemoryError異常。
(3)Java虛擬機棧也是線程私有的,每個線程都有各自的Java虛擬機棧,而且隨着線程的創建而創建,隨着線程的死亡而死亡。

注:StackOverFlowError和OutOfMemoryError的異同?StackOverFlowError表示當前線程申請的棧超過了事先定好的棧的最大深度,但內存空間可能還有很多。而OutOfMemoryError是指當線程申請棧時發現棧已經滿了,而且內存也全都用光了。
1.3 本地方法棧

1.3.1 什麼是本地方法棧?

本地方法棧和Java虛擬機棧實現的功能類似,只不過本地方法區是本地方法運行的內存模型。

本地方法被執行的時候,在本地方法棧也會創建一個棧幀,用於存放該本地方法的局部變量表、操作數棧、動態鏈接、出口信息。

方法執行完畢後相應的棧幀也會出棧並釋放內存空間。

也會拋出StackOverFlowError和OutOfMemoryError異常。

1.4 堆

1.4.1 什麼是堆?

堆是用來存放對象的內存空間。

幾乎所有的對象都存儲在堆中。

1.4.2 堆的特點

(1)線程共享

整個 Java 虛擬機只有一個堆,所有的線程都訪問同一個堆。而程序計數器、Java 虛擬機棧、本地方法棧都是一個線程對應一個的。

(2)在虛擬機啓動時創建。

(3)垃圾回收的主要場所。

(4)可以進一步細分爲:新生代、老年代。

新生代又可被分爲:Eden、From Survior、To Survior。不同的區域存放具有不同生命週期的對象。這樣可以根據不同的區域使用不同的垃圾回收算法,從而更具有針對性,從而更高效。

(5)堆的大小既可以固定也可以擴展,但主流的虛擬機堆的大小是可擴展的,因此當線程請求分配內存,但堆已滿,且內存已滿無法再擴展時,就拋出 OutOfMemoryError。

1.5 方法區

1.5.1 什麼是方法區?

Java 虛擬機規範中定義方法區是堆的一個邏輯部分。方法區中存放已經被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等。

1.5.2 方法區的特點

線程共享
方法區是堆的一個邏輯部分,因此和堆一樣,都是線程共享的。整個虛擬機中只有一個方法區。
永久代
方法區中的信息一般需要長期存在,而且它又是堆的邏輯分區,因此用堆的劃分方法,我們把方法區稱爲老年代。
內存回收效率低
方法區中的信息一般需要長期存在,回收一遍內存之後可能只有少量信息無效。
對方法區的內存回收的主要目標是:對常量池的回收 和 對類型的卸載。
Java虛擬機規範對方法區的要求比較寬鬆。
和堆一樣,允許固定大小,也允許可擴展的大小,還允許不實現垃圾回收。
1.5.3 什麼是運行時常量池?

方法區中存放三種數據:類信息、常量、靜態變量、即時編譯器編譯後的代碼。其中常量存儲在運行時常量池中。

我們一般在一個類中通過public static final來聲明一個常量。這個類被編譯後便生成Class文件,這個類的所有信息都存儲在這個class文件中。

當這個類被Java虛擬機加載後,class文件中的常量就存放在方法區的運行時常量池中。而且在運行期間,可以向常量池中添加新的常量。如:String類的intern()方法就能在運行期間向常量池中添加字符串常量。

當運行時常量池中的某些常量沒有被對象引用,同時也沒有被變量引用,那麼就需要垃圾收集器回收。

1.6 直接內存

直接內存是除Java虛擬機之外的內存,但也有可能被Java使用。

在NIO中引入了一種基於通道和緩衝的IO方式。它可以通過調用本地方法直接分配Java虛擬機之外的內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象直接操作該內存,而無需先將外面內存中的數據複製到堆中再操作,從而提升了數據操作的效率。

直接內存的大小不受Java虛擬機控制,但既然是內存,當內存不足時就會拋出OOM異常。

1.7 綜上所述

Java虛擬機的內存模型中一共有兩個“棧”,分別是:Java虛擬機棧和本地方法棧。
兩個“棧”的功能類似,都是方法運行過程的內存模型。並且兩個“棧”內部構造相同,都是線程私有。
只不過Java虛擬機棧描述的是Java方法運行過程的內存模型,而本地方法棧是描述Java本地方法運行過程的內存模型。
Java虛擬機的內存模型中一共有兩個“堆”,一個是原本的堆,一個是方法區。方法區本質上是屬於堆的一個邏輯部分。堆中存放對象,方法區中存放類信息、常量、靜態變量、即時編譯器編譯的代碼。
堆是Java虛擬機中最大的一塊內存區域,也是垃圾收集器主要的工作區域。
程序計數器、Java虛擬機棧、本地方法棧是線程私有的,即每個線程都擁有各自的程序計數器、Java虛擬機棧、本地方法棧。並且他們的生命週期和所屬的線程一樣。
而堆、方法區是線程共享的,在Java虛擬機中只有一個堆、一個方法棧。並在JVM啓動的時候就創建,JVM停止才銷燬。
第二章 揭開Java對象創建的奧祕

2.1 對象的創建過程

當虛擬機遇到一條含有new的指令時,會進行一系列對象創建的操作:

(1)檢查常量池中是否有即將要創建的這個對象所屬的類的符號引用;

若常量池中沒有這個類的符號引用,說明這個類還沒有被定義!拋出ClassNotFoundException;
若常量池中有這個類的符號引用,則進行下一步工作;
(2)進而檢查這個符號引用所代表的類是否已經被JVM加載;

若該類還沒有被加載,就找該類的class文件,並加載進方法區;
若該類已經被JVM加載,則準備爲對象分配內存;
(3)根據方法區中該類的信息確定該類所需的內存大小;

一個對象所需的內存大小是在這個對象所屬類被定義完就能確定的!且一個類所生產的所有對象的內存大小是一樣的!JVM在一個類被加載進方法區的時候就知道該類生產的每一個對象所需要的內存大小。

(4)從堆中劃分一塊對應大小的內存空間給新的對象;分配堆中內存有兩種方式:

指針碰撞
如果JVM的垃圾收集器採用複製算法或標記-整理算法,那麼堆中空閒內存是完整的區域,並且空閒內存和已使用內存之間由一個指針標記。那麼當爲一個對象分配內存時,只需移動指針即可。因此,這種在完整空閒區域上通過移動指針來分配內存的方式就叫做“指針碰撞”。
空閒列表
如果JVM的垃圾收集器採用標記-清除算法,那麼堆中空閒區域和已使用區域交錯,因此需要用一張“空閒列表”來記錄堆中哪些區域是空閒區域,從而在創建對象的時候根據這張“空閒列表”找到空閒區域,並分配內存。
綜上所述:JVM究竟採用哪種內存分配方法,取決於它使用了何種垃圾收集器。
(5)爲對象中的成員變量賦上初始值(默認初始化);

(6)設置對象頭中的信息;

(7)調用對象的構造函數進行初始化;

此時,整個對象的創建過程就完成了。

2.2 對象的內存模型

一個對象從邏輯角度看,它由成員變量和成員函數構成,從物理角度來看,對象是存儲在堆中的一串二進制數,這串二進制數的組織結構如下。

對象在內存中分爲三個部分:

對象頭
實例數據
對齊補充
2.2.1 對象頭

對象頭中記錄了對象在運行過程中所需要使用的一些數據:哈希碼、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等。

此外,對象頭中可能還包含類型指針。通過該指針能確定這個對象所屬哪個類。

此外,如果對象是一個數組,那麼對象頭中還要包含數組長度。

2.2.2 實例數據

實力數據部分就是成員變量的值,其中包含父類的成員變量和本類的成員變量。

2.2.3 對齊補充

用於確保對象的總長度爲8字節的整數倍。

HotSpot要求對象的總長度必須是8字節的整數倍。由於對象頭一定是8字節的整數倍,但實例數據部分的長度是任意的,因此需要對齊補充字段確保整個對象的總長度爲8的整數倍。

2.3 訪問對象的過程

我們知道,引用類型的變量中存放的是一個地址,那麼根據地址類型的不同,對象有不同的訪問方式:

句柄訪問方式
堆中需要有一塊叫做“句柄池”的內存空間,用於存放所有對象的地址和所有對象所屬類的類信息。
引用類型的變量存放的是該對象在句柄池中的地址。訪問對象時,首先需要通過引用類型的變量找到該對象的句柄,然後根據句柄中對象的地址再訪問對象。
直接指針訪問方式
引用類型的變量直接存放對象的地址,從而不需要句柄池,通過引用能夠直接訪問對象。
但對象所在的內存空間中需要額外的策略存儲對象所屬的類信息的地址。
比較

HotSpot採用直接指針方式訪問對象,因爲它只需一次尋址操作,從而性能比句柄訪問方式快一倍。但它需要額外的策略存儲對象在方法區中類信息的地址。

第三章 揭開 Java 對象內存分配的祕密

Java所承諾的自動內存管理主要是針對對象內存的回收和對象內存的分配。

在Java虛擬機的五塊內存空間中,程序計數器、Java虛擬機棧、本地方法棧內存的分配和回收都具有確定性,一半都在編譯階段就能確定下來需要分配的內存大小,並且由於都是線程私有,因此它們的內存空間都隨着線程的創建而創建,線程的結束而回收。也就是這三個區域的內存分配和回收都具有確定性。

而Java虛擬機中的方法區因爲是用來存儲類信息、常量

靜態變量,這些數據的變動性較小,因此不是Java內存管理重點需要關注的區域。

而對於堆,所有線程共享,所有的對象都需要在堆中創建和回收。雖然每個對象的大小在類加載的時候就能確定,但對象的數量只有在程序運行期間才能確定,因此堆中內存的分配具有較大的不確定性。此外,對象的生命週期長短不一,因此需要針對不同生命週期的對象採用不同的內存回收算法,增加了內存回收的複雜性。

綜上所述:Java自動內存管理最核心的功能是堆內存中對象的分配與回收。

3.1 對象優先在 Eden 區中分配

目前主流的垃圾收集器都會採用分代回收算法,因此需要將堆內存分爲新生代和老年代。

在新生代中爲了防止內存碎片問題,因此垃圾收集器一般都選用“複製”算法。因此,堆內存的新生代被進一步分爲:Eden區+Survior1區+Survior2區。

每次創建對象時,首先會在Eden區中分配。

若Eden區已滿,則在Survior1區中分配。

若Eden區+Survior1區剩餘內存太少,導致對象無法放入該區域時,就會啓用“分配擔保”,將當前Eden區+Survior1區中的對象轉移到老年代中,然後再將新對象存入Eden區。

3.2 大對象直接進入老年代

所謂“大對象”就是指一個佔用大量連續存儲空間的對象,如數組。

當發現一個大對象在Eden區+Survior1區中存不下的時候就需要分配擔保機制把當前Eden區+Survior1區的所有對象都複製到老年代中去。

我們知道,一個大對象能夠存入Eden區+Survior1區的概率比較小,發生分配擔保的概率比較大,而分配擔保需要涉及到大量的複製,就會造成效率低下。

因此,對於大對象我們直接把他放到老年代中去,從而就能避免大量的複製操作。

那麼,什麼樣的對象纔是“大對象”呢?

通過-XX:PretrnureSizeThreshold參數設置大對象

該參數用於設置大小超過該參數的對象被認爲是“大對象”,直接進入老年代。

注意:該參數只對Serial和ParNew收集器有效。

3.3 生命週期較長的對象進入老年代

老年代用於存儲生命週期較長的對象,那麼我們如何判斷一個對象的年齡呢?

新生代中的每個對象都有一個年齡計數器,當新生代發生一次MinorGC後,存活下來的對象的年齡就加一,當年齡超過一定值時,就將超過該值的所有對象轉移到老年代中去。

使用-XXMaxTenuringThreshold設置新生代的最大年齡

設置該參數後,只要超過該參數的新生代對象都會被轉移到老年代中去。

3.4 相同年齡的對象內存超過Survior內存一半的對象進入老年代

如果當前新生代的Survior中,年齡相同的對象的內存空間總和超過了Survior內存空間的一半,那麼所有年齡相同的對象和超過該年齡的對象都被轉移到老年代中去。無需等到對象的年齡超過MaxTenuringThreshold才被轉移到老年代中去。

3.5 “分配擔保”策略詳解

當垃圾收集器準備要在新生代發起一次MinorGC時,首先會檢查“老年代中最大的連續空閒區域的大小 是否大於 新生代中所有對象的大小?”,也就是老年代中目前能夠將新生代中所有對象全部裝下?

若老年代能夠裝下新生代中所有的對象,那麼此時進行MinorGC沒有任何風險,然後就進行MinorGC。

若老年代無法裝下新生代中所有的對象,那麼此時進行MinorGC是有風險的,垃圾收集器會進行一次預測:根據以往MinorGC過後存活對象的平均數來預測這次MinorGC後存活對象的平均數。

如果以往存活對象的平均數小於當前老年代最大的連續空閒空間,那麼就進行MinorGC,雖然此次MinorGC是有風險的。

如果以往存活對象的平均數大於當前老年代最大的連續空閒空間,那麼就對老年代進行一次Full GC,通過清除老年代中廢棄數據來擴大老年代空閒空間,以便給新生代作擔保。

這個過程就是分配擔保。

注意:
分配擔保是老年代爲新生代作擔保;
新生代中使用“複製”算法實現垃圾回收,老年代中使用“標記-清除”或“標記-整理”算法實現垃圾回收,只有使用“複製”算法的區域才需要分配擔保,因此新生代需要分配擔保,而老年代不需要分配擔保。

第四章 瞭解 Java 虛擬機的垃圾回收算法

Java虛擬機的內存模型分爲五個部分,分別是:程序計數器、Java虛擬機棧、本地方法棧、堆、方法區。

這五個區域既然是存儲空間,那麼爲了避免Java虛擬機在運行期間內存存滿的情況,就必須得有一個垃圾收集者的角色,不定期地回收一些無效內存,以保障Java虛擬機能夠健康地持續運行。

這個垃圾收集者就是平常我們所說的“垃圾收集器”,那麼垃圾收集器在何時清掃內存?清掃哪些數據?這就是接下來我們要解決的問題。

程序計數器、Java虛擬機棧、本地方法棧都是線程私有的,也就是每條線程都擁有這三塊區域,而且會隨着線程的創建而創建,線程的結束而銷燬。那麼,垃圾收集器在何時清掃這三塊區域的問題就解決了。

此外,Java虛擬機棧、本地方法棧中的棧幀會隨着方法的開始而入棧,方法的結束而出棧,並且每個棧幀中的本地變量表都是在類被加載的時候就確定的。因此以上三個區域的垃圾收集工作具有確定性,垃圾收集器能夠清楚地知道何時清掃這三塊區域中的哪些數據。

然而,堆和方法區中的內存清理工作就沒那麼容易了。

堆和方法區所有線程共享,並且都在JVM啓動時創建,一直得運行到JVM停止時。因此它們沒辦法根據線程的創建而創建、線程的結束而釋放。

堆中存放JVM運行期間的所有對象,雖然每個對象的內存大小在加載該對象所屬類的時候就確定了,但究竟創建多少個對象只有在程序運行期間才能確定。

方法區中存放類信息、靜態成員變量、常量。類的加載是在程序運行過程中,當需要創建這個類的對象時纔會加載這個類。因此,JVM究竟要加載多少個類也需要在程序運行期間確定。

因此,堆和方法區的內存回收具有不確定性,因此垃圾收集器在回收堆和方法區內存的時候花了一些心思。

4.1 堆內存的回收

4.1.1 如何判定哪些對象需要回收?

在對堆進行對象回收之前,首先要判斷哪些是無效對象。我們知道,一個對象不被任何對象或變量引用,那麼就是無效對象,需要被回收。一般有兩種判別方式:

引用計數法
每個對象都有一個計數器,當這個對象被一個變量或另一個對象引用一次,該計數器加一;若該引用失效則計數器減一。當計數器爲0時,就認爲該對象是無效對象。
可達性分析法
所有和GC Roots直接或間接關聯的對象都是有效對象,和GC Roots沒有關聯的對象就是無效對象。
GC Roots是指:

Java虛擬機棧所引用的對象(棧幀中局部變量表中引用類型的變量所引用的對象)
方法區中靜態屬性引用的對象
方法區中常量所引用的對象
本地方法棧所引用的對象
兩者對比:

引用計數法雖然簡單,但存在一個嚴重的問題,它無法解決循環引用的問題。

因此,目前主流語言均使用可達性分析方法來判斷對象是否有效。

4.1.2 回收無效對象的過程

當JVM篩選出失效的對象之後,並不是立即清除,而是再給對象一次重生的機會,具體過程如下:

(1)判斷該對象是否覆蓋了finalize()方法

若已覆蓋該方法,並該對象的finalize()方法還沒有被執行過,那麼就會將finalize()扔到F-Queue隊列中;
若未覆蓋該方法,則直接釋放對象內存。
(2)執行F-Queue隊列中的finalize()方法

虛擬機會以較低的優先級執行這些finalize()方法們,也不會確保所有的finalize()方法都會執行結束。如果finalize()方法中出現耗時操作,虛擬機就直接停止執行,將該對象清除。

(3)對象重生或死亡

如果在執行finalize()方法時,將this賦給了某一個引用,那麼該對象就重生了。如果沒有,那麼就會被垃圾收集器清除。

注意:強烈不建議使用finalize()函數進行任何操作!如果需要釋放資源,請使用try-finally。因爲finalize()不確定性大,開銷大,無法保證順利執行。
4.2 方法區的內存回收

我們知道,如果使用複製算法實現堆的內存回收,堆就會被分爲新生代和老年代,新生代中的對象“朝生夕死”,每次垃圾回收都會清除掉大量的對象;而老年代中的對象生命較長,每次垃圾回收只有少量的對象被清除掉。

由於方法區中存放生命週期較長的類信息、常量、靜態變量,因此方法區就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉。

方法區中主要清除兩種垃圾:

廢棄常量
廢棄的類
4.2.1 如何判定廢棄常量?

清除廢棄的常量和清除對象類似,只要常量池中的常量不被任何變量或對象引用,那麼這些常量就會被清除掉。

4.2.2 如何廢棄廢棄的類?

清除廢棄類的條件較爲苛刻:

該類的所有對象都已被清除
該類的java.lang.Class對象沒有被任何對象或變量引用
只要一個類被虛擬機加載進方法區,那麼在堆中就會有一個代表該類的對象:java.lang.Class。這個對象在類被加載進方法區的時候創建,在方法區中該類被刪除時清除。
加載該類的ClassLoader已經被回收
4.3 垃圾收集算法

現在我們知道了判定一個對象是無效對象、判定一個類是廢棄類、判定一個常量是廢棄常量的方法,也就是知道了垃圾收集器會清除哪些數據,那麼接下來介紹如何清除這些數據。

4.3.1 標記-清除算法

首先利用剛纔介紹的方法判斷需要清除哪些數據,並給它們做上標記;然後清除被標記的數據。

分析:

這種算法標記和清除過程效率都很低,而且清除完後存在大量碎片空間,導致無法存儲大對象,降低了空間利用率。

4.3.2 複製算法

將內存分成兩份,只將數據存儲在其中一塊上。當需要回收垃圾時,也是首先標記出廢棄的數據,然後將有用的數據複製到另一塊內存上,最後將第一塊內存全部清除。

分析:

這種算法避免了碎片空間,但內存被縮小了一半。

而且每次都需要將有用的數據全部複製到另一片內存上去,效率不高。

解決空間利用率問題:

在新生代中,由於大量的對象都是“朝生夕死”,也就是一次垃圾收集後只有少量對象存活,因此我們可以將內存劃分成三塊:Eden、Survior1、Survior2,內存大小分別是8:1:1。分配內存時,只使用Eden和一塊Survior1。當發現Eden+Survior1的內存即將滿時,JVM會發起一次MinorGC,清除掉廢棄的對象,並將所有存活下來的對象複製到另一塊Survior2中。那麼,接下來就使用Survior2+Eden進行內存分配。

通過這種方式,只需要浪費10%的內存空間即可實現帶有壓縮功能的垃圾收集方法,避免了內存碎片的問題。

但是,當一個對象要申請內存空間時,發現Eden+Survior中剩下的空間無法放置該對象,此時需要進行Minor GC,如果MinorGC過後空閒出來的內存空間仍然無法放置該對象,那麼此時就需要將對象轉移到老年代中,這種方式叫做“分配擔保”。

什麼是分配擔保?

當JVM準備爲一個對象分配內存空間時,發現此時Eden+Survior中空閒的區域無法裝下該對象,那麼就會觸發MinorGC,對該區域的廢棄對象進行回收。但如果MinorGC過後只有少量對象被回收,仍然無法裝下新對象,那麼此時需要將Eden+Survior中的所有對象都轉移到老年代中,然後再將新對象存入Eden區。這個過程就是“分配擔保”。

4.3.3 標記-整理算法

在回收垃圾前,首先將所有廢棄的對象做上標記,然後將所有未被標記的對象移到一邊,最後清空另一邊區域即可。

分析:

它是一種老年代的垃圾收集算法。老年代中的對象一般壽命比較長,因此每次垃圾回收會有大量對象存活,因此如果選用“複製”算法,每次需要複製大量存活的對象,會導致效率很低。而且,在新生代中使用“複製”算法,當Eden+Survior中都裝不下某個對象時,可以使用老年代的內存進行“分配擔保”,而如果在老年代使用該算法,那麼在老年代中如果出現Eden+Survior裝不下某個對象時,沒有其他區域給他作分配擔保。因此,老年代中一般使用“標記-整理”算法。

4.3.4 分代收集算法

將內存劃分爲老年代和新生代。老年代中存放壽命較長的對象,新生代中存放“朝生夕死”的對象。然後在不同的區域使用不同的垃圾收集算法。

4.4 Java中引用的種類

Java中根據生命週期的長短,將引用分爲4類。

4.4.1 強引用

我們平時所使用的引用就是強引用。

A a = new A();

也就是通過關鍵字new創建的對象所關聯的引用就是強引用。

只要強引用存在,該對象永遠也不會被回收。

4.4.2 軟引用

只有當堆即將發生OOM異常時,JVM纔會回收軟引用所指向的對象。

軟引用通過SoftReference類實現。

軟引用的生命週期比強引用短一些。

4.4.3 弱引用

只要垃圾收集器運行,軟引用所指向的對象就會被回收。

弱引用通過WeakReference類實現。

弱引用的生命週期比軟引用短。

4.4.4 虛引用

虛引用也叫幽靈引用,它和沒有引用沒有區別,無法通過虛引用訪問對象的任何屬性或函數。

一個對象關聯虛引用唯一的作用就是在該對象被垃圾收集器回收之前會受到一條系統通知。

虛引用通過PhantomReference類來實現。

第五章 class 文件結構詳解

5.1 什麼是JVM的“無關性”?

Java具有平臺無關性,也就是任何操作系統都能運行Java代碼。之所以能實現這一點,是因爲Java運行在虛擬機之上,不同的操作系統都擁有各自的Java虛擬機,因此Java能實現“一次編寫,處處運行”。

而JVM不僅具有平臺無關性,還具有語言無關性。

平臺無關性是指不同操作系統都有各自的JVM,而語言無關性是指Java虛擬機能運行除Java以外的代碼!

這聽起來非常驚人,但JVM對能運行的語言是有嚴格要求的。首先來了解下Java代碼的運行過程。

Java源代碼首先需要使用Javac編譯器編譯成class文件,然後啓動JVM執行class文件,從而程序開始運行。

也就是JVM只認識class文件,它並不管何種語言生成了class文件,只要class文件符合JVM的規範就能運行。

因此目前已經有Scala、JRuby、Jython等語言能夠在JVM上運行。它們有各自的語法規則,不過它們的編譯器都能將各自的源碼編譯成符合JVM規範的class文件,從而能夠藉助JVM運行它們。

5.2 縱觀Class文件結構

class文件是二進制文件,它的內容具有嚴格的規範,文件中沒有任何空格,全是連續的0/1。class文件中的所有內容被分爲兩種類型:無符號數 和 表。

無符號數:它表示class文件中的值,這些值沒有任何類型,但有不同的長度。根據這些值長度的不同分爲:u1、u2、u4、u8,分別代表1字節的無符號數、2字節的無符號數、4字節的無符號數、8字節的無符號數。
表:class文件中所有數據(即無符號數)要麼單獨存在,要麼由多個無符號數組成二維表。即class文件中的數據要麼是單個值,要麼是二維表。
5.2.1 class文件的組織結構

魔數
本文件的版本信息
常量池
訪問標誌
類索引
父類索引
接口索引集合
字段表集合
方法表集合
5.3 Class文件的構成1:魔數

class文件的頭4個字節稱爲魔數,用來表示這個class文件的類型。

魔數的作用就相當於文件後綴名,只不過後綴名容易被修改,不安全,因此在class文件中標示文件類型比較合適。

class文件的魔數是用16進製表示的“CAFEBABE”,非常具有浪漫主義色彩,誰說程序員的情商都很低!

5.4 Class文件的構成2:版本信息

緊接着魔數的4個字節是版本號。它表示本class中使用的是哪個版本的JDK。

在高版本的JVM上能夠運行低版本的class文件,但在低版本的JVM上無法運行高版本的class文件,即使該class文件中沒有用到任何高版本JDK的特性也無法運行!

5.5 Class文件的構成3:常量池

5.5.1 什麼是常量池?

緊接着版本號之後的就是常量池。常量池中存放兩種類型的常量:

字面值常量
字面值常量即我們在程序中定義的字符串、被final修飾的值。
符號引用
符號引用就是我們定義的各種名字:
類和接口的全限定名
字段的名字 和 描述符
方法的名字 和 描述符
5.5.2 常量池的特點

常量池長度不固定
常量池的大小是不固定的,因此常量池開頭放置一個u2類型的無符號數,用來存儲當前常量池的容量。JVM根據這個值就知道常量池的頭尾來。
注:這個值是從1開始的,若爲5表示池中有4個常量。

常量池中的常量由而爲表來表示
常量池開頭有個常量池容量計數器,接下來就全是一個個常量了,只不過常量都是由一張張二維表構成,除了記錄常量的值以外,還記錄當前常量的相關信息。
常量池是class文件的資源倉庫
常量池是與本class中其它部分關聯最多的部分
常量池是class文件中空間佔用最大的部分之一
5.5.3 常量池中常量的類型

剛纔介紹了,常量池中的常量大體上分爲:字面值常量 和 符號引用。在此基礎上,根據常量的數據類型不同,又可以被細分爲14種常量類型。這14種常量類型都有各自的二維表示結構。每種常量類型的頭1個字節都是tag,用於表示當前常量屬於14種類型中的哪一個。

以CONSTANT_Class_info常量爲例,它的二維表示結構如下:

CONSTANT_Class_info表:

類型名稱數量u1tag1u2name_index1

tag表示當前常量的類型(當前常量爲CONSTANT_Class_info,因此tag的值應爲7,表示一個類或接口的全限定名);

name_index表示這個類或接口全限定名的位置。它的值表示指向常量池的第幾個常量。它會指向一個CONSTANT_Utf8_info類型的常量,它的二維表結構如下:

CONSTANT_Utf8_info表:

類型名稱數量u1tag1u2length1u1byteslength

CONSTANT_Utf8_info表示字符串常量;
tag表示當前常量的類型,這裏應該是1;
length表示這個字符串的長度;
bytes爲這個字符串的內容(採用縮略的UTF8編碼)
問:爲什麼Java中定義的類、變量名字必須小於64K?

類、接口、變量等名字都屬於符號引用,它們都存儲在常量池中。而不管哪種符號引用,它們的名字都由CONSTANT_Utf8_info類型的常量表示,這種類型的常量使用u2存儲字符串的長度。由於2字節最多能表示65535個數,因此這些名字的最大長度最多隻能是64K。

問:什麼是UTF-8編碼?什麼是縮略UTF-8編碼?

前者每個字符使用3個字節表示,而後者把128個ASKII碼用1字節表示,某些字符用2字節表示,某些字符用3字節表示。

5.6 Class文件的構成4:訪問標誌

在常量池之後是2字節的訪問標誌。訪問標誌是用來表示這個class文件是類還是接口、是否被public修飾、是否被abstract修飾、是否被final修飾等。

由於這些標誌都由是/否表示,因此可以用0/1表示。

訪問標誌爲2字節,可以表示16位標誌,但JVM目前只定義了8種,未定義的直接寫0.

5.7 Class文件的構成5:類索引、父類索引、接口索引集合

類索引、父類索引、接口索引集合是用來表示當前class文件所表示類的名字、父類名字、接口們的名字。

它們按照順序依次排列,類索引和父類索引各自使用一個u2類型的無符號常量,這個常量指向CONSTANT_Class_info類型的常量,該常量的bytes字段記錄了本類、父類的全限定名。

由於一個類的接口可能有好多個,因此需要用一個集合來表示接口索引,它在類索引和父類索引之後。這個集合頭兩個字節表示接口索引集合的長度,接下來就是接口的名字索引。

5.8 Class文件的構成6:字段表的集合

5.8.1 什麼是字段表集合?

接下來是字段表的集合。字段表集合用於存儲本類所涉及到的成員變量,包括實例變量和類變量,但不包括方法中的局部變量。

每一個字段表只表示一個成員變量,本類中所有的成員變量構成了字段表集合。

5.8.2 字段表結構的定義

類型名稱數量u2access_flags1u2name_index1u2descriptor_index1u2attributes_count1attribute_infoattributesattributes_count

access_flags:字段的訪問標誌。在Java中,每個成員變量都有一系列的修飾符,和上述class文件的訪問標誌的作用一樣,只不過成員變量的訪問標誌與類的訪問標誌稍有區別。
name_index:本字段名字的索引。指向一個CONSTANT_Class_info類型的常量,這裏面存儲了本字段的名字等信息。
descriptor_index:描述符。用於描述本字段在Java中的數據類型等信息(下面詳細介紹)。
attributes_count:屬性表集合的長度。
attributes:屬性表集合。到descriptor_index爲止是字段表的固定信息,光有上述信息可能無法完整地描述一個字段,因此用屬性表集合來存放額外的信息,比如一個字段的值(下面會詳細介紹)。
5.8.3 什麼是描述符?

成員變量(包括靜態成員變量和實例變量)和 方法都有各自的描述符。

對於字段而言,描述符用於描述字段的數據類型;

對於方法而言,描述符用於描述字段的數據類型、參數列表、返回值。

在描述符中,基本數據類型用大寫字母表示,對象類型用“L對象類型的全限定名”表示,數組用“[數組類型的全限定名”表示。

描述方法時,將參數根據上述規則放在()中,()右側按照上述方法放置返回值。而且,參數之間無需任何符號。

5.8.4 字段表集合的注意點

一個class文件的字段表集合中不能出現從父類/接口繼承而來字段;
一個class文件的字段表集合中可能會出現程序猿沒有定義的字段
如編譯器會自動地在內部類的class文件的字段表集合中添加外部類對象的成員變量,供內部類訪問外部類。
Java中只要兩個字段名字相同就無法通過編譯。但在JVM規範中,允許兩個字段的名字相同但描述符不同的情況,並且認爲它們是兩個不同的字段。
5.9 Class文件的構成7:方法表的集合

在class文件中,所有的方法以二維表的形式存儲,每張表來表示一個函數,一個類中的所有方法構成方法表的集合。

方法表的結構和字段表的結構一致,只不過訪問標誌和屬性表集合的可選項有所不同。

類型名稱數量u2access_flags1u2name_index1u2descriptor_index1u2attributes_count1attribute_infoattributesattributes_count

方法表的屬性表集合中有一張Code屬性表,用於存儲當前方法經編譯器編譯過後的字節碼指令。

方法表集合的注意點

如果本class沒有重寫父類的方法,那麼本class文件的方法表集合中是不會出現父類/父接口的方法表;
本class的方法表集合可能出現程序猿沒有定義的方法
編譯器在編譯時會在class文件的方法表集合中加入類構造器
和實例構造器。
重載一個方法需要有相同的簡單名稱和不同的特徵簽名。JVM的特徵簽名和Java的特徵簽名有所不同:
Java特徵簽名:方法參數在常量池中的字段符號引用的集合
JVM特徵簽名:方法參數+返回值
第六章 詳解 Java 類的加載過程

6.1 類的生命週期

一個類從加載進內存到卸載出內存爲止,一共經歷7個階段:

加載——>驗證——>準備——>解析——>初始化——>使用——>卸載

其中,類加載包括5個階段:

加載——>驗證——>準備——>解析——>初始化

在類加載的過程中,以下3個過程稱爲連接:

驗證——>準備——>解析

因此,JVM的類加載過程也可以概括爲3個過程:

加載——>連接——>初始化

C/C++在運行前需要完成預處理、編譯、彙編、鏈接;而在Java中,類加載(加載、連接、初始化)是在程序運行期間完成的。

在程序運行期間進行類加載會稍微增加程序的開銷,但隨之會帶來更大的好處——提高程序的靈活性。Java語言的靈活性體現在它可以在運行期間動態擴展,所謂動態擴展就是在運行期間動態加載和動態連接。

6.2 類加載的時機

6.2.1 類加載過程中每個步驟的順序

我們已經知道,類加載的過程包括:加載、連接、初始化,連接又分爲:驗證、準備、解析,所以說類加載一共分爲5步:加載、驗證、準備、解析、初始化。

其中加載、驗證、準備、初始化的開始順序是依次進行的,這些步驟開始之後的過程可能會有重疊。

而解析過程會發生在初始化過程中。

6.2.2 類加載過程中“初始化”開始的時機

JVM規範中只定義了類加載過程中初始化過程開始的時機,加載、連接過程都應該在初始化之前開始(解析除外),這些過程具體在何時開始,JVM規範並沒有定義,不同的虛擬機可以根據具體的需求自定義。

初始化開始的時機:

在運行過程中遇到如下字節碼指令時,如果類尚未初始化,那就要進行初始化:new、getstatic、putstatic、invokestatic。這四個指令對應的Java代碼場景是:
通過new創建對象;
讀取、設置一個類的靜態成員變量(不包括final修飾的靜態變量);
調用一個類的靜態成員函數。
使用java.lang.reflect進行反射調用的時候,如果類沒有初始化,那就需要初始化;
當初始化一個類的時候,若其父類尚未初始化,那就先要讓其父類初始化,然後再初始化本類;
當虛擬機啓動時,虛擬機會首先初始化帶有main方法的類,即主類;
6.2.3 主動引用 與 被動引用

JVM規範中要求在程序運行過程中,“當且僅當”出現上述4個條件之一的情況纔會初始化一個類。如果間接滿足上述初始化條件是不會初始化類的。

其中,直接滿足上述初始化條件的情況叫做主動引用;間接滿足上述初始化過程的情況叫做被動引用。

那麼,只有當程序在運行過程中滿足主動引用的時候纔會初始化一個類,若滿足被動引用就不會初始化一個類。

6.2.4 被動引用的場景示例

示例一

public class Fu{
public static String name = "柴毛毛";
static{
System.out.println("父類被初始化!");
}
}
public class Zi{
static{
System.out.println("子類被初始化!");
}
}
public static void main(String[] args){
System.out.println(Zi.name);
}
輸出結果:

父類被初始化!

柴毛毛

原因分析:

本示例看似滿足初始化時機的第一條:當要獲取某一個類的靜態成員變量的時候如果該類尚未初始化,則對該類進行初始化。

但由於這個靜態成員變量屬於Fu類,Zi類只是間接調用Fu類中的靜態成員變量,因此Zi類調用name屬性屬於間接引用,而Fu類調用name屬性屬於直接引用,由於JVM只初始化直接引用的類,因此只有Fu類被初始化。

示例二

public class A{
public static void main(String[] args){
Fu[] arr = new Fu[10];
}
}
輸出結果:

並沒有輸出“父類被初始化!”

原因分析:

這個過程看似滿足初始化時機的第一條:遇到new創建對象時若類沒被初始化,則初始化該類。

但現在通過new要創建的是一個數組對象,而非Fu類對象,因此也屬於間接引用,不會初始化Fu類。

示例三

public class Fu{
public static final String name = "柴毛毛";
static{
System.out.println("父類被初始化!");
}
}
public class A{
public static void main(String[] args){
System.out.println(Fu.name);
}
}
輸出結果:

柴毛毛

原因分析:

本示例看似滿足類初始化時機的第一個條件:獲取一個類靜態成員變量的時候若類尚未初始化則初始化類。

但是,Fu類的靜態成員變量被final修飾,它已經是一個常量。被final修飾的常量在Java代碼編譯的過程中就會被放入它被引用的class文件的常量池中(這裏是A的常量池)。所以程序在運行期間如果需要調用這個常量,直接去當前類的常量池中取,而不需要初始化這個類。

6.2.5 接口的初始化

接口和類都需要初始化,接口和類的初始化過程基本一樣,不同點在於:類初始化時,如果發現父類尚未被初始化,則先要初始化父類,然後再初始化自己;但接口初始化時,並不要求父接口已經全部初始化,只有程序在運行過程中用到當父接口中的東西時才初始化父接口。

6.3 類加載的過程

通過之前的介紹可知,類加載過程共有5個步驟,分別是:加載、驗證、準備、解析、初始化。其中,驗證、準備、解析稱爲連接。下面詳細介紹這5個過程JVM所做的工作。

6.3.1 加載

注意:“加載”是“類加載”過程的第一步,千萬不要混淆。

在加載過程中,JVM主要做3件事情:

通過一個類的全限定名來獲取這個類的二進制字節流,即class文件:
在程序運行過程中,當要訪問一個類時,若發現這個類尚未被加載,並滿足類初始化時機的條件時,就根據要被初始化的這個類的全限定名找到該類的二進制字節流,開始加載過程。
將二進制字節流的存儲結構轉化爲特定的數據結構,存儲在方法區中;
在內存中創建一個java.lang.Class類型的對象:
接下來程序在運行過程中所有對該類的訪問都通過這個類對象,也就是這個Class類型的類對象是提供給外界訪問該類的接口。
從哪裏加載?

JVM規範對於加載過程給予了較大的寬鬆度。一般二進制字節流都從已經編譯好的本地class文件中讀取,此外還可以從以下地方讀取:

從壓縮包中讀取,如:Jar、War、Ear等。
從其它文件中動態生成,如:從JSP文件中生成Class類。
從數據庫中讀取,將二進制字節流存儲至數據庫中,然後在加載時從數據庫中讀取。有些中間件會這麼做,用來實現代碼在集羣間分發。
從網絡中獲取,從網絡中獲取二進制字節流。典型就是Applet。
類 和 數組加載過程的區別?

數組也有類型,稱爲“數組類型”。如:

String[] str = new String[10];

這個數組的數組類型是Ljava.lang.String,而String只是這個數組中元素的類型。

當程序在運行過程中遇到new關鍵字創建一個數組時,由JVM直接創建數組類,再由類加載器創建數組中的元素類。

而普通類的加載由類加載器完成。既可以使用系統提供的引導類加載器,也可以使用用戶自定義的類加載器。

加載過程的注意點

JVM規範並未給出類在方法區中存放的數據結構
類完成加載後,二進制字節流就以特定的數據結構存儲在方法區中,但存儲的數據結構是由虛擬機自己定義的,JVM規範並沒有指定。
JVM規範並沒有指定Class對象存放的位置
在二進制字節流以特定格式存儲在方法區後,JVM會創建一個java.lang.Class類型的對象,作爲本類的外部接口。既然是對象就應該存放在堆內存中,不過JVM規範並沒有給出限制,不同的虛擬機根據自己的需求存放這個對象。HotSpot將Class對象存放在方法區。
加載階段和連接階段是交叉的
通過之前的介紹可知,類加載過程中每個步驟的開始順序都有嚴格限制,但每個步驟的結束順序沒有限制。也就是說,類加載過程中,必須按照如下順序開始:
加載、連接、初始化,但結束順序無所謂,因此由於每個步驟處理時間的長短不一就會導致有些步驟會出現交叉。
6.3.2 驗證

驗證階段比較耗時,它非常重要但不一定必要,如果所運行的代碼已經被反覆使用和驗證過,那麼可以使用-Xverify:none參數關閉,以縮短類加載時間。

驗證的目的是什麼?

驗證是爲了保證二進制字節流中的信息符合虛擬機規範,並沒有安全問題。

爲什麼需要驗證?

雖然Java語言是一門安全的語言,它能確保程序猿無法訪問數組邊界以外的內存、避免讓一個對象轉換成任意類型、避免跳轉到不存在的代碼行,如果出現這些情況,編譯無法通過。也就是說,Java語言的安全性是通過編譯器來保證的。

但是我們知道,編譯器和虛擬機是兩個獨立的東西,虛擬機只認二進制字節流,它不會管所獲得的二進制字節流是哪來的,當然,如果是編譯器給它的,那麼就相對安全,但如果是從其它途徑獲得的,那麼無法確保該二進制字節流是安全的。通過上文可知,虛擬機規範中沒有限制二進制字節流的來源,那麼任意來源的二進制字節流虛擬機都能接受,爲了防止字節流中有安全問題,因此需要驗證!

驗證的過程

(1)文件格式驗證

這個階段主要驗證輸入的二進制字節流是否符合class文件結構的規範。二進制字節流只有通過了本階段的驗證,纔會被允許存入到方法區中。

本驗證階段是基於二進制字節流的,而後面的三個驗證階段都是在方法區中進行,並基於類特定的數據結構的。

通過上文可知,加載開始前,二進制字節流還沒進方法區,而加載完成後,二進制字節流已經存入方法區。而在文件格式驗證前,二進制字節流尚未進入方法區,文件格式驗證通過之後才進入方法區。也就是說,加載開始後,立即啓動了文件格式驗證,本階段驗證通過後,二進制字節流被轉換成特定數據結構存儲至方法區中,繼而開始下階段的驗證和創建Class對象等操作。這個過程印證了:加載和驗證是交叉進行的。

(2)元數據驗證

本階段對方法區中的字節碼描述信息進行語義分析,確保其符合Java語法規範。

(3)字節碼驗證

本階段是驗證過程的最複雜的一個階段。本階段對方法體進行語義分析,保證方法在運行時不會出現危害虛擬機的事件。

(4)符號引用驗證,本階段驗證發生在解析階段,確保解析能正常執行。

6.3.3 準備

準備階段完成兩件事情:

爲已經在方法區中的類中的靜態成員變量分配內存
類的靜態成員變量也存儲在方法區中。
爲靜態成員變量設置初始值
初始值爲0、false、null等。
示例1:

public static String name = "柴毛毛";

在準備階段,JVM會在方法區中爲name分配內存空間,並賦上初始值null。

給name賦上"柴毛毛"是在初始化階段完成的。

示例2:

public static final String name = "柴毛毛";

被final修飾的常量如果有初始值,那麼在編譯階段就會將初始值存入constantValue屬性中,在準備階段就將constantValue的值賦給該字段。

6.3.3 解析

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

6.3.4 初始化

初始化階段就是執行類構造器clinit()的過程。

clinit()方法由編譯器自動產生,收集類中static{}代碼塊中的類變量賦值語句和類中靜態成員變量的賦值語句。在準備階段,類中靜態成員變量已經完成了默認初始化,而在初始化階段,clinit()方法對靜態成員變量進行顯示初始化。

初始化過程的注意點:

clinit()方法中靜態成員變量的賦值順序是根據Java代碼中成員變量的出現的順序決定的。
靜態代碼塊能訪問出現在靜態代碼塊之前的靜態成員變量,無法訪問出現在靜態代碼塊之後的成員變量。
靜態代碼塊能給出現在靜態代碼塊之後的靜態成員變量賦值。
構造函數init()需要顯示調用父類構造函數,而類的構造函數clinit()不需要調用父類的類構造函數,因爲虛擬機會確保子類的clinit()方法執行前已經執行了父類的clinit()方法。
如果一個類/接口中沒有靜態代碼塊,也沒有靜態成員變量的賦值操作,那麼編譯器就不會生成clinit()方法。
接口也需要通過clinit()方法爲接口中定義的靜態成員變量顯示初始化。
接口中不能使用靜態代碼塊。
接口在執行clinit()方法前,虛擬機不會確保其父接口的clinit()方法被執行,只有當父接口中的靜態成員變量被使用到時纔會執行父接口的clinit()方法。
虛擬機會給clinit()方法加鎖,因此當多條線程同時執行某一個類的clinit()方法時,只有一個方法會被執行,其它的方法都被阻塞。並且,只要有一個clinit()方法執行完,其它的clinit()方法就不會再被執行。因此,在同一個類加載器下,同一個類只會被初始化一次。
6.4 類加載器

6.4.1 類與類加載器

類加載器的作用:將class文件加載進JVM的方法區,並在方法區中創建一個java.lang.Class對象作爲外界訪問這個類的接口。
類與類加載器的關係:比較兩個類是否相等,只有當這兩個類由同一個加載器加載纔有意義;否則,即使同一個class文件被不同的類加載器加載,那這兩個類必定不同,即通過類的Class對象的equals執行的結果必爲false。
6.4.2 類加載器種類

JVM提供如下三種類加載器:

啓動類加載器
負責加載Java_Homelib中的class文件。
擴展類加載器
負責加載Java_Homelibext目錄下的class文件。
應用程序類加載器
負責加載用戶classpath下的class文件。
6.4.3 雙親委派模型

工作過程:如果一個類加載器收到了加載類的請求,它首先將請求交由父類加載器加載;若父類加載器加載失敗,當前類加載器纔會自己加載類。
作用:像java.lang.Object這些存放在rt.jar中的類,無論使用哪個類加載器加載,最終都會委派給最頂端的啓動類加載器加載,從而使得不同加載器加載的Object類都是同一個。
原理:雙親委派模型的代碼在java.lang.ClassLoader類中的loadClass函數中實現,其邏輯如下:
首先檢查類是否被加載;
若未加載,則調用父類加載器的loadClass方法;
若該方法拋出ClassNotFoundException異常,則表示父類加載器無法加載,則當前類加載器調用findClass加載類;
若父類加載器可以加載,則直接返回Class對象;
第七章 Java 虛擬機的鎖優化策略

7.1 自旋鎖

背景:互斥同步對性能最大的影響是阻塞,掛起和恢復線程都需要轉入內核態中完成;並且通常情況下,共享數據的鎖定狀態只持續很短的一段時間,爲了這很短的一段時間進行上下文切換並不值得。
原理:當一條線程需要請求一把已經被佔用的鎖時,並不會進入阻塞狀態,而是繼續持有CPU執行權等待一段時間,該過程稱爲『自旋』。
優點:由於自旋等待鎖的過程線程並不會引起上下文切換,因此比較高效;
缺點:自旋等待過程線程一直佔用CPU執行權但不處理任何任務,因此若該過程過長,那就會造成CPU資源的浪費。
自適應自旋:自適應自旋可以根據以往自旋等待時間的經驗,計算出一個較爲合理的本次自旋等待時間。
7.2 鎖清除

編譯器會清除一些使用了同步,但同步塊中沒有涉及共享數據的鎖,從而減少多餘的同步。

7.3 鎖粗化

若有一系列操作,反覆地對同一把鎖進行上鎖和解鎖操作,編譯器會擴大這部分代碼的同步塊的邊界,從而只使用一次上鎖和解鎖操作。

7.4 輕量級鎖

本質:使用CAS取代互斥同步。
背景:『輕量級鎖』是相對於『重量級鎖』而言的,而重量級鎖就是傳統的鎖。
輕量級鎖與重量級鎖的比較:
重量級鎖是一種悲觀鎖,它認爲總是有多條線程要競爭鎖,所以它每次處理共享數據時,不管當前系統中是否真的有線程在競爭鎖,它都會使用互斥同步來保證線程的安全;
而輕量級鎖是一種樂觀鎖,它認爲鎖存在競爭的概率比較小,所以它不使用互斥同步,而是使用CAS操作來獲得鎖,這樣能減少互斥同步所使用的『互斥量』帶來的性能開銷。
實現原理:
對象頭稱爲『Mark Word』,虛擬機爲了節約對象的存儲空間,對象處於不同的狀態下,Mark Word中存儲的信息也所有不同。
Mark Word中有個標誌位用來表示當前對象所處的狀態。
當線程請求鎖時,若該鎖對象的Mark Word中標誌位爲01(未鎖定狀態),則在該線程的棧幀中創建一塊名爲『鎖記錄』的空間,然後將鎖對象的Mark Word拷貝至該空間;最後通過CAS操作將鎖對象的Mark Word指向該鎖記錄;
若CAS操作成功,則輕量級鎖的上鎖過程成功;
若CAS操作失敗,再判斷當前線程是否已經持有了該輕量級鎖;若已經持有,則直接進入同步塊;若尚未持有,則表示該鎖已經被其他線程佔用,此時輕量級鎖就要膨脹成重量級鎖。
前提:輕量級鎖比重量級鎖性能更高的前提是,在輕量級鎖被佔用的整個同步週期內,不存在其他線程的競爭。若在該過程中一旦有其他線程競爭,那麼就會膨脹成重量級鎖,從而除了使用互斥量以外,還額外發生了CAS操作,因此更慢!
7.5 偏向鎖

作用:偏向鎖是爲了消除無競爭情況下的同步原語,進一步提升程序性能。
與輕量級鎖的區別:輕量級鎖是在無競爭的情況下使用CAS操作來代替互斥量的使用,從而實現同步;而偏向鎖是在無競爭的情況下完全取消同步。
與輕量級鎖的相同點:它們都是樂觀鎖,都認爲同步期間不會有其他線程競爭鎖。
原理:當線程請求到鎖對象後,將鎖對象的狀態標誌位改爲01,即偏向模式。然後使用CAS操作將線程的ID記錄在鎖對象的Mark Word中。以後該線程可以直接進入同步塊,連CAS操作都不需要。但是,一旦有第二條線程需要競爭鎖,那麼偏向模式立即結束,進入輕量級鎖的狀態。
優點:偏向鎖可以提高有同步但沒有競爭的程序性能。但是如果鎖對象時常被多條線程競爭,那偏向鎖就是多餘的。
偏向鎖可以通過虛擬機的參數來控制它是否開啓。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章