面试官最爱的 volatile 关键字,这些问题你都搞懂了没?

来源:博客园 分类: 文章浏览史 发布时间:2020-08-18 09:39:21 最后更新:2020-08-18 浏览:85
转载声明:
本文为摘录自“博客园”,版权归原作者所有。
温馨提示:
为了更好的体验,请点击原文链接进行浏览
摘录时间:
2020-08-18 09:39:21

前言

volatile相关的知识点,在面试过程中,属于基础问题,是必须要掌握的知识点,如果回答不上来会严重扣分的哦。

volatile关键字基本介绍

volatile可以看成是synchronized的一种轻量级的实现,但volatile并不能完全代替synchronized,volatile有synchronized可见性的特性,但没有synchronized原子性的特性。

可见性即用volatile关键字修饰的成员变量表明该变量不存在工作线程的副本,线程每次直接都从主内存中读取,每次读取的都是最新的值,这也就保证了变量对其他线程的可见性。

另外,使用volatile还能确保变量不能被重排序,保证了有序性。

  • 当一个变量定义为volatile之后,它将具备两种特性:

    • 保证此变量对所有线程的可见性
    • 禁止指令重排序优化
  • volatile与synchronized的区别:

    • 1、volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
    • 2、volatile保证数据的可见性,但是不保证原子性; 而synchronized是一种排他(互斥)的机制,既保证可见性,又保证原子性。
    • 3、volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
    • 4、volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

保证此变量对所有线程的可见性:

当一条线程修改了这个变量的值,新值对于其他线程可以说是可以立即得知的。Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存,线程的工作内存保存了该线程使用到的变量在主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读取主内存中的变量。

知识拓展:内存可见性

  • 概念:JVM内存模型:主内存 和 线程独立的 工作内存。Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。
  • 如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,定义了8种原子操作:
    • lock:将主内存中的变量锁定,为一个线程所独占。
    • unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量。
    • read:将主内存中的变量值读到工作内存当中。
    •  load:将read读取的值保存到工作内存中的变量副本中。
    • use:将值传递给线程的代码执行引擎。
    • assign:将执行引擎处理返回的值重新赋值给变量副本。
    • store:将变量副本的值存储到主内存中。
    • write:将store存储的值写入到主内存的共享变量当中。

通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。

即其他线程的本地内存中的变量已经是过时的,并不是更新后的值。volatile保证可见性的原理是在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以volatile关键字的作用之一就是保证变量修改的实时可见性。

即,volatile的特殊规则就是:

  • read、load、use动作必须连续出现。
  • assign、store、write动作必须连续出现。

所以,使用volatile变量能够保证:

  • 每次读取前必须先从主内存刷新最新的值。
  • 每次写入后必须立即同步回主内存当中。

也就是说,volatile关键字修饰的变量看到的是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。

禁止指令重排序优化:

复制代码
volatile boolean isOK = false;

//假设以下代码在线程A执行
A.init();
isOK=true;

//假设以下代码在线程B执行
while(!isOK){
  sleep();
}
B.init();
复制代码

 

A线程在初始化的时候,B线程处于睡眠状态,等待A线程完成初始化的时候才能够进行自己的初始化。这里的先后关系依赖于isOK这个变量。

如果没有volatile修饰isOK这个变量,那么isOK的赋值就可能出现在A.init()之前(指令重排序,Java虚拟机的一种优化措施),此时A没有初始化,而B的初始化就破坏了它们之前形成的那种依赖关系,可能就会出错。

知识拓展:指令重排序

  • 概念:指令重排序是JVM为了优化指令,提高程序运行效率,在不影响 单线程程序 执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序带来问题。

不同的指令间可能存在数据依赖。比如下面的语句:

  int l = 3; // (1)
  int w = 4; // (2)
  int s = l * w; // (3)

 

面积的计算依赖于l与w两个变量的赋值指令。而l与w无依赖关系。

重排序会遵守两个规则:

  • as-if-serial规则:as-if-serial规则是指不管如何重排序(编译器与处理器为了提高并行度),(单线程)程序的结果不能被改变。这是编译器、Runtime、处理器必须遵守的语义。
  • happens-before规则
    • 程序顺序规则:一个线程中的每个操作,happens-before于线程中的任意后续操作。
    • 监视器锁规则一个锁的解锁,happens-before于随后对这个锁的加锁。
    • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
    • 传递性:如果(A)happens-before(B),且(B)happens-before(C),那么(A)happens-before(C)。
    • 线程start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens-before 线程B中的操作。
    • 线程join()规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
    • 中断规则:一个线程调用另一个线程的interrupt,happens-before于被中断的线程发现中断。
    • 终结规则:一个对象的构造函数的结束,happens-before于这个对象finalizer的开始。
    • 概念:前一个操作的结果可以被后续的操作获取。讲直白点就是前面一个操作把变量a赋值为1,那后面一个操作肯定能知道a已经变成了1。
    • happens-before(先行发生)规则如下:

虽然,(1)-happensbefore ->(2),(2)-happens before->(3),但是计算顺序(1)(2)(3)与(2)(1)(3)对于l、w、area变量的结果并无区别。编译器、Runtime在优化时可以根据情况重排序(1)与(2),而丝毫不影响程序的结果。

  • volatile使用场景:
    • 1、对变量的写操作不依赖当前变量的值。
    • 2、该变量没有包含在其他变量的不变式中。
    • 如果正确使用volatile的话,必须依赖下以下种条件:

也可以这样理解,就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(i++)看上去类似一个单独操作,实际上它是一个由(读取-修改-写入)操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。

实现正确的操作需要使 i 的值在操作期间保持不变,而 volatile 变量无法实现这点。

  • 在以下两种情况下都必须使用volatile:
    • 1、状态的改变。
    • 2、读多写少的情况。

具体如下:

复制代码
// 场景一:状态改变

/**
 * 双重检查(DCL)
 */
public class Sun {
  private static volatile Sun sunInstance;

  private Sun() {
  }

  public static Sun getSunInstance() {
    if (sunInstance == null) {
      synchronized (Sun.class) {
        if (sunInstance == null){
          sunInstance = new Sun();
        }
      }
    }
    return sunInstance;
  }
}

// 场景二:读多写少

public class VolatileTest {
    private volatile int value;

    //读操作,没有synchronized,提高性能
    public int getValue() {
        return value;
    }

    //写操作,必须synchronized。因为x++不是原子操作
    public synchronized int increment() {
        return value++;
    }
}
复制代码

 

问题来了,volatile是如何防止指令重排序优化的呢?

答:

volatile关键字通过 “内存屏障” 的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

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

知识拓展:内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

内存屏障可以被分为以下几种类型:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
php技术微信