java多线程中volatile关键字

一:计算机中的内存模型

计算机中指令都通过CPU去执行,执行执行的时候一般都会涉及到读写,我们都知道CUP的计算速度是很快的,如果都把数据放到我们的主存中则会造成CPU每执行一条指令都要等待的问题,这个时候高速缓存Cache应运而生。Cache就是把一些处理的中间数据缓存起来大大加快了指令的处理速度。
以上的模型针对于单核CPU是没有问题的,但是多核CPU的话就会产生数据不一致的情况。真实的计算机内存模型如下。
计算机内存模型
这个内存模型比我们想的多了个“缓存一致性协议或者总线锁机制”,这个东东就是解决我们上面说的缓存不一致的问题。
为了解决缓存一致性的问题,现代计算机系统需要各个处理器读写缓存时遵循一些协议(MSI、MESI、MOSI、Synapse、Firefly、DragonProtocal,这些都是缓存协议),按照协议来进行读写访问缓存。
其实,除了缓存之外,处理器还会对输入的代码程序在保证结果不变的情况下进行重排序,这就是著名的“指令重排序”,旨在提高运行效率。
例如:

int a = 0;
int b = 1;
int c = a + b;

这里a=0,b=1两句可以随便排序,不影响程序逻辑结果,但c=a+b这句必须在前两句的后面执行。

二:内存模型中三个概念

1. 原子性(Atomicity)

原子性指的是操作不可中断,不可分割的原子操作。Java内存模型直接用来保证原子性变量的操作包括use、read、load、assign、store、write,我们大致可以认为Java基本数据类型的访问都是原子性的,如果用户要操作一个更大的范围保证原子性,Java内存模型还提供了lock和unlock来满足这种需求,但是这两种操作没有直接开放给用户,而是提供了两个更高层次的字节码指令:monitorenter 和 moniterexit,这两个指令对应到Java代码中就是synchronized关键字,所以synchronized代码块之间的操作具有原子性。

2. 可见性(Visibility)

可见性指的一个变量的是共享的,一个线程修改,其他线程立刻可见。在Java中,除了volatile可以实现可见性之外,synchronized和final关键字也能实现可见性。synchronized同步块的可见性是因为对一个变量执行unlock操作之前,必须将变量的改动写回主内存来(store、write两个操作)实现的。而final字段则是因为一旦final字段初始化完成,其他线程就可以访问final字段的值,而且final字段初始化完成之后就不再可变。

3. 有序性(Ordering)

处理器会对指令或者程序进行重排序优化。这种优化在单线程处理中不会存在问题,但是多线程条件下可能会出问题。Java中提供了volatile和synchronized关键字来保证线程间操作的有序性。

三:Java内存模型

1. Java主内存和工作内存

Java内训模型中规定所有变量都存储在主内存中,对应就是Java的堆内存。每个线程都有自己独有的工作内存,工作内存中变量来自主内存副本拷贝,线程对变量的读写操作都必须在工作内存中机型,不能直接读写主存中的变量。工作内存也是相互独立的。交互图如下:
主内存和工作内存
由图可知如果两个线程同时操作同一个共享变量,则可能产生数据不一致的问题。

2. 内存交互操作

Java内存模型为主内存和工作内存间的变量拷贝及同步写回定义了具体的实现协议,该协议主要由8种操作来完成。

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把通过read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作使用。
  • write(写入):作用于主内存的变量,它把通过store操作从工作内存中得到的变量的值放入主内存的变量中。

线程、工作内存、主内存对应这8种操作的交互关系图如下:
线程-工作内存-主存
根据交互图,我们可以看出,read和load要顺序执行,如果把变量从工作内存同步回主存需要先执行store和write操作。除此之外,Java内存模型对这8中操作还存在着其他的约束:

  • 只允许read和load、store和write这两对操作成对出现。
  • 不允许线程丢弃它的最近的assign操作,即变量在工作内存中改变之后,必须同步回写到主内存。
  • 不允许线程把没有经过assign操作的变量,同步回写到主内存。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中使用未经初始化的变量,即对一个变量进行use、store操作之前,必须先执行过load、assign操作。
  • 一个变量在同一时刻只能被一条线程执行lock操作,一旦lock成功,可以被同一线程重复lock多次,多次执行lock之后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 对一个变量执行lock操作,将会清空工作内存中该变量的值,所以在执行引擎使用这个变量前,需要重新执行load或assign操作对其进行初始化。
  • 对一个变量执行unlock操作之前,必须先把该变量同步回主内存(执行store、write操作)。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程lock的变量。

