第一部分:ConcurrentHashMap的演进与实现原理
ConcurrentHashMap在JDK 1.7和1.8中有什么重要改进?
ConcurrentHashMap在JDK 1.7到1.8版本有重大重构:
- 数据结构:由1.7的Segment数组+HashEntry改为Node数组+链表/红黑树
- 同步策略:由ReentrantLock锁住整个Segment改为仅对每个桶的头部节点添加synchronized,并配合大量CAS操作
- 锁粒度:减小锁竞争的概率
除此之外,1.8版本还有其他优化:
- get()操作无需加锁:因为Node节点的val和next字段被volatile修饰
- 多线程扩容:提升了扩容效率
- size()方法优化 :
size()方法采用分桶计数(CounterCell),通过累加各部分的计数来获取总量,避免了全局锁,实现了高并发下的精确计数 -
- 引入红黑树:当单个桶内的元素过多时,链表查询会退化为O(n)。1.8版在特定条件下将链表转换为红黑树,将查询复杂度优化为O(log n),有效解决了哈希冲突严重时的性能问题
第二部分:volatile与原子操作
volatile 保证了可见性,却无法保证操作的原子性。
以经典的"读-改-写"场景------计数器递增 i++ 为例,它实际上包含三个独立步骤:
- 读取当前值
- 计算新值(当前值+1)
- 写入新值
如果 i 是 volatile 的,两个线程A和B几乎同时执行 i++,可能出现以下交错执行序列:
- 线程A读取
i=0 - 线程B读取
i=0(因为A还未写入,B读到了旧值) - 线程A计算
0+1=1并写入,i变为1(且立刻对所有线程可见) - 线程B计算
0+1=1并写入,i再次变为1
最终结果 i=1,而不是正确的 2。volatile 保证了B在第4步能立刻看到A在第3步写入的 1,但无法阻止B在第2步已经读取了旧的 0。这就是原子性缺失导致的线程安全问题。
既然volatile不足以保证复合操作的原子性,那有哪些解决方案?
保证原子性的核心思想是将多个操作捆绑成一个不可分割的整体,方法包括:
-
加互斥锁:使用synchronized或ReentrantLock
- 优点:简单安全
- 缺点:性能消耗相对较大
-
Atomic原子类:如AtomicInteger进行CAS操作
- 优点:性能通常比加锁好
- 缺点:高并发下CAS自旋可能消耗CPU,且只适用于单个变量
-
并发容器提供的原子方法:如ConcurrentHashMap的putIfAbsent
- 优点:简单高效
- 缺点:依赖容器,不够灵活
第三部分:CAS与ABA问题
什么是ABA问题?如何解决?
ABA问题是指在CAS操作时,一个值从A变成B又变回A,线程误以为状态没有改变。
示例:张三借李四的钱,李四账户余额为0。张三还款后改为100,又被王五取走,账户余额归0。张三看到余额为0就判断李四没有还钱。
解决方案:
-
添加版本号或时间戳:每次更改数值必须同步修改版本号
-
JDK工具:
- AtomicStampedReference:提供版本号
- AtomicMarkableReference:提供boolean值判断状态是否被修改
第四部分:JMM与Happens-Before规则
volatile变量如何建立happens-before关系?有什么经典应用?
volatile变量遵循两个原则:
- 写操作时:所有其他变量的修改也会一起刷新
- 禁止重排序:写操作之前的操作不能重排序到写之后,读操作之后的操作不能重排序到读之前
经典应用:标志位开关
Java
public class Example {
// 核心:volatile 标志位开关
private volatile boolean stopRequested = false;
private int someState = 0; // 一个普通的非volatile变量
// 工作线程
private Thread workerThread = new Thread(() -> {
while (!stopRequested) { // 【 volatile 读 】- 这里建立了hb关系
// 模拟工作:读取或修改 someState
someState++;
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {}
}
// 循环结束后,能看到主线程在设置stopRequested=true之前修改的所有状态
System.out.println("Worker stopped. Final state: " + someState);
});
public void start() {
workerThread.start();
}
// 主线程调用此方法请求停止
public void stop() {
// 1. 主线程可以在此更新一些最终状态(例如保存进度)
someState = 100; // 普通写
// 2. 【关键 volatile 写 】
stopRequested = true; // 这个写操作与workerThread的读操作建立happens-before
System.out.println("Stop requested sent.");
}
public static void main(String[] args) throws InterruptedException {
Example example = new Example();
example.start();
Thread.sleep(1000); // 让工作线程运行1秒
example.stop(); // 发送停止信号
example.workerThread.join(); // 等待工作线程结束
}
}
2.双重检查锁定单例模式
Java
public class Singleton {
// 关键:instance必须声明为volatile
private static volatile Singleton instance;
private final Config config;
private Singleton() {
// 这是一个昂贵的初始化过程
this.config = loadConfigFromDB(); // 假设此操作很耗时
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁,性能关键)
synchronized (Singleton.class) { // 加锁
if (instance == null) { // 第二次检查(防止重复创建)
// 非原子操作:1.分配内存 2.调用构造函数 3.将引用赋给instance
// 如果没有volatile,步骤2和3可能被重排序,导致其他线程看到未初始化完的对象(config为null)
instance = new Singleton();
}
}
}
return instance;
}
public Config getConfig() { return config; }
}
双重检查锁定单例中,为什么instance变量必须用volatile修饰?
synchronized保证同一时刻只有一个线程执行实例化代码块,但无法阻止JVM或CPU优化导致的指令重排序。
instance = new Singleton()在JVM层面分三步:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
没有volatile时可能发生的问题:
- 线程A执行创建,JVM可能先执行步骤3(引用指向内存),但还未执行步骤2(初始化对象)
- 此时instance已不是null,但指向的是未初始化完成的半成品对象
- 线程B执行第一次检查,发现instance不是null,直接返回这个半成品对象
- 线程B使用这个对象时出错
volatile的关键作用:
- 禁止指令重排序:强制保证步骤2在步骤3之前执行
- 保证可见性:线程A对instance的完整写入能立即被线程B看到
2. 一次性状态发布
当一个对象构造完成后,其状态不再改变,但需要被多个线程安全地访问。volatile提供了比synchronized更轻量级的发布方式。
java
arduino
public class EventRouter {
// 保存全局路由配置,启动后加载一次,之后只读。
private volatile Map<String, String> routeConfig;
public void init() {
// 在单线程中完成所有复杂的、耗时的初始化
Map<String, String> config = heavyWeightInitialization();
// 全部完成后,一次性volatile写入,对所有线程立即可见
this.routeConfig = config; // volatile写
}
public String getRoute(String event) {
// 多线程并发读:volatile读,能立刻看到init线程写入的所有routeConfig内容
Map<String, String> config = this.routeConfig; // volatile读
return config.get(event);
}
}
最佳实践 :配合不可变对象 (如Collections.unmodifiableMap包装的Map)使用,效果最佳。因为发布后状态不可变,读线程无需同步。
3. 低成本读写锁(读多写少)
在某些读远多于写、且写操作非常简单的场景,可以用volatile变量配合CAS(如AtomicInteger)模拟一个更轻量的读写锁。
java
csharp
public class LightweightCoordinator {
private volatile int epoch = 0; // "纪元"版本号
private final AtomicInteger writeLock = new AtomicInteger(0);
public void beginRead() {
int snapshotEpoch;
do {
snapshotEpoch = epoch; // volatile读,获取当前"稳定"版本
// 检查是否有写操作正在进行(通过CAS状态判断,此处简化)
} while (isWritingInProgress()); // 如果正在写,则重试
// 后续读操作基于snapshotEpoch进行
}
public void endRead() {
// 通常无需操作
}
public void beginWrite() {
// 通过CAS获取写锁
while (!writeLock.compareAndSet(0, 1)) {
// 自旋或后退
}
epoch++; // volatile写,递增纪元,强制所有读线程在下一次读取时看到新数据
// ... 执行写操作
}
public void endWrite() {
writeLock.set(0); // 释放写锁
}
}
注意 :这是一种高级模式,仅适用于写操作极快、读操作可以容忍短暂重试的场景。多数情况下,应直接使用ReentrantReadWriteLock或StampedLock。