一、Java 中的 DCL 單例模式
? 單例模式是設計模式中最常用的模式之一,其核心目標是確保一個類在程序中僅有一個實例,并提供全局訪問點。在 Java 中,實現單例模式需要兼顧線程安全和性能優化。DCL(Double-Checked Locking,雙重檢查鎖定)單例模式通過巧妙的鎖機制,在保證線程安全的同時顯著提升了性能,成為高并發場景下的首選方案。
DCL 單例模式實現代碼
public class DCLSingleton {// 使用volatile保證可見性和禁止指令重排private static volatile DCLSingleton instance;// 私有構造函數,禁止外部實例化private DCLSingleton() {}// 雙重檢查鎖定的核心方法public static DCLSingleton getInstance() {// 第一次檢查:避免無意義的同步if (instance == null) {synchronized (DCLSingleton.class) { // 同步類鎖// 第二次檢查:確保唯一實例化if (instance == null) {instance = new DCLSingleton();}}}return instance;}
}
代碼解釋
volatile
?關鍵字:instance
?變量使用?volatile
?修飾,它有兩個重要作用。一是保證變量的可見性,即一個線程修改了該變量的值,其他線程能立即看到最新的值。二是禁止指令重排,避免在多線程環境下,new DCLSingleton()
?操作可能出現的問題,如對象還未完全初始化就被其他線程使用。- 私有構造函數:
private DCLSingleton()
?阻止了外部代碼通過?new
?關鍵字創建該類的實例。 - 雙重檢查鎖:
- 第一次檢查?
if (instance == null)
:在沒有進入同步塊之前先檢查?instance
?是否為?null
,如果不為?null
?則直接返回實例,避免了每次調用?getInstance()
?方法都進行同步操作,提高了性能。 - 同步塊?
synchronized (DCLSingleton.class)
:當多個線程同時通過第一次檢查時,會進入同步塊。 - 第二次檢查?
if (instance == null)
:在同步塊內再次檢查?instance
?是否為?null
,因為可能在第一個線程進入同步塊創建實例的過程中,其他線程也通過了第一次檢查并等待進入同步塊。如果沒有第二次檢查,可能會創建多個實例。
- 第一次檢查?
具體擴展:
1. 第一次檢查?if (instance == null)
- 目的:提高性能。在單例模式中,
getInstance()
?方法可能會被頻繁調用。如果每次調用都進行同步操作(即進入?synchronized
?塊),會帶來較大的性能開銷,因為同步操作涉及到線程的阻塞和喚醒,是比較耗時的。通過在進入同步塊之前先檢查?instance
?是否為?null
,如果不為?null
,說明單例實例已經被創建,直接返回該實例,無需進行同步操作,這樣就避免了不必要的同步開銷,提高了程序的性能。 - 多線程情況分析:在多線程環境下,多個線程可能同時調用?
getInstance()
?方法。由于此時還未進入同步塊,多個線程可能都會通過這第一次檢查,認為?instance
?為?null
,從而繼續執行后續代碼進入同步塊。
2. 同步塊?synchronized (DCLSingleton.class)
- 目的:保證線程安全。當多個線程同時通過第一次檢查后,為了避免多個線程同時創建單例實例,需要使用同步機制。這里使用?
synchronized
?關鍵字對?DCLSingleton
?類進行加鎖,確保同一時刻只有一個線程能夠進入同步塊執行后續代碼。 - 鎖的范圍:使用類級別的鎖?
DCLSingleton.class
,這意味著所有線程在訪問這個同步塊時都需要競爭同一把鎖。這樣可以保證在同一時刻只有一個線程能夠執行同步塊內的代碼,從而避免多個線程同時創建多個單例實例。
3. 第二次檢查?if (instance == null)
- 目的:防止創建多個實例。雖然通過第一次檢查和同步塊的機制,理論上可以保證單例實例的唯一性,但在多線程環境下仍存在一個問題。假設線程 A 和線程 B 同時通過了第一次檢查,線程 A 先進入同步塊創建了單例實例,然后線程 B 獲得鎖進入同步塊。如果沒有第二次檢查,線程 B 會再次創建一個新的實例,這就破壞了單例模式的原則。因此,在同步塊內再次檢查?
instance
?是否為?null
,如果為?null
?才創建實例,這樣就確保了單例實例的唯一性。 - 多線程情況分析:當線程 A 進入同步塊創建實例后,
instance
?不再為?null
。此時線程 B 進入同步塊,通過第二次檢查發現?instance
?不為?null
,就不會再創建新的實例,而是直接跳過創建實例的代碼。
二、Java HashMap 深度解析
? HashMap 是 Java 開發者最常用的數據結構之一,其底層基于哈希表實現,結合數組、鏈表和紅黑樹的特性,在大多數場景下都能提供高效的增刪查操作。本文將深入剖析 HashMap 的工作原理、核心實現細節及優化策略,幫助讀者理解其設計哲學與工程實踐。
工作原理
HashMap
?的核心工作原理可以概括為:通過哈希函數將鍵(key
)映射到數組的某個位置,當發生哈希沖突時,使用鏈表或紅黑樹來處理。其工作流程主要包含以下幾個步驟:
-
初始化:在創建?
HashMap
?對象時,會初始化一個數組(也稱為哈希桶),默認初始容量為 16,負載因子為 0.75。負載因子表示當數組中元素數量達到容量的一定比例時,會進行擴容操作。 -
插入元素:當調用?
put(key, value)
?方法插入鍵值對時,首先會計算鍵的哈希值,然后根據哈希值找到對應的數組位置。如果該位置為空,直接將鍵值對存儲在該位置;如果該位置已經有元素,說明發生了哈希沖突,此時會根據具體情況處理。在 JDK 8 及以后的版本中,如果鏈表長度達到 8 且數組長度達到 64,會將鏈表轉換為紅黑樹,以提高查找效率;如果紅黑樹節點數量小于 6,會將紅黑樹轉換回鏈表。 -
查找元素:當調用?
get(key)
?方法查找元素時,同樣會先計算鍵的哈希值,然后根據哈希值找到對應的數組位置。如果該位置只有一個元素,直接比較鍵是否相等;如果該位置是鏈表或紅黑樹,會在鏈表或紅黑樹中查找鍵對應的元素。 -
擴容:當數組中元素數量達到容量乘以負載因子時,會觸發擴容操作。擴容時會創建一個新的數組,容量為原來的 2 倍,然后將原數組中的元素重新哈希到新數組中。
具體計算過程
1. 計算哈希值
HashMap
?中使用?hash()
?方法計算鍵的哈希值,該方法會對鍵的?hashCode()
?方法返回的值進行二次哈希,以減少哈希沖突的概率。具體代碼如下:
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
這里?key.hashCode()
?是鍵對象的哈希碼,h >>> 16
?是將哈希碼右移 16 位,然后進行異或運算。這樣做的目的是將哈希碼的高位和低位進行混合,使得哈希值更加均勻地分布在數組中。
2. 計算數組索引
計算出哈希值后,需要根據哈希值計算數組的索引位置。HashMap
?中使用以下公式計算數組索引:
index = (n - 1) & hash
?其中?n
?是數組的長度,hash
?是鍵的哈希值。由于數組長度通常是 2 的冪次方,n - 1
?的二進制表示中低位都是 1,通過與哈希值進行按位與運算,可以確保計算出的索引值在數組的有效范圍內。
3. 處理數組位置的元素
- 位置為空:若該位置為空,就直接把鍵值對存儲在這個位置。
- 位置已有元素(發生哈希沖突):這時候要依據具體情形處理。
- JDK 8 之前:采用鏈表來處理哈希沖突。新插入的元素會被添加到鏈表的頭部,查找時需要遍歷鏈表,時間復雜度為?O(n),其中?n?是鏈表的長度。
- JDK 8 及以后:引入了紅黑樹來優化鏈表過長時的查找性能。
- 鏈表轉換為紅黑樹:當鏈表長度達到 8 并且數組長度達到 64 時,鏈表會被轉換為紅黑樹。紅黑樹是一種自平衡的二叉搜索樹,其查找、插入和刪除操作的時間復雜度為?O(logn),在處理大量數據時性能優于鏈表。
- 紅黑樹轉換為鏈表:當紅黑樹的節點數量小于 6 時,紅黑樹會被轉換回鏈表。這是為了避免在數據量較小時,紅黑樹的維護開銷過大。
以下是簡化版的?put
?方法代碼,用于說明插入元素的邏輯:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;if ((p = tab[i = (n - 1) & hash]) == null)// 位置為空,直接插入tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;else if (p instanceof TreeNode)// 是紅黑樹節點,插入到紅黑樹中e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {// 是鏈表節點,遍歷鏈表for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st// 鏈表長度達到 8,轉換為紅黑樹treeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;
}
- 性能優化:在數據量較小的時候,鏈表的插入和刪除操作比較簡單,開銷較小;而當數據量增大,鏈表長度變長時,查找效率會顯著下降,此時轉換為紅黑樹可以將查找時間復雜度從?O(n)?降低到?O(logn),提高整體性能。
- 空間與時間的平衡:紅黑樹的維護需要額外的空間和操作,在數據量較小時,使用鏈表可以減少空間開銷;當數據量達到一定程度時,使用紅黑樹可以在時間復雜度上獲得更好的表現。
總結:
? HashMap 基于哈希表,底層由數組、鏈表(JDK7)和紅黑樹(JDK8+)組成,通過哈希值快速定位存儲位置。通過key.hashCode()
與高位異或運算生成哈希值,結合數組長度(2 的冪)的位運算確定索引。沖突元素存入鏈表,當鏈表長度≥8 且數組長度≥64 時轉為紅黑樹,提升查找效率至 O (log n)。容量不足時(元素數超過閾值),數組擴容為原來的 2 倍,重新分配哈希桶并遷移元素。?合理設置初始容量和負載因子(默認 0.75),避免哈希沖突,多線程場景使用 ConcurrentHashMap。
擴展內容:
ConcurrentHashMap 的優勢
1. 線程安全
- 傳統?
HashMap
?問題:HashMap
?是非線程安全的,在多線程環境下進行讀寫操作可能會導致數據不一致、死循環等問題,例如在擴容時可能會形成環形鏈表。 ConcurrentHashMap
?解決方案:ConcurrentHashMap
?是線程安全的,它允許多個線程同時進行讀寫操作,不會出現數據不一致的問題。這使得開發者在多線程環境中無需額外的同步機制,降低了編程復雜度。
2. 高性能
- 細粒度鎖:在 JDK 7 及以前,
ConcurrentHashMap
?使用分段鎖機制,將整個哈希表分成多個段(Segment),每個段相當于一個小的?HashMap
,不同的段可以被不同的線程同時訪問,從而提高了并發性能。在 JDK 8 及以后,采用了 CAS(Compare-And-Swap)和 synchronized 相結合的方式,鎖的粒度進一步細化到節點級別,減少了鎖的競爭,提高了并發度。 - 并發操作:多個線程可以同時對不同的桶進行讀寫操作,大大提高了并發性能。例如,在高并發場景下,多個線程可以同時對不同的鍵值對進行插入、刪除和查找操作,而不會相互阻塞。
3. 內存效率高
- 減少鎖的開銷:由于采用了細粒度的鎖機制,減少了鎖的持有時間和范圍,降低了鎖的開銷,從而提高了內存的使用效率。
ConcurrentHashMap 的工作原理
JDK 7 及以前的分段鎖機制
- 數據結構:
ConcurrentHashMap
?內部包含一個?Segment
?數組,每個?Segment
?繼承自?ReentrantLock
,相當于一個小的?HashMap
。每個?Segment
?包含一個?HashEntry
?數組,用于存儲鍵值對。 - 并發控制:當進行讀寫操作時,首先根據鍵的哈希值找到對應的?
Segment
,然后對該?Segment
?加鎖。不同的?Segment
?可以被不同的線程同時訪問,從而實現了并發操作。例如,線程 A 對?Segment1
?進行寫操作,線程 B 可以同時對?Segment2
?進行寫操作,只要它們操作的是不同的?Segment
。
JDK 8 及以后的 CAS 和 synchronized 機制
- 數據結構:采用數組 + 鏈表 + 紅黑樹的結構,與?
HashMap
?類似。 - 并發控制
- CAS(Compare-And-Swap):在插入或更新節點時,首先使用 CAS 操作嘗試更新節點的值。CAS 是一種無鎖算法,它通過比較內存中的值和預期值是否相等來決定是否更新,避免了鎖的使用,提高了并發性能。例如,在插入一個新的節點時,首先使用 CAS 操作將新節點插入到指定的位置,如果 CAS 操作失敗,說明有其他線程已經修改了該位置的值,此時再使用 synchronized 進行加鎖操作。
- synchronized:當 CAS 操作失敗或需要對鏈表或紅黑樹進行操作時,使用 synchronized 對節點進行加鎖。在 JDK 8 中,synchronized 進行了優化,鎖的粒度細化到節點級別,減少了鎖的競爭。例如,當多個線程同時對同一個鏈表進行操作時,只會對鏈表的頭節點進行加鎖,其他線程可以繼續對其他鏈表進行操作。
示例代碼
?
import java.util.concurrent.ConcurrentHashMap;public class ConcurrentHashMapExample {public static void main(String[] args) {ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();// 插入元素map.put("apple", 1);map.put("banana", 2);// 獲取元素Integer value = map.get("apple");System.out.println("Value of apple: " + value);// 并發操作示例Thread t1 = new Thread(() -> {map.put("cherry", 3);});Thread t2 = new Thread(() -> {Integer val = map.get("banana");System.out.println("Value of banana in thread 2: " + val);});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Final map: " + map);}
}
?總結:ConcurrentHashMap
?通過分段鎖(JDK7)或 CAS+synchronized 細粒度鎖(JDK8)實現線程安全,允許多線程并發讀寫,性能遠超線程不安全的?HashMap
。底層采用數組 + 鏈表 / 紅黑樹結構(類似?HashMap
),通過哈希值定位節點,JDK8 后利用 CAS 無鎖算法和節點級鎖減少競爭,提升并發效率。適用于高并發場景(如分布式系統、緩存),提供線程安全的高效操作,避免?HashMap
?的多線程問題,同時比?Hashtable
?的全表鎖更輕量。
?感謝觀看!!!