【備戰秋招】高質量 Java知識點整理2:集合、JVM、併發

P18:List

List 是一種線性列表結構,元素是有序、可重複的。

**ArrayList **

底層由數組實現,隨機訪問效率高,讀快寫慢,由於寫操作涉及元素的移動,因此寫操作效率低。

ArrayList 實現了 RandomAcess 標記接口,如果一個類實現了該接口,那麼表示這個類使用索引遍歷比迭代器更快。

三個重要的成員變量:

transient Object[] elementData;

elementData 是 ArrayList 的數據域,transient 表示它不會被序列化,不使用 elementData 直接序列化是因爲這是一個緩存數組,出於性能考慮通常會預留一些容量,當容量不足時會擴充容量,因此可能會有大量空間沒有存儲元素,採用這樣的方式可以保證只序列化實際有值的那些元素而不需要序列化整個數組。

private int size;

size 表示當前 List 的實際大小,elementData 的大小是大於等於 size 的。

protected transient int modCount = 0;

該成員變量繼承自 AbstractList,記錄了ArrayList 結構性變化的次數。所有涉及結構變化的方法都會增加該值,包括add()、remove()、addAll()、removeRange() 及clear() 等。

在使用迭代器遍歷 ArrayList 時不能修改元素,modCount 統計 ArrayList 修改次數,expectedModCount 則是在迭代器初始化時記錄的modCount 值,每次訪問新元素時都會檢查 modCount 和 expectedModCount是否相等,如果不相等就會拋出異常。

LinkedList

底層由鏈表實現,與 ArrayList 相反,需要順序訪問元素,即使有索引也需要從頭遍歷,因此寫快讀慢。

LinkedList 實現了 Deque 接口,具有隊列的屬性,可在尾部增加元素,在頭部獲取元素,也能操作頭尾之間任意元素。

所有成員變量都被 transient 修飾,序列化原理和ArrayList類似。

Vector 和 Stack

Vector 的實現和 ArrayList 基本一致,底層使用的也是數組,它和 ArrayList 的區別主要在於:(1)Vector 的所有公有方法都使用了 synchronized 修飾保證線程安全性。(2)增長策略不同,Vector 多了一個成員變量 capacityIncrement 用於標明擴容的增量。

Stack 是 Vector 的子類,實現和 Vector基本一致,與之相比多提供了一些方法表達棧的含義。


P19:HashSet

HashSet 中的元素是無序、不重複的,最多隻能有一個 null 值。

HashSet 的底層是通過 HashMap 實現的,HashMap 的 key 值即 HashSet 存儲的元素,所有 key 都使用相同的 value ,一個static final 修飾的變量名爲 PRESENT 的 Object 類型的對象。

由於 HashSet 的底層是 HashMap 實現的,HashMap 是線程不安全的,因此 HashSet 也是線程不安全的。

去重:
對於基本類型的包裝類,直接按值進行比較。對於引用數據類型,會先比較 hashCode() 返回值是否相同,如果不同則代表不是同一個對象,如果相同則繼續比較equals()方法返回值是否相同,都相同說明是同一個對象。


P20:HashMap

JDK 8 之前

底層實現是數組 + 鏈表,主要成員變量包括:存儲數據的 table 數組、鍵值對數量 size、加載因子 loadFactor。

table 數組用於記錄 HashMap 的所有數據,它的每一個下標都對應一條鏈表,所有哈希衝突的數據都會被存放到同一條鏈表中,Entry 是鏈表的節點元素,包含四個成員變量:鍵 key、值 value、指向下一個節點的指針 next 和 元素的散列值 hash。

在 HashMap 中數據都是以鍵值對的形式存在的,鍵對應的 hash 值將會作爲其在數組裏的下標,如果兩個元素 key 的 hash 值一樣,就會發送哈希衝突,被放到同一個下標中的鏈表上,爲了使 HashMap 的查詢效率儘可能高,應該使鍵的 hash 值儘可能分散。

HashMap 默認初始化容量爲 16,擴容容量必須是 2 的冪次方、最大容量爲 1<< 30 、默認加載因子爲 0.75。

1.put 方法:添加元素

① 如果 key 爲 null 值,直接存入 table[0]。② 如果 key 不爲 null 值,先計算 key 對應的散列值。③ 調用 indexFor 方法根據 key 的散列值和數組的長度計算元素存放的下標 i。④ 遍歷 table[i] 對應的鏈表,如果 key 已經存在,就更新其 value 值然後返回舊的 value 值。⑤ 如果 key 不存在,就將 modCount 的值加 1,使用 addEntry 方法增加一個節點,並返回 null 值。

2.hash 方法:計算元素 key 對應的散列值

① 處理 String 類型的數據時,直接調用對應方法來獲取最終的hash值。② 處理其他類型數據時,提供一個相對於 HashMap 實例唯一不變的隨機值 hashSeed 作爲計算的初始量。③ 執行異或和無符號右移操作使 hash 值更加離散,減小哈希衝突的概率。

3.indexFor 方法:計算元素下標

直接將 hash 值和數組長度 - 1 進行與操作並返回,保證計算後的結果不會超過 table 數組的長度範圍。

4.resize 方法:根據newCapacity 來確定新的擴容閾值 threshold

① 如果當前容量已經達到了最大容量,就將閾值設置爲 Integer 的最大值,之後擴容就不會再觸發。② 創建一個新的容量爲 newCapacity 的 Entry 數組,並調用 transfer 方法將舊數組的元素轉移到新數組.③ 將閾值設爲(newCapacity 和加載因子 loadFactor 的積)和(最大容量 + 1 )的較小值。

5.transfer:轉移舊數組到新數組

① 遍歷舊數組的所有元素,調用 rehash 方法判斷是否需要哈希重構,如果需要就重新計算元素 key 的散列值。② 調用 indexFor 方法根據 key 的散列值和數組的長度計算元素存放的下標 i,利用頭插法將舊數組的元素轉移到新的數組。

6.get 方法:根據 key 獲取元素的 value 值

① 如果 key 爲 null 值,調用 getForNullKey 方法,如果 size 爲 0 表示鏈表爲空,返回 null 值。如果 size 不爲 0,說明存在鏈表,遍歷 table[0] 的鏈表,如果找到了 key 爲 null 的節點則返回其 value 值,否則返回 null 值。② 調用 getEntry 方法,如果 size 爲 0 表示鏈表爲空,返回 null 值。如果 size 不爲 0,首先計算 key 的散列值,然後遍歷該鏈表的所有節點,如果節點的 key 值和 hash 值都和要查找的元素相同則返回其 Entry 節點。③ 如果找到了對應的 Entry 節點,使用 getValue 方法獲取其 value 值並返回,否則返回 null 值。


**JDK 8 開始 **

使用的是數組 + 鏈表/紅黑樹的形式,table 數組的元素數據類型換成了 Entry 的靜態實現類 Node。

1.put 方法:添加元素

① 調用 putVal 方法添加元素。② 如果 table 爲空或沒有元素時就進行擴容,否則計算元素下標位置,如果不存在就新創建一個節點存入。③ 如果首節點和待插入元素的 hash值和 key 值都一樣,直接更新 value 值。④ 如果首節點是 TreeNode 類型,調用 putTreeVal 方法增加一個樹節點,每一次都比較插入節點和當前節點的大小,待插入節點小就往左子樹查找,否則往右子樹查找,找到空位後執行兩個方法:balanceInsert 方法,把節點插入紅黑樹並對紅黑樹進行調整使之平衡。moveRootToFront 方法,由於調整平衡後根節點可能變化,table 裏記錄的節點不再是根節點,需要重置根節點。⑤ 如果是鏈表節點,就遍歷鏈表,根據 hash 值和 key 值判斷是否重複,決定更新值還是新增節點。如果遍歷到了鏈表末尾,添加鏈表元素,如果達到了建樹閾值,還需要調用 treeifyBin 方法把鏈表重構爲紅黑樹。⑥ 存放元素後,將 modCount 值加 1,如果節點數 + 1大於擴容閾值,還需要進行擴容。

2.get 方法:根據 key 獲取元素的 value 值

① 調用 getNode 方法獲取 Node 節點,如果不是 null 值就返回 Node 節點的 value 值,否則返回 null。② 如果數組不爲空,先比較第一個節點和要查找元素的 hash 值和 key 值,如果都相同則直接返回。③ 如果第二個節點是 TreeNode 節點則調用 getTreeNode 方法進行查找,否則遍歷鏈表根據 hash 值和 key 值進行查找,如果沒有找到就返回 null。

3.hash 方法:計算元素 key 對應的散列值

Java 8 的計算過程簡單了許多,如果 key 非空就將 key 的 hashCode() 返回值的高低16位進行異或操作,這主要是爲了讓儘可能多的位參與運算,讓結果中的 0 和 1 分佈得更加均勻,從而降低哈希衝突的概率。

4.resize 方法:擴容數組

重新規劃長度和閾值,如果長度發生了變化,部分數據節點也要重新排列。

重新規劃長度

① 如果 size 超出擴容閾值,把 table 容量增加爲之前的2倍。② 如果新的 table 容量小於默認的初始化容量16,那麼將 table 容量重置爲16。③ 如果新的 table 容量大於等於最大容量,那麼將閾值設爲 Integer 的最大值,並且 return 終止擴容,由於 size 不可能超過該值因此之後不會再發生擴容。

重新排列數據節點

① 如果節點爲 null 值則不進行處理。② 如果節點不爲 null 值且沒有next節點,那麼重新計算其散列值然後存入新的 table 數組中。③ 如果節點爲 TreeNode 節點,那麼調用 split 方法進行處理,該方法用於對紅黑樹調整,如果太小會退化回鏈表。④ 如果節點是鏈表節點,需要將鏈表拆分爲 hashCode() 返回值超出舊容量的鏈表和未超出容量的鏈表。對於hash & oldCap == 0 的部分不需要做處理,反之需要放到新的下標位置上,新下標 = 舊下標 + 舊容量。

**線程不安全:**Java 7 擴容時 resize 方法調用的 transfer 方法中使用頭插法遷移元素,多線程會導致 Entry 鏈表形成環形數據結構,Entry 節點的 next 永遠不爲空,引起死循環。Java 8 在 resize 方法中完成擴容,並且改用了尾插法,不會產生死循環的問題,但是在多線程的情況下還是可能會導致數據覆蓋的問題,因此依舊線程不安全。


**紅黑樹:**紅黑樹是一種自平衡的二叉查找樹。

**特性:**紅黑樹的每個節點只能是紅色或者黑色、根節點是黑色的、每個葉子節點都是黑色的、如果一個葉子節點是紅色的,它的子結點必須是黑色的、從一個節點到該節點的葉子節點的所有路徑都包含相同數目的黑色節點。

**左旋:**對節點進行左旋,相當於把節點的右節點作爲其父節點,即將節點變成一個左節點。

**右旋:**對節點進行右旋,相當於把節點的左節點作爲其父節點,即將節點變成一個右節點。

**插入:**① 被插入的節點是根節點,直接將其塗爲黑色。② 被插入節點的父節點是黑色的,不做處理,節點插入後仍是紅黑樹。③ 被插入節點的父節點是紅色的,一定存在非空祖父節點,根據叔叔節點的顏色分類處理。

**刪除:**① 被刪除的節點沒有子節點,直接將其刪除。② 被刪除節點只有一個子節點,直接刪除該節點,並用其唯一子節點替換其位置。③ 被插入節點有兩個子節點,先找出該節點的替換節點,然後把替換節點的數值複製給該節點,刪除替換節點。

**調整平衡:**在插入和刪除節點後,通過左旋、右旋或變色使其重新成爲紅黑樹。① 如果當前節點的子節點是一紅一黑,直接將該節點設爲黑色。② 如果當前節點的子結點都是黑色,且當前節點是根節點,則不做處理。③ 如果當前節點的子節點都是黑色且當前節點不是根節點,根據兄弟節點的顏色分類處理。


JVM 15

P1:運行時數據區

程序計數器

程序計數器是一塊較小的內存空間,可以看作當前線程所執行字節碼的行號指示器。字節碼解釋器工作時通過改變這個計數器的值來選取下一條需要執行的字節碼指令,它是程序控制流的指示器,分支、循環、跳轉、線程恢復等功能都需要依賴計數器完成。程序計數器是線程私有的,各條線程之間互不影響,獨立存儲。

如果線程正在執行的是一個 Java 方法,計數器記錄的是正在執行的虛擬機字節碼指令的地址。如果正在執行的是本地(Native)方法,計數器值則應爲空(Undefined)。

此內存區域是唯一一個在《 Java 虛擬機規範》中沒有規定任何內存溢出情況的區域。

Java 虛擬機棧

Java 虛擬機棧是線程私有的,每當有新的線程創建時就會給它分配一個棧空間,當線程結束後棧空間就被回收,因此棧與線程擁有相同的生命週期。棧主要用來實現方法的調用與執行,每個方法在執行的時候都會創建一個棧幀用來存儲這個方法的局部變量、操作棧、動態鏈接和方法出口等信息。當一個方法被調用時,會壓入一個新的棧幀到這個線程的棧中,當方法調用結束後會彈出這個棧幀,回收掉調用這個方法使用的棧空間。

該區域有兩類異常情況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常。如果 JVM 棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存會拋出 OutOfMemoryError 異常(HotSpot 不可以動態擴展,不存在此問題)。

本地方法棧

本地方法棧與虛擬機棧的作用相似,不同的是虛擬機棧爲虛擬機執行 Java 方法(字節碼)服務,而本地方法棧是爲虛擬機棧用到的本地(Native)方法服務。調用本地方法時虛擬機棧保持不變,動態鏈接並直接調用指定的本地方法。

《 Java 虛擬機規範》對本地方法棧中方法所用語言、使用方式與數據結構無強制規定,具體的虛擬機可根據需要自由實現,例如 HotSpot 直接將虛擬機棧和本地方法棧合二爲一。

