您好,登錄后才能下訂單哦!
前言:
今天要給大家分享的是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表:
類型名稱數量u1tag1u2length2u1byteslength
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_Home\lib中的class文件。
擴展類加載器
負責加載Java_Home\lib\ext目錄下的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操作都不需要。但是,一旦有第二條線程需要競爭鎖,那么偏向模式立即結束,進入輕量級鎖的狀態。
優點:偏向鎖可以提高有同步但沒有競爭的程序性能。但是如果鎖對象時常被多條線程競爭,那偏向鎖就是多余的。
偏向鎖可以通過虛擬機的參數來控制它是否開啟。
小編在學習過程中整理了一些學習資料,可以分享給做java的工程師朋友們,相互交流學習,需要的可以加入我的學習交流群?778477315?即可免費獲取Java架構學習資料(里面有高可用、高并發、高性能及分布式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)
其中覆蓋了互聯網的方方面面,期間碰到各種產品各種場景下的各種問題,很值得大家借鑒和學習,擴展自己的技術廣度和知識面。最后記得幫作者點個關注
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。