阅读 23

Java中的原子操作以及CAS的概念

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

详细介绍了Java的原子操作的定义,以及Java如何实现原子操作,以及CAS的概念。

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在单线程下保证了天然的原子操作,但是在多线程下实现原子操作就变得有点复杂。

1 术语定义

原子操作相关的术语:

在这里插入图片描述

2 处理器如何实现原子操作

首先,处理器会保证基本的内存操作的原子性,当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址;一些处理器可以自动保证单处理器对同一个缓存行里进行16/32/64位(根据缓存行里的字节宽度来定)的操作是原子的;对于复杂的内存操作处理器是不能自动保证原子性的,比如对跨总线宽度/跨缓存行/跨页表的访问,但是有两个解决方法来保证内存操作的原子性。

2.1 使用总线锁保证原子性

如果多个处理器同时对共享变量进行读改写操作,例如,i++操作,那么共享变量会被多个处理器同时操作,这样读改写操作就不是原子的了。例如,cpu1将共享变量i=1读到自己的缓存,与此同时,cup2将i=1也读入了了自己的缓存,cup1 和cpu2在各自读到的i=1的基础上进行++操作,之后再写回系统内存中,此时i=2,两次自加操作,并没有让i=3。

要想保证读写改一个变量的操作是原子的,就必须保证CUP1读写改共享变量的时候,CUP2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决这个问题的。总线锁就是使用处理器提供的一个LOCK信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

2.2 使用缓存锁定来保证原子性

在同一时刻,我们只需要保证对某个内存地址的操作是原子性即可,但是使用总线锁时,所有处理器的请求都会被阻塞,即使那些访问的内存地址不与该处理器起冲突的处理器也一样,这就使得总线的开销比较大,因此某些CPU使用缓存锁代替总线锁来进行优化。

所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行数据时,会使缓存行无效。简单一句话概括就是,针对缓存行的锁定,保证只有一个处理器缓存被锁定的内存地址,当该处理器修改缓存行时使用了缓存锁定,那么其他处理器就不能同时缓存该缓存行。

3 Java如何实现原子操作

Java中可以使用锁和循环CAS的方式实现原子操作。

3.1 使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

3.2 使用循环CAS(无锁)实现原子操作

对于并发控制而言, 锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突,因此,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,则宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。 而无锁是一种乐观的策略,它会假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下待续执行。那遇到冲突怎么办呢?无锁的策略使用一种叫作比较交换(CAS, Compare And Swap) 的技术来鉴别线程冲突, 一旦检测到冲突产生,就重试当前操作直到没有冲突为止。

3.2.1 CAS的介绍

CAS(Compare and Swap),翻译过来就是“比较并交换”。CAS 操作包含三个操作数 —— 要更新的变量内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。

CAS 操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,,直到成功。当然也允许失败的线程放弃操作。

简单地说, CAS 需要你额外给出一个期望值, 也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样, 则说明它已经被别人修改过了。你就重新读取, 再次尝试修改就好了。

在硬件层面, 大部分的现代处理器都已经支持原子化的CAS 指令。在JDK5 以后, 虚拟机便可以使用这个指令来实现并发操作和并发数据结构, 并且这种操作在虚拟机中可以说是无处不在的。

从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。它们的内部主要就是使用CAS操作来实现的

如下案例:

public class Practise {
    private AtomicInteger atomicI = new AtomicInteger(0);
    //使用到计数器实现线程等待,设置初始计数器值为100
    private static CountDownLatch countDownLatch = new CountDownLatch(100);

    public static void main(String[] args) throws InterruptedException {
        final Practise cas = new Practise();
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(50, 100, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(50), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
        for (int j = 0; j < 100; j++) {
            threadPoolExecutor.submit(() -> {
                for (int i = 0; i < 10000; i++) {
                    cas.safeCount();
                    //cas.unsafeCount();
                }
                //上面的任务执行完毕,计数器值减去一
                countDownLatch.countDown();
            });
        }
        threadPoolExecutor.shutdown();
        //main线程将会等待所有任务都执行完,即计数器值变为0时,才继续执行
        countDownLatch.await();
        System.out.println(cas.atomicI.get());
    }

    /**
     * 不安全的更新方式,unsafeCount方法中的代码不是原子性的,有可能造成多个线程重复写同一个变量
     */
    private void unsafeCount() {
        int i = atomicI.get();
        atomicI.set(++i);
    }

    /**
     * 使用CAS实现安全的更新方法
     */
    private void safeCount() {
        //for循环尝试使得共享变量atomicI的值增加1
        for (; ; ) {
            int i = atomicI.get();
            //compareAndSet方法是Java帮我们实现的一个CAS方法,CAS成功之后会返回true,CAS失败则返回false
            //compareAndSet方法的参数含义是:预估原值为i,让后将原值尝试CAS更新为++i;其他所需的参数则是在compareAndSet方法内部帮我们自动获取了。
            //如果是true,说明变量atomicI的值增加成功,跳出循环,如果返回false,说明变量atomicI的值增加失败,重新循环直到成功为止
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }

    /**
     * compareAndSet方法内部,变量和字段内存偏移量帮我们获取了
     * @param expect
     * @param update
     * @return
     */
//    public final boolean compareAndSet(int expect, int update) {
//        //this:变量  valueOffset:value值的偏移量,通过此可以定位该字段在JVM内存中的位置  expect:预估原值   update:更新为指定值
//        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
//    }
}
复制代码

1.3.2.2 CAS实现原子操作的三大问题

  1. ABA问题:cas需要再操作值的时候,检查值有没有发生变化,如果没有发生变化则更新。但是一个值,如果原来为A,变成了B,又变成了A,那么使用CAS进行compare and set的时候,会发现它的值根本没变化过,但实际上是变化过的。ABA问题的解决思路就是使用版本号,1A->2B->3C。在Atomic包中(above 1.5),提供了一个AtomicStampedReference来解决ABA问题。
  2. 循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
  3. 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

参考资料:

  1. 《Java并发编程之美》
  2. 《实战Java高并发程序设计》
  3. 《Java并发编程的艺术》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

文章分类
后端
文章标签