深入拆解 Java CAS:从底层原理到 ABA 问题实战

0 阅读7分钟

在Java并发编程的世界里,锁是保证线程安全的常用手段,但独占锁的性能开销往往成为高并发场景的瓶颈。CAS(Compare-And-Swap,比较并交换)作为一种无锁并发算法,通过硬件级别的原子操作实现了线程安全,成为Java并发包(java.util.concurrent)的基石。本文将深入拆解CAS的底层原理,剖析ABA问题的产生与解决方案,并详解Unsafe类在CAS中的核心作用。

一、CAS底层原理

1.1 什么是CAS

CAS是一种无锁原子操作,其核心思想是:当且仅当内存地址V中的值等于预期值E时,将V的值更新为新值U,否则不执行任何操作并返回当前值。整个过程是原子性的,由CPU指令直接保证。

与synchronized等独占锁不同,CAS不需要线程阻塞和唤醒,而是通过循环重试的方式实现并发控制,因此也被称为“乐观锁”。

1.2 CPU指令级支持

CAS的原子性并非由JVM凭空实现,而是依赖于底层CPU的硬件指令。以x86架构为例,CAS通过cmpxchg指令实现,该指令会在总线锁或缓存锁的保护下,完成“比较-交换”的原子操作。

总线锁:当CPU要操作某个内存地址时,会锁住总线,阻止其他CPU访问该内存区域,直到操作完成。总线锁的开销较大,因为它会锁住整个总线。

缓存锁:如果内存地址被缓存在CPU的缓存行中,且该缓存行处于独占状态(MESI协议的M状态),CPU可以直接操作缓存行,无需锁住总线。缓存锁的开销远小于总线锁,是现代CPU的主流实现方式。

1.3 CAS执行流程

二、Unsafe类的核心作用

2.1 Unsafe简介

sun.misc.Unsafe是Java底层的一个核心类,它提供了直接操作内存、线程调度、CAS等硬件级别的能力。由于Unsafe的功能过于强大,且绕过了JVM的安全机制,因此被设计为“不安全”的类,不允许开发者直接实例化。

尽管如此,Unsafe却是Java并发包的基石,AtomicInteger、ConcurrentHashMap等并发工具类的底层实现都依赖于Unsafe。

2.2 获取Unsafe实例

Unsafe类的构造方法是私有的,且getUnsafe()方法会检查调用者的类加载器,只有启动类加载器(Bootstrap ClassLoader)加载的类才能直接获取Unsafe实例。因此,我们需要通过反射的方式获取Unsafe实例:

package com.jam.demo;

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class UnsafeUtil {
    private static Unsafe unsafe;

    static {
        try {
            Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafeField.setAccessible(true);
            unsafe = (Unsafe) theUnsafeField.get(null);
        } catch (Exception e) {
            throw new RuntimeException("Failed to get Unsafe instance", e);
        }
    }

    public static Unsafe getUnsafe() {
        return unsafe;
    }
}

2.3 Unsafe中的CAS方法

Unsafe提供了三个核心的CAS方法,均为native方法,直接调用底层CPU指令:

public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);

参数说明:

  • o:要操作的对象
  • offset:要操作的字段在对象中的内存偏移量
  • expected:预期值
  • x:新值

2.4 使用Unsafe实现原子操作

下面通过一个示例,演示如何使用Unsafe实现原子递增操作:

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;
import sun.misc.Unsafe;
import java.lang.reflect.Field;

@Slf4j
public class UnsafeAtomicCounter {
    private static final Unsafe UNSAFE;
    private static final long VALUE_OFFSET;
    private volatile int value = 0;

    static {
        UNSAFE = UnsafeUtil.getUnsafe();
        try {
            Field valueField = UnsafeAtomicCounter.class.getDeclaredField("value");
            VALUE_OFFSET = UNSAFE.objectFieldOffset(valueField);
        } catch (NoSuchFieldException e) {
            throw new RuntimeException(e);
        }
    }

    public int getValue() {
        return value;
    }

    public void increment() {
        int oldValue;
        do {
            oldValue = value;
        } while (!UNSAFE.compareAndSwapInt(this, VALUE_OFFSET, oldValue, oldValue + 1));
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeAtomicCounter counter = new UnsafeAtomicCounter();
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    counter.increment();
                }
            });
        }
        for (Thread thread : threads) {
            thread.start();
        }
        for (Thread thread : threads) {
            thread.join();
        }
        log.info("Final counter value: {}", counter.getValue());
    }
}

在这个示例中,increment()方法通过循环调用compareAndSwapInt()实现原子递增:每次先获取当前值oldValue,然后尝试将oldValue + 1更新到内存中,如果更新失败(说明其他线程已经修改了值),则重新获取当前值并重试,直到更新成功。

三、ABA问题的产生与解决方案

3.1 什么是ABA问题

