复习(一)从Volatile到CAS

Volatile随着学而不用后,又被我忘在脑后,重新学习一下。


目录

Volatie是什么?

Java的内存模型

Volatile可见性验证

创建资源类

线程操纵资源类

Volatile不保证原子性

volatile不保证原子性代码演示

如何保证原子性?

使用原子类保证原子性

CAS

CAS是什么?

从代码体验CAS

CAS源码解析

CAS简单小总结

CAS的缺点

循环时间长 开销大

只能保证一个共享变量的原子操作

ABA问题


Volatie是什么?

Volatile是Java虚拟机提供的轻量级的同步机制,它有两个功能:保证线程对共享变量的可见性,和禁止指令重排。

为什么说是轻量级

这要从Java的内存模型说起。

Java的内存模型

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通讯机制有两种:共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态来进行隐式通信。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明,如果编写多线程程序的Java程序员不理解隐式进行的线程之间的通讯的工作机制,很可能会遇到各种奇怪的内存可见性问题

                                             ---- 摘抄于《Java并发编程的艺术》第三章,Java的内存模型

Java的线程之间的通信由Java的内存模型(JMM)所控制,JMM决定了一个线程堆共享变量的写入何时对另一个线程可见。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有个本地内存,本地内存中存储了该线程以读/写共享变量的副本。

JMM就是一个抽象的概念,它并不真实存在,它描述的是一种规范。

如下图所示:

根据上图来看,如果线程A和线程B要进行通信,则要经历下面两个步骤:

  • 线程A把本地内存A中更新过的共享变量刷新到主内存中
  • 线程B到主内存中读取线程A之前已更新过的共享变量

从整体来看,这两个步骤实际上是线程A向线程B发送消息,而且这个通讯过程必须要经过主内存。JMM控制主内存与每个线程本地内存之间的交互,来为Java程序提供内存可见性保证。

JMM有三大特性:

  • 可见性
  • 原子性
  • 有序性

而Volatile实现了其中两种:可见性有序性,因此说它是轻量级的同步机制

Volatile可见性验证

通过前面对JMM的介绍,我们知道,各个线程对共享变量的操作都是各个线程各自拷贝到自己本地的工作内存进行操作再写回到主内存中的。

这就可能存在一个问题:

假设线程AAA修改了共享变量X的值但还未写回主内存时候,线程BBB又对主内存的共享变量X进行了操作,但此时A线程工作内存中共享变量X对线程B来说并不可见。

这种工作内存与主内存同步延迟的现象造成了可见性问题

创建资源类

定义i=0,addTo60的方法使i=60

class Mydata {
    int i = 0;

    public void addTo60() {
        i = 60;
    }
}

线程操纵资源类

线程aaa操纵资源类,使i变为60,另外一条线程则是main线程,i==0的时候进入了无限循环

public class VolatileDemo {

    public static void main(String[] args) {
        Mydata mydata = new Mydata();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "come in ");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            mydata.addTo60();
            System.out.println(Thread.currentThread().getName() + "update number value :" + mydata.i);
        }, "aaa").start();

        while (mydata.i == 0) {

        }
        System.out.println(Thread.currentThread().getName() + "mission is over");
    }
}

当这个程序开始执行后,线程aaa在沉睡三秒以后对共享变量i进行了赋值到60,而mian线程因为不知道i已经变为60,还以为i==0在无限循环中,程序一直无法结束。

而把共享变量i用volatie修饰以后,程序就能执行下去了,main线程知道了共享变量i已经不再为0了。

volatile int i = 0;

Volatile不保证原子性

volatile能够保证可见性,却不能保证原子性,也就是在某个线程正在执行每个具体业务时,中间不可以被加塞或者被分割,需要整体完整,也就是数据的完整性。

volatile不保证原子性代码演示

资源类

class Mydata {
    volatile int i = 0;

    public void addPlusPlus() {
        i++;
    }
}

线程操作资源类,创建20个线程执行对变量i++的操作,每个线程执行1000次。理想情况,最终i的结果会等于20000。

    public static void main(String[] args) {
        Mydata mydata = new Mydata();
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    mydata.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t finally number value" + mydata.i);
    }

实际上程序执行的结果却总是丢失:

为什么最终结果会不等于20000?

要知道各线程是把共享变量拿到自己工作内存中进行修改,再写会主内存,当 i = 0,ABC线程都拿到i=0的数据,执行i++操作,假设A线程在写完i=1的时候,因为上下文切换,被挂起,值还未写回主内存。B线程将写好的值放回主内存,通知其他线程值已经改变,但线程执行速度是很快的。在还未反应过来的前提下,A把i=1写回了主内存,覆盖了B线程写的值i=1,主线程里的值本应该相加到2 ,却被1覆盖了。

出现了丢失写值的情况,这就为什么最终结果不等于20000的原因。

如何保证原子性?

参考 什么是CAS机制?_金所炫我女朋友的博客-CSDN博客_cas机制

synchronize同步锁能够使以上代码变成原子性操作,最终实现了线程安全。虽然synchronize能够保证线程安全,但是在某些情况下,这并不是一个最优选择。

  public synchronized void addPlusPlus() {
        i++;
    }