與虛擬機棧一樣,本地方法棧也會在棧深度異常和棧擴展失敗時分別拋出 StackOverflowError 和 OutOfMemoryError 異常。

Java 堆

Java 堆是虛擬機所管理的內存中最大的一塊。堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此區域的唯一目的就是存放對象實例,Java 裏幾乎所有的對象實例都在這裏分配內存。

Java 堆可以處於物理上不連續的內存空間中,但在邏輯上它應該被視爲連續的。但對於大對象(例如數組),多數虛擬機實現出於簡單、存儲高效的考慮會要求連續的內存空間。

Java 堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的 JVM 都是按照可擴展來實現的。如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,虛擬機將拋出 OutOfMemoryError 異常。

方法區

方法區和 Java 堆一樣是各個線程共享的內存區域,它用於存儲被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。

JDK 8 之前使用永久代來實現方法區,這種設計導致了 Java 應用容易遇到內存溢出問題,因爲永久代有-XX:MaxPermSize的上限,即使不設置也有默認大小。JDK 6 時 HotSpot 的開發團隊就準備放棄永久代,改用本地內存來實現方法區,JDK 7 時已經把原本放在永久代的字符串常量池、靜態變量等移出,到了 JDK8 時永久代被完全廢棄,改用在本地內存中實現的元空間來代替,把 JDK 7 中永久代剩餘內容(主要是類型信息)全部移到元空間。

《 Java 虛擬機規範》對方法區的約束很寬鬆,除了和 Java 堆一樣不需要連續的內存和可以選擇固定大小或可擴展外,還可以選擇不實現垃圾回收。垃圾回收行爲在該區域出現較少,主要回收目標是針對常量池的回收和對類型的卸載,一般來說該區域的回收效果比較難令人滿意,尤其是類型的卸載,條件十分苛刻。如果方法區無法滿足新的內存分配需求時,將拋出 OutOfMemoryError 異常。

運行時常量池

運行時常量池是方法區的一部分,Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表,用於存放編譯器生成的各種字面量與符號引用,這部分內容將在類加載後存放到方法區的運行時常量池中。一般來說,除了保存 Class 文件中描述的符號引用外,還會把符號引用翻譯出來的直接引用也存儲在運行時常量池中。

運行時常量池相對於 Class 文件常量池的另一個重要特徵是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是說並非預置入 Class 文件中常量池的內容才能進入方法區運行時常量池,運行期間也可以將新的常量放入池中,這種特性被利用的較多的是String 類的 intern() 方法。

由於運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。

直接內存

直接內存不是 JVM 運行時數據區的一部分,也不是《 Java 虛擬機規範》中定義的內存區域,但是這部分內存也被頻繁使用,而且也可能導致內存溢出異常。

JDK 1.4 中新加入了 NIO 模型,引入了一種基於通道與緩衝區的 IO 方式,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆裏的 DirectByteBuffer 對象作爲這塊內存的引用進行操作,這樣能在一些場景中顯著提高性能,避免了在 Java 堆和 Native堆中來回複製數據。

本機直接內存的分配不會收到 Java 堆大小的限制,但還是會受到本機總內存大小以及處理器尋址空間的限制,一般配置虛擬機參數時會根據實際內存去設置 -Xmx 等參數信息,但經常忽略掉直接內存,使得各個內存區域總和大於物理內存限制,從而導致動態擴展時出現OutOfMemoryError 異常。


P2:對象創建的過程

當 JVM 遇到一條字節碼 new 指令時,首先將檢查該指令的參數能否在常量池中定位到一個類的符號引用,並檢查這個引用代表的類是否已被加載、解析和初始化,如果沒有就必須先執行類加載過程。

在類加載檢查通過後虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,分配空間的任務實際上等於把一塊確定大小的內存塊從 Java 堆中劃分出來。假設 Java 堆內存是絕對規整的,所有被使用過的內存都被放在一邊,空閒的內存被放在另一邊,中間放着一個指針作爲分界點指示器,分配內存就是把該指針向空閒方向挪動一段與對象大小相等的距離,這種方式叫"指針碰撞"。

如果 Java 堆中的內存不是規整的,那麼虛擬機就必須維護一個列表記錄哪些內存塊是可用的,在分配時從列表中找到一塊足夠大的空間劃分給對象實例並更新列表上的記錄,這種方式叫做"空閒列表"。

選擇哪種分配方式由堆是否規整決定,堆是否規整又由所用垃圾回收器是否帶有空間壓縮整理能力決定。因此使用 Serial、ParNew 等帶壓縮整理的收集器時,系統採用指針碰撞;當使用 CMS 這種基於清除算法的垃圾收集器時,理論上只能採用空間列表分配內存。

**分配內存的線程安全問題:**對象創建在虛擬機中十分頻繁,即使修改一個指針所指向的位置在併發情況下也不是線程安全的,可能出現正給對象 A 分配內存,指針還沒來得及修改,對象 B 又同時使用了原來的指針來分配內存的情況。解決該問題有兩個方法:① 虛擬機採用 CAS 加失敗重試的方式保證更新操作的原子性。② 把內存分配的動作按照線程劃分在不同空間進行,即每個線程在 Java 堆中預先分配一小塊內存,叫做本地線程分配緩衝 TLAB,哪個線程要分配內存就在對應的 TLAB 分配,只有 TLAB 用完了分配新緩衝區時才需要同步。

內存分配完成後虛擬機必須將分配到的內存空間(不包括對象頭)都初始化爲零值,保證對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,使程序能訪問到這些字段的數據類型對應的零值。之後虛擬機還要對對象進行必要設置,例如對象是哪個類的實例、如何找到類的元數據信息等。

至此從虛擬機的視角來看一個新的對象已經產生了,但從程序的角度來說對象創建纔剛開始。此時構造方法,即 Class 文件中的 init 方法還沒有執行,所有字段都爲默認零值,對象需要的其他資源和狀態信息也還沒有按照預定的意圖構造好。一般來說 new 指令後會接着執行 init 方法,按照程序員的意願進行初始化,這樣一個真正可用的對象纔算完全被構造出來。


P3:對象的內存佈局

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

對象頭

對象頭包括兩類信息,第一類是用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID等,這部分數據叫做"Mark Word"。

對象頭的另一部分是類型指針,即對象指向它的類型元數據的指針,JVM 通過該指針來確定對象是哪個類的實例。並非所有虛擬機實現都必須在對象數據上保留類型指針,查找對象的元數據不一定要經過對象本身。此外如果對象是一個 Java 數組,在對象頭還必須有一塊用於記錄數組長度的數據。

實例數據

實例數據部分是對象真正存儲的有效信息,即程序員在代碼裏所定義的各種類型的字段內容。存儲順序會受到虛擬機分配策略參數和字段在源碼中定義順序的影響。相同寬度的字段總是被分配到一起存放,在滿足該前提條件的情況下父類中定義的變量會出現在子類之前。

對齊填充

這部分不是必然存在的,僅僅起佔位符的作用。由於 HotSpot 虛擬機的自動內存管理系統要求對象的起始地址必須是8字節的整數倍,而對象頭已經被設爲正好是 8 字節的整數倍,因此如果對象實例數據部分沒有對齊,就需要對齊填充來補全。


P4:對象的訪問定位

Java 程序會通過棧上的 reference 數據來操作堆上的具體對象,而具體對象訪問方式是由虛擬機決定的,主流的訪問方式主要有使用句柄和直接指針兩種。

使用句柄

如果使用句柄訪問,Java 堆中將可能會劃分出一塊內存作爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自具體的地址信息。

優點是 reference 中存儲的是穩定句柄地址,在對象被移動(處於垃圾收集過程中)時只會改變句柄中的實例數據指針,而 reference 本身不需要被修改。

直接指針

如果使用直接指針訪問的話,Java 堆中對象的內存佈局就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址,如果只是訪問對象本身的話就不需要多一次間接訪問的開銷。

優點就是速度更快,節省了一次指針定位的時間開銷,HotSpot 主要使用的就是直接指針來進行對象訪問。


P5:內存溢出異常

Java 堆溢出

Java 堆用於存儲對象實例,我們只要不斷創建對象,並且保證GC Roots到對象有可達路徑來避免垃圾回收機制清除這些對象,那麼隨着對象數量的增加,總容量觸及最大堆容量的限制後就會產生OOM異常。例如在 while 死循環中一直 new 創建實例。

Java 堆內存的 OOM 是實際應用中最常見的 OOM 情況,常規的處理方法是先通過內存映像分析工具對 Dump 出來的堆轉儲快照進行分析,確認內存中導致 OOM 的對象是否是必要的,即分清楚到底是出現了內存泄漏還是內存溢出。

如果是內存泄漏,可進一步通過工具查看泄漏對象到 GC Roots 的引用鏈,找到泄露對象是通過怎樣的引用路徑、與哪些GC Roots相關聯才導致垃圾收集器無法回收它們,一般可以準確定位到對象創建的位置進而找出產生內存泄漏代碼的具體位置。

如果不是內存泄漏,即內存中的對象確實都是必須存活的那就應當檢查 JVM 的堆參數設置,與機器的內存相比是否還有向上調整的空間。再從代碼上檢查是否存在某些對象生命週期過長、持有狀態時間過長、存儲結構設計不合理等情況,儘量減少程序運行期的內存消耗。

虛擬機棧和本地方法棧溢出

由於HotSpot虛擬機不區分虛擬機和本地方法棧,因此設置本地方法棧大小的參數沒有意義,棧容量只能由 -Xss 參數來設定,存在兩種異常:

  • **StackOverflowError:**如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常。例如一個遞歸方法不斷調用自己。

    該異常有明確錯誤堆棧可供分析,容易定位到問題所在。

  • **OutOfMemoryError:**如果 JVM 棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存會拋出OutOfMemoryError異常。HotSpot 虛擬機不支持虛擬機棧的擴展,所以除非在創建線程申請內存時就因無法獲得足夠內存而出現OOM異常,否則在線程運行時是不會因爲擴展而導致內存溢出的,只會因爲棧容量無法容納新的棧幀而導致StackOverflowError異常。

運行時常量池溢出

String類的intern方法是一個本地方法,它的作用是如果字符串常量池中已經包含一個等於此 String 對象的字符串,則返回代表池中這個字符串的 String 對象的引用,否則會將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。

在 JDK6及之前常量池都分配在永久代,因此可以通過 -XX:PermSize-XX:MaxPermSize 限制永久代的大小,間接限制常量池的容量。在 while 死循環中不斷調用intern方法,之後將導致運行時常量池溢出。

在 JDK7 及之後版本不會導致該問題,因爲存放在永久代的字符串常量池已經被移至 Java 堆中。

方法區溢出

方法區的主要職責是用於存放類型的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。只要不斷在運行時產生大量的類去填滿方法區,就會導致溢出。例如使用 JDK 的反射或 CGLib 直接操作字節碼在運行時生成大量的類會導致溢出。當前的很多主流框架如Spring、Hibernate等對類增強是都會使用CGLib這類字節碼技術,增強的類越多,就需要越大的方法區保證動態生成的新類型可以載入內存,也就更容易導致方法區溢出。

JDK 8 之後永久代完全被廢棄,取而代之的是元空間,HotSpot 提供了一些參數作爲元空間的防禦措施:

-XX:MaxMetaspaceSize:設置元空間的最大值,默認 -1,表示不限制即只受限於本地內存大小。

-XX:MetaspaceSize:指定元空間的初始大小,以字節爲單位,達到該值就會觸發垃圾收集進行類型卸載,同時收集器會對該值進行調整:如果釋放了大量空間就適當降低該值,如果釋放了很少的空間就適當提高該值。

-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之後控制最小的元空間剩餘容量百分比,可減少因爲元空間不足導致的垃圾收集的頻率。類似的還有-XX:MinMetaspaceFreeRatio,用於控制最大的元空間剩餘容量百分比。

本機直接內存溢出

直接內存的容量大小可通過 -XX:MaxDirectMemorySize 指定,如果不去指定則默認與 Java 堆的最大值一致。

由直接內存導致的內存溢出,一個明顯的特徵是在 Heap Dump 文件中不會看見有什麼明顯的異常情況,如果發現內存溢出後產生的Dump 文件很小,而程序中又直接或間接使用了直接內存(典型的間接使用就是 NIO),那麼就可以考慮檢查直接內存方面的原因。


P6:判斷對象是否是垃圾

在堆中存放着所有對象實例,垃圾收集器在對堆進行回收前,首先要判斷對象是否還存活着。

引用計數算法

在對象中添加一個引用計數器,如果有一個地方引用它計數器就加1,引用失效時計數器就減1,如果計數器爲0則該對象就是不再被使用的。該算法原理簡單,效率也高,但是在 Java中很少使用,因爲它存在對象之間互相循環引用的問題,導致計數器無法清零。

可達性分析算法

當前主流語言的內存管理子系統都是使用可達性分析算法來判斷對象是否存活的。這個算法的基本思路就是通過一系列稱爲 GC Roots 的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲引用鏈,如果某個對象到GC Roots之間沒有任何引用鏈相連,則此對象是不可能再被使用的。

可作爲GC Roots的對象:

  • 在虛擬機棧中引用的對象,如線程被調用的方法堆棧中的參數、局部變量等。
  • 在方法區中類靜態屬性引用的對象,如類的引用類型靜態變量。
  • 在方法區中常量引用的對象,如字符串常量池中的引用。
  • 在本地方法棧中 JNI 即 Native 方法引用的對象。
  • JVM 內部的引用,如基本數據類型對應的 Class 對象,一些常駐異常對象,系統類加載器等。
  • 所有被 synchronized 同步鎖持有的對象。

P7:引用類型

無論通過引用計數還是可達性分析判斷對象是否存活,都和引用離不開關係。在 JDK1.2 之前引用的定義是:如果 reference 類型數據存儲的數值代表另外一塊內存的起始地址,那麼就稱該 reference 數據是代表某塊內存、某個對象的引用。在 JDK 1.2之後 Java 對引用的概念進行了擴充,按強度分爲四種:

