聊聊ConcurrentHashMap
一、为什么需要ConcurrentHashMap
1、HashMap 线程不安全
在多线程环境下,使用HashMap进行 put 操作的时候可能造成死循环,导致 CPU 使用率太高
为什么HashMap线程不安全?
在 put 的时候,插入元素超过了容量,就会进行rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行 put 操作,如果 hash 值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在 get 时会出现死循环,所以不安全
2、HashTable 安全但是效率太低了
因为 HashTable 是利用 synchronized 来保证线程安全的,在线程竞争激烈的情况下效率将会非常低。因为在一个线程访问同步方法的时候,其他线程只能阻塞等待。
二、ConcurrentHashMap 的 好处
实现了前面的两个问题 :线程安全了 并且也解决了HashTable 效率低下的问题
三、怎么解决效率低下的问题
这个主要是运用了分段锁
(segment)的思想
HashTable 为什么效率低,就是因为它是多个线程竞争同一把锁,那么如果容器里面有很多把锁,这个问题是不是就可以解决了,这个就是ConcurrentHashMap所使用的分段锁
技术。
首先将数据分为一段一段的进程存储,然后给每一段分别加上锁,当一个线程占用锁访问其中一个段的数据的时候,其他段的数据也可以被其他线程访问。
接下来我们就重点讲讲分段锁吧
ConcurrentHashMap 是由Segment 数组结构和HashEntry数组结构组成的。Segment是一种可重入锁ReentrantLock,在 ConcurrentHashMap 里面扮演锁的角色,HashEntry 则是用来村塾键值对数据。一个 ConcurrentHashMap 里面包含一个 Segment数组,Segment的结构和HashMap类似,是一种数组+链表结构,一个Segment里面包含一个 HashEntry数组,每个HashEntry是一个链表节点构成的元素,每个Segment守护一个HashEntry数组里面的元素,当对HashEntry数组的元素进行修改时,必须首先获得它对应的Segment锁。可以说,ConcurrentHashMap是一个二级的哈希表。在一个总的哈希表下面还有若干个子哈希表。
四、采用分段锁技术的好处:并发的读写
case1:不同Segment的并发写入:
不同的Segment的写入是可以并发执行的
case 2:同一个Segment 的写
Segment的写入是需要上锁的,因此对同一个Segment的并发写会被阻塞
case 3:同一个Segment 的写-读
同一个Segment的写-读是可以并发执行的
五、详细看看 读-写 的过程
1、读:Get()
- 为输入的Key做 Hash 运算,得到 hash 值
- 通过 hash 值,定位到对应的Segment 对象
- 再次通过 hash 值,定位到 Segment 当中数组的具体位置
读操作其实是没有锁的,第一次通过 hash 定位到 Segment 上,第二次通过 hash 定位到 具体元素上。因为 hashEntry 中的 value 属性是用 volatile 修饰的,保证了可见性,所以每次获取的都死最新值。
2、写:Put()
- 为输入的 Key 做 Hash 运算,得到 hash 值
- 通过 hash 值,定位到对应的 Segment 对象
- 获取可重入锁
- 再次通过 hash 值,定位到 Segment 当中数组的具体位置
- 插入或覆盖 HashEntry 对象
- 释放锁
总结:可以看出ConcurrentHashMap在读写的时候都需要两次定位。首先是定位到 Segment ,然后再定位到 Segment 下的具体的数组下标
六、size() 怎么 解决一致性问题
size()目的是统计ConcurrentHashMap的总元素数量,自然需要把各个Segment 内部的元素都加起来。但是在统计数量的时候,有可能 已经统计过的Segment顺佳插入了新的元素,这个时候应该怎么办?下面我们来看看ConcurrentHashMap的size(),他是一个嵌套循环,大致逻辑如下:
- 遍历所有的 Segment
- 把 Segment 的元素数量累加起来
- 把 Segment 的修改次数累加起来
- 判断所有 Segment 的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是,说明没有修改,统计结束
- 如果尝试次数超过阈值,则对每一个 Segment 加锁,在重新统计
- 再次判断所有 Segment 的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上一次相同
- 释放锁,统计结束
总结:一开始对 Segment 不加锁,而是直接尝试将所有的 Segment 元素中的count相加,这样执行两次,然后将两次的结果进行对比,如果两次结果相同则直接返回;如果不相同,则将所有的Segment加锁,然后再次执行统计得到对应的 size 值。
七、再看看1.8是怎么实现的
1.8 抛弃了分段锁(Segment),而是采取的是 CAS + Synchronized 的方式
将1.7里面的 HashEntry 改为 Node ,但是作用是一样的。
先看看一个概念:乐观锁和悲观锁
悲观锁:认为对于同一个数据的并发操作,一定是为发生修改的
乐观锁:对于同一个数据的并发操作是不会发生修改的,在更新的时候会采取尝试更新不断尝试的方式更新数据
CAS(compare and swap,比较交换)原理:
CAS有三个操作数,内存值V、预期值A、要修改的值B,当且仅当A和V相等时才会将V修改为B,否则什么也不会做。
CAS的缺点:
存在 ABA 问题,解决办法,添加版本号
循环时间长,开销大
只能保证一个共享变量的原子操作