Java面试---volatile

一、volatile定义

volatile是Java虚拟机提供的轻量级的同步机制,其中具有的特征为:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

二、JMM

在验证volatile特征之前,需要先了解另外一个知识点—JMM

2.1. JMM的定义

JMM(Java Memory Model):Java内存模型,它是一种抽象的概念,并不是真实存在的,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。其具有一下特征:

  • 可见性

  • 原子性

  • 有序性

    由于JMM保证了三个特性,而volatile只能保证两个,所以volatile是低配版的同步机制。

2.2. JMM关于同步的规定

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

我们知道JVM中运行程序的实体是线程,每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而JMM(Java内存模型)中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回内存,不能直接操作内存中的变量,各个线程中的工作内存中存储着内存中的变量副本拷贝,因此不同的线程之间无法访问对方的工作内存,线程间的通信必须通过主内存来完成,访问过程如下图:

线程A
本地内存A
主内存
线程B
本地内存B

三、验证volatile的可见性

我们先来看看当没有加volatile时的代码:

@Slf4j
public class VisibilityVolatile {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(()->{
            log.info(Thread.currentThread().getName() + "\t come in");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.add();
        },"AAA").start();
        while (myData.number == 0 ){
            //main线程就一直在这里循环,直到number不再等于0
        }
    }
}

class MyData{
    int number = 0;
    public void add(){
        this.number = 60;
    }
}
/**
 * 运行结果:
 * [AAA] INFO com.glw.myVolatile.atomicity.AtomicityVolatile - AAA	 come in
 */

在此我们得到以上结果,并且一直处于循环未结束状态。那么为什么会有以上的结果???

当我们启动一个线程AAA,线程AAA将数据拷贝到自己的工作内存,在其本地内存去修改myData.number的值后,再写回主内存,但是并没有通知主线程,以至于主线程并不知道内存中的值已经被修改,一直处于循环状态…

那么我们再来看看加上volatile后的运行结果

@Slf4j
public class VisibilityVolatile {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(()->{
            log.info(Thread.currentThread().getName() + "\t come in");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.add();
        },"AAA").start();
        while (myData.number == 0 ){
            //main线程就一直在这里循环,直到number不再等于0
        }
        log.info("main线程结束...");
    }
}

class MyData{
    volatile int number = 0;
    public void add(){
        this.number = 60;
    }
}

/**
 * 运行结果:
 * [AAA] INFO com.glw.myVolatile.atomicity.AtomicityVolatile - AAA	 come in
 * [main] INFO com.glw.myVolatile.atomicity.AtomicityVolatile - main线程结束...
 */

由此我们能证明volatile可以保证当数据修改后可以通知其他线程,即—可见性


四、验证volatile的不保证原子性

4.1 不保证原子性的案例演示:

@Slf4j
public class AtomicityVolatile {
    public static void main(String[] args) {
        MyData2 myData2 = new MyData2();
        for(int i = 1; i <= 20; i++){
            new Thread(()->{
                for(int j = 1; j <= 1000; j++){
                    myData2.addPlus();
                }
            },String.valueOf(i)).start();
        }
        //线程默认有两个线程,分别为:main线程和GC线程,等以上20个线程计算完成
        while(Thread.activeCount()>2){
            Thread.yield();
        }
        log.info(Thread.currentThread().getName()+"\t finally number value:" + myData2.number);
    }
}

class MyData2{
    volatile int number = 0;
    public void add(){
        this.number = 60;
    }
    public void addPlus(){
        number++;
    }
}
/**
 * 运行结果:
 * [main] INFO com.glw.myVolatile.atomicity.AtomicityVolatile - main	 finally number value:19500
 * 每次运行结果都会不同
 */

此时,我们创建了20个线程来计算number的值,如果可以保证原子性的话,最后的结果应该为20000,但是我们看到上面的结果为19500,计算结果是不对的。那么为什么结果是这个样子呢???

因为number++在多线程下是非线程安全的,那么为什么number++在多线程下是非线程安全的呢,首先需要了解一下number++底层所做的工作,我们以简单的Test为例子:

public class Test {
    volatile int n = 0;

    public void add() {
        n++;
    }
}

/**
 * public void add();
 *     Code:
 *        0: aload_0
 *        1: dup
 *        2: getfield      #2                  // Field n:I
 *        5: iconst_1
 *        6: iadd
 *        7: putfield      #2                  // Field n:I
 *       10: return
 */

