1. 简介

简单来说,当涉及并发时,共享可变状态极易引发问题。如果对共享可变对象的访问管理不当,应用程序很快就会陷入难以检测的并发错误。

本文将回顾使用锁处理并发访问的传统方式,探讨锁机制存在的缺点,最后介绍原子变量作为替代方案。

2. 锁机制

先看这个类:

public class Counter {
    int counter; 
 
    public void increment() {
        counter++;
    }
}

在单线程环境下,这段代码完美运行;但一旦允许多个线程写入,结果就会变得不一致。

这是因为看似原子的 counter++ 操作,实际由三个步骤组成:获取值、递增、写回新值。

如果两个线程同时尝试获取并更新值,可能导致更新丢失。

管理对象访问的常见方式是使用锁。通过在 increment 方法签名上添加 synchronized 关键字实现(关于锁与同步的更多细节可参考:Java Synchronized 关键字指南):

public class SafeCounterWithLock {
    private int counter;
 
    public synchronized void increment() {
        counter++;
    }
}

使用锁确实解决了问题,但性能会受损。

当多个线程尝试获取锁时,只有一个线程能成功,其余线程会被阻塞或挂起。

线程挂起和恢复的过程开销极大,严重影响系统整体效率。

在像计数器这样的小程序中,上下文切换耗时可能远超实际代码执行时间,导致整体效率大幅下降。

3. 原子操作

并发编程领域有一个研究方向专注于创建非阻塞算法。这些算法利用底层原子机器指令(如比较并交换 CAS)来保证数据完整性。

典型的 CAS 操作包含三个操作数:

  1. 操作的内存位置(M)
  2. 变量的当前预期值(A)
  3. 需要设置的新值(B)

CAS 操作会原子性地将 M 的值更新为 B,但前提是 M 的当前值必须等于 A,否则不执行任何操作。

无论是否成功,都会返回 M 的当前值。这相当于将"获取值-比较值-更新值"三个步骤合并为单个机器级指令。

当多个线程通过 CAS 尝试更新同一值时,只有一个线程会成功并更新值。但与锁机制不同,其他线程不会被挂起;它们只是被告知更新失败。线程可以继续执行后续任务,完全避免了上下文切换。

另一个副作用是核心程序逻辑变得更复杂。因为我们需要处理 CAS 操作失败的情况:可以不断重试直到成功,也可以根据业务场景选择放弃操作。

4. Java 中的原子变量

Java 中最常用的原子变量类包括:

  • AtomicInteger
  • AtomicLong
  • AtomicBoolean
  • AtomicReference

它们分别表示可原子更新的 intlongboolean 和对象引用。这些类暴露的核心方法有:

  • get() – 从内存获取值,确保其他线程的修改可见;等效于读取 volatile 变量
  • incrementAndGet() – 原子性地将当前值加一
  • set() – 将值写入内存,确保对其他线程可见;等效于写入 volatile 变量
  • lazySet() – 最终将值写入内存,可能与后续相关内存操作重排序。典型场景是:为垃圾回收而置空不再访问的引用。通过延迟 volatile 写入可获得更好性能
  • compareAndSet() – 如第 3 节所述,成功返回 true,否则返回 false
  • weakCompareAndSet() – 如第 3 节所述,但内存语义更弱:不建立 happens-before 顺序,可能看不到其他变量的更新。自 Java 9 起,该方法在所有原子实现中已废弃,改用 weakCompareAndSetPlain()。原方法内存效果是 plain(普通)但名称暗示 volatile 效果,为避免混淆,废弃该方法并新增了四种不同内存效果的方法,如 weakCompareAndSetPlain()weakCompareAndSetVolatile()

使用 AtomicInteger 实现的线程安全计数器示例如下:

public class SafeCounterWithoutLock {
    private final AtomicInteger counter = new AtomicInteger(0);
    
    int getValue() {
        return counter.get();
    }
    
    void increment() {
        counter.incrementAndGet();
    }
}

可以看到,incrementAndGet() 方法相当于一个同步代码块:获取当前值、加一、赋值给计数器变量,最后存储到内存中。

5. 总结

本教程介绍了处理并发的替代方案,可避免锁机制带来的性能问题。我们还探讨了 Java 原子变量类暴露的核心方法。

所有示例代码均可在 GitHub 获取。

要了解更多内部使用非阻塞算法的类,可参考 ConcurrentMap 指南


原始标题:An Introduction to Atomic Variables in Java