ConcurrentHashMap(1.8)
HashTable 是早期的线程安全的哈希表, 但是锁的范围太大了, 其 put, get 方法都有 synchronized 关键字修饰, 锁的范围是 hashtable 对象, 并发度太低;
JDK1.7 的 ConcurrentHashMap ( 以下简称为 CHM ), 锁的范围是一个段, 段的数量可以在构造的时候指定, 又称并发级别;
JDK1.7版本 CHM 的详细实现原理移步 【ConcurrentHashMap】JDK1.7版本源码解读与分析
但是1.7版本的 CHM 并发度还是不够高, 能不能进一步减小锁的粒度? 如果能锁 Entry 数组的一个下标, 并发度岂不是又可以提升一大截?
JDK1.8 版本 CHM 的实现方案, 就是通过锁住 Entry 数组的一个下标来实现的;
对 Entry 数组或哈希表原理有疑问请移步 HashMap源码解读与分析;
JDK 1.8 版本的 CHM, 整体架构上来说, 和 JDK1.8 版本的 HashMap 是基本一致的, 只不过在一些细节上做了特殊处理, 重要的部分下文都会提到; 下文提到的 HashMap 和 CHM, 均指JDK1.8版本的实现;
关于红黑树, ConcurrentHashMap的数组中放入的不是TreeNode结点,而是将TreeNode包装起来的TreeBin对象
哈希值
CHM 对 key 计算哈希值的方法是 spread 方法, 在 HashMap 的基础上, 多了一个最高位恒置为 0 的操作;
- 为什么要异或自己右移16位? 哈希表使用哈希值的时候, 是用来对数组长度取模的(虽然最终是通过取与实现的), 这样的话, 一个哈希值, 就只有低若干位有效, 具体取决于数组的长度; 通过异或自己右移 16 位, 可以将高位的信息带入到低位, 增加扰动;
- 例如 Float 类型, 使用 IEEE754 编码, 底层是一个长 32 位的二进制值, 其 hashCode 方法返回的就是底层编码本身, 没有经过任何处理; 连续的 Float 类型的整数, 他们的二进制编码的低几位很有可能都是一样的(这是由 IEEE754 的特性导致的), 这样的话他们进行散列的时候, 一定会碰撞; 通过移位异或, 可以减少这种类型的碰撞;
- 为什么最高位无效(恒为0)? 因为 CHM 会将结点的 Hash 值设置为负数来表示一些特殊状态, -1表示ForwardingNode结点,-2表示TreeBin结点。
- ForwardingNode节点是Node节点的子类,hash值固定为-1,只在扩容 transfer的时候出现,当旧数组中某个位置的全部节点都迁移到新数组中时,就在旧数组中放置一个ForwardingNode。读操作或者迭代读时碰到ForwardingNode时,将操作转发到扩容后的新的table数组上去执行,写操作碰见它时,则尝试帮助扩容。
PUT
初始化
GET
可以看出, get 的逻辑全程没有涉及到加锁的操作, 在这种情况下, 如何保证其他线程的修改能立即可见? 如何保证扩容时不会因为元素转移而漏掉数据?
一, Node 也就是 Entry, 其 val 成员和 next 成员, 都由 volatile 修饰, 其它线程所做的修改或添加, 能够立即被感知;
需要注意, 底层的 Node 数组虽然加了 volatile, 但只能保证引用的修改具有可见性, 而非其中元素具有可见性
Node 数组被 volatile 修饰, 能保证扩容后 table 引用的变化能够立即被感知;
二, 另外, 如果 get 的时候正在扩容, 读到 ForwardingNode 时会被转发到 nextTable 执行读操作;
ForwardingNode节点是Node节点的子类,hash值固定为-1,只在扩容 transfer的时候出现;
当旧数组中全部的节点都迁移到新数组中时,就在旧数组对应位置放置一个ForwardingNode。读操作或者迭代读时碰到ForwardingNode时,将操作转发到 nextTable数组上去执行,写操作碰见它时,则尝试帮助转移元素, 转移完了再put。
三, 如果正在扩容, 但是当前结点又没有完成迁移, 没有放置 ForwardingNode, 怎么办? 不用担心, 迁移的过程并不是将Node一个一个从oldTable移除, 添加到 nextTable, 而是先复制到 nextTable 上, 最后再删除 oldTable 中的引用;
扩容
使用了一个 volatile 的辅助数组进行扩容;
支持多线程扩容; 每个线程负责转移某些下标;
转移的时候从右往左扫描, transferIndex 代表待转移的下标 + 1;
每个线程会负责迁移一定长度的区间内的下标, 转移完了以后尝试去取下一个转移范围;
可以理解为分段转移元素, 每个线程负责若干个段; 段的默认最小长度 (用变量 stride 表示)是 16, 也就是说, 如果当前 CHM 长度为 16, 那么最多只能有一个线程参与扩容;
每个线程开始转移的时候, 都会确认自己负责的边界,同时会更新 transferIndex, transferIndex 的初始值为扩容前数组的长度, 第一个参与扩容的线程, 由 transferIndex 计算出自己负责的范围, 即[tranferIndex - stride, transferIndex - 1], 然后将 transferIndex - stride 赋给 transferIndex;
转移时, 会用 synchronized 锁住当前下标的第一个结点;
一个下标转移完成后, 给扩容前的数组的对应位置, 插入一个 ForwardingNode