线程安全与竞态条件:什么是竞态条件、原子操作与可见性、使用Lock解决竞态

各位同学,今天我们来聊聊多线程编程里最核心、也最容易踩坑的话题——线程安全与竞态条件。说实话,我见过太多项目因为这个问题在线上翻车了。我自己也曾经在一个支付系统里,因为没处理好竞态条件,导致用户余额出现了短暂的不一致。嗯,那次教训挺深刻的。

一、竞态条件:到底是个什么东西?

竞态条件,说白了就是多个线程同时访问共享数据,最终结果取决于线程执行的「时序」。你想想看,如果两个线程同时往一个账户里存钱,谁先谁后,结果可能完全不同。

我举个例子。假设有一个计数器,初始值是0。两个线程各自执行100次自增操作。你期望的结果是200,对吧?但实际跑起来,可能只有198、199,甚至更少。为什么会这样?

因为 count++ 这个操作,在CPU眼里其实是三步:

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

如果线程A刚读完count=0,还没来得及写回去,线程B也读到了count=0。然后两个线程各自加1,都写回1。你看,两次自增,结果只加了1。这就是典型的竞态条件。

核心定义:竞态条件是指多个线程并发访问共享资源,且最终结果依赖于线程执行的相对时序,导致程序行为不可预测。

二、原子操作:一把锁住关键步骤

解决竞态条件最直接的办法,就是保证操作的「原子性」。原子操作,就是不可分割的操作。要么全部执行完,要么一点都不执行。中间不能被其他线程打断。

在Java里,我们可以用 synchronized 或者 AtomicInteger 来实现原子操作。我个人习惯用 AtomicInteger,因为它性能更好,而且API设计得很直观。

// 非原子操作——有竞态风险
private int count = 0;
public void increment() {
    count++;  // 不是原子的!
}

// 原子操作——安全
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet();  // 原子的!
}

我在项目中遇到过这样一个场景:一个高并发的库存扣减系统。最开始用 synchronized 锁整个方法,结果压测发现吞吐量上不去。后来改成 AtomicInteger,性能提升了将近3倍。嗯,选对工具很重要。

小提示:原子操作适合简单的计数器、标志位等场景。如果涉及多个变量的复合操作,还是得用锁。

三、可见性:你以为改了,别人不一定看得见

竞态条件还有一个「隐形杀手」——可见性问题。什么意思呢?就是线程A修改了一个变量,但线程B看不到这个修改。你想想看,这多可怕。

为什么会这样?因为现代CPU都有多级缓存。线程A可能把变量改在了自己的CPU缓存里,还没来得及写回主内存。线程B读的时候,直接从主内存读,读到的还是旧值。

解决可见性问题,可以用 volatile 关键字。它保证:

  • 写 volatile 变量时,会强制刷新到主内存
  • 读 volatile 变量时,会从主内存重新读取
// 不加 volatile——可能有可见性问题
private boolean running = true;
public void run() {
    while (running) {
        // 线程可能永远看不到 running 被改成 false
    }
}

// 加 volatile——保证可见性
private volatile boolean running = true;
public void run() {
    while (running) {
        // 一定能看到 running 的最新值
    }
}

我曾经在一个分布式任务调度系统里,就因为忘了加 volatile,导致一个停止信号发了之后,工作线程过了好几秒才响应。排查了半天才发现是可见性问题。从那以后,我对共享标志位都格外小心。

注意:volatile 只能保证可见性,不能保证原子性。比如 volatile int count 的 count++ 仍然不是原子的。

四、使用Lock解决竞态:更灵活的锁

除了 synchronized,Java还提供了 Lock 接口,比如 ReentrantLock。它比 synchronized 更灵活:

  • 支持尝试获取锁(tryLock)
  • 支持超时获取锁
  • 支持公平锁
  • 可以绑定多个条件变量
private final Lock lock = new ReentrantLock();
private int count = 0;

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();  // 一定要在 finally 里释放!
    }
}

// 带超时的尝试获取锁
public boolean tryIncrement(long timeout, TimeUnit unit) {
    try {
        if (lock.tryLock(timeout, unit)) {
            try {
                count++;
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false;
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return false;
    }
}

我个人建议,在以下场景优先考虑 Lock:

  • 需要尝试获取锁,获取不到就做其他事情
  • 需要控制锁的公平性
  • 需要多个条件变量(Condition)

但如果是简单的同步块,synchronized 更简洁,而且JVM对它做了很多优化,性能不一定比 Lock 差。

五、知识体系总览

下面这张图,是我梳理的本章核心知识结构。你可以把它当作一个思维导图来看。

线程安全与竞态条件 竞态条件 定义:结果依赖线程执行时序 典型场景:count++ 非原子操作 原子操作 AtomicInteger / synchronized 不可分割,要么全做要么不做 可见性 volatile 关键字 强制从主内存读写 使用 Lock 解决竞态 ReentrantLock tryLock 超时机制 公平锁 / 条件变量 核心:原子性 + 可见性 = 线程安全

六、避坑指南

最后,分享几个我踩过的坑:

我曾经犯过的错:

  • 在 synchronized 块里调用了外部方法,导致死锁。后来规定:锁内代码要尽量短,不要调用不可控的方法。
  • 用 volatile 修饰了 long 和 double 类型,以为万事大吉。其实在32位JVM上,long 和 double 的读写不是原子的,必须加 volatile 或者用锁。
  • 在 Lock 的 finally 块里忘了释放锁。后来我养成了一个习惯:写 lock() 之后立刻写 try { ... } finally { unlock() } 的模板。

嗯,今天就讲到这里。线程安全这个话题,说起来简单,但真正用好需要大量实践。希望你能把这些概念记在心里,写代码的时候多想想:这个变量会被多个线程访问吗?它的可见性和原子性都保证了吗?

可可精品资料库