1. 程式人生 > >HashMap的基本原理與它的執行緒安全性

HashMap的基本原理與它的執行緒安全性

1. 前言

能用圖說清楚的,就堅決不用程式碼。能用程式碼擼清楚的,就堅決不寫解釋(不是不寫註釋哦)。

以下所有僅針對JDK 1.7及之前中的HashMap。

2. 資料結構

HashMap內部通過維護一個Entry<K, V>陣列(變數為table),來實現其基本功能,而Entry<K, V>是HashMap的內部類,其主要作用便是儲存鍵值對,其資料結構大致如下圖所示。

Entry的資料結構

從Entry的資料結構可以看出,多個Entry是可以形成一個單向連結串列的,HashMap中維護的Entry<K, V>陣列(之後簡稱為Entry陣列,或table,容易區分)其實就是儲存的一系列Entry<K, V>連結串列的表頭。那麼HashMap中儲存資料table陣列的資料結構,大致可以如下圖所示(假設只有部分資料)。

HashMap的資料結構

注:Entry陣列的預設長度為16,負載因子為0.75。

將上圖中的每一行,稱為桶(bucket),那麼table的索引便是bucketIndex。而HashMap中的插入、獲取、刪除等操作最主要的便是對table和桶(bucket)的操作。下面將主要通過插入操作,看其資料結構的變化。

3. 插入

對於上圖中的資料結構,插入操作便是將要插入的鍵 - 值(key - value)對根據key計算hash值來選擇具體的儲存位置。

插入函式的原始碼如下(以Mark開頭的或者中文註釋,非JDK原始碼中的註釋,下同):

public V put(K key, V value) {
    // Mark A Begin
if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); // Mark A End int hash = hash(key); // 計算hash值 int i = indexFor(hash, table.length); // 計算桶的位置索引(bucketIndex) // Mark B begin for (Entry<K,V> e = table[i]; e != null
; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // Mark B end modCount++; // 記錄修改次數,迭代的時候會據此判斷是否有被修改 addEntry(hash, key, value, i); return null; }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

在上面的程式碼中,程式碼段A(Mark A Begin - Mark A End,下同)的主要作用是如果table為空則初始化陣列以及插入key為null時的操作,程式碼段B則是插入相同key時覆蓋原有的值,並返回原有的值。這裡重點關注的是addEntry(hash, key, value, i)方法。

addEntry方法原始碼如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 擴充table陣列的大小
        resize(2 * table.length);
        // 重新計算hash值
        hash = (null != key) ? hash(key) : 0;
        // 重新計算桶的位置索引
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

createEntry方法原始碼如下:

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 將新的Enrty元素插入到對應桶的表頭
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

Entry<>例項化的原始碼如下:

Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n; // 將原先桶的表頭向後移動
    key = k;
    hash = h;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

在整個插入操作中,有一個很重要的操作,便是對table陣列擴容,擴容的演算法相對簡單,但是在多執行緒下它卻容易引發一個執行緒安全的問題。

注:擴容需要會把原先table中的值移動到新的陣列中,再賦值給table變數,一個合適的初始大小和負載因子能夠提高效率。

4. 執行緒不安全

在多執行緒環境下,假設有容器map,其儲存的情況如下圖所示(淡藍色為已有資料)。

這裡寫圖片描述

此時的map已經達到了擴容閾值12(16 * 0.75 = 12),而此時執行緒A與執行緒B同時對map容器進行插入操作,那麼都需要擴容。此時可能出現的情況如下:執行緒A與執行緒B都進行了擴容,此時便有兩個新的table,那麼再賦值給原先的table變數時,便會出現其中一個newTable會被覆蓋,假如執行緒B擴容的newTable覆蓋了執行緒A擴容的newTable,並且是在A已經執行了插入操作之後,那麼就會出現執行緒A的插入失效問題,也即是如下圖中的兩個table只能有一個會最後存在,而其中一個插入的值會被捨棄的問題。

這裡寫圖片描述

這便是HashMap的執行緒不安全性,當然這只是其中的一點。而要消除這種隱患,則可以加鎖或使用HashTable和ConcurrentHashMap這樣的執行緒安全類,但是HashTable不被建議使用,推薦使用ConcurrentHashMap容器。