多线程并发下的HashMap

多线程并发下的HashMap

HashMap在多线程高并发下时线程不安全的,可能会出现cpu占用过高(死循环)的情况。

这个现象的出现从源码分析来看,这个死循环的出现时因为resize()时复制元素时产生了循环链表。

此部分转载自https://yq.aliyun.com/articles/66683

void transfer(Entry[] newTable) {  
        Entry[] src = table;  //引用原table                     
        int newCapacity = newTable.length;     
        for (int j = 0; j < src.length; j++) {   //遍历原table   
            Entry e = src[j];                  
            if (e != null) {   //如果原table[j]处不为null,即存在要复制的元素  
                src[j] = null;     将原table[j]标记为null,等待GC回收  
                do {   //这个while循环作用时为该处的链表中的所有Entry 在新的table中找到相应的位置  
                    Entry next = e.next;  
                    int i = indexFor(e.hash, newCapacity);    //计算在新的table中的下标  
                    e.next = newTable[i];     
                    newTable[i] = e;          
                    e = next;                 
                } while (e != null);  
            }  
        }  
    }  

(1)假设我们有两个线程。我用红色和浅蓝色标注了一下。我们再回头看一下我们的 transfer代码中的这个细节:

do{
    Entry next = e.next; // <--假设线程一执行到这里就被调度挂起了
    inti = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
}while(e != null);

而我们的线程二执行完成了。于是我们有下面的这个样子。
多线程并发下的HashMap_第1张图片

注意:因为Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。
(2)线程一被调度回来执行。

  1. 先是执行 newTalbe[i] = e。
  2. 然后是e = next,导致了e指向了key(7)。
  3. 而下一次循环的next = e.next导致了next指向了key(3)。

多线程并发下的HashMap_第2张图片
(3)一切安好。
线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移。
多线程并发下的HashMap_第3张图片
(4)环形链接出现。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
多线程并发下的HashMap_第4张图片

当形成环链表之后,我们在调用get()方法试图获取该处的值时,就会陷入死循环。



多线程并发时,还有可能出现丢失数据的情况。

主要问题出在addEntry方法的new Entry (hash, key, value, e),如果两个线程都同时取得了e,则他们下一个元素都是e,然后赋值给table元素的时候有一个成功有一个丢失。


put非null元素后get出来的却是null

在transfer方法中代码如下:

voidtransfer(Entry[] newTable) {
    Entry[] src = table;
    intnewCapacity = newTable.length;
    for(intj = 0; j < src.length; j++) {
        Entry e = src[j];
        if(e != null) {
            src[j] = null;
            do{
                Entry next = e.next;
                inti = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }while(e != null);
        }
    }
}

在这个方法里,将旧数组赋值给src,遍历src,当src的元素非null时,就将src中的该元素置null,即将旧数组中的元素置null了,也就是这一句:

if(e != null) {
        src[j] = null;
}

此时若有get方法访问这个key,它取得的还是旧数组,当然就取不到其对应的value了。

总结:HashMap未同步时在并发程序中会产生许多微妙的问题,难以从表层找到原因。所以使用HashMap出现了违反直觉的现象,那么可能就是并发导致的了。


解决方法

三种解决方案

1.Hashtable替换HashMap

Hashtable 是同步的,但由迭代器返回的 Iterator 和由所有 Hashtable 的“collection 视图方法”返回的 Collection 的 listIterator 方法都是快速失败的:在创建 Iterator 之后,如果从结构上对 Hashtable 进行修改,除非通过 Iterator 自身的移除或添加方法,否则在任何时间以任何方式对其进行修改,Iterator 都将抛出 ConcurrentModificationException。因此,面对并发的修改,Iterator 很快就会完全失败,而不冒在将来某个不确定的时间发生任意不确定行为的风险。由 Hashtable 的键和值方法返回的 Enumeration 不是快速失败的。

注意,迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出任何硬性保证。快速失败迭代器会尽最大努力抛出 ConcurrentModificationException。因此,为提高这类迭代器的正确性而编写一个依赖于此异常的程序是错误做法:迭代器的快速失败行为应该仅用于检测程序错误。

2.Collections.synchronizedMap将HashMap包装起来

返回由指定映射支持的同步(线程安全的)映射。为了保证按顺序访问,必须通过返回的映射完成对底层映射的所有访问。在返回的映射或其任意 collection 视图上进行迭代时,强制用户手工在返回的映射上进行同步:

Map m = Collections.synchronizedMap(newHashMap());
...
Set s = m.keySet();  // Needn't be in synchronized block
...
synchronized(m) {  // Synchronizing on m, not s!
Iterator i = s.iterator(); // Must be in synchronized block
    while(i.hasNext())
        foo(i.next());
}

不遵从此建议将导致无法确定的行为。如果指定映射是可序列化的,则返回的映射也将是可序列化的。

3.ConcurrentHashMap替换HashMap

支持检索的完全并发和更新的所期望可调整并发的哈希表。此类遵守与 Hashtable 相同的功能规范,并且包括对应于 Hashtable 的每个方法的方法版本。不过,尽管所有操作都是线程安全的,但检索操作不必锁定,并且不支持以某种防止所有访问的方式锁定整个表。此类可以通过程序完全与 Hashtable 进行互操作,这取决于其线程安全,而与其同步细节无关。
检索操作(包括 get)通常不会受阻塞,因此,可能与更新操作交迭(包括 put 和 remove)。检索会影响最近完成的更新操作的结果。对于一些聚合操作,比如 putAll 和 clear,并发检索可能只影响某些条目的插入和移除。类似地,在创建迭代器/枚举时或自此之后,Iterators 和 Enumerations 返回在某一时间点上影响哈希表状态的元素。它们不会抛出 ConcurrentModificationException。不过,迭代器被设计成每次仅由一个线程使用。




你可能感兴趣的:(Java)