3. 线程安全与数据竞争:别让共享变量变成定时炸弹

多线程编程里,最让人头疼的问题是什么?我个人觉得,就是数据竞争。你想想看,两个线程同时读写同一个变量,结果完全不可预测。我早年在一个支付系统里就踩过这个坑——两个线程同时更新账户余额,最后账对不上,排查了整整两天。

这一章,我们就来彻底搞清楚线程安全是怎么回事。我会从问题分析讲起,然后带你掌握原子操作,最后用 Queue 实现一个安全的数据交换机制。

3.1 线程安全问题:到底哪里不安全?

先看一个最经典的例子。假设我们有一个计数器,两个线程各自对它加 10000 次。你觉得结果是多少?

public class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++;  // 这行代码不是原子的!
    }

    public int getCount() {
        return count;
    }
}

我告诉你,结果大概率不是 20000。为什么?因为 count++ 在 CPU 层面其实是三步操作:

  1. 从内存读取 count 的值到寄存器
  2. 在寄存器里加 1
  3. 把新值写回内存

两个线程同时执行这三步,就会发生交错。比如线程 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 核心知识体系

下面这张图总结了本章的核心逻辑,从问题到解决方案的完整链路:

线程安全与数据交换知识体系 线程安全与数据交换知识体系 问题:数据竞争 表现:结果不可预测、偶发 bug、账务不一致 解决方案:原子操作 + 线程安全队列 原子操作(Atomic 类) 线程安全队列(BlockingQueue) AtomicInteger AtomicLong AtomicReference LongAdder ArrayBlockingQueue LinkedBlockingQueue PriorityBlockingQueue SynchronousQueue

3.5 实战建议

说了这么多,最后给你几条我自己的实战经验:

  • 优先用原子类:如果只是对单个变量做增减操作,别自己写锁,Atomic 系列足够高效。
  • 队列选型要谨慎:无界队列虽然方便,但可能导致内存溢出。我习惯在生产环境用有界队列,配合合理的拒绝策略。
  • 不要迷信 CAS:CAS 在低竞争时很快,但高竞争下自旋会浪费 CPU。如果冲突率超过 30%,考虑用锁或者分段策略。
  • 测试要覆盖并发场景:单线程跑一百遍都没问题,一上并发就崩。写单元测试时一定要模拟多线程同时访问。

一个小技巧:调试并发问题时,可以用 Thread.currentThread().getName() 打印线程名,配合日志时间戳,能快速定位是哪个线程出了问题。我每次排查并发 bug 都会先用这招。

嗯,线程安全这个话题,说深了可以写一本书。但核心就一句话:共享可变数据需要同步。记住这个原则,大部分并发问题都能避免。