集合(五)不正确地使用HashMap引发死循环及元素丢失

前一篇文章讲解了HashMap的实现原理,讲到了HashMap不是线程安全的。那么HashMap在多线程环境下又会有什么问题呢?

几个月前,公司项目的一个模块在线上运行的时候出现了死循环,死循环的代码就卡在HashMap的get方法上。尽管最终发现不是因为HashMap导致的,但却让我重视了HashMap在多线程环境下会引发死循环的这个问题,下面先用一段代码简单模拟出HashMap的死循环:

public class HashMapThread extends Thread
{
    private static AtomicInteger ai = new AtomicInteger(0);
    private static Map<Integer, Integer> map = new HashMap<Integer, Integer>(1);

    public void run()
    {
        while (ai.get() < 100000)
        {
            map.put(ai.get(), ai.get());
            ai.incrementAndGet();
        }
    }
}

这个线程的作用很简单,给AtomicInteger不断自增并写入HashMap中,其中AtomicInteger和HashMap都是全局共享的,也就是说所有线程操作的都是同一个AtomicInteger和HashMap。开5个线程操作一下run方法中的代码:

public static void main(String[] args)
{
    HashMapThread hmt0 = new HashMapThread();
    HashMapThread hmt1 = new HashMapThread();
    HashMapThread hmt2 = new HashMapThread();
    HashMapThread hmt3 = new HashMapThread();
    HashMapThread hmt4 = new HashMapThread();
    hmt0.start();
    hmt1.start();
    hmt2.start();
    hmt3.start();
    hmt4.start();
}

多运行几次之后死循环就出来了,我大概运行了7次、8次的样子,其中有几次是数组下标越界异常ArrayIndexOutOfBoundsException。这里面要提一点,多线程环境下代码会出现问题并不意味着多线程环境下一定会出现问题,但是只要出现了问题,或者是死锁、或者是死循环,那么你的项目除了重启就没有什么别的办法了,所以代码的线程安全性在开发、评审的时候必须要重点考虑到。OK,看一下控制台:

红色方框一直亮着,说明代码死循环了。死循环问题的定位一般都是通过jps+jstack查看堆栈信息来定位的:

看到Thread-0处于RUNNABLE,而从堆栈信息上应该可以看出,这次的死循环是由于Thread-0对HashMap进行扩容而引起的。

所以,本文就解读一下,HashMap的扩容为什么会引起死循环。

正常的扩容过程

先来看一下HashMap一次正常的扩容过程。简单一点看吧,假设我有三个经过了最终rehash得到的数字,分别是5 7 3,HashMap的table也只有2,那么HashMap把这三个数字put进数据结构了之后应该是这么一个样子的:

这应该很好理解。然后看一下resize的代码,上面的堆栈里面就有:

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);
    if (size++ >= threshold)
        resize(2 * table.length);
}

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    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);
        }
    }
}

我总结一下这三段代码,HashMap一次扩容的过程应该是:

1、取当前table的2倍作为新table的大小

2、根据算出的新table的大小new出一个新的Entry数组来,名为newTable

3、轮询原table的每一个位置,将每个位置上连接的Entry,算出在新table上的位置,并以链表形式连接

4、原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了新的table上,HashMap中的table指向newTable

这样就完成了一次扩容,用图表示是这样的:

HashMap的一次正常扩容就是这样的,这很好理解。

扩容导致的死循环

既然是扩容导致的死循环,那么继续看扩容的代码:

 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) {
 5         Entry<K,V> e = src[j];
 6         if (e != null) {
 7             src[j] = null;
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity);
11                 e.next = newTable[i];
12                 newTable[i] = e;
13                 e = next;
14             } while (e != null);
15         }
16     }
17 }

两个线程,线程A和线程B。假设第9行执行完毕,线程A切换,那么对于线程A而言,是这样的:

CPU切换到线程B运行,线程B将整个扩容过程全部执行完毕,于是就形成了:

此时CPU切换到线程A上,执行第8行~第14行的do...while...循环,首先放置3这个Entry:

我们必须要知道,由于线程B已经执行完毕,因此根据Java内存模型(JMM),现在table里面所有的Entry都是最新的,也就是7的next是3,3的next是null。3放置到table[3]的位置上了,下面的步骤是:

1、e=next,即e=7

2、判断e不等于null,循环继续

3、next=e.next,即next=7的next,也就是3

4、放置7这个Entry

所以,用图表示就是:

放置完7之后,继续运行代码:

1、e=next,也就是说e=3

2、判断e不等于null,循环继续

3、next=e.next,即3的next,也就是null

4、放置3这个Entry

把3移到table[3]上去,死循环就出来了:

3移到table[3]上去了,3的next指向7,由于原先7的next指向3,这样就成了一个死循环。

此时执行13行的e=next,那么e=null,循环终止。尽管此次循环确实结束了,但是后面的操作,只要涉及轮询HashMap数据结构的,无论是迭代还是扩容,都将在table[3]这个链表处出现死循环。这也就是前面的死循环堆栈出现的原因,transfer的484行,因为这是一次扩容操作,需要遍历HashMap数据结构,transfer方法是扩容的最后一个方法。

3 5 7又会有怎样的结果

可能有人觉得上面的数字5 7 3太巧了,像是专门为了产生HashMap的死循环而故意选择的数字。

这个问题,我这么回答:我记得在《从Paxos到Zookeeper分布式一致性原理与实践》有一段话大概是这么描述的,有一个被反复实践得出的结论是,任何在多线程下可能发生的错误场景最终一定会发生

5 7 3这个数字可不巧,扩容前相邻两个Entry被分配到扩容后同样的table位置是很正常的。关键的是,即使这种异常场景发生的可能性再低,只要发生一次,那么你的系统就部分甚至全部不可用了----除了重启系统没有任何办法。所以,这种可能会发生的异常场景必须提前扼杀。