強引用:最傳統的引用定義,指代碼中普遍存在的引用賦值。任何情況下只要強引用存在,垃圾收集器就永遠不會回收被引用的對象。

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

弱引用:描述非必需對象,引用強度比軟引用更弱,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器開始工作時無論當前內存是否足夠都會回收只被弱引用關聯的對象。

虛引用:也稱幽靈引用或幻影引用,是最弱的引用關係。一個對象是否有虛引用存在,完全不會對其生存時間造成影響,也無法通過虛引用來取得一個對象實例。該引用的唯一目的就是爲了能在這個對象被垃圾收集器回收時收到一個系統通知。


P8:GC 算法

標記-清除算法

  • **原理:**分爲標記和清除兩個階段,首先標記出所有需要回收的對象,在標記完成之後統一回收掉所有被標記的對象,或者標記存活的對象並統一回收所有未被標記的對象。標記過程就是判斷對象是否屬於垃圾的過程。

  • **特點:**① 執行效率不穩定,如果堆中包含大量對象且其中大部分是需要被回收的,這時必須進行大量標記和清除,導致效率隨對象數量增長而降低。② 內存空間碎片化問題,標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致以後在程序運行中需要分配較大對象時無法找出足夠的連續內存而不得不提前觸發另一次垃圾收集。

標記-複製算法

  • **原理:**將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中一塊。當這一塊的空間用完了,就將還存活着的對象複製到另一塊,然後再把已使用過的內存空間一次清理掉。

  • **特點:**① 實現簡單、運行高效,解決了內存碎片問題。② 代價是將可用內存縮小爲原來的一半,浪費了過多空間。

  • HotSpot 的新生代劃分:

    把新生代劃分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次分配內存只使用 Eden 和其中一塊 Survivor。發生垃圾收集時將 Eden 和 Survivor 中仍然存活的對象一次性複製到另一塊 Survivor 上,然後直接清理掉 Eden 和已用過的那塊 Survivor 空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,即每次新生代中可用空間爲整個新生代的90%。

標記-整理算法

  • **原理:**標記-複製算法在對象存活率較高時要進行較多的複製操作,效率將會降低。並且如果不想浪費空間,就需要有額外空間進行分配擔保,應對被使用內存中所有對象都100%存活的極端情況,所以老年代一般不使用此算法。老年代使用標記-整理算法,標記過程與標記-清除算法一樣,只是後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然後直接清理掉邊界以外的內存。
  • **特點:**標記-清除與標記-整理的本質差異在於前者是一種非移動式回收算法而後者是移動式的。是否移動回收後的存活對象是一項優缺點並存的風險策略:① 如果移動存活對象,尤其是在老年代這種每次回收都有大量對象存活的區域,是一種極爲負重的操作,而且這種移動必須全程暫停用戶線程才能進行。② 如果不移動對象就會導致空間碎片問題,只能依賴更復雜的內存分配器和內存訪問器來解決。所以是否移動對象都存在弊端,移動則內存回收時更復雜,不移動則內存分配時更復雜。

P9:垃圾收集器

**經典垃圾收集器:**指 JDK 11之前的全部可用垃圾收集器。

Serial

最基礎、歷史最悠久的收集器,該收集器是一個使用複製算法的單線程工作收集器,單線程的意義不僅是說明它只會使用一個處理器或一條收集線程去完成垃圾收集工作,更重要的是強調它進行垃圾收集時必須暫停其他所有工作線程直到收集結束。

Serial 是虛擬機運行在客戶端模式下的默認新生代收集器,優點是簡單高效,對於內存受限的環境它是所有收集器中最小的;對於單核處理器或處理器核心較少的環境來說,Serial 收集器由於沒有線程交互開銷,因此可獲得最高的單線程收集效率。

ParNew

實質上是 Serial 的多線程版本,除了使用多線程進行垃圾收集外其餘行爲完全一致。

ParNew 是虛擬機運行在服務端模式下的默認新生代收集器,一個重要原因是除了 Serial 外只有它能與 CMS 配合。自從 JDK 9 開始,ParNew 加 CMS 收集器的組合就不再是官方推薦的服務端模式下的收集器解決方案了,官方希望他能被 G1 完全取代。

Parallel Scavenge

新生代收集器,基於標記-複製算法,是可以並行的多線程收集器,與 ParNew 類似。

特點是它的關注點與其他收集器不同,CMS 等收集器的關注點是儘可能縮短收集時用戶線程的停頓時間,而 Parallel Scavenge 的目標是達到一個可控制的吞吐量,吞吐量就是處理器用於運行用戶代碼的時間與處理器消耗總時間的比值。自適應調節策略也是它區別於 ParNew 的一個重要特性。

Serial Old

Serial 的老年代版本,同樣是一個單線程收集器,使用標記-整理算法。

Serial Old 是虛擬機在客戶端模式下的默認老年代收集器,用於服務端有兩種用途:一種是 JDK 5 及之前與 Parallel Scavenge 搭配使用,另一種是作爲CMS 發生失敗時的預案。

Parellel Old

Parallel Scavenge 的老年代版本,支持多線程收集,基於標記-整理算法實現。這個收集器直到 JDK 6 纔開始提供,在注重吞吐量優先的場景可以有效考慮Parallel Scavenge 加 Parallel Old 組合。

CMS

以獲取最短回收停頓時間爲目標的收集器,如果希望系統停頓時間儘可能短以給用戶帶來更好的體驗就可以使用 CMS。

基於標記-清除算法,過程相對複雜,分爲四個步驟:初始標記、併發標記、重新標記、併發清除。

其中初始標記和重新標記仍然需要 STW(Stop The World,表示系統停頓),初始標記僅是標記 GC Roots 能直接關聯到的對象,速度很快。併發標記就是從 GC Roots 的直接關聯對象開始遍歷整個對象圖的過程,耗時較長但不需要停頓用戶線程,可以與垃圾收集線程併發運行。重新標記則是爲了修正併發標記期間因用戶程序運作而導致標記產生變動的那一部分對象的標記記錄,該階段停頓時間比初始標記稍長,但遠比並發標記短。最後是併發清除,清理標記階段判斷的已死亡對象,由於不需要移動存活對象,因此該階段也可以與用戶線程併發。

由於整個過程中耗時最長的併發標記和併發清除階段中,垃圾收集器都可以和用戶線程一起工作,所以從總體上說CMS 的內存回收過程是與用戶線程併發執行的。

CMS 是 HotSpot 追求低停頓的第一次成功嘗試,但還存在三個明顯缺點:① 對處理器資源非常敏感,在併發階段雖然不會導致用戶線程暫停,但會降低總吞吐量。② 無法處理浮動垃圾,有可能出現併發失敗而導致另一次 FullGC。③ 由於基於標記-清除算法,因此會產生大量空間碎片,給大對象分配帶來麻煩。

G1

開創了收集器面向局部收集的設計思路和基於Region的內存佈局,是一款主要面向服務端的收集器,最初設計目標是替換CMS。

G1 之前的收集器,垃圾收集的目標要麼是整個新生代,要麼是整個老年代或整個堆。而 G1 可以面向堆內存任何部分來組成回收集進行回收,衡量標準不再是它屬於哪個分代,而是哪塊內存中存放的垃圾數量最多,回收受益最大,這就是 G1 的 MixedGC 模式。

不再堅持固定大小及數量的分代區域劃分,而是把 Java 堆劃分爲多個大小相等的獨立區域(Region),每一個 Region 都可以根據需要扮演新生代的 Eden 空間、Survivor 空間或老年代空間。收集器能夠對扮演不同角色的 Region 採用不同的策略處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。

跟蹤各個 Region 裏面的垃圾堆積的價值大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,在後臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間優先處理回收價值收益最大的 Region。這種回收方式保證了 G1 在有限的時間內獲取儘可能高的收集效率。

G1的運作過程:

  • **初始標記:**標記 GC Roots 能直接關聯到的對象並修改 TAMS 指針的值,讓下一階段用戶線程併發運行時能正確地在可用 Region 中分配新對象。該階段需要 STW 但耗時很短,是借用進行 MinorGC 時同步完成的。
  • **併發標記:**從 GC Roots 開始對堆中對象進行可達性分析,遞歸掃描整個堆的對象圖,找出需要回收的對象。該階段耗時長,但可與用戶線程併發執行,當對掃描完成後還要重新處理 SATB 記錄的在併發時有引用變動的對象。
  • **最終標記:**對用戶線程做一個短暫暫停,用於處理併發階段結束後仍遺留下來的少量 SATB 記錄。
  • **篩選回收:**對各個 Region 的回收價值和成本排序,根據用戶期望的停頓時間指定回收計劃,可自由選擇任意多個 Region 構成回收集然後把決定回收的那一部分的存活對象複製到空的 Region 中,再清理掉整個舊的 Region 的全部空間。該操作必須暫停用戶線程,由多條收集器線程並行完成。

可以由用戶指定期望的停頓時間是 G1 的一個強大功能,但該值不能設得太低,一般設置爲100~300毫秒比較合適。G1不會存在內存空間碎片的問題,但 G1 爲了垃圾收集產生的內存佔用和程序運行時的額外執行負載都高於CMS。


**低延遲垃圾收集器:**指 Shenandoah 和 ZGC,這兩個收集器幾乎整個工作過程全都是併發的,只有初始標記、最終標記這些階段有短暫停頓,停頓的時間基本上是固定的。

Shenandoah

相比 G1 內存佈局同樣基於 Region,默認回收策略也是優先處理回收價值最大的 Region。但在管理堆內存方面,與 G1 有不同:① 支持併發整理,G1 的回收階段不能與用戶線程併發。②默認不使用分代收集,不會有專門的新生代 Region 或老年代 Region。③ 摒棄了在 G1 中耗費大量內存和計算資源去維護的記憶集,改用名爲連接矩陣的全局數據結構來記錄跨 Region 的引用關係。

ZGC

JDK11中新加入的具有實驗性質的低延遲垃圾收集器,和 Shenandoah 的目標高度相似,都希望在儘可能對吞吐量影響不大的前提下實現在任意堆大小下都可以把垃圾收集器的停頓時間限制在 10ms 以內的低延遲。

基於 Region 內存佈局,不設分代,使用了讀屏障、染色指針和內存多重映射等技術來實現可併發的標記-整理,以低延遲爲首要目標。內存佈局也採用基於 Region 的堆內存佈局,但不同的是 ZGC 的 Region 具有動態性,是動態創建和銷燬的,並且區容量大小也是動態變化的。


P10:內存分配與回收策略

以 Seial + Serial Old 客戶端默認收集器組合爲例:

對象優先在 Eden 區分配

大多數情況下對象在新生代 Eden 區分配,當 Eden 區沒有足夠空間進行分配時虛擬機將發起一次 MinorGC。

可通過 -XX:Xms-XX:Xmx 設置堆大小, -Xmn 設置新生代的大小, -XX:SurvivorRatio 設置新生代中 Eden 和 Survivor的比例。

大對象直接進入老年代

大對象是指需要大量連續內存空間的對象,最典型的是很長的字符串或者元素數量很龐大的數組。大對象容易導致內存明明還有不少空間時就提前觸發垃圾收集以獲得足夠的連續空間才能安置它們,當複製對象時大對象就意味着高額內存複製開銷。

HotSpot 提供了 -XX:PretenureSizeThreshold 參數,大於該值的對象直接在老年代分配,避免在 Eden 和 Survivor 之間來回複製產生大量內存複製操作。

長期存活對象進入老年代

虛擬機給每一個對象定義了一個對象年齡計數器,存儲在對象頭。對象通常在 Eden 誕生,如果經歷過第一次 MinorGC 仍然存活並且能被 Survivor 容納,該對象就會被移動到 Survivor 中並將年齡設置爲 1。對象在 Survivor 中每熬過一次 MinorGC 年齡就加 1 ,當增加到一定程度(默認15)就會被晉升到老年代。對象晉升老年代的年齡閾值可通過 -XX:MaxTenuringThreshold 設置。

動態對象年齡判定

爲了適應不同程序的內存狀況,虛擬機並不永遠要求對象年齡達到閾值才能晉升老年代,如果在 Survivor 中相同年齡所有對象大小的總和大於 Survivor 的一半,年齡不小於該年齡的對象就可以直接進入老年代,無需等到 -XX:MaxTenuringThreshold 參數設置的年齡。

空間分配擔保

發生 MinorGC 前,虛擬機必須先檢查老年代最大可用連續空間是否大於新生代所有對象總空間,如果這個條件成立,那這一次 MinorGC可以確定是安全的。

如果不成立,虛擬機會先查看 -XX:HandlePromotionFailure 參數的值是否允許擔保失敗,如果允許會繼續檢查老年代最大可用連續空間是否大於歷次晉升老年代對象的平均大小,如果滿足將冒險嘗試一次 MinorGC,如果不滿足或不允許擔保失敗就會改成一次 FullGC。

之所以說冒險是因爲新生代使用複製算法,爲了內存利用率只使用其中一個 Survivor 作爲備份,因此當出現大量對象在 MinorGC 後仍然存活的情況時需要老年代進行分配擔保,把 Survivor 無法容納的對象直接送入老年代。


P11:故障處理工具

jps:虛擬機進程狀況工具

jps 即 JVM Process Status,參考了 UNIX 命令的命名格式,功能和 ps 命令類似:可以列出正在運行的虛擬機進程,並顯示虛擬機執行主類名稱以及這些進程的本地虛擬機唯一 ID(LVMID)。LVMID 與操作系統的進程 ID(PID)是一致的,使用 Windows 的任務管理器或 UNIX 的 ps 命令也可以查詢到虛擬機進程的 LVMID,但如果同時啓動了多個虛擬機進程,無法根據進程名稱定位就必須依賴 jps 命令。

