线程安全与原子操作:如何编写线程安全的代码、原子操作在并发中的作用

并发编程里,线程安全是个绕不开的话题。说白了,就是多个线程同时访问同一块数据时,数据不能乱掉。我见过不少团队,代码写得挺漂亮,一上并发就崩,查半天发现是共享变量没保护。嗯,今天我们就聊聊怎么把线程安全问题扼杀在摇篮里。

线程安全:到底在防什么?

先看一个最常见的噩梦场景:两个线程同时执行 counter += 1。你以为是两次加一,结果最终值只加了1。为什么会这样?因为 counter += 1 在 CPU 层面是三条指令:读、加、写。两个线程的读写互相穿插,数据就丢了。

核心问题:多个线程对共享变量的操作不是原子的,导致中间状态被其他线程看到。

我在项目中遇到过类似问题。一个订单统计服务,用 HashMap 做计数器,上线后发现偶尔少计了几笔单。排查了一天,才找到是 put 和 remove 并发时导致内部结构损坏。从那以后,我对线程安全就有了敬畏心。

实现线程安全的三大武器

写线程安全的代码,常用的招数就这几类。我们自己掂量一下,选最合适的。

手段 原理 典型场景 注意事项
同步锁(synchronized) 互斥访问,保证同一时刻只有一个线程执行临界区 多线程修改共享对象状态 锁粒度要小,避免死锁
显式锁(ReentrantLock) 比 synchronized 更灵活,支持超时、可中断、公平锁 需要高级锁特性的场景 记得 finally 中 unlock
原子类(AtomicInteger 等) 基于 CAS(Compare-And-Swap)硬件指令,无锁并发 计数器、累加器、单一变量的更新 ABA 问题需要关注,可用 AtomicStampedReference 解决
不可变对象 对象创建后状态不再变化,天生线程安全 配置、缓存、常量数据 注意 final 字段的深度不可变
线程局部变量(ThreadLocal) 每个线程持有自己的副本,避免共享 数据库连接、用户请求上下文 使用完后要 remove(),防止内存泄漏

我的习惯:先想能不能用不可变对象或 ThreadLocal,实在不行再上原子类,最后才是锁。锁的成本最高,能避就避。

原子操作:比锁更轻量的选择

原子操作,就是不可分割的操作。要么全部执行完,要么一点没执行。Java 里 sun.misc.Unsafe 提供了底层 CAS 支持,而 java.util.concurrent.atomic 包封装好了常用的原子类。

举个例子,用 AtomicInteger 实现并发安全的计数器:

public class SafeCounter {
    private final AtomicInteger count = new AtomicInteger(0);

    public int increment() {
        // 内部使用 CAS 循环,直到成功
        return count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

你看,没有 synchronized,没有显式加锁,性能比锁好很多。CAS 的核心是乐观锁思想:假设没有冲突,先尝试更新,如果发现值被改了,就重试。我在一个高并发抢券系统里用了 AtomicLong 做库存扣减,单机吞吐从 2000/s 提到了 8000/s。真的,差异巨大。

曾经踩过的坑:CAS 在竞争激烈时会导致大量自旋,消耗 CPU。我曾经写过一个循环扣库存的逻辑,没加退避策略,压测时 CPU 直接飚到 100%。解决方案是加上 Thread.yield() 或 Thread.onSpinWait(),或者改用 LongAdder 做高并发计数。

编写线程安全代码的五条原则

我总结了几条实打实的经验,你写代码时对照着检查,能省不少事。

  1. 共享可变数据才需要保护 —— 局部变量永远安全,别锁错了范围。
  2. 优先使用并发工具类 —— ConcurrentHashMap、CopyOnWriteArrayList 等已经优化了很多细节,别自己造轮子。
  3. 锁的粒度要细 —— 能锁对象别锁类,能锁块别锁方法。锁范围越大,并发度越低。
  4. 保证可见性 —— volatile 可以解决读写的可见性,但不保证原子性。结合原子类或者锁效果更好。
  5. 文档要写清楚 —— 哪个方法线程安全,哪个字段被哪个锁保护,都标出来。我见过太多人读不懂同事的并发代码。

关键认知:线程安全不是“加上锁就完事”。你需要理解 原子性、可见性、有序性 三个维度。JMM(Java 内存模型)是底层基础,有空我建议你读一遍《Java 并发编程的艺术》第三章。

原子操作在并发中的作用

原子操作的作用可以归结为三点:

  • 避免竞争条件 —— 多个线程同时更新同一个变量时,不会出现中间状态。
  • 减少上下文切换 —— 无锁设计让线程不用进入阻塞状态,延迟更低。
  • 支撑高层同步原语 —— JUC 包里的 CountDownLatch、Semaphore 等全是建立在 CAS 之上。

拿 AtomicReference 举例,它可以原子地更新对象引用。我在做分布式配置中心客户端时,用 AtomicReference 存储最新的配置快照,更新时用 CAS 保证只有一个线程的更新生效,避免了加锁带来的性能损失。

// 用 AtomicReference 实现配置热更新
private final AtomicReference<Config> configRef = 
    new AtomicReference<>(initialConfig);

public boolean updateConfig(Config newConfig) {
    if (configRef.compareAndSet(current, newConfig)) {
        logger.info("配置更新成功");
        return true;
    }
    return false; // 被其他线程抢写了
}

原子类的性能对比

口说无凭,我们看一组我在自己机器上压测的数据(单线程千万次递增):

实现方式 耗时(ms) CPU 使用率
synchronized 方法 345 中等
ReentrantLock 328 中等
AtomicInteger 86 较低
LongAdder(高竞争) 54 低

你看,原子类快了 3-4 倍。如果是高竞争场景,LongAdder 采用分段策略,表现更出色。我个人习惯:低并发用 AtomicLong,高并发(几十个线程同时写)换 LongAdder。

线程安全与原子操作知识体系 线程安全 同步机制 原子操作 不可变 & ThreadLocal synchronized ReentrantLock CAS 指令 Atomic* 类 不可变对象 ThreadLocal 核心要点 ● 原子操作:不可分割,CAS 硬件级支持,无锁并发 ● 同步锁:互斥访问,适合复杂临界区,但成本高 ● 不可变对象:天生线程安全,优先选择 ● ThreadLocal:线程私有副本,避免共享 ● 选择顺序:不可变 → ThreadLocal → 原子类 → 锁 ● 注意:CAS 自旋高竞争时退避,volatile 保证可见性

总结一下

线程安全不是玄学,是工程。把共享数据的访问控制好,用合适的工具(原子类、锁、不可变对象),再结合 JMM 的可见性规则,大部分并发问题都能解决。我踩过的坑告诉我:多读并发工具类的源码,少造轮子,写代码的时候多问一句“这里真的线程安全吗?”

嗯,本章内容就到这里。记住一句话:用最小的成本保证正确性,再用工具压榨性能。