自動記憶體管理機制
運作時資料區
JVM運作時資料區分為:程式計數器,Java虛拟機棧,本地方法棧,java堆,方法區,運作時常量池,直接記憶體。
線程共享的區域有:方法區,堆,執行引擎,本地庫接口
線程隔離的區域有:虛拟機棧,本地方法棧,程式計數器
程式計數器:可以看做是目前線程所執行的位元組碼的行号訓示器
Java虛拟機棧:生命周期與線程相同,每個方法在執行的同時都會建立一個棧幀用于存儲局部變量表,操作數棧,動态連結,方法出口等資訊。如果線程請求的棧深度大于虛拟機所允許的深度,将抛出StackOverflowError異常,如果虛拟機棧可以動态擴充且擴充時無法申請到足夠的記憶體,就會抛出OutOfMemoryError異常
局部變量表:存放了編譯期可知的各種基本資料類型,對象引用和returnAddress類型,long和double類型的資料會占用2個局部變量空間,其餘占1個,局部變量表的記憶體配置設定在編譯期完成
本地方法棧:和虛拟機棧非常相似,虛拟機棧為虛拟機執行java方法(位元組碼)服務,本地方法棧則為虛拟機使用到的Native方法服務。
Java堆:是java虛拟機所管理的記憶體中最大的一塊,在虛拟機啟動時建立,目的是存放對象執行個體,所有的對象執行個體以及數組都要在堆上配置設定,java堆也稱為GC堆,從記憶體回收的角度來看,現在收集器基本都采用分代收集算法,是以java堆中還可以細分為:新生代和老年代,在細緻一點的有Eden空間,From Survivor空間,To Survivor空間。從記憶體配置設定的角度看,線程共享的java堆中可能劃分出多個線程私有的配置設定緩沖區,java堆可以處于實體上不連續的記憶體空間中,隻要邏輯上是連續的即可,在實作時,既可以實作成固定大小,也可以是可擴充的,主流的虛拟機都是按照可擴充來實作的,如果堆中沒有記憶體完成執行個體配置設定,并且堆也無法在擴充時,将會抛出OutOfMemoryError異常
方法區(java8.0後變為中繼資料區,存放在本地記憶體中,1.7之前存放在虛拟機中):用于存儲已被虛拟機加載的類資訊,常量,靜态變量,即時編譯器編譯後的代碼等資料,規範把方法區描述為堆的一個邏輯部分,有個别名叫Non-Heap(非堆),當方法區無法滿足記憶體配置設定需求是,将抛出OutOfMemoryError異常
運作時常量池:是方法區的一部分,Class檔案中除了有類的版本,字段,方法,接口,等描述資訊外,還有一項資訊是常量池,用于存放編譯期生成的各種字面量和符号引用,這部分内容将在類加載後進入方法區的運作時常量池中存放,除了儲存Class檔案中描述的符号引用外,還會把翻譯出來的直接引用也存儲在運作時常量池中,運作時常量池具備動态性,運作期間也可能将新的常量放入池中,比如String類的intern()方法。常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會抛出OutOfMemoryError異常
直接記憶體:并不是虛拟機運作時資料區的一部分,也不是規範中定義的記憶體區域。會受到本機總記憶體大小以及處理器尋址空間的限制,各個記憶體區域總和大于實體記憶體限制會導緻動态擴充時出現OutOfMemoryError異常
對象的建立
虛拟機遇到一條new指令時,首先将去檢查這個指令的參數是否能在常量池中定位到一個類的符号引用,并且檢查這個符号引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。在類加載檢查通過後,虛拟機将為新生對象配置設定記憶體。對象所需記憶體的大小在類加載完成後便可完全确定,假設java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閑的記憶體放在另一邊,中間放着一個指針作為分界點的訓示器,那所配置設定記憶體就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離,這種配置設定方式稱為“指針碰撞”,如果java堆中的記憶體并不是規整的,虛拟機就必須維護一個清單,記錄上哪些記憶體塊是可用的,在配置設定的時候從清單中找到一塊足夠大的空間劃分給對象執行個體,并更新清單上的記錄,這種配置設定方式稱為“空閑清單”采用空閑清單方式在并發情況下也并不是線程安全的,解決這個問題有兩種方案,一種是對配置設定記憶體空間的動作進行同步處理——實際上虛拟機采用CAS配上失敗重試的方式保證更新操作的原子性,另一種是把記憶體配置設定的動作按照線程劃分在不同的空間之中進行,即每個線程在java堆中預先配置設定一小塊記憶體,稱為本地線程配置設定緩沖,哪個線程要配置設定記憶體,就在哪個線程的TLAB上配置設定,隻有TLAB用完并配置設定新的TLAB時,才需要同步鎖定。記憶體配置設定完成後,虛拟機需要将配置設定到的記憶體空間都初始化為零值(不包括對象頭),這一步保證了對象的執行個體字段在java代碼中可以不賦初始值就直接使用,程式能通路到這些字段的資料類型所對應的零值。接下來,虛拟機要對對象進行必要的設定,工作完成後,執行<init>方法,把對象按照程式員的意願進行初始化,這樣一個真正可用的對象才算完全産生出來
對象的記憶體布局
對象在記憶體中存儲的布局可以分為3塊區域:對象頭,執行個體資料和對齊填充.
對象頭包括兩部分資訊,第一部分用于存儲對象自身的運作時資料,如哈希碼,GC分代年齡,鎖狀态标志,線程持有的鎖,偏向線程ID,偏向時間戳等.對象頭的另一部分是類型指針,即對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體.如果對象是一個java數組,那在對象頭中還必須有一塊用于記錄數組長度的資料,因為虛拟機可以通過普通java對象的中繼資料資訊确定java對象的大小,但是從數組的中繼資料中卻無法确定數組的大小.
執行個體資料部分是對象真正存儲的有效資訊,也是在程式代碼中所定義的各種類型的字段内容.
第三部分對齊填充并不是必然存在的,也沒有特别的含義,它僅僅起着占位符的作用.
對象的通路定位
引用應該通過何種方式去定位,通路堆中的對象的具體位置,目前主流的通路方式有使用句柄和直接指針兩種
如果使用句柄通路的話,那麼java堆中将會劃分出一塊記憶體來作為句柄池,reference中存儲的就是對象的句柄位址,而句柄中包含了對象執行個體資料和類型資料各自的具體位址資訊.
如果使用直接指針通路,那麼java堆對象的布局中就必須考慮如何放置通路類型資料的相關資訊,而reference中存儲的直接就是對象位址.
兩種對象通路方式各有優勢,使用句柄來通路的最大好處就是reference中存儲的時穩定的句柄位址,在對象被移動時隻會改變句柄中的執行個體資料指針,而reference本身不需要修改.
使用直接指針通路方式的最大好處就是速度更快,它節省了一次指針定位的時間開銷.
垃圾收集器與記憶體配置設定政策
GC需要完成的3件事情:
哪些記憶體需要回收?
什麼時候回收?
如何回收?
程式計數器,虛拟機棧,本地方法棧3個區域随線程而生,随線程而滅,這幾個區域不需要過多考慮回收的問題,因為方法結束或者線程結束時,記憶體自然就随着回收了,而java堆和方法區則不一樣,一個接口中的多個實作類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們隻有在程式處于運作期間才能知道會建立哪些對象,這部分記憶體的配置設定和回收都是動态的,垃圾收集器所關注的是這部分記憶體.
引用計數算法:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1,當引用失效時,計數器值就減1,任何時刻計數器為0的對象就是不可能再被使用的.主流的java虛拟機沒有選用引用計數算法來管理記憶體,其中最主要的原因是它很難解決對象之間互相循環引用的問題.
可達性分析算法:這個算法的基本思路就是通過一系列的稱為”GC Roots”的對象作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鍊,當一個對象到GC Roots沒有任何引用鍊相連時,則證明此對象是不可用的.
在java語言中,可作為GC Roots的對象包括下面幾種:
- 虛拟機棧(棧幀中的本地變量表)中引用的對象
- 方法區中類靜态屬性引用的對象
- 方法區中常量引用的的對象
- 本地方法棧JNI(即一般說的Native方法)引用的對象
引用
判定對象是否存活與引用有關,如果reference類型的資料中存儲的數值代表的是另一塊記憶體的起始位址,就稱這塊記憶體代表着一個引用.引用可分為強引用,軟引用,弱引用和虛引用,這4中引用強度依次逐漸減弱.
強引用:指在程式代碼之中普遍存在的引用,隻要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象.
軟引用:用來描述一些還有用但并非必需的對象.對于軟引用關聯着的對象,在系統将要發生記憶體溢出異常之前,将會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的記憶體,才會抛出記憶體溢出異常.用SoftReference類來實作軟引用
弱引用:用來描述非必需對象,強度比軟引用更弱一些,被弱引用關聯的對象隻能生存到下一次垃圾收集發生之前.當垃圾收集器工作時,無論目前記憶體是否足夠,都會回收掉隻被弱引用關聯的對象,用WeakReference類來實作弱引用
虛引用:也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系,一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象執行個體,為一個對象設定虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知.用PhantomReference類來實作虛引用.
死亡
要真正宣告一個對象死亡,至少要經曆兩次标記過程,如果對象在進行可達性分析後發現沒有與GC Roots相連接配接的引用鍊,那麼它将會被第一次标記并且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法.當對象沒有覆寫finalize()方法,或者finalize()方法已經被虛拟機調用過,虛拟機将這兩種情況都視為”沒有必要執行”.
如果這個對象被判定為有必要執行finalize()方法,那麼這個對象将會放置在一個叫做F-Queue的隊列之中,并在稍後由一個由虛拟機自動建立的,低優先級的Finalizer線程去執行它,這裡所謂的”執行”是指虛拟機會觸發這個方法,但并不承諾會等待它運作結束.finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC将對F-Queue中的對象進行第二次小規模的标記,如果對象要在finalize()中成功拯救自己,隻要重新與引用鍊上的任何一個對象建立關聯即可,那在第二次标記時它将被移除出”即将回收”的集合,如果對象這時候還沒有逃脫,那基本上它就真的被回收了
回收方法區
在方法區(HotSpot虛拟機中的永久代)中進行垃圾收集的”成本效益”一般比較低,永久代的垃圾收集主要回收兩部分内容:廢棄常量和無用的類
垃圾收集算法
标記-清除算法
标記-清除算法分為标記和清除兩個階段,首先标記處所有需要回收的對象,在标記完成後統一回收所有被标記的對象.它的不足有兩個:一個是效率問題,标記和清除兩個過程的效率都不高;另一個是空間問題,标記清除之後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻以後在程式運作過程中需要配置設定較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作.
複制算法
它将可用記憶體按容量劃分為大小相等的兩塊,每次隻使用其中的一塊.當這一塊的記憶體用完了,就将還存活着的對象複制到另一塊上面,然後再把已使用過的記憶體空間一次清理掉,實作簡單,運作高效,這種算法的代價是将記憶體縮小為原來的一半.現在的商業虛拟機都采用這種收集算法來回收新生代,将記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor.當回收時,将Eden和Survivor中還存活着的對象一次性地複制到另一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間,HotSpot虛拟機預設Eden和Survivor的大小比例是8:1.當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行配置設定擔保
标記-整理算法
複制收集算法在對象存活率較高時就要進行較多的複制操作,效率将會變低,是以老年代一般不能直接選用這種算法.标記-整理算法的标記過程仍然與标記-清除算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體.
分代收集算法
分代收集算法根據對象存活周期的不同将記憶體劃分為幾塊,一般是把java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最适當的收集算法,新生代選用複制算法,老年代使用标記-清理或者标記-整理算法來進行回收.
HotSpot的算法實作
枚舉根節點
安全點
安全區域
垃圾收集器
記憶體配置設定與回收政策
Java計數體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題,給對象配置設定記憶體以及回收配置設定給對象的記憶體
對象的記憶體配置設定,往大方向将,就是在堆上配置設定,對象主要配置設定在新生代的Eden區上,如果啟動了本地線程配置設定緩沖,将按線程優先在TLAB上配置設定.少數情況下也可能會直接配置設定在老年代中.
新生代GC(Minor GC)指發生在新生代的垃圾收集動作,Minor GC非常頻繁,一般回收速度也比較快.
老年代GC(Major GC/Full GC)指發生在老年代的GC,Major GC的速度一般會比Minor GC慢10倍以上.
對象優先在Eden配置設定:大多數情況下,對象在新生代Eden區中配置設定,當Eden區沒有足夠的空間進行配置設定時,虛拟機将發起一次Minor GC.
大對象直接進入老年代:所謂的大對象是指,需要大量連續記憶體空間的java對象,最典型的大對象就是那種很長的字元串以及數組,經常出現大對象容易導緻記憶體還有不少空間時就提前觸發垃圾收集以擷取足夠的連續空間來”安置”他們.預設超過3MB的對象都會直接在老年代進行配置設定
長期存活的對象将進入老年代:虛拟機給每個對象定義了一個對象年齡計數器,如果對象在Eden出生并經過第一次Minor GC後仍然存活,并且能被Survivor容納的話,将被移動到Survivor空間中,并且對象年齡設為1,對象在Survivor區中每”熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲),就将會被晉升到老年代中.
動态對象年齡判定:為了能更好地适應不同程式的記憶體狀況,虛拟機并不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡.
空間配置設定擔保:在發生Minor GC之前,虛拟機會先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確定是安全的.如果不成立,則虛拟機會檢視HandlePromotionFailure設定值是否允許擔保失敗,如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大于曆次晉升到老年代對象的平均大小,如果大于,将嘗試着進行一次Minor GC,盡管這次Minor GC是有風險的;如果小于,或者HandlePromotionFailure設定不允許冒險,那這時也要改為進行一次Full GC.
虛拟機性能監控與故障處理工具
調優案例分析與實戰
類檔案結構
Class檔案是一組以8位位元組為基礎機關的二進制流,各個資料項目嚴格按照順序緊湊地排列在Class檔案之中,中間沒有添加任何分隔符,這使得整個Class檔案中存儲的内容幾乎全部是程式運作的必要資料,沒有空隙存在.當遇到占用8位位元組以上空間的資料項時,則會按照高位在前的方式分割成若幹個8位位元組進行存儲
Class檔案格式采用一種類似C語言結構體的僞結構來存儲資料,這種僞結構中隻有兩種資料類型:無符号數和表
無符号數屬于基本的資料類型,以u1,u2,u4,u8來分别代表1個位元組,2個位元組,4四個位元組和8個位元組的無符号數,無符号數可以用來描述數字,索引引用,數量值或者按照UTF-8編碼構成字元串值.
表是由多個無符号數或者其他表作為資料項構成的複合資料類型,所有表都習慣性地以”info”結尾.表用于描述有層次關系的複合結構的資料,整個Class檔案本質上就是一張表
無論是無符号數還是表,當需要描述同一類型但數量不定的多個資料時,經常會使用一個前置的容量計數器加若幹個連續的資料項的形式,這時稱這一系列連續的某一類型的資料為某一類型的集合.
魔數與Class檔案的版本
每個Class檔案的頭4個位元組稱為魔數,它的唯一作用是确定這個檔案是否為一個能被虛拟機接受的Class檔案.很多檔案存儲标準中都使用魔數來進行身份識别,如圖檔格式gif或者jpeg等在檔案頭中都存有魔數.(基于安全考慮)Class魔數的值為:0xCAFEBABE(咖啡寶貝?)
緊接着魔數的4個位元組存儲的時Class檔案的版本号:第5和第6個位元組是次版本号,第7和第8個位元組是主版本号.
緊接着主次版本号之後的時常量池入口,常量池可以了解為Class檔案之中的資源倉庫,它是Class檔案結構中與其他項目關聯最多的資料類型,也是占用Class檔案空間最大的資料項目之一,同時它還是在Class檔案中第一個出現的表類型資料項目,由于常量池中常量的數量是不固定的,是以在常量池的入口需要放置一項u2類型的資料,代表常量池容量計數值,這個容量計數是從1開始,而不是從0開始的.設計者将第0項常量空出來是有特殊考慮的,這樣做的目的在于滿足後面某些指向常量池的索引值的資料在特定情況下需要表達”不引用任何一個常量池項目”的含義,這種情況就可以把索引值置為0來表示.Class檔案結構中隻有常量池的容量計數是從1開始的.
常量池中主要存放兩大類常量:字面量和符号引用
字面量比較接近于java語言層面的常量概念,如文本字元串,聲明為final的常量值等.
符号引用則屬于編譯原理方面的概念,包括了下面三類常量:
- 類和接口的全限定名
- 字段的名稱和描述符
- 方法的名稱和描述符
Java代碼在進行javac編譯的時候,并不像C和C++那樣有”連接配接”這一步驟,而是在虛拟機加載Class檔案的時候進行動态連接配接.在Class檔案中不會儲存各個方法,字段的最終記憶體布局資訊,是以這些字段,方法的符号引用不經過運作期轉換的話無法得到真正的記憶體入口位址,也就無法直接被虛拟機使用.當虛拟機運作時,需要從常量池獲得對應的符号引用,再在類建立時或運作時解析,翻譯到具體的記憶體位址之中.
常量池中每一項常量都是一個表,14種表都有一個共同的特點,就是表開始的第一位是一個u1類型的标志位,代表目前這個常量屬于哪種常量類型
Javap:專門用于分析Class檔案位元組碼的工具
在常量池結束之後,緊接着的兩個位元組代表通路标志,這個标志用于識别一些類或者接口層次的通路資訊,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstract類型;如果是類的話,是否被聲明為final等.通路标志中一共有16個标志位可以使用,目前隻定義了其中8個.
類索引,父類索引和接口索引集合都按順序排列在通路标志之後,類索引,父類索引都是一個u2類型的資料,而接口索引集合是一組u2類型的資料的集合,Class檔案中由這三項資料來确定這個類的繼承關系,類索引用于确定這個類的全限定名,父類索引用于确定這個類的父類的全限定名,由于java語言不允許多重繼承,是以父類索引隻有一個,是以除了java.lang.Object外,所有java類的父類索引都不為0.接口索引集合就用來描述這個類實作了哪些接口,這些被實作的接口将按implements語句(如果這個類本身是一個接口,則應當是extends語句)後的接口順序從左到右排列在接口索引集合中.對于接口索引集合,入口的第一項----u2類型的資料為接口計數器,表示索引表的容量,如果該類沒有實作任何接口,則該計數器值為0,後面接口的索引表不再占用任何位元組
字段表用于描述接口或者類中聲明的變量.字段包括類級變量以及執行個體級變量,但不包括在方法内部聲明的局部變量,字段可以包括的資訊有:字段的作用域,是執行個體變量還是類變量,可變性,并發可見性.可否被序列化,字段資料類型,字段名稱.這些資訊,各個修飾符都是布爾值.
.
.
.
.
虛拟機類加載機制
虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗,轉換解析和初始化,最終形成可以被虛拟機直接使用的java類型,這就是虛拟機的類加載機制.
在java語言裡面,類型的加載,連接配接和初始化過程都是在程式運作期間完成的.
類加載的時機
類從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期包括:加載,驗證,準備,解析,初始化,使用,解除安裝7個階段.其中驗證,準備,解析3個部分統稱為連接配接.
JVM規範規定了有且隻有5種情況必須立即對類進行”初始化”
- 遇到new,getstatic,putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化.生存這4條指令的最常見的java場景是:使用new關鍵字執行個體化對象的時候,讀取或設定一個類的靜态字段(被final修飾,已在編譯期把結果放入常量池的靜态字段除外)的時候,以及調用一個類的靜态方法的時候.
- 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化.
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化
- 當虛拟機啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛拟機會先初始化這個主類
- 當使用JDK1.7的動态語言支援時,如果一個java.lang.invoke.MethodHandle執行個體最後的解析結果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化.
這5種場景中的行為稱為對一個類進行主動引用,除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用
通過子類引用父類的靜态字段,不會導緻子類初始化.
通過數組定義來引用類,不會觸發此類的初始化
常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,是以不會觸發定義常量的類的初始化
接口的加載過程與類加載過程稍有一些不同,針對接口需要做一些特殊說明:接口也有初始化過程,而接口中不能使用static{}語句塊,但編譯器仍然會為接口生成<clinit>()類構造器,用于初始化接口中定義的成員變量
方法構造器<init>
當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,隻有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化.
類加載的過程
加載
在加載階段,虛拟機需要完成以下3件事情:
- 通過一個類的全限定名來擷取定義此類的二進制位元組流
- 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構
- 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口
數組類本身不通過類加載器建立,它是由java虛拟機直接建立的.但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型最終是要靠類加載器去建立.
驗證
驗證是連接配接階段的第一步,這一階段的目的是為了確定Class檔案的位元組流中包含的資訊符合目前虛拟機的要求,并且不會危害虛拟機自身的安全.
驗證階段大緻上回完成下面4個階段的檢驗動作:檔案格式驗證,中繼資料驗證,位元組碼驗證,符号引用驗證
檔案格式驗證:要驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理.
中繼資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合java語言規範的要求
位元組碼驗證:通過資料流和控制流分析,确定程式語義時合法的,符合邏輯的.
符号引用驗證:發生在虛拟機将符号引用轉化為直接引用的時候,這個轉化動作将在連接配接的第三階段-----解析階段中發生.符号引用驗證可以看做是對類自身以外的資訊(常量池的各種符号引用)進行比對性校驗.
對于虛拟機的類加載機制來說,驗證階段是一個非常重要的,但不是一定必要的階段.如果所運作的全部代碼都已經被反複使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛拟機類加載的時間.
準備
準備階段是正式為類變量配置設定記憶體并設定類變量初始值(零值)的階段,這些變量所使用的記憶體都将在方法區中進行配置設定,類變量在方法區中,執行個體變量将會在對象執行個體化時随着對象一起配置設定在java堆中.
如果使用了final,那麼在準備階段虛拟機就會根據程式員定義的值進行指派.
解析
解析階段是虛拟機将常量池内的符号引用替換為直接引用的過程
符号引用:以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可.
直接引用:可以是直接指向目标的指針,相對偏移量或是一個能間接定位到目标的句柄.
解析動作主要針對類或接口,字段,類方法,接口方法,方法類型,方法句柄和調用點限定符7類符号引用進行.
類或接口的解析
字段解析
類方法解析
接口方法解析
初始化
到了初始化階段,才真正開始執行類中定義的java程式代碼(或者說是位元組碼),在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程式員通過程式制定的主觀計劃去初始化類變量和其他資源,初始化階段是執行類構造器<clinit>()方法的過程.
<clinit>()方法是由編譯器自動收集類中的所有類變量的指派動作和靜态語句塊中的語句合并産生的.編譯器收集的順序是由語句在源檔案中出現的順序所決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句塊可以指派,但是不能通路.
<clinit>()方法與類的構造函數(或者說執行個體構造器<init>()方法)不同,它不需要顯式地調用父類構造器,虛拟機會保證在子類<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢.是以在虛拟機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object.
由于父類的<clinit>()方法先執行,也就意味着父類中定義的靜态語句塊要優先于子類的變量指派操作.
<clinit>()方法對于類或接口來說并不是必需的,如果一個類中沒有靜态語句塊,也沒有對變量的指派操作,那麼編譯器可以不為這個類生産<clinit>()方法.
接口中不能使用靜态語句塊,但仍然有變量初始化的指派操作,是以接口與類一樣都會生成<clinit>()方法.但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法.隻有當父接口中定義的變量使用時,父接口才會初始化.另外,接口的實作類在初始化時也一樣不會執行接口的<clinit>()方法.
虛拟機會保證一個類的<clinit>()方法在多線程環境中被正确的加鎖,同步,如果多個線程同時去初始化一個類,那麼隻會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢.如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個程序阻塞.
同一個類加載器下,一個類型隻會初始化一次.
類加載器
虛拟機設計團隊把類加載階段中的”通過一個類的全限定名來擷取描述此類的二進制位元組流”這個動作放到java虛拟機外部去實作,以便讓應用程式自己決定如何去擷取所需要的類.實作這個動作的代碼子產品稱為”類加載器”.
類與類加載器
類加載器隻用于實作類的加載動作.對于任意一個類,都需要由加載它的類加載器和這個類一同确立其在java虛拟機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間.通俗來講:比較兩個類是否”相等”,隻有在這兩個類是由同一個類加載器的前提下才有意義,否則,即使這兩個類來源于同一個Class檔案,被同一個虛拟機加載,隻有加載它們的類加載器不同,那這兩個類就必定不相等.
雙親委派模型
從java虛拟機的角度來講,隻存在兩種不同的類加載器:一種是啟動類加載器,這個類加載器使用C++語言實作,是虛拟機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都由java語言實作,獨立于虛拟機外部,并且全都繼承自抽象類java.lang.ClassLoader.
絕大部分java程式都會使用到一下3種系統提供的類加載器.
啟動類加載器(無父類):這個類加載器負責将存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,并且是虛拟機所識别的類庫加載到虛拟機記憶體中.啟動類加載器無法被java程式直接引用,使用者在編寫自定義類加載器時,如果需要把加載請求委派給啟動類加載器,那直接使用null代替即可.
擴充類加載器(父類為null):這個加載器由sun.misc.Launcher$ExtClassLoader實作,它負責加載<JAVA_HOME>\lib\ext目錄中,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴充類加載器.
應用程式類加載器(父類為擴充類加載器):這個類加載器由sun.misc.Launcher$App-ClassLoader實作.由于這個類加載器是ClassLoader中的getSystemClassLoader()方法的傳回值,是以一般也稱它為系統類加載器.它負責加載使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程式中沒有自定義過自己的類加載器,一般情況下這個就是程式中預設的類加載器.
自定義類加載器(父類必定為應用程式類加載器)