synchronize关键字会让没有得到锁资源的线程进入BLOCKED的状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统优化模式和内核模式的转换,代价比较高。

尽管Java 1.6 为synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。

面对这种情况,我们就可以使用java中的原子操作类。

所谓原子操作类,指的是 java.util.concurrent.atomic包下,一系列以Amotic开头的包装类。如果AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子操作。

使用原子类保证原子性

getAndIncrement()方法就是原子类中的i++操作,它使最终的结果变为正确的20000,这样的代码性能会比synchronized更好。

 为什么Atomic能够保证原子性且性能较好?因为Atomic操作类的底层用到了CAS机制。

CAS

CAS是什么?

CAS的全称为Compare and swap,它是一条CPU并发原语。

它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

CAS并发原语体现在Java语言中sun.misc.Unsafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。只是一种完全依赖于硬件的功能。通过它实现了原子操作。

再次强调,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU原子指令,不会造成所谓的数据不一致的问题。

从代码体验CAS

说白了,CAS就是更新之前,比较下变量是否更期望值一样,如果是,则更新成功。

    public static void main(String[] args) {
        // atomicInteger的值为0
        AtomicInteger atomicInteger = new AtomicInteger(0);
        // 比较再交换 如果期望值为0,那么更新为1 此处返回true 更新成功
        System.out.println("atomicInteger:"+atomicInteger+","+atomicInteger.compareAndSet(0, 1));
        // atomicInteger目前为 1
        // 比较再交换 如果期望值为0,那么更新为1 此处返回false 更新失败 因为atomicInteger目前为1
        System.out.println("atomicInteger:"+atomicInteger+","+atomicInteger.compareAndSet(0, 1) );
    }

CAS源码解析

查看Atomic原子类的源码,以getAndIncrement()方法举例,可以看到三个参数,当前对象,内存偏移量,1。

内存偏移量来自与Unsafe.class,Unsafe类存在于sun.misc包中,其内部方法可以像C操作指针一样直接操纵内存,因为CAS操作的执行依赖于Unsafe类的方法。

继续查看getAndIncrement()调用的getAndAddInt()方法的源码,可以看到:先获取当前对象var1和var2内存偏移量,赋值给var5。

如果compareAndSwapInt()方法中,当前对象var1和内存偏移量var2刚好等于var5,var5则加上var4(要增加的数字),并返回true,比较并替换成功。结果取反,就跳出循环。

如果对比失败,则返回false,取反false,就一直在while循环中比较,直到成功为止。

以上代码,我们拿两个线程AB为例,根据JMM内存模型,线程A和线程B各自从主内存拿到共享变量value的值5(假设这个值为5)到自己的工作空间修改,执行getAndAddInt()操作。

A线程通过getIntVolatile(var1,var2)拿到value值5

B线程通过getIntVolatile(var1,var2)拿到value值5,此时刚好B线程没有挂起并执行了compareAndSwapInt()方法,比较主内存的值也为5,对比成功,把值更新为6

A线程这时醒过来,通过compareAndSwapInt()方法对比,发现工作内存里的数字5和主内存中的6不一致,说明该值已被其他线程更新过了。A线程本次修改失败,只能重新读取再来一次。

A线程重新获取value值,因为变成value被volatile修饰,所以其他线程对它的修改,A线程总是能看到,线程A继续执行compareAndSwapInt()进行比较替换,直到成功(这是一个自旋的过程)

CAS简单小总结

CAS(CompareAndSwap)

比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,否则继续比较知道主内存和工作内存的值一致为止。

CAS应用

CAS有三个操作数,内存值V,旧的预期值A,要修改的更新值B。

只有当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS的缺点

  • 循环开销大
  • 只能保证一个共享变量的原子操作
  • ABA问题

循环时间长 开销大

可以看到底层源码中有个do{}while{}的循环,如果遇到极端情况,CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大开销。

只能保证一个共享变量的原子操作

从源码可见只能保证当前对象

ABA问题

 CAS的ABA问题详解 - 技术-刘腾飞 - 博客园

在多线程环境中,当某个共享变量被一个线程连续重复读取两次,那么只要第一次和第二次读取的值一样,那么这个线程就会认为这个变量在两次读取时间间隔内没有任何变化。

假设

线程1 期望值为A 想要把值更新为B

线程2 期望值为A 想要把值更新为B

线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望值A比较,发现相等更新为B。这个时候出现了线程3,期望值为B,希望更新为A,发现主内存的值就是B,更新为A成功。这是线程2从阻塞中恢复,获得CPU时间片,这时线程2取值与期望值A比较,发现相等把值更新为B。

虽然线程2也完成了操作,但线程2并不知道已经经过了A-B-A的变化过程。

如果要解决ABA问题,做到严谨的CAS机制,我们在compare阶段不仅仅是要比较期望值A和地址V的实际值,还要比较变量的版本号是否一致。

在Java中,AtomicStampedReference类实现了用版本号做比较的CAS机制。

        // 初始值 版本号
        AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference(100, 1);

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值