【面試(自己看)】一.JUC多线程及高并发(1)之 volatile关键字与CAS

JUC多线程及高并发

1- 请谈谈你对volatile的理解

volatile是Java虚拟机提供的轻量级的同步机制
它具有 保证可见性,不保证原子性,禁止指令重排序 即 有序性的三大特性

可见性
由于JVM运行程序的实体是线程, 而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域, 而Java内存模型中规定所有变量都存储在主内存, 主内存是共享内存区域, 所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行, 首先要将变量从主内存拷贝到自己的工作的内存空间,然后对变量进行操作, 操作完成后再将变量写回主内存, 不能直接操作主内存中的变量,各个线程中的工作内存存储着主内存中的变量副本拷贝, 因此不同线程间无法访问对方的工作内存, 线程间的通信(传值)必须通过主内存来完成,
其简要访问过程如下图:
【面試(自己看)】一.JUC多线程及高并发(1)之 volatile关键字与CAS_第1张图片
代码示例:证明volatile可见性

不用volatile修饰的情况

class MyData
{
    int number = 0;

    public void addTo60() {
        this.number = 60;
    }
}

/**
 * 1. 验证volatile 的可见性
 * 1.1 假如 int number = 0; number变量之前根本没有添加volatile关键字修饰,没有可见性
 */
public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"\t come in");
            try {
                //暂停一会线程
                TimeUnit.SECONDS.sleep(3);}catch (InterruptedException e){e.printStackTrace();}
                //将number值修改为60
                myData.addTo60();
                System.out.println(Thread.currentThread().getName()+"\t updated number value: " + myData.number);
        },"AAA").start();

        while (myData.number == 0){
            //main线程就一直等待,直到number值不等于零
        }
        System.out.println(Thread.currentThread().getName()+"\t mission is over");
    }
}

以上示例代码,在number如果没有关键字volatile修饰的时候,程序会一直假死在while里面, 因为虽然线程 AAA 对变量number进行了修改,并且将新值刷新到了主内存之中,但是 该修改对其它线程是不可见的,因此主线程在将MyData初始化完成之后,变量number的值就是0,而不是在线程AAA中修改的 60

使用volatile修饰的情况

class MyData
{
   volatile int number = 0;

    public void addTo60() {
        this.number = 60;
    }
}

/**
 * 1. 验证volatile 的可见性
 * 1.1 假如 int number = 0; number变量之前根本没有添加volatile关键字修饰,没有可见性
 */
public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"\t come in");
            try {
                //暂停一会线程
                TimeUnit.SECONDS.sleep(3);}catch (InterruptedException e){e.printStackTrace();}
                //将number值修改为60
                myData.addTo60();
                System.out.println(Thread.currentThread().getName()+"\t updated number value: " + myData.number);
        },"AAA").start();

        while (myData.number == 0){
            //main线程就一直等待,直到number值不等于零
        }
        System.out.println(Thread.currentThread().getName()+"\t mission is over,main get number value:" + myData.getNumber());
    }
}

执行结果

AAA come in
AAA updated number value: 60
main mission is over,main get number value:60

volatile关键字它具有的一个特性就是,保证可见性,因此变量number在线程AAA中的修改,并且刷新回主内存的操作,保证其它线程的可见;


volatile不能保证原子性

原子性指的是什么意思?
指 不可分割,完整性, 即某个线程在做某个业务时,中间不可被加塞或者被分割. 需要整体完整,要么同时成功,要么同时失败;

示例验证volatile不能保证原子性
假设20个线程,对同一个被volatile修饰的常量int number = 0执行++操作,每个线程执行1000次, 如果volatile能够保证++运算的原子性,那么最终结果应该是 20000

示例代码

class MyData
{
   volatile int number = 0;
    //此时的number已经被volatile关键字修饰
    public void addPlusPlus() {
        number ++;
    }
}

public class VolatileDemo {
    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 myData number value:"+myData.number);
    }
}

最终执行结果:

main finally myData number value:18468
多执行几次,18607,18533,19542
总之到20000的可能性是有,但是 这也正说明了volatile不能够保证原子性,(关键字syncronized可以保证原子性)


为什么volatile不能保证原子性?
【面試(自己看)】一.JUC多线程及高并发(1)之 volatile关键字与CAS_第2张图片
如图

++运算在JVM其实是被拆分成了三个指令,也就是说它本身在JVM中并不是原子性的操作. 那么这个时候在多线程的情况下线程在从主内存取值,以及各自线程内存中运算完成,写回主内存的时候就会出现加塞啊, 最终值的覆写等情况的发生.因此,虽然volatile 能够保证可见性,但是无法保证原子性

如何解决原子性?

  • syncronized

但是,syncronized不被建议使用,因为太重了

  • 使用JUC包下的Atomic

AtomicInteger类举例, 以下代码

class MyData
{
    AtomicInteger atomicInteger = new AtomicInteger(); 
    public void addAtomicOne() {
        atomicInteger.getAndDecrement();
    }
}

