声明
本文对 java.util.concurrent.ConcurrentHashMap
的相关讨论全部基于 JDK1.7 源码.
阅读本文前,需要读者对 java.util.HashMap
有一定程度的了解, 比如基于 K-V 存储, 底层数据结构等.
正文
接下来会基于以下五大块展开来讲:
-
ConcurrentHashMap 的相关介绍
-
ConcurrentHashMap 的构造函数
-
ConcurrentHashMap 的 put 方法
-
ConcurrentHashMap 的 get 方法
-
ConcurrentHashMap 的哈希寻址算法
一、ConcurrentHashMap 介绍
ConcurrentHashMap
同 Hashtable
一样, 它的所有操作都是线程安全的.
不同的是在操作 Hashtable
时, 会锁住整个 “表”, 而 ConcurrentHashMap
采用了分段锁(Segment) 、并利用并发编程的可见性和原子性原理, 根据并发度(构造参数, 默认为 16)将所有数据节点划分到 N 个区域 (Segment
), 对每个区域分别加锁, 当一个线程访问其中一个区域同时, 不妨碍其他线程访问其他区域.
所以 ConcurrentHashMap
要比 Hashtable
的效率高.
既然 ConcurrentHashMap
是线程安全的, 那阅读源码时, 每个操作步骤, 我们都要想着如果两个线程同时访问, 会出现什么问题 以及 ConcurrentHashMap
是如何解决的.
1.1 ConcurrentHashMap 底层数据结构
-
ConcurrentHashMap
内部维护了一个Segment[]
数组 , 这个数组的长度是不会发生改变的. -
Segment
内部维护了一个HashEntry[]
数组, 这个数组是可以动态扩容的. -
HashEntry
是一个单向链表.
1.1.1 HashEntry
不管是 HashMap
、还是 ConcurrentHashMap
, 不管是 jdk1.7 还是 1.8, 它的元素定义基本都是如下所示, 只不过名字不一样, 有的叫 Node
、有的叫 HashEntry
、TreeNode
等等. 在 jdk1.7 的 ConcurrentHashMap
里, 这个节点叫做 HashEntry
. 每个 HashEntry
又可以是一个单向链表.
class 节点 {
int hash;
K key;
V value;
// 节点 next;
}
1.1.2 Segment
前面提到, ConcurrentHashMap
被划分成了 N 个区域, 每个区域都有自己的锁, 这个区域的定义如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
// 其他省略
}
- 从源码中可以得知,
Segment
继承了ReentrantLock
, 所以自带锁操作, 至于锁怎么用, 后面源码分析会细讲. Segment
中维护了一个HashEntry
数组, 如果了解HashMap
源码的, 八一将这个Segment
比作一个HashMap
.
总体来讲, 如果把 HashMap
比作一级哈希表的话, 那么ConcurrentHashMap
就类似于一个二级哈希表, 当我们想要访问某一个元素时, 会先经过一个 hash 寻址, 定位到某个 Segment , 然后再通过一个 hash 寻址, 最终定位到 HashEntry
节点.
二、ConcurrentHashMap 的构造函数
ConcurrentHashMap
有 5 个构造函数, 我们拿其中一个最核心的来讲.
-
initialCapacity
ConcurrentHashMap
的初始容量, 表示能容纳多少个HashEntry
, 并非Segment
. -
loadFactor
负载因子, 跟扩容
Segment
中的HashEntry
数组相关. -
concurrencyLevel
并发级别, 表示同时最多可以支持多少个线程操作, 默认为 16.
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
// 参数检查
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// concurrencyLevel 最大值 65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 这个先不用管,往下看
int sshift = 0;
// ssize 为 segment 数组的长度, 为了方便后面的运算, ssize 最好为 n 的2 次方
// 举几个例子:
// 当 concurrencyLevel 为 10 时, ssize 就等于 16, 即 2 的 4次方
// 当 concurrencyLevel 为 3 时, ssize 就等于 4, 即 2 的 2次方
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// segmentShift 和 segmentMask 就是为了计算 Segment 索引的. 后面再讲
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// c 就是为了辅助计算每个 Segment 中的 HashEntry 容量
int c = initialCapacity / ssize;
// 这里加上上一步, 就是要达到 5/3=2, 7/2=4 这样的效果, 向上取整
if (c * ssize < initialCapacity)
++c;
// 通过 c 变量计算出的 cap 值才是每个 Segment 中的 HashEntry 真正容量
int cap = MIN_SEGMENT_TABLE_CAPACITY;
// 跟上面 ssize 的原理一样. 需要取 2 次方
while (cap < c)
cap <<= 1;
// 根据上面的计算的值, 创建 Segment 数组, 并创建一个 Segment 对象.
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// UNSAFE 类提供了内存操作, 这里可以直接将 s0 对象,放到 ss 数组的第 0 个位置.
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
总结:
简单来讲, 通过 ConcurrentHashMap
的构造函数, 就是做了两件大事:
-
进行了一系列的计算
Segment[]
数组的长度- 每个
Segment
中HashEntry
的容量 - 提前计算好哈希寻址需要的一些数据.
-
初始化
- 初始化
Segment[]
数组 - 在
Segment[]
数组的第 0 个位置, 放置一个Segment
对象.
- 初始化
当然, 里面还有很多细节, 比如, 为什么那些值非得取 n 的二次方, UNSAFE 类的原理等等, 文末会简单聊一聊.
三、ConcurrentHashMap 的 put 方法
public V put(K key, V value) {
Segment<K,V> s;
// 不支持 null value
if (value == null)
throw new NullPointerException();
// 对 key 进行 hash 运算
int hash = hash(key);
// 这里就用到了构造函数中通过计算得来的两个变量 segmentShift 和 segmentMask
int j = (hash >>> segmentShift) & segmentMask;
// 同样通过 UNSAFE 类取出对应内存地址的 segment 对象
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 如果没有, 就新建一个 segment 对象
s = ensureSegment(j);
// 调用 segment 的 put 方法,
return s.put(key, hash, value, false);
}
3.1 Segment 的 ensureSegment方法
private Segment<K,V> ensureSegment(int k) {
// 拿到 segment 数组
final Segment<K,V>[] ss = this.segments;
// 计算内存地址
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
// 这里其实还是想再次通过 UNSAFE 类的方法去从内存中取 segment 对象
// getObjectVolatile 能保证拿到的值是最新的.
// 没有这一步其实不影响结果, 只是为了提高性能, 因为很可能其他线程已经把 Segment 对象创建好了,
// 所以这里叫 recheck
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 之前在构造函数中, 已经默认在 segment 数组的第 0 个位置创建了一个 Segment 对象.
// 而且每个 Segment 对象的基本属性值都是一样的, 比如 HashEntry 的容量, 负载因子.
// 所以这里就不用重新计算了, 直接复用
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 创建 HashEntry 数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
// 这里还是 recheck, 跟上面一样.
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 创建 Segment 对象
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 通过 cas 操作, 把 Segment 对象放到数组中去
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
3.2 Segment 的 put 方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 这行代码就是获取锁, 前文有提到 Segment 继承自 ReentrantLock, 自带锁的特性.
// scanAndLockForPut 后面细讲, 这里简单提一下, tryLock 只是尝试获取一次锁, 是不会阻塞的
// 如果尝试获取锁失败, 就会进入 scanAndLockForPut, 再去不断地获取锁(这里面有一些策略),
// 获取锁的过程中, 不能什么事都不干呀, 就会去尝试创建 HashEntry 对象
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
// 拿到 Segment 内部维护的 HashEntry 数组
HashEntry<K,V>[] tab = table;
// hash 寻址, 计算数组索引位置
int index = (tab.length - 1) & hash;
// 拿到对应数组位置的头节点
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
// 头节点不为空
if (e != null) {
K k;
// key 相同, 就覆盖旧值
if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
} else {
// 头节点为空, 并且如果获取锁的过程中, 创建了 HashEntry 节点, 就采用头插法
// 插入到 HashEntry 链表中
if (node != null)
node.setNext(first);
// 如果获取锁的过程中, 没有拿到 HashEntry 节点.
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// rehash 其实就是扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// 把作为链表头节点的 node 节点放到 tab 数组中
setEntryAt(tab, index, node);
// 统计 segment 中的修改次数
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
3.3 Segment 的 scanAndLockForPut 方法
scanAndLockForPut()
方法应该是截止目前比较难, 同时又是比较有意思的源码了.
读完下面这段话, 再去看源码
简单来讲, 该方法就是去获取锁, 获取锁是最终目的, 但是在获取锁的过程中, 又不想闲着, 所以会尽可能想多做一点事, 或者说提前做一些事, 但是这些事能不能做成其实对后续的流程不影响.
另外, 这个方法是获取锁, 还并没有真正拿到锁, 可以多个线程同时访问, 要带着这个去看源码.
// 承接上文的获取锁
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
// 拿到当前 segment 的对应 HashEntry 链表的头节点.
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
// 我们希望在获取锁过程中, 在某种条件下尽可能创建一个 HashEntry 节点, 并返回
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
// 如果没有获取到锁, 就不断地去尝试获取锁
// 注意 tryLock 和 lock() 的区别, 前者是非阻塞的
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
// 这里需要回过头来, 总结下什么时候开始这个 if 条件不成立
if (retries < 0) {
// 如果头节点为空, 就预先创建一个节点.
// 这里联系上面 put 方法
// 顺便提一句, 其实源码中很多操作都是为了提高性能, 所以用了巧妙的做法
// 即使没有这些做法, 也不会影响最终操作结果.
if (e == null) {
// speculatively 表示投机, 推测
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
// 如果头节点不为 null, 且当前插入的 key 跟头节点的 key 相等
else if (key.equals(e.key))
retries = 0;
// 从头节点往下遍历
else
e = e.next;
}
// 超过最大尝试次数, 就进行阻塞式的获取锁操作.
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
// (retries & 1) == 0 就是当前重试次数达到偶数次
// 如果在获取锁的过程中, 头节点被修改了, 就得重头来过.
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
// 如果下次 while 判断没有拿到锁, 不就相当于从头来过嘛.
retries = -1;
}
}
// 由上可知, 这个 node 是可以为 null的.
return node;
}
当上面每一行都看懂, 每一段都看懂后, 最后要纵观全局去分析.
四、 ConcurrentHashMap 的 get 方法
明确亮点:
-
get()
方法是没有加锁的, 所以效率很高 -
UNSAFE.getObjectVolatile()
方法是根据内存地址获取对应的对象, 并且根据 volite 修饰词, 可以得知拿到对象一定是最新的. 但也仅限于遍历起始时, 遍历过程中, HashEntry 链表还是会被修改的. 所以才有了官方的一条说法:get()
方法只能保证看到之前完成的操作,无法保证看到正在进行中的操作. 简称 弱一致性.
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
// 根据当前 key 获取 Segemnt 数组中的对应内存地址
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 根据内存地址 拿到 对应的 segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 同理拿到 segment 中 HashEntry 链表, 开始遍历.
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
五、 ConcurrentHashMap 的哈希寻址算法
哈希算法和哈希寻址算法内容有很多, 不是本文的重点, 简单一点可以参考 [jdk1.8 中 HashMap 的 hash 算法和数组寻址]
ConcurrentHashMap
里有两个地方用到了 通过 hash 寻址, 获取 数组索引.
-
ConcurrentHashMap#put 方法
// 在构造函数中预先计算了两个值 segmentShift 和 segmentMask // concurrencyLevel 默认为 16, 下面基于这个 16 进行计算 int sshift = 0; int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } // 一通计算 sshift 为 4, ssize 为 16 this.segmentShift = 32 - sshift;// 28 this.segmentMask = ssize - 1; // 15 // 在 put 方法中用到了segmentShift 和 segmentMask int hash = hash(key); // 11010110 11010110 11010110 11010110 int j = (hash >>> segmentShift) & segmentMask;// (hash >>> 28) & 15 hash >>> 28 位, 相当于让 hash 值的高四位参与后面的运算. 因此最终结果 j = 13, 而 segment 数组长度是 16.
-
Segment#put 方法
熟悉 HashMap 源码的, 下面这段代码不会陌生.
int index = (tab.length - 1) & hash;
总结
本文基于 JDK1.7 的源码逐行分析了 ConcurrentHashMap
的 put
方法和 get
方法的执行流程.
ConcurrentHashMap
源码的难点在于为了提高操作效率, 作了很多设计, 比如:
-
多次
recheck
目的是尽量能让方法提前结束. 尽量不去做锁操作. -
通过
while(!tryLock())
获取锁过程中, 会消耗CPU内存, 但是为了不浪费, 就在循环中去提前创建对象.
这些设计会让读者误以为这行代码没有了, 可能结果就不一样了, 最终导致思维长时间卡在某个点上跳不出来.
但是文中涉及到的一些 hash 算法, 以及 UNSAFE 类提供的一些“骚操作”, 本文并未详细讲, 感兴趣的可以自行了解.