各阶段的解释为:

  • aload_0:从局部变量0中装载引用类型值;

  • dup:复制栈顶部一个字长的内容;

    在此处,number++被分为了3个指令:

    • 执行getfield拿到原始number;
    • 执行iadd进行加1操作;
    • 执行putfield写把累加后的值写回

    因为很多值在执行putfield这一步操作写回去的时候,可能线程的调度被挂起了,刚好没有收到最新值的通知,有近乎纳秒级别的时间差,一写就出现了写覆盖,就把别人的值覆盖掉了,就出现了丢失写值的情况。

解决原子性问题我们知道可以利用加synchronized解决,但是对于简单的number++操作来说,使用synchronized就好比杀鸡用牛刀,小材大用,那么如何不加synchronized解决???

答案是:使用JUC下的AtomicInteger。下面我们来看代码:

@Slf4j
public class AtomicityVolatile {
    public static void main(String[] args) {
        MyData2 myData2 = new MyData2();
        for(int i = 1; i <= 20; i++){
            new Thread(()->{
                for(int j = 1; j <= 1000; j++){
                    myData2.addPlus();
                    myData2.addMyAtomic();
                }
            },String.valueOf(i)).start();
        }
        //线程默认有两个线程,分别为:main线程和GC线程,等以上20个线程计算完成
        while(Thread.activeCount()>2){
            Thread.yield();
        }
        log.info(Thread.currentThread().getName()+"\t int type, finally number value:" + myData2.number);
        log.info(Thread.currentThread().getName()+"\t AtomicInteger type, finally number value:" + myData2.atomicInteger);
    }
}

class MyData2{
    volatile int number = 0;
    public void add(){
        this.number = 60;
    }
    public void addPlus(){
        number++;
    }

    AtomicInteger atomicInteger = new AtomicInteger();
    public void addMyAtomic(){
        atomicInteger.getAndIncrement();  //调用一次就会+1
    }
}
/**
 * [main] INFO com.glw.myVolatile.atomicity.AtomicityVolatile - main	 int type, finally number value:19428
 * [main] INFO com.glw.myVolatile.atomicity.AtomicityVolatile - main	 AtomicInteger type, finally number value:20000
 */

从以上结果我们可以看出使用AtomicInteger是可以达到预期的效果的,即是可以解决原子性问题。其主要内部原因为CAS自旋锁的原因,后面我们会讲到什么是自旋锁,以及AtomicInteger为什么使用的是CAS而不是synchronized。


五、禁止指令重排

5.1 JMM的有序性

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分一下3种:

源代码
编译器优化的重排
指令并行的重排
内存系统的重排
最终执行的指令

5.2 指令重排会出现的情况:

  • 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致;
  • 处理器在进行重排时必须要考虑指令之间的数据依赖性
  • 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

我们具体来看一下优化重排会出现的结果,我们以代码为例:

public class ResortSeq {
    int a = 0;
    boolean flag = false;
    public void method01(){
        a = 1;
        flag = false;
    }
    
    public void method02(){
        if(flag){
            a = a+5;
        }
    }
}

以上的代码会因为指令重排的原因,可能会先执行flag=true,后执行a=1;这时还未来得及执行a=1,导致method02执行的结果错误,对于这种,我们就应该禁止指令重排。这时,volatile就实现了禁止指令重排的优化,从而避免多线程环境下程序出现乱序执行的情况。

5.3 内存屏障

这时我们就需要出现内存屏障这个词,内存屏障又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 保证特定操作的执行顺序

  2. 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

    由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier则会告诉编译器通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

    下面我们来看一下大概的结构:

    左半部分为对volatile变量进行写操作时,会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回到主内存;

    右半部分为对volatile变量进行读操作时,会在读操作前加入一条Load屏障指令,从主内存中读取共享变量。

    普通读
    普通写
    StoreStore屏障
    禁止上面的普通写和下面的colatile写重排序
    volatile写
    StoreLoad屏障
    防止上面的volatile写和下面可能有的volatile读/写重排序
    普通读
    LoadLoad屏障
    禁止下面所有普通读操作和上面的volatile读重排序
    LoadStore屏障
    普通读
    普通写
    禁止下面所有的写操作和上面的volatile读重排序

那么线程安全性获得保证的做法:

  • 工作内存与主内存同步延迟现象导致的可见性问题,可以使用volatile或synchronized关键字解决,他们都可以使一个线程修改后的变量立即对其他线程可见;
  • 对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另一个作用就是禁止重排序优化。

