volatile你以为你真的懂?

目录

volatile是个啥

保证线程的可见性

MESI和Volatile的关系

什么是MESI?

一个例子理解MESI

禁止指令重排序

DCL单例

指令重排序知多少

内存屏障

总结

扫描二维码关注公众号,回复: 10746710 查看本文章

【吊打面试官系列】多线程和高并发硬核技能  专栏往期回顾


volatile是个啥

这里我们看一下百度词条的解释:volatile是一个特征修饰符(type specifier).volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。

现在像大型互联网企业的面试,基本上volatile是必问的,当然了有时候不问是因为他觉得你应该会,现在volatile中小企业也开始会问这方面的问题了。volatile你想通吃各种企业的面试官,你只需要掌握以下这两大点,也就是Volatile的特性:

  • 保证线程的可见性
    • MESI
  • 禁止指令重排序
    • DCL单例
    • Double Check Lock
    • 内存屏障

保证线程的可见性

我们从一段代码,很形象的给你们展现出Volitale是怎么保证可见性的,话不多说,上代码:

public class HelloVolatile {
    //对比有无Volitale的区别
    /*volatile*/ boolean runing = true;
   
    void process(){
        System.out.println("process start running ...");
        while (runing){
        }
        System.out.println("process end ...");
    }

    public static void main(String[] args) {
        //主线程
        HelloVolatile A = new HelloVolatile();
        
        new Thread(A::process, "B").start();
        
        try{
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        A.runing = false;
    }
}

Volatile关键字,是一个变量在多个线程间可见,A\B线程都使用到一个变量,Java默认是A线程中保留一份Copy,这样如果线程B修改了该变量,则A线程未必知道。

我们来看上面的代码,不加Volatile结果如下:

程序一直在运行,死循环中,我们给running加上Volatile,在看运行结果:

程序不会陷入死循环,在主线程修改了running的值以后,程序结束运行,那么为什么会这样呢?

在上面的代码中,当线程B运行的时候,会把running值从内存中读到B线程的工作区,在运行的过程中直接使用这个工作区的Copy,并不会每次都去读取堆内存,这样,当线程A修改running的值之后,B线程感知不到,所以不会停止运行,使用Volatile,将会强制所有的线程都去堆内存中读取running的值。但是需要注意的一点,Volatile不是锁,并不能像Synchronized一样保证多个线程共同修改running变量的时候所带来的的不一致的问题,Volatile不能替代Synchronized。

那么Volatile是怎么保证可见性的呢?

MESI和Volatile的关系

什么是MESI?

在JVM的内存模型中,每个线程有自己的工作内存,实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象

现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成,具体每个cache,实际上是有很多缓存行组成:

缓存一致性协议给缓存行(通常为64字节)定义了个状态:独占(exclusive)、共享(share)、修改(modified)、失效(invalid),用来描述该缓存行是否被多处理器共享、是否修改。所以缓存一致性协议也称MESI协议。

  • 独占(exclusive):仅当前处理器拥有该缓存行,并且没有修改过,是最新的值。
  • 共享(share):有多个处理器拥有该缓存行,每个处理器都没有修改过缓存,是最新的值。
  • 修改(modified):仅当前处理器拥有该缓存行,并且缓存行被修改过了,一定时间内会写回主存,会写成功状态会变为S。
  • 失效(invalid):缓存行被其他处理器修改过,该值不是最新的值,需要读取主存上最新的值。

协议协作如下:

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
  • 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
  • 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。

另外MESI协议为了提高性能,引入了Store Buffe和Invalidate Queues,还是有可能会引起缓存不一致,还会再引入内存屏障来确保一致性。

一个例子理解MESI

i = i + 1;

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU不同的核中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?我们都知道并发情况下是不一定的。

  可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。

  最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

  也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

  为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK#锁的方式

  2)通过缓存一致性协议

  这2种方式都是硬件层面上提供的方式。

  在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

  但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

  所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

禁止指令重排序

DCL单例

我们来聊一聊什么是单例,单例的意思就是我保证你在JVM的内存里头永远只有某一个类的一个实例,其实这个很容易理解,在我们的工程当中有一些类真的没必要new很多个对象,比如说权限管理者。

单例最简单的写法就是下面的这种写法:

class HungrySingleton implements Serializable {

    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    private HungrySingleton() {
    }
    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }
 }

这是经典的饿汉式,类加载到内存后,被实例化一个单例,JVM保证线程安全。简单实用,推荐使用,唯一的缺点是,不管用到与否,类加载时就完成实例化。

有的人他会吹毛求疵,我还没开始用这个对象呢,没用这个对象调用这个方法,你干嘛把它初始化了,能不能用的时候再初始化,所以呢出现了下面这种写法:

/**
 * 懒汉模式(延迟加载,非线程安全)
 */
class LazySingleton {

     private static LazySingleton lazySingleton = null;

     private LazySingleton() {
  }

            public static LazySingleton getInstance() {
            if (lazySingleton == null) {
                  lazySingleton = new LazySingleton();
        }
           return lazySingleton;
    }
}

