Java集合之ConcurrentHashMap解析
上一篇介紹了HashMap的資料結構:陣列+單鏈表(jdk 1.8,當連結串列長度達到8後,連結串列將會被轉換為紅黑樹結構)。日常開發中我們經常使用,隨著業務規模、場景的不斷複雜發展,多執行緒開發越來越多的進入到我們日常開發中,那麼問題就來了,HashMap是執行緒安全的嗎?答案是否定的,保證HashMap的執行緒安全需要我們開發中自行維護。那麼有沒有執行緒安全的集合框架呢?答案是肯定的,java.util包下的HashTab類,就是一種執行緒安全的Map容器。
HashTabe
為了更快速的理解HashTabe,接下來就結合HashMap做下對比,幫助我們更直觀的認識。
1、HashTabe預設的容量是11,而HashMap是16
2、HashTabe陣列表是一旦建立就構造,屬於餓漢模式,而HashMap是在第一次put時的resize構造
3、HashTabe資料結構是陣列+單向連結串列,而HashMap則是陣列+單向連結串列+紅黑樹
4、HashTabe中連結串列Node節點採用頭插法,而HashMap則是採用尾插法
5、HashTabe通過對put、get、remove、size等方法新增synchronized關鍵字保證執行緒安全,而HashMap本身並沒有保證執行緒安全的相關處理,需要業務使用時自行保障
6、HashTabe的鍵值均不能為null,而HashMap支援鍵值為null
下面我們看一下Hashtabe的原始碼,驗證一下我們上面提到的內容,首先我們看一下構造方法:
public Hashtable(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal Load: "+loadFactor);if (initialCapacity==0) initialCapacity = 1; this.loadFactor = loadFactor;
// 對陣列進行初始化 table = new Entry<?,?>[initialCapacity]; // 擴容閾值 = 陣列容量 * 負載係數;最大值為:0x7fffffff - 8 + 1 threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1); } public Hashtable(int initialCapacity) { this(initialCapacity, 0.75f); } public Hashtable() { this(11, 0.75f); } public Hashtable(Map<? extends K, ? extends V> t) { this(Math.max(2*t.size(), 11), 0.75f); putAll(t); }
通過原始碼我們可以看到,無參構造方法中,系統預設為我們定義了陣列的容量和負載係數,並且在呼叫構造方法時,系統會預設為我們建立初始陣列,這裡和HashMap有所不同,大家可以做下對比,便於更好的記憶。
下面我們以put方法為例,分析一下上面我們提到的幾個點
public synchronized V put(K key, V value) { // 檢查值value是否為空 if (value == null) { throw new NullPointerException(); } // 檢查鍵是否存在於hash表中 Entry<?,?> tab[] = table; // 鍵不能為空,否則會導致空指標 int hash = key.hashCode(); // 這裡獲取key陣列下標有別於HashMap int index = (hash & 0x7FFFFFFF) % tab.length; @SuppressWarnings("unchecked") Entry<K,V> entry = (Entry<K,V>)tab[index]; // 遍歷陣列當前節點的單鏈表查詢鍵是否已存在 for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; // 鍵值存在直接更新,並返回原鍵值 entry.value = value; return old; } } // 當鍵不存在時,將鍵值插入指定連結串列中 addEntry(hash, key, value, index); return null; } private void addEntry(int hash, K key, V value, int index) { modCount++; // 檢查陣列長度是否達到閾值,達到閾值對陣列進行擴容 Entry<?,?> tab[] = table; if (count >= threshold) { // Rehash the table if the threshold is exceeded rehash(); tab = table; hash = key.hashCode(); // 陣列擴容後,以新陣列長度計算鍵的陣列下標 index = (hash & 0x7FFFFFFF) % tab.length; } // 以鍵值建立新的Node節點,將陣列該位置的原頭節點,設定為新節點的next @SuppressWarnings("unchecked") Entry<K,V> e = (Entry<K,V>) tab[index]; tab[index] = new Entry<>(hash, key, value, e); count++; } protected void rehash() { int oldCapacity = table.length; Entry<?,?>[] oldMap = table; // 建立一個數組容量擴大2倍 + 1的新陣列 int newCapacity = (oldCapacity << 1) + 1; if (newCapacity - MAX_ARRAY_SIZE > 0) { if (oldCapacity == MAX_ARRAY_SIZE) // Keep running with MAX_ARRAY_SIZE buckets return; newCapacity = MAX_ARRAY_SIZE; } Entry<?,?>[] newMap = new Entry<?,?>[newCapacity]; modCount++; // 更新新陣列的閾值 threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1); table = newMap; // 迴圈遍歷進行陣列資料遷移 for (int i = oldCapacity ; i-- > 0 ;) { for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) { Entry<K,V> e = old; old = old.next; int index = (e.hash & 0x7FFFFFFF) % newCapacity; e.next = (Entry<K,V>)newMap[index]; newMap[index] = e; } } }
到這裡大家應該對於Hashtable已經有了一個清晰的認識了,這裡提到synchronized關鍵字,我們知道synchronized有兩個維度:1、類維度加鎖;2、物件維度加鎖,Hashtabe採用的是什麼維度呢?答案是物件維度加鎖。這樣做有產生什麼樣的問題呢?這要簡單聊一下多執行緒的使用場景,我們為什麼要用多執行緒?我們知道單執行緒下我們的任務是序列執行的,對於多CPU系統中,無法發揮多核心的優勢,使用多執行緒將一個任務拆分為並行的多個任務,在多CPU系統並行執行任務,從而提高任務的執行效率。那麼問題就來了,Hashtabe通過物件維度加鎖,當存在多個執行緒並行操作時,就會存在鎖競爭,這也是為什麼說HashTable慢的原因所在。synchronized關鍵字加鎖是對整個物件進行加鎖,也就是說在進行put等修改Hash表的操作時,鎖住了整個Hash表,從而使得其表現的效率低下。