ConcurrentHashMap1.8源码分析
ConcurrentHashMap是在JDK5种引入的线程安全的哈希式集合,在JDK8之前采用了分段锁的设计理念,相当于Hashtable与HashMap的折中
版本,这是效率与一致性权衡后的结果。
分段锁是由内部类Segment实现的,它继承于ReentrantLock,用来管理它辖区的各个HashEntry,通过加锁的方式,保证每个Segment内都不
发生冲突。
1.8版本的ConcurrentHashMap对JDK7的版本进行了三点改造:
1)、取消分段锁机制,进一步降低冲突概率
2)、引入红黑树结构。同一个哈希槽上的元素个数超过一定阀值,单向链表改为红黑树结构
3)、使用了更加优化的方式统计集合内的元素数量。
首先,Map原有的size()方法最大只能表示到2^31-1,ConcurrentHashMap额外提供了mappingCount()方法,用来返回集合内元素的数量,
最大可以表示到2^63-1。此外,在元素总数更新时,使用了CAS和多种优化以提高并发能力。
相关属性定义:
/** * 默认为null,ConcurrentHashMao存放数据的地方,扩容时大小总是2的幂次方 * 初始化发生在第一次插入操作,数组默认初始化大小为16 */ transient volatile Node<K,V>[] table; /** * 默认为null,扩容时新生成的数组,其大小为原数组的两倍 */ private transient volatile Node<K,V>[] nextTable; /** * 存储单个KV数据节点。内部有key、value、hash、next指向下一个节点 * 它有4个在ConcurrentHashMap类内部定义的子类:TreeBin、TreeNode、ForwarddingNode、ReservationNode * 前三个子类都重写了查找元素的重要方法find(). */ static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; } /** * 它并不存储实际数据,维护对桶内红黑树的读写锁,存储对红黑树节点的引用 */ static final class TreeBin<K,V> extends Node<K,V> { TreeNode<K,V> root; volatile TreeNode<K,V> first; volatile Thread waiter; volatile int lockState; } /** * 在红黑树中,实际存储数据的节点 */ static final class TreeNode<K,V> extends Node<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; } /** * 扩容转发节点,放置此节点后,外部对原有哈希槽的操作会转发到nextTable上 */ static final class ForwardingNode<K,V> extends Node<K,V> { final Node<K,V>[] nextTable; } /** * 占位加锁节点。执行某些方法时,对其加锁,如computeIfAbsent等 */ static final class ReservationNode<K,V> extends Node<K,V> { } /** * 默认为0,重要属性,用来控制table的初始化和扩容操作 * sizeCtl=-1,表示正在初始化中 * sizeCtl=-n,表示n-1个线程正在进行扩容中 * sizeCtl>0,初始化或扩容中需要使用的容量 * sizeCtl=0,默认值,使用默认容量进行初始化 */ private transient volatile int sizeCtl; /** * 集合size小于64,无论如何,都不会受用红黑树结构 * 转化为红黑树还有一个条件是TREEIFY_THRESHOLD */ static final int MIN_TREEIFY_CAPACITY = 64;/** * 同一个哈希桶内存储的元素个数超过此阀值时,则存储结构由链表转为红黑树 */ static final int TREEIFY_THRESHOLD = 8; /** * 同一个哈希桶内存储的元素个数小于等于此阀值时,从红黑树回退至链表结构,因为元素个数较少时,链表更快 */ static final int UNTREEIFY_THRESHOLD = 6;
当某个槽内的元素个数增加到超过8个且table的容量大于或等于64时,由链表转为红黑树;当某个槽内的元素个数减少到6个时,由红黑树重新转回
链表。链表转红黑树的过程,就是把给定顺序的元素构成一棵红黑树的过程。需要注意的是,当table的容量小于64时,只会扩容,并不会把链表转为红黑树。
在转化过程中,使用同步块锁synchronized住当前槽的首元素,防止其他进行对当前槽进行增删改操作,转化完成后利用CAS替换原有链表。
因为TreeNode节点也存储了next引用,所以红黑树转链表的操作就变得非常简单,只需从TreeBin的first元素开始遍历所有的节点,并把节点从TreeNode类型转化为Node类型即可,当构造好新的链表之后,会同样利用CAS替换原有红黑树。相对来说,链表转红黑树更为复杂。
ConcurrentHashMap元素插入流程图:
ForwardingNode在table扩容时使用,内部记录了扩容后的table,即nextTable。当table需要进行扩容时,依次遍历当前table中的每一个槽,如果
不为null,则需要把其中所有的元素根据hash值放入扩容后的nextTable中,而原table的槽内会放置一个ForwardingNode节点。正如其名,此节点会
把find()请求转发到扩容后的nextTable上。而执行put()方法的线程如果碰到此节点,也会协助进行迁移。
ReservationNode在ComputeIfAbsent()及其相关方法中作为一个预留节点使用。computeIfAbsent()方法会先判断相应的Key值是否已经存在,如
果不存在,则调用由用户实现的自定义方法来生成Value值,组成KV键值对,随后插入此哈希集合中。在并发场景下,在从得知Key不存在到插入哈希
集合。在并发场景下,在得知Key不存在到插入哈希集合的时间间隔内,为了防止哈希槽被其他线程抢占,当前线程会使用一个ReservationNode节点
放到槽中并加锁,从而保证了线程的安全性。
对于size(),JDK7或者8中的size方法都只能返回一个大概数量,无法做到100%的精确,因为已经统计过的槽在size()返回最终结果前有可能又发生
了变化,从而导致返回大小与实际大小存在些许差异。在多个槽的设计下,如果仅仅是为了统计数量而停下所有的增删操作,又会显得因噎废食。因此,ConcurrentHashMap在涉及元素总数的相关更新和计算时,会最大限度地减少锁的使用,以减少线程间的竞争与互相等待。在这个设计思路下,JDK8的ConcurrentHashMap对元素总数的计算又做了进一步的优化,具体表现在:在put()、remove()和size()方法中,涉及到元素总数的更新和计算,都彻底避免了锁的使用,取而代之的众多的CAS操作。
JDK7的put和remove方法,对于segment内部元素和计数器的更新,全部处于锁的保护下,如Segment.put()方法的第一行:
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
这行代码能保证当前线程取得该Segment上的锁,随后可以大胆地更新元素和内部的计数器。
JDK7的ConcurrentHashMap获取集合大小流程图:
可以看到在JDK7版本中,ConcurrentHashMap在统计元素总数时已经开始避免使用锁了,毕竟加锁操作会极大地影响到其他线程对哈希元素的修改。
当经过了3次计算(2次对比)后,发现每次统计时哈希都有结构性的变化,这是它就会把所有的Segment都加上锁;而当自己统计完成后,才会把锁
释放掉,再允许其他线程修改哈希中的元素。
在JDK8中,对获取集合元素个数又进一步的优化,在put中,对于哈希元素总数的更新,是置于某个槽的锁之外的,主要会用到的属性如下:
/** * 记录了元素总数值,主要用在无竞争状态下,在总数更新后,通过CAS方式直接更新这个值 */ private transient volatile long baseCount; /** * 一个计数器单元,维护了一个value值 */ @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } /** * Table of counter cells. When non-null, size is a power of 2.
* 在竞争激烈的状态下启用,线程会把总数更新情况存放到该结构内,当竞争进一步加剧时,会通过扩容减少竞争 */ private transient volatile CounterCell[] counterCells;
正是借助baseCount和counterCells两个属性,并配合多次使用CAS方法,JDK8种的ConcurrentHashMap避免了锁的使用。
1):当并发量较小时,优先使用CAS的方式直接更新baseCount
2):当更新baseCount冲突,则会认为进入到比较激烈的竞争状态,通过启用counterCells减少竞争,通过CAS的方式把总数更新情况记录在
counterCells对应的位置上。
3):如果更新counterCells上的某个位置时出现了多次失败,则会通过扩容counterCells的方式减少冲突
4):当counterCells处在扩容期间时,会尝试更新baseCount值
对于元素总数的统计,逻辑就非常简单了,只需要让baseCount加上各counterCells内的数据,就可以得出哈希内的元素总数,整个过程完全不需要借助锁。
正因为ConcurrentHashMap提供了高效的锁机制实现,在各种多线程应用场景中,推荐使用此集合进行KV键值对的存储与使用。