volatile 关键字(轻量级同步机制)

更多内容,前往 IT-BLOG

volatile 表示 “不稳定” 的意思。用于修饰共享可变变量,即没有使用 final(不可变变量) 关键字修饰的实例变量或静态变量,相应的变量就被称为 volatile 变量,如下:

private volatile String name;

volatile 关键字修饰的变量容易变化(多线程下会被其它线程修改),因而不稳定。volatile 变量的不稳定性意味着对这种变量的读和写操作都必须从高速缓存或者主内存(也是通过高速缓存读取),以读取变量的最新值。因此,volatile 变量不会被编译器分配到寄存器进行存储(相当于备份),对 volatile 变量的读写操作都是内存访问(访问高速缓存相当于主内存)操作。

Volatile 被称为轻量级锁,其作用与锁的作用有相同的地方:保证可见性有序性。所不同的是,在原子性方面它仅能保障写 volatile 变量操作的原子性,但没有锁的排他性volatile 关键字的使用不会引起上下文切换(这是 volatile 被冠以 “轻量级” 的原因)。

一、volatile 的作用


volatile 关键字的作用包括:保障可见性、有序性和 long/double 型变量读写操作的原子性。一般而言,对 volatile 变量的赋值操作,其右边表达式中只要涉及共享变量(包括被赋值的 volatile 变量本身),那么这个赋值操作就不是原子操作。要保障这样操作的原子我们仍需要借助锁。

volatile Map map = new HashMap(); 可以分解为如下伪代码所示的几个子操作:
【1】objRef = allocate(HashMap.class);  //子操作①:分配对象所需的存储空间。
【2】invokeConstructor(objRef); //子操作②:初始化 objRef 引用的对象。
【3】map = objRef; //子操作③:将对象引用写入变量 map。
虽然 volatile 关键字仅保障其中的子操作③是一个原子操作,但是由于子操作①和子操作②仅涉及局部变量而未涉及共享变量,因此对变量 map 的赋值操作仍然是一个原子操作。

写线程对 volatile 变量的写操作会产生类似于释放锁的效果。读线程对 volatile 变量的读操作会产生类型于获得锁的效果。因此,volatile 具有保障有序性和可见性的作用

对于 volatile 变量的写操作,Java 虚拟机会在该操作之前插入一个释放屏障[禁止 volatile 写操作与该操作之前的任何读、写操作进行重排序从而保证了 volatile 写操作之前的任何读、写操作会先于 volatile 写操作被提交],并在该操作之后插入一个存储屏障[具有冲刷处理器缓存的作用,因此在 volatile 变量写操作之后插入的一个存储屏障就使得该存储屏障前所有操作的结果(包括 volatile 变量写操作及该操作之前的任何操作)对其他处理器来说是可同步的],如下图:

对于 volatile 变量读操作,Java 虚拟机会在该操作之前插入一个加载屏障(Load Barrier),并在该操作之后插入一个获取屏障(Acquire Barrier),如下图:

加载屏障通过冲刷处理器缓存,使其执行线程(读线程)所在的处理器将其他处理器对共享变量(可能是多个变量)所做的更新同步到该处理器的高速缓存中读线程执行的加载屏障和写线程执行的存储屏障配合在一起使得写线程对 volatile 变量的写操作以及在此之前所执行的其他内存操作的结果对读线程可见,即保障了可见性因此 volatile 不仅仅保障了 volatile 变量本身的可见性,还保障了写线程在更新 volatile 变量之前执行的所有操作的结果对读线程可见。这种可见性保障类似于锁对可见性的保障,与锁不同的是 volatile 不具备排他性,因而它不能保障读线程读取到的这些共享变量的值是最新的,即读线程读取到这些共享变量的那一刻可能已经有其他写线程更新了这些共享变量的值。另外,获取屏障禁止了 volatile 读操作之后的任何操作读,写操作与volatile 读操作进行重排序。因此它保障了 volatile 读操作之后的任何操作开始执行之前,写操作对相关共享变量(包括 volatile 变量和普通变量)的更新已经对当前线程可见。
【volatile 在有序性保障方面也可以从禁止重排序的角度理解,即 volatile 禁止了如下重排序】:
【1】写 volatile 变量操作与该操作之前的任何读、写操作不会被重排序;
【2】读 volatile 变量操作与该操作之后的任何读、写操作不会被重排序;

二、volatile 变量的开销


volatile 变量的开销包括读变量和写变量两个方面。volatile 变量的读、写操作都不会导致上下文切换,因此 volatile 的开销比锁要小。写一个 volatile 变量会使该操作以及该操作之前的任何写操作的结果对其他处理器是可同步的,因此 volatile 变量写操作的成本介于普通变量的写操作和在临界区进行的写操作之间。读取 volatile 变量的成本也比在临界区中读取变量要低(没有锁的申请与释放以及上下文切换的开销),但是其成本可能比读取普通变量要高一些。这是因为 volatile 变量的值每次都需要从高速缓存或者主内存中读取,而无法被暂存在寄存器中,从而无法发挥访问的高效性。

