1. 程式人生 > >併發中的Map容器

併發中的Map容器

本文作者:王一飛,叩丁狼高階講師。原創文章,轉載請註明出處。  

上幾篇討論了併發環境下list容器的操作, 本篇我們來聊下另外一個集合容器:Map

家族體系

Map:以key-value對的形式存在,一種資料結構,一個key, 對映一個value值, map中不能包含重複的key值, 一個key最多隻能對映到一個值。
常用方法有:
新增: V put(K key, V value);
刪除: V remove(Object key);
修改: V put(K key, V value);
查詢: V get(Object key);

常見的實現類:
HashMap
LinkedHashMap
TreeMap
Hashtable
ConcurrentHashMap

HashMap

要研究HashMap在併發環境下使用, 先得了解hashmap實現原理.HashMap是基於雜湊表的 Map 介面的實現。(傳送門:雜湊表百度百科),其底層維護一個數組與連結串列.原始碼說話:
注意:
jdk8以前hashMap結構: 陣列 + 連結串列
jdk8以後hashMap結構: 陣列 + 連結串列 + 紅黑樹
此處原始碼使用的jdk8
原始碼:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    //初始容量, 預設16,俗稱桶, 可以認為陣列長度
    //一個桶對應一條連結串列
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //預設負載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //真實的負載因子, 不特意指定是等於0.75f;
    final float loadFactor;
    //閾值:所能容納kv對最大數量,超過這個值,則需擴容
    //規則: threshold = capacity * loadFactor
    int threshold;
    //雜湊表, 如果必要, 長度以2的n次方方式拓展
    transient Node<K,V>[] table;
}
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
}

HashMap運作規則:
JDK8 HashMap結構圖
1>HashMap 初始化時(不特意指定), 預設建立長度為16的一維陣列, 用於儲存Node(即kv對)/掛載連結串列投節點

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

2>map新增元素時map.put(key, value), 先通過雜湊演算法h = hash(key)計算出當前Node(key, value)在table陣列中位置, 如果table[h]位置為null.table[h] = Node(key, value), 如果有值, 稱之為碰撞, 則建立連結串列, 將當前Node(key, value) 拼接在連結串列後面.
3>JDK8的特性, 新增後,table某個位置的掛載連結串列個數大於等於TREEIFY_THRESHOLD=8時, 為提高查詢效率,則將該位置下的連結串列轉換成紅黑樹.

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) {
                        //建立新node
                        p.next = newNode(hash, key, value, null);
                        //如果node連結串列超過8
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                            //轉換成紅黑樹
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //如果map的容量大於threshold, map擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

4> 新增後, 如果Map 的的size > threshold 閾值, 則需要對map擴容, 演算法:

情況一:使用空參HashMap()構造器構建map/首次初始化
newSize = DEFAULT_INITIAL_CAPACITY = 16
newThreshold = newSize = oldSize loadFactor
情況二:非空參構造器建立Map物件/後續初始化
newSize = oldSize 
2 //擴容2倍
newThreshold = oldThreshold * 2

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 2倍
        }
        else if (oldThr > 0) 
            newCap = oldThr;
        else {               
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //....省略資料拷貝
}

到這大體瞭解HashMap底層實現, 細心的朋友應該可以看出來, hashMap所有的操作並沒有使用synchronized修飾, 也就說, hashMap在高併發的環境下存在明顯的執行緒安全問題.

場景1:多執行緒複合操作時
執行緒t1給map新增資料(key),而執行緒t2操作相同的key值, 先新增後獲取. 某一時刻, t2先新增,如果此時切換到t1執行, t1會覆蓋t2新增的資料, t2再次讀取時, 資料被修改了, 出現髒讀問題.

public class App {

    public static void main(String[] args) throws InterruptedException {
        final HashMap<String, String> map = new HashMap<>();

        Thread t1 = new Thread(new Runnable() {
            public void run() {
                map.put("key", "t1");
            }
        }, "t1");

        Thread t2 = new Thread(new Runnable() {
            public void run() {
                map.put("key", "t2");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(map.get("key"));  //t1
            }
        }, "t2");

        t2.start();
        Thread.sleep(2000);
        t1.start();

    }

}

場景2:多執行緒同時新增相同hash 碼值時
多個執行緒同時執行put操作時, 如果key的hash碼一樣時, 根據HashMap的實現,會有多個key新增到陣列的同一個位置,如果此位置已經被佔用,掛載新節點時,容易發生執行緒put的資料被覆蓋。

public  class User {
    private String name;

    public User(String name) {
        this.name = name;
    }

    //保證hashMap呼叫hash算出的hashcode一致
    public int hashCode() {
        return 1;
    }
}
public class App {
    public static void main(String[] args) throws InterruptedException {
        //final Hashtable<User, String> map = new Hashtable<>();
        final HashMap<User, String> map = new HashMap<>();
        //3個相同的執行緒, 同時往map中新增
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    map.put(new User(1 + ""), i + "t1");
                }
                System.out.println(Thread.currentThread().getName() + "..end...");
            }
        }, "t1").start();

        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    map.put(new User(1 + ""), i + "t1");
                }
                System.out.println(Thread.currentThread().getName() + "..end...");
            }
        }, "t2").start();


        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    map.put(new User(1 + ""), i + "t1");
                }
                System.out.println(Thread.currentThread().getName() + "..end...");
            }
        }, "t3").start();

        Thread.sleep(1000);
        System.out.println(map.size());
    }
}

JDK1.8計算的hash 碼演算法:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }

上面程式執行存在幾種結果:
1:結果為3000, 這是正確的
2:結果少於3000, 這就出現key值被覆蓋了.原因:連結串列node移位時,出現併發問題.
3:報異常, 類轉換異常.原因:前面說過,jdk8中hashmap同一個位置, 掛載大於等於8個節點會轉換成紅黑樹, 多個執行緒爭奪時,這個臨界點會出問題.

java.lang.ClassCastException: HashMap$Node cannot be cast to HashMap$TreeNode

4:報異常, 記憶體溢位異常, 這是場景3問題, map擴容造成的.

Exception in thread "t3" Exception in thread "t2" Exception in thread "t1" java.lang.StackOverflowError

場景3:多執行緒同時擴容時
多執行緒剛好同時對hashMap進行擴容,最終只有一個執行緒擴容的table替換舊table陣列, 那麼其他執行緒put的資料會丟失.另外更有甚者, 可能會造成put操作的死迴圈(詳細參考一個大牛寫的:HashMap死迴圈)

HashMap 在設計之初就沒考慮過執行緒安全的問題, 所以在併發環境下HashMap並不是首選, 更多偏向下面幾個Map.

HashTable&Collections.synchronizedMap

HashTable 跟hashMap底層實現類似, 但在設計上考慮到執行緒安全操作, hashTable中所有的核心操作都加上synchronized修飾,確保操作的安全性. 所以併發環境下, hashTable不會出現場景2, 場景3 情況.而場景1中的複合操作還需要額外加鎖, 確保操作安全. 具體操作, 參考上上遍併發中的list的案例.

public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }

        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        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;
    }

Collections.synchronizedMap 的操作跟HashTable大同小異,都是通過synchronized給操作方加鎖, 這裡不累贅了.

ConcurrentHashMap

ConcurrentHashMap 是jdk1.5引入的併發map實現類, 該類的設計者推薦併發環境首選Map實現了, 那麼它能解決上面場景1,場景2,場景3的併發為問麼?篇幅所限, 我們下回分解.