1 前言
在java的并发编程中我们经常会使用到Volatile关键字。而关于Volatile关键字的使用以及Volatile关键字的特性和实现原理也是在笔面试中经常会遇到的问题了。
2 正文
volatile关键字虽然从字面上理解起来比较简单,它的中文意思是:易变的; 无定性的; 无常性的; 可能急剧波动的; 不稳定的; 易恶化的; 易挥发的; 易发散的;所以我们大概能够知道这个关键字的大概含义。但是由于volatile关键字是与Java的内存模型有关的,所以想要用好它不是一件容易的事情。
volatile是Java提供的一个轻量级同步机制,作为并发编程里一个重要组成部分,它用来修饰变量。通过volatile修饰的变量可以保证可见性与有序性。在双重检查加锁方式实现的单例中,就有使用,比如下面这个代码
public class User extends Person{ //使用volatile修饰单例变量 private volatile static User userBean; //获取单例,双重检查加锁方式 public static User getInstance() { if (userBean== null) { synchronized (User.class) { if (userBean== null) { userBean= new User (); } } } return userBean; } //构造方法私有 private User() { } } 复制代码
上面代码中的:
userBean= new User (); 复制代码
一行代码其实做了三件事情:
1、为User对象分配内存
2、实例化对象
3、将userBean引用指向实例
这里还要提到并发编程中的三个重要概念:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性问题很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。如果这2个操作不具备原子性,假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
//线程1执行的代码 int i = 0; i = 10; //线程2执行的代码 j = i; 复制代码
如果执行线程1的是CPU1,而执行线程2的是CPU2。当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
可见性问题就是线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
int i = 0; boolean flag = false; i = 1; //语句1 flag = true; //语句2 复制代码
上面定义了一个int型变量和一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
指令重排序指的是处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么问题来了,它靠什么来保证呢?
//语句1 int a = 10; //语句2 int r = 2; //语句3 a = a + 3; //语句4 r = a*a; 复制代码
这段代码有4个语句,它的执行顺序可能是:语句2-->语句1-->语句3-->语句4,也可能是语句1-->语句2-->语句3-->语句4,这些对于它的执行结果都是没有影响的,但是执行顺序不可能是:语句2-->语句1-->语句4-->语句3或者语句1-->语句2-->语句4-->语句3。我们按照顺序这些顺序可以发现如果语句4在语句3之前执行,那么结果就会发生改变的。所以根据指令重排序,后面的两种执行顺序是不可能的。
这是因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令2必须用到指令1的结果,那么处理器会保证1必须会在2之前执行。
以上都是单线程的情况下,那么多线程编程的时候呢?
//线程1: //语句1 string s= "hello world"; //语句2 bool flag = true; //线程2: while(!flag){ sleep() } PrintHello(s) 复制代码
上面代码中,可以看出语句1和语句2之前没有数据依赖性,因此处理器可能会对其进行指令重排序。如果进行了重排序,那么在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行PrintHello(s)方法,而此时s变量并没有被初始化,就会导致程序出错。
所以指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
所以在并发编程中要想保证并发程序正确地执行,就必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
前面说到关于volatile的关键字其实和java的内存模型,也就是JVM有关。
在JMM中,为了提高效率,抽象出一个主内存与工作内存的概念。线程之间的共享变量存储在主内存中,另外每个线程又都配备了一个私有的工作内存,工作内存中使用到的变量需要到主内存去拷贝,线程对变量的读取、赋值操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
那么volatile关键字如何保证可见性的呢?
当变量被volatile修饰后,在生成汇编代码指令时会在volatile修饰的共享变量进行写操作的时候添加一个Lock前缀。
Lock前缀表示当一个线程修改该共享变量后,它会将新值立即刷新到主内存中,同时导致该变量在所有线程的本地内存失效,这样其他线程再读取共享变量时,会直接从主内存中读取,达成缓存一致性。这里与synchronize或者Lock等锁机制保证可见性的做法还是有差别的。锁机制的做法是保证同一时刻只有一个线程获取锁并执行同步代码,释放锁时将对变量的修改刷新到主存当中。
再来看看有序性。
前面说到了有序性问题需要归咎于指令重排。在Java内存模型中,如果某些指令之间不存在数据依赖,为了提高效率,是允许编译器和CPU对指令进行重排序,指令重排序不会影响单线程的运行结果,但是对多线程会有影响。
在多线程中为了避免指令重排引起的并发问题,就需要依靠volatile关键字了。在Java编译器生成指令时,对于volatile关键字修饰的变量,会在指令序列中插入特定的内存屏障。
内存屏障其实说到底也是一个指令而已,例如StoreStore、StoreLoad、LoadLoad、LoadStore等,但是它特殊的地方就是告诉编译器和CPU,不管什么指令都不能和我的内存屏障指令重排序。所以被volatile关键字修饰的变量就会被禁止进行指令重排序,这样就能够保证并发编程的有序性。
关于内存屏障的概念设计的概念又比较多,篇幅有限这里就不进行说明了,后面可能会单独记录下,有兴趣的可以自己先了解下。这里我们只需要知道使用volatile关键字修饰的变量会被禁止指令重排,从而保证了有序性。
那么原子性呢?volatile关键字能够保证并发编程的原子性吗?这里答案是不能的!我们先来看看JVM的说明:
JMM规定了所有的变量都存储在主内存(Main Memory)中,多个线程共享主内存中的数据。每个线程都有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量在主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
这里需要说明自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存,也就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现。
假设在工作内存中有一个共享的变量 int number = 0,并且有四个线程:线程1、线程2、线程3、线程4,并且在四个线程中的操作都是对 number变量进行number++操作。
开始四个线程都将共享变量读入到自己的工作内存中,同时执行++操作,然后线程1将自己的变量刷新到主内存中,此时值为 number = 1,因为变量发生的变化,线程2、线程3都开始重新读取数据进行操作,线程4在没有收到变量发生改变的通知之前,已经将自己的变量刷新到主内存,此时主内存中的变量 number 还是等于1,但此时线程1、线程4都已经执行完,所以最后的结果时2或者3。
原因就是自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
所以volatile关键字能够保证可见性和有序性但是并不能够保证原子性。
3 总结
所以关于volatile关键字的笔面试题虽然答案很简单,局势保证了可见性和有序性,但不能保证有序性。但是能够考察的方面其实还是很多的比如:volatile的使用;JMM指令重排以及volatile的实现原理,这些还是很考验基本功的。