Unsafe与CAS

69 阅读6分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。

首先我们来看一个类—Unsafe 类 ,Unsafe类是在sun.misc包下,不属于Java标准

Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,一旦能够直接操作内存,这也就意味着

(1)不受jvm管理,也就意味着无法被GC,需要我们手动GC,稍有不慎就会出现内存泄漏。

(2)Unsafe的不少方法中必须提供原始地址(内存地址)和被替换对象的地址,偏移量要自己计算,一旦出现问题就是JVM崩溃级别的异常,会导致整个JVM实例崩溃,表现为应用程序直接crash掉。

(3)直接操作内存,也意味着其速度更快,在高并发的条件之下能够很好地提高效率。

一个java对象在内存中存储形式如下 image.png

只要我们能获取到storage对象在内存中的开始的地址值,再获取到变量 i 的偏移量 offset 就可以对变量 i 进行操作

Unsafe类对属性的操作

//获取Unsafe对象
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);
// 获取变量的偏移量
long offset = unsafe.objectFieldOffset(Storage.class.getDeclaredField("i"));

Unsafe提供了三个CAS原子操作的方法:compareAndSwapObject、 compareAndSwapInt、compareAndSwapLong

既然有可以直接操作底层的原子操作的方法,就可以直接替换掉我们上面写的 compareAndSwap 方法就可以避免使用 synchronized 来保证 cas的原子性

代码实现如下:

public class UnsafeCasIncrDemo {
 
    private static final int n = 100;
 
    public static void main(String[] args) throws InterruptedException {
        //保证子线程执行完后,主线程再打印storage.i的值
        CountDownLatch cd = new CountDownLatch(n);
        //创建内部类Storage对象
        Storage storage = new Storage();
 
        long t1 = System.currentTimeMillis();
 
        //创建 n 个线程,每个线程内部循环10000 次,进行自增操作
        for (int i = 0; i < n; i++) {
            new Thread(() -> {
                for (int x = 0; x < 10000; x++) {
                    storage.unsafeIncr();
                }
                cd.countDown();
            }).start();
        }
 
        cd.await();
 
        long t2 = System.currentTimeMillis();
        System.out.println(storage.i);
        System.out.println("cost: " + (t2 - t1));
    }
 
    static class Storage {
        public volatile int i;
        private static Unsafe unsafe;
        static long offset;
 
        static {
            try {
                //通过反射获取 Unsafe对象
                Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
                theUnsafe.setAccessible(true);
                unsafe = (Unsafe) theUnsafe.get(null);
                //获取变量 i 的 offset
                offset = unsafe.objectFieldOffset(Storage.class.getDeclaredField("i"));
            } catch (Exception e) {
 
            }
            AtomicInteger count = new AtomicInteger(0);
            int c = count.incrementAndGet();
 
        }
 
        public void unsafeIncr() {
            int v = i;
            while (!unsafe.compareAndSwapInt(this, offset, v, v + 1)) ;
        }
    }
}

上一节讲了 volatile 关键字的原理和使用,留给大家一个思考题:这里的变量 i 不加 volatile 可以吗?

我们使用cas操作实现了 i++操作,至此就应该完全理解了我们经常使用的java提供的原子类的原理。 image.png

以 AtomicInteger 为例:

AtomicInteger 同样提供了cas操作:

AtomicInteger count = new AtomicInteger(0);
boolean b = count.compareAndSet(0, 1);

来看 compareAndSet 方法的底层实现,是不是很眼熟,和我们之前写的一模一样 image.png

再来看 AtomicInteger 的自增操作

AtomicInteger count = new AtomicInteger(0);
int c = count.incrementAndGet();

再来看incrementAndGet的实现 image.png incrementAndGet 内部调用了 unsafe类的getAndAddInt方法,乍一看和我们的实现不一样,我们再往里看一层,看 unsafe.getAndAddInt方法

image.png

cas操作实现了无锁的原子操作,了解了cas操作是怎么实现的,来看cas存在的问题 cas的三大问题

  1. ABA问题 在多线程环境中,某个location(或可以理解为某内存地址指向的变量)会被一个线程连续重复读取两次,那么只要第一次读取的值和第二次读取的值一样,那么这个线程就会认为这个变量在两次读取时间间隔内没有发生任何变化;

