07-Java多线程、原子操作和CAS

一、原子操作

  • 原子操作是一个不能再切分的操作。如果一个操作满足原子性和可见性,那么就是线程安全的。

1.1 Java中原子操作实现方式

1.1.1 悲观锁(阻塞同步)

  • synchronized或者显示锁

1.1.2 乐观锁(非阻塞同步)

  • 乐观的解决方案,顾名思义,就是很大度乐观,每次操作数据的时候,都认为别的线程不会参与竞争修改,也不加锁。如果操作成功了那最好;如果失败了,比如中途确有别的线程进入并修改了数据(依赖于冲突检测),也不会阻塞,可以采取一些补偿机制,一般的策略就是反复重试。很显然,这种思想相比简单粗暴利用锁来保证同步要合理的多。

1.2 原子操作示例

1.2.1 JDK原子操作类型

类型描述
AtomicBoolean原子布尔
AtomicInteger原子整型
AtomicIntegerArray原子整型数组
AtomicLong原子长整型
AtomicLongArray原子长整型数组
AtomicReference原子引用
AtomicReferenceArray原子引用数组
AtomicMarkableReference原子布尔版本戳(判断是否被修改)
AtomicIntegerFieldUpdater原子整型字段更新
AtomicLongFieldUpdater原子长整型字段更新
AtomicReferenceFieldUpdater原子引用字段更新
AtomicStampedReference原子整型版本戳(判断被修改过几次)

1.2.1 示例

  • 我们看到,如果使用原子类型,多线程之间的操作自增是安全的,每一次都可以获得正确的结果,如果只是使用volatile的话,是不行的,每次结果都是不一样的
public class AtmoTest {

    //切换volatile和原子类型
    static AtomicInteger auto = new AtomicInteger(0);
    //static volatile int  auto = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(new Modify()).start();
        }
        SleepTools.second(5);
        System.out.println("修改后的值是:" + auto);
    }

    static class Modify implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                //切换volatile和原子类型
                auto.getAndIncrement();
                //auto++;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

二、CAS

  • CAS是Compare And Swap,即先比较再设置。CAS是java中原子操作乐观锁的一种实现。在java.util.concurrent.atomic包下的原子类都是基于CAS来实现的。
  • 更新基本类型类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
  • 更新数组类:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
  • 更新引用类型:AtomicReference,AtomicMarkableReference,AtomicStampedReference
  • 原子更新字段类: AtomicReferenceFieldUpdater,AtomicIntegerFieldUpdater,AtomicLongFieldUpdater

2.1 CAS的实现原理

  • JAVA中的CAS操作都是通过sun包下Unsafe类实现,而Unsafe类中的方法都是native方法,由JVM本地实现,具体和不同的OS和硬件CPU都有关系。在Linux的X86下主要是通过cmpxchg 这个指令在CPU级完成CAS操作的,具体实现上有下面2种处理办法。
按照《深入理解Java虚拟机-JVM高级特效》一书的解释,CAS操作都需要硬件指令集的支持,因为其包含两个动作,一个是操作,另
一个是冲突检测,比如自增1,然后操作自增,然后检测是否冲突,检测的方式就是判断结果是否符合预期值,这两个步骤要保证原子
性,通过硬件层面的指令来保证。

实际上硬件层面的CAS指令也很多,CAS是比较并交换、测试并设置、获取并增加等..

2.1.1 总线加锁

  • 多个处理器同时对共享变量操作时,有可能引起数据安全问题,比如两个cpu都执行自增操作,有可能两个cpu都从自己的缓存中读取原值,然后自增之后再写入主存,由此来看,共享变量在多处理器环境下要保证原子性,需要保证当一个CPU操作共享变量时,另外CPU不能操作该变量的缓存(也有可能是缓存变量的地址)。
  • 总线加锁就是就是给处理器提供的一个LOCK信号,当一个处理器在总线上输出此信号时其他处理器的请求将被阻塞住,该处理器可以独占使用共享内存。在锁定期间,其他处理器都不能其他内存地址的数据,其开销较大。

2.1.1 缓存加锁

  • 缓存加锁用于应对总线加锁性能低的问题,其思路和编程语言层面的加锁类似,就是降低加锁的粒度,既然我只操作一个共享变量,那么每月必要锁住整个总线,只要保证对应的变量操作是原子性的就ok了,可以优化很多场景下的性能;
  • 缓存加锁:当需要操作的共享变量被缓存在处理器缓存中时,处理器加锁不再输出总线LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,当CPU1修改缓存行中的共享变量时会使得其他CPU缓存无效,因此其他CPU就不能同时修改共享变量,保证了原子性。
CPU本身有高速缓存,一般还有多级缓存,频繁访问的数据通常缓存在这一层Cache中
  • PS:
从缓存锁定的原理来看,如果数据没有缓存在CPU缓存内部,则无法使用缓存加锁,另外当操作数据跨多个缓存行
时,处理器也会使用总线锁定;

另外某些CPU可能并不支持缓存加锁

2.2 CAS的缺陷

2.2.1 循环时间长

  • 循环时间太长。如果CAS一直不成功呢?这种情况绝对有可能发生,如果自旋CAS长时间地不成功,则会给CPU带来非常大的开销。在JUC中,有些地方就限制了CAS自旋的次数,例如: BlockingQueue的SynchronousQueue 。

2.2.2 只能对一个变量操作

  • 只能对一个变量做原子操作。看了 CAS 的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当
    然如果你有办法把多个变量整成一个变量,利用 CAS 也不错。例如读写锁中 state 的高低位。

