【面试题】关于 volatile 关键字 学习总结

详细介绍关于volatile关键字的特性、原理、使用场景

Posted by Litany on November 3, 2019

volatile 是多并发环境中使用的关键字,是虚拟机提供的 轻量级同步机制低配版synchronized

volatile 是什么?

volatile 是 一个类型修饰符。可以让A线程知道被volatile 修饰的变量被B线程改变了。

    volatile int num ;
    volatile String str;
    volatile Person person;
    volatile Boolean aBoolean;

精确地说就是,编译器在用到这个变量时必须每次都小心地重新读取这个变量的值,确保读取到的是主内存中的最新值。

想要更好理解volatile 需要先了解一些线程执行的概念

前置概念:jmm(java memory model)Java内存模型

JMM与 JVM(Java Virtual Machine)(Java虚拟机)的关系

看起来似乎有相连的关系,其实并没有直接关系。

jmm是为了规定线程和内存之间的关系。(属于抽象的概念并不真实存在)

而jvm是java虚拟机内部的内存划分方案。(是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的)

jmm 是 一种规范,定义了Java程序运行中各个变量(实例字段、静态字段和构成数组对象的元素)的访问方式。

JMM - 线程同步的规定(规范)

1.线程解锁前,必须把共享变量的值刷新回主内存

2.线程加锁前,必须把共享变量的最新值读取到自己的工作内存

3.加锁与解锁,都是同一把锁。

JMM - 线程执行的流程

QsZKht.png

JVM运行程序的实体是线程,每创建一个线程 JVM都会为其创建一个工作内存(栈空间),工作内存属于每个线程的私有数据区域

JMM(Java内存模型)规定:所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问。

当某一线程需要对变量进行操作时,必须在私有工作内存中进行。具体如下:

1.将主内存中 需要操作的变量, 拷贝 到 该线程 私有的工作内存空间中

2.当线程在私有工作空间中对变量操作完成后,将该变量写回主内存

总之:

不允许线程直接操作内存中的变量,各个线程中的工作内存中存储着主内存中的变量拷贝副本。每个线程只能对自己私有工作内存空间中的变量进行操作,最终将变量写回主内存。程序运行中,每个线程都有独立的工作内存,无法访问其他线程的工作内存。因此,线程间的通信(传值)必须通过主内存才能完成。

java内存模型的必要性 为了规范内存数据和工作空间数据交互带来的不一致性问题 (并发环境中的 原子性 、可见性 、有序性)

volatile 特性

1.可见性

2.禁用指令重排序

3.不保证原子性

volatile 可见性

在JMM(java memory model)介绍中,了解到 每个线程对变量的操作,都是在自己工作内存中完成,最终写回主内存。

多线程环境下,某一线程 在工作内存中对共享变量进行修改操作,并将数据写回主内存中。而其他线程并不知道有线程修改该共享变量,他无法感知到其他线程对共享变量的操作,因此也不会主动从主内存中重新获取该变量的最新值。

多线程环境中,因为A线程并不知道B线程对共享变量进行修改,会造成B线程的工作内存与主内存同步延迟现象,导致可见性问题

而可见性就是:某一线程对变量修改完毕,写回主内存后,其他线程均能感知到该变量发生改变,并能将私有空间中的共享变量的值刷新为其他线程被修改过的共享变量的最新值

验证volatile 可见性

package cn.litany.study.thread;


import java.util.concurrent.TimeUnit;

class Student {
    //去掉volatile后,主线程会进入死循环,无限等待
    volatile int age = 0;

    public void addAgeTo18() {
        this.age = 18;
    }

}

/**
 * @author Litany
 * 验证volatile可见性
 */
public class VolatileDemo {
    public static void main(String[] args) {
        Student student = new Student();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + ":正在执行");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //线程沉睡3秒后改为18
            student.addAgeTo18();
            System.out.println(Thread.currentThread().getName() + ":修改完毕==>" + student.age);
        }, "张无忌").start();

        while (student.age==0){
            //主线程等待其他线程将age修改为18
        }

        System.out.println("student.age被修改成功");
    }
}

执行结果:

Qs32Lt.png

当没有volatile关键字时,main线程会一直等待循环(因为他无法感知到Student的Age发生变化)。

添加volatile后,main线程感知到age的变化,结束循环,并打印出“student.age被修改成功”。

volatile 禁止指令重排序

什么是指令重排序?

计算机在执行程序时,为提高性能编译器处理器 通常会对指令进行重新排序

(就好比考试时答题,未必会从头写到尾,而是优先会做的题)

编写的Java代码是.java结尾,需要被编译器编译为.class文件。再通过虚拟机加载.class文件,最终执行程序。

编译器进行编译处理器运行的过程中,不一定会按照.java文件中的代码逐行进行。会根据一些规则自动优化,对指令进行重新排序,再执行操作。

在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。

int a =1;//a 不依赖
int b =2;//b 不依赖
int c =a+b;//c依赖a,b
int d =c*c;//d 依赖于c

以上代码中,想要得到c的值,首先需要a和b的值;想要得到d的值,首先需要c的值。因此 指令重排序只会对a,b 无任何依赖的指令进行重排序。

也就是说:多线程下程序执行中 声明变量 b可能会在声明变量a之前。而声明变量d一定会在声明变量c之后,因为d依赖于c

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

什么时候要禁止指令重排序?

多线程环境中,线程交替执行,由于编译器优化指令重排序的存在,无法保证每个线程中使用的变量一致性,会导致结果无法预测。

package cn.litany.study.thread;

/**
 * volatile禁止指令重排序
 * @author Litany
 * */
public class Student {
    private static Student student = null;

