之前我们已经学习了HashMap,也知道HashMap是线程不安全的,今天我们就来学习线程安全的ConcurrentHashMap. 先来学习JDK7版本的.
使用
ConcurrentHashMap的使用类似HashMap
public static void main(String[] args) {
ConcurrentHashMap<Object, Object> concurrentHashMap = new ConcurrentHashMap<>();
concurrentHashMap.put(1,"x");
System.out.println(concurrentHashMap.get(1));
}
// 输出
x
数据结构
ConcurrentHashMap使用了双数组 + 链表的数据存储结构.
Segment [ ] 数组每个元素都是一个HashEntry [ ] 数组.(HashEntry类似HashMap的Entry对象)
// HashEntry
final int hash; // key的hash值
final K key; // key
volatile V value; // value
volatile HashEntry<K,V> next; // 链表的下一个节点
这种设计在 JDK 7 中被用来提高并发性能,允许多个线程在访问不同 Segment 时并行操作,从而降低了锁的竞争。每个 Segment 都包含了自己的哈希表,因此在操作时只需锁住对应的 Segment,而不影响其他 Segment。
构造方法
我们直接来看默认无参构造方法
// 默认无参构造
// DEFAULT_INITIAL_CAPACITY:HashEntry初始容量
// DEFAULT_LOAD_FACTOR:加载因子
// DEFAULT_CONCURRENCY_LEVEL:Segment[]的长度
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
// ->
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
// 检查加载因子、初始容量和并发级别是否合法
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 限制并发级别的最大值
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
// 计算 Segment 数组的大小,找到不小于并发级别的最小的 2 的幂次方数
// 类似HashMap中数组的大小(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;
// 这里计算每个Segment下HashEntry [] 数组的大小
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 每个Segment下HashEntry [] 数组最小长度是2
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 创建segments[0],后续创建segment就从这里拿去信息,不用重复计算
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
// 创建 segments
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 有序地将第一个 Segment 放入 segments 数组的第一个位置
UNSAFE.putOrderedObject(ss, SBASE, s0);
// 构建好Segment[]
this.segments = ss;
}
// 总结
这里主要是计算Segment[]大小,计算每个Segment下的HashEntry[]大小(最小为2),
然后默认创建segment[0]位置的Segment,将创建Segment的信息放入,后续创建Segment可以从这里获取,不用重复计算.
put元素
看完构造方法,我们就来探究ConcurrentHashMap存储元素的过程:
public V put(K key, V value) {
Segment<K,V> s;
// value不能为null
if (value == null)
throw new NullPointerException();
// 计算hash值(类似HashMap),若key为null会报错
int hash = hash(key);
// 获取Segment[]的所在索引
int j = (hash >>> segmentShift) & segmentMask;
// 尝试获取该索引上的Segment
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 若为空-> 则去创建Segment
s = ensureSegment(j);
// 操作Segment去存储元素
return s.put(key, hash, value, false);
}
ensureSegment(j)
这段代码的主要作用是确保对给定索引处的 Segment 进行安全的获取或创建.
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // 原始偏移量
Segment<K,V> seg;
// 获取 Segment 数组中指定索引位置的 Segment
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 使用第一个 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]; // HashEntry 数组
// 再次检查 Segment 是否为空
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 如果为空,创建新的 Segment
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 不断尝试将新的 Segment 放入数组中
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 使用 CAS 操作确保线程安全地设置新的 Segment
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg; // 返回获取到或新创建的 Segment
}
Segment存储元素
// 现在来看在Segment存储元素
return s.put(key, hash, value, false);
// ->
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 尝试获取锁或扫描节点以获取锁
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<K,V> first = entryAt(tab, index);
// 遍历节点
for (HashEntry<K,V> e = first;;) {
// 如果节点不为空
if (e != null) {
K k;
// 如果键值对已存在
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 {
// 如果节点为空
if (node != null)
// 将新节点设置为链表的第一个节点
node.setNext(first);
else
// 创建新节点
node = new HashEntry<K,V>(hash, key, value, first);
// 增加计数器
int c = count + 1;
// 如果节点数量超过阈值,进行扩容操作
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
// 设置节点到数组对应索引位置
setEntryAt(tab, index, node);
++modCount;
count = c;
// 设置旧值为空
oldValue = null;
break;
}
}
} finally {
// 最终释放锁
unlock();
}
// 返回旧值
return oldValue;
}
扩容rehash
上述涉及到了扩容,注意ConcurrentHashMap的扩容指得是Segment下的HashEntry数组.
现在让我们来看下扩容的流程:
// 当tab长度大于阈值小于最大值就会进行扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
// ->
private void rehash(HashEntry<K,V> node) {
// 取出老数组
HashEntry<K,V>[] oldTable = table;
// 新的数组长度是老数组的2倍
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) // Single node on list
// 该元素没有下一个节点,说明该位置只有一个元素 -> 直接转移到新数组
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// 这里说明该位置是链表
// 记录上一个元素
HashEntry<K,V> lastRun = e;
// 记录上一个元素的数组索引
int lastIdx = idx;
// 遍历链表(第一个循环)
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
// 计算下一个节点的数组索引
int k = last.hash & sizeMask;
// 若与上一个节点不在同一个索引下 -> 重新赋值lastIdx、lastRun
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将lastRun指向的节点元素放入新数组
newTable[lastIdx] = lastRun;
// Clone remaining nodes
// 遍历,这里也是头插法(第二个循环)
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];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
为了演示上述扩容数组转移的逻辑,请结合下图:
// 元素从旧数组转向新数组的索引可能与原来相同或者比原来多旧数组的长度
// 现在假设旧数组有一个链表1-2-3-4,然后向新数组转移,模拟一种场景,1和3节点放到新数组同一个位置,2和4一起。
// 可以看出转移后的链表元素相对顺序可能改变也可能不变.
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;
// 获取对应段并检查表是否存在
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 在哈希表数组中查找键对应的条目
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;
}
}
// 未找到对应键的条目,返回null
return null;
}
总结
JDK7 中的 ConcurrentHashMap 通过分段锁和哈希表的组合实现了高效的并发操作。每个 Segment 独立进行操作,通过减小锁的粒度来提高并发性能。下节我们来看JDK8中对ConcurrentHashMap又有哪些改进!!!