接触过HashMap的人应该对线程安全问题都不陌生,就算是没踩过多线程下HashMap的坑,起码在学习的过程中应该也听说过是非线程安全的,几乎你问每一个程序员hashmap是不是线程安全的,大家都会告诉不是的,那么我来从个人层面探讨一下看似人人都懂的线程安全问题。
首先,hashmap线程安全吗?答案显然是否定的,在java中如果你想要在多线程中安全的使用map的话,目前我所知道的有四种:
1.juc包下的ConcurrentHashMap,提到ConcurrentHashMap这里又是涉及了许多思想,线程安全,数据结构等问题的一个map类,后面有机会的话会好好写文章分析一下这个并发安全的map类。我相信如果有多线程编程经验的同学应该也大多数是采用了ConcurrentHashMap。
2.调用Collections 工具包下的Collections.synchronizedMap()方法可以返回一个线程安全的map,点进源码可以方法,这个方法实现线程安全的方法其实是在类内部维护了一个Object mutex,在所有对map的操作方法里都加入了对mutex这个对象的同步处理进而保证线程安全。
3.使用hashtable。非常不推荐使用hashtable,原因很简单,看看源码你就会发现,这个类的实现非常简单粗暴,就是把所有方法都加入了synchronized关键字保证线程安全的,在多线程下效率会受到较大影响。
4.当然了,我们也可以不依靠java内置的类,通过自己开发map类或者编写线程安全的代码去保证线程安全。
光知道怎么解决还不够,我们更多是应该关注于hashmap为什么不能线程安全,究竟是哪一部分导致了多线程下的安全问题。关于线程安全,一直可以说是程序员的痛点,不仅学习消耗精力,调试更是磨练心智,所以我认为每一名程序员在学习和工作中都不能放过任何一个锻炼多线程的机会。
我先说说我对线程安全的理解,首先发生线程安全的前提条件,那肯定是多线程下,而安全的意思是,你所操作的数据不正确了,导致没有返回正确的结果,然后你的数据再使用的话就不安全了。而之所以数据会不正确,原理也很简单,就是多个线程同时修改了一个数据嘛,你先改我后改,可是我改之前拿的不是你改之后的数据,咱俩改完了都往原来的数据上覆盖,必然有一个结果会被另一个结果覆盖,顺着这个思路我们可以想到,hashmap的线程安全肯定也是因为存在这样的情况。
-
一.数据丢失
那么说回hashmap,map中修改数据的方法那就很明显,肯定是put方法,所以说线程安全的问题主要就体现再put方法上,我们来看一下put方法的源码(jdk1.8):
其实都没有必要一行一行的读了,大致浏览一下就可以看出端倪。我们知道jdk8中hashmap的实现是数组+链表+红黑树,元素存放在数组中,发生哈希冲突时会在对应的数组位置形成链表,当链表长度达到8时就会转化为红黑树,这样做是因为链表的添加删除效率高,但是查询效率低,当链表过长时会影响查找效率。其实从上到下看看这个方法你就会发现根本没有任何锁机制存在,这其实就已经注定了线程非安全。
当向hashmap中put的时候其实都是在操作底层数组table,对于table对象没有任何锁的限制,而整个方法也没有任何同步的限制存在,那么在多线程的情况下很容易出现线程1调用了put方法计算出存放位置是index,而线程2此时进入,计算出存放位置也是index,然后put了元素b,此时线程1继续工作,向index上put了元素a,但是刚刚线程2更新了index位置之后并没有通知到其他线程,此时线程1拿到的还是未被线程2修改的table[index],那么结果肯定就是线程2的更新丢失,线程非安全。这就是hashmap线程非安全的基本情况,但是我在以前学习的过程中查找网上的其他文章,很多文章一谈到hashmap线程安全就说并发情况下链表成环的情况,而对其他情况没做说明,其实我认为这是不对的,数据更新丢失,链表成环和fail-fast都是hashmap线程非安全的体现。
-
二.链表成环
关于链表成环,过程相对还是比较复杂和烧脑的,这里有一篇文章推荐给大家,http://www.importnew.com/22011.html,图画的很详细,解释的很清楚,我就不重复造轮子了 ,如果理解了这篇文章,那么下面的内容也就好理解了,如果链表成环还没理解,建议自己动手画个图试试。值得注意的是,所谓的链表成环逻辑是根本原因是因为put时hashmap采用尾插入,而在扩容时选择了头插入,而这一点已经在jdk8中得到了解决,我们来看一下源码:
其实不难理解,首先假设一个元素a的hash值时101101,将a放入map时计算应该存放的位置时(101101)&(size - 1),size默认值为16=>10000,所以相当于是101101 & 1111,相当于就是元素a的hash值对16取模,而扩容我们知道时扩容为两倍,即对32取模,对16取模的话,值范围:0~15,对32取模的话,值范围:0~31,在元素a的hash值不变的情况下其实元素a扩容前后的数组位置要么不变,要么时原位置+原来数组大小,不知道这个各位能不能理解。
举个例子,7%16=7,7%32=7,19%16=3,19%32=19=3+16。
转换成二进制我们看一下,101011 & 1111(16-1) = 1011,101011 & 11111(32-1) = 01011 = 1011,这种情况下属于数组位置没有改变,再来看一个例子,111011 & 11111(16-1) = 1011,111011 & 11111(32-1) = 11011 = 10000 + 1011,这种情况属于原位置+ 原来数组长度。这样应该就比较清晰了,其实原因就在于扩容成两倍的话做与运算的位数就多了一位,从1111变成了11111,所以新的结果只需要看多出来的那一位是0还是1就可以了,101011,111011由于新计算位不同导致一个位置不动,一个向后移动原数组长度个位数。
回到代码中e.hash & oldCap,oldCap就是原数组长度,不过这里需要注意有一点不一样,这里是直接用了size,我们把上面的例子换一下101011 & 10000 = 00000,111011 & 10000 = 10000,然后判断当结果为0时,扩容后位置不变,否则向后挪动原数组长度个位置,其实本意都是一样的。后面的代码比较好理解了,就是把两种不同位置的元素按照顺序分别存到low和high两个node节点中。理解了扩容机制后就会发现,按照这个实现算法,put和resize其实都是头插入的操作,那么前文所提到的链表成环的情况也就不存在了。
-
三.fail-fast
fail-fast机制大家应该不陌生,这也不是map独有的东西,就不过多介绍了,在其他的java容器类中都存在相似的机制,也就是在迭代的过程中,其本身不能被修改,否则会引发ConcurrentModificationException异常,这个也是hashmap不能多线程的原因之一,假如线程1正在循环put元素,而线程2从map中remove了一个元素,那么线程1那边的下一次迭代肯定就会抛出异常。
我个人认为综合这三点都是hashmap不能保证线程安全的原因,希望大家不要光记住循环链表这么一点,毕竟这个已经在jdk8中优化了,而且jdk8的使用应该已经非常普遍了,不管公司的技术背景如何,作为技术人,技术水平应该是跟时代看齐的。