jps 還可以通過 RMI 協議查詢開啓了 RMI 服務的遠程虛擬機進程狀態,參數 hostid 爲 RMI 註冊表中註冊的主機名。

jstat:虛擬機統計信息監視工具

jstat 即 JVM Statistic Monitoring Tool,是用於監視虛擬機各種運行狀態信息的命令行工具。它可以顯示本地或者遠程虛擬機進程中的類加載、內存、垃圾收集、即時編譯器等運行時數據,在沒有 GUI 界面的服務器上是運行期定位虛擬機性能問題的常用工具。

一些參數的含義:S0 和 S1 表示兩個 Survivor 區,E 表示新生代,O 表示老年代,YGC 表示 Young GC 次數,YGCT 表示Young GC 耗時,FGC 表示 Full GC 次數,FGCT 表示 Full GC 耗時,GCT 表示所有 GC 總耗時。

jinfo:Java 配置信息工具

jinfo 表示 Configuration Info for Java,作用是實時查看和調整虛擬機各項參數,使用 jps 的 -v 參數可以查看虛擬機啓動時顯式指定的參數列表,但如果想知道未被顯式指定的參數的系統默認值就只能使用 jinfo 的 -flag 選項進行查詢。jinfo 還可以把虛擬機進程的 System.getProperties() 的內容打印出來。

jmap:Java 內存映像工具

jmap 表示 Memory Map for Java,jamp 命令用於生成堆轉儲快照,還可以查詢 finalize 執行隊列、Java 堆和方法區的詳細信息,如空間使用率,當前使用的是哪種收集器等。和 jinfo 命令一樣,有部分功能在 Windows 平臺下受限,除了生成堆轉儲快照的 -dump 選項和用於查看每個類實例的 -histo 選項外,其餘選項都只能在 Linux 使用。

jhat:虛擬機堆轉儲快照分析工具

jhat 表示 JVM Heap Analysis Tool,JDK 提供 jhat 命令與 jmap 搭配使用來分析 jamp 生成的堆轉儲快照。jhat 內置了一個微型的 HTTP/Web 服務器,生成堆轉儲快照的分析結果後可以在瀏覽器中查看。

jstack:Java 堆棧跟蹤工具

jstack 表示 Stack Trace for Java,jstack 命令用於生成虛擬機當前時刻的線程快照。線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照的目的通常是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間掛起等。線程出現停頓時通過 jstack 查看各個線程的調用堆棧,就可以獲知沒有響應的現場到底在後臺做什麼或等待什麼資源。

除了上述的基礎故障處理工具,還有一些可視化故障處理工具,例如 JHSDB 基於服務性代理的調試工具、JConsole Java 監視與管理控制檯、VisualVM 多合一故障處理工具、Java Mission Control 可持續在線監控工具。


P12:類加載機制和初始化時機

在 Class 文件中描述的各類信息最終都需要加載到虛擬機後才能運行和使用。JVM 把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、解析和初始化,最終形成可以被虛擬機直接使用的 Java類型,這個過程被稱爲虛擬機的類加載機制。與其他在編譯時需要連接的語言不同,Java 中類型的加載、連接和初始化都是在程序運行期間完成的,這種策略讓 Java 進行類加載時增加了性能開銷,但卻爲 Java 應用提供了極高的擴展性和靈活性,Java 可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。

一個類型從被加載到虛擬機內存開始,到卸載出內存爲止,整個生命週期將會經歷加載、驗證、準備、解析、初始化、使用和卸載七個階段,其中驗證、解析和初始化三個部分統稱爲連接。加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是爲了支持 Java 語言的動態綁定特性。

關於何時需要開始類加載的第一個階段"加載",《 Java 虛擬機規範》沒有強制約束,但對於初始化嚴格規定了有且只有6種情況:

  • 遇到 new、getstatic、putstatic 或 invokestatic 這四條字節碼指令時,如果類型沒有初始化則需要先觸發初始化。典型場景有:① 使用new關鍵字實例化對象。② 讀取或設置一個類型的靜態字段。③ 調用一個類型的靜態方法。

  • 對類型進行反射調用時,如果類型沒有初始化則需要先觸發初始化。

  • 當初始化類時,如果其父類沒有初始化則需要先觸發父類的初始化。

  • 當虛擬機啓動時,用戶需要指定一個要執行的主類即包含 main 方法的類,虛擬機會先初始化該類。

  • 當使用 JDK 7 新加入的動態語言支持時,如果 MethodHandle 實例的解析結果爲指定類型的方法句柄且這個句柄對應的類沒有進行過初始化,則需要先觸發其初始化。

  • 當一個接口定義了默認方法時,如果該接口的實現類發生初始化,那接口要在其之前初始化。

除了這六種情況外其餘所有引用類型的方式都不會觸發初始化,稱爲被動引用。被動引用的實例:① 子類使用父類的靜態字段時,只有直接定義這個字段的父類會被初始化。② 通過數組定義使用類。③ 常量在編譯期會存入調用類的常量池,不會初始化定義常量的類。

接口的加載過程和類真正有所區別的是當初始化類時,如果其父類沒有初始化則需要先觸發其父類的初始化,但在一個接口初始化時並不要求其父接口全部完成了初始化,只有在真正使用到父接口時(如引用接口中定義的常量)纔會初始化。


P13:類加載過程

加載

加載是類加載的第一個階段,在該階段虛擬機需要完成三件事:① 通過一個類的全限定類名來獲取定義此類的二進制字節流。② 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據區結構。③ 在內存中生成一個代表這個類的 Class 對象,作爲方法區這個類的各種數據的訪問入口。

加載結束後,虛擬機外部的二進制字節流就按照虛擬機所設定的格式存儲在方法區中了,方法區中的數據存儲格式完全由虛擬機自定義實現。類型數據安置在方法區之後,會在 Java 堆中實例化一個 Class 對象,這個對象將作爲程序員訪問方法區中類型數據的外部接口。加載與連接的部分動作是交叉進行的,加載尚未完成時連接可能已經開始。

驗證

驗證是連接的第一步,目的是確保 Class 文件的字節流中包含的信息符合約束要求,保證這些信息不會危害虛擬機的安全。Java 語言本身是安全的,但如果虛擬機不檢查輸入的字節流,對其完全信任的話,很可能因爲載入了有錯誤或有惡意企圖的字節碼流而導致整個系統受攻擊甚至崩潰。

驗證主要包含了四個階段:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。

驗證對於虛擬機的類加載機制來說是一個非常重要但非必需的階段,因爲驗證只有通過與否的區別,只要通過了驗證其後就對程序運行期沒有任何影響了。如果程序運行的全部代碼都已被反覆使用和驗證過,在生產環境的就可以考慮關閉大部分類驗證措施縮短類加載時間。

準備

準備是正式爲類變量分配內存並設置零值的階段,該階段進行的內存分配僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在 Java 堆中。如果變量被final修飾,編譯時 Javac 會爲變量生成 ConstantValue 屬性,那麼在準備階段虛擬機就會將該變量的值設爲程序員指定的值。

解析

解析是將常量池內的符號引用替換爲直接引用的過程。

  • **符號引用:**符號引用以一組符號描述引用目標,可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。與虛擬機內存佈局無關,引用目標並不一定是已經加載到虛擬機內存中的內容。
  • **直接引用:**直接引用是可以直接指向目標的指針、相對偏移量或者能間接定位到目標的句柄。和虛擬機的內存佈局直接相關,引用目標必須已在虛擬機的內存中存在。

解析部分主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這7類符合引用進行。

初始化

初始化是類加載過程的最後一步,直到該階段,JVM 才真正開始執行類中編寫的代碼。

準備階段時變量已經賦過一次系統零值,而在初始化階段會根據程序員的編碼去初始化類變量和其他資源。

初始化階段就是執行類構造器 <client> 方法的過程,該方法是 Javac 編譯器自動生成的。


P14:類加載器和雙親委派模型

類加載階段中"通過一個類的全限定名來獲取描述該類的二進制字節流"的動作被設計爲放到 JVM 外部實現,以便讓應用程序自己決定如何獲取所需的類,實現這個動作的代碼就是類加載器。

**比較兩個類是否相等:**對於任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在虛擬機中的唯一性,每一個類加載器都擁有一個獨立的類名稱空間。只有在兩個類是由同一個類加載器加載的前提下才有意義,否則即使兩個類來源於同一個 Class 文件,被同一個 JVM 加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

從 JVM 的角度看只存在兩種不同的類加載器:一種是啓動類加載器,由 C++ 語言實現,是虛擬機自身的一部分;另一種是其他所有類加載器,由 Java 語言實現,獨立存在於虛擬機外部且全部繼承自抽象類 java.lang.ClassLoader

自 JDK1.2 起 Java 一直保持着三層類加載器、雙親委派的類加載結構。

  • 啓動類加載器:負載加載存放在 JAVA_HOME/lib 目錄,或者指定路徑中存放的能夠被虛擬機識別的類庫加載到虛擬機內存中。啓動類加載器無法被 Java 程序直接引用,如果用戶需要把加載請求委派給啓動類加載器,直接使用 null 代替即可。
  • 擴展類加載器:負載加載 JAVA_HOME/lib/ext 目錄,或者系統變量所指定的路徑中的類庫。這種擴展機制在 JDK 9 後被模塊化所取代,由於擴展類加載器是由 Java 編寫的,開發者可以直接在程序中使用擴展類加載器來加載 Class 文件。
  • 應用程序類加載器:也稱系統類加載器,負載加載用戶類路徑上的所有類庫,同樣可以直接在代碼中使用。如果應用程序中沒有自定義類加載器,一般情況下該類加載器就是程序中默認的類加載器。

雙親委派模型

雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應該有自己的父類加載器。不過這裏類加載器之間的父子關係一般不是以繼承關係來實現的,而通常使用組合關係來複用父加載器的代碼。

如果一個類加載器收到了類加載請求,它不會自己去嘗試加載這個類,而首先將該請求委派給自己的父加載器完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啓動類加載器中,只有當父加載器反饋自己無法完成請求時,子加載器纔會嘗試自己完成加載。

好處是 Java 中的類跟隨它的類加載器一起具備了一種帶有優先級的層次關係,可以保證某個類在程序的各個類加載器環境中都是同一個類,對於保證程序的穩定運行極爲重要。


P15:Java 程序運行的過程

通過 Javac 編譯器將 .java 代碼轉爲 JVM 可以加載的 .class 字節碼文件。

Javac 編譯器是由 Java 語言編寫的程序,從 Javac 代碼的總體結構看,編譯過程可以分爲 1 個準備過程和 3 個處理過程:① 準備過程:初始化插入式註解處理器。② 解析與填充符號表過程:進行詞法、語法分析,將源代碼的字符流轉爲標記集合,構造出抽象語法樹。填充符號表,產生符號地址和符號信息。③ 插入式註解處理器的註解處理過程。④ 分析與字節碼生成過程,包括標註檢查,對語法的靜態信息進行檢查;數據流及控制流分析,對程序動態運行過程進行檢查;解語法糖,將簡化代碼編寫的語法糖還原爲原有的形式;字節碼生成,將前面各個步驟的信息轉換爲字節碼。

Javac 屬於前端編譯器,完成了從程序到抽象語法樹或中間字節碼的生成,在此之後還有一組內置於 JVM 內部的後端編譯器,即即時編譯器或提前編譯器,來完成代碼優化以及從字節碼生成本地機器碼的過程。

通過即時編譯器 JIT 把字節碼文件編譯成本地機器碼。

Java 程序最初都是通過解釋器進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁,就會把這些代碼認定爲"熱點代碼",熱點代碼的檢測主要有基於採樣和基於計數器兩種方式,爲了提高熱點代碼的執行效率,在運行時虛擬機會把這些代碼編譯成本地機器碼,並儘可能對代碼優化,在運行時完成這個任務的後端編譯器被稱爲即時編譯器。

客戶端編譯器的執行過程:① 平臺獨立的前端將字節碼構造成一種高級中間代碼表示 HIR。② 平臺相關的後端從 HIR 中產生低級中間代碼表示 LIR。③ 在平臺相關的後端使用線性掃描算法在 LIR 上分配寄存器,並在 LIR 上做窺孔優化,然後產生機器代碼。

服務端編譯器專門面向服務端的典型應用場景,併爲服務器的性能配置針對性調整過的編譯器,也是一個能容忍很高優化複雜度的高級編譯器,它會執行大部分經典的優化動作,如無用代碼消除、循環表達式外提、消除公共子表達式、基本塊重排序等,還會實施一些與 Java 語言特性相關的優化,如範圍檢查消除、空值檢查消除等,也可能根據解釋器或客戶端編譯器提供的性能監控信息進行一些不穩定的預測性激進優化。

還可以通過靜態的提前編譯器 AOT 直接把程序編譯成與目標機器指令集相關的二進制代碼。


併發 20

P1:Java 內存模型

Java 線程的通信由 JMM 控制,JMM 的主要目的是定義程序中各種變量的訪問規則,關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。此處的變量包括實例字段、靜態字段和構成數組元素的對象,但不包括局部變量與方法參數,因爲它們是線程私有的,不存在多線程競爭問題。爲了獲得更好的執行效率,JMM 沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器是否要進行調整代碼執行順序這類優化措施,JMM 遵循一個基本原則:只要不改變程序執行結果,編譯器和處理器怎麼優化都行。例如編譯器分析某個鎖只會單線程訪問就消除該鎖,某個 volatile 變量只會單線程訪問就把它當作普通變量。

JMM 規定了所有變量都存儲在主內存中,每條線程還有自己的工作內存,工作內存中保存了被該線程使用的變量的主內存副本,線程對變量的所有操作都必須在工作空間中進行,而不能直接讀寫主內存中的數據。不同線程之間也無法直接訪問對方工作內存中的變量,兩個線程之間的通信必須經過主內存,JMM 通過控制主內存與每個線程的工作內存之間的交互來提供內存可見性保證。