類加載器雙親委派模型.
類加載器之間的父子關系一般不會以繼承的關系來實作,而是使用組合關系來複用父加載器的代碼.
雙親委派模型的工作過程:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的類加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求時,子加載器才會嘗試自己去加載.
使用雙親委派模型的好處就是java類随着它的類加載器一起具備了一種帶有優先級的層次關系.
雙親委派模型對于保證java程式的穩定運作很重要,實作雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中:先檢查是否已經被加載過,若沒有加載則調用父加載器的loadClass()方法,若父加載器為空則預設使用啟動類加載器作為父加載器.如果父類加載失敗,抛出ClassNotFoundException異常後,再調用自己的findClass()方法進行加載.
虛拟機位元組碼執行引擎
執行引擎輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果.
棧幀是用于支援虛拟機進行方法調用和方法執行的資料結構,是虛拟機棧的棧元素.棧存儲了方法的局部變量表,操作數棧,動态連接配接和方法傳回位址等資訊.每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛拟機裡面從入棧到出棧的過程.一個棧幀需要配置設定多少記憶體,不會受到程式運作期變量資料的影響.對于執行引擎來說,在活動線程中,棧頂的棧幀稱為目前棧幀,與這個棧幀相關聯的方法稱為目前方法.執行引擎運作的所有位元組碼指令都隻針對目前棧幀進行操作.
局部變量表
局部變量表是一組變量值存儲空間,用于存放方法參數和方法内部定義的局部變量,編譯時就确定了該方法所需要配置設定的局部變量表的最大容量.虛拟機通過索引定位的方式使用布局變量表.方法執行時,虛拟機是使用局部變量表完成參數值到參數變量清單的傳遞過程的.如果執行的是執行個體方法,那局部變量表中第0位索引的Slot預設是用于傳遞方法所屬對象執行個體的引用,在方法中可以通過關鍵字”this”來通路到這個隐含的參數.
方法體中定義的變量,其作用域并不一定會覆寫整個方法體,如果目前位元組碼PC計數器的值已經超出了某個變量的作用域,那這個變量對于的Slot就可以交給其他變量使用.
不應當對賦null值的操作有過多的依賴,經過JIT編譯器後,賦null值的操作在經過JIT編譯優化後就會被消除掉,這時候将變量設定為null就是沒有意義的.
局部變量表不存在”準備階段”.局部變量定義了但并沒有賦初始值是不能使用的.
操作數棧
操作數棧也常稱為操作棧,操作數棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks資料項中,操作數棧元素可以是任意的java資料類型
操作數棧優化處理:令兩個棧幀出現一部分重疊,讓下面棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用時就可以共用一部分資料,無須進行額外的參數複制傳遞.
Java虛拟機的解釋執行引擎稱為”基于棧的執行引擎”,其中所指的棧就是操作數棧.
動态連接配接
每個棧幀都包含一個指向運作時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連接配接.
常量池中指向方法的符号引用,一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化成為靜态解析,另外一部分将在每一次運作期間轉化為直接引用,這部分稱為動态連接配接.
方法傳回位址
當一個方法開始執行後,隻有兩種方式可以退出這個方法.第一種方式是正常完成出口,另外一種退出方式是異常完成出口.無論采用何種退出方式,在方法退出之後,都需要傳回到方法被調用的位置,一般來說,方法正常退出時,調用者的PC計數器的值可以作為傳回位址,而方法異常退出時,傳回位址是要通過異常處理器表來确定的.棧幀中一般不會儲存這部分資訊.方法退出的過程實際上就等同于把目前棧幀出棧.
方法調用
方法調用并不等同于方法執行,方法調用階段唯一的任務就是确定被調用方法的版本(即調用哪一個方法),Class檔案的編譯過程中不包含傳統編譯中的連接配接步驟,一切方法調用在Class檔案裡面存儲的都隻是符号引用,而不是方法在實際運作時記憶體布局中的入口位址.需要在類加載期間,甚至到運作期間才能确定目标方法的直接引用.
靜态解析
所有方法調用中的目标方法在Class檔案裡面都是一個常量池中的符号引用,在類加載的解析階段,會将其中的一部分符号引用轉化為直接引用,前提是方法必須”編譯器可知,運作期不可變”,滿足要求的方法主要有:靜态方法和私有方法以及final方法,前者與類型直接關聯,後者在外部不可通路,它們都不可能被重寫,是以他們都适合在類加載階段進行解析.
靜态調用一定是個靜态的過程,在編譯期間就完全确定,在類裝載的解析階段就會把涉及的符号引用全部轉變為可确定的直接引用.而分派調用可能是靜态的也可能是動态的.
靜态分派
左邊的稱為變量的靜态類型(外觀類型),右邊的稱為變量的實際類型.靜态類型和實際類型在程式中都可以發生一些變化,差別是靜态類型的變化僅僅發生在使用時發生,變量本身的靜态類型不會被改變,并且最終的靜态類型是在編譯期可知的;而實際類型變化的結果在運作期才可确定.
編譯器在重載時是通過參數的靜态類型而不是實際類型作為判定依據的.
所有依賴靜态類型來定位方法執行版本的分派動作稱為靜态分派.靜态分派的典型應用是方法重載,靜态分派發生在編譯階段.是以确定靜态分派的動作實際上不是由虛拟機來執行的.
靜态方法會在類加載期就進行解析,而靜态方法顯然也是可以擁有重載版本的,選擇重載版本的過程也是通過靜态分派完成的.
動态分派
在運作期根據實際類型确定方法執行版本的分派過程稱為動态分派.
單分派與多分派
方法的接受者與方法的參數稱為方法的宗量,單分派是根據一個宗量對目标方法進行選擇,多分派則是根據多于一個宗量對目标方法進行選擇.
Java語言是一門靜态多分派,動态單分派的語言.
虛拟機動态分派的實作
動态分派的方法版本選擇過程需要運作時在類的方法中繼資料中搜尋合适的目标方法,是以在虛拟機的實際實作中基于性能的考慮,大部分實作都不會真正地進行如此頻繁的搜尋.最常用的”穩定優化”手段就是為類在方法區中建立一個虛方法表,使用虛方法表索引來代替中繼資料查找以提高性能.
虛方法表中存放着各個方法的實際入口位址,如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的位址入口和父類相同方法的位址入口是一緻的.如果子類中重寫了這個方法,子類方法表中的位址将會替換為指向子類實作版本的入口位址.
虛拟機除了使用方法表之外,在條件允許的情況下,還會使用内聯緩存和守護内聯兩種非穩定的”激進優化”手段來獲得更高的性能.
程式編譯與代碼優化
早期(編譯期)優化
前端編譯器把*.java檔案轉變成*.class檔案.
後端運作期編譯器(JIT)把位元組碼轉變成機器碼.
靜态提前編譯器直接把*.java檔案編譯成本地機器代碼.
虛拟機設計團隊把針對性能的優化集中到了後端的即時編譯器中,相當多新生的java文法特性,都是靠編譯器的”文法糖”來實作,而不是依賴虛拟機的底層改進來支援.
Java中即時編譯器在運作期的優化過程對于程式運作來說更重要,而前端編譯器在編譯期的優化過程對程式編碼來說關系更加密切.
編譯過程大緻可以分為3個過程:
- 解析與填充符号表過程.
- 插入式注解處理器的注解處理過程.
- 分析與位元組碼生成過程.
解析與填充符号表
詞法分析将源代碼的字元流轉變為标記(Token)集合.
文法分析根據Token序列構造抽象文法樹
完成了文法分析和詞法分析後,接着就是填充符号表.符号表是由一組符号位址和符号資訊構成的表格.符号表中所登記的資訊在編譯的不同階段都要用到.在語義分析中,符号表所登記的内容将用于語義檢查和産生中間代碼.在目标代碼生成階段,當對符号名進行位址配置設定時,符号表是位址配置設定的依據.
注解處理器
注解與普通的java代碼一樣,是在運作期間發揮作用的.
語義分析與位元組碼生成
文法分析之後,編譯器獲得了程式代碼的抽象文法樹表示,文法樹能表示一個結構正确的源程式的抽象,但無法保證源程式是符合邏輯的.
語義分析的主要任務是對結構上正确的源程式進行上下文有關性質的審查.
解文法糖
文法糖也稱糖衣文法,指在計算機語言中添加某種文法,這種文法對語言的功能并沒有影響,但是更友善程式員使用.使用文法糖能夠增加程式的可讀性,進而減少程式代碼出錯的機會.
Java中常用的文法糖主要有:泛型,變長參數,自動裝箱/自動拆箱等.虛拟機運作時不支援這些文法,它們在編譯階段還原回簡單的基礎文法結構,這個過程稱為解文法糖.
位元組碼生成是javac編譯過程的最後一個階段,位元組碼生成階段不僅僅是把前面各個步驟所生成的資訊轉化為位元組碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作.如執行個體構造器<init>()方法和類構造器<clinit>()方法就是在這個階段添加到文法樹中的(預設構造函數在填充符号表階段就已經完成)還有一些代碼替換工作用于優化程式的實作邏輯,如把字元串的加操作替換為StringBuffer或StringBuilder的append()操作等.
泛型與類型擦除
泛型的本質是參數化類型,參數化類型可以用在類,接口和方法的建立中,分别稱為泛型類,泛型接口和泛型方法.
基于類型膨脹方法實作的泛型稱為真實泛型.
Java語言中的泛型隻在程式源碼中存在,在編譯後的位元組碼檔案中,就已經替換為原來的原生類型(也稱裸類型)了,并且在相應的地方插入了強制類型轉換代碼
Java語言中的泛型實作方法稱為類型擦除,基于這種方法實作的泛型稱為僞泛型.
泛型用于提升語義準确性,當泛型遇見重載,擦除動作導緻兩個方法的特征簽名變得一模一樣.
方法重載要求方法具備不同的特征簽名,傳回值并不包含在方法的特征簽名之中,是以傳回值不參與重載選擇.
在Class檔案格式之中,隻要描述符不是完全一緻的兩個方法就可以共存.也就是說,兩個方法如果有相同的名稱和特征簽名,但傳回值不同,那它們也是可以合法地共存于一個Class檔案中的.
擦除法所謂的擦除,僅僅是對方法的Code屬性中的位元組碼進行擦除,實際上中繼資料中還是保留了泛型資訊,這也是我們能通過反射手段取得參數化類型的根本依據.
自動裝箱,拆箱與周遊循環
自動裝箱,拆箱與周遊循環是java語言裡使用得最多的文法糖.周遊循環需要被周遊的類實作Iterable接口.
包裝類的”==”運算在不遇到算數運算的情況下不會自動拆箱,它們的equals()方法不處理資料轉型.
條件編譯
Java語言也可以進行條件編譯,方法就是使用條件為常量的if語句,這也是java語言的文法糖,根據布爾常量值的真假,編譯器将會把分支中不成立的代碼塊消除掉.
Java語言中還有不少其他的文法糖,如内部類,枚舉類,斷言語句,對枚舉和字元串的switch支援,try語句中定義和關閉資源等
晚期(運作期)優化.
JIT的部分優化技術:
方法内聯,備援通路消除,複寫傳播,無用代碼消除.
語言無關的經典優化技術之一:公共子表達式消除.
語言相關的金店優化技術之一:數組範圍檢查消除.
最重要的優化技術之一:方法内聯.
最前沿的優化技術之一:逃逸分析.
公共子表達式消除:如果一個表達式E已經計算過了,并且從先前的計算到現在E中所有變量的值都沒有發生變化,那麼E的這次出現就成為了公共子表達式.隻需要直接使用前面計算過的表達式結果代替E就可以了.如果這種優化僅限于程式基本塊内,便稱為局部公共子表達式消除,如果這種優化的範圍涵蓋了多個基本塊,就稱為全局公共子表達式消除.
數組邊界檢查消除:通路數組元素的時候系統會自動進行上下界的範圍檢查,每次數組元素的讀寫都帶有一次隐含的條件判定操作,對于擁有大量數組通路的程式代碼,這無疑也是一種性能負擔.
Java中空指針檢查和算數運算中除數為零的檢查都采用了隐式異常處理.
異常處理器抛出異常必須從使用者态轉到核心态中處理,結束後再回到使用者态,速度遠比一次判斷檢查慢.
與語言相關的消除操作還有:自動裝箱消除,安全點消除,消除反射等.
方法内聯:可以消除方法的調用成本,為其他優化手段建立良好的基礎.工作原理------把目标方法的代碼”複制”到發起調用的方法之中,避免發生真實的方法調用而已.
内聯時,如果是非虛方法,那麼直接進行内聯就可以了.如果遇到虛方法,則會向CHA查詢此方法在目前程式下是否有多個目标版本可供選擇,如果查詢結果隻有一個版本,那也可以進行内聯,不過這種内聯就屬于激進優化,需要預留一個”逃生門”,稱為守護内聯.
内聯緩存:是一個建立在目标方法正常入口之前的緩存,它的工作原理大緻是:在未發生方法調用之前,内聯緩存狀态為空,當第一次調用發生後,緩存記錄下方法接收者的版本資訊,并且每次進行方法調用時都比較接受者版本,如果以後進來的每次調用的方法接受者版本都是一樣的,那這個内聯還可以一直用下去.如果發生了方法接收者不一緻的情況,就說明程式真正使用了虛方法的多态特性,這時才會取消内聯,查找虛方法表進行方法分派.
逃逸分析:是為其他優化手段提供依據的分析技術,逃逸分析的基本行為就是分析對象動态作用域:當一個對象在方法中被定義後,它可能被外部方法所引用,例如作為調用參數傳遞到其他方法中,稱為方法逃逸.甚至還有可能被外部線程通路到,譬如指派給類變量或可以在其他線程之中通路的執行個體變量,稱為線程逃逸.
如果能證明一個對象不會逃逸方法或線程之外,則可能為這個變量進行一些高效的優化:棧上配置設定,同步消除,标量替換.
棧上配置設定:如果确定一個對象不會逃逸出方法之外,那讓這個對象在棧上配置設定記憶體,對象所占用的記憶體空間就可以随棧幀出棧而銷毀.在一般應用中,不會逃逸的局部對象所占的比例很大,如果能使用棧上配置設定,那大量的對象就會随着方法的結束而自動銷毀了,垃圾收集系統的壓力将會小很多.
同步消除:線程同步本身是一個相對耗時的過程,如果逃逸分析能夠确定一個變量不會逃逸出線程,無法被其他線程通路,那這個變量的讀寫肯定不會有競争,對這個變量實施的同步措施也就可以消除掉.
标量替換:标量是指一個資料已經無法在分解成更小的資料來表示了,java虛拟機中的原始資料類型就可以稱為标量,如果一個資料可以繼續分解,那它就稱作聚合量,java中的對象就是最典型的聚合量.如果把一個java對象拆散,根據程式通路的情況,将其使用到的成員變量恢複原始類型來通路就叫标量替換.如果逃逸分析證明一個對象不會被外部通路,并且這個對象可以被拆散的話,那程式真正執行的時候将可能不建立這個對象,而改為直接建立它的若幹個被這個方法使用到的成員變量來代替.将對象拆分後,除了可以讓對象的成員變量在棧上配置設定和讀寫之外,還可以為後續進一步優化手段建立條件.
逃逸分析這項優化尚未足夠成熟,仍有很大的改進餘地,主要原因是不能保證逃逸分析的性能收益必定高于它的消耗.
棧上配置設定在HotSpot中暫時還沒有做這項優化.
高效并發
Amdahl定律通過系統中并行化與串行化的比重來描述多處理器系統能獲得的運算加速能力
摩爾定律則用于描述處理器半導體與運作效率之間的發展關系.
衡量一個服務性能的高低好壞,每秒事務處理數TPS是最重要的名額之一,它代表着一秒内服務端平均能響應的請求總數,而TPS值與程式的并發能力又有非常密切的關系.
基于高速緩存的存儲互動很好地解決了處理器與記憶體的速度沖突,但是也引入了一個新的問題:緩存一緻性.
除了增加高速緩存之外,為了使得處理器内部的運算單元能盡量被充分利用,處理器可能會對輸入代碼進行亂序執行優化,是以如果存在一個計算任務依賴另一個計算任務的中間結果,那麼其順序性并不能靠代碼的先後順序來保證.與處理器的亂序執行優化類似,java虛拟機的即時編譯器中也有類似的指令重排序優化.
Java記憶體模型的主要目标是定義程式中各個變量的通路規則,此處的變量與java變成中所說的變量有所差別,它包括了實力字段,靜态字段和構成數組對象的元素,但不包括局部變量與方法參數,因為後者是線程私有的.不會被共享,自然就不會存在競争問題.
Java記憶體模型規定了所有的變量都存儲在主記憶體中,每條線程還有自己的工作記憶體,線程的工作記憶體中儲存了被該線程使用到的變量的主記憶體副本拷貝,線程對變量的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變量.不同的線程之間也無法直接通路對方工作記憶體中的變量,線程間變量值的傳遞均需要通過主記憶體來完成.
如果局部變量是一個reference類型,它引用的對象在java堆中可被各個線程共享,但是reference本身在java棧的局部變量表中,它是線程私有的.
Java記憶體模型中定義了以下8種操作來完成主記憶體與工作記憶體之間具體的互動協定:
- Lock(鎖定):作用于主記憶體的變量,它把一個變量辨別為一條線程獨占的狀态.
- unlock(解鎖):作用于主記憶體的變量,它把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定.
- read(讀):作用于主記憶體的變量,它把一個變量的值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用.
- load(載入):作用于工作記憶體的變量,它把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中.
- use(使用):作用于工作記憶體的變量,它把工作記憶體中的一個變量的值傳遞給執行引擎,每當虛拟機遇到一個需要使用到變量的值的位元組碼指令時将會執行這個操作.
- assign(指派):作用于工作記憶體的變量,它把一個從執行引擎接收到的值賦給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼指令時執行這個操作.
- store(存儲):作用于工作記憶體的變量,它把工作記憶體中一個變量的值傳送到主記憶體中,以便随後的write操作使用.
- write(寫入):作用于主記憶體的變量,它把store操作從工作記憶體中得到的變量的值放入主記憶體的變量中.
先行發生原則用來确定一個通路在并發環境下是否安全.
關鍵字volatile是java虛拟機提供的最輕量級的同步機制.java記憶體模型對volatile專門定義了一些特殊的通路規則:第一是保證此變量對所有線程的可見性,這裡的可見性是指當一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的.第二是禁止指令重排序優化.
由于volatile變量隻能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖來保證原子性:
- 運算結果并不依賴變量的目前值,或者能夠確定隻有單一的線程修改變量的值.
- 變量不需要與其他狀态變量共同參與不變限制.
DCL雙鎖檢測
我們在volatile與鎖之中選擇的唯一依據僅僅是volatile的語義是否能滿足場景的需求.
原子性:由java記憶體模型來直接保證的原子性變量操作包括read,load,assign,use,store和write.我們大緻可以認為基本資料類型的通路讀寫是具備原子性的.synchronzed塊之間的操作也具備原子性.
可見性:可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改.除了volatile之外,java還有兩個關鍵字能實作可見性,即synchronized和final.final關鍵字的可見性是指:被final修飾的字段在構造器中一旦初始化完成,并且構造器沒有把”this”的引用傳遞出去,那在其他線程中就能看見final字段的值.
有序性:如果在本線程内觀察,所有操作都是有序的,如果在一個線程中觀察另一個線程,所有的操作都是無序的.
先行發生原則是判斷資料是否存在競争,線程是否安全的主要依據.先行發生是java記憶體模型中定義的兩項操作之間的偏序關系,如果說操作A先行發生于操作B,其實就是說在發生操作B之前,操作A産生的影響能被操作B觀察到,”影響”包括了修改記憶體中共享變量的值,發送了消息,調用了方法等.
Java記憶體模型下一些”天然的”先行發生關系:
程式次序規則:在一個線程内,按照程式代碼順序,書寫在前面的操作先行發生于書寫在後面的操作.
管程鎖定規則:一個unlock操作先行發生于後面(時間上的)對同一個鎖的lock操作.
volatile變量規則:對一個volatile變量的寫操作先行發生于後面對這個變量的讀操作.
線程啟動規則:Thread對象的start()方法先行發生于此線程的每一個動作.
線程終止規則:線程中的所有操作都先行發生于對此線程的終止檢測.
線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生.
對象終結規則:一個對象的初始化完成先行發生于它的finalize()方法的開始.
傳遞性:如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論.
實作線程主要有3種方式:使用核心線程實作,使用使用者線程實作和使用使用者線程加輕量級程序混合實作.
核心線程就是直接由作業系統核心支援的線程,這種線程由核心來完成線程切換,核心通過操縱排程器對線程進行排程,并負責将線程的任務映射到各個處理器上.每個核心線程可以視為核心的一個分身.這樣作業系統就有能力同時處理多件事情,支援多線程的核心就叫做多線程核心.
程式一般不會直接去使用核心線程,而是使用核心線程的一種進階接口-----輕量級程序,輕量級程序就是我們通常意義上所講的線程.由于每個輕量級程序都由一個核心線程支援,是以各種線程操作,如建立,析構及同步,都需要進行系統調用,需要在使用者态和核心态中來回切換,每個輕量級程序都需要有一個核心線程的支援,是以輕量級程序要消耗一定的核心資源(如核心線程的棧空間)是以一個系統支援輕量級程序的數量是有限的.
從廣義上件,一個線程隻要不是核心線程,就可以認為是使用者線程,從這個定義上來講,輕量級程序也屬于使用者線程,但輕量級程序的實作始終是建立在核心之上的,許多操作都要進行系統調用,效率會受到限制.
狹義上的使用者線程指的是完全建立在使用者空間的線程庫上,系統核心不能感覺線程存在的實作.使用者線程的建立,同步,銷毀和排程完全在使用者态中完成,不需要核心的幫助.操作可以是非常快速且低消耗的.也可以支援規模更大的線程數量,部分高性能資料庫中的多線程就是由使用者線程實作的.使用使用者線程實作的程式一般都比較複雜.java放棄使用它.
還有一種将核心線程與使用者線程一起使用的實作方式.這種實作下,既存在使用者線程,也存在輕量級程序.使用者線程還是完全建立在使用者空間中,是以使用者線程的建立,切換,析構等操作依然廉價,并且可以支援大規模的使用者線程并發.
Java語言定義了5種線程狀态,在任意時間點,一個線程隻能有且隻有其中的一種狀态.
建立:建立後尚未啟動的線程處于這種狀态.
運作:包括了作業系統線程狀态中的Running和Ready,也就是處于此狀态的線程有可能正在執行,也有可能正在等待着CPU為它配置設定執行時間.
無限期等待:處于這種狀态的線程不會被配置設定CPU執行時間,它們要等待被其他線程顯式地喚醒.以下方法會讓線程陷入無限期的等待狀态:
- 沒有設定Timeout參數的Object.wait()方法.
- 沒有設定Timeout參數的Thread.join()方法.
- LockSupport.park()方法.
限期等待:處于這種狀态的線程也不會被配置設定CPU執行時間,不過無須等待被其他線程顯式地喚醒.在一定時間之後它們會由系統自動喚醒,以下方法會讓線程進入限期等待狀态:
Thread.sleep()方法.
設定了Timeout參數的Object.wait()方法.
設定了Timeout參數的Thread.join()方法.
LockSupport.parkNanos()方法.
LockSupport.parkUntil()方法.
阻塞:線程被阻塞了,”阻塞狀态”與”等待狀态”的差別是:”阻塞狀态”在等待着擷取到一個排他鎖,這個事件将在另一個線程放棄這個鎖的時候發生;而”等待狀态”則是在等待一段時間,或者喚醒動作的發生.在程式等待進入同步區域的時候,線程将進入這種狀态.
結束:已終止線程的線程狀态,線程已經結束執行.
線程安全與鎖優化
當多個線程通路一個對象時,如果不用考慮這些線程在運作時環境下的排程和交替執行,也不需要進行額外的同步,或者在調用方法進行任何其他的協調操作,調用這個對象的行為都可以獲得正确的結果,那這個對象是線程安全的.
不可變的對象一定是線程安全的.隻要一個不可變的對象被正确的建構出來(沒有發生this引用逃逸的情況)那其外部的可見狀态用于也不會改變.”不可變”帶來的安全性是最簡單和最純粹的.如果共享資料是一個基本資料類型,那麼隻要在定義時使用final關鍵字修飾它就可以保證它是不可變的.如果共享資料是一個對象,那就需要保證對象的行為不會對其狀态産生任何影響才行.
在javaAPI中标注自己是線程安全的類,大多數都不是絕對的線程安全.如果不在方法調用端做額外的同步措施的話,代碼仍然是不安全的.
相對的線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單獨的操作是線程安全的.我們在調用的時候不需要做額外的保障措施,但是對于一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正确性.
線程相容是指對象本身并不是線程安全的,但是可以通過在調用端正确地使用同步手段來保證對象在并發環境中可以安全地使用.
線程對立是指無論調用端是否采取了同步措施,都無法在多線程環境中并發使用的代碼.
互斥同步:同步是指在多個線程并發通路共享資料時,保證共享資料在同一時刻隻被一個線程使用.而互斥是實作同步的一種手段,臨界區,互斥量和信号量都是主要的互斥實作方式.
synchronized同步塊對同一條線程來說是可重入的,不會出現自己把自己鎖死的問題.同步塊在已進入的線程執行完之前,會阻塞後面其他線程的進入.要阻塞或喚醒一個線程,都需要從使用者态轉換到核心态中.
還可以使用重入鎖來實作同步,它還增加了一些進階功能:等待可中斷,可實作公平鎖,以及鎖可以綁定多個條件.
等待可中斷是指目前持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他事情.可中斷特性對處理執行時間非常長的同步塊很有幫助.
公平鎖是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;synchronized中的鎖是非公平的,重入鎖預設情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖.
鎖綁定多個條件是指一個重入鎖對象可以同時綁定多個Condition對象,而在synchronized中,鎖對象的wait()和notify()或notifyAll()方法可以實作一個隐含的條件,如果要和多于一個的條件關聯的時候,就不得不額外地添加一個鎖,而重入鎖則無須這樣做,隻需要多次調用newCondition()方法即可.
互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,是以這種同步也稱為阻塞同步.從處理問題的方式上說,互斥同步屬于一種悲觀的并發政策,總是認為隻要不去做正确的同步措施,那就肯定會出現問題,無論共享的資料是否真的會出現競争,它都要進行加鎖,使用者态核心态轉換,維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作.
随着硬體指令集的發展,有了基于沖突檢測的樂觀并發政策:就是先操作,如果沒有其他線程争用共享資料,那操作就成功了,如果共享資料有争用,産生了沖突,那就在采取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止),這種樂觀的并發政策的許多實作都不需要把線程挂起,是以這種同步操作稱為非阻塞同步.
要保證線程安全,并不是一定就要進行同步,兩者沒有因果關系.同步隻是保證共享資料争用時的正确性的手段,如果一個方法本來就不涉及共享資料,那它自然就無須任何同步措施去保證正确性,是以會有一些代碼天生就是線程安全的.主要有兩類:
可重入代碼:這種代碼也叫做純代碼,可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權傳回後,原來的程式不會出現任何錯誤.所有的可重入代碼都是線程安全的.
線程本地存儲:如果一段代碼中所需要的資料必須與其他代碼共享,那就看看這些共享資料的代碼是否能保證在同一線程中執行?如果能保證,我們就可以把共享資料的可見範圍限制在同一個線程之内,這樣,無須同步也能保證線程之間不出現資料争用的問題.
Java語言中,如果一個變量要被多線程通路,可以使用volatile關鍵字聲明它為”易變的”;如果一個變量要被某個線程獨享.可以通過java.lang.ThreadLocal類來實作線程本地存儲的功能.
鎖優化技術有:适應性自旋,鎖消除,鎖粗化,輕量級鎖和偏向鎖.這些技術都是為了線上程之間更高效地共享資料,以及解決競争問題.進而提高程式的執行效率.
在許多應用上,共享資料的鎖定狀态隻會持續很短的一段時間,為了這段時間去挂起和恢複線程并不值得.如果實體機器有一個以上的處理器.能讓兩個或以上的線程同時并行執行.我們就可以讓後面請求鎖的那個線程”稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖.為了讓線程等待,我們隻需讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖.
自旋等待本身雖然避免了線程切換的開銷,但它是要占用處理器時間的,如果鎖被占用的時間很短,那麼自旋的線程隻會白白消耗處理器資源.會帶來性能上的浪費.是以,自旋等待的時間必須要有一定的限度.如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統等待方式去挂起線程了.自旋次數的預設值是10次.
在JDK1.6中引入了自适應的自旋鎖.自适應意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀态來決定.
鎖消除是指虛拟機即時編譯器在運作時,對一些代碼上要求同步,但是被檢測到不可能存在共享資料競争的鎖進行消除.鎖消除的主要判定依據來源于逃逸分析的資料支援.
由于String是一個不可變的類,對字元串的連接配接操作總是通過生成新的String對象來進行的,是以javac編譯器會對String連接配接做自動優化.在JDK1.5之後,會轉化為StringBuilder對象的連續append()操作.
原則上,我們在編寫代碼的時候,總是推薦将同步塊的作用範圍限制得盡量小---隻在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的操作數量盡可能的變小,如果存在鎖競争,那等待鎖的線程也能盡快拿到鎖.
如果一系列的連續操作都對同一個對象反複加鎖和解鎖,甚至加鎖操作是出現在循環體重的,那即時沒有線程競争,頻繁地進行互斥同步操作也會導緻不必要的性能損耗.如果虛拟機探測到有這樣一串零碎的操作都對同一個對象加鎖,将會把加鎖同步的範圍擴充(粗化)到整個操作序列的外部.這就是鎖粗化
輕量級鎖:是JDK1.6之中加入的新型鎖機制,它名字中的”輕量級”是相對于使用作業系統互斥量來實作的傳統鎖而言的,是以傳統的鎖機制就稱為”重量級”鎖.輕量級鎖并不是用來代替重量級鎖的,它的本意是在沒有多線程競争的前提下,減少傳統的重量級鎖使用作業系統互斥量産生的性能消耗.
HotSpot虛拟機的對象頭分為兩部分資訊,第一部分用于存儲對象自身的運作資料,它是實作輕量級鎖和偏向鎖的關鍵.另外一部分用于存儲指向方法區對象類型資料的指針,如果是數組對象的話,還會有一個額外的部分用于存儲數組長度........
偏向鎖:偏向鎖是JDK1.6引入的一項鎖優化,它的目的是消除在無競争情況下的同步原語,進一步提高程式的運作性能.這個鎖會偏向于第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程擷取,則持有偏向鎖的線程将永遠不需要再進行同步.