5.4 volatile的使用情况

单例模式下的DCL模式:

@Slf4j
public class SingletonVolatile {

    private static SingletonVolatile instance = null;

    private SingletonVolatile(){
        log.info(Thread.currentThread().getName() + "我是单例构造方法");
    }

    public static SingletonVolatile getInstance(){

        if(instance == null){

            instance = new SingletonVolatile();
        }
        return instance;
    }

    public static void main(String[] args) {
        for(int i = 1; i <= 10; i++){
            new Thread(() -> {
                SingletonVolatile.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

/**
 * [4] INFO com.glw.myVolatile.SingletonVolatile - 4我是单例构造方法
 * [3] INFO com.glw.myVolatile.SingletonVolatile - 3我是单例构造方法
 * [5] INFO com.glw.myVolatile.SingletonVolatile - 5我是单例构造方法
 * [6] INFO com.glw.myVolatile.SingletonVolatile - 6我是单例构造方法
 * [9] INFO com.glw.myVolatile.SingletonVolatile - 9我是单例构造方法
 * [7] INFO com.glw.myVolatile.SingletonVolatile - 7我是单例构造方法
 * [10] INFO com.glw.myVolatile.SingletonVolatile - 10我是单例构造方法
 * [2] INFO com.glw.myVolatile.SingletonVolatile - 2我是单例构造方法
 * [1] INFO com.glw.myVolatile.SingletonVolatile - 1我是单例构造方法
 * [8] INFO com.glw.myVolatile.SingletonVolatile - 8我是单例构造方法 
 */

从上面看出,当我们不使用关键字时时,单例模式的构造方法被调用了10次,这是违反单例模式的规则的。。。

使用synchronized时:

@Slf4j
public class SingletonVolatile {

    private static SingletonVolatile instance = null;

    private SingletonVolatile(){
        log.info(Thread.currentThread().getName() + "我是单例构造方法");
    }

    //DCL (Double Check Lock双端检锁机制)  也就是在进来之前和进来之后判断两次
    public static SingletonVolatile getInstance(){
        synchronized (SingletonVolatile.class) {
            if (instance == null) {
                instance = new SingletonVolatile();
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for(int i = 1; i <= 10; i++){
            new Thread(() -> {
                SingletonVolatile.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

/**
 *[1] INFO com.glw.myVolatile.SingletonVolatile - 1我是单例构造方法
 */

当加入synchronized时,从上面的结果看是可以达到效果的,但是其正确性不能百分之百保证,原因是因为底层有指令重排,,,在于某个线程执行到第一次检测,读到的instance不为null时,instance的引用对象可能没有完成初始化。指令重排只会保证串行予以的执行的一致性(单线程),但并不会关心多线程间的语义一致性

所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全的问题,所以加入volatile的原因就是为了禁止底层的指令重排,从而保证线程的安全。

所以上面的代码需要修改为:

private static volatile SingletonVolatile instance = null;

六、CAS

6.1 CAS(CompareAndSet):比较并交换

CAS,它是一条CPU并发原语。

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

以一个实例为例:

public class CASDemo {

    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(5);
        System.out.println(atomicInteger.compareAndSet(5, 2019) + "\t" + atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(5, 2023) + "\t" + atomicInteger.get());
    }
}
/**
 * true		2019
 * false	2019
 */

简单解释为如果拿到的值与期望值一致,就修改值。即当拿到的值如果为5,就修改为2019。

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

6.2 AtomicInteger为什么使用的是CAS而不是synchronized

使用synchronized的话,是多个线程的抢用一份资源,只允许一个线程运行,虽然一致性保证了,但是会导致并发量下降,而CAS底层原理是Unsafe,并且不加锁,保证一致性,允许多个线程同时操作,并发量得到保障,但是循环比较。

6.3 CAS底层原理

 private static final Unsafe unsafe = Unsafe.getUnsafe();
 public final int getAndIncrement() {
     	//                               在内存中的偏移地址
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

什么是Unsafe类?

Unsafe:是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据,Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法。

Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都是直接调用操作系统底层资源执行相应任务。

6.4 CAS缺点

  • 循环时间长开销很大 (执行时间的do…while,导致自旋)
  • 只能保证一个共享变量的原子操作
  • 引出来的ABA问题

七、ABA问题

7.1 ABA原理

ABA问题就是所说的狸猫换太子。比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且线程two进行了一写操作将值变成了B,然后线程two又将位置V的数据变成A,这时线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功。

7.2 ABA问题的解决

7.2.1 AtomicReference原子引用
@Slf4j
public class AtomicReferenceDemo {
    public static void main(String[] args) {
        User zs = new User("zs",18);
        User li = new User("li",22);
        AtomicReference<User> atomicReference = new AtomicReference<>();
        atomicReference.set(zs);
        log.info(atomicReference.compareAndSet(zs,li) + "\t" + atomicReference.get().toString());
    }

}

@Data
@AllArgsConstructor
class User{
    String userName;
    Integer age;
}

/**
 * [main] INFO com.glw.myVolatile.aba.AtomicReferenceDemo - true	User(userName=li, age=22)
 */
7.2.2 ABA问题的解决(理解原子引用+新增一种机制,就是修改版本号(类似时间戳))
@Slf4j
public class ABADemo {
    static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
    static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {
        //理解原子引用+新增一种机制,就是修改版本号(类似时间戳)
        new Thread(() -> {
            atomicReference.compareAndSet(100, 101);
            atomicReference.compareAndSet(101, 100);
        }, "t1").start();
        new Thread(() -> {
            //暂停1秒钟t2线程,保证上面的t1线程完成了一次ABA操作
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.info(atomicReference.compareAndSet(100, 2019) + "\t" + atomicReference.get());
        }, "t2").start();
        /**
         * [t2] INFO com.glw.myVolatile.aba.ABADemo - true	2019
         */
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //以下为ABA问题的解决
        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            log.info(Thread.currentThread().getName() + "\t第1次版本号:" + stamp);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            log.info(Thread.currentThread().getName() + "\t第2次版本号:" + atomicStampedReference.getStamp());
            atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
            log.info(Thread.currentThread().getName() + "\t第3次版本号:" + atomicStampedReference.getStamp());
        }, "t3").start();
        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            log.info(Thread.currentThread().getName() + "\t第1次版本号:" + stamp);
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean result = atomicStampedReference.compareAndSet(100, 2019, stamp, stamp + 1);
            log.info(Thread.currentThread().getName() + "\t修改成功否:" + result + "\t当前最新版本号:" + atomicStampedReference.getStamp());
            log.info("当前实际最新值:" + atomicStampedReference.getReference());
        }, "t4").start();
    }
}
/**
 * [t3] INFO com.glw.myVolatile.aba.ABADemo - t3	第1次版本号:1
 * [t4] INFO com.glw.myVolatile.aba.ABADemo - t4	第1次版本号:1
 * [t3] INFO com.glw.myVolatile.aba.ABADemo - t3	第2次版本号:2
 * [t3] INFO com.glw.myVolatile.aba.ABADemo - t3	第3次版本号:3
 * [t4] INFO com.glw.myVolatile.aba.ABADemo - t4	修改成功否:false	当前最新版本号:3
 * [t4] INFO com.glw.myVolatile.aba.ABADemo - 当前实际最新值:100
 */
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java中,`volatile`关键字用于修饰变量,表示该变量是易变的,即每次使用该变量时,都必须从内存中读取最新的值,而不是使用缓存值。它的主要作用是保证多线程之间对该变量的可见性和禁止指令重排。 下面是一些可能的面试题: 1. `volatile`关键字的作用是什么? 答:`volatile`关键字用于保证多线程之间对该变量的可见性和禁止指令重排。它可以强制线程从主内存中读取变量的值,而不是使用线程本地的缓存值,以确保多个线程之间的变量值是一致的。 2. `volatile`关键字与`synchronized`关键字有什么区别? 答:`volatile`关键字和`synchronized`关键字都可以用于多线程编程中,但它们的作用不同。`volatile`关键字用于保证变量的可见性和禁止指令重排,而`synchronized`关键字用于保证线程的安全性和同步性。在使用`synchronized`关键字时,同一时刻只能有一个线程进入临界区,而`volatile`关键字并没有这种限制。 3. 什么情况下应该使用`volatile`关键字? 答:`volatile`关键字适用于以下情况: - 变量被多个线程共享; - 变量的值在多个线程之间发生了变化; - 对变量的读操作不依赖于变量的当前值; - 对变量的写操作不会覆盖其它线程对变量的修改。 4. `volatile`关键字是否可以保证线程安全? 答:`volatile`关键字不能保证线程安全,它仅仅保证了变量在多个线程之间的可见性和禁止指令重排。要保证线程安全,还需要使用`synchronized`关键字或其它线程安全的机制。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值