關於主內存與工作內存之間的交互,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存等實現細節,JMM 定義了 8 種原子操作:

  • **lock:**作用於主內存變量,把變量標識爲一條線程獨佔的狀態。
  • **unlock:**作用於主內存變量,把處於鎖定狀態的變量釋放出來,釋放後的變量才能被其他線程鎖定。
  • **read:**作用於主內存變量,把變量值從主內存傳到工作內存。
  • **load:**作用於工作內存變量,把 read 從主存中得到的值放入工作內存的變量副本。
  • **use:**作用於工作內存變量,把工作內存中的變量值傳給執行引擎,每當虛擬機遇到需要使用變量值的字節碼指令時執行該操作。
  • **assign:**作用於工作內存變量,把從執行引擎接收的值賦給工作內存變量,每當虛擬機遇到給變量賦值的字節碼指令時執行該操作。
  • **store:**作用於工作內存變量,把工作內存中的變量值傳送到主內存。
  • **write:**作用於主內存變量,把 store 從工作內存取到的變量值放入主內存變量中。

如果要把一個變量從主內存拷貝到工作內存,就要按順序執行 read 和 load ,如果要把變量從工作內存同步回主內存,就要按順序執行 store 和 write 。JMM 只要求這兩種操作必須按順序執行,但不要求連續,也就是說 read 和 load、store 和 write 之間可插入其他指令。這種定義十分嚴謹但過於複雜,之後 Java 將內存操作簡化爲 lock、unlock、read 和 write 四種,但這只是語言描述上的等價化簡。


P2:as-if-serial 和 happens-before

as-if-serial

as-if-serial 的語義是:不管怎麼重排序,單線程程序的執行結果不能被改變,編譯器和處理器必須遵循 as-if-serial 語義。

爲了遵循 as-if-serial 語義,編譯器和處理器不會對存在數據依賴關係的操作重排序,因爲這種重排序會改變執行結果。但是如果操作之間不存在數據依賴關係,這些操作就可能被編譯器和處理器重排序。

as-if-serial 語義把單線程程序保護了起來,給了程序員一種幻覺:單線程程序是按程序的順序執行的,使程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。

happens-before

先行發生原則,是 JMM 中定義的兩項操作之間的偏序關係,它是判斷數據是否存在競爭,線程是否安全的重要手段。

JMM 將 happens-before 要求禁止的重排序按是否會改變程序執行結果分爲兩類。對於會改變結果的重排序 JMM 要求編譯器和處理器必須禁止這種重排序,對於不會改變結果的重排序,JMM 對編譯器和處理器不做要求。

JMM 存在一些天然的 happens-before 關係,無需任何同步器協助就已經存在。如果兩個操作的關係不在此列,並且無法從這些規則推導出來,它們就沒有順序性保障,虛擬機可以對它們隨意進行重排序。

  • **程序次序規則:**在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。
  • **管程鎖定規則:**一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作。
  • **volatile 規則:**對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作。
  • **線程啓動規則:**線程對象的 start 方法先行發生於此線程的每一個動作。
  • **線程終止規則:**線程中的所有操作都先行發生於對此線程的終止檢測。
  • **線程中斷規則:**對線程 interrupt 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。
  • **對象終結規則:**一個對象的初始化完成先行發生於它的 finalize 方法的開始。
  • **傳遞性:**如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C 。

區別

as-if-serial 保證單線程程序的執行結果不被改變,happens-before 保證正確同步的多線程程序的執行結果不被改變。這兩種語義的目的都是爲了在不改變程序執行結果的前提下儘可能提高程序執行的並行度。


P3:指令重排序

重排序指從源代碼到指令序列的重排序,在執行程序時爲了提高性能,編譯器和處理器通常會對指令進行重排序,分爲三種:

  • 編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下可以重排語句的執行順序。

  • 指令級並行的重排序:如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

  • 內存系統的重排序:由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作操作看上去可能是亂序執行。

從 Java 源代碼到最終實際執行的指令序列,會分別經歷編譯器優化重排序、指令級並行重排序和內存系統重排序,這些重排序可能會導致多線程程序出現內存可見性問題。

對於編譯器,JMM 的編譯器重排序規則會禁止特定類型的編譯器重排序。對於處理器重排序,JMM 的處理器重排序規則會要求 Java 編譯器在生成指令序列時,插入特定類型的內存屏障指令,即一組用於實現對內存操作順序限制的處理器指令,通過內存屏障指令來禁止特定類型的處理器重排序。JMM 屬於語言級的內存模型,它確保在不同的編譯器和處理器平臺上,通過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。


P4:原子性、可見性和有序性

原子性

由 JMM 直接保證的原子性變量操作包括 read、load、assign、use、store 和 write,基本數據類型的訪問都是具備原子性的,例外就是 long 和 double 的非原子性協定,允許虛擬機將沒有被 volatile 修飾的 64 位數據的操作劃分爲兩次 32 位的操作。

如果應用場景需要更大範圍的原子性保證,JMM 還提供了 lock 和 unlock 操作滿足這種需求,儘管 JVM 沒有把這兩種操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令 monitorenter 和 monitorexit 來隱式地使用這兩個操作,這兩個字節碼指令反映到 Java 代碼中就是 synchronized 關鍵字。

可見性

可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知修改。JMM 通過在變量修改後將值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式實現可見性,無論是普通變量還是volatile變量都是如此,區別是 volatile 保證新值能立即同步到主內存以及每次使用前立即從主內存刷新,因此說 volatile 保證了多線程操作變量的可見性,而普通變量則不能保證。

除了 volatile 之外,還有兩個關鍵字能實現可見性,分別是 synchronized 和 final,同步塊的可見性是由"對一個變量執行unlock 前必須先把此變量同步回主內存中,即先執行 store 和 write"這條規則獲得的。final 的可見性是指:被 final 修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把"this"引用傳遞出去,那麼其他線程就能看到 final 字段的值。

有序性

有序性可以總結爲:在本線程內觀察所有操作是有序的,在一個線程內觀察另一個線程,所有操作都是無序的。前半句是指"as-if-serial 語義",後半句是指"指令重排序"和"工作內存與主內存同步延遲"現象。

Java 提供了 volatile 和 synchronized 保證線程間操作的有序性,volatile 本身就包含了禁止指令重排序的語義,而 synchronized 則是由"一個變量在同一個時刻只允許一條線程對其進行lock操作"這條規則獲得的,該規則決定了持有同一個鎖的兩個同步塊只能串行進入。


P5:volatile 關鍵字

JVM 提供的最輕量級的同步機制,JMM 爲 volatile 定義了一些特殊的訪問規則,當一個變量被定義爲 volatile 後具備兩種特性:

  • 保證此變量對所有線程的可見性

    可見性是指當一條線程修改了這個變量的值,新值對於其他線程來說是立即可以得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。

    volatile 變量在各個線程的工作內存中不存在一致性問題,但 Java 的運算操作符並非原子操作,這導致 volatile 變量運算在併發下仍是不安全的。

  • 禁止指令重排序優化

    使用 volatile 變量進行寫操作,彙編指令操作是帶有 lock 前綴的,相當於一個內存屏障,後面的指令不能重排到內存屏障之前的位置。只有一個處理器時不需要使用內存屏障,但如果有兩個或更多的處理器訪問同一塊內存,且其中有一個在觀測另一個,就需要使用內存屏障來保證一致性了。

    使用 lock 前綴的指令在多核處理器中會引發兩件事:① 將當前處理器緩存行的數據寫回到系統內存。② 這個寫回內存的操作會使其他在CPU裏緩存了該內存地址的數據無效。

    這種操作相當於對緩存中的變量做了一次 store 和 write 操作,可以讓 volatile 變量的修改對其他處理器立即可見。


靜態變量 i 執行多線程 i++ 的不安全問題

通過反編譯會發現一個自增語句是由 4 條字節碼指令構成的,依次爲getstatic、iconst_1、iadd、putstatic,當getstatic把 i 的值取到操作棧頂時,volatile保證了 i 的值在此刻是正確的,但是在執行iconst_1、iadd這些指令時,其他線程可能已經改變了i的值,而操作棧頂的值就變成了過期的數據,所以 putstatic 指令執行後就可能把較小的 i 值同步回了主內存。

即使編譯出來只有一條字節碼指令也不能意味着這條指令就是一個原子操作,一條字節碼指令在解釋執行時,解釋器要運行很多行代碼才能實現它的語義。如果是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼指令。


適用場景

運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。

變量不需要與其他狀態變量共同參與不變約束。


volatile的內存語義

從內存語義角度來說,volatile的寫-讀與鎖的釋放-獲取具有相同的內存效果。

  • **寫內存語義:**當寫一個volatile變量時,JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存。

  • **讀內存語義:**當讀一個volatile變量時,JMM 會把該線程對應的本地內存置爲無效,線程接下來將從主內存中讀取共享變量。


volatile指令重排序的特點

當第二個操作是volatile 寫時,不管第一個操作是什麼都不能重排序,確保寫之前的操作不會被編譯器重排序到寫之後。

當第一個操作是volatile 讀時,不管第二個操作是什麼都不能重排序,確保讀之後的操作不會被編譯器重排序到讀之前。

當第一個操作是volatile 寫,第二個操作是 volatile 讀時不能重排序。


JSR-133 增強 volatile 語義的原因

在舊的內存模型中,雖然不允許 volatile 變量之間重排序,但允許 volatile 變量與普通變量重排序,可能導致內存不可見問題。爲了提供一種比鎖更輕量級的線程通信機制,嚴格限制了編譯器和處理器對 volatile 變量與普通變量的重排序,確保 volatile 的寫-讀和鎖的釋放-獲取具有相同的內存語義。


P6:final 關鍵字

final 可以保證可見性,被 final 修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把 this 的引用傳遞出去,那麼在其他線程中就能看見 final 字段的值。

JSR-133 增強 final語義的原因

在舊的 JMM 中,一個嚴重的缺陷就是線程可能看到 final 域的值會改變。比如一個線程看到一個 int 類型 final 域的值爲0,此時該值是還未初始化之前的零值,過一段時間之後該值被某線程初始化後這個線程再去讀這個 final 域的值會發現值變爲1。

爲了修復該漏洞,JSR-133 通過爲 final 域增加寫和讀重排序規則,提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造方法中沒有逸出),那麼不需要使用同步就可以保證任意線程都能看到這個final域在構造方法中被初始化之後的值。

寫 final 域重排序規則

禁止把 final 域的寫重排序到構造方法之外,編譯器會在final域的寫之後,構造方法的 return之前,插入一個Store Store屏障。該規則可以確保在對象引用爲任意線程可見之前,對象的 final 域已經被正確初始化過了,而普通域不具有這個保障。

對於引用類型,增加了約束:在構造方法內對一個 final 引用的對象的成員域的寫入,與隨後在構造方法外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。

讀 final 域重排序規則

在一個線程中,初次讀對象引用和初次讀該對象包含的final域,JMM 禁止處理器重排序這兩個操作。編譯器會在讀final域操作的前面插入一個Load Load 屏障,該規則可以確保在讀一個對象的 final 域之前,一定會先讀包含這個 final 域的對象的引用。


P7:synchronized 關鍵字

最基本的互斥同步手段就是 synchronized 關鍵字,它是一種塊結構的同步語法。synchronized 經過 Javac 編譯後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 兩個字節碼指令,這兩個字節碼指令都需要一個引用類型的參數來指明要鎖定和解鎖的對象,對於同步普通方法,鎖是當前實例對象;對於靜態同步方法,鎖是當前類的Class對象;對於同步方法塊,鎖是 synchronized 括號裏的對象。

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

被 synchronized 修飾的同步塊對一條線程來說是可重入的,並且同步塊在持有鎖的線程執行完畢並釋放鎖之前,會無條件地阻塞後面其他線程的進入。從執行成本的角度看,持有鎖是一個重量級的操作。在主流 JVM 實現中,Java 的線程是映射到操作系統的原生內核線程之上的,如果要阻塞或喚醒一條線程,則需要操作系統幫忙完成,這就不可避免陷入用戶態到核心態的轉換中,進行這些狀態轉換需要耗費很多的處理器時間。

每個 Java 對象都有一個關聯的 monitor 監視器,當這個對象由同步塊或同步方法調用時,執行方法的線程必須先獲取到對象的監視器才能進入同步塊或同步方法。例如有兩個線程 A 和 B 競爭鎖資源對應的 monitor,當線程 A 競爭到鎖時,會將 monitor 中的 owner 設置爲 A,把線程 B 阻塞並放到等待競爭資源的 ContentionList 隊列。ContentionList 中的部分線程會進入 EntryList,EntryList 中的線程會被指定爲 OnDeck 競爭候選者線程,如果獲得了鎖資源將進入 Owner 狀態,釋放鎖資源後進入 !Owner 狀態。被阻塞的線程會進入 WaitSet。

不公平的原因

所有收到鎖請求的線程首先自旋,如果通過自旋也沒有獲取鎖資源將被放入 ContentionList 隊列,該做法對於已經進入隊列的線程是不公平的。

爲了防止 ContentionList 尾部的元素被大量線程進行 CAS 訪問影響性能,Owner 線程會在釋放鎖時將 ContentionList 的部分線程移動到 EntryList 並指定某個線程爲 OnDeck 線程,Owner 並沒有將鎖直接傳遞給 OnDeck 線程而是把鎖競爭的權利交給它,該行爲叫做競爭切換,犧牲了公平性但提高了性能。


P8:鎖優化

JDK 6 對 synchronized 做了很多優化,引入了適應自旋、鎖消除、鎖粗化、偏向鎖和輕量級鎖等提高鎖的效率,鎖一共有 4 個狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。鎖可以升級但不能降級,這種只能升級不能降級的鎖策略是爲了提高獲得鎖和釋放鎖的效率。

自旋鎖與自適應自旋

