ConcurrentHashMap 也是一种存储键值对的数据结构,和 HashMap 相比,它可以保证同步
HashTable 虽然也能保证同步,但由于它通过 synchronized 直接对方法加锁,并且没有引入红黑树等效率更高的数据结构,导致整体效率较差,一般很少用到
在 JDK 1.7 中,ConcurrentHashMap 的构造方法如下:
// 默认容量,通过它计算每个 Segment(段)中数组的长度
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 默认支持并发等级,这里表示最多16个线程并发操作
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 无参构造方法
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
// 包含初始容量的构造方法
public ConcurrentHashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
// 包含初始容量和负载因子的构造方法
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
}
// 通过其它 Map 初始化 ConcurrentHashMap
// initialCapacity: 默认大小和 Map 大小除以默认加载因子的最大值
// loadFactor:默认负载因子
// concurrencyLevel:默认并发等级
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
// 实际还是调用三参构造方法
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY),
DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
// 将原 Map 所有元素放入 ConcurrentHashMap
putAll(m);
}
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 最大并发等级数
static final int MAX_SEGMENTS = 1 << 16;
// 每个 Segment(段)对应 HashEntry[] 数组的最小长度
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
// 最终都会执行的构造方法
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
// 确保负载因子大于0,容量大于等于0,并发等级大于0
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
// sshift:第一个大于等于 concurrencyLevel 2 次幂数字的二进制有效位长度
// ssize:第一个大于等于 concurrencyLevel 2 次幂数字的值
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// segmentShift:第一个大于等于 concurrencyLevel 2 次幂数字的二进制无效位长度
this.segmentShift = 32 - sshift;
// 减 1 保证二进制总是 11...1 这种格式,保证分配均匀
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// initialCapacity 表示最大容量,ssize 表示有多少段
// 每段中 HashEntry[] 的数组长度
int c = initialCapacity / ssize;
// ConcurrentHashMap initialCapacity 没有转换为对应 2 次幂
// 如果存在余数,这里要加一,也就是每段数组长度加 1
if (c * ssize < initialCapacity)
++c;
// 要想分配均匀,HashEntry[] 数组长度必须是 2 次幂
// cap(默认是2):第一个大于等于 c 的 2 次幂数字的值
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 创建 Segment 对象
Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]);
// 初始化 Segment 数组,数组大小为大于 concurrencyLevel 的第一个 2 次幂
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// UNSAFE 直接操作内存,ss 就是数组对象,SBASE 值的计算下面介绍,s0 表示新创建的 Segment 对象
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
UNSAFE.putOrderedObject(Object arg1, long arg2, Object agr3),在 arg1 内存起始位置 arg2 的偏移量处保存 agr3 对象,putOrderedObject 保存完毕后会有些许延时,不保证可见性,被操作的对象必须 volatile 修饰
ConcurrentHashMap 在 JDK 1.7 基于分段锁实现,每一段实际就是一个 Segment 数组,Segment 数组的长度实际就是支持最大并发的线程数,每段又通过 HashEntry[] 数组保存元素:
- Segments[] 长度:基于 concurrencyLevel 计算得来,值为大于等于 concurrencyLevel 的第一个 2 次幂的值
- HashEntry[] 长度:基于 initialCapacity 和 Segments 长度得来,值为大于 initialCapacity / (Segments[] 长度) 的第一个 2 次幂的值
举个例子,initialCapacity 为 32,concurrencyLevel 为 4,此时 Segments[] 数组的长度就是 4,也就是最多支持 4 个线程并发操作,每个 Segment 包含一个长度为 8 的 HashEntry[] 数组,HashEntry 才是保存具体 key-value 的数据结构
保证 segments[] 数组和 HashEntry[] 数组长度总是 2 的幂次方都是为了提高效率,保证计算出的散列相对均匀,hash 结构,越均匀,查询效率越高,由于 JDK 1.7 一般采用头插的原因,对效率影响不大
segements[] 在 ConcurrentHashMap 中这样定义:
final Segment<K,V>[] segments;
回头再看 ConcurrentHashMap 构造方法中的 SBASE属性, 它的计算逻辑如下所示:
private static final long SBASE;
static {
Class sc = Segment[].class;
// 获取 Segment[] 数组第一个元素的偏移量
SBASE = UNSAFE.arrayBaseOffset(sc);
}
也就是说,ConcurrentHashMap 在初始化 Segment[] 数组同时,还会初始化 Segment[0] 位置的 Segment 对象和该对象所对应的 HashEntry[] 数组,这样做的好处在于有个原型,方便其它 Segment 节点的初始化,关于这块代码在下面源码中会看到
接下来看 Segment 类源码:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 具体保存 key-value 键值对的数组
transient volatile HashEntry<K,V>[] table;
// 负载值
transient int threshold;
// 负载因子
final float loadFactor;
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
}
由于 Segment 继承 ReentrantLock 锁对象,也就是说 Segment 对象本身可以实现加解锁操作,保证每一段同时最多只能有一个线程操作。这也是为什么一般使用 ConcurrentHashMap 而不是 HashTable 的主要原因:HashTable 由于直接对 put() 方法加锁,导致最多只能有一个线程并发执行,而 ConcurrentHashMap 支持多个线程并发执行,效率更高
接下来看 HashEntry 类源码:
static final class HashEntry<K,V> {
// hash 值
final int hash;
final K key;
volatile V value;
// HashEntry 可以通过 next 组成链表
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
到这里和 HashMap 实际已经很像了:数组 + 链表,ConcurrentHashMap 只是多维护了一层 Segments[] 数组,通过 ReentrantLock、UnSafe 保证同步,分段支持多线程,效率更高
关联类和属性介绍完了,我们开始看具体实现:ConcurrentHashMap put() 方法源码如下:
以下内容存在大量 unsafe 内存处理,这块我就不详细说明了,不然篇幅太长,全部看懂了也容易忘,大家知道整体逻辑就好
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 计算 hash 值
int hash = hash(key);
// 实际计算分到哪个 Segment,也就是哪一段,获取对应下标
int j = (hash >>> segmentShift) & segmentMask;
// unsafe 获取对应下标 Segment 对象,判断是否初始化
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
// 特别复杂,总得来说就是让 hash 值尽可能饱满一些,这些分配更均匀
private int hash(Object k) {
// hashSeed 是根据 ConcurrentHashMap 对象本身计算出的随机值
// hashSeed 一般只计算一次,否则相同 key 计算出的 hash 值不同了
int h = hashSeed;
// 字符串类型特殊处理
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
ConcurrentHashMap put() 方法主要计算 hash 值,判断对应下标的 Segment 对象是否初始化,初始化完毕后调用 Segement 对象的 put() 方法,这里先看 Segment 对象初始化方法源码:
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
// 计算应处理节点的偏移量
long u = (k << SSHIFT) + SBASE;
Segment<K,V> seg;
// 这里改用 getObjectVolatile,确保可见性,防止两个线程同时初始化同一个 Segment
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 使用下标 0 位置元素作为原型,这里可以回想原型模式,也是个学习的过程
// 初始化 HashEntry 数组 Segment
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
// 再次判断是否其它线程已经初始化对应下标
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 创建 Segment 对象
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 循环判断还没有初始化
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// CAS 循环处理,防止二次初始化
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
初始化 segment 对象后,调用它的 put() 方法保存 key-value 键值对,下面看 Segment 的 put() 方法源码:
// onlyIfAbsent 主要用来判断是否已经存在,默认 put() 会覆盖旧值,putIfAbsent() 方法不会覆盖
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// tryLock() 方法尝试获取锁,它不会阻塞,lock() 方法会阻塞当前线程,等待锁资源
// 获取锁成功啥都不做,否则执行 scanAndLockForPut() 方法
HashEntry<K,V> node = tryLock() ? null :scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
// 计算下标
int index = (tab.length - 1) & hash;
// 获取对应下标 HashEntry 对象
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// key 相等 或 hash 相等,key 相等
// 前面针对基础类型值比较,后面针对引用类型
if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// 如果存在覆盖旧值并返回旧值
e.value = value;
// modCount 记录当前 Segment 有效操作次数
++modCount;
}
break;
}
e = e.next;
}
else {
// 初始化新的 HashEntry
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
// count 表示当前 Segment 的元素数量
int c = count + 1;
// 是否需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// 通过 unsafe 在指定位置头插对象
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
put() 方法执行前会尝试获取锁,获取锁失败执行 scanAndLockForPut() 方法,否则开始遍历,找到相同 key 覆盖旧值,否则创建新的 HashEntry 对象,采用头插的方式保存,同时判断是否需要扩容
这里有两个很容易搞混的概念需要区分:
- count:当前 Segment 保存的元素数量
- modCount:当前 Segment 有效操作的次数,用于 ConcurrentHashMap isEmpty() 方法,这个最后再说
回头看获取锁失败执行的 scanAndLockForPut() 方法,它的源码如下:
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
// 获取对应下标第一个对象
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1;
// 假如一直没有抢到锁
// 模拟自旋过程
while (!tryLock()) {
HashEntry<K,V> f;
// 默认执行的代码块
// 如果对应下标存在或不存在相等key都跳出
// 不存在时初始化 HashEntry 对象
if (retries < 0) {
// 遍历到链表结尾仍没有初始化
if (e == null) {
if (node == null)
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
// 如果对应下标存在相等的元素
else if (key.equals(e.key))
retries = 0;
// 向后遍历
else
e = e.next;
}
// 达到最大自旋次数就阻塞等待
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
// 由于采用头插法,如果头节点被其它线程替换,则重新开始遍历
else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) {
e = first = f;
retries = -1;
}
}
// 返回自旋期间初始化的节点
return node;
}
scanAndLockForPut() 方法主要就是自旋,如果不存在相同 key,提前初始化 HashEntry 对象,自旋达到一定次数仍没获取到锁就阻塞,没做啥特殊处理
总得来说 put() 操作需要知道以下几点:
- put() 开始时需要加锁,防止多个线程同时处理同一个 Segment
- segment 初始化采用 double check 以及 CAS 的方式保证同步
- segment 和 hashEntry 的下标计算都是通过唯一 hash 值,只是计算方法不同
put() 看完了,再看看相对简单的 get() 方法:
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 对应 segment 不为空,segment 对应 hashEntry[] 不为空
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
// 获取 hashEntry 对应下标首个元素,循环遍历,全程使用 getObjectVolatile 保证可见性
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;
}
get() 方法相对简单,一个方法就全写完了,无须加锁,主要基于 unsafe 类直接操作内存,保证每次取到最新值
get()、put() 都说完了,再看看 rehash() 扩容方法源码:
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 扩为原来两倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
// 方便计算原节点在新数组的下标
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算新下标
int idx = e.hash & sizeMask;
// 当前下标只有一个元素
if (next == null)
newTable[idx] = e;
else {
// 找出链表最后几个新下标相等的元素,lastRun记录它们中第一个
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
newTable[lastIdx] = lastRun;
// 其它节点依次计算,直到 lastRun
// lastRun 及其以后的元素新下标相同并且已处理
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
// 获取对应下标头节点
HashEntry<K,V> n = newTable[k];
// 替换头节点,新节点 next 指向原来的头节点
// 妥妥的头插法
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 处理引起扩容的元素
int nodeIndex = node.hash & sizeMask;
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
从这也就可以看出,ConcurrentHashMap 采用分段扩容的思想,也就是说每个 Segment 独立处理,满足扩容条件就扩容为原来的两倍,如果下标 0 位置扩容,新初始化的 Segment 也将以扩容后的 Segment 为原型初始化
需要注意的一点是整个扩容过程并不完全是头插,而是部分头插,举个例子:
HashEntry[] 数组长度为 2,下标 1 位置存在链表 a>b>c>d>e>f,扩容后 a 的下标为 3,b 为 1,c 为 3,d、e、f 都为1。根据上面的算法,首先最后三个元素 d、e、f 新下标相等,直接放到新下标位置,前面的三个元素依次采用头插的方式处理,新数组元素顺序为:
下标 1:b(头插来)-> d -> e -> f(后三个元素直接移动过来)
下标 3:c(头插来)-> a(头插来)a 先插
a、b、c 是满足头插的,d、e、f 是直接移动过来的,这样做肯定也是出于效率考虑
最后需要注意 ConcurrentHashMap Segments[] 不扩容,扩容的是 HashEntry[] 数组,也就是说最大线程数初始化后就不变了,ConcurrentHashMap 的容量也只是用来计算 Segments 初始容量,后续每个 Segments 随着元素数量增多扩容
最最后我们来看 modCount 的作用:它只在 isEmpty() 方法中用到,具体代码如下:
public boolean isEmpty() {
long sum = 0L;
final Segment<K,V>[] segments = this.segments;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
// segment count 不为0,说明一定不为空
if (seg.count != 0)
return false;
// 为 0 时也不一定为空,通过 modCount 决定
sum += seg.modCount;
}
}
if (sum != 0L) {
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
if (seg.count != 0)
return false;
sum -= seg.modCount;
}
}
if (sum != 0L)
return false;
}
return true;
}
如果两个 for() 循环前后,modCount 相等,说明这段时间 ConcurrentHashMap 没有任何有效操作,并且每个 Segment 数组 count 为 0,两者合起来才能说明 ConcurrentHashMap 为空,否则说明仍存在有效操作,存在有效操作说明还有元素
好了,到这里 JDK 1.7 版本 ConcurrentHashMap 源码就全部介绍完毕了。总结也懒得写了,因为实际每行注释都是干货。只看总结容易知其然而不知其所以然,还是建议大家能全篇读下来,如果有问题欢迎评论区指针
本来打算 1.8 一起写的,篇幅实在太长就算了。下篇 1.8 我就不逐行逐行解释了,太耗费脑力,挑重点说说算了,也轻松一点