ConcurrentHashMap的实现原理
HashMap
不是线程安全的,在多线程下会出现错误。ConcurrentHashMap
是HashMap
的线程安全版本。
JDK1.7 采用分段锁机制实现
其最主要的概念是Segment
Segment
本身就相当于一个HashMap
对象。
同HashMap
一样,Segment
包含一个HashEntry
数组,数组中的每一个HashEntry
既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
像这样的Segment
对象,在ConcurrentHashMap
集合中有多少个呢?有2的N次方个,共同保存在一个名为segments
的数组当中。
因此整个ConcurrentHashMap
的结构如下:
ConcurrentHashMap
是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。这样的二级结构,和数据库的水平拆分有些相似。 ConcurrentHashMap
中各个Segment
读写操作相互独立,互不影响,不同的Segment
之间可以并发读写;同一个Segment
读写可并发进行;Segment
的写入是需要上锁的,因此对同一Segment
的并发写入会被阻塞。
由此可见,ConcurrentHashMap
当中每个Segment
各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
ConcurrentHashMap
的两个常用方法
Get
方法:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.再次通过hash值,定位到Segment当中数组的具体位置。
ConcurrentHashMap
的get操作上面并没有加锁。所以在多线程操作的过程中,并不能完全的保证一致性,是弱一致性,关于弱一致性的原因,主要是因为Java的内存模型,写操作所做的修改并没有即时的刷到内存,导致读操作对刚写的内容不可见,可参考 http://ifeve.com/concurrenthashmap-weakly-consistent/
Put
方法:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.获取可重入锁
4.再次通过hash值,定位到Segment当中数组的具体位置。
5.插入或覆盖HashEntry对象。
6.释放锁。
size()
方法:
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
1.遍历所有的Segment。
2.把Segment的元素数量累加起来。
3.把Segment的修改次数累加起来。
4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
7.释放锁,统计结束。
size()
采用了乐观锁、悲观锁的思想。
为了尽量不锁住所有Segment,首先乐观地假设计算size的过程中不会有修改。当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性。
JDK1.8采用CAS+Synchronized
ConcurrentHashMap
在JDK1.8中的实现已经抛弃了Segment分段锁机制,利用CAS+Synchronized来保证并发更新的安全,底层依然采用数组+链表+红黑树的存储结构。
1.8相对于1.7做了改进:
改进一:取消segments字段,直接采用transient volatile HashEntry<K,V> table
保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap
类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n)
;因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN)
,可以改进性能。
详细可参考 http://blog.****.net/fjse51/article/details/55260493