ConcurrentHashMap线程安全的原因

本文详细介绍了JDK1.8中HashMap的优化,包括桶数组、扩容策略、红黑树以及线程不安全性。并重点讨论了ConcurrentHashMap的设计思想,如初始化、非扩容及扩容时的处理方式,以及为何不允许key和value为null的原因。文章还探讨了volatile和CAS在并发编程中的作用。
摘要由CSDN通过智能技术生成

注:文章基于JDK1.8

1.前驱知识:

1.1.HashMap

HashMap从1.8开始做了一下优化:

(1)桶数组:如图所示,虚线重圈出的就叫桶数组。桶数组的长度必须是2的次幂(8,16,32,64。。。),而每次扩容也只能变为原理的2倍(8→16,32→64)。

当初始化时,若插入的大小不是2的次幂,会自动往上取成2的次幂:比如初始化传入15,实际会自动取成16。如此做的原因一是为了使用位运算提高效率,二是为了扩容。

(2)扩容:扩容时因为桶数组的容量变为原来的2倍了,迁移原有数组上的链表或红黑树中的元素不再需要每次都重新进行hash运算,二是可以通过位运算直接变化。

(3)红黑树:当hash冲突时,链地址法在链的长度小于等于8时为链表,大于8时会由链表变为红黑树。注:并不是每次链表但是并不是一旦链表大于8的时候就变红黑树,而是判断桶数组是否>64,若不大于,先扩容桶数组。

  (4)多线程:HashMap是线程不安全的,甚至由于他的put方法不是原子的,在多线程同时操作某个同时,甚至可能会使得桶数组的链表行成一个环。因为这个原因才有了ConcurrentHashMap。

(5)头插变尾插。HashMap在增加链表节点和扩容时再1.7是头插法,在1.8变为尾插法。头插法在扩容时会改变链表顺序(如:原来链表是1-2-3-4,而扩容后是4-3-2-1),这在多线程下可能会使链表变为环,而尾插法不会。但是HashMap多线程本来也就是不安全的,那为什么要有这个改变?个人认为之前使用头插是认为局部性原理:刚进来的节点更有可能被查询,放在前面有助于减少链表访问长度。但是,在实践中发现这个并不适用,扩容后链表顺序都变了,故改成了尾插法。

1.2.volatile:被volatile修饰的共享变量,将具有以下两点特性:

(1) 保证了不同线程对该变量操作的内存可见性。通过总线嗅探以及写回内存实现的(写回内存用的也是CAS,其实是保证内存一致性是很复杂的协议,比如Intel的MESI)。

(2) 禁止指令重排序。通过内存屏障禁止指令重排序。

即保证了可见性和有序性性,但无法保证原子性。

·可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

1.3.CAS:Compare and Swap,即比较再交换。CAS是一种操作系统级别的支持,是用来更新数据的原始操作。CAS有3个参数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

2.ConcurrentHashMap

2.1基本思想:尽量使用CAS和Volatile而不是加锁,实在需要加锁则尽量减少锁住的区域。

2.2主要分为以下几种情况:

1.初始化问题

读或写时未初始化,此时将sizeCtl字段(使用volatile修饰)使用CAS变为-1,表示正在初始化,别的线程此时进入需要等待。

2.非扩容时的情况:

2.1.写

写时,如果两个线程需要写的不是一个桶数组(即不是一条链表或一棵红黑树),则各写各的,否则先写的线程需要对这个桶数组进行加锁,等到写完再释放。加锁后别的线程不能进行写操作,读操作不受印象。

2.2.读

因为map中每一个节点是用volatile修饰的(具体一点Node的元素val和指针next都是用volatile修饰的),可以保证可见性,所以读可以不加锁。.

3.扩容时的情况:

3.1.扩容发生的时机

每次写完线程之后,会将当前map的实际大小和容量*负载因子进行比较,如果发现超过了,就开始扩容,即负责写的线程写完后,如果发现需要扩容,还要负责扩容。

3.2.扩容

扩容则是先通过CAS对sizeCtl变为扩容状态,然后重新开辟空间一个桶数组为现在map桶数组2倍的空间,然后将现在map中各个桶数组的链表或红黑树从低位到高位向新开辟出来的map迁移,每次开始迁移桶时,将桶的hash置为-1。每迁移完一个桶,就将旧map中桶指向新map中的桶。

如果此时别的写线程进来,经过hash计算后,若此节点还未迁移,则直接插入就行,但是若正在迁移,即发现桶的hash值是-1,则会参与复制过程(不是在这个桶上,而是此时无法插入,还不如去帮忙,是帮别的桶迁移),这样可以加速复制过程。其实sizeCtl还记录着此时有几个线程在扩容,具体最多允许几个线程参与扩容,和CPU的核心数有关,每个线程负责连续的多个桶。

若在迁移过程中又有新的线程进去,如果是是读操作则直接去读,并不会阻塞,因为迁移是复制操作,旧map中的数据不会发生变化(前面说过迁移是在添加完数据后发生的),故可以照常操作。若碰到已经迁移的桶,此时这个节点指向新的桶,会直接去新的桶中查找。

3.3.key和vaule不允许为null:

在 HashMap中,key和value可以为null的,但是在ConcurrentHashMap中不允许。

原因是无法判断,可能会产生歧义。比如调用get(key)时返回mull,此时是这个key对应的值本身是null还是根本就没这个值。在非并发环境中,我们可以通过containsKey进行判断,但在并发时,两种操作中间可能会有别的操作来修改key,引发更多的混乱。

且作者 Doug Lea 认为,若允许在集合比如map和set等中存在 null 值的话,即使在非并发集合中也有一种公开允许程序中存在错误的意思,这也是 Doug Lea 和 Josh Bloch(HashMap作者之一) 在设计问题上少数不同意见之一,而 ConcurrentHashMap 是 Doug Lea 一个人开发的,所以就直接禁止了 null 值的存在。

4.参考文献

https://zhuanlan.zhihu.com/p/355565143,助力面试之ConcurrentHashMap面试灵魂拷问,你能扛多久 - 双子孤狼的文章 - 知乎

https://www.jianshu.com/p/87e73d25e1ce,ConcurrentHashMap简书,ledge

https://blog.csdn.net/ZOKEKAI/article/details/90051567,ConcurrentHashMap1.8 - 扩容详解,ZOKEKAI

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值