互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作給 JVM 的併發性能帶來了很大壓力。同時虛擬機開發團隊也注意到了在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛機和恢復線程並不值得。現在絕大多數的個人電腦和服務器都是多核心處理器系統,如果物理機器有一個以上的處理器或者處理器核心,能讓兩個或以上的線程同時並行執行,我們就可以讓後面請求鎖的那個線程稍等一會,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,我們只需讓線程執行一個忙循環,這項技術就是所謂的自旋鎖。

自旋鎖在 JDK 1.4中就已經引入,只不過默認是關閉的,在 JDK 6中就已經改爲默認開啓了。自旋等待不能代替阻塞,自旋等待本身雖然避免了線程切換的開銷,但它要佔用處理器時間,所以如果鎖被佔用的時間很短,自旋的效果就會非常好,反之只會白白消耗處理器資源。因此自旋的時間必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起線程。自旋次數的默認次數是 10 次。

在 JDK 6 中對自旋鎖的優化,引入了自適應自旋。自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過鎖,那在以後要獲取這個鎖時將有可能之間省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨着程序運行時間的增長以及性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越精準。

鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上的數據對待,認爲它們是線程私有的,同步加鎖自然就無須再進行。

鎖粗化

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

大多數情況下這種原則是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體之外的,那麼即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能消耗。

如果虛擬機探測到有一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的範圍擴展(粗化)到整個操作序列的外部。

偏向鎖

它的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。如果說輕量級鎖是在無競爭的情況下使用 CAS 操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都去掉,連 CAS 操作都不去做了。

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

當一個線程訪問同步代碼塊並獲取鎖時,會在對象頭和幀棧中的鎖記錄裏存儲鎖偏向的線程 ID,以後該線程再進入和退出同步代碼塊不需要進行 CAS 操作來加鎖和解鎖,只需要簡單地測試一下對象頭的"Mark Word"裏是否存儲着指向當前線程的偏向鎖。如果測試成功表示線程已經獲得了鎖,如果失敗則需要再測試一下"Mark Word"中偏向鎖的標識是否設置成了 1 即表示當前使用偏向鎖,如果設置了就嘗試使用 CAS 將對象頭的偏向鎖指向當前線程,否則使用 CAS 方式競爭鎖。

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷需要等待全局安全點即此時沒有正在執行的字節碼,它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處於活動狀態則將對象頭設爲無鎖狀態。如果線程還活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的"Mark Word"要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作爲偏向鎖,最後喚醒暫停的線程。

輕量級鎖

輕量級是相對於操作系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就被稱爲重量級鎖。輕量級鎖並不是用來代替重量級鎖的,它設計的初衷是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。

在代碼即將進入同步塊的時候,如果此同步對象沒有被鎖定,虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄的空間,用於存儲鎖對象目前的Mark Word的拷貝。然後虛擬機將使用 CAS 操作嘗試把對象的 Mark Word 更新爲指向鎖記錄的指針,如果這個更新操作成功了,即代表該線程擁有了這個對象的鎖,並且鎖標誌位將轉變爲"00",表示此對象處於輕量級鎖定狀態。

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

解鎖操作也同樣是通過 CAS 操作來進行,如果對象的 Mark Word 仍然指向線程的鎖記錄,那就用 CAS 操作把對象當前的 Mark Word 和線程複製的 Mark Word 替換回來。假如能夠替換成功,那整個同步過程就順利完成了,如果替換失敗,則說明有其他線程嘗試過獲取該鎖,就要在釋放鎖的同時喚醒被掛起的線程。

偏向鎖、輕量級鎖和重量級鎖的區別

偏向鎖的優點是加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距,缺點是如果線程間存在鎖競爭會帶來額外鎖撤銷的消耗,適用於只有一個線程訪問同步代碼塊的場景。

輕量級鎖的優點是競爭的線程不會阻塞,提高了程序的響應速度,缺點是如果線程始終得不到鎖會自旋消耗CPU,適用於追求響應時間和同步代碼塊執行非常快的場景。

重量級鎖的優點是線程競爭不使用自旋不會消耗CPU,缺點是線程會被阻塞,響應時間很慢,適應於追求吞吐量、同步代碼塊執行較慢的場景。


P9:Lock 接口

自 JDK 5 起 Java類庫提供了 juc 併發包,其中的 Lock 接口成爲了一種全新的互斥同步手段。基於Lock 接口,用戶能夠以非塊結構來實現互斥同步,從而擺脫了語言特性的束縛,改爲在類庫層面去實現同步。

重入鎖 ReentrantLock 是 Lock 接口最常見的一種實現,它與 synchronized 一樣是可重入的,在基本用法上也很相似,不過它增加了一些高級功能,主要包括以下三項:

  • **等待可中斷:**是指持有鎖的線程長期不釋放鎖時,正在等待的線程可以選擇放棄等待而處理其他事情。可中斷特性對處理執行時間非常長的同步塊很有幫助。
  • **公平鎖:**是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖,而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock在默認情況下也是非公平的,但可以通過帶有布爾值的構造器要求使用公平鎖。不過一旦使用了公平鎖,將會導致性能急劇下降,明顯影響吞吐量。
  • **鎖綁定多個條件:**是指一個 ReentrantLock 對象可以同時綁定多個 Condition 對象。在 synchronized中,鎖對象的 wait 跟它的notify/notifyAll 方法配合可以實現一個隱含的條件,如果要和多於一個的條件關聯時就不得不額外添加一個鎖,而 ReentrantLock 可以多次調用 newCondition 方法。

一般優先考慮使用synchronized:① synchronized 是 Java 語法層面的同步,足夠清晰和簡單。② Lock 應該確保在 finally 中釋放鎖,否則一旦受同步保護的代碼塊中拋出異常,則有可能永遠不會釋放持有的鎖。這一點必須由程序員自己來保證,而使用 synchronized 可以由 JVM 來確保即使出現異常鎖也能被正常釋放。③ 儘管在 JDK 5 時ReentrantLock 的性能領先於 synchronized,但在 JDK 6 進行鎖優化之後,二者的性能基本能夠持平。從長遠來看 JVM 更容易針對synchronized進行優化,因爲 JVM 可以在線程和對象的元數據中記錄 synchronized 中鎖的相關信息,而使用Lock的話 JVM 很難得知具體哪些鎖對象是由特定線程持有的。

ReentrantLock 的可重入實現

以非公平鎖爲例,通過 nonfairTryAcquire 方法獲取鎖,該方法增加了再次獲取同步狀態的處理邏輯:通過判斷當前線程是否爲獲取鎖的線程來決定獲取操作是否成功,如果是獲取鎖的線程再次請求則將同步狀態值進行增加並返回 true,表示獲取同步狀態成功。

成功獲取鎖的線程再次獲取鎖,只是增加了同步狀態值,這就要求 ReentrantLock 在釋放同步狀態時減少同步狀態值。如果該鎖被獲取了 n 次,那麼前 n-1 次 tryRelease 方法必須都返回fasle,只有同步狀態完全釋放了才能返回 true,該方法將同步狀態是否爲 0 作爲最終釋放的條件,當同步狀態爲 0 時,將佔有線程設置爲null,並返回 true 表示釋放成功。

對於非公平鎖只要 CAS 設置同步狀態成功則表示當前線程獲取了鎖,而公平鎖則不同。公平鎖使用 tryAcquire 方法,該方法與nonfairTryAcquire 的唯一區別就是判斷條件中多了對同步隊列中當前節點是否有前驅節點的判斷,如果該方法返回 true 表示有線程比當前線程更早地請求獲取鎖,因此需要等待前驅線程獲取並釋放鎖之後才能繼續獲取鎖。、


P10:讀寫鎖

ReentrantLock 是排他鎖,在同一時刻只允許一個線程進行訪問,而讀寫鎖在同一時刻可以允許多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護了一個讀鎖和一個寫鎖,通過分離讀寫鎖使併發性相比一般的排他鎖有了很大提升。

除了保證寫操作對讀操作的可見性以及併發性的提升之外,讀寫鎖能夠簡化讀寫交互場景的編程方式。只需要在讀操作時獲取讀鎖,寫操作時獲取寫鎖即可,當寫鎖被獲取時後續的讀寫操作都會被阻塞,寫鎖釋放之後所有操作繼續執行,編程方式相對於使用等待/通知機制的實現方式變得簡單。

讀寫鎖同樣依賴自定義同步器來實現同步功能,而讀寫狀態就是其同步器的同步狀態。讀寫鎖的自定義同步器需要在同步狀態即一個整形變量上維護多個讀線程和一個寫線程的狀態。如果在一個 int 型變量上維護多種狀態,就一定要按位切割使用這個變量,讀寫鎖將變量切分成了兩個部分,高 16 位表示讀,低 16 位表示寫。

寫鎖是一個支持重入的排他鎖,如果當前線程已經獲得了寫鎖則增加寫狀態,如果當前線程在獲取寫鎖時,讀鎖已經被獲取或者該線程不是已經獲得寫鎖的線程則當前線程進入等待狀態。寫鎖的釋放與 ReentrantLock 的釋放過程類似,每次釋放均減少寫狀態,當寫狀態爲 0時表示寫鎖已被釋放,從而等待的讀寫線程能夠繼續訪問讀寫鎖,同時前次寫線程的修改對後續讀寫線程可見。

讀鎖是一個支持重入的共享鎖,它能夠被多個線程同時獲取,在沒有其他寫線程訪問時,讀鎖總會被成功地獲取,而所做的只是線程安全地增加讀狀態。如果當前線程已經獲取了讀鎖,則增加讀狀態。如果當前線程在獲取讀鎖時,寫鎖已被其他線程獲取則進入等待狀態。讀鎖的每次釋放均會減少讀狀態,減少的值是(1<<16),讀鎖的每次釋放是線程安全的。

鎖降級指的是寫鎖降級成爲讀鎖,如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級。鎖降級指的是把持住當前擁有的寫鎖,再獲取到讀鎖,隨後釋放先前擁有的寫鎖的過程。

鎖降級中讀鎖的獲取是必要的,主要是爲了保證數據的可見性,如果當前線程不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個線程 A 獲取了寫鎖修改了數據,那麼當前線程是無法感知線程 A 的數據更新的。如果當前線程獲取讀鎖,即遵循鎖降級的步驟,線程 A 將會被阻塞,直到當前線程使用數據並釋放讀鎖之後,線程 A 才能獲取寫鎖進行數據更新。


P11:AQS 隊列同步器

隊列同步器是用來構建鎖或者其他同步組件的基礎框架,它使用了一個 int 類型的成員變量表示同步狀態,通過內置的 FIFO 隊列來完成資源獲取線程的排隊工作。

使用方式

同步器的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態,在抽象方法的實現過程中免不了要對同步狀態進行更改,這時就需要使用同步器提供的3個方法 getState、setState 和 compareAndSetState 來進行操作,因爲它們能夠保證狀態的改變是安全的。子類推薦被定義爲自定義同步組件的靜態內部類,同步器自身沒有實現任何同步接口,它僅僅是定義了若干同步狀態獲取和釋放的方法來供自定義同步組件使用,同步器既可以支持獨佔式地獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣就可以方便實現不同類型地同步組件。

和鎖的關係

同步器是實現鎖的關鍵,在鎖的實現中聚合同步器,利用同步器實現鎖的語義。鎖是面向使用者的,它定義了使用者與鎖交互的接口,隱藏了實現細節;同步器面向的是鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理、線程的排隊、等待與喚醒等底層操作。鎖和同步器很好地隔離了使用者和實現者所關注的領域。

同步隊列

AQS 中每當有新的線程請求資源時,該線程都會進入一個等待隊列,只有當持有鎖的線程釋放鎖資源後該線程才能持有資源。等待隊列通過雙向鏈表實現,線程會被封裝在鏈表的 Node 節點中,Node 的等待狀態包括:CANCELLED 表示線程已取消、SIGNAL 表示線程需要喚醒、CONDITION 表示線程正在等待、PROPAGATE 表示後繼節點會傳播喚醒操作,只會在共享模式下起作用。

兩種模式

獨佔模式表示鎖會被一個線程佔用,其他線程必須等到持有鎖的線程釋放鎖後才能獲取到鎖繼續執行,在同一時間內只能有一個線程獲取到這個鎖,ReentrantLock 就採用的是獨佔模式。

共享模式表示多個線程獲取同一個鎖的時候有可能會成功,ReadLock 就採用的是共享模式。

獨佔模式通過 acquire 和 release 方法獲取和釋放鎖,共享模式通過 acquireShared 和 releaseShared 方法獲取和釋放鎖。

獨佔式的獲取和釋放流程

在獲取同步狀態時,同步器調用 acquire 方法,維護一個同步隊列,使用 tryAcquire 方法安全地獲取線程同步狀態,獲取狀態失敗的線程會構造同步節點並通過 addWaiter 方法被加入到同步隊列的尾部,並在隊列中進行自旋。之後會調用 acquireQueued 方法使得該節點以死循環的方式獲取同步狀態,如果獲取不到則阻塞節點中的線程,而被阻塞線程的喚醒主要依靠前驅節點的出隊或阻塞節點被中斷實現,移出隊列或停止自旋的條件是前驅節點是頭結點並且成功獲取了同步狀態。

在釋放同步狀態時,同步器調用 tryRelease 方法釋放同步狀態,然後調用 unparkSuccessor 方法(該方法使用 LockSupport 喚醒處於等待狀態的線程)喚醒頭節點的後繼節點,進而使後繼節點重新嘗試獲取同步狀態。

只有當前驅節點是頭節點時才能夠嘗試獲取同步狀態原因

頭節點是成功獲取到同步狀態的節點,而頭節點的線程釋放同步狀態之後,將會喚醒其後繼節點,後繼節點的線程被喚醒後需要檢查自己的前驅節點是否是頭節點。

