HashMap1.8线程安全问题

537 阅读6分钟

在向null节点添加元素的时候

首先我们来看一段HashMap的源码:

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 ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    省略......

这是一段HashMap添加元素的代码,这个if判断就是判断当前节点是不是为空,若为空就放上元素。它并没有考虑线程安全问题,若是两或多个个线程同时进来,就会出现数据存储问题,假设两个线程计算hash索引的位置是一样的,可能会导致数据被覆盖,导致数据不一致。 ConcurrentHashMap是怎么解决的呢?我们来看一下它的源码:

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        省略......

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null)从这段代码中我们看到它在判断当前节点是否为null的时候顺便为 i 赋了值,在往下走,嗯~又是熟悉的味道,

casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)) 

这段代码和我们在ConcurrentHashMap初始化时做的那个线程安全的操作一样,它也是通过CAS无锁化机制来做的,casTabAt方法中tab代表当前节点,i 就是上面判断时被赋值的那个 i ,第三个是null,最后一个就是要插入的节点。

这时候我们进行多线程的put操作,当线程进入else if判断时发现当前节点为null 随即 i 被赋值为null,往下走,进入casTabAt方法 i 与 null 比较相同,就把要元素放进当前节点,并修改 i 的值。这是如果再来一个线程来执行casTabAt方法时会发现 i 已经不等于 null 了,所以就不会插入元素。这就是ConcurrentHashMap在put元素的时候解决的线程安全的操作。

在对同一个数组中的元素操作时

其实就是多个线程在对同一个数组元素进行修改,或者hash相同时往后追加链表,又或是追加为红黑树的时候,这是也可能出现线程安全的问题。

在HashMap中也没有做相应的线程安全的操作,HashTable也还是使用的是synchronized修饰。

HashMap中相应的代码在这里:

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    省略......
    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
                        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 key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

我们再来看ConcurrentHashMap是怎么处理的:

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        省略......
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

这里面同时包含了修改值、追加链表、追加红黑树的操作,在这里我们看到了ConcurrentHashMap使用了synchronized 代码块的形式,加在了方法内部来保证线程对当前节点进行操作是的线程安全,大家在这里可能会有些疑惑,既然CAS那么好用,效率又高,那为什么在这里就不使用了?

其实是这样的,我们在put元素时可能会put的很频繁,它里面的链表或红黑树的节点也可能很多,那它就得频繁的进行比较,每一次put都得比较判断,这样效率不高,但是加上synchronized 之后,就相当于一劳永逸了,这两种方式比起来,加上synchronized的方式效率会很高。 我们要知道这个synchronized锁的只是当前这个数组节点,如果在来一个线程要操作另一个节点,那么它是可以正常访问的。

synchronized (f) 

上面这个synchronized 里的 f 代表的就是当前节点,所以这个synchronized 只是对当前节点加了锁。这就是ConcurrentHashMao在处理在对同一个数组中的元素操作时对线程安全问题的处理。

数组扩容时出现的线程安全问题。

大家都知道,HashMap在数组的元素过多时会进行扩容操作,扩容之后会把原数组中的元素拿到新的数组中,这时候在多线程情况下就有可能出现多个线程搬运一个元素。或者说一个线程正在进行扩容,但是另一个线程还想进来存或者读元素,这也可会出现线程安全问题,这一点在ConcurrentHashMap中是怎么处理的呢? 我们来看一下:

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
           省略......
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            省略......
    }
    addCount(1L, binCount);
    return null;
}

我们看到 (fh = f.hash) == MOVED 有这样一个判断,MOVED 是一个成员静态变量,值为-1,当数组在扩容的时候会把数组的头节点的hash值变为-1,所以当线程进来不管是查询还是修改还是添加只要看到当前主节点的hash值为-1时就会进入这里面的方法,我们看到它里面是helpTransfer()方法,

我们去看一下

/**
 * 如果当前节点正在重新分配元素,则帮助它
 */
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; int sc;
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        int rs = resizeStamp(tab.length);
        while (nextTab == nextTable && table == tab &&
               (sc = sizeCtl) < 0) {
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || transferIndex <= 0)
                break;
            if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}

从上面的代码我们可以得知,当扩容后,元素正在重新分配时,其他线程在进来操作就会被拦住,拦住之后干什么呢?只是在那里等着吗?

不,ConcurrentHashMap让这些线程去帮助分配元素,与其在那里等着还不如给它找点事干干,还能提高效率,这一点不得不佩服ConcurrentHashMap的设计师。但是这些来帮忙的线程怎么知道自己要搬哪些数据呢?万一多个线程去搬一个元素这不又出现线程安全问题了吗?

这一点ConcurrentHashMap早就想好了,它对来帮忙的每一个线程都分配了一块区域,每个线程只能搬运自己所属区域内的元素,这样就互不干扰了。这些线程在帮助分配完元素之后,才会去做自己本来的操作。