秋招準備之——Java虛擬機

秋招復習筆記系列目錄(不斷更新中):

前段時間看了周志明老師的《深入理解Java虛擬機(第三版)》,加上自己在看的過程中查找的一些資料和理解,做了一些筆記,今天趁着複習,在這裏分享一下。希望能幫助到同在複習Java虛擬機的同學,希望大家秋招Offer多多!

一、Java內存區域與內存溢出異常

1.運行時數據區域

在這裏插入圖片描述

  • 1.程序計數器: 當前線程所執行字節碼的行號指示器,線程私有
  • 2.虛擬機棧: 描述方法執行的線程內存模型,每個方法執行的時候,會同步創建一個幀棧存儲局部變量等信息。與方法中的局部變量緊密相關,局部變量會存放在局部變量表中,在編譯期間,局部變量表所需的空間就確定了。局部變量中的存儲空間以局部變量槽表示,long和double佔兩個變量槽,其他佔一個。
    • 出現異常的情況:
      • 1.線程請求的棧深度大於虛擬機允許的深度會StackOverflow
      • 2.如果虛擬機棧可以動態擴容,當擴展無法申請到足夠內存時會OutOfMemory
  • 3.本地方法棧: 和虛擬機棧類似,但本地方法棧爲native方法服務
  • 4.堆: 所有線程共享,在虛擬機啓動時創建,用於存放對象實例(所有的對象實例都在堆上分配),堆是垃圾收集器管理的區域。堆中可以劃分出多個線程私有的分配緩衝區(TLAB,在分配內存時,每個線程都有自己的本地線程緩存池,分配時先從緩存池中分配,防止同步操作帶來的性能問題),以提升對象分配效率。當堆中無法完成實例分配且堆無法擴展時,會拋出OOM異常。
  • 5.方法區: 所有線程共享,存儲已被虛擬機加載的類型信息,常量,靜態變量等,無法完成內存分配時,會拋OOM異常
    • 5.1 運行時常量池: 是方法區的一部分,存放字面量及符號引用(比如字符串常量池)
  • 6.直接內存: 堆外內存,受到本機總內存大小以及處理器尋址空間的限制。

2.HotSpot對象探祕

2.1 對象創建的大致步驟:

在這裏插入圖片描述

2.2 對象的內存佈局

在HotSpot虛擬機中,對象在堆內存中的存儲佈局分爲三個部分:

  • 1.對象頭
    • Mark Word: 存儲對象自身的運行時數據,如HashCode、GC分代年齡以及鎖有關的信息
    • 類型指針: 對象指向它類型元數據的指針,通過這個對象確定類型是哪個類的實例
    • ③數組長度: 如果對象是數組,還需要一塊內存記錄數組長度。
  • 2.對象信息: 存儲對象具體的信息,相同寬度的字段總是被分配到一起存放
  • 3.對齊填充: 僅僅起佔位符作用(HotSpot中任何對象的大小必須8字節的整數倍,不足的需要填充)

2.3 對象的訪問定位

訪問過程:通過棧上的reference數據來操作堆上的具體對象,如下圖所示:
通過句柄訪問對象
在這裏插入圖片描述
通過直接指針訪問對象:
在這裏插入圖片描述
對象類型數據和實例區別:

  • 對象類型數據(方法區):對象的類型、父類、實現的接口、方法等
  • 對象實例數據(堆 ):對象中各個實例字段的數據

二、垃圾收集器與內存分配策略

2.1 對象生命的確定

1.引用計數器算法

在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器爲零的對象就是不可能再被使用的。

2.可達性分析算法

這個算法的基本思路就是通過一系列稱爲“GC Roots”的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

  • 可以作爲GCRoot的對象有哪些: 虛擬機棧中本地方法變量表中的引用對象、方法區中的類靜態屬性引用的對象,方法區中的常量引用的對象,本地方法棧中JNI的引用對象。
  • **爲什麼要選擇這些對象作爲GCRoot:**可達性分析的基本思路是,以當前活着的對象爲root,遍歷出他們(引用)關聯的所有對象,沒有遍歷到對象即爲非存活對象,這部分對象可以GC掉。當前幀棧中的引用型變量、靜態變量引用的對象、本地方法棧JNI對象,是當前存活的對象,所有他們應該作爲GCRoots。方法區中的常量引用對象,在當前可能存活,因此,也可能是GC roots的一部分。還有其他一些對象也可能是GC Roots的一部分,比如被classloader加載的class對象,monitor的對象,被JVM持有的對象等等,這些都需要視當前情況而定。

3.引用類型

  • 強引用: 是指在程序代碼之中普遍存在的引用賦值,即類似Object obj=new Object()這種引用關係。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象

  • 軟引用: 用來描述一些還有用,但非必須的對象。只被軟引用關聯着的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的內存,纔會拋出內存溢出異常。

  • 弱引用: 弱引用也是用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生爲止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。

  • 虛引用: 爲一個對象設置虛引用關聯的唯一目的只是爲了能在這個對象被收集器回收時收到一個系統通知。

2.2 垃圾收集算法

1.分代收集理論

  • (1) 兩個分代假說

    • 弱分代假說:絕大多數對象都是朝生夕滅
    • 強分代假說:熬過越多次垃圾收集過程的對象就難以消亡
    • 跨代引用假說:跨代引用相對於同代引用來說僅佔極少數。所以新生代GC時,若存在老年代引用時,直接判定對象,若干年齡後進去老年代,存活避免新生代與老年代有GC引用鏈,導致新生代GC的時候,需要進行老年代GC
  • (2) 垃圾收集器的設計原則: 應該將Java堆劃分出不同的區域,然後將對象根據其年齡(熬過垃圾收集過程的次數)分配到不同的區域中存儲。

  • (3) Java虛擬機中的設計: 一般把Java堆分成新生代和老生代兩個區域。新生代的未被收集的對象會逐漸跨向老生代。

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

      • Eden區:Java新對象的出生地(如果新創建的對象佔用內存很大則直接分配給老年代)。當Eden區內存不夠的時候就會觸發一次MinorGc,對新生代區進行一次垃圾回收。
      • ServivorTo:保留了一次MinorGc過程中的倖存者。
      • ServivorFrom: 上一次GC的倖存者,作爲這一次GC的被掃描者。當JVM無法爲新建對象分配內存空間的時候(Eden區滿的時候),JVM觸發MinorGc。因此新生代空間佔用越低,MinorGc越頻繁。 MinorGC採用複製算法。
    • 老年代: 老年代的對象比較穩定,所以MajorGC不會頻繁執行。

      Minor GC(新生代GC):簡單理解就是發生在年輕代的GC。
      Minor GC的觸發條件爲:當產生一個新對象,新對象優先在Eden區分配。如果Eden區放不下這個對象,虛擬機會使用複製算法發生一次Minor GC,清除掉無用對象,同時將存活對象移動到Survivor的其中一個區(fromspace區或者tospace區)。虛擬機會給每個對象定義一個對象年齡(Age)計數器,對象在Survivor區中每“熬過”一次GC,年齡就會+1。待到年齡到達一定歲數(默認是15歲),虛擬機就會將對象移動到年老代。如果新生對象在Eden區無法分配空間時,此時發生Minor GC。發生MinorGC,對象會從Eden區進入Survivor區,如果Survivor區放不下從Eden區過來的對象時,此時會使用分配擔保機制將對象直接移動到年老代。
      Major GC(老年代GC)的觸發條件:清理老年代,
      Full GC(堆清理):整個堆的清理,包括老年代和新生代
      

