共享数组是Java并发编程里最常踩坑的场景之一。多个线程同时对数组的同一个下标执行自增、累加操作,看似代码没问题,实际运行时结果总是比预期小。这是因为a[i]++这样的操作在字节码层面被拆分成读取、加一、写回三步,线程之间随时可能在中间任意一步被打断,最终导致修改被覆盖。本文围绕这一问题,详细分析成因并给出几种可靠的解决方案。

先复现问题:不安全的数组自增
我们先用一段最直观的代码复现这个经典问题。定义一个长度为10的int数组,启动8个线程,每个线程对数组的每个元素自增1000次。理论上最终每个元素都应该是8000,但实际运行几乎每次都小于这个值。
public class UnsafeArrayDemo {
private static final int[] array = new int[10];
public static void main(String[] args) throws InterruptedException {
int threadCount = 8;
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int k = 0; k < 1000; k++) {
for (int j = 0; j < 10; j++) {
array[j]++; // 非原子操作,存在竞态条件
}
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println(Arrays.toString(array));
// 输出大概率不是 [8000, 8000, ...]
}
}</code>问题的核心在于竞态条件(Race Condition)。两个线程同时读到旧值,各自加一后先后写回,后写的会覆盖先写的,一次自增就凭空消失了。值得注意的是,即使给数组加上volatile也无法解决,因为volatile只保证可见性和有序性,不保证复合操作的原子性。这是一个非常常见的认知误区。
另外还有一个容易被忽视的细节:可见性问题。如果没有同步措施,一个线程的修改可能长时间对其他线程不可见(由于CPU缓存和工作内存的存在),这会让问题进一步复杂化。所以解决方案必须同时兼顾原子性与可见性。
方案一:synchronized同步块
最直接的办法是用synchronized把修改数组的代码块包起来。同一时刻只有一个线程能进入同步块,既保证了原子性,也顺带保证了可见性。
public class SyncArrayDemo {
private static final int[] array = new int[10];
private static final Object lock = new Object();
public static void increment(int index) {
synchronized (lock) {
array[index]++;
}
}
}这种写法的优点是简单直观,JDK内部对synchronized做了大量优化(偏向锁、轻量级锁等),低竞争场景下性能并不差。缺点是锁粒度问题:上面的例子锁住了整个数组,所有线程在更新任何下标时都要竞争同一把锁,高并发下会形成串行化瓶颈。
如果不同下标之间没有逻辑关联,可以细化锁粒度,比如按index % lockCount分段加锁,让不同段的操作并行执行。这种分段锁思想在ConcurrentHashMap的早期实现中也有体现,能显著提升吞吐量,但代码复杂度也随之上升,需要谨慎评估是否值得。
方案二:AtomicIntegerArray原子类数组
JDK的java.util.concurrent.atomic包提供了AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,专门用于解决数组元素的原子更新问题。它底层基于CAS(Compare And Swap)操作,无锁化实现,高并发下性能优异。
import java.util.concurrent.atomic.AtomicIntegerArray;
public class AtomicArrayDemo {
private static final AtomicIntegerArray array = new AtomicIntegerArray(10);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int k = 0; k < 10000; k++) {
array.getAndIncrement(k % 10); // 原子自增
}
});
Thread t2 = new Thread(() -> {
for (int k = 0; k < 10000; k++) {
array.addAndGet(k % 10, 2); // 原子增加指定值
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(array); // 结果总是确定的
}
}AtomicIntegerArray的优势在于无锁。CAS在硬件层面直接支持,冲突激烈时会自旋重试,不会让线程挂起。对于单纯的计数、累加场景,它几乎是首选方案。不过CAS也有短板:高竞争下自旋次数增多会浪费CPU;此外它只适合单个变量的简单更新,如果一次业务操作需要同时改多个下标且要求整体一致,CAS就无能为力了,这时还得回到锁方案。
还有一个细节值得注意:AtomicIntegerArray内部并不是把每个元素包装成AtomicInteger对象,而是用单个int数组配合偏移量直接操作内存,避免了额外的对象创建开销,这也是它性能好的原因之一。
方案三:显式锁与读写锁
当业务逻辑复杂到需要一段代码整体原子执行,或者需要尝试获取锁、设置超时等高级能力时,可以使用ReentrantLock。它的语义与synchronized类似,但功能更灵活。
import java.util.concurrent.locks.ReentrantLock;
public class LockArrayDemo {
private static final int[] array = new int[10];
private static final ReentrantLock lock = new ReentrantLock();
public static void update(int index, int delta) {
lock.lock();
try {
// 复合操作整体原子执行
int old = array[index];
array[index] = old + delta;
} finally {
lock.unlock(); // 必须在finally中释放
}
}
}如果场景是读多写少,比如配置数组、路由表,大部分线程只读、偶尔有线程更新,那么ReentrantReadWriteLock更合适。读写锁允许多个读线程同时进入,写线程独占,读吞吐量能成倍提升。不过要注意写锁升级的问题:一个持有读锁的线程不能直接获取写锁,否则会死锁,需要先释放读锁再重新竞争写锁。
对于数组结构经常整体变化的情况,还可以考虑直接换成CopyOnWriteArrayList之类的容器,每次写都复制一份新数组,读操作完全无锁。它适合读远多于写且数据量不大的场景,写频繁则内存开销会难以承受。
如何选择合适的方案
没有万能的方案,选型要看具体场景。下面给出一个简单的对照参考:
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 简单计数、累加 | AtomicIntegerArray | 无锁CAS,高并发性能最好 |
| 复合逻辑需整体原子 | synchronized或ReentrantLock | 锁保证代码块整体互斥 |
| 读多写少 | 读写锁、CopyOnWrite容器 | 读并行化,提升吞吐 |
| 低并发、追求简单 | synchronized | 代码最简单,JIT优化后性能足够 |
除了选型,还有几条实践建议值得遵守。第一,尽量缩小同步范围,只锁住真正需要互斥的语句,锁内的IO操作、耗时计算都会放大竞争。第二,明确锁保护的对象,最好用一个专门的lock对象,而不是直接锁数组或this,避免外部代码意外锁同一对象造成死锁。第三,写完并发代码后一定要用多线程压测验证,比如使用jcstress工具或简单的循环断言,很多并发bug在低负载下根本不会暴露。
总结一下,共享数组的线程安全问题本质上是对原子性和可见性的理解。volatile解决不了复合操作的竞态条件;单变量更新优先考虑原子类数组;跨多个下标的一致性操作用锁;读多写少用读写锁或写时复制容器。理解了这些原理,再面对更复杂的并发数据结构时也就有了判断依据。