OK,不扯了,前面讲了5 7 3导致了死循环,现在看一下正常的顺序3 5 7,会发生什么问题。简单看一下,就不像上面讲得这么详细了:

这是扩容前数据结构中的内容,扩容之后正常的应该是:

现在在多线程下遇到问题了,某个线程先放7:

再接着放5:

由于5的next此时为null,因此扩容操作结束,3 5 7造成的结果就是元素丢失。

如何解决

把一个线程非安全的集合作为全局共享的,本身就是一种错误的做法,并发下一定会产生错误。

所以,解决这个问题的办法很简单,有两种:

1、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap变成一个线程安全的Map

2、使用Hashtable、ConcurrentHashMap这两个线程安全的Map

不过,既然选择了线程安全的办法,那么必然要在性能上付出一定的代价----毕竟这个世界上没有十全十美的事情,既要运行效率高、又要线程安全。

时间: 2024-11-02 23:39:22

集合(五)不正确地使用HashMap引发死循环及元素丢失的相关文章

图解集合5:不正确地使用HashMap引发死循环及元素丢失

问题引出 前一篇文章讲解了HashMap的实现原理,讲到了HashMap不是线程安全的.那么HashMap在多线程环境下又会有什么问题呢? 几个月前,公司项目的一个模块在线上运行的时候出现了死循环,死循环的代码就卡在HashMap的get方法上.尽管最终发现不是因为HashMap导致的,但却让我重视了HashMap在多线程环境下会引发死循环的这个问题,下面先用一段代码简单模拟出HashMap的死循环: public class HashMapThread extends Thread { pri

JDK1.7源码分析【集合】HashMap的死循环

前言 在JDK1.7&1.8源码对比分析[集合]HashMap中我们遗留了一个问题:为什么HashMap在调用resize() 方法时会出现死循环?这篇文章就通过JDK1.7的源码来分析并解释这个问题. 如下,并发场景下使用HashMap造成Race Condition,从而导致死循环,现象是CPU 100%被占用. final HashMap<String, String> map = new HashMap<String, String>(); for (int i =

深入理解JAVA集合系列三:HashMap的死循环解读

由于在公司项目中偶尔会遇到HashMap死循环造成CPU100%,重启后问题消失,隔一段时间又会反复出现.今天在这里来仔细剖析下多线程情况下HashMap所带来的问题: 1.多线程put操作后,get操作导致死循环. 2.多线程put非null元素后,get操作得到null值. 3.多线程put操作,导致元素丢失. 死循环场景重现 下面我用一段简单的DEMO模拟HashMap死循环: 1 public class Test extends Thread 2 { 3 static HashMap<

多线程下HashMap的死循环问题

多线程下[HashMap]的问题: 1.多线程put操作后,get操作导致死循环. 2.多线程put非NULL元素后,get操作得到NULL值. 3.多线程put操作,导致元素丢失. 本次主要关注[HashMap]-死循环问题. 为何出现死循环? 大家都知道,HashMap采用链表解决Hash冲突,具体的HashMap的分析可以参考一下Java集合---HashMap源码剖析 的分析.因为是链表结构,那么就很容易形成闭合的链路,这样在循环的时候只要有线程对这个HashMap进行get操作就会产生

Java 集合系列14之 Map总结(HashMap, Hashtable, TreeMap, WeakHashMap等使用场景)

http://www.cnblogs.com/skywang12345/p/3311126.html 概要 学完了Map的全部内容,我们再回头开开Map的框架图. 本章内容包括:第1部分 Map概括第2部分 HashMap和Hashtable异同第3部分 HashMap和WeakHashMap异同 转载请注明出处:http://www.cnblogs.com/skywang12345/admin/EditPosts.aspx?postid=3311126 第1部分 Map概括 (01) Map

string和stringbuffer的区别 集合的作用 ArrayList vector linklist hashmap hashtable collection和collections

string给定的长度 不可变,当多个字符串联合的时候先转化为stringbuffer然后联合,速度慢,stringbuffer可以改变字符串的长度,当多个字符串连接的时候采用stringbuffer效率比较高. 集合的作用 对数据进行传送,对数据进行增删改查,还可以用来存放不同的对象. import java.util.Vector;import java.util.List;import java.util.Iterator;import java.util.Enumeration; /**

java并发编程(五)正确使用volatile

转载请注明出处:     volatile用处说明     在JDK1.2之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的.而随着JVM的成熟和优化,现在在多线程环境下volatile关键字的使用变得非常重要. 在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写.这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致. 要解决这个问题,就需要

struts2获取表单数据之 属性封装 模型驱动 表达式封装 对象封装到list集合 对象封装到map集合 五种方便的封装方式

一.属性封装 属性封装是在action里面设定属性值,属性名字一定要和表单中的name一样,action中extends ActionSupport demo1.jsp: <body> This is my login JSP page. <br> <form action="demo1.action" method="post" > username<input type="text" name=&qu

linux杂记(五)正确关机方法(shutdown,reboot,init,halt)

前言:由于在linux底下,每个程序(或者说是服务)都是在背景下运行的,因此,在你看不到的屏幕背后其实可能有相当多人同时在你的主机上面工作,如果 你直接按下电源开关来关机,则可能导致其他人的数据就此中断.此外最大的问题是,若是不正常关机,由于来不及把数据回写到档案中,从而可能造成档案系统的 损毁.因此,正确的linux系统关机姿势是非常重要的. 正常情况下,要关机时需要注意底下几件事: 观察系统的使用状态:如果要看目前有谁在线,可以用who指令:如果要看网络的联机状态,可以用netstat -a