☆HashMap多线程并发问题分析

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

咱们简单的看一下咱们本身的代码,咱们就知道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<K,V>(hash, key, value, e),若是两个线程都同时取得了e,则他们下一个元素都是e,而后赋值给table元素的时候有一个成功有一个丢失。 ###put非null元素后get出来的倒是null### 在transfer方法中代码以下:jvm

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,而后就把这个<key, value>插到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,而后全部的<key,value> 从新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。不过,迭代器被设计成每次仅由一个线程使用。

相关文章
相关标签/搜索