維護同步隊列的FIFO原則,節點和節點在循環檢查的過程中基本不相互通信,而是簡單地判斷自己的前驅是否爲頭節點,這樣就使得節點的釋放規則符合FIFO,並且也便於對過早通知的處理(過早通知是指前驅節點不是頭結點的線程由於中斷而被喚醒)。

共享式的獲取和釋放流程

在獲取同步狀態時,同步器調用 acquireShared 方法,該方法調用 tryAcquireShared 方法嘗試獲取同步狀態,返回值爲 int 類型,當返回值大於等於 0 時表示能夠獲取到同步狀態。因此在共享式獲取鎖的自旋過程中,成功獲取到同步狀態並退出自旋的條件就是該方法的返回值大於等於0。

釋放同步狀態時,調用 releaseShared 方法,釋放同步狀態後會喚醒後續處於等待狀態的節點。對於能夠支持多線程同時訪問的併發組件,它和獨佔式的主要區別在於 tryReleaseShared 方法必須確保同步狀態安全釋放,一般通過循環和 CAS 來保證,因爲釋放同步狀態的操作會同時來自多個線程。


P12:線程

現代操作系統在運行一個程序時會爲其創建一個進程,而操作系統調度的最小單位是線程,線程也叫輕量級進程。在一個進程中可以創建多個線程,這些線程都擁有各自的計數器、堆棧和局部變量等屬性,並且能夠訪問共享的內存變量。處理器在這些線程上告訴切換,讓使用者感覺到這些線程在同時執行。

生命週期

①NEW:初始狀態,創建後還沒有啓動的線程處於這種狀態,此時還沒有調用 start 方法。②RUNNABLE:Java 線程將操作系統中的就緒和運行兩種狀態統稱爲 RUNNABLE,此時線程有可能正在等待操作系統分配CPU時間片,也有可能正在執行③BLOCKED:阻塞狀態,阻塞狀態與等待狀態的區別是阻塞狀態在等待一個排它鎖,在程序等待進入同步區域時線程將進入這種狀態。④WAITING:等待狀態,表示線程進入等待狀態,處於該狀態的線程不會被分配CPU時間片,進入該狀態表示當前線程需要等待其他線程通知或中斷。會導致線程陷入等待狀態的方法:沒有設置超時參數的wait 和 join方法、LockSupport的 park 方法。⑤TIME_WAITING:限期等待狀態,該狀態不同於 WAITING,可以在指定時間內自行返回。會導致線程陷入限期等待狀態的方法:設置了超時參數的 wait 和 join 方法、LockSupport 的 parkNanos 和 parkUntil 方法。⑥TERMINATED:終止狀態,表示當前線程已經執行完畢或異常退出。

實現方式

①繼承 Thread 類並重寫 run方法。優點是實現簡單,缺點是不能繼承其他類,功能單一。②實現 Runnable 接口並重寫 run 方法,並將該實現類作爲參數傳入 Thread 構造器。優點是避免了單繼承的侷限性,適合多個相同程序代碼的線程共享一個資源,實現解耦操作,代碼和線程獨立。③實現 Callable 接口並重寫 call 方法,包裝成 FutureTask 對象並作爲參數傳入Thread構造器。優點是可以獲取線程執行結果的返回值。④可以通過線程池創建。

方法

① wait是Object類的方法,調用wait方法的線程會進入WAITING狀態,只有等待其他線程的通知或被中斷後纔會解除阻塞,調用wait方法會釋放鎖資源。② sleep 是 Thread 類的方法,調用 sleep 方法會導致當前線程進入休眠狀態,與 wait 不同的是該方法不會釋放鎖資源,進入的是 TIMED-WAITING 狀態。③ yiled 方法會使當前線程讓出 CPU 時間片給優先級相同或更高的線程,回到 RUNNABLE 狀態,與其他線程一起重新競爭CPU時間片。④ join 方法用於等待其他線程運行終止,如果當前線程調用了另一個線程的 join 方法,則當前線程進入阻塞狀態,當另一個線程結束時當前線程才能從阻塞狀態轉爲就緒態,等待獲取CPU時間片。底層使用的是wait,也會釋放鎖。

守護線程

守護線程是一種支持型線程,因爲它主要被用作程序中後臺調度以及支持性工作,當 JVM 中不存在非守護線程時,JVM 將會退出,可以通過 setDaemon(true) 將線程設置爲daemon線程,但必須在線程啓動之前設置。守護線程被用於完成支持性工作,但是在 JVM 退出時守護線程中的 finally 塊並不一定會被執行,因爲當 JVM 中沒有非守護線程時需要立即退出,所有守護線程都將立即終止,因此不能依靠 finally 確保執行關閉或清理資源的邏輯。


P13:線程間通信

通信是指線程之間以何種機制來交換信息,在命令式編程中線程之間的通信機制有兩種,共享內存和消息傳遞。在共享內存的併發模型裏線程之間共享程序的公共狀態,通過寫-讀內存中的公共狀態進行隱式通信。在消息傳遞的併發模型裏線程之間沒有公共狀態,線程之間必須通過發送消息來顯示通信。Java 併發採用共享內存模型,線程之間的通信總是隱式進行,整個通信過程對程序員完全透明。

volatile 和 synchronized 關鍵字

volatile 可以修飾字段,告知程序任何對該變量的訪問均需要從共享內存中獲取,而對它的改變必須同步刷新回主內存,它能保證所有線程對變量訪問的可見性。

synchronized 可以修飾方法或以同步塊的形式使用,它主要確保多個線程在同一個時刻只能有一個線程處於方法或同步塊中,保證了線程對變量訪問的可見性和排他性。

等待/通知機制

等待通知機制是指一個線程 A 調用了對象 O 的 wait 方法進入等待狀態,而另一個線程 B 調用了對象 O 的 notify 或 notifyAll 方法,線程 A 收到通知後從 wait 方法返回,進而執行後序操作。兩個線程通過對象 O 完成交互,對象上的 wait 和 notify/notifyAll 就如同開關信號,用來完成等待方和通知方之間的交互工作。

管道 IO 流

管道 IO 流和普通文件IO 流或網絡 IO 流的不同之處在於它主要用於線程之間的數據傳輸,傳輸的媒介爲內存。管道流主要包括了四種具體實現:PipedOutputStream、PipedInputStream、PipedReader 和 PipedWriter,對於 Piped 類型的流必須要通過 connect 方法進行綁定。

Thread.join

如果一個線程執行了某個線程的 join 方法,這個線程就會阻塞等待執行了 join 方法的線程終止之後才返回,這裏涉及了等待/通知機制。join 方法的底層是通過 wait 方法實現的,當線程終止時會調用自身的 notifyAll 方法,通知所有等待在該線程對象上的線程。

ThreadLocal

ThreadLoacl 是線程變量,內部以 ThreadLocal 爲鍵,任意對象爲值的存儲結構實現。該結構綁定在每個線程上,存儲的值在每個線程中都是一個唯一副本,每個線程可以通過 ThreadLocal 對象訪問自己唯一的值。

這種存儲結構叫 ThreadLocalMap ,是 ThreadLocal 的一個靜態內部類,是一個弱引用集合,它的存值、取值實現類似於 HashMap,使用 set 設置值,使用 get 獲取值。使用弱引用的目的是爲了節約資源,如果執行過程中發生了 GC,ThreadLocal 是 null 但由於 ThreadLocalMap 生命週期和線程一樣,不會被回收,這時候就會導致 ThreadLocalMap 的 key 不存在而 value 還在的內存泄漏問題,解決辦法是使用完 ThreadLocal 後執行remove操作。


P14:ConcurrentHashMap

JDK 8 之前

ConcurrentHashMap 用於解決 HashMap 的線程不安全和 HashTable 的併發效率低下問題,HashTable 之所以效率低下是因爲所有線程都必須競爭同一把鎖,假如容器裏有多把鎖,每一把鎖用於鎖容器一部分數據,那麼多線程訪問容器不同數據段的數據時線程間就不會存在鎖競爭,從而有效提高併發效率,這就是 ConcurrentHashMap 的鎖分段技術。首先將數據分成 Segment 數據段,然後給每一個數據段配一把鎖,當一個線程佔用鎖訪問其中一個段的數據時,其他段的數據也能被其他線程訪問。

get 操作實現簡單高效,先經過一次再散列,然後使用這個散列值通過散列運算定位到 Segment,再通過散列算法定位到元素。get 的高效在於這個過程不需要加鎖,除非讀到空值纔會加鎖重讀。get 方法裏將要使用的共享變量都定義爲 volatile 類型,volatile 保證了多線程的可見性,可以多線程讀,但是隻能保證單線程寫,在 get 操作裏只需要讀所以不用加鎖。

put 操作必須加鎖,put 方法首先定位到 Segment,然後進行插入操作,第一步判斷是否需要對 Segment 裏的 HashEntry 數組進行擴容,第二步定位添加元素的位置,然後將其放入數組。

size 操作用於統計元素的數量,必須統計每個 Segment 的大小然後求和,在統計結果累加的過程中,之前累加過的 count 變化的機率很小,因此 ConcurrentHashMap 的做法是先嚐試兩次通過不加鎖的方式統計結果,如果統計過程中容器大小發生了變化則再通過加鎖的方式統計所有 Segment 的大小。判斷容器是否發生變化是根據 modCount 變量確定的。

JDK 8 開始

JDK 8 的實現摒棄了 Segment 分段概念,使用 Synchronized 和 CAS 來保證線程安全。

get 操作同樣不需要同步控制,put 操作時如果沒有出現哈希衝突,就使用 CAS 方式來添加元素,如果出現了哈希衝突就使用 synchronized 加鎖的方式添加元素。


P15:CAS 操作

CAS 表示 Compare And Swap,比較並交換,CAS 需要三個操作數,分別是內存位置 V、舊的預期值 A 和準備設置的新值 B。CAS 指令執行時,當且僅當 V 符合 A 時,處理器纔會用 B 更新 V 的值,否則它就不執行更新。但不管是否更新都會返回 V 的舊值,這些處理過程是原子操作,執行期間不會被其他線程打斷。

在 JDK 5 後,Java 類庫中才開始使用 CAS 操作,該操作由 Unsafe 類裏的 compareAndSwapInt 等幾個方法包裝提供。HotSpot 在內部對這些方法做了特殊處理,即時編譯的結果是一條平臺相關的處理器 CAS 指令。Unsafe 類不是給用戶程序調用的類,因此在 JDK 9 之前只有 Java 類庫可以使用 CAS,譬如 juc 包裏的 AtomicInteger類中 compareAndSet 等方法都使用了Unsafe 類的 CAS 操作來實現。

儘管 CAS 既簡單又高效,但這種操作無法涵蓋互斥同步的所有場景,並且 CAS 從語義上來說存在一個邏輯漏洞:如果 V 初次讀取的時候是 A,並且在準備賦值的時候檢查到它的值仍爲 A,這依舊不能說明它的值沒有被其他線程更改過,因爲這段時間內假設它的值先改爲了 B 又改回 A,那麼 CAS 操作就會誤認爲它從來沒有被改變過。這個漏洞稱爲 ABA 問題,juc 包提供了一個 AtomicStampedReference,原子更新帶有版本號的引用類型,它可以通過控制變量值的版本來解決 ABA 問題。這個類並不常用,大部分情況下 ABA 問題不會影響程序併發的正確性,如果需要解決該問題,改用傳統的互斥同步可能會比原子類更高效。


P16:原子操作類

Java 從 JDK 5 開始提供了 java.util.concurrent.atomic 包,這個包中的原子操作類提供了一種用法簡單、性能高效、線程安全地更新一個變量的方式。到 JDK 8 該包共有17個類,依據作用分爲四種:原子更新基本類型類、原子更新數組類、原子更新引用類以及原子更新字段類,atomic 包裏的類基本都是使用 Unsafe 實現的包裝類。

原子更新基本類型

AtomicInteger 原子更新整形、 AtomicLong 原子更新長整型、AtomicBoolean 原子更新布爾類型。

getAndIncrement 以原子方式將當前的值加 1,首先在 for 死循環中取得 AtomicInteger 裏存儲的數值,第二步對 AtomicInteger 當前的值進行加 1 操作,第三步調用 compareAndSet 方法進行原子更新,該操作先檢查當前數值是否等於 expect,如果等於則說明當前值沒有被其他線程修改,則將值更新爲 next,否則會更新失敗返回 false,程序會進入 for 循環重新進行 compareAndSet 操作。

atomic 包中只提供了 三種基本類型的原子更新,atomic 包裏的類基本都是使用 Unsafe 實現的,Unsafe 只提供三種 CAS 方法:compareAndSwapInt、compareAndSwapLong 和 compareAndSwapObject,例如原子更新 Boolean 時是先轉成整形再使用 compareAndSwapInt 進行 CAS,所以原子更新 char、float、double 也可以用類似思路實現。

原子更新數組

AtomicIntegerArray,原子更新整形數組裏的元素、 AtomicLongArray 原子更新長整型數組裏的元素、 AtomicReferenceArray 原子更新引用類型數組裏的元素。

原子更新引用

AtomicReference 原子更新引用類型、AtomicMarkableReference 原子更新帶有標記位的引用類型,可以綁定一個 boolean 類型的標記位、 AtomicStampedReference 原子更新帶有版本號的引用類型,關聯一個整數值用於原子更新數據和數據的版本號,可以解決 ABA 問題。

原子更新字段

AtomicIntegerFieldUpdater 原子更新整形字段的更新器、 AtomicLongFieldUpdater 原子更新長整形字段的更新器AtomicReferenceFieldUpdater 原子更新引用類型字段的更新器。

由於原子更新字段類都是抽象類,每次使用的時候必須使用靜態方法 newUpdater 創建一個更新器,並且需要設置想要更新的類和字段。並且更新類的字段必須使用 public volatile 修飾。

JDK 8 更新的類

DoubleAccumulator 、 LongAccumulator、DoubleAdder、LongAdder、Striped64。