2.標記清除算法

  • 定義: 算法分爲標記清除兩個階段,首先標記出要回收的對象(或者標記處不需要回收的對象),然後統一回收。
  • 缺點: ①效率不穩定(對象太多時效率下降);②內存空間碎片化
    在這裏插入圖片描述

3.標記複製算法

  • 定義: 將內存按容量劃分成大小相等的兩塊,每次只適用其中的一塊,當一塊用完了,就將還存活的對象複製到另一半,將已使用的一半全部清除掉。能避免內存空間碎片化的問題。 此方法一般用於新生代的垃圾回收中
  • 缺點: 可用內存縮小爲原來的一半,會產生對象複製的開銷
    在這裏插入圖片描述

4.標記整理算法

  • 定義: 標記過程和標記清除算法一樣,但後續步驟不是清除,而是所有存活的對象都向內存空間另一端移動,然後直接清理掉邊界以外的內存
  • 缺點: 大量老生代存活的對象的移動,耗費時間,且需要全程暫停用戶程序。基於此,一種解決方式是,先使用標記清除算法,當內存碎片化嚴重到不可忍受時,再使用標記整理算法整理一次
    在這裏插入圖片描述

2.3 HotSpot的算法實現細節

1.根節點枚舉

枚舉GCRoots,利用OopMaps的數據結構,來達到根節點快速枚舉,OopMaps其實就是一個映射表,通過映射表知道在對象內的什麼偏移量上是什麼類型的數據。

2.安全點

只在安全點的位置建立OopMaps,強制到達安全點以後才暫停,進行垃圾收集,通常在方法調用、循環跳轉、異常跳轉等地方設置安全點。

3.安全區域

安全區域是指能夠確保在某一段代碼片段之中,引用關係不會發生變化,因此,在這個區域中任意地方開始垃圾收集都是安全的。我們也可以把安全區域看作被擴展拉伸了的安全點。當代碼執行到安全區域時,首先標識自己已經進入了安全區域,那樣如果在這段時間裏JVM發起GC,就不用管標示自己在安全區域的那些線程了,在線程離開安全區域時,會檢查系統是否正在執行GC,如果是,就等到GC完成後再離開安全區域。

在這裏插入圖片描述

4.記憶集和卡表

爲了解決跨代引用問題,在新生代引入的記錄集(Remember Set)的數據結構(記錄從非收集區到收集區的指針集合),避免把整個老年代加入GCRoots掃描範圍。這樣在垃圾收集場景中,收集器只需通過記憶集判斷出某一塊非收集區域是否存在指向收集區域的指針即可,無需瞭解跨代引用指針的全部細節。可以採用不同的記錄粒度,以節省記憶集的存儲維護成本。如:

  • 字長精度:每個記錄精確到一個機器字長(處理器的尋址位數,如常見的 32 位或 64 位),該字包含跨代指針
  • 對象精度:每個記錄精確到一個對象,該對象中有字段包含跨代指針
  • 卡精度:每個記錄精確到一塊內存區域,該區域中有對象包含跨代指針

卡精度使用"卡表"的方式實現記憶集,卡表使用一個字節數組實現,每個元素對應着其標誌的內存區域中一塊特定大小的內存塊,稱爲卡頁。卡頁大小爲2的整數次方,HotSpot中是29 ,即512字節。

一個卡頁中可包含多個對象,只要有一個對象的字段存在跨代指針,其對應的卡表的元素標識就變成1,表示該元素變髒,否則爲0。GC時,只要篩選卡表中變髒的元素加入GCRoots。

5.寫屏障

寫屏障可以看作在虛擬機層面對“引用類型字段賦值”這個動作的AOP切面,在引用對象賦值時會產生一個環形(Around)通知,供程序執行額外的動作,也就是說賦值的前後都在寫屏障的覆蓋範疇內。在賦值前的部分的寫屏障叫作寫前屏障(Pre-Write Barrier),在賦值後的則叫作寫後屏障(Post-Write Barrier)。大多收集器使用寫後屏障

