volatile详解

570 阅读8分钟

引言:更多相关请看 JAVA并发编程系列

Volatile是什么

volatile是JVM提供的轻量级同步机制。三大特性如下: 1、保证可见性(当一个线程修改值其它线程能第一时间发现) 2、不保证原子性 3、禁止指令重排

JMM

核心思想:线程安全性获得保证。 JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式.。 JMM同步规定: 1.线程解锁前,必须把共享变量的值刷新回主内存。 2.线程加锁前,必须读取主内存的最新值到自己的工作内存。 3.加锁解锁是同一把锁。

阐述

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

可见性

通过前面对JMM的介绍,我们知道。在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的. 这就可能存在一个线程AAA修改了共享变量X的值还未写回主内存中时 ,另外一个线程BBB又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题。 代码演示

// 验证volatile可见性Demo
class VolatileDemo {

    public static void main(String[] args) {
        Data data = new Data();
        // 线程1
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "进入!");
            // 模拟num更改操作耗时3m,并保证其他线程读取了num变量
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改资源类的num值
            data.add();
            System.out.println(Thread.currentThread().getName() + "修改了值,值为" + data.num);
        }, "线程A").start();

        // 线程2 主线程
        while (data.num != 60) {
            // 如果值为零,线程就在此等待,直到num不为0为止
        }
        // 打印主线程获取到的值
        System.out.println(Thread.currentThread().getName() + "的值为" + data.num);
    }
}

// 资源类
class Data {
    int num;

    public Data() {
        System.out.println("资源类被初始化!");
    }

    // 加60
    public void add() {
        this.num += 60;
    }
}

效果:

程序一直在while循环,无法打印主线程的值。 如果对资源类的num加volatile修饰

再运行看效果:

A线程对值的修改马上对main线程尔可见。

原子性

即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 num++在多线程下是非线程安全的,num++被拆分为三个指令:
1.执行getfield拿到元素num;
2.执行iadd进行加1操作;
3.执行putfield把累加的值写回;
volatile不保证原子性代码演示

// volatile不保证原子性代码演示
class VolatileDemo{
    volatile int num;// 保证可见性

    public static void main(String[] args){
        VolatileDemo volatileDemo = new VolatileDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 1; j < 2000; j++) {
                    volatileDemo.num++;
                }
            }, String.valueOf(i)).start();
        }

        // 如果线程数量大于2(基础讲过,主线程和GC回收线程)
        while (Thread.activeCount() > 2){
            Thread.yield();// 启动线程,让线程变成就绪状态等待 CPU 调度后执行
        }
        // 执行结果几乎小于20000,如果保证原子性,结果应该为20000
        System.out.println(Thread.currentThread().getName() + " value:"+volatileDemo.num);
    }
}

出现值等于20000的原因:
比如线程A和B得到值为1998并进行加1、值都变成1999时。如果线程A把值写回,既主内存的值变成1999且还没来得及通知其它线程的时候,线程B也把1999的值写回,出写覆盖操作。
注意:主内存的值改变到通知其它线程之间是有一定的时间间隔的。原子性【执行的过程如果被任何打断就会全部不执行】,不保证原子性就是即使有可能影响也不会被打断,继续执行。

有序性

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

重排1

public void mySort(){
    int x=11;//语句1
    int y=12;//语句2
    x=x+5;//语句3
    y=x*x;//语句4
}
// 语句执行顺序
1234
2134
1324

问题: 请问语句4 可以重排后变成第一条码?
答:存在数据的依赖性 没办法排到第一个

重排2

初始化变量:int a ,b ,x,y=0;
线程1 线程2
x=a; y=b;
b=1; a=2;
最终值:x=0;y=0;
如果编译器对这段代码进行执行重排优化后,可能出现下列情况:
初始化变量:int a ,b ,x,y=0;
线程1 线程2
b=1; a=2;
x=a; y=b;
最终值:x=2;y=1;
这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程使用的变量能否保持一致是无法确定的.

禁止指令重排小总结

Volatile实现禁止指令重排优化,从而避免多线程下程序出现乱序执行的现象。了解一个概念,内存屏障(Memory Barrier)又称为内存栅栏,是一个CPU指令,它的作用有两个:
一是保证特定操作的执行顺序
二是保证某些遍历的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此CPU上的线程都能读取到这些数据的最新版本。
1.对Volatile变量进入写操作,会有写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回到主内存。

2.对Volatile变量进行读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。

volatile版单例模式

代码:

package com.example.demo.test01;

public class SingletonDemo {

    private static volatile SingletonDemo instance=null;
    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t 构造方法");
    }

    /**
     * 双重检测机制
     * @return
     */
    public static SingletonDemo getInstance(){
        if(instance == null){
            synchronized (SingletonDemo.class){
                if (instance == null) {
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单例只有一个线程能创建对象,打印“构造方法”功能
        for (int i = 1; i <=10; i++) {
            new Thread(() ->{
                SingletonDemo.getInstance();
            },String.valueOf(i)).start();
        }
    }
}


分析:
DCL(双端检锁) 机制不一定线程安全,原因是有指令重排的存在,加入volatile可以禁止指令重排。原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
instance=new SingletonDemo(); 可以分为以下步骤(伪代码)
memory=allocate();//1。分配对象内存空间
instance(memory);//2。初始化对象
instance=memory;//3。设置instance的指向刚分配的内存地址,此时instance!=null
步骤2和步骤3不存在数据依赖关系。而且无论重排前还是重排后程序执行的结果在单线程中并没有改变,因此这种重排优化是允许的。
memory=allocate();//1。分配对象内存空间
instance=memory;//3。设置instance的指向刚分配的内存地址,此时instance!=null 但对象还没有初始化完。
instance(memory);//2。初始化对象
会出现对象还未初始化就把instance指向空的内存地址值,也就是对象为null。线程不安全。 但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题。