public class VolatileDemo {
    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.addAtomicOne();
                }
            },String.valueOf(i)).start();
        }
        //主线程+其它正在运行的线程
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t finally myData number value:"+myData.number);
    }

    //volatile 可以保证可见性,及时通知其它线程,主物理内存的值已经被修改
    public static void seeOkByVolatile() {
        MyData myData = new MyData();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"\t come in");
            try {
                //暂停一会线程
                TimeUnit.SECONDS.sleep(3);}catch (InterruptedException e){e.printStackTrace();}
            //将number值修改为60
            myData.addTo60();
            System.out.println(Thread.currentThread().getName()+"\t updated number value: " + myData.atomicInteger);
        },"AAA").start();

        while (myData.number == 0){
            //main线程就一直等待,直到number值不等于零
        }
        System.out.println(Thread.currentThread().getName()+"\t mission is over");
    }
}

执行结果:

main finally myData number value:-20000

volatile禁止指令重排

何为指令重排?

计算机在执行程序时,为了提高性能,编译器和处理器都常常会对指令做重排,一般分为以下三种:
在这里插入图片描述
单线程环境里面 可以确保 程序最终执行结果和代码顺序执行的结果一致;不关心多线程之间的语义一致性;

处理器在进行重排序时必须要考虑指令之间的数据依赖性

多线程环境中线程交替执行, 由于编译器优化重排的存在, 两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测;

volatile禁止指令重排小结:
volatile实现了禁止指令重排优化, 从而避免多线程环境下程序出现乱序执行的现象;

我们可以先了解一个概念:
内存屏障(Memory Barrier) 又称内存栅栏, 是一个 CPU指令, 它的作用有两个:

  • 一是保证特定操作的执行顺序;
  • 二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排优化. 如果在指令间插入一条 Memory Barrier则会告诉编译器CPU,不管什么指令都不能和这条Memory Barrier指令重排序, 也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化. 内存屏障另外一个作用是强制刷出各种CPU的缓存数据, 因此任何CPU上的线程都能读取到这些数据的最新版本;
【面試(自己看)】一.JUC多线程及高并发(1)之 volatile关键字与CAS_第3张图片
volatile总结

工作内存与主内存同步延迟现象导致的可见性问题,可以使用syncronizedvolatile关键字解决, 它们都可以使一个线程修改后的变量立即对其他线程可见.

对于指令重排导致的可见性问题和有序性问题, 可使用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化


问题延伸

为何AtomicInteger就能够保证原子性呢?(延伸出来的重点)

你在哪些地方用到过volatile?

单例模式DCL代码(Double Check Lock 双端检锁机制)

/*
	手写一个线程安全的单例模式
*/
public class SingleInstance {

    private static volatile SingleInstance instance = null;

    private SingleInstance() {}

    public static SingleInstance getInstance() {
        //DCL(Double Check Lock)
        if (instance == null){
            synchronized (SingleInstance.class) {
                if (instance == null){
                    instance = new SingleInstance();
                }
            }
        }
        return instance;
    }
}

读写锁手写缓存的时候也会用到

CAS底层源码中,JUC包中大规模使用

2- CAS

CAS是什么?

Compare And Swap, 它是一条CPU并发原语
它的功能时判断内存某个位置的值是否为预期值, 如果是则更改为新值, 这个过程是原子的;

CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法. 调用Unsafe类中的CAS方法, JVM会帮我们实现出 CAS汇编指令. 这是一种完全依赖于 硬件 的功能, 通过它实现了原子操作.
由于CAS是一种系统原语,原语属于操作系统用于范畴, 是若干条指令组成的, 用于完成某个功能的一个过程, 并且 原语的执行必须是连续的, 在执行过程中不允许被中断, 也就是说 CAS是一条 CPU 的原子指令, 不会造成所谓的数据不一致问题

简单示例代码:

public class CASDemo {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(5);

        //main do thing...

        System.out.println(atomicInteger.compareAndSet(5, 1024)+"\t current data:"+atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(5, 10)+"\t current data:"+atomicInteger.get());
    }
}

打印结果

true current data:1024
false current data:1024
因为第一个println中已经做了比较交换,而且交换成功,atomicInteger交换之后的值为1024. 因此第二个println中,期望值为5的话,就会失败了.因为主内存中的atomicInteger值已经变成了1024;


atomicInteger.getAndIncrement()为什么就能够保证操作的原子性
如果你知道,谈谈对Unsafe的理解

1 Unsafe

CAS的核心类, 由于 Java 方法无法直接访问底层系统, 需要通过本地(native) 方法来访问. Unsafe相当于一个后门, 基于该类可以直接操作特定内存的数据. Unsafe类存在于sun.misc包中, 其内部方法操作可以像C的指针一样直接操作内存, 因为JavaCAS操作的执行依赖于Unsafe类的方法
注意:Unsafe类中的所有方法都是native修饰的, 也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务

2 变量valueOffset

表示该变量之爱在内存中的偏移地址, 因为Unsafe就是根据内存偏移地址获取数据的;

3 变量 value

3 变量 valuevolatile修饰,保证了多线程之间的内存可见性;


问题延伸
讲一讲AtomicInteger,为什么要用CAS而不是syncronized?