6.併發的可達性分析(參考博客

  • (1)可達性分析過程(三色分析): 從GCRoot開始,訪問過的對象標記爲黑色,未訪問的標記爲白色,已經被訪問過但是至少有一個引用未被訪問過的標記爲灰色。一直訪問,直到所有節點都訪問完,那白色的就是需要回收的,黑色的不需要回收。

  • (2)浮動垃圾問題: 併發情況下,一個對象已經標記成黑色了,但是這個線程後面又不需要了,需要進行回收。這個問題可以在下一次回收中解決。

  • (3)對象消失問題: 併發情況下,有兩種情況會導致對象消失:

    • ①賦值器插入了一條或多條從黑色對象到白色對象的新引用
      在這裏插入圖片描述
    • ②賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用
      在這裏插入圖片描述
  • (4)對象消失的解決方法

    • ①增量更新: 黑色對象一旦新插入了指向白色對象的引用之後,它就變回灰色對象,等第一次掃描結束後再掃描一次
    • ②原始快照: 當灰色對象要刪除指向白色對象的引用關係時,就將這個要刪除的引用記錄下來,在併發掃描結束之後,再將這些記錄過的引用關係中的灰色對象爲根,重新掃描一次。簡化理解爲,無論引用關係刪除與否,都會按照剛剛開始掃描那一刻的對象圖快照來進行搜索。
      上面的兩種操作,都是通過寫屏障實現的

2.4 經典垃圾收集器

經典垃圾收集器之間的關係:

1. Serial收集器

其工作示意圖如下:
在這裏插入圖片描述
Serial Old是Serial收集器的老生代版本,使用標記整理算法,主要用於客戶端模式下的HotSpot虛擬機使用。服務端用於和Parallel Scavenge搭配使用,或者作爲CMS的失敗預案。

2. ParNew收集器(新生代)

是Serial收集器的多線程並行版本,主要用於和CMS搭配使用。
在這裏插入圖片描述

3. Parallel Scavenge收集器(新生代)

Parallel Scavenge收集器更關注吞吐量,適合於不熟悉收集器運作的情況下,使用該收集器配合自適應調節策略,把內存管理的優化任務交給虛擬機去完成。
在這裏插入圖片描述
其老生代收集器爲Parallel Old收集器,兩者搭配使用
在這裏插入圖片描述

4. CMS收集器(老年代)

是一種以獲取最短回收停頓時間爲目標的收集器 ,適合於服務器端,基於標記清除算法。收集過程分爲四個步驟:

  • ①初始標記: 主要做兩件事:一是遍歷CGRoots可直達的老年對象;二是遍歷新生代可直達的老年對象。直達指的一級連接,並不會遍歷整個對象圖

  • ②併發標記: 遍歷初始標記的對象圖並進行標記

  • ③重新標記: 爲了防止併發中,引用關係發生錯誤而導致的的錯誤(與增量更新、原始快照有關)

  • ④併發清除: 清理刪除掉標記階段判斷已經死亡的對象
    在這裏插入圖片描述
    缺點:

  • 1.對處理器資源極度敏感,適用於四核以上的處理器

  • 2.會產生浮動垃圾

  • 3.會產生大量內存碎片,需要進行碎片整理

5. G1收集器(新生帶+老年代)

和以前的垃圾收集器不同,G1收集器不再劃分新生代、老年代,而是將內存分成一個一個的Region(大小爲2的整數),在收集過程中,衡量標準不是屬於哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大,就去回收哪個region每個region都可以根據需要扮演Egen空間,Survivor空間,或者老年代空間。 因爲每次垃圾收集的空間都是region的整數倍,可以有計劃的避免全區域的垃圾收集,通過使用一個優先級列表,保證在有限時間內取得儘可能高的收集效率。

整個收集器的運作過程分爲四個步驟:

  • 初始標記(參考): 這階段僅僅只是標記GC Roots能直接關聯到的對象並修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確的可用的Region中創建新對象,這階段需要停頓線程,但是耗時很短。
  • 併發標記: 從GC Roots開始對堆的對象進行可達性分析,遞歸掃描整個堆裏的對象圖,找出存活的對象,這階段耗時較長,但是可以與用戶程序併發執行。
  • 最終標記: 對用戶線程做另一個短暫的暫停,用於處理併發階段結束後仍遺留下來的最後那少量的 SATB (快照搜索)記錄。
  • 篩選回收: 負責更新 Region 的統計數據,對各個 Region 的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃。此階段也要暫停用戶線程。
    在這裏插入圖片描述

2.4 低延遲垃圾收集器

1. Shenandoah收集器

  • (1)與G1不同的地方:

    • ①支持併發整理

    • ②默認不是用分代收集

    • ③放棄記憶集,改用連接矩陣,降低處理跨代指針的記憶集維護消耗。連接矩陣類似於鄰接矩陣
      在這裏插入圖片描述

  • (2)工作過程

    • ①初始標記: 與G1一樣,首先標記所有與GC Roots直接相連的對象,需要暫停用戶線程
    • ②併發標記: 與G1一樣,併發遍歷對象圖,標記處所有可達的對象
    • ③最終標記: 與G1一樣,處理剩餘SATB掃描,並統計回收價值最高的Region,構成回收集。
    • ④併發清理: 清理那些整個區域內一個存活對象都沒有找到的Region
    • ⑤併發回收: 與其他收集器的核心差別,具體過程爲:先將回收集裏的存活對象複製一份到其他未被使用的Region中,通過讀屏障和轉發指針解決。
    • ⑥初始引用更新: 將堆中所有指向舊對象的引用修正到複製後的新地址。
    • ⑦併發引用更新: 真正進行更新操作,此操作不需要按照對象圖搜索,只需要按照物理內存地址的順序,線性搜索引用類型,把舊值改爲新值。
    • ⑧最終引用更新: 修正GC Roots中的引用。
    • ⑨併發清理: 併發回收所有Region中不存活的對象,供以後新對象的使用
      在這裏插入圖片描述
  • (3)轉發指針

    轉發指針,給每個對象佈局結構的最前面統一加一個新的引用字段,在正常不處於併發的情況下,該引用指向自己。當需要修改時,只需要將指針指向另一個對象即可,此時,舊對象仍存在,未被清理。Shenandoah通過CAS操作,來保證併發。
    在這裏插入圖片描述
    在這裏插入圖片描述

2.ZGC收集器

  • (1)特點

    • ①雖然也有Region的概念,但Region分爲大、中、小三類容量:
      • 小型:固定2MB,用於放置小於256KB的小對象
      • 中型:固定32MB,用於放置256KB~4MB的對象
      • 大型:容量不固定,容量可以動態變化,但必須是2MB的整數倍
    • ②和轉發指針不同,採用染色體指針技術
  • (2)染色體指針

    將指針的高4位提取出來存儲四個標誌信息,通過這些標誌信息,虛擬機直接可以直接看出其引用對象的三色標記狀態、是否進入了重分配集(即是否被移動過),是否只能通過finalize()方法才能訪問到等。這樣一旦某個region的存活對象全被移走,這個region就能被釋放和重用,而不必等其他指向該region的引用都被修正。還可以大幅減少在垃圾回收過程中內存屏障的使用數量。

  • (3)工作過程

    • ①併發標記:和G1、Shenandoah一樣,遍歷對象圖做可達性分析並標記

    • ②併發預備重分配:根據特定的查詢條件統計得出本次手機過程要清理哪些region,將這些region組成重分配集。這裏每次都會掃描所以region,以省去記憶集維護成本。、

    • ③併發重分配:這個過程要把重分 配集中的存活對象複製到新的Region上,併爲重分配集中的每個Region維護一個轉發表(Forward Table),記錄從舊對象到新對象的轉向關係。得益於染色指針的支持,ZGC收集器能僅從引用上就明 確得知一個對象是否處於重分配集之中,如果用戶線程此時併發訪問了位於重分配集中的對象,這次 訪問將會被預置的內存屏障所截獲,然後立即根據Region上的轉發表記錄將訪問轉發到新複製的對象 上,並同時修正更新該引用的值,使其直接指向新對象,ZGC將這種行爲稱爲指針的“自愈”(Self- Healing)能力。

    • ④併發重映射:重映射所做的就是修正整個堆中指向重分配集中舊對象的所 有 引 用 , 這 一 點 從 目 標 角 度 看 是 與 Sh e n a n d o a h 並 發 引 用 更 新 階 段 一 樣 的 , 但 是 Z G C 的 並 發 重 映 射 並 不 是一個必須要“迫切”去完成的任務,因爲舊引用也是可以自愈的,最多隻是第 一次使用時多一次轉發和修正操作。重映射清理這些舊引用的主要目的是爲了不變慢(還有清理結束 後可以釋放轉發表這樣的附帶收益),所以說這並不是很“迫切”。因此,ZGC很巧妙地把併發重映射 階段要做的工作,合併到了下一次垃圾收集循環中的併發標記階段裏去完成,反正它們都是要遍歷所

      有對象的,這樣合併就節省了一次遍歷對象圖[9]的開銷。一旦所有指針都被修正之後,原來記錄新舊 對象關係的轉發表就可以釋放掉了。

2.5 垃圾收集器的選擇

收集器的權衡點

  • 應用的主要關注點是什麼: 如客戶端和嵌入式應用關心內存佔用,數據分析等關係吞吐量
  • 使用JDK的發行商,版本號

2.6 內存分配與回收策略

1.對象優先在Eden分配

一般來說,對象都在新生代Eden區中分配,當Eden區沒有足夠空間進行分配時,將發起一起Minor GC。

相關參數設置:

  • -Xmx: 最大堆大小
  • -Xms: 最小堆大小
  • -Xmn: 年輕代堆大小
  • -XXSurvivorRatio: 年輕代中Eden區與Survivor區的大小比值

2.大對象直接進入老年代

通過PretenureSizeThreshold參數可以設置當對象大於多少時,分配時直接進入老年代。所以應該儘量避免大對象,特別是生存週期很短的大對象(因爲會頻繁導致Minor GC)。

3.長期存活的對象將進入老年代

虛擬機給每個對象定義了一個對象年齡(Age)計數器,存儲在對象頭中。對象通常在Eden區誕生,如果經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,會被移入Survivor空間中,並將年齡設定爲1歲,在Survivor區中每熬過一代,年齡就增加一歲,當熬過一定程度後(默認15歲),就會晉升到老年代。

相關參數設置:

  • MaxTenuringThreshold: 設置對象熬過幾代進入老年期

4.動態對象年齡判定

並不是永遠要求對象的年齡必須達到MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於等於該年齡的對象就可以直接進入老年代。

5.空間分配擔保

發生Minor GC之前,虛擬機先檢查老年代最大可用連續空間是否大於新生代所有對象總空間,如果成立,說明這次Minor GC是安全的。如果不成立,會先查看XX:HandlePromotionFailure參數的設置值是否允許擔保失敗(Handle Promotion Failure);如果允許,那會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試進行一次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者-XX: HandlePromotionFailure設置不允許冒險,那這時就要改爲進行一次Full GC。JDK 6 Update 24之 後的規則變爲只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小,就會進行Minor GC,否則將進行Full GC。

三、類文件結構

3.1 Class類文件的結構

1.基礎概念

  • 無符號數: 屬於基本的數據類型,以u1、u2、u4、u8來分別表示1、2、4、8個字節的無符號數。可用來描述數字、索引引用、數量值或UTF-8編碼構成的字符串值。
  • 表: 表是由多個無符號數或者其他表作爲數據項構成的複合數據類型,用於描述有層次關係的複合結構的數據,整個Class文件本質上是一張表。

2.魔數與Class文件的版本

  • 魔數: 每個Class文件的頭四個字節被稱爲魔數,唯一作用是確定這個文件是否是一個能被虛擬機接收的Class文件, 值爲0xCAFEBABE。
  • Class文件的版本: 緊接着魔數的4個字節存儲的是Class文件的版本號,其中第5和第6個字節是次版本號,第7和第8個字節是主版本號。Java版本號從45開始,高版本的JDK能向下兼容以前版本的Class文件,但不能運行以後版本的Class文件。

3.常量池

緊接文件版本後的(從第9個字節開始)是常量池入口。具體內容爲:

  • 常量池容量計數: 用u2類型的數字,來表示常量的個數(從1開始計數,設計者將第0項常量空出來是有特殊考慮的,這樣做的目的在於,如果後面某些指向常量池的索引值的數據在特定情況下需要表達“不引用任何一個常量池項目”的含義,可以把索引值設置爲0來表示。)

  • 常量池中存放的數據: 主要存放以下兩大類數據,

    • ①字面量:Java語言層面的常量,如字符串、聲明爲final的常量值
    • ②符號引用:包括被模塊導出或者開放的包、類和接口的全限定名、字段的名稱和描述符、方法的名稱和描述符等。

    常量池中每項常量都是一個表,截止JDK13,一共有17種不同類型的常量。

4.訪問標誌

常量池結束後,緊接着2個字節代表訪問標誌,用於識別一些類或接口層次的訪問信息,包括這個Class是類還是接口,是否爲public,是否爲abstract,是否申明爲final等等。這些信息在2個字節的數字中使用標誌位的方式來表示

5.類索引、父類索引、接口索引集合

類索引(this_class)和父類索引(super_class)都是一個u2類型的數據,而接口索引集合是一組u2類型的數據的集合,Class文件中由這三項數據來確定該類型的繼承關係。類索 引用於確定這個類的全限定名,父類索引用於確定這個類的父類的全限定名。

6.字段表集合

用於描述接口或者類中聲明的變量,不包括在方法中聲明的局部變量。字段表結構分爲以下幾個部分:

  • access_flag: 字段的一些屬性(包括作用域(public、private、protected)、可變性(final)、併發可見性(volatile)、能否序列化),使用一個u2類型數來表示,表示方法與類的訪問標誌類似,都是用標誌位來表示。
  • name_index和descriptor_index: 都是對常量池的引用,分別代表字段的簡單名稱(指沒有類型和參數修飾的方法或字段名,如int m中,m就是簡單名稱)以及字段和方法的描述符(用於描述字段的數據類型(如用I來表示基本數據類型int,L表示對象類型,數組在類型前面添加一個[字符來描述)、方法的參數列表(包括數量、類型及順序)和返回值)。
  • 屬性表集合: 在descriptor_index之後跟隨着一個屬性表集合用於存儲一個額外的信息,字段表可以在附加描述零至多項的額外的信息。

7.方法表集合

方發表集合和字段表集合採用了幾乎一致的描述方式。方法表結構如下:
在這裏插入圖片描述
所不同的是,access_flag中增加了去掉了一個屬於字段的標誌(如volatile),增加了一些屬於方法的訪問標誌(如native等)。

  • 方法中的代碼的位置: 方法中的代碼被Javac編譯成字節碼指令之後,存放在方法屬性表集合中一個名爲Code的屬性裏面
  • Java函數什麼無法僅僅依靠返回值不同來重載: Java語言中,除了要與原方法具有相同的簡單名稱外,還要求必須有一個與原方法不同的特徵簽名,特徵簽名是指一個方法中各個參數在常量池中的字段符號引用的集合,因爲返回值不包括在特徵簽名中,所以無法僅通過返回值不同來重載。

8.屬性表集合

Class文件、字段表、方法表都能有自己的屬性表,來描述一些場景專有的信息。一個符合規則的屬性表滿足以下結構:
在這裏插入圖片描述
一些常見的屬性有:

  • 1.Code: 方法體裏面的代碼編譯後,變成字節碼存儲在Code屬性中。接口和抽象類中的方法不存在Code屬性,其結構如下所示:
    在這裏插入圖片描述
    上面需要注意的問題有:

    • ①方法所佔用的內存空間並不是簡單的方法中用到了多少個變量,就佔用多少空間,Java編譯層面會進行優化,將局部變量表中的變量槽進行重用,當代碼執行超過一個局部變量的作用域時,這個局部變量佔用的變量槽可以被重用。

    • ②雖然code_length是一個u4類型的長度,理論可以存儲232 條指令,但是《JAVA虛擬機規範》限定一個方法不允許超過65535條指令(一般只在編譯JSP文件的時候會出現超過這個限制的情況)。

    • ③異常表的結構如下:
      在這裏插入圖片描述
      表示從第start_pc行到end_pc行(不包含end_pc行)之間,出現了類型爲catch_type或其子類的異常,則轉到handler_pc行進行處理。

  • Exceptions屬性: 該屬性用於列舉出方法中可能拋出的異常檢查,結構如下:
    在這裏插入圖片描述

  • LineNumberTable屬性: 用於描述Java源碼行號與字節碼行號(字節碼的偏移量)之間的對應關係。非必須,如果不要這個屬性,則程序拋出異常時不能顯示異常所在的行號,且調試時,也無法按照源碼行來設置斷點

  • LocalVariableTable及LocalVariableTypeTable屬性: 用於描述棧幀中局部變量表的變量與Java源碼中定義的變量之間的關係。非必須,沒有這個屬性時,則別人引用這個方法時,所以參數名都將丟失

  • SourceFile及 SourceDebugExtension屬性: SourceFile屬性用於記錄生成這個Class文件的源碼文件名稱。SourceDebugExtension屬 性 用 於 存 儲 額 外 的 代 碼 調 試 信 息 。

  • ConstantValue屬性: 作用是通知虛擬機自動爲靜態變量賦值,只有被static屬性修飾的變量才能使用這項屬性。Java對static和非static變量的賦值過程爲:

    • 非static類型的變量,賦值是在實例構造器(<init>()方法)中進行的
    • static類型的變量,有兩種方式可選:①在類構造器(<clinit>()方法)中進行;②在ConstantValue中進行(目前的虛擬機中,如果同時用static和final字段修飾,並且這個類型是基本類型或String類型,就會生成ConstantValue屬性來初始化)。
  • InnerClasses屬性: 用於記錄內部類和宿主類之間的關聯。結構如下:
    在這裏插入圖片描述
    innner_classes表內容如下:

  • Deprecated及Synthetic屬性: Deprecated屬性用於表示某個類、字段或者方法,已經被程序作者定爲不再推薦使用。Synthetic實現了對private級別的字段和類的訪問,從而繞開了語言限制

  • Signature屬性: 與Java泛型有關。任何類、接口、構造器方法或字段的聲明如果包含了類型變量(type variable)或參數化類型,則Signature屬性會爲它記錄泛型簽名信息。泛型最終的信息來源就來自於此屬性。

3.2 字節碼指令簡介

Java虛擬機的指令由一個字節長度的、代表着某種特定操作含義的數字(稱爲操作碼,Opcode) 以及跟隨其後的零至多個代表此操作所需的參數(稱爲操作數,Operand)構成。其中,由於限制操作碼的長度爲1個字節,故指令集的綜述不能超過256條。爲了減少指令的數量,故意將指令設計成支持非完全獨立的,且部分指令都沒有支持整數類型byte、char和short,甚至沒有任何指令 支持boolean類型。大多數對於boolean、byte、short和char類型數據的操作,實際上都是使用相應的對int類 型作爲運算類型(Computational Type)來進行的

Java中的指令集分爲以下幾類:

  • 1.加載和存儲指令: 用於將數據在幀棧中的局部變量表和操作數棧之間來回傳輸

  • 2.運算指令: 用於對兩個操作數棧上的值進行某種特定運算,並把結果重新存入到操作棧頂。大體分爲針對整型和針對浮點數類型的運算兩類。

  • 3.類型轉換指令: 用於兩種不同的數值類型相互轉換,這些轉換操作一般用於實現用戶代碼中的顯式類型轉換操作。

  • 4.對象創建與訪問指令: 針對數組和對象的指令不同。

  • 5.操作數棧管理指令: 用於直接操作操作數棧
    在這裏插入圖片描述

  • 6.控制轉移指令: 控制轉移指令可以讓Java虛擬機有條件或無條件地從指定位置指令(而不是控制轉移指令)的下 一條指令繼續執行程序,用於各種條件分支。

  • 7.方法調用和返回指令: 用於調用各類方法

  • 8.異常處理指令: 處理異常

  • 9.同步指令: Java虛擬機可以支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor,更常見的是直接將它稱爲“鎖”)來實現的。

    當方法調用時,調用指令將會檢查方法的ACC_SYNCHRONIZED訪問標誌是否被設置,如果設置了,執行線程就要求先成功持有管程,然後才能執行方法,最後當方法完成(無論是正常完成 還是非正常完成)時釋放管程。在方法執行期間,執行線程持有了管程,其他任何線程都無法再獲取到同一個管程。如果一個同步方法執行期間拋出了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的管程將在異常拋到同步方法邊界之外時自動釋放。

四、虛擬機類加載機制

4.1 類加載的時機

類加載的七個階段如下:

  • 1.加載、驗證、準備、初始化和卸載這五個階段的順序是確定的。爲支持動態綁定,解析階段可以在初始化階段之後再開始。類加載在什麼時候開始由虛擬機自己決定

  • 2.初始化的時間: 分爲以下幾種情況:

    • ①遇到new、getstatic、putstatic、invokestatic四條字節碼指令時,如果沒有初始化,則需要先觸發初始化階段。具體場景有:
      • 使用new實例化對象時
      • 讀取或設置一個類型的靜態字段(除被final修飾放到常量池中的字段)
      • 調用一個類型的靜態方法時
    • ②反射調用時,如未初始化,則先初始化
    • 類初始化時,如父類未初始化,則先初始化父類
    • ④虛擬機啓動時,先初始化main函數所在的類

    上面這幾種情況,稱爲對一個類型進行主動引用,其他所有的引用均不會觸發初始化,稱爲被動引用

4.2 類加載的過程

1.加載

加載階段主要完成三件事情:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流(並沒有規定從哪裏獲取二進制流,所以可以從ZIP壓縮文件中獲取(JAR、WAR的基礎)、網絡中獲取(applet的基礎)、運行時計算(動態代理技術)、從其他文件生成(JSP))
  • 將這個字節流所代表的靜態存儲結構轉換成方法區的運行時數據結構
  • 在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。

2.驗證

該階段的主要目的是保證Class文件的字節流中包含的信息符合《Java虛擬機規範》的要求

3.準備

正式爲類變量(靜態變量) 分配內存並設置初始值。JDK7之前,Hotspot用永久代實現方法區時,靜態變量是在方法區中分配的。但JDK8及之後,類變量會隨着Class對象一起存在在Java堆中。需要注意的兩點:

  • ① 此階段只給類變量分配內存,不給實例變量分配內存,而且分配的是系統默認的初始值,而不是代碼賦的值(如static int a=1;準備階段後,不會賦值爲1,而是0)具體的賦值操作,會在初始化階段
  • ②當給類變量賦值的時候,賦值過程會在類構造器方法中進行

4.解析

該階段是虛擬機將常量池內的符號引用替換爲直接引用的過程

  • 符號引用: 用一組符號來描述所引用的目標(相當於名字),可以是任何形式的字面量。
  • 直接引用: 可以是直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這七類符號引用進行。

  • 1.類或接口的解析: 在類D中,要把一個符號N解析爲一個類或接口C的直接引用,分爲3步:

    • ①如果C不是數組類型,則將N的全限定名傳遞給D的類加載器去加載類C,加載過程中,由於繼承等關係,可能又會引起其他類的加載。
    • ②如果C是數組類型,並且數組類型爲對象,則按照①加載對象,然後由虛擬機生成一個代表數組未讀和元素的數組對象
    • ③進行符號引用驗證,確認D是否有對C的訪問權限。
  • 2.字段解析: 首先按照字段的類型進行字段所屬類的解析。解析成功,假設這個字段屬於C。那後續的解析步驟爲:

    • ① 如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引 用,查找結束
    • ② 如果在C中實現了接口,將會按照繼承關係從下往上遞歸搜索各個接口和它的父接口, 如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找 結束。
    • ③如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞歸搜索其父類,如果在父 類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
    • ④查找失敗,拋出NoSuchFieldError異常
  • 3.方法解析: 首先解析出方法所屬的類或接口的引用,然後去找。與字段解析不同的是,方法解析先去父類中找,再去接口中找。

  • 4.接口方法解析: 和方法解析類似,但是隻在本接口和父接口中找,不會去類中找

5.初始化

初始化階段就是執行類構造器<clinit>()方法的過程。有關<clinit>()方法:

  • ①該方法是由編譯器自動收集類中所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,收集順序由語句在源文件中出現的順序決定,靜態語句塊中只能訪問 到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。
    在這裏插入圖片描述
  • <clinit>()與類構造方法init()方法不同,虛擬機會保證在子類的<clinit>()執行前,父類的已經執行完畢。所以,Object類的<clinit>()方法一定是最先執行的。這也意味着,父類的靜態語句塊要優先與子類的變量賦值操作
  • ③如果類中沒有靜態語句塊,也沒有對變量進行賦值操作,編譯器可以不爲這個類生成<clinit>()方法
  • ④接口中的<clinit>()執行前不需要先執行父類的<clinit>()方法,因爲只有當父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實現類在初始化時也 一樣不會執行接口的<clinit>()方法。
  • ⑤Java虛擬機必須保證一個類的<clinit>()方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那麼只會有其中一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行完畢<clinit>()方法。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個進程阻塞

4.3 類加載器

1.類與類加載器

類加載器的作用是通過一個類的全限定名來獲取描述該類的二進制字節流。對於任意一個類,必須由加載它的類加載器和這個類本身一起確立其在虛擬機中的唯一性。 比較兩個類是否相等,只有在這兩個類是由一個類加載器加載的前提下才有意義。否則,如果類加載器不相等,那兩個類肯定不相等。

2.雙親委派模型

  • ①三層類加載器

    • 啓動類加載器(Bootstrap ClassLoader) :負責加載存放在<JAVA_HOME>\lib目錄,或者被-Xbootclasspath參數指定的路徑中存放的,且是JVM能夠識別的類庫。只有這個類加載器是使用C++語言實現,是JVM的一部分,其他類加載器全部由java語言實現,獨立存在於JVM外部,且都繼承於java.lang.Classloader抽象類。

    • 擴展類加載器(Extension ClassLoader):負責加載<JAVA_HOME>\home\ext目錄中,或者被java.ext.dirs系統變量所指定的路徑中所有的類庫,開發者可以直接使用此類加載器加載Class文件。

    • 應用程序類加載器(Application ClassLoader):負責加載用戶類路徑(ClassPath)上的所有類庫,開發者同樣可以直接使用此類加載器,如果沒有指定,則默認使用此類加載器。

      他們之間的協作關係如下所示,此關係稱爲雙親委派模型,雙親委派模型要求除了啓動類加載器,其餘的類加載器都應有自己的父類加載器。但是類加載器之間的父子關係一般不是以繼承關係實現,而是通過使用組合關係來複用父加載器的代碼
      在這裏插入圖片描述

  • ②雙親委派模型:

    • 工作流程: 如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加 載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的 加載請求最終都應該傳送到最頂層的啓動類加載器中,只有當父加載器反饋自己無法完成這個加載請 求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試自己去完成加載。
    • 優點: Java中的類隨着它的類加載器一起具備了帶有優先級的層次關係,這樣能保證在各種類加載環境中都是同一個類

五、虛擬機字節碼執行引擎

5.1 運行時幀棧結構

幀棧是用於支持虛擬機進行方法調用和方法執行背後的數據結構。幀棧存儲了方法的局部變量、操作數棧、動態連接和方法返回地址等信息。在編譯Java程序碼時,幀棧中需要多大的局部變量表多深的操作棧已經被分析計算出來,並寫入到方法表的Code屬性當中了。故幀棧需要分配多少內存在編譯時就確定了

對於執行引擎,在活動線程中,==只有位於棧頂的方法(當前方法)纔是運行的,只有位於棧頂的幀棧(當前幀棧)纔是生效的。==執行引擎所運行的字節碼指令都只針對當前幀棧操作。幀棧的結構如下:
在這裏插入圖片描述

1.局部變量表

  • 局部變量表用於存放方法參數和方法內部定義的局部變量。在Java程序被編譯爲Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方 法所需分配的局部變量表的最大容量。以變量槽爲最小容量單位
  • 虛擬機通過索引定位的方式使用局部變量表,索引值從0到局部變量表的最大變量槽數量。32位的數據,N代表使用第N個變量槽。64位使用N和N+1兩個變量槽。
  • 當一個方法調用時,虛擬機會使用局部變量表來完成實參到形參的傳遞。實例方法(static方法)中,第一個參數用於傳遞方法所屬對象實例的引用。方法參數結束後,再根據方法體內部定義的變量順序和作用域分配其餘的變量槽。
  • 局部變量表中,變量槽是可以重用的,一些超出作用域範圍的變量槽能重用。

2.操作數棧

方法剛開始執行時,操作數棧爲空。方法執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧和入棧操作。操作數棧中元素的數據類型必須要和字節碼指令的序列嚴格匹配,編譯程序的時候就要保證這一點

3.動態連接

每個幀棧都包含一個指向運行時常量池中該幀棧所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態鏈接。在將符號引用轉換成直接引用時,有一部分會在類加載階段或者第一個使用的時候就轉換成直接引用,這種稱謂靜態解析。另一部分,將在每一次運行期間,都轉換爲直接引用,稱謂動態連接

4.方法返回地址

當一個方法開始執行後,有兩種方式退出這個方法:

  • 1.遇到任意一個返回字節碼指令正常退出
  • 2.執行過程中遇到異常,並未處理,會異常退出

方法退出後,都必須返回方法最初被調用的位置,程序才能繼續執行。方法返回時,需要在幀棧保存一些信息,用來幫助恢復它的上層主調方法的執行狀態。(方法退出就能相當於把當前幀棧出棧,然後再返回值壓到調用者幀棧的操作數棧中,然後PC計數器指向方法調用後的指令)

5.2 方法調用

1.解析

所有方法調用的目標在Class文件裏都是一個常量池中的符號引用,故要調用方法,則需要將符號引用接解析成直接引用;解析指的是在類加載解析階段,將符號引用轉換成直接引用的過程,調用的方法就已經確定的方法。這個過程要求,程序運行之前就有一個可確定調用的版本,並且這個版本運行期間不可改變

2.分派

分派用來調用體現Java多態性的方法。

  • 1.靜態分派: 依賴靜態類型來決定方法執行版本的分派動作,稱爲靜態分派。最典型應用是方法重載。下面代碼輸出hello,guy。因爲上面的代碼在編譯時,就能確定調用哪個重載函數
    在這裏插入圖片描述

    • ①靜態類型:上面的Human就是靜態類型
    • ②實例類型:上面的Man是實例類型
  • 2.動態分配:與重寫(Override)有關,下面的代碼

輸出結果爲下圖
在這裏插入圖片描述
這涉及到invokevirtual指令的解析過程,過程如下:

  • 找到操作數棧頂的第一個元素所指向的對象的 實際類型 ,記作C。

  • 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;不通過則返回java.lang.IllegalAccessError異常。

  • 否則,按照繼承關係從下往上依次對C的各個父類進行第二步的搜索和驗證過程。

  • 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。

  • 3.單分派與多分派

    • 宗量: 方法的接受者與方法的參數統稱爲方法的宗量
      根據分派基於多少種宗量,可以將分派劃分爲單分派和多分派兩種

      • 單分派: 根據一個宗量對目標方法進行選擇
      • 多分派: 根據多個宗量對目標方法進行選擇

      Java是一種靜態多分派,動態單分派的語言。靜態多分派是指,在靜態分派時,可以根據多個宗量選擇,而動態分派時,只與該方法的接受者有關(方法的參數已經確定了),只有一個宗量作爲選擇依據。

  • 4.虛擬機動態分派的實現

    爲了提高性能,虛擬機爲類型在方法區建立了虛擬方法表和接口方法表,以此減少查找目標對象的時間。虛方法表中存放着各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方 法表中的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了 這個方法,子類虛方法表中的地址也會被替換爲指向子類實現版本的入口地址。
    在這裏插入圖片描述

5.3 動態類型語言支持

1.動態類型語言

動態類型語言,是指數據類型的檢查是在運行時做的。用動態類型語言編程時,不用給變量指定數據類型,該語言會在你第一次賦值給變量時,在內部記錄數據類型。關鍵特徵是類型檢查的主體過程是在雲慈寧宮期而不是在編譯器進行的

2.java.lang.invoke包

使用invoke包中的內容,可以做到類似下面的動態類型的操作。
在這裏插入圖片描述
使用反射也能做到類似上面的操作。兩者有以下區別:

  • ①反射模擬代碼層面的調用,而invoke模擬字節碼層面的調用
  • ②反射中的Method比invoke包含的信息多得多,包含執行權限等信息
  • ③使用MethodHandle理論上,虛擬機可以在這方面做優化(如方法內聯)

3.invokedynamic指令(參考博客

每一處含有invokedynamic指令的位置都被稱作“動態調用點(Dynamically-Computed Call Site)”, 這條指令的第一個參數不再是代表方法符號引用的CONSTANT_Methodref_info常量,而是變爲JDK 7 時新加入的CONSTANT_InvokeDynamic_info常量,從這個新常量中可以得到3項信息:引導方法 (Bootstrap Method,該方法存放在新增的BootstrapMethods屬性中)、方法類型(MethodType)和 名稱。引導方法是有固定的參數,並且返回值規定是java.lang.invoke.CallSit e對象,這個對象代表了真 正要執行的目標方法調用。根據CONSTANT_InvokeDynamic_info常量中提供的信息,虛擬機可以找到 並且執行引導方法,從而獲得一個CallSit e對象,最終調用到要執行的目標方法上。

六、Java內存模型與線程

6.1 Java內存模型

1.主內存和工作內存

Java內存模型規定了所有的內存變量都存儲在主內存中。 每條線程還有自己的工作內存,線程的工作內存中保存了該線程使用的變量的主內存副本。線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據,不同線程之間要交換數據均需要通過主內存來完成。
在這裏插入圖片描述

2.內存間交互操作

主內存和工作內存之間的數據的交換,Java內存模型規定了以下8種操作來完成:

  • lock: 作用於主內存的變量,用於把一個變量標誌位線程獨佔
  • unlock: 與lock相對,只有unlock的變量才能被其他線程鎖定
  • read: 作用與主內存變量,將一個變量值從主內存傳輸到線程的工作內存
  • laod: 作用於工作內存變量,將read得到的變量,放入工作內存的變量副本中
  • use: 作用於工作內存變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  • assign: 作用於工作內存變量,它把一個從執行引擎接收的值賦給工作內存的變量, 每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store: 作用由於工作內存變量,用於將工作內存中的一個變量的值傳送到主內存中,以便隨後的write
  • write: 用於主內存變量,將store操作的變量值放入主內存變量中
    上述操作必須滿足以下規則:
  • 不允許read和load、store和write操作之一單獨出現
  • 不允許一個線程丟棄它最近的assign操作(工作內存賦值後必須同步到主內存)
  • 不允許未assign的數據同步到主線程
  • 新變量只能在主內存中產生
  • 同一時刻只允許一條線程對其lock,但lock可以執行多次,多次執行後,必須執行同樣次數的unlock解鎖
  • 執行lock會清空工作內存中該變量的值,執行引擎使用這個變量之前,需要重新load和assign初始化該變量的值
  • 如果變量沒有被lock,則不允許unlock
  • 對一個變量unlock之前,必須先將其同步回主內存中

3.對volatile類型變量的特殊規則

  • ①作用:

    • ①保證變量對所有線程的可見性,當一條線程修改了這個變量的值,其他線程能立馬知道。這並不能保證被volatile修飾的變量是線程安全的,原因是Java中的運算符並不是原子操作的。比如多個線程對volatile修飾的變量進行自增操作,每個線程在拿到變量時,是正確的,但是自增後,再寫回的時候,別的線程可能已經改了這個變量。
    • ②禁止指令重排優化
  • 內存交互的規則

    相比普通變量,有以下規則,這些規則保證了可見性和禁止指令重排

    • 1.要use,前面必須是load(變量可見的原理)
    • 2.要store,前面必須是assign(每次修改後,必須立即同步到主內存)
    • 3.代碼中變量的順序還操作的順序一致(防止指令重排)

4.long和double的非原子性協定

Java內存模型允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分爲兩次32位的操作來進行,即允許虛擬機實現自行選擇是否要保證64位數 據類型的load 、store 、read和write這四個操作的原子性 。

5.先行發生原則

先行發生是Java內存模型中定義的兩項操作之間的偏序關係,比如說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B 觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。

Java中天然的線性發生原則:①程序次序控制(按照控制流程順序,書寫在前面的操作先行發生於書寫在後面的操作)。②管程鎖定規則(unlock肯定發生在lock後)③volatile變量規則(對volatile的寫操作先行發生於對這個的讀操作)④線程啓動規則(start肯定先行與線程中的任何動作)⑤線程終止規則(線程中的所有操作先行發生於對此線程的終止檢測)⑥線程中斷規則(線程interrupt()方法調用先行與中斷檢測)⑥對象終結規則(初始化肯定先行於finalize方法)⑦傳遞性(先行規則具有傳遞性)

6.2 Java與線程

1.線程的實現

有三種線程的實現方式:

  • ①內核線程實現: 指直接由操作系統內核支持的線程,這種線程由內核來完成線程切換。內核通過調度器對線程調度,並負責映射到各個處理器上。通常通過輕量級進程實現,系統支持的輕量級進程是有限的。

  • ②用戶線程的實現: 用戶線程指的是完全建立在用戶空間的線程庫上,系統內核不能感知到用戶線程的存 在及如何實現的。用戶線程的建立、同步、銷燬和調度完全在用戶態中完成,不需要內核的幫助。線程的所有操作(創建、銷燬、切換和調度)需要用戶自己處理

  • ③混合實現: 內核線程與用戶線程一起使用的實現。這樣線程的調度及映射可以由內核線程實現,而其他需要大規模併發的操作則由用戶線程實現。

2.Java線程的實現

HotSpot虛擬機的每一個線程都是直接映射到操作系統的原生線程來實現的(即以內核線程的方式實現),自己不用關係線程的調度,全權交給操作系統處理。

3.Java線程的調度

兩種線程調度:

  • 1.協同式: 線程執行時間由線程本身控制,線程自己的工作執行完後,通知系統切換到另一個線程上去。
  • 2.搶佔式: 每個線程由系統分配執行時間,線程切換不由線程本身決定。在Java中使用搶佔式的線程調度。可以通過設置線程優先級,來“建議”操作系統多分配資源給線程。

4.狀態轉換

Java定義了線程的6種狀態,分別是:

  • 新建: 創建後尚未啓動的線程處於這種狀態
  • 運行: 包括操作系統線程狀態中的Running和Ready ,也就是處於此狀態的線程有可 能正在執行,也有可能正在等待着操作系統爲它分配執行時間。
  • 無限期等待: 處理器不會分配時間,要等待被其他線程喚醒。
  • 限期等待: 處理器不會分配時間,不過無需等待其他線程喚醒,在一定時間後會由系統自動喚醒。
  • 阻塞: 與”等待狀態“的區別是:阻塞狀態等待着獲取一個排它鎖,而等待則是在等待一段時間或者喚醒動作。
  • 結束: 已經終止或者執行結束的線程處於這種狀態
    這6種狀態的轉化關係如下:
    在這裏插入圖片描述

七、線程安全與鎖優化

7.1 線程安全

代碼本身對對象封裝了所有必要性的保障手段(如互斥同步),調用者無需關心多線程下的調用問題,更無需自己實現任何措施來保證多線程環境下的正確調用

1.Java中的線程安全

可以將Java語言中,各種操作共享的數據分爲以下5類:

  • 1.不可變: Java語言中,不可變的對象一定是線程安全的
  • 2.絕對線程安全: 表示在任何情況下,都是線程安全的,但很難做到,一般說的線程安全的Java工具類都不是絕對線程安全的
  • 3.相對線程安全: 保證對這個對象單次的操作是線程安全的,在調用時不需要進行額外的保障措施。Java中大部分聲稱線程安全的類都屬於相對線程安全。
  • 4.線程兼容: 指對象本身不是線程安去的,但是可以通過在調用端正確地使用同步手段來保證對象在併發環境中可以安全使用。
  • 5.線程對立: 線程對立是指不管調用端是否採取了同步措施,都無法在多線程環境中併發使用代碼。

2.線程安全的實現方法

  • 1.互斥同步: 互斥同步是最常見也是最主要的併發正確性保障手段,爲阻塞同步(悲觀併發策略)。其中,互斥是方法(臨界區、互斥量、信號量),同步(在同一時刻只被一條(使用信號量時是一些)線程使用)是目的

    • synchronized關鍵字: 最基本的互斥同步手段,是一種塊結構同步語法。通過編譯後,會在同步塊的前後分別形成monitorenter和monitorexit兩個字節碼指令,這兩個字節碼指令都需要一個reference類型的參數來指明要鎖定和解鎖的對象。如果Java源碼中的synchronized明確指定了對象參數,那就以這個對象的引用作 爲reference;如果沒有明確指定,那將根據synchronized修飾的方法類型(如實例方法或類方法),來決定是取代碼所在的對象實例還是取類型對應的Class對象來作爲線程要持有的鎖

      在執行monitorenter指令時,首先要去嘗試獲取對象的鎖。如果這個對象沒被鎖定,或者當前線程已經持有了那個對象的鎖,就把鎖的計數器的值增加一,而在執行monitorexit指令時會將鎖計數器的值減 一 。 一旦計數器的值爲零 , 鎖隨即就 被釋放了 。 如果獲取對象鎖失敗,那當前線程就應當被阻塞等待,直到請求鎖定的對象被持有它的線程釋放爲止。

      兩個特點: ①被synchronized修飾的同步塊對同一條線程來說是可重入的。這意味着同一線程反覆進入同步塊 也不會出現自己把自己鎖死的情況②被synchronized修飾的同步塊在持有鎖的線程執行完畢並釋放鎖之前,會無條件地阻塞後面其他線程的進入。這意味着無法像處理某些數據庫中的鎖那樣,強制已獲取鎖的線程釋放鎖;也無法強制 正在等待鎖的線程中斷等待或超時退出。

    • 重入鎖: 與synchronized一樣可重入,但有一些高級功能:

      • ①等待中斷
      • ②公平鎖
      • ③可綁定多個條件:condition條件
  • 2.非阻塞同步: 基於衝突檢測的樂觀併發策略。基本策略是不管風險,先操作,如果沒有其他線程爭用數據,那操作成功。否則,一旦產生衝突,則採取補償措施,最常見的補償措施是不斷重試,直到沒有競爭的共享數據爲止。

    • CAS操作: 需要三個操作數,分別是內存位置、舊的預期值、準備設置的新值。執行時,當且僅內存地址處的值符合舊的預期值時,才用新值更新。

      CAS操作存在ABA問題:如果變量V初次讀取的時候是A值,在準備賦值的時候仍是A值,並不能保證V沒被改過,有可能先被修改,然後再改回原值,可以使用AtomicStampedReference避免,其通過控制變量值的版本來保證CAS的正確性。

  • 3.無同步方案: 如果一個方法本身不涉及共享數據,就不需要任何同步措施去保證其正確性。常見的有以下兩類:

    • ①可重入代碼: 指可以在代碼執行的任何 時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程序不會出現任何錯誤,也不會對結果有所影響。判斷原則爲,如果一個方法的返回結果是可以預測的,只要輸入了相同的數據,就都能返 回相同的結果,那它就滿足可重入性的要求,當然也就是線程安全的。
    • 線程本地存儲: 如果一段代碼中所需要的數據必須與其他代碼共享,那就 看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可 見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。ThreadLocal就是線程本地存儲,從表面上看ThreadLocal相當於維護了一個map,key就是當前的線程,value就是需要存儲的對象。當某些數據是以線程爲作用域並且不同線程具有不同的數據副本的時候,就可以考慮採用ThreadLocal。

7.2 鎖優化

1.自旋鎖與自適應自旋

對於互斥鎖,如果資源已經被佔用,資源申請者只被掛起,這樣對於一些執行時間很短的線程,不斷掛起喚醒會很耗費資源。但是自旋鎖不會引起調用者掛起,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那裏看是否該自旋鎖的保持者已經釋放了鎖,

虛擬機現在已經能根據以往的獲取鎖的過程,自適應地去決定自旋的次數。

2.鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享 數據競爭的鎖進行消除。主要通過逃逸分析(在方法中定義的對象,可能被方法外的對象所引用(比如方法返回這個定義的對象,然後在別的地方被使用),那這個對象就逃逸了)實現,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可 以把它們當作棧上數據對待,認爲它們是線程私有的,同步加鎖自然就無須再進行。

3.鎖粗化

同步時,總推薦將同步塊的作用範圍限制地儘可能小,只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變少,即使存在鎖競爭,等 待鎖的線程也能儘可能快地拿到鎖。

但是有時候,如果一系列的連續操作都對同一個對象反覆加鎖和 解鎖,甚至加鎖操作是出現在循環體之中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。這時,就需要鎖粗化,將加鎖的部分粗化。

4.輕量級鎖

  • 對象頭: HotSpot虛擬機的對象頭分爲兩部分,第一部分用於存儲對象自身的運行時數據,如HashCode、GC年齡分代等。數據的長度在32位和64位的Java虛擬機中分別會佔用32個或64個比特,官方稱它爲“Mark Word”。另外一部分用於存儲指向方法區對象類型數據的指針,如果是數組對象,還會有一個額外的部分用於存儲數組長度。
    (img-tXtHybgR-1591964446678)(Java虛擬機.assets/image-20200531152651217.png)]

  • 依據: 對於絕大部分的鎖,在整個同步週期內都是不存在競爭的。如果沒有競爭,輕量級鎖就使用CAS操作,而避免使用互斥量的開銷。但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖比傳統的重量級鎖更慢。

  • 輕量級鎖的工作過程: 在代碼即將進入 同步塊的時候,如果此同步對象沒有被鎖定(鎖標誌位爲“ 01”狀態),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(官方爲這份拷貝加了一個Displaced前綴,即Displaced Mark Word),

    然後,虛擬機將使用CAS操作嘗試把對象的Mark Word更新爲指向Lock Record的指針。如果這個更新動作成功了,即代表該線程擁有了這個對象的鎖,並且對象Mark Word的鎖標誌位(Mark Word的 最後兩個比特)將轉變爲“ 00”,表示此對象處於輕量級鎖定狀態。

    如果這個更新操作失敗了,那就意味着至少存在一條線程與當前線程競爭獲取該對象的鎖。虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個鎖對象已經被其他線程搶佔了。如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹爲重量級鎖,鎖標誌 的狀態值變爲“10”,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也必須進入阻塞狀態。

    具體的輕量級鎖膨脹爲重量級鎖的過程如下:
    在這裏插入圖片描述

5.鎖偏向

鎖會偏向於第一個獲得它的線 程,如果在接下來的執行過程中,該鎖一直沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。

整個偏向鎖、輕量級鎖的狀態轉換過程如下所示:
在這裏插入圖片描述
在鎖偏向時,Mark Word中的大部分用用來存儲threadID,而佔用了原有存儲對象哈希碼的位置。但是一個對象如果計算過哈希碼,就應該一直保持不變。這樣的話,一旦計算過哈希,就需要寫到Mark Word中,一旦寫入,那這個對象就再也無法進入偏向鎖狀態了。當一個對象當前正處於偏向鎖狀態,又收到計算hashcode請求時,偏向狀態會立即被撤銷,並且鎖會膨脹成重量級鎖。如果一個對象經常存在競爭,那就最好不要使用偏向鎖。

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