在单线程环境下,确实可以保证说当一个线程对同一个内存地址连续读取两次,如若取值没有变化,就可以认为内存地址的值没有被修改过;而在多线程环境下,在两次读取的时间间隔内,其他线程很可能对这个值做了修改,然后又改回原值,这似乎给此时正在重复读取变量的线程造成了该内存变量没有发生变化的错觉。

归结起来,构成 ABA 问题有三个重要的条件:

某个线程需要重复读某个内存地址,并以内存地址的值变化作为该值是否变化的唯一判定依据; 重复读取的变量会被多线程共享,且存在『值回退』的可能,即值变化后有可能因为某个操作复归原值; 在多次读取间隔中,开发者没有采取有效的同步手段,比如上锁。 代码复现ABA问题如下

public class ABAProblemDemo {
 
 
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(1);
 
        new Thread(() -> {
            int x = num.get();
            int n = num.get() + 1;
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean b = num.compareAndSet(x, n);
            System.out.println(b);
        }).start();
 
        Thread.sleep(5);
        num.compareAndSet(num.get(), num.get() + 1);
        System.out.println("num 1:" + num.get());
 
        num.compareAndSet(num.get(), num.get() - 1);
        System.out.println("num 2: " + num.get());
    }
}

如何解决ABA问题? 可以给每个值加一个版本号,在比较的时候不光比较值和预期值是否相等,同时比较版本号是否和预期版本号相等 当值被修改,版本号自增加1,版本号是单调递增的,可以保证不再出现ABA问题。 image.png

java提供了AtomicStampedReference和AtomicMarkableReference来解决ABA问题

AtomicStampedReference可以原子更新两个值:引用和版本号,通过版本号来区别值是否被修改

AtomicMarkableReference可以原子更新一个布尔类型的标记位和引用类型

我们以AtomicStampedReference 为例

public class ABAProblemSolveDemo {
    static AtomicStampedReference<Integer> num = new AtomicStampedReference<>(0,1);
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            int stamp = num.getStamp();
            int x = num.getReference();
            int n = num.getReference() + 1;
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean b = num.compareAndSet(x,n,stamp,stamp+1);
            System.out.println(b);
        }).start();
 
        Thread.sleep(5);
        num.compareAndSet(num.getReference(), num.getReference() + 1,num.getStamp(),num.getStamp()+1);
        System.out.println("num 1:" + num.getReference()+" , stamp1: " + num.getStamp());
 
        num.compareAndSet(num.getReference(), num.getReference() -1 ,num.getStamp(),num.getStamp()+1);
        System.out.println("num 2:" + num.getReference()+" , stamp2: " + num.getStamp());
    }
}
  1. 循环时间长开销大 自旋CAS如果长时间不成功会一直自旋循环,会给CPU带来非常大的执行开销。

解决思路——让阻塞的线程挂起,等到合适的时机再唤醒

  1. 只能保证一个共享变量的原子性操作: cas可以保证原子性,但是只能保证一个变量的原子性。因为cas的原子性是靠cpu执行指令的时候内部机制保证的,它只能一次保证比较和交换操作的原子性。

那么问题来了,如果要保证多个变量的原子性该怎么办呢?

可以把多个变量合并成一个变量,然后使用JDK 提供的 AtomicReference 类来保证引用对象之间的原子性,就可以把 多个变量放在一个对象里来进行 CAS 操作。

public class AtomicReferenceDemo {
    static AtomicReference<SimpleObject> atomicReference = new AtomicReference(new SimpleObject());
 
    public static void main(String[] args) {
 
        for(int i=0;i<1000;i++){
            Thread thread = new Thread(() ->{
                SimpleObject simpleObject = atomicReference.get();
                int vi = simpleObject.i;
                int vj = simpleObject.j;
                atomicReference.compareAndSet(simpleObject,new SimpleObject(vi+1,vj+1));
            });
            thread.start();
        }
 
        System.out.println(atomicReference.get());
    }
 
    static class SimpleObject{
        Integer i = 0;
        Integer j = 0;
 
        public SimpleObject() {
        }
 
        public SimpleObject(Integer i, Integer j) {
            this.i = i;
            this.j = j;
        }
    }
}