目录
ConcurrentHashMap是Java中一个非常重要的并发集合类,它提供了线程安全的哈希表实现。其初衷是为了优化同步HashMap,减少线程竞争,提高并发访问效率。随着Java的发展,ConcurrentHashMap在1.7和1.8中经历了显著的变化。以下内容将深入探索这两个版本的区别,同时结合源码和底层实现来进行说明。
1. Java 1.7中的ConcurrentHashMap
在Java 1.7(及之前的版本)中,ConcurrentHashMap采用了分段锁(Segmentation)的概念,其核心是将数据分成一段一段地存储,然后为每一段数据配备一把锁。
1.1 核心实现
在Java 1.7中,ConcurrentHashMap内部维护了一个Segment
数组。每个Segment继承自ReentrantLock
并且它内部本质上是一个Hash表
。这样做的好处是能够减小锁的粒度,提高并发访问的效率。默认Segment 数量为 16
,可以通过构造函数来修改默认值。当需要put或get一个元素时,线程首先通过hash定位到具体的Segment,然后在对应的Segment上进行锁定操作。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 省略其他属性和方法
// Segment内部的HashEntry数组
transient volatile HashEntry<K,V>[] table;
Segment(float loadFactor, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = loadFactor;
this.threshold = threshold;
this.table = tab;
}
// 其他方法...
}
1.2 写操作
计算Hash和定位Segment
: 首先,根据键的hashCode计算hash值,并使用这个hash值找到对应的段(Segment)。每个段内部都有一组桶(Bucket),用于存储键值对。获取Segment锁
:在写入操作开始之前,需要获取目标Segment的锁。这是通过ReentrantLock来实现的,确保同一时间只有一个线程可以操作该Segment内的数据。计算桶的位置
:在获取到Segment锁之后,根据hash值进一步计算具体的桶(Bucket)位置。每个桶用于存储具有相同hash值的键值对。
处理桶中的节点
桶为空
:如果桶中还没有节点,则直接创建一个新节点并放入桶中。桶中已有节点
:如果桶中已经存在节点,则需要根据节点的类型(链表或红黑树)进行相应的处理。对于链表,可能需要遍历链表来找到插入位置或替换已有节点;对于红黑树,则需要在树上执行相应的插入或更新操作。
public V put(K key, V value) {
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int i = (hash >>> segmentShift) & segmentMask;
return segments[i].put(key, hash, value, false);
}
1.3 读操作
对于读操作,如果没有进行结构修改,可以允许一定程度的并发。如果读操作需要确保最新的数据被读取,可能需要对Segment进行加锁。
2. Java 1.8中的ConcurrentHashMap
Java 8中对ConcurrentHashMap的实现进行了重大的改进。在这个版本中,去掉了Segment
的概念,转而使用了CAS
操作(Compare-And-Swap)和synchronized
关键字配合节点的锁实现高效的并发控制。
2.1 核心实现
在Java 1.8中,ConcurrentHashMap内部主要是由Node数组构成,每个Node包含了一个key-value键值对。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V value;
volatile Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// 其他方法...
}
2.2 写操作
计算Hash和定位节点
:首先,通过键的hashCode计算hash值,并使用这个hash值找到数组中的目标位置(桶)。CAS写入
:如果目标位置(桶)为空,即当前没有任何节点,CHM会尝试使用CAS操作来写入新节点。如果CAS成功,则新节点被放置在桶中,写入操作完成。同步控制
:如果目标位置已经有节点存在(无论是链表还是红黑树),那么会根据当前节点的状态来执行不同的操作:链表
:如果桶中的节点是链表,则首先会尝试获取头节点的锁(使用synchronized),然后检查链表或红黑树的状态。在链表的情况下,会遍历链表来找到正确的插入位置,或者替换已有的节点(如果键相同)。红黑树
:如果桶中的节点是红黑树,同样会先获取头节点的锁,然后在树上执行相应的插入或更新操作。树化
:如果链表长度达到某个阈值(TREEIFY_THRESHOLD,默认为8),且数组大小大于MIN_TREEIFY_CAPACITY(默认为64),链表会转换为红黑树。这是为了优化长链表的性能。表化
:如果红黑树的节点数量小于UNTREEIFY_THRESHOLD(默认为6),且数组大小小于MIN_TREEIFY_CAPACITY,红黑树会退化为链表。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
// ...
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// ...
}
return null;
}
2.3 读操作
Java 8的ConcurrentHashMap在读操作上基本不加锁(除非在读操作过程中检测到写操作正在进行),利用volatile
关键字的读写内存语义来保证可见性,从而大大提高读操作的并发性。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// ...
}
return null;
}
3. 结构优化
自Java 1.8开始,ConcurrentHashMap内部结构由链表逐渐转化为红黑树,以减少搜索时间。链表在元素数量增加到一定程度时会转换为红黑树结构。
4. 总结
Java 1.7的ConcurrentHashMap通过分段锁实现高并发,但它的并发度受限于Segment的数量。而Java 1.8通过精细化控制,只在必需时进行锁定,显著提升了读写性能,尤其是读操作几乎不受影响,这对于读多写少的场景来说是一个巨大的优化。