    private Student() {
        //单例模式,创建对象时打印,证明只创建一个实例
        System.out.println(Thread.currentThread().getName()+":Student 的构造方法执行");
    }

    static Student getStudent() {
        //DCL (Douub Check Lock 双重检锁机制)
        if (student == null) {
            //当前student为null 进入同步代码块
            synchronized (Student.class) {
                //再次校验是否为null(多线程环境中,其他线程可能已经完成该对象的实例初始化)
                if (student == null) {
                    student = new Student();
                }
            }
        }
        return student;
    }

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

}

以上单列模式的代码是否有问题?

在上述代码中,成员变量并没有使用volatile关键字。程序运行多次,并没有发生任何报错,也没有出现构造多个对象的现象发生。那么问题在哪里呢?

答:多线程下,指令重排序会导致出现问题(NullPointerException)。多线程中,处理器执行机器指令如果进行重新排序,会导致出错。

student=new Student();
//在底层中的执行分三步:1.分配对象内存空间 2.对象实例的初始化 3.将student的指向分配的内存地址

memory = allocate();//1.申请并分配对象内存空间
student(memory);//2.初始化对象
student=memory;//3.指向内存地址,此时student!=null

由于以上三步没有数据依赖性 如果发生指令重排序 变为1.3.2的顺序先分配对象内存空间,在初始化未完成的情况下,该变量指向内存地址)就会发生错误。

因此在该单例模式中,成员对象(Student)应该加上volatile关键字。

volatile 不保证原子性

什么是原子性?

与事务中的原子性有些类似。这里具体指:线程执行过程中,从开始到结束是完整的,执行期间不会被加塞或者被分割。强调执行的结果只会同时成功,或同时失败,保证数据完整一致性。

验证volatile不保证原子性

package cn.litany.study.thread;

/**
 * 不保证原子性
 *
 * @author Litany
 */
class Person {
    volatile int age=0;

    public void growUpOneYearOld() {
        this.age++;
    }
}

public class VolatileDemo {


    public static void main(String[] args) {
        Person person = new Person();
        //有20个线程对其操作,期望每个线程对age+1,最终结果为20000
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                //期望最终结果为20*1000=20000
                for (int j = 0; j < 1000; j++) {
                    person.growUpOneYearOld();
                }
            },String.valueOf(i)).start();
        }

        //java有main线程、gc垃圾回收线程 两个默认线程。
        //只要Thread.activeCount()>20 说明当前线程的其他任务没结束
        while (Thread.activeCount()>2){
            //main线程将执行权放弃,由其他线程继续执行age++
            Thread.yield();
        }

        //打印最终结果
        System.out.println(person.age);
    }

}
执行结果:

每次的结果都无法达到预期值(20000),并且都会产生不同的结果。

原因分析:

多线程环境中,每个线程从主内存中将变量拷贝回线程的私有工作内存中,都对共享变量进行修改。有可能发生这种情况:

0.A线程,B线程将共享变量Age=0 拷贝 到线程私有的工作内存中。

1.A线程将工作内存中的变量从0修改为1

2.由于多线程竞争关系,A线程准备将工作内存中的1写入主内存时,A线程被挂起。

3.A线程挂起,B线程将变量从0修改为1,并成功写入主内存中。

4.A线程执行之前将变量的写入主内存的操作,此时主内存的共享变量age =1,由于线程执行极快,A线程重复写入了age=1到主内存中,出现写覆盖的情况。

结论:多线程环境中,由于线程竞争关系,某一线程的执行可能会被其他线程打断。且线程执行极快,会出现数据重复写回主内存,造成数据丢失现象

解决方案:

非加锁的解决方案:

通过java.util.concurrent.atomic包下的AtomicInteger解决问题。

代码如下:

/**
 * 解决多线程中原子性问题
 * @author Litany
 */
class Person {
    volatile int age = 0;
    AtomicInteger atomicInteger = new AtomicInteger();

    public void growUpOneYearOld() {
        atomicInteger.getAndIncrement();
        age = atomicInteger.get();
    }
}

Volatile 原理

volatile变量的内存可见性,是基于内存屏障(Memory Barrie)实现的。又称内存栅栏,是一个 CPU 指令。

  • 保障特定操作的执行顺序
  • 保障某些变量的内存可见性

原理:

  • 通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
  • 内存屏障的另一个作用是强制刷出各种cpu的缓存数据,因此任何cpu上的线程都能读取到这些数据的最新版本。

volatile变量进行写操作时:

会在volatile写操作之前加一条StoreStore屏障 禁止其之前的普通写操作与volatile写操作重排序。

volatile写操作完成后,会加入一条StroeLoad屏障,将工作内存中的共享变量刷新回主内存。

volatile变量进行读操作时:

volatile读操作之前加入LoadStore屏障禁止其之后的普通写操作与volatile读操作重排序。

会在volatile读操作之前,加一条LoadLoad屏障禁止其之后的普通读操作与volatile读操作重排序。同时,从主内存中读取最新共享变量。

内存屏障列表:

1.loadload:确保“前者数据装载”先于“后者装载指令”;

2.storestore:确保“前者数据”先于“后者数据”刷入系统内存,且,“前者刷入系统内存的数据”对“后者是可见的”;

3.loadstore:确保“前者装载数据”先于“后者刷新数据到系统内存”;

4.storeload:确保“前者刷入系统内存”的数据对“后者加载数据”是可见;

Qy6LND.png

总结: volatile 是一个关键字,主要解决多线程下的可见性问题和禁止指令重排序。多线程下的单例模式中推荐使用volatile 修饰成员变量,适用于一个线程写,多线程读的场景。

您只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。