ABA问题是CAS操作中的一个经典问题,指的是:线程1从内存地址V中读取值A,此时线程2也读取了值A,然后线程2将V的值修改为B,接着又修改回A,最后线程1执行CAS操作,发现V的值仍然是A,于是CAS操作成功

尽管线程1的CAS操作成功了,但实际上V的值已经被线程2修改过两次,这可能会导致一些隐藏的问题。

3.2 ABA问题演示

下面通过一个示例,演示ABA问题的产生:

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
public class ABADemo {
    private static AtomicInteger atomicInt = new AtomicInteger(100);

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            atomicInt.compareAndSet(100101);
            atomicInt.compareAndSet(101100);
            log.info("Thread A: 100 -> 101 -> 100");
        });

        Thread threadB = new Thread(() -> {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            boolean result = atomicInt.compareAndSet(100200);
            log.info("Thread B: CAS result = {}, value = {}", result, atomicInt.get());
        });

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

运行结果:

Thread A: 100 -> 101 -> 100
Thread B: CAS result = true, value = 200

在这个示例中,线程A将值从100修改为101,然后又修改回100,线程B在执行CAS操作时,发现值仍然是100,于是CAS操作成功。尽管结果看起来是正确的,但实际上值已经被修改过两次,这就是ABA问题。

3.3 解决方案一:版本号(AtomicStampedReference)

解决ABA问题的最常用方法是引入版本号。每次修改值时,不仅修改值本身,还会递增版本号。CAS操作时,不仅比较值是否相等,还会比较版本号是否相等。只有当值和版本号都相等时,才会执行更新操作。

Java并发包提供了AtomicStampedReference类,它可以同时维护一个对象引用和一个整数版本号,从而解决ABA问题。

下面通过一个示例,演示如何使用AtomicStampedReference解决ABA问题:

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicStampedReference;

@Slf4j
public class AtomicStampedReferenceDemo {
    private static AtomicStampedReference<Integer> stampedRef = new AtomicStampedReference<>(1000);

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            int stamp = stampedRef.getStamp();
            stampedRef.compareAndSet(100101, stamp, stamp + 1);
            stamp = stampedRef.getStamp();
            stampedRef.compareAndSet(101100, stamp, stamp + 1);
            log.info("Thread A: 100 -> 101 -> 100, stamp = {}", stampedRef.getStamp());
        });

        Thread threadB = new Thread(() -> {
            int stamp = stampedRef.getStamp();
            log.info("Thread B: initial stamp = {}", stamp);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            boolean result = stampedRef.compareAndSet(100200, stamp, stamp + 1);
            log.info("Thread B: CAS result = {}, value = {}, current stamp = {}", result, stampedRef.getReference(), stampedRef.getStamp());
        });

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

运行结果:

Thread B: initial stamp = 0
Thread A: 100 -> 101 -> 100, stamp = 2
Thread B: CAS result = false, value = 100, current stamp = 2

在这个示例中,线程B在执行CAS操作时,不仅比较值是否为100,还比较版本号是否为初始的0。由于线程A已经将版本号递增到2,因此线程B的CAS操作失败,从而避免了ABA问题。

3.4 解决方案二:标记(AtomicMarkableReference)

如果只需要知道值是否被修改过,而不需要知道修改的次数,可以使用AtomicMarkableReference类。它可以同时维护一个对象引用和一个布尔标记,标记表示值是否被修改过。

AtomicMarkableReference的使用方式与AtomicStampedReference类似,只是将版本号替换为布尔标记。

四、CAS的优缺点

4.1 优点

  1. 无锁并发:CAS不需要线程阻塞和唤醒,减少了线程上下文切换的开销,在高并发场景下性能优于独占锁。
  2. 细粒度控制:CAS可以针对单个变量进行原子操作,实现更细粒度的并发控制。
  3. 硬件级支持:CAS直接依赖于CPU的硬件指令,执行效率高。

4.2 缺点

  1. ABA问题:如前文所述,CAS可能会遇到ABA问题,需要通过版本号或标记来解决。
  2. 循环时间长:如果CAS操作一直失败,会导致线程长时间循环重试,消耗CPU资源。
  3. 只能保证一个变量的原子性:CAS只能保证单个变量的原子操作,无法保证多个变量的原子性。如果需要保证多个变量的原子性,可以使用锁或AtomicReference封装多个变量。

五、总结

CAS是Java并发编程中的核心技术,它通过硬件级别的原子操作实现了无锁并发,成为Java并发包的基石。在实际开发中,我们可以直接使用Java并发包提供的原子类(如AtomicInteger、AtomicStampedReference),它们已经封装了CAS的底层实现,使用起来更加方便和安全。同时,我们也需要注意CAS的缺点,合理选择并发控制手段,在保证线程安全的前提下,最大化系统的性能。