这是懒汉式写法,意识是说,我什么时候调用getInstance,什么时候才会初始化这个单例。

不过呢,更加吹毛求疵的事情又来了,我不单要求是在用的时候初始化,我还要求你线程安全。所以又出现了下面这种写法:

public class LazySingleton {

    private static LazySingleton lazySingleton = null;

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        synchronized (LazySingleton.class) {
            if (lazySingleton == null) {
                lazySingleton = new LazySingleton();
            }
        }
        return lazySingleton;
    }
}

没错,你也看出来了我们加了锁,这里要注意锁的粒度对性能的影响 ,synchronized关键字可以修饰方法,也可以在方法内部作为synchronized块。如果用synchronized修饰方法对于程序性能是有较大影响的,因为每次进入方法都会加锁。而在方法内部特定的逻辑使用synchronized块,灵活性较高,没有直接用synchronized修饰方法性能的损耗大。

但是要注意一点,为了减小锁的粒度,下面这种写法是错误的:

public class LazySingleton_unsafe {

    private static LazySingleton_unsafe lazySingleton = null;

    private LazySingleton_unsafe() {
    }

    public static LazySingleton_unsafe getInstance() {
        if(lazySingleton == null){
            synchronized (LazySingleton.class) {
                lazySingleton = new LazySingleton_unsafe();
            }
            return lazySingleton;
        }
        return lazySingleton;
    }
}

为什么呢?假设现在有两个线程,我们分析一下,第一个线程判断为空,还没执行下面的代码,第二个线程来了,也判断为空,第一个线程加锁,初始化单例以后把锁释放了,第二个线程是判断为空之后拿到的锁,所以也初始化了一遍,这样就出现了两个单例,所以是错误的。那这种情况如何避免呢?所以出现了Dubbo Check(双重校验加锁机制DCL)。代码如下。

/**
  * 双重校验加锁(延迟加载,线程安全)
  */
public class LazyDoubleCheckSingleton {

    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;

    private LazyDoubleCheckSingleton() {
    }

    public static LazyDoubleCheckSingleton getInstance() {
        if (lazyDoubleCheckSingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class){
                if(lazyDoubleCheckSingleton == null){
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}

这是延迟加载,线程安全的。在这种双重检查判断的情况下,上面说的线程安全问题就不会出现了,分析一下:在第二个线程拿到锁进入准备初始化的时候,再次判断是否已经实例化,如果第一个线程实例化成功了,那么第二个线程就会直接返回线程一初始化的单例。就不会出现初始化两次的情况了。所以说双重检查是线程安全的。就算你在高并发的环境下运行,拿多少机器getInstance,每个机器上跑一万个线程,使劲儿跑,这个程序运行的结果也是正确的。

但是真的是这样吗,你是不是在想说了这么多单例的事,跟Volatile啥关系呢?

这是一道面试题:你听说过单例模式吗,单例模式里有一种叫双重检查你了解吗,这个单例要不要加Volatile?

答案是要加的,我们测试很那出现让上面的代码出错的情况,所以很多人写代码不加这个Volatile也不会出现问题,但是不加Volatile问题会处在指令重排序上。

指令重排序知多少

再看上面双重检查的代码Demo,“看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题:当lazyDoubleCheckSingleton不为null时,仍可能指向一个"被部分初始化的对象"

问题出在这行简单的赋值语句:

lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();

它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:

memory = allocate();	//1:分配对象的内存空间
initInstance(memory);	//2:初始化对象
instance = memory;	//3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:

memory = allocate();	//1:分配对象的内存空间
instance = memory;	//3:设置instance指向刚分配的内存地址(此时对象还未初始化)
ctorInstance(memory);	//2:初始化对象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化,即引用instance指向了一个"被部分初始化的对象"。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。
解决这个该问题,只需要将instance声明为volatile变量:

    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;

也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不同的单例了,但我会拿到“半个单例”(未完成初始化)。然而,许多面试书籍中,涉及懒加载的单例模式最多深入到DCL,却只字不提volatile。这“看似聪明”的机制,曾经被我广大初入Java世界的猿胞大加吹捧。

面试中你可能得意洋洋的从饱汉、饿汉讲到Double Check,现在看来你其实没有学到单例模式或者说Volatile的精髓。对于考查并发的面试官而言,单例模式的实现就是一个很好的切入点,看似考查设计模式,其实期望你从设计模式答到并发和内存模型。

内存屏障

volatile关键字通过内存屏障来防止指令被重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。

下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

如果你感兴趣,可以去了解一下内存屏障的机器原语,看看内存屏障是如何实现的。可以做一下了解。

总结

Volatile存在两大特性:

  • 线程可见性
  • 防止指令重排。

你把我上面说的记下来,无论面试官怎么考你,相信你都能对达入流,你也能体现出你对一个知识点的钻研。祝你成功!

【吊打面试官系列】多线程和高并发硬核技能  专栏往期回顾

CAS你以为你真的懂?

Synchronized你以为你真的懂?

发布了72 篇原创文章 · 获赞 295 · 访问量 20万+

猜你喜欢

转载自blog.csdn.net/lyztyycode/article/details/105445025
今日推荐