ConcurrentHashMap从名称是可以看出,它是一个HashMap而且是线程安全的。在多线程编程中使用非常广泛。
ConcurrentHashMap的实现方式,在jdk6,7,8中都不一样。本文只针对jdk8中的实现作一些说明。
ConcurrentHashMap实现原理
先来看看ConcurrentHashMap底层是发何实现的。总的来说,它是采用Node<K,V>类型(继承了Map.Entry)的数组table+单向链表+红黑树的结构。table数组的大小默认为16,数组中的每一项称为桶(bucket),桶中存放的是链表或者是红黑树结构,取决于链表的长度是否达到了阀值8(大于等于8)(默认),如果是,接着再判断数组的长度是否小于64,如果小于则优先扩容table容量来解决单个桶中元素增多的问题,如果不是则转换成红黑树结构存放。
再次,我们看到ConcurrentHashMap类中,Unsafe类。说明线程安全的实现是基于CAS算法的无锁化修改值的操作,它可以大大降低锁带来的性能消耗。其基本思想是不停的去比较当前内存中的变量值与给定的值是否相同(值相等且引用也相等),如果相同则修改成指定的值,否则什么也不做。这与乐观锁的思想类似。缺点就是消耗CPU性能。
1 | private static final sun.misc.Unsafe U; |
源码分析
先来看看ConcurrentHashMap扩容是如何发生的,主要是在put一个KV时,如果达到某些阀值则会重新new一个nextTable其长度是原table的2倍。
1 | public V put(K key, V value) { |
上面是对put操作的整个流程的分析,可以看出需要关注的几个点
- hash算法及table下标i的计算方法
- 首次放元素时,initTable方法做了哪些事情
- 当前为正在扩容时help做了哪些操作?
- table中的元素有可能是链表结构,也有可能是红黑树结构
- 什么条件下会去执行链表转换成红黑树?
下面,我们先来看看链表转成红黑树的方法操作
1 | /** |
还有一个就是addCount方法,这个方法在执行时,有可能会触发扩容操作
1 | private final void addCount(long x, int check) { |
在多线的环境下,用volatile的方式读取sizectrl属性的值,来判断map所处的状态,通过cas修改操作来告诉其它线程Map的状态类型。不同的数值类型,代表着不同的状态:
- 未初始化
- 等于0,表示未指定初始化容量,则使用默认容量
- 大于0,为指定的初始化容量
- 初始化中
- 等于-1,表示正在初始化,并且通过cas告诉其它线程
- 正常状态
- 等于原table长度n*0.75,扩容阀值
- 扩容中
- 小于0,表示有其他线程正在执行扩容操作
- 等于(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2表示此时只有一个线程在执行扩容
接下来我们来看看扩容方法
1 | /** |
关于上面迁移链表的操作,比较有意思,我们来分析一下。还记得,在putVal方法有有一段代码
1 | else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) |
用于计算tab中元素的下标的,n就是tab的长度,只会是2的x次幂,先来熟悉一下&运算,它是指对应的二进制位上,如果都是1则结果为1,否则为0。假设现在,tab的长度为16,换成二进制就是10000,减1就是01111,取hash的值,这个值有点特别,就是从右起第x位(log以2为底的16)=4(从0开始数)。如果是10000&此数,则结果一定是0,例如:
1 | 0000000000010000 0000000000001111 |
如果此时tab扩容到32,也就是100000,再来看看(n-1)&hash的结果
1 | 0000000000011111 |
说明,如果右起第x位为0的话,runbit==0成立,此时扩容到原来的2倍的话在新数组中的下标是不变的,所在可以看到把ln链表直接放到nextTable的i位了。
再来看看,右起第x位为1的情况
1 | 0000000000010000 0000000000001111 |
如果此时扩容到了32,也就是100000时,再来看看(n-1)&hash的结果
1 | 0000000000011111 |
即扩容后新下标变成了25,也就是原来的下标9再加扩容的量16,就是i+n的结果,所以对于hn来说在新table中的位置就变成了i+n了。
总结
通过代码我们可以看出,这里面的思想还是值得学习借鉴的。下标取(n-1)&hash并不是随便设计出来的,而是经过精心设计的。扩容后,桶的数量发生了变化,但无论是当前时刻使用的是新table还是扩容后的table访问的位置相对table长度来说都没有发生变化,为访问get提供便利。扩容时也不用重新计算hash值,同时结合多线程操作扩容提升操作效率。