并发安全问题之HashMap

原文地址:html

http://my.oschina.net/xianggao/blog/393990#OSC_h2_1java

 

目录[-]算法

以及 

ConcurrentHashMap 的弱一致性问题 是不能彻底代替HashTable的缘由

 

并发问题的症状

多线程put后可能致使get死循环

从前咱们的Java代码由于一些缘由使用了HashMap这个东西,可是当时的程序是单线程的,一切都没有问题。后来,咱们的程序性能有问题,因此须要变成多线程的,因而,变成多线程后到了线上,发现程序常常占了100%的CPU,查看堆栈,你会发现程序都Hang在了HashMap.get()这个方法上了,重启程序后问题消失。可是过段时间又会来。并且,这个问题在测试环境里可能很难重现。segmentfault

咱们简单的看一下咱们本身的代码,咱们就知道HashMap被多个线程操做。而Java的文档说HashMap是非线程安全的,应该用ConcurrentHashMap。可是在这里咱们能够来研究一下缘由。简单代码以下:数组

package com.king.hashmap; import java.util.HashMap; public class TestLock { private HashMap map = new HashMap(); public TestLock() { Thread t1 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t1 over"); } }; Thread t2 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t2 over"); } }; Thread t3 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t3 over"); } }; Thread t4 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t4 over"); } }; Thread t5 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t5 over"); } }; Thread t6 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t6 over"); } }; Thread t7 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t7 over"); } }; Thread t8 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t8 over"); } }; Thread t9 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t9 over"); } }; Thread t10 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t10 over"); } }; t1.start(); t2.start(); t3.start(); t4.start(); t5.start(); t6.start(); t7.start(); t8.start(); t9.start(); t10.start(); } public static void main(String[] args) { new TestLock(); } } 

就是启了10个线程,不断的往一个非线程安全的HashMap中put内容/get内容,put的内容很简单,key和value都是从0自增的整数(这个put的内容作的并很差,以至于后来干扰了我分析问题的思路)。对HashMap作并发写操做,我原觉得只不过会产生脏数据的状况,但反复运行这个程序,会出现线程t一、t2被hang住的状况,多数状况下是一个线程被hang住另外一个成功结束,偶尔会10个线程都被hang住。安全

产生这个死循环的根源在于对一个未保护的共享变量 — 一个"HashMap"数据结构的操做。当在全部操做的方法上加了"synchronized"后,一切恢复了正常。这算jvm的bug吗?应该说不是的,这个现象很早之前就报告出来了。Sun的工程师并不认为这是bug,而是建议在这样的场景下应采用"ConcurrentHashMap”,服务器

CPU利用率太高通常是由于出现了出现了死循环,致使部分线程一直运行,占用cpu时间。问题缘由就是HashMap是非线程安全的,多个线程put的时候形成了某个key值Entry key List的死循环,问题就这么产生了。数据结构

当另一个线程get 这个Entry List 死循环的key的时候,这个get也会一直执行。最后结果是愈来愈多的线程死循环,最后致使服务器dang掉。咱们通常认为HashMap重复插入某个值的时候,会覆盖以前的值,这个没错。可是对于多线程访问的时候,因为其内部实现机制(在多线程环境且未做同步的状况下,对同一个HashMap作put操做可能致使两个或以上线程同时作rehash动做,就可能致使循环键表出现,一旦出现线程将没法终止,持续占用CPU,致使CPU使用率居高不下),就可能出现安全问题了。多线程

使用jstack工具dump出问题的那台服务器的栈信息。死循环的话,首先查找RUNNABLE的线程,找到问题代码以下:并发

java.lang.Thread.State:RUNNABLE 
at java.util.HashMap.get(HashMap.java:303) 
at com.sohu.twap.service.logic.TransformTweeter.doTransformTweetT5(TransformTweeter.java:183) 
共出现了23次。 
java.lang.Thread.State:RUNNABLE 
at java.util.HashMap.put(HashMap.java:374) 
at com.sohu.twap.service.logic.TransformTweeter.transformT5(TransformTweeter.java:816) 
共出现了3次。

