3. 线程安全与数据竞争:别让共享变量变成定时炸弹
多线程编程里,最让人头疼的问题是什么?我个人觉得,就是数据竞争。你想想看,两个线程同时读写同一个变量,结果完全不可预测。我早年在一个支付系统里就踩过这个坑——两个线程同时更新账户余额,最后账对不上,排查了整整两天。
这一章,我们就来彻底搞清楚线程安全是怎么回事。我会从问题分析讲起,然后带你掌握原子操作,最后用 Queue 实现一个安全的数据交换机制。
3.1 线程安全问题:到底哪里不安全?
先看一个最经典的例子。假设我们有一个计数器,两个线程各自对它加 10000 次。你觉得结果是多少?
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 这行代码不是原子的!
}
public int getCount() {
return count;
}
}
我告诉你,结果大概率不是 20000。为什么?因为 count++ 在 CPU 层面其实是三步操作:
- 从内存读取 count 的值到寄存器
- 在寄存器里加 1
- 把新值写回内存
两个线程同时执行这三步,就会发生交错。比如线程 A 读到 count=5,还没来得及写回去,线程 B 也读到 count=5。然后两个线程都加 1,都写回 6。你看,明明加了两次,结果只增加了 1。
数据竞争的本质:多个线程同时访问同一块内存,至少有一个是写操作,而且没有同步机制来保证顺序。这就是并发 bug 的根源。
3.2 原子操作:从根源上解决问题
解决数据竞争最直接的办法,就是让操作变成原子的。什么叫原子操作?就是不可分割的操作——要么全部执行完,要么完全不执行,中间不会被其他线程打断。
Java 里提供了 java.util.concurrent.atomic 包,里面有一组原子类。我习惯用 AtomicInteger 来替代普通的 int 计数器:
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子自增
}
public int getCount() {
return count.get();
}
}
用 AtomicInteger 之后,incrementAndGet() 方法在硬件层面保证了读-改-写三步是连续的。底层用的是 CAS(Compare-And-Swap)指令,这是 CPU 直接支持的原子操作。
我的经验:CAS 虽然高效,但在高竞争场景下会有自旋开销。如果冲突非常频繁,可以考虑用 LongAdder,它在内部做了分段处理,适合写多读少的场景。
常用的原子类还有这些:
| 原子类 | 适用场景 | 核心方法 |
|---|---|---|
| AtomicInteger | 计数器、序列生成 | incrementAndGet(), addAndGet() |
| AtomicLong | 大范围计数、ID 生成 | incrementAndGet(), compareAndSet() |
| AtomicBoolean | 开关控制、标志位 | compareAndSet(), getAndSet() |
| AtomicReference | 对象引用原子更新 | compareAndSet(), getAndSet() |
| AtomicIntegerArray | 数组元素的原子操作 | getAndAdd(), compareAndSet() |
3.3 使用 Queue 实现线程安全的数据交换
原子操作解决的是单个变量的安全问题。但实际项目中,我们经常需要在多个线程之间传递数据。这时候就需要用到线程安全的队列。
Java 提供了 BlockingQueue 接口,它的实现类都是线程安全的。我个人最常用的是 LinkedBlockingQueue 和 ArrayBlockingQueue。
来看一个生产者-消费者的例子:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class DataExchange {
// 创建一个容量为 100 的阻塞队列
private BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);
// 生产者线程
public void produce(String data) throws InterruptedException {
queue.put(data); // 如果队列满了,会阻塞等待
System.out.println("生产: " + data);
}
// 消费者线程
public String consume() throws InterruptedException {
String data = queue.take(); // 如果队列空了,会阻塞等待
System.out.println("消费: " + data);
return data;
}
}
这个模式的好处很明显:生产者和消费者完全解耦,不需要自己写锁和同步逻辑。队列内部已经帮你处理好了线程安全问题。
避坑指南:我曾经在一个项目中,用 add() 方法往队列里放数据,结果队列满了直接抛异常。后来改成 put() 才解决问题。记住:put() 和 take() 是阻塞的,offer() 和 poll() 是非阻塞的,按需选择。
不同队列的适用场景也不一样:
| 队列类型 | 特点 | 推荐场景 |
|---|---|---|
| ArrayBlockingQueue | 有界、基于数组、公平性可配 | 资源受限、需要严格控制内存 |
| LinkedBlockingQueue | 有界/无界、基于链表、吞吐量高 | 生产者消费者模式、任务队列 |
| PriorityBlockingQueue | 无界、支持优先级 | 任务优先级调度 |
| SynchronousQueue | 容量为 0,直接传递 | 一对一交接、CachedThreadPool |
3.4 核心知识体系
下面这张图总结了本章的核心逻辑,从问题到解决方案的完整链路:
3.5 实战建议
说了这么多,最后给你几条我自己的实战经验:
- 优先用原子类:如果只是对单个变量做增减操作,别自己写锁,Atomic 系列足够高效。
- 队列选型要谨慎:无界队列虽然方便,但可能导致内存溢出。我习惯在生产环境用有界队列,配合合理的拒绝策略。
- 不要迷信 CAS:CAS 在低竞争时很快,但高竞争下自旋会浪费 CPU。如果冲突率超过 30%,考虑用锁或者分段策略。
- 测试要覆盖并发场景:单线程跑一百遍都没问题,一上并发就崩。写单元测试时一定要模拟多线程同时访问。
一个小技巧:调试并发问题时,可以用 Thread.currentThread().getName() 打印线程名,配合日志时间戳,能快速定位是哪个线程出了问题。我每次排查并发 bug 都会先用这招。
嗯,线程安全这个话题,说深了可以写一本书。但核心就一句话:共享可变数据需要同步。记住这个原则,大部分并发问题都能避免。