三、volatile 的典型应用场景


场景一:使用 volatile 变量作为状态标志。在该场景中,应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据(或者仅仅读取并输出这个状态值)。此时使用 volatile 变量作为同步机制的好处是一个线程能够“通知”另外一个线程某种事件(例如,网络连接断连之后重新连上)的发生,而这些线程又无须使用锁,从而避免了锁的开销以及相关问题。
场景二:使用 volatile 保障可见性。在该场景中,多个线程共享一个可变状态变量,其中一个线程更新了该变量之后,其他线程在无须加锁的情况下也能够看到该更新。
场景三:使用 volatile 变量代替锁。volatile 关键字并非锁的代替品,但是在一定的条件下它比锁更合适(性能开销小,代码简单)。多个线程共享一组可变状态变量的时候,通常我们需要使用锁来保障对这些变量的更新操作的原子性,以避免产生不一致问题。利用 volatile 变量写操作具有的原子性,我们可以把这一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。在这个过程中,volatile 保障了原子性和可见性,从而避免了锁的使用。

volatile 关键字并非锁的替代品,volatile 关键字和锁各有其使用条件。前者更适合与多个线程共享一个状态变量(对象),而后者更适合于多个线程共享一组状态变量。某些情形下,我们可以将多个线程共享的一组状态变量合并成一个对象,用一个 volatile 变量来引用该对象,从而使我们不必要使用锁。

场景四:使用 volatile 实现简易版读写锁。在该场景中,读写锁是通过混合使用锁和 volatile 变量而实现的,其中锁用于保障共享变量写操作的原子性,volatile 变量用于保障共享变量的可见性。因此,与 ReentrantReadWriteLock 所实现的读写锁不同的是,这种简易版读写锁仅涉及一个共享变量并且允许一个线程读取这个共享变量时其他线程可以更新该变量(这是因为读线程并没有加锁)。因此,这种读写锁允许读线程可以读取到共享变量的非最新值。该场景的一个典型例子是实现一个计数器:

 1 public class Counter {
 2     private volatile long count;
 3     public long value(){
 4         return count;
 5     }
 6 
 7     public void increment() {
 8         synchronize(this){
 9             count++;
10         }
11     }
12 }

四、volatile 实现可见性的原理


在 Java 并发编程中,一定绕不开 volatilesynchronizelock 几个关键字,其中 volatile 关键字是用来解决共享变量(类成员变量、类的静态成员变量等)的可见性问题,非共享成员变量(局部变量)是分配在 JVM 虚拟机的栈中,是线程私有的,不涉及可见性问题。
可见性在 JAVA规范中是这样定义的:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。通俗的讲就是如果有一个共享变量N,当有两个线程T1、T2同时获取了N的值,T1修改N的值,而T2读取N的值。那么可见性规范要求T2读取到的必须是T1修改后的值,而不能在T2读取旧值后T1修改为新值。volatile关键字修饰的共享变量可以提供这种可见性规范,也叫做读写可见。那么底层实现是通过机制保证 volatile变量读写可见的?Volatile的实现机制:在说这个问题之前,我们先看看CPU是如何执行 Java 代码的。

首先 Java代码会被编译成字节码 .class文件,在运行时会被加载到 JVM中,JVM会将 .class转换为具体的 CPU执行指令,CPU加载这些指令逐条执行。

以多核CPU为例(两核),我们知道 CPU的速度比内存要快得多,为了弥补这个性能差异,CPU 内核都会有自己的高速缓存区,当内核运行的线程执行一段代码时,首先将这段代码的指令集进行缓存行填充到高速缓存,如果非 volatile 变量当 CPU执行修改了此变量之后,会将修改后的值回写到高速缓存,然后再刷新到内存中。如果在刷新回内存之前,由于是共享变量,那么CORE2 中的线程执行的代码也用到了这个变量,这是变量的值依然是旧的。volatile关键字就会解决这个问题的,如何解决呢,首先被 volatile关键字修饰的共享变量在转换成汇编语言时,会加上一个以 lock为前缀的指令,当CPU发现这个指令时,立即做两件事:
【1】将当前内核高速缓存行的数据立刻回写到内存;
【2】使在其他内核里缓存了该内存地址的数据无效;
第一步很好理解,第二步如何做到呢?
【MESI协议】:在早期的 CPU中,是通过在总线加 LOCK#锁的方式实现的,但这种方式开销太大,所以 Intel开发了缓存一致性协议,也就是 MESI协议[伊利诺斯],该解决缓存一致性的思路是:当 CPU写数据时,如果发现操作的变量是共享变量,即在其它 CPU中也存在该变量的副本,那么它会发出信号通知其他 CPU将该变量的缓存行设置为无效状态。当其它 CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。以上这些就是 volatile关键字的内部实现机制。

posted @ 2020-11-19 19:52  Java程序员进阶  阅读(343)  评论(0编辑  收藏  举报