Java的ConcurrentHashMap使用技巧
1. 理解ConcurrentHashMap的基本结构
在深入探讨使用技巧之前,我们首先要理解 ConcurrentHashMap
的基本结构。ConcurrentHashMap
自 Java 1.5 引入,它是线程安全的哈希表,采用了分段锁(Segment)的设计理念,在 JDK 1.8 之后摒弃了分段锁,采用了 Node
数组加链表或红黑树的结构,结合 CAS
(Compare - and - Swap)操作来保证线程安全。
1.1 JDK 1.7 中的结构
在 JDK 1.7 中,ConcurrentHashMap
由多个 Segment
组成,每个 Segment
就像是一个小型的 HashMap
。Segment
继承自 ReentrantLock
,这意味着每个 Segment
都可以独立加锁。当对 ConcurrentHashMap
进行写操作(如 put
方法)时,只会锁住操作的那个 Segment
,而其他 Segment
仍然可以被并发访问,从而提高了并发性能。例如,假设有一个 ConcurrentHashMap
有 16 个 Segment
,那么理论上可以支持 16 个线程同时进行写操作而不产生竞争。
// JDK 1.7 中 ConcurrentHashMap 部分代码示例
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(16, 0.75f, 16);
map.put(1, "value1");
1.2 JDK 1.8 中的结构
JDK 1.8 对 ConcurrentHashMap
进行了重大改进,不再使用分段锁,而是采用了与 HashMap
类似的数组加链表或红黑树的结构。在 put
操作时,首先通过 hash
值找到对应的数组位置,如果该位置为空,则直接使用 CAS
操作插入数据。如果该位置已经有数据,且为链表结构,那么就会对链表进行遍历,若链表长度超过阈值(默认为 8),则将链表转换为红黑树。在这个过程中,synchronized
关键字被用于锁住链表或红黑树的头节点,而不是整个数组,从而进一步提高了并发性能。
// JDK 1.8 中 ConcurrentHashMap 部分代码示例
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
2. 初始化ConcurrentHashMap
正确地初始化 ConcurrentHashMap
对于性能至关重要。在初始化时,需要考虑初始容量、加载因子和并发级别(仅 JDK 1.7 有)等参数。
2.1 初始容量
初始容量指的是 ConcurrentHashMap
在创建时的大小。如果能够预先估计数据量的大小,设置合适的初始容量可以减少扩容的次数,从而提高性能。扩容操作会涉及到重新计算哈希值、迁移数据等操作,开销较大。
例如,如果预计会有 1000 个元素,根据加载因子的默认值 0.75,我们可以计算出合适的初始容量:
// 预计元素个数为1000
int expectedSize = 1000;
// 计算初始容量
int initialCapacity = (int) Math.ceil(expectedSize / 0.75f) + 1;
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(initialCapacity);
2.2 加载因子
加载因子表示 ConcurrentHashMap
在达到一定的元素填充比例时会进行扩容。默认的加载因子是 0.75,这个值在时间和空间效率上取得了较好的平衡。如果加载因子设置得过高,虽然可以减少扩容次数,但会增加哈希冲突的概率,导致链表或红黑树变长,影响查找性能;如果设置得过低,则会频繁扩容,增加开销。
// 设置加载因子为0.8
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(16, 0.8f);
2.3 并发级别(JDK 1.7)
在 JDK 1.7 中,并发级别表示 ConcurrentHashMap
内部 Segment
的数量。默认值是 16,意味着最多可以支持 16 个线程同时进行写操作。如果应用程序中并发写操作较多,可以适当增加并发级别,以减少锁竞争。
// JDK 1.7 中设置并发级别为32
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>(16, 0.75f, 32);
3. 使用ConcurrentHashMap进行操作
ConcurrentHashMap
提供了一系列的操作方法,包括 put
、get
、remove
等,我们需要正确地使用这些方法来发挥其高性能和线程安全的特性。
3.1 put操作
put
操作是向 ConcurrentHashMap
中插入键值对。在 JDK 1.8 中,put
方法的实现如下:
public V put(K key, V value) {
return putVal(key, value, false);
}
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;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else 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
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
在使用 put
方法时,需要注意以下几点:
- 键值不能为空:
ConcurrentHashMap
不允许键或值为null
,否则会抛出NullPointerException
。 - 避免重复插入:如果已经存在相同的键,
put
方法会覆盖旧的值。如果不希望覆盖,可以使用putIfAbsent
方法。
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
// 再次插入相同键,值会被覆盖
map.put(1, "newValue1");
// 使用putIfAbsent方法,如果键不存在则插入
map.putIfAbsent(2, "value2");
3.2 get操作
get
操作是从 ConcurrentHashMap
中获取对应键的值。get
操作通常是无锁的,这是因为 ConcurrentHashMap
的数据结构设计使得在大多数情况下可以直接通过哈希值找到对应的位置,而不需要加锁。
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;
}
else if (eh < 0)
return (p = e.find(h, key)) != null? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
在使用 get
方法时,虽然它是线程安全的,但由于 ConcurrentHashMap
是允许并发修改的,所以在某些极端情况下,可能会获取到旧的值。例如,在一个线程进行 put
操作的同时,另一个线程进行 get
操作,可能会出现短暂的不一致。不过,这种情况在大多数应用场景下不会对程序逻辑产生严重影响。
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
String value = map.get(1);
System.out.println(value); // 输出 value1
3.3 remove操作
remove
操作是从 ConcurrentHashMap
中移除指定键的键值对。在 JDK 1.8 中,remove
方法的实现如下:
public V remove(Object key) {
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
validated = true;
for (Node<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V ev = p.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
return null;
}
在使用 remove
方法时,需要注意以下几点:
- 键不能为空:与
put
方法一样,remove
方法的键也不能为null
,否则会抛出NullPointerException
。 - 返回值:
remove
方法会返回被移除的旧值,如果键不存在,则返回null
。
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
String removedValue = map.remove(1);
System.out.println(removedValue); // 输出 value1
4. 并发访问控制
虽然 ConcurrentHashMap
本身是线程安全的,但在某些复杂的业务场景下,可能需要额外的并发访问控制。
4.1 复合操作
有时候,我们需要进行一些复合操作,例如先检查某个键是否存在,然后根据结果进行插入或更新操作。在这种情况下,如果不进行适当的同步控制,可能会出现竞态条件。
例如,假设有一个场景,我们需要统计某个单词在文本中出现的次数:
ConcurrentHashMap<String, Integer> wordCountMap = new ConcurrentHashMap<>();
// 错误的实现方式,可能出现竞态条件
String word = "example";
if (!wordCountMap.containsKey(word)) {
wordCountMap.put(word, 1);
} else {
wordCountMap.put(word, wordCountMap.get(word) + 1);
}
上述代码中,containsKey
和 put
操作不是原子的,在多线程环境下,可能会出现多个线程同时判断键不存在,然后都进行插入操作,导致统计结果错误。
为了解决这个问题,可以使用 compute
方法,它会以原子方式执行复合操作:
ConcurrentHashMap<String, Integer> wordCountMap = new ConcurrentHashMap<>();
String word = "example";
wordCountMap.compute(word, (k, v) -> v == null? 1 : v + 1);
4.2 迭代器
ConcurrentHashMap
的迭代器是弱一致性的。这意味着在迭代过程中,如果 ConcurrentHashMap
被其他线程修改,迭代器仍然可以继续迭代,不会抛出 ConcurrentModificationException
,但可能会反映不出最新的修改。
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "value1");
map.put(2, "value2");
map.forEach((key, value) -> {
System.out.println(key + " : " + value);
});
// 使用迭代器
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> entry = iterator.next();
System.out.println(entry.getKey() + " : " + entry.getValue());
}
在使用迭代器时,要注意其弱一致性的特点,避免在迭代过程中依赖于数据的实时一致性。如果需要强一致性的迭代,可以考虑在迭代前对 ConcurrentHashMap
进行快照(例如使用 Collections.unmodifiableMap
方法创建一个不可修改的视图)。
5. 性能优化
为了充分发挥 ConcurrentHashMap
的性能,我们可以采取一些优化措施。
5.1 合理设置参数
在初始化 ConcurrentHashMap
时,合理设置初始容量、加载因子和并发级别(JDK 1.7)等参数,可以减少扩容和锁竞争的开销。如前文所述,根据预计的数据量和并发情况来设置这些参数是关键。
5.2 减少哈希冲突
哈希冲突会导致链表或红黑树变长,影响查找和插入性能。为了减少哈希冲突,我们可以提供高质量的哈希函数。ConcurrentHashMap
默认使用 spread
方法来计算哈希值,在自定义类作为键时,也应该重写 hashCode
方法,确保哈希值的分布均匀。
class CustomKey {
private int id;
private String name;
public CustomKey(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + id;
result = 31 * result + (name != null? name.hashCode() : 0);
return result;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomKey that = (CustomKey) o;
return id == that.id &&
Objects.equals(name, that.name);
}
}
ConcurrentHashMap<CustomKey, String> map = new ConcurrentHashMap<>();
CustomKey key = new CustomKey(1, "customKey1");
map.put(key, "value1");
5.3 避免不必要的同步
ConcurrentHashMap
本身已经是线程安全的,在使用时应避免在外部再进行不必要的同步操作,以免降低并发性能。例如,不要在对 ConcurrentHashMap
进行操作的方法上使用 synchronized
关键字,除非有特殊的业务需求。
6. 与其他集合的比较
ConcurrentHashMap
与其他一些集合类在功能和性能上有一些区别。
6.1 与HashMap的比较
- 线程安全性:
HashMap
是非线程安全的,在多线程环境下使用需要进行额外的同步控制,而ConcurrentHashMap
是线程安全的,无需外部同步。 - 性能:在单线程环境下,
HashMap
的性能通常略高于ConcurrentHashMap
,因为ConcurrentHashMap
为了保证线程安全会有一些额外的开销。但在多线程环境下,ConcurrentHashMap
的并发性能优势明显,能够支持更高的并发访问。
6.2 与Hashtable的比较
- 线程安全性:
Hashtable
和ConcurrentHashMap
都是线程安全的。但Hashtable
使用的是全局锁,在任何时候只有一个线程可以进行读写操作,而ConcurrentHashMap
在 JDK 1.8 之后采用更细粒度的锁机制,允许多个线程同时进行部分操作,并发性能更高。 - 对null的支持:
Hashtable
不允许键或值为null
,这一点与ConcurrentHashMap
相同,但HashMap
允许键或值为null
。
通过深入理解 ConcurrentHashMap
的结构、操作方法、并发控制和性能优化等方面,我们可以在多线程编程中充分发挥它的优势,构建高效、稳定的应用程序。在实际应用中,根据具体的业务需求和场景,合理选择和使用 ConcurrentHashMap
,能够有效提升系统的并发处理能力。