Java并发编程:Volatile关键字

前言
最近LZ在准备和巩固面试的东西,最近的LZ文笔风格就会比较略显老土,稍失俏皮了,还望海涵,会当博主时日得志之时,再展风骚…咳咳咳…

不扯了,今天就来写写水文,回到正题提到Volatile,在单线程环境中,我们几乎用不到Volatile,但是 多线程 环境中,这个关键词随处可见。

文章目录

    • 1、关于Volatile
        • 1. 1、Volatile如何保证可见性?
        • 1.2、Volatile不保证原子性
        • 1.3、Valatile禁止指令重排
        • 1. 4、 Volatile的使用场景
        • 1. 5、 volatile与synchronized的区别

1、关于Volatile

volatile有三个特性:保证可见性、不保证原子性、禁止指令重排。下面就来详细的说说这三个特性。

1. 1、Volatile如何保证可见性?

什么是可见性?

简单来说可见性就是一个线程对共享变量值的修改,能够及时被其他线程看到。

谈到可见性,又不得不说Java内存模型JMM( java memory model ) 。不在本文的概述中,这里不再叙述,等等等等,宜春是暖男来的嘛,怎么忍心让各位靓仔、靓女亲自出手呢,所以需要补充对JMM知识的铁汁请点击查阅:终于有人把Java内存模型(JMM)说清楚了。对了,暖到没有?

OK,回归正题:Volatile如何保证可见性?

重点来了....
重点来了....
重点来了....

为了提高处理速度,处理器不会直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或L3)后再进行操作,但操作完不知道何时会写到内存,这个时候volatile就开始起作用了!

对声明了Volatile的变量进行写操作,jvm就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会系统主内存,但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一 致性协议,其中最出名的就是Intel 的MESI协议

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

不知道大家有没有想过一个问题:CPU是怎么发现缓存中缓存该变量的缓存行是无效的呢?原理则是每个处理器会通过嗅探机制在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。嗅探的缺点也显而易见:由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探CAS不断循环,无效交互会导致总线带宽达到峰值。

所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。

深入来说Volatile是通过加入 内存屏障 禁止重排序优化 来实现Volatile 保证可见性指令重排后面会讲到。

关于内存屏障:
对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
对volatile变量执行读操作时,会在读操作前加入一条load屏障指令

我们通常用happen - before (先行发生原则),来阐述操作之间内存的可见性。也就是前一个的操作结果对后一个操作可见,那么这两个操作就存在 happen - before 规则。两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前,因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。

1.2、Volatile不保证原子性

什么叫原子性?

所谓原子性,就是说一个操作不可被分割或加塞,要么全部执行,要么全不执行。

java程序在运行时,JVM将java文件编译成了class文件。我们使用javap命令对class文件进行反汇编,就可以查看到java编译器生成的字节码。关于原子性最常见的就是 i++ 问题,其实 i++反汇编后是分三步进行的。

第一步:将i的初始值装载进工作内存;
第二步:在自己的工资内存中进行自增操作;
第三步:将自己工作内存的值刷回到主内存。

我们知道线程的执行具有随机性,假设现在i的初始值为0,有A和B两个线程对其进行++操作。首先两个线程将0拷贝到自己工作内存,当线程A在自己工作内存中进行了自增变成了1,还没来得及把1刷回到主内存,这是B线程抢到CPU执行权了。B将自己工作内存中的0进行自增,也变成了1。然后线程A将1刷回主内存,主内存此时变成了1,然后B也将1刷回主内存,主内存中的值还是1。本来A和B都对i进行了一次自增,此时主内存中的值应该是2,而结果是1,出现了写丢失的情况。这是因为i++本应该是一个原子操作,但是却被加塞了其他操作。所以说Volatile不保证原子性。

当然,也有对应的解决方法

第一种:可以用Synchronized
第二种:使用原子Atomic包下的类,比如AtomicInteger

第一种办法不太好,因为synchronized太重量级了。
第二种办法更好。为什么Atomic包就可以保证原子性呢?因为它使用了CAS算法。CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

1.3、Valatile禁止指令重排

什么是指令重排?
在多线程情况下,计算机为了提高执行效率,就会对这些步骤进行重排序,这就叫指令重排。如果还不够通俗,那么请看这四条语句:

int x = 1;  //1
int y = 2;  //2
x = x + 3;  //3
y = x - 4;  //4

正常执行顺序是从上往下1234这样执行,x的结果应该是4,y的结果应该是0。
多线程环境中编译器指令重排后执行顺序可能就变成了1243,这样得出的x就是4,y就是-3.
这结果显然就不正确了。不过编译器在重排的时候也会考虑数据的依赖性,比如哪怕指令重排后执行顺序也不可能为2413,因为第4条语句的执行是依赖 x 的。使用Volatile修饰,就可以禁止指令重排

由于上文提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

实际上,volatile关键字禁止指令重排两层意思

1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

可能上面说的比较绕,举个简单的例子:

x = 1;                  //语句1
y = 2;                  //语句2
volatile int flag = 3;  //语句3
x = 4;                  //语句4
y = 5;                  //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

关于指令重排这里还得提一个概念,就是as-if-serialas-if-serial 语义的意思是:不管怎么重排序,(单线程) 程序的执行结果不能被改变。,编译器、runtime和处理器都必须遵守as-if-serial语义。

1. 4、 Volatile的使用场景

通常来说,使用volatile必须具备以下2个条件:

1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中

实际上,这两个条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

当然也可以这样理解,就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。那么再落实到使用场景上,经典的有如下两个场景:

1、一个线程写,其他线程读。【其他地方就不要乱用volatile关键字】
2、还有就是DCL懒汉单例【双重检查】的synchronization代码块外的那个共享变量的指令重排序问题

这里就提一下经典的DCL版单例模式,是double check lock 的缩写,中文名叫双端检索机制。所谓双端检索,就是在加锁前和加锁后都用进行一次判断。代码如下:

public class SingletonDemo {
    private static volatile SingletonDemo  singletonDemo = null; //加了volatile关键字修饰
    private SingletonDemo(){
        System.err.println("构造方法被执行");
    }
    public static SingletonDemo getInstance(){
        if (singletonDemo == null){           // 第一次check
            synchronized (SingletonDemo.class){
                if (singletonDemo == null)    // 第二次check
                    singletonDemo = new SingletonDemo();   //特别注意这一行代码!!!!
            }
        }
        return singletonDemo;
    }
 }

我们知道new 一个对象也是分三步的:

1.分配对象内存空间;
2.初始化对象;
3.将对象指向分配的内存地址。

如果没有加Volatile关键字修饰,并且步骤二和步骤三不存在数据依赖,因此编译器优化时允许这两句颠倒顺序。当指令重拍后,多线程去访问就可能会导致还没有初始化完对象,就开始使用这个对象了,因此volatile 关键字必须加上。

1. 5、 volatile与synchronized的区别

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

参考:
《Java并发编程的艺术》
《Java多线程编程核心技术》
https://www.jianshu.com/p/b05e4da39de9
https://mp.weixin.qq.com/s/Oa3tcfAFO9IgsbE22C5TEg
https://www.cnblogs.com/dolphin0520/p/3920373.html

你可能感兴趣的:(java并发编程)