底层原理: 自旋锁, Unsafe

再次强调Unsafe

CAS的核心类, 由于 Java 方法无法直接访问底层系统, 需要通过本地(native) 方法来访问. Unsafe相当于一个后门, 基于该类可以直接操作特定内存的数据. Unsafe类存在于sun.misc包中, 其内部方法操作可以像C的指针一样直接操作内存, 因为JavaCAS操作的执行依赖于Unsafe类的方法
注意:Unsafe类中的所有方法都是native修饰的, 也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务

【面試(自己看)】一.JUC多线程及高并发(1)之 volatile关键字与CAS_第4张图片

CAS的缺点

  1. 循环时间长,给CPU 带来很大的开销

    我们看一下Unsafe类的getAndAddInt()方法,其中有do{}while()操作
    【面試(自己看)】一.JUC多线程及高并发(1)之 volatile关键字与CAS_第5张图片
    如果CAS失败, 会一直进行尝试. 如果CAS长时间不成功, 可能会给CPU带来很大的开销;

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

    当对一个共享变量执行操作时, 我们可以使用循环CAS的方式来保证原子操作,但是 对多个共享变量操作时, 循环CAS就无法保证操作的原子性, 这个时候就可以用锁来保证原子性;

  3. 引来ABA问题

ABA问题
定义

CAS 会导致"ABA问题."
CAS算法实现一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换. 那么在这个时间差类会导致数据的变化.
比如说一个线程1 从内存位置 V 处取出A, 这时候另一个线程2 也从内存中取出A, 并且线程2 进行了一些操作将值变成了B, 然后线程2 又将V位置的数据变成A, 这时候线程1 进行 CAS操作发现内存中任然是A, 然后线程1 操作成功.
尽管线程1 的CAS操作成功了, 但是不代表这个过程就是没有问题的. 当然如果有些业务只关心头尾,即对ABA问题不关心,那没所谓

如何解决
使用带时间戳的原子引用AtomicStampedReference
看一个小Demo,了解下ABA操作具体是如何通过AtomicStampedReference解决的

/**
 * @author:xukai
 * @date:2020/3/17,16:58
   假设两个线程,线程t1模拟ABA问题,
   在线程t1完成ABA操作之后,线程t2通过和线程t1几乎同时拿到的
   最初的版本号stmap去做自己的CAS操作
   因为ABA问题,如果线程t2单单只是compare 要修改的值的话,是没有问题的
   但是t1线程已经对值进行过了修改,而此时假设我们的业务要求我们
   不能够出现ABA问题,这个时候我们使用的是带有时间戳(版本号)的原子引用类
   AtomicStampedReference,
   两个线程几乎同时获取到最初的版本号都是1
   此时我们用线程t1模拟的ABA操作,致使时间戳从1变成了3
   而线程t2在比对的时候,由于线程t1做了ABA操作,原子引用类的版本也发生了改变
   但是线程t2并不知道,在做CAS操作的时候使用的时间戳还是最初和t1几乎同时获得的
   初始时间戳1, 此时由于时间戳与预期的不同,CAS操作失败以下示例代码
 */
public class CASDemo {
    //参数 initialRef 初始值, initialStamp 初始版本号
    static AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100,1);
    public static void main(String[] args) {
        //线程t1完成一次ABA问题
        new Thread(() -> {
                int t1Stamp = stampedReference.getStamp();
                System.out.println(Thread.currentThread().getName()+"\t 线程t1获取到的最初始版本号:"+t1Stamp);
                stampedReference.compareAndSet(100,101,stampedReference.getStamp(),stampedReference.getStamp()+1);
                stampedReference.compareAndSet(101,100,stampedReference.getStamp(),stampedReference.getStamp()+1);
        },"t1").start();

        new Thread(() -> {
                int t2Stamp = stampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t 线程t2获取到的最初始版本号:"+t2Stamp);
            try {
                //线程t2休眠3秒 保证线程t1能够完成ABA的操作
                TimeUnit.SECONDS.sleep(3);
                //取出stampedReference的reference看看是不是100,如果是说明ABA成功
                System.out.println(Thread.currentThread().getName()+"\t 当前对象的值:"+ stampedReference.getReference());
                //然后再次进行CAS操作看看能否成功
                boolean result = stampedReference.compareAndSet(100,2019,
                        t2Stamp,t2Stamp+1);
                System.out.println(Thread.currentThread().getName()+"\t 线程t2CAS结局:"+result);
                System.out.println(Thread.currentThread().getName()+"\t 线程t2在线程1执行完ABA操作之后的真正版本号"+stampedReference.getStamp());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"t2").start();
    }
}

》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》

CAS问题汇总

什么是CAS,其原理是什么,应用场景有哪些?

AtomicInteger类的为何使用CAS而不是syncronized
syncronized的话, 最明显的缺点就是并发会下降

CAS的缺点是什么?

ABA问题又是什么?什么是原子更新引用?如何规避ABA问题呢?

syncronized的话, 最明显的缺点就是并发会下降

你可能感兴趣的:(笔记,并发编程)