目录
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存在两大特性:
- 线程可见性
- 防止指令重排。
你把我上面说的记下来,无论面试官怎么考你,相信你都能对达入流,你也能体现出你对一个知识点的钻研。祝你成功!