四:volatile关键字

volatile满足两层含义:可见性、有序性。举例说明此关键字使用场景。

场景一:使用volatile修饰的变量做主线程和子线程之间的通信。

public class VolatileTest {
    //不使用volatile关键字,线程会一直死循环
//    private static Boolean stop = false;
    //使用volatile关键字
    private static volatile Boolean stop = false;

    public static void main(String args[]) throws InterruptedException {
        //新建立一个线程
        Thread workThread = new Thread() {
            @Override
            public void run() {
                getThreadLog("线程开始执行!");
                while (true) {
                    if (stop) {
                        break;
                    }
                }
                getThreadLog("线程执行结束了!");
            }
        };
        //启动该线程
        workThread.start();
        //休眠一会儿,让子线程飞一会儿
        Thread.sleep(1000);
        //主线程将stop置为true
        stop = true;
        //打印日志
        getThreadLog("主线程执行结束了");
        //使用join方法继续执行子线程
        workThread.join();
    }

    /**
     * 获取线程名和时间
     *
     * @return
     */
    public static void getThreadLog(String logContent) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("[");
        stringBuffer.append(Thread.currentThread().getName());
        stringBuffer.append(" ");
        stringBuffer.append(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date()));
        stringBuffer.append("]");
        stringBuffer.append(logContent);
        System.out.println(stringBuffer.toString());
    }

场景二:使用volatile修饰在单例模式中体现

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (uniqueInstance == null) {
            synchronized (Singleton.class){
                if(uniqueInstance == null){//进入区域后,再检查一次,如果仍是null,才创建实例
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

注意volatile不满足原子性,因此使用此关键字进行多线程修改共享变量会出问题。

五:内存屏障

为什么会有内存屏障?
  1. 每个CPU都会有自己的缓存(有的甚至有三级缓存),缓存的目的就是为了提高性能,避免每次都要向内存取,但是这样的弊端也是很明显:不能实时和内存发生信息交换,分在不同CPU执行的不同线程对同一变量的缓存值不同。
  2. volatile关键字修饰变量可以解决上述问题,volatile通过内存屏障来实现,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样的,java通过屏蔽这些差异,统一由jvm来生成内存屏障指令
作用
  1. 确保指令重排序时不会把屏障后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面。
  2. 强制把写缓冲区/高速缓存中的数据等写回主内存,让缓存中相应的数据失效;

Load Barrier 读屏障
在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据;

Store Barrier 写屏障
利用缓存一致性机制强制将对缓存的修改操作立即写入主存,让其他线程可见,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据。

内存屏障类型

为了保证可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
屏障类型
其中StoreLoad指令是现代多处理器都需要使用的,但是它的开销也很昂贵。

volatile插入屏障策略
  1. 在每个volatile写操作的前面插入StoreStore屏障;
  2. 在每个volatile写操作的后面插入StoreLoad屏障;
  3. 在每个volatile读操作的前面插入LoadLoad屏障;
  4. 在每个volatile读操作的后面插入LoadStore屏障;
实现原理

volatile变量 写汇编指令会多出#Lock前缀,Lock前缀在多核处理器下的作用:

  1. 将当前处理器缓存行的数据写会主存;
  2. 令其他CPU里缓存该内存地址的数据失效;(总线锁定?MESI缓存一致性协议)

参考:
JMM和底层实现原理(https://www.jianshu.com/p/8a58d8335270)

猜你喜欢

转载自blog.csdn.net/u011047968/article/details/106098670