一、为啥要聊这个事儿
咱们写代码的时候,经常会碰到多个线程同时操作同一个数组的情况。比如一个电商网站的库存系统,每个商品ID对应一个数组下标,多个线程同时扣减库存;再比如一个实时排行榜,多个线程同时往数组里写入分数。这时候最大的麻烦就是:大家都在改同一个位置,最后数据就乱套了。
有人说“加锁呗”,没错,加锁是简单粗暴的办法。但锁有锁的代价:线程多了会堵车,性能刷刷往下掉。那有没有不用锁的办法呢?CAS(Compare And Swap)就是一条“无锁”的路子。不过CAS也不是万能药,有时候反而比锁还慢。今天咱们就拿“共享数组”这个具体场景,掰开了揉碎了聊聊,到底什么时候该用读写锁,什么时候改用无锁CAS,帮你在实际开发中少踩坑。
二、问题场景:大家一起改一个数组
先看一个最简单的例子:有一个长度为5的整数数组,两个线程同时把下标0的位置加1。按道理结果应该是2,但实际跑出来可能是1,因为a[0] = a[0] + 1这个操作本身不是原子性的——先读、再加、再写,中间被打断就丢数据。
这种问题在真实项目里特别常见。比如一个游戏服务器,用数组记录每个地图在线玩家数量,多个登录线程同时修改同一个地图的计数;再比如一个日志分析系统,多个线程把结果累加到同一个数组的不同位置。只要是多线程写同一块内存,就绕不开“数据一致”这个坎儿。
三、传统方案:读写锁(ReadWriteLock)
3.1 读写锁是个啥
读写锁是锁家族里比较智能的一个。它把操作分成“读”和“写”两类。多个线程可以同时读,不会互相干扰;但只要有一个线程在写,其他所有读和写都得等着。简单说就是“读共享、写独占”。
对于数组来说,如果你大部分操作是读,只有少部分写,读写锁就很划算。比如一个配置数组,99%的时间线程都是读配置,只有管理员更新配置时才写,那读写锁比普通的互斥锁快好几倍。
3.2 Java代码示例
下面咱们用Java来演示。技术栈:Java 8+,使用ReentrantReadWriteLock保护一个int[]。
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ArrayWithReadWriteLock {
// 共享数组
private int[] data = new int[10];
// 读写锁
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
// 读取数组元素:加读锁
public int get(int index) {
readLock.lock(); // 多个线程可以同时获取读锁
try {
return data[index];
} finally {
readLock.unlock(); // 记得释放
}
}
// 写入数组元素:加写锁(写锁会阻塞其他读锁和写锁)
public void set(int index, int value) {
writeLock.lock();
try {
data[index] = value;
} finally {
writeLock.unlock();
}
}
// 自增某个位置(先读后写,需要写锁)
public void increment(int index) {
writeLock.lock(); // 直接上写锁,因为要改写
try {
data[index] = data[index] + 1;
} finally {
writeLock.unlock();
}
}
// 演示用法
public static void main(String[] args) throws InterruptedException {
ArrayWithReadWriteLock arr = new ArrayWithReadWriteLock();
// 启动10个线程,每个线程对下标0加1000次
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
arr.increment(0);
}
});
threads[i].start();
}
// 等待所有线程完成
for (Thread t : threads) {
t.join();
}
// 最终结果应该是 10000
System.out.println("最终值:" + arr.get(0)); // 输出 10000
}
}
3.3 读写锁的优缺点
优点:简单可靠,代码逻辑清楚,适合读多写少的场景。在写操作稀少的系统里,几乎不影响读性能。
缺点:写操作时,所有读线程也要等着。如果写操作很频繁(比如每次请求都要写数据库),那锁的竞争会非常激烈,甚至比互斥锁还慢。另外,锁本身有上下文切换的开销,线程一多CPU就忙不过来了。
四、无锁方案:CAS与原子数组
4.1 CAS是啥
CAS的全称是Compare And Swap(比较并交换)。它是一条CPU指令级别的操作,过程很简单:比较一个内存位置的值是不是等于某个“期望值”,如果是,就把它更新成“新值”;如果不是,就说明已经有别人改过了,当前操作失败(但可以重试)。整个过程是原子性的,不会被打断。
Java里把CAS封装成了AtomicInteger、AtomicIntegerArray等类。用CAS做自增的操作是:先读当前值,然后算出新值,再用CAS尝试把旧值换成新值。如果CAS失败,就循环重试直到成功。这就是所谓的“无锁编程”里最常见的“乐观锁”思路——先乐观地假设没人跟我抢,万一抢到了就重来。
4.2 AtomicIntegerArray示例
下面用AtomicIntegerArray来实现同样功能:10个线程同时对下标0加1000次。
import java.util.concurrent.atomic.AtomicIntegerArray;
public class ArrayWithAtomic {
// 原子数组,长度10,自动初始化为0
private static final int SIZE = 10;
private AtomicIntegerArray arr = new AtomicIntegerArray(SIZE);
// 自增指定下标,内部使用CAS循环
public void increment(int index) {
// 死循环直到成功
while (true) {
int oldVal = arr.get(index); // 获取当前值
int newVal = oldVal + 1; // 计算新值
// 如果当前值还是oldVal,就更新为newVal;否则说明被改了,重试
if (arr.compareAndSet(index, oldVal, newVal)) {
break; // 成功则跳出
}
// 如果失败,下一轮循环重新读取最新值再试
}
}
// 读取(原子类get本身是安全的)
public int get(int index) {
return arr.get(index);
}
// 演示
public static void main(String[] args) throws InterruptedException {
ArrayWithAtomic arr = new ArrayWithAtomic();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
arr.increment(0);
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("最终值:" + arr.get(0)); // 10000
}
}
4.3 无锁方案的优缺点
优点:没有锁竞争,不会引起线程阻塞和上下文切换,在高并发场景下性能通常优于锁。特别适合“写操作是简单自增或赋值”的情况,比如计数器、累加器。
缺点:如果CAS操作失败次数多(即“自旋”很多次),会白白浪费CPU。而且它只能保证对单个内存位置的原子操作。如果操作涉及多个数组下标(比如把a[1]和a[2]一起修改,且两者有关联性),CAS就无法保证整体的原子性了,这时候还是得用锁。
五、读写锁 vs 无锁CAS:适用场景详细分析
5.1 从操作模式看
读多写少:比如一个游戏服务器里,大部分时候只是读取玩家等级数组,只有升级时才写。读写锁表现很好,因为读不互相阻塞。CAS虽然也能读,但它没有任何优化,读操作也是直接
get,没区别。但写操作时CAS可能自旋,而读写锁写时也会阻塞读,所以当写很少时两者差不多,但CAS自旋的CPU消耗可能略高一点点。不过现代CPU CAS非常快,所以读多写少的场景两者都可以,读写锁代码更直观。写多读少:比如日志系统不停往里写,偶尔有人读。这时候读写锁的写锁会频繁阻塞读,但读又很少,所以瓶颈在写。而写锁本身是互斥的,每次只有一个线程能写,其他写线程都在等。CAS可以让多个写线程同时尝试自增,只要不冲突就能并行,显然CAS更快。所以写多的场景,CAS优势明显。
读写比例均衡:比如实时统计系统,每秒成千上万的读写。这时候CAS往往比锁好,因为锁的上下文切换开销太大。但如果CAS自旋激烈(比如很多线程同时改同一个位置),反而自旋浪费。一个典型的经验是:当冲突不严重(线程数远小于数组大小)时,CAS胜出;当所有线程都挤在一个下标上(比如全局总计数器),CAS自旋会非常多,甚至不如一个简单的
AtomicLong?但AtomicLong内部也是CAS,所以本质一样。只有用锁配合分段降低冲突才有意义。
5.2 从数据一致性粒度看
CAS只能保证一个元素的原子操作。如果你需要两个元素联动(比如转账:同时减少A账户、增加B账户),CAS就无法直接做到,需要结合其他机制(比如版本号或复杂循环)。而锁可以轻松包裹一个代码块,保证多个操作的整体原子性。
5.3 从实现复杂度看
锁的代码非常直白:加锁、操作、释放。CAS代码往往需要循环重试,看起来有点绕。对于团队里大家水平参差不齐的情况,锁更容易维护。
5.4 一个决策小表格
| 场景 | 推荐方案 |
|---|---|
| 读操作占99%,写操作很少 | 读写锁 |
| 写操作频繁,冲突低(分散) | 无锁CAS |
| 写操作频繁,冲突高(集中) | 需要分段锁或改造结构 |
| 需要同时修改多个下标 | 读写锁(或显式锁) |
| 对性能极致要求,且只有单个下标原子操作 | 无锁CAS |
六、注意事项
ABA问题:CAS的经典陷阱。例如线程1读到A,然后被阻塞,线程2把A改成B再改回A,线程1醒来后CAS发现还是A就成功更新,但实际值已经被改过一轮了。对于数组计数器这种“值一直在增加”的场景,ABA不影响正确性。但如果数组中存的是对象引用,可能引发问题。Java的
AtomicStampedReference可以加版本号解决。内存可见性:无论是锁还是CAS,都能保证内存可见性(因为CAS底层有volatile语义),所以不用担心读线程看到旧值。
自旋消耗CPU:CAS失败时不会让出CPU,而是空转。如果自旋次数多了,CPU占用率会升高。可以在重试之间插入
Thread.yield()或LockSupport.parkNanos(),但会降低吞吐。实际生产中可以结合退避策略。读写锁的锁降级和锁升级:ReentrantReadWriteLock支持从写锁降级为读锁,但不支持从读锁升级为写锁(会死锁)。写代码时要小心。
数组大小固定:两种方案都假设数组长度不变。如果数组需要动态扩容,那锁或CAS都需要额外处理扩容时的迁移一致性问题,通常需要全局锁。
测试环境:一定要在真实的并发压力下测试,因为两者的性能差异与硬件核数、线程数、操作耗时紧密相关。别只跑两个线程就得出结论。
七、总结
多线程共享数组的数据一致性问题,没有银弹。读写锁和无锁CAS各有各的舞台。粗浅地说:如果你写操作很少,或者需要同时改好几个元素,用读写锁,简单稳当;如果你主要是在做单个元素的累加或赋值,而且写操作频繁,用CAS,性能更猛。 但实际项目中往往还需要结合分段、分桶等技巧进一步优化。
写代码就像盖房子,工具不是越高级越好,而是越合适越好。希望你看完这篇文章后,下次面对共享数组时,能快速判断是该上锁还是该上CAS,少走一些坑。
Comments