2.2.3 ABA问题

  • CAS可以有效的提升并发的效率,但同时也会引入ABA问题。CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是 A,变成了 B,然后又变成了 A,那么在 CAS检查的时候会发现没有改变,但是实质
    上它已经发生了改变,这就是所谓的ABA问题。对于 ABA 问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次
    改变时加 1 ,即 A —> B —> A ,变成1A —> 2B —> 3A 。 如线程1从内存X中取出A,这时候另一个线程2也从内存X中取出A,
    并且线程2进行了一些操作将内存X中的值变成了B,然后线程2又将内存X中的数据变成A,这时候线程1进行CAS操作发现内存X中仍然是A,然后线程1操作成功。虽然线程1的CAS操作成功,但是整个过程是有问题的。比如链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。

2.3 解决ABA问题

  • 原子包中提供了AtomicStampedReference/AtomicMarkableReference来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。前者可以记录版本号,通过版本号可知变量被修改了几次,后者内部通过一个布尔类型来记录变量是否被修改了,不记录修改的次数。

2.3.1 代码

public class ABA {

    private static AtomicInteger atomicInteger = new AtomicInteger(100);
    private static AtomicStampedReference atomicStampedReference = new AtomicStampedReference(100, 1);

    public static void main(String[] args) throws InterruptedException {

        // 1.线程at1悄悄的将atomicInteger改变了,然后又改了回来,
        Thread at1 = new Thread(new Runnable() {
            @Override
            public void run() {
                atomicInteger.compareAndSet(100, 110);
                atomicInteger.compareAndSet(110, 100);
            }
        });

        // 2.线程at1悄悄的将atomicInteger改变了,然后又改了回来,at2是感觉不到的
        Thread at2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(2);      // at1,执行完
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("使用AtomicInteger无法避免AB问题,我们修改成功了,说明我们没有意识到AtomicInteger已经被修改了: " + atomicInteger.compareAndSet(100, 120));
            }
        });

        at1.start();
        at2.start();

        at1.join();
        at2.join();

        // AtomicStampedReference
        // 3.线程tsf1悄悄的将atomicStampedReference改变了,然后又改了回来,但是版本号递增了
        Thread tsf1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    //让 tsf2先获取stamp,导致预期时间戳不一致
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 预期引用:100,更新后的引用:110,预期标识getStamp() 更新后的标识getStamp() + 1
                atomicStampedReference.compareAndSet(100, 110, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
                atomicStampedReference.compareAndSet(110, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            }
        });

        // 4.线程tsf2悄悄的将atomicStampedReference改变了,然后又改了回来,但是版本号递增了,因此tsf2能够感知到,
        //因为他在sleep期间发现版本号被修改了
        Thread tsf2 = new Thread(new Runnable() {
            @Override
            public void run() {
                int stamp = atomicStampedReference.getStamp();

                try {
                    TimeUnit.SECONDS.sleep(2);      //线程tsf1执行完
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("使用AtomicStampedReference可以避免AB问题,我们修改失败了,因为我们发现版本戳被修改了: " + atomicStampedReference.compareAndSet(100, 120, stamp, stamp + 1));
            }
        });

        tsf1.start();
        tsf2.start();
    }
}
  • 输出
使用AtomicInteger无法避免AB问题,我们修改成功了,说明我们没有意识到AtomicInteger已经被修改了: true
使用AtomicStampedReference可以避免AB问题,我们修改失败了,因为我们发现版本戳被修改了: false

三、小结

  • 在AtomicInteger中的主要方法都是一些先增在获取(addAndGet),或者先获取再自增(getAndIncrement)之类的API,这些操作其实包含2个步骤,拿getAndIncrement来举例,其实包括2个步骤:
1.是获取到这个变量在内存中的最新的,
2.将值自增1
3.将自增后的新的值写回内存保证其他线程看到最新结果
  • 这里实际上第一步和第三步是一个可见性的问题,在内存模型中,线程都有一个自己的私有内存空间,整个进程有一个主存,私有内存空间对其他线程是不可见的,因此我们写操作完成之后要刷到主内存,其他线程才能看到,读也是一样,我们读私有内存就可以读到脏值,需要去读主内存。
  • 第二步是一个原子性的问题,就是说自增的过程对这个变量来说是原子的,也就是这个过程是不可能被打断的。
  • 上面的3个过程是借助于CPU指令来实现的,简单描述就在2.1小节。借助于CPU指令级别的锁机制来保证线程之间的可见性和操作的原子性。
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
  • 疑问:CAS自旋会不会进入一个永远的死循环,如下所示如果期望将变量从11修改为15,如果这个变量永远都不会是11岂不是永远修改不成功,当然这个方法是立即返回的。但是在CAS的模式下编程会不会出现这样的死循环呢?这个要更多看JUC包下的源码中是如何使用CAS的。在AtomicInteger中类似于自增的操作是不会有这样的问题是,因为那些操作都是在现有值的基础上加1,不过仔细想想其实也有一个先读,再写的过程,具体可能需要研究更底层的实现机制。
    atomicInteger.compareAndSet(11, 15);
  • 补充
补充一个,在for循环中不断循环实现线程安全的自增方法,因为一个线程获取到之后,再去设置,这个过程中可能会有别的线程修改了,假如说
有多个线程,初始时为1,那么多个线程都获取到1,只会有一个线程将其设置为2,那么其他的线程在尝试将其设置为2的时候,发现他的值已经不
是1了,因此会失败,只能循环一次,再来获取一把,在尝试将其由2设置为3,因此类推,因此需要在循环中来实现。

四、参考

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值