在并发编程领域,CAS(Compare-And-Swap)是一种基础且强大的原子操作机制。作为Java并发包的核心实现技术,它通过硬件级别的原子指令实现了无锁并发控制。让我们从计算机科学的角度来剖析这个关键机制。
CAS操作包含三个核心参数:
其原子性保证体现在:比较和交换这两个操作作为一个不可分割的整体执行。现代CPU通过特定的指令(如x86的CMPXCHG)实现这一机制,整个过程不会被线程调度打断。
重要提示:CAS虽然是无锁操作,但在高竞争环境下可能导致大量重试(自旋),实际性能可能反而不如锁机制。需要根据具体场景选择。
Java中的CAS操作通过sun.misc.Unsafe类提供底层支持,典型方法签名如下:
java复制public final native boolean compareAndSwapObject(
Object o, long offset, Object expected, Object x);
JVM会将这些方法调用映射到具体的CPU指令。以HotSpot虚拟机为例:
这种硬件级别的支持使得单个CAS操作的时间复杂度为O(1),远优于锁机制带来的上下文切换开销。
当插入新元素到空桶时,采用CAS保证原子性:
java复制// JDK源码摘录(简化版)
if ((tab = table) == 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)))
break; // CAS成功则退出
}
这个模式被称为"乐观锁"——先尝试无锁操作,失败后再考虑其他策略。相比直接使用synchronized,在高并发低冲突场景下性能优势明显。
ConcurrentHashMap的size()实现依赖CAS计数器:
java复制// JDK8的计数器实现
CounterCell[] counterCells;
final long sumCount() {
CounterCell[] as = counterCells;
long sum = baseCount;
if (as != null) {
for (CounterCell a : as)
if (a != null) sum += a.value;
}
return sum;
}
这种分散计数的方式避免了单一计数器的争用,是典型的"分而治之"并发策略。
扩容时需要协调多个线程的工作,通过CAS控制状态转换:
java复制// 扩容状态控制代码片段
while (s >= (long)(sc = sizeCtl) && (tab = table) != null) {
if (sc < 0) {
// 其他线程正在扩容
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 尝试成为扩容发起者
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
JDK8对节点结构进行了重大优化,主要变化包括:
节点类型多样化:
内存布局优化:
java复制static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 使用final修饰,保证不变性
final K key; // 同样不可变
volatile V val; // 保证可见性
volatile Node<K,V> next; // 保证链表操作的可见性
// 省略方法实现...
}
这种设计实现了:
ConcurrentHashMap采用延迟初始化策略,首次插入时才构建数组:
java复制// 初始化表的代码片段
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 其他线程正在初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 双重检查
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); // 计算阈值
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
扩容过程分为几个关键阶段:
实践技巧:可以通过构造函数指定初始容量,避免频繁扩容。建议预估元素数量除以0.75(默认负载因子)作为初始容量。
链表转为红黑树需要同时满足:
java复制// 树化代码片段
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1); // 优先扩容
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) { // 对桶头加锁
// 树化具体实现...
}
}
}
}
红黑树退化为链表的情况包括:
JDK7与JDK8的实现有显著差异:
| 特性 | JDK7 Segment分段锁 | JDK8 CAS + synchronized |
|---|---|---|
| 并发粒度 | 段级别(默认16段) | 桶级别(更细粒度) |
| 锁机制 | ReentrantLock | synchronized + CAS |
| 扩容方式 | 段内独立扩容 | 整体协同扩容 |
| 内存占用 | 较高(每个Segment独立结构) | 较低(统一数组结构) |
ConcurrentHashMap使用特殊的哈希算法来减少冲突:
java复制static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
这个算法:
JDK8新增了多种并行操作方法:
java复制// 并行遍历
public void forEach(long parallelismThreshold,
BiConsumer<? super K,? super V> action)
// 并行搜索
public <U> U search(long parallelismThreshold,
BiFunction<? super K,? super V,? extends U> searchFunction)
这些方法使用ForkJoinPool实现,适合大规模数据处理的场景。
内存占用过高:
CPU使用率飙升:
并发更新丢失:
| 参数 | 默认值 | 调优建议 |
|---|---|---|
| 初始容量 | 16 | 预估元素数量/0.75 |
| 负载因子 | 0.75 | 读多写少可适当调高 |
| 并发级别(JDK7) | 16 | 写并发线程数 |
| 树化阈值 | 8 | 监控实际链表长度分布 |
键对象设计:
API选择:
监控指标:
在长时间使用ConcurrentHashMap的过程中,我发现对于写密集型场景,适当增加初始容量(如预估元素数量的1.5倍)能显著减少扩容开销。而对于读密集型场景,保持较低的负载因子(如0.5)可以提高查询效率。实际应用中,需要通过JMX或监控工具持续观察Map的运行状态,根据实际情况动态调整参数。