注意:不合理使用HashMap致使出现的是死循环而不是死锁。

多线程put的时候可能致使元素丢失

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

put非null元素后get出来的倒是null (写覆盖)

多个线程在同一个数据组上写入元素,后写入的覆盖前一个的数据。

在transfer方法中代码以下:

void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { Entry e = src[j]; if (e != null) { src[j] = null; do { Entry next = e.next; int i = 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出现了违反直觉的现象,那么可能就是并发致使的了。

HashMap数据结构

我须要简单地说一下HashMap这个经典的数据结构。

HashMap一般会用一个指针数组(假设为table[])来作分散全部的key,当一个key被加入时,会经过Hash算法经过key算出这个数组的下标i,而后就把这个 插到table[i]中,若是有两个不一样的key被算在了同一个i,那么就叫冲突,又叫碰撞,这样会在table[i]上造成一个链表。

咱们知道,若是table[]的尺寸很小,好比只有2个,若是要放进10个keys的话,那么碰撞很是频繁,因而一个O(1)的查找算法,就变成了链表遍历,性能变成了O(n),这是Hash表的缺陷。

因此,Hash表的尺寸和容量很是的重要。通常来讲,Hash表这个容器当有数据要插入时,都会检查容量有没有超过设定的thredhold,若是超过,须要增大Hash表的尺寸,可是这样一来,整个Hash表里的元素都须要被重算一遍。这叫rehash,这个成本至关的大。

HashMap的rehash源代码

下面,咱们来看一下Java的HashMap的源代码。Put一个Key,Value对到Hash表中:

public V put(K key, V value) { ...... //算Hash值 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); //若是该key已被插入,则替换掉旧的value (连接操做) 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; } } modCount++; //该key不存在,须要增长一个结点 addEntry(hash, key, value, i); return null; } 

检查容量是否超标:

void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //查看当前的size是否超过了咱们设定的阈值threshold,若是超过,须要resize if (size++ >= threshold) resize(2 * table.length); } 

新建一个更大尺寸的hash表,而后把数据从老的Hash表中迁移到新的Hash表中。

void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; ...... //建立一个新的Hash Table Entry[] newTable = new Entry[newCapacity]; //将Old Hash Table上的数据迁移到New Hash Table上 transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); } 

迁移的源代码,注意高亮处:

void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; //下面这段代码的意思是: // 从OldTable里摘一个元素出来,而后放到NewTable中 for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } } 

好了,这个代码算是比较正常的。并且没有什么问题。

正常的ReHash过程

画了个图作了个演示。

  1. 我假设了咱们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
  2. 最上面的是old hash 表,其中的Hash表的size=2, 因此key = 3, 7, 5,在mod 2之后都冲突在table1这里了。
  3. 接下来的三个步骤是Hash表 resize成4,而后全部的 从新rehash的过程。

在此输入图片描述

并发的Rehash过程

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

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

而咱们的线程二执行完成了。因而咱们有下面的这个样子。 
在此输入图片描述

注意:由于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)。

在此输入图片描述 
(3)一切安好。 
线程一接着工做。把key(7)摘下来,放到newTable[i]的第一个,而后把e和next往下移。 
在此输入图片描述 
(4)环形连接出现。 
e.next = newTable[i] 致使 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。 
在此输入图片描述 
因而,当咱们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop。

三种解决方案

Hashtable替换HashMap

Hashtable 是同步的,但由迭代器返回的 Iterator 和由全部 Hashtable 的“collection 视图方法”返回的 Collection 的 listIterator 方法都是快速失败的:在建立 Iterator 以后,若是从结构上对 Hashtable 进行修改,除非经过 Iterator 自身的移除或添加方法,不然在任什么时候间以任何方式对其进行修改,Iterator 都将抛出 ConcurrentModificationException。所以,面对并发的修改,Iterator 很快就会彻底失败,而不冒在未来某个不肯定的时间发生任意不肯定行为的风险。由 Hashtable 的键和值方法返回的 Enumeration 不是快速失败的。

注意,迭代器的快速失败行为没法获得保证,由于通常来讲,不可能对是否出现不一样步并发修改作出任何硬性保证。快速失败迭代器会尽最大努力抛出 ConcurrentModificationException。所以,为提升这类迭代器的正确性而编写一个依赖于此异常的程序是错误作法:迭代器的快速失败行为应该仅用于检测程序错误。

Collections.synchronizedMap将HashMap包装起来

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

Map m = Collections.synchronizedMap(new HashMap()); ... 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()); } 

不听从此建议将致使没法肯定的行为。若是指定映射是可序列化的,则返回的映射也将是可序列化的。

ConcurrentHashMap替换HashMap

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

 

ConcurrentHashMap详解 

http://www.cnblogs.com/dolphin0520/p/3932905.html

 

深刻剖析ConcurrentHashMap

http://ifeve.com/java-concurrent-hashmap-1/

http://ifeve.com/java-concurrent-hashmap-2/

ConcurrentHashMap的 若弱一致性,是不能彻底代替HashTable的缘由

 

说明: 弱一致性和并发安全并不矛盾,并发安全,强调是数据的完整性,即银行有扣余额,同时会有流水产生,但不必定一致性,即另外一个帐户立马同时加钱(每每有延迟)。若是是并发问题,多是产生两笔流水,单只扣了一次钱。还能够用100个线程i++解释,并发问题是出现的i结果小于100.,而一致性,是中间可能结果延迟,但结果必定会是100

 

ConcurrentHashMap弱一致性的三种表现 https://segmentfault.com/a/1190000000377057

#get操做 

get方法是弱一致的,是什么含义?可能你指望往ConcurrentHashMap底层数据结构中加入一个元素后,立马能对get可见,但ConcurrentHashMap并不能如你所愿。换句话说,put操做将一个元素加入到底层数据结构后,get可能在某段时间内还看不到这个元素。

注意是 底层数据结构,不表明put操做完成 。

 

若是某个Segment实例中的put将一个Entry加入到了table中,在未执行count赋值操做以前有另外一个线程执行了同一个Segment实例中的get,来获取这个刚加入的Entry中的value,那么是有可能取不到的!

此外,若是getFirst(hash)先执行,tab[index] = new HashEntry\<K,V>(key, hash, first, value)后执行,那么,这个get操做也是看不到put的结果的。

正是由于get操做几乎全部时候都是一个无锁操做(get中有一个readValueUnderLock调用,不过这句执行到的概率极小),使得同一个Segment实例上的put和get能够同时进行,这就是get操做是弱一致的根本缘由。Java
API中对此有一句简单的描述:

Retrievals reflect the results of the most recently completed
update operations holding upon their onset.

也就是说API上保证get操做必定能看到已完成的put操做。已完成的put操做确定在get读取count以前对count作了写入操做。所以,也就是咱们第一个轨迹分析的状况。

 

#clear

clear方法很简单,看下代码即知。

public void clear() { for (int i = 0; i < segments.length; ++i) segments[i].clear(); } 

由于没有全局的锁,在清除完一个segments以后,正在清理下一个segments的时候,已经清理segments可能又被加入了数据,所以clear返回的时候,ConcurrentHashMap中是可能存在数据的。所以,clear方法是弱一致的。

 

ConcurrentHashMap中的迭代器

遍历过程当中,若是已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。若是未遍历的数组上的内容发生了变化,则有可能反映到迭代过程当中。这就是ConcurrentHashMap迭代器弱一致的表现。

相关文章
相关标签/搜索