并发编程大师系列之:深入理解Volatile关键字

/ 并发编程 / 0 条评论 / 413人围观

写在前面

今晚翻看我以前写的博客,包括博客园还有我的个人博客,也有好多篇了,其中很大一部分都是我的原创,也有我谷歌百度自己总结演练后写下的,没有一篇是完全粘贴复制过来的,然后有群里的小伙伴提出了宝贵的意见,说写的太直白了,就跟直奔主题一样,上来就干代码,讲理论,太乏味,读的犯困,我翻看了一下,还真是这样的,我想今后我改变一下我写博客的风格,毕竟博客存在的意义并不只有自己能看懂就ok了,能让别人从中学到知识,指出错误,改进思路,分享知识,才是重要的。我决定以后写博客的方式有以下改进:

  1. 面试会面到的基本点。
  2. 理论知识。
  3. 举简单的例子。
  4. 在实际工作中用到的地方。

面试必问

你知道volatile吗?

volatile首先这个词的音标:[ˈvɒlətaɪl],翻译过来是易变的,不稳定的意思。读音要标准。

  1. 它是一个关键字,翻译过来是易改变的意思。
  2. 我们经常用它来修饰成员变量。
  3. 被它修饰的变量具备两种特性:第一:保证这个变量的可见性,但不具有原子性。第二:禁止进行指令重排序。
  4. 当一个变量被 volatile 修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。并不是让线程直接从主内存中获取数据,依然需要将变量拷贝到工作内存中。

volatile的作用是什么?

参见第一条

volatile的原理是什么?

如果面试官问到这里,说明他真的认真的研究过volatile,那就得好好的给他敲黑板了。

首先:

  1. 既然它是关键字,那就表明它跟syn一样,没有可见的源码,都是jvm层面了,其中涉及到了内存模型也就是JMM(Java Memory Model)。
  2. 内存模型?!Java虚拟机规范中定义了一种Java内存模型来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
  3. 为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
  4. Java内存模型规定所有的变量都是存在主存当中(类似于物理内存),每个线程都有自己的工作内存(类似于高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。也有的人说主内存可以简单认为是堆内存,而工作内存则可以认为是栈内存。

举个例子:

int i = 10;

执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。

原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

一个很经典的例子就是银行账户转账问题:

比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

volatile关键字并不能保证数据的原子性,所以说它是线程安全的是错误的,看似简单的 i++ 操作在多线程的环境下,是不安全的,但是可以通过加锁来保证原子性。

i ++ 操作在计算机中其实进行了三个步骤:

  1. cpu从主内存中读取 i 变量的值,并且复制到工作内存中。
  2. 在工作内存中+1。
  3. 将+1后的结果刷回主内存中。

举例:

public class VolatileTest implements Runnable {

    //使用 volatile 修饰基本数据内存不能保证原子性
    private static volatile int count = 0;

    public void run() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
        System.out.println(Thread.currentThread().getName() + " : " + count);
    }

    public static void main(String[] args) {
        VolatileTest volatiletest = new VolatileTest();
        Thread t1 = new Thread(volatiletest, "t1");
        Thread t2 = new Thread(volatiletest, "t2");
        t1.start();
        t2.start();
    }
}

运行结果:几乎每次都不一样,这是因为,加了volatile 每次都会去主内存中拿最新的值。可以通过给run方法加锁方式或者利用AtomicInteger来替换int来实现同步。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 举例:

// 线程1执行的代码
int i = 0;
i = 10;
 
// 线程2执行的代码
j = i;

上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到线程1的高速缓存中,然后赋值为10,那么在线程1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到线程2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性和指令重排序(Instruction Reorder)

有序性即程序执行的顺序按照代码的先后顺序执行。(happens-before原则以后再补充。)

举例:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,这里可能会发生指令重排序。

指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

举例:

int a = 10;    //语句1
int b = 2;    //语句2
int c = a + b;    //语句3

上面代码可能发生的顺序为:语句2 -->语句1-->语句3,但不可能是语句2 -->语句3-->语句1,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令2必须用到指令1的结果,那么处理器会保证指令1会在指令2之前执行。

但在多线程的情况下:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

什么场景下会用到volatile?

  1. 对变量的写操作不依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。
  3. 只有一个线程写,多个线程读的情况下使用比较频繁。

工作中哪里用到了volatile?

1. 状态标记量

volatile boolean flag = false;
while(!flag){
    doSomething();
} 
public void setFlag() {
    flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

2. double check

双重懒加载的单例模式 这里的 volatile 关键字主要是为了防止指令重排。 如果不用 ,singleton = new Singleton();,这段代码其实是分为三步:

  1. 分配内存空间。
  2. 初始化对象。
  3. 将 singleton 对象指向分配的内存地址。 加上 volatile 是为了让以上的三步操作顺序执行,反之有可能第二步在第三步之前被执行就有可能某个线程拿到的单例对象是还没有初始化的,以致于报错。
class Singleton{
    // 加volatile关键字是防止指令重排,保证了变量在内存中的可见性,但不能保证原子性。
    private volatile static Singleton instance = null;
    private Singleton() { }
    public static Singleton getInstance() {
        if(instance==null) {// 此处是为了减少加锁
            synchronized (Singleton.class) {
                if(instance==null) {// 此处为了并发进来后不要重复new对象
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile能取代syn吗?为什么?

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。