P17:併發工具類

等待多線程完成的 CountDownLatch

CountDownLatch 允許一個或多個線程等待其他線程完成操作,構造器接收一個 int 類型的參數作爲計數器,如果要等待 n 個點就傳入 n。每次調用 countDown 方法時計數器減 1,await 方法會阻塞當前線程直到計數器變爲0,由於 countDown方法可用在任何地方,所以 n 個點既可以是 n 個線程也可以是一個線程裏的 n 個執行步驟。

同步屏障 CyclicBarrier

同步屏障的作用是讓一組線程到達一個屏障或同步點時被阻塞,直到最後一個線程到達屏障時,屏障纔會解除,所有被攔截的線程纔會繼續運行。構造器中的參數表示屏障攔截的線程數量,每個線程調用 await 方法告訴 CyclicBarrier 自己已到達屏障,然後當前線程被阻塞。還支持在構造器中傳入一個 Runable 類型的任務,當線程到達屏障時會優先執行該任務。適用於多線程計算數據,最後合併計算結果的應用場景。

CountDownLacth 的計數器只能用一次,而 CyclicBarrier 的計數器可使用 reset 方法重置,所以 CyclicBarrier 能處理更爲複雜的業務場景,例如計算錯誤時可用重置計數器重新計算。

控制併發線程數的 Semaphore

信號量用來控制同時訪問特定資源的線程數量,它通過協調各個線程以保證合理使用公共資源。信號量可以用於流量控制,特別是公共資源有限的應用場景,比如數據庫連接。Semaphore 的構造器參數接收一個 int 值,表示可用的許可數量即最大併發數。使用acquire 方法獲得一個許可證,使用 release 方法歸還許可,還可以用 tryAcquire 嘗試獲得許可。

線程間交換數據的 Exchanger

交換者是用於線程間協作的工具類,用於進行線程間的數據交換。它提供一個同步點,在這個同步點兩個線程可以交換彼此的數據。這兩個線程通過 exchange 方法交換數據,如果第一個線程先執行exchange方法它會阻塞等待第二個線程執行exchange方法,當兩個線程都到達同步點時這兩個線程就可以交換數據,將本線程生產出的數據傳遞給對方。應用場景包括遺傳算法、校對工作等。


P18:線程池

好處

① 降低資源消耗,複用已創建的線程降低線程創建和消耗的開銷。② 提高響應速度,當任務到達時,任務可以不需要等到線程創建就可以立即執行。③ 提高線程的可管理性,線程是稀缺資源,如果無限制地創建不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一分配、調優和監控。

當提交一個新任務到線程池時的處理流程

① 判斷核心線程池裏的線程是否都在執行任務,如果不是則創建一個新的工作線程來執行任務,此時 workCount < corePoolSize,這一步需要獲取全局鎖。② 如果核心線程池已滿,判斷工作隊列是否已滿,如果沒有就將新提交的任務存儲在工作隊列中,此時 workCount >= corePoolSize。③ 如果工作隊列已滿,判斷線程池的線程是否都處於工作狀態,如果沒有則創建一個新的工作線程來執行任務,此時 workCount < maximumPoolSize,這一步也需要獲取全局鎖。④ 如果線程池已滿,按照拒絕策略來處理任務,此時 workCount > maximumPoolSize。

線程池採取這種設計思路是爲了在執行 execute 方法時儘可能地避免獲取全局鎖,在線程池完成預熱之後,即當前運行的線程數大於等於corePoolSize 時,幾乎所有的 execute 方法都是執行步驟 2,不需要獲取全局鎖。

線程池創建線程時,會將線程封裝成工作線程 Worker,Worker 在執行完任務後還會循環獲取工作隊列中的任務來執行。線程池中的線程執行任務分爲兩種情況:①在 execute 方法中創建一個線程時會讓這個線程執行當前任務。②這個線程執行完任務之後,就會反覆從工作隊列中獲取任務並執行。

可以使用 execute 和 submit 方法向線程池提交任務。execute 用於提交不需要返回值的任務,所以無法判斷任務是否被線程池執行成功了。submit 用於提交需要返回值的任務,線程池會返回一個 Future 類型的對象,通過該對象可以判斷任務是否執行成功,並且可以通過該對象的 get 方法獲取返回值,get 會阻塞當前線程直到任務完成,帶超時參數的 get 方法會在阻塞當前線程一段時間後立即返回,這時任務可能還沒有完成。

創建線程池的參數

① **corePoolSize:**線程池的基本大小,當提交一個任務到線程池時,線程池會創建一個線程來執行任務,即使其他空閒的基本線程能夠執行新任務也會創建線程,等到需要執行的任務數大於線程池的基本大小時就不再創建。② **workQueue:**工作隊列,用於保存等待執行任務的阻塞隊列。③ **maximumPoolSize:**線程池最大數量,如果工作隊列已滿,並且創建的線程數小於最大線程數,則線程池還會創建新的線程執行任務,如果使用無界阻塞隊列該參數無意義。④ **threadFactory:**用於設置創建線程的工廠,可以通過線程工廠給每個創建出來的線程設置更有意義的名字。⑤ **handler:**拒絕策略,默認策略下使用 AbortPolicy 直接拋出異常,CallerRunsPolicy 表示重新嘗試提交該任務,DiscardOldestPolicy 表示拋棄隊列裏最近的一個任務並執行當前任務,DiscardPolicy 表示直接拋棄當前任務不處理。⑥ **keepAliveTime:**線程活動的保持時間,線程池工作線程空閒後保持存活的時間,所以如果任務很多,且每個任務的執行時間較短,可以調大時間提高線程的利用率。⑦ **unit:**線程活動保持時間的單位。

關閉線程池

可以通過調用 shutdown 或 shutdownNow 方法關閉線程池,原理是遍歷線程池中的工作線程,然後逐個調用線程的 interrupt 方法中斷線程,所以無法響應中斷的任務可能永遠無法終止。區別是 shutdownNow 首先將線程池的狀態設爲 STOP,然後嘗試停止所有正在執行或暫停任務的線程,並返回等待執行任務的列表,而 shutdown 只是將線程池的狀態設爲 SHUTDOWN,然後中斷所有沒有正在執行任務的線程。通常調用 shutdown 來關閉線程池,如果任務不一定要執行完則可以調用 shutdownNow。

合理設置線程池

首先可以從以下角度分析:①任務的性質:CPU密集型任務、IO密集型任務和混合型任務。②任務的優先級:高、中和低。③任務的執行時間:長、中和短。④任務的依賴性:是否依賴其他系統資源,如數據庫連接。

性質不同的任務可以用不同規模的線程池分開處理,CPU密集型任務應配置儘可能小的線程,如配置 Ncpu+1 個線程的線程池。由於IO密集型任務線程並不是一直在執行任務,則應配置儘可能多的線程,如 2 * Ncpu。混合型的任務,如果可以拆分,將其拆分爲一個 CPU 密集型任務和一個 IO 密集型任務,只要這兩個任務執行的時間相差不是太大那麼分解後的吞吐量將高於串行執行的吞吐量,如果相差太大則沒必要分解。

優先級不同的任務可以使用優先級隊列 PriorityBlockingQueue 處理。

執行時間不同的任務可以交給不同規模的線程池處理,或者使用優先級隊列讓執行時間短的任務先執行。

依賴數據庫連接池的任務,由於線程提交 SQL 後需要等待數據庫返回的結果,等待的時間越長 CPU 空閒的時間就越長,因此線程數應該儘可能地設置大一些提高CPU的利用率。

建議使用有界隊列,能增加系統的穩定性和預警能力,可以根據需要設置的稍微大一些。


P19:阻塞隊列

阻塞隊列支持阻塞的插入和移除,當隊列滿時,阻塞插入元素的線程直到隊列不滿。當隊列爲空時,獲取元素的線程會被阻塞直到隊列非空。阻塞隊列常用於生產者和消費者的場景,阻塞隊列就是生產者用來存放元素,消費者用來獲取元素的容器。

Java中的阻塞隊列

ArrayBlockingQueue,由數組組成的有界阻塞隊列,默認情況下不保證線程公平,有可能先阻塞的線程最後才訪問隊列。

LinkedBlockingQueue,由鏈表結構組成的有界阻塞隊列,隊列的默認和最大長度爲 Integer 的最大值。

PriorityBlockingQueue,支持優先級排序的無界阻塞隊列,默認情況下元素按照順序升序排序。可以自定義 compareTo 方法指定元素排序規則,或者初始化時指定 Comparator 對元素排序,不能保證同優先級元素的順序。

DelayQueue,支持延時獲取元素的無界阻塞隊列,使用優先級隊列實現。創建元素時可以指定多久才能從隊列中獲取當前元素,只有延遲期滿時才能從隊列中獲取元素,適用於緩存系統和定時任務調度。

SynchronousQueue,不存儲元素的阻塞隊列,每一個 put 操作必須等待一個 take 操作。默認使用非公平策略,也支持公平策略,適用於傳遞性場景,吞吐量高於 ArrayBlockingQueue 和 LinkedBlockingQueue。

LinkedTransferQueue,由鏈表組成的無界阻塞隊列,相對於其他阻塞隊列多了 tryTransfer 和 transfer 方法。transfe方法:如果當前有消費者正在等待接收元素,可以把生產者傳入的元素立刻傳輸給消費者,如果沒有消費者等待接收元素,會將元素放在隊列的尾節點並等到該元素被消費者消費了才返回。tryTransfer 方法用來試探生產者傳入的元素能否直接傳給消費者,如果沒有消費者等待接收元素則返回 false,和transfer 的區別是無論消費者是否消費都會立即返回。

LinkedBlockingDeque,由鏈表組成的雙向阻塞隊列,可以從隊列的兩端插入和移出元素,在多線程同時入隊時減少了競爭。

實現原理

使用通知模式實現,當生產者往滿的隊列裏添加元素時會阻塞生產者,當消費者消費了一個隊列中的元素後,會通知生產者當前隊列可用。例如 JDK 中的 ArrayBlockingQueue 使用了 Condition 實現。當往隊列裏插入一個元素,如果隊列不可用,那麼阻塞生產者主要通過LockSupport 的 park 方法實現,park 在不同的操作系統中使用不同的方式實現,在 Linux 下使用的是系統方法 pthread_cond_wait 實現。


P20:Executor 框架

Java 的線程既是工作單元,也是執行機制。從 JDK 5開始把工作單元與執行機制分離開來,工作單元包括 Runnable 和 Callable,而執行機制由 Exectuor 框架提供。

在 HotSpot 的線程模型中,Java 線程被一對一映射爲本地操作系統線程,Java 線程啓動時會創建一個本地操作系統線程,當該 Java線程終止時,這個操作系統線程也會被回收,操作系統會調度所有線程並將它們分配給可用的CPU。

在上層,Java 多線程程序通常把應用分解爲若干任務,然後使用用戶級的調度器即 Executor 框架將這些任務映射爲固定數量的線程;在底層,操作系統內核將這些線程映射到硬件處理器上。

Executor 框架主要由三部分組成:①任務,包括被執行任務需要實現的接口,Runnable 或 Callable。②任務的執行,包括任務執行機制的核心接口 Executor 以及繼承自 Executor 的 ExecutorService 。③異步計算的結果,包括接口 Future 和實現 Future 接口的 FutureTask 類。

ThreadPoolExecutor

ThreadPoolExecutor 是 Executor框架最核心的類,是線程池的實現類,主要有三種。

① FixedThreadPool,可重用固定線程數的線程池,corePoolSize 和 maximumPoolSize都被設置爲創建時的指定參數 nThreads,當線程池中的線程數大於 corePoolSize 時,keepAliveTime 爲多餘的空閒線程等待新任務的最長時間,超過這個時間後的多餘線程將被終止,將其設置爲 0L 時多餘空閒線程將被立即終止。該線程池使用的工作隊列是無界阻塞隊列 LinkedBlockingQueue,適用於爲了滿足資源管理的需求,而需要限制當前線程數量的應用場景,適用於負載比較重的服務器。

② SingleThreadExecutor,使用單個線程的線程池,corePoolSize 和 maximumPoolSize都被設置爲 1,其他參數和 FiexedThreadPool相同。適用於需要保證順序執行各個任務,並且在任意時間點不會有多個線程是活動的的應用場景。

③ CachedThreadPool,一個根據需要創建線程的線程池,corePoolSize 被設置爲0,maximumPoolSize 被設置爲 Integer 最大值。該線程池使用的工作隊列是沒有容量的 SynchronousQueue,但由於 maximumPoolSize 設爲 Integer最大值,如果主線程提交任務的速度高於線程處理的速度,線程池會不斷創建新線程,極端情況下會創建過多線程而耗盡CPU和內存資源。適用於執行很多短期異步任務的小程序,或者負載較輕的服務器。

ScheduledThreadPoolExecutor

繼承自 ThreadPoolExecutor,主要用來在給定的延遲之後運行任務,或者定期執行任務。其功能與 Timer 類似但更強大和靈活。Timer對應的是單個後臺線程,而 ScheduledThreadPoolExecutor 可以在構造器中指定多個對應的後臺線程數。

主要有兩種:① ScheduledThreadPool:創建固定線程個數的線程池,適用於需要多個後臺線程執行週期任務,同時需要限制後臺線程數量的應用場景。② SingleThreadScheduledExecutor:只包含一個線程的線程池,適用於單個後臺線程執行週期任務,同時需要保證順序執行任務的應用場景。

實現原理是將待調度任務放入一個DelayQueue 中,調度任務主要有三個 long 類型的參數,time 表示這個任務將要被執行的具體時間,sequenceNumber 表示這個任務被添加到線程池的序號,period 表示任務執行時間間隔。DelayQueue封裝了一個PriorityQueue,隊列按照 time 進行排序,如果相同則比較sequenceNumber,越小的排在前面,即如果兩個任務的執行時間相同,先提交的任務先被執行。

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