栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

java并发编程(4) 有序性-详细说说volatile

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

java并发编程(4) 有序性-详细说说volatile

文章目录

前言一. 引入二. volatile的应用

1. volatile的定义和实现原理

1 CPU 术语2 volatile实现原理 2. volatile的使用优化

1 为什么追加到4字节可以提高并发效率2 什么情况不能追加到64字节 三. volatile 的内存语义

1. volatile 读写的特性2. volatile写-读建立的happens-before关系3. volatile 写-读的内存语义4. volatile 内存语义的实现

1. 对重排序的限制2.内存屏障3.内存屏障理解图示4.编译器的优化 5. JSR-133 为什么要增强 volatile 的内存语义 四. 总结


前言

在这篇文章中对 volatile 进行说明,这个关键字在很久之前就想写一篇文章了,在《Java并发编程的艺术》这本书里面其实有很详细的介绍,这篇文章结合这本书以及自己的一些理解来写的。如果哪里有错欢迎指出!!!




一. 引入

在并发编程中 synchronized 和 volatile 都有着重要的作用,volatile 是轻量级的 synchronized,在多线程器开发的过程中保证了线程的可见性。所谓的的可见性,其实就是在并发编程的时候,当共享变量发生变化的时候,其他的线程能够同步感受到这种变化。如果 volatile 使用合使,不但可以保证线程安全,还可以提高效率。

举个例子: 下面代码中主线程就算修改了 run 为false,也不会停下来,因为由于要大量访问 run 这个变量,编译器为了优化会在缓存区划出一块来,把 run 放在缓存中,此时主线程修改的变量是缓存中的变量,对于主存中的变量其实没有影响。解决方法就是加上一个 volatile,这个关键字保证了可见性,在主线程修改了缓存中的变量的时候,会马上同步修改主存中的 run 变量,那么线程再读取 run 这个变量的时候读到的就是 false 了,而不是 true。

public class Test32 {
    // 易变
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(true){
                if(!run) {
                    break;
                }
            }
        });
        t.start();

        sleep.mySleep(1);
        run = false; // 线程t不会如预想的停下来
    }
}



二. volatile的应用 1. volatile的定义和实现原理

Java 语言规范第三版对 volatile 的定义如下:Java 程序语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获取这个变量。 Java 提供了 volatile 关键字用于修饰共享变量,对于修饰的共享变量,Java 线程模型 JMM 确保所有线程获取到的共享变量的值都是同一个。


1 CPU 术语
CPU 术语
大概了解了 volatile 是什么,我们先来看看和 volatile 原理相关的 CPU 术语和说明。

内存屏障:是一组处理器指令,用于实现对内存操作的顺序限制,在指令重排序中会说到

缓冲行:CPU 高速缓存中可以分配的最小存储单位。处理器填写缓存行的时候会加载整个缓存行,现代 CPU 需要执行几百次 CPU 指令。作用就是读取或者写出数据的时候做一个临时存储的作用,以及对于频繁操作的变量可以存到这里面,加快读取效率。

原子操作:一个或者一系列操作不可被中断,要么全部执行,要不都不执行。就是说线程在执行这类的操作时是不可以被打断的。如果可以保证对共享变量的操作是原子操作,其实就能解决变量写回的问题。

缓存行填充:当处理器识别从内存中读取操作数是可以缓存的时候,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或者所有),下次再读取数据的时候就可以从缓存行中直接读取

缓存命中:如果高速缓存行填充操作的内存位置仍是下次处理器访问的位置,那么处理器将从缓存行中读取数据,而不是从内存中。这部分学过计算机组成原理的朋友应该会了解。里面缓存映射这些以及LRU、FIFO等替换策略这些就不细说了

写命中:当处理器将操作数写换一个内存缓存的区域的时候,它会先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回缓存,而不是写回内存,这个操作被叫做写命中

写缺失:一个有效的缓存行被写到不存在的内存区域



2 volatile实现原理
实现原理
了解上面的概念之后,就可以试着理解 volatile 的实现原理了。我们来看一个例子:
public class TestLock {
  // 易变
   static volatile int run = 1;

   public static void main(String[] args) {
       run ++;
   }
}

然后使用汇编代码输出,我们截取其中的一段来看,其中最重要的就是这个lock指令:




我们对这个 lock 进行一个说明:
有 volatile 变量修饰的共享变量进行写操作的时候会多出 lock 开头的这行代码,lock 前缀的指令在多核处理器下会引发了两件事:

将当前处理器缓存行的数据写回系统内存这个写回内存的操作会使其他 CPU 里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,在这里说一下,处理器访问内存的效率顺序:CPU --> 缓存 --> 内存。处理器会优先把数据写入缓存行(L1,L2或者其他),然后再进行后续操作,但是操作完是不知道什么时候写入内存的,所以如果不做处理,当一个线程在修改完这个操作之后,其他线程继续从主存中读取数据是获取不到最新的数据的。

那么这时候我们加上了 volatile 这条指令,在对该变量进行了写操作的时候,JVM 就会向处理器发送一条 Lock 前缀的指令,就是上面截图那种,将这个变量所在缓存行的数据刷新回到系统内存。但是这时候又会有一个问题,就是如果这时候写回了内存,但是其他线程中的数据还是旧的,那还是会按照旧的数据来执行, 下面引申出缓存一致性协议,处理器基于这个协议来保证数据的最新

缓存一致性协议: 每个处理器通过嗅探在总线上传播的数据来检测自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存里读取数据到处理器的缓存中。所以从这里我们就可以发现了,数据的一致性是由 volatile 和 缓存一致性协议共同决定的。



下面再来说说 volatile 的两条实现原则(这部分不太懂,就直接按书上的来写了)

(1)Lock前缀指令会引起处理器缓存写回内存

Lock前缀指令导致在执行指令期间会声言处理器的 LOCK# 信号。在多处理器环境中,这个信号会保证在声言该信号期间,处理器可以独占任何共享内存。你可以理解为有 lock 前缀的指令,会导致共享内存被一个处理器独占。但是在最近的处理器里,LOCK# 信号一般不锁总线,而是锁缓存,这一步你可以理解为总线是数据指令等都要经过的。如果把总线锁了,那么其他的指令就在这个期间发送不出去,导致其他操作无法完成,而且其他 CPU 访问不了总线,那么也访问不了内存。

对于 Intel486 和 Pentium 处理器,在锁操作的时候是锁总线的对于 P6 和目前的处理器,如果访问的内存区域已经缓存在处理器内部,那么就不会声言 LOCK# 信号,而是会锁住这块区域的缓存并写回内存,并使用缓存一致性协议来确保修改的原子性,这个操作叫做 “缓存锁定” ,缓存一致性机制阻止同时修改两个以上处理器缓存的内存区域数据。其实锁住缓存也确保了防止其他线程来修改这个操作,同时保证了此时操作的原子性,也就是说不会被打断。就算时间片到了,但是在没有执行完这条指令的时候缓存行还是锁住的,不用担心被其他线程修改。


(2)一个处理器的缓存回写到内存会导致其他处理器的缓存行无效

IA - 32 处理器和 Intel64 处理器 使用 MESI 控制协议 去维护内部缓存和其他处理器缓存的一致性MESI 缓存一致性协议:多个 CPU 在主存中读取数据到各自的高速缓存上,当其中的某个 CPU 修改了缓存的数据,就会马上同步回主存,其他 CPU 通过总线嗅探机制来感知内存中数据的变化,同时使自己的缓存中的数据失效在多和处理器系统中进行操作的之后(多线程),这两种处理器使用嗅探技术来保证内部的缓存、系统缓存和其他处理器的缓存的数据在总线上保持一致。



2. volatile的使用优化

JDK7 有一个集合类 linkedTransferQueue,由并发编程大神Doug lea提出,这个类在使用 volatile 变量的时候,用了一种追加字节的方式来优化入队和出队的性能。下面这段代码使静态内部类 PaddedAtomicReference 中的代码

//队列中的头节点
private transient final PaddedAtomicReference head;

//队列中的尾节点
private transient final PaddedAtomicReference tail;

static final class PaddedAtomicReference extends AtomicReference {        // enough padding for 64bytes with 4byte refs
        Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
        PaddedAtomicReference(T r) {
       		 super(r); 
         }
    }
//省略其他代码

上面的代码做了一件事,在 linkedTransferQueue 这个类内部定义了一个静态内部类PaddedAtomicReference,这个类做了一件事,把数据填充到64字节,一个对象占用4个字节,这里定义了15个对象,刚好就是64个字节。



1 为什么追加到4字节可以提高并发效率

对于英特尔酷睿i7、酷睿、Atom 和 NetBurst,以及 Core Solo 和 Pentium M 处理器的 L1、L2 或者 L3 缓存的高速缓存行是64个字节宽度,不支持部分填充行。这意味着:

如果队列的头节点和尾节点都不足 64 个字节,那么处理器会把他们都读到同一个高速缓存行中,多处理器下每个处理器都会缓存相同的头尾结点比如当一个处理器尝试修改头节点的时候,会把整个缓存行锁定,但是问题就来了,头节点和尾节点都在一个缓存行中,但是此时尾节点也被锁定了,也就是说其他处理器不能访问自己高速缓存中的尾节点,但是队列最频繁的操作就是入队和出队,这时候相当于把两个操作都锁死了,多处理器情况下会严重影响队列的入队和出队操作。这时候使用填充的方式使得头节点和尾节点不要缓存在同一个缓存行,那么在进行入队的时候,出队等操作是不受影响的,头尾结点在修改的时候不会相互锁定。



2 什么情况不能追加到64字节

缓存行不是 64 字节宽的处理器。如 P6 系列和奔腾处理器,它们的 L1 和 L2高速缓存行是 32 个字节宽共享变量不会被频繁写。 我们知道,空间和时间是相对的,时间效率上去了但是空间相对的也会消耗得更多。另外使用追加字节得方式需要读取更多得字节到缓存行中,这本来就会消耗一定的性能,如果对于队列的写操作不是很频繁,那么没必要通过追加字节的方式来避免互相锁定。



三. volatile 的内存语义

上面讲解了 volatile 这个变量对可见性的实现原理,当一个变量声明为 volatile 之后,对这个变量的读/写会很特别。下面我们就讨论讨论读写这方面的内容

1. volatile 读写的特性

我们来看下面三个方法:

public class TestLock {
    // 易变
    static volatile long v1 = 0L;

    public static void set(long l){
        v1 = l;                     //单个volatile变量的写
    }

    public static void getAndIncrement(){
        v1 ++;                      //复合(多个)volatile变量的写
    }

    public static long get(){
        return v1;                  //单个volatile变量的读
    }
}

然后分别在多线程下测试:

//1.单个volatile变量的写,没有线程安全问题
 public static void main(String[] args) {
        new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                set(1);
            }
        }, "t1").start();

        new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                set(1);
            }
        }, "t2").start();

        System.out.println(v1); //1
    }


//2.复合(多个)volatile变量的写
//多线程下有问题,可以看到两个线程一共加了10000次,而现在结果才 2310
 public static void main(String[] args) {
        new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                getAndIncrement();
            }
        }, "t1").start();

        new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                getAndIncrement();
            }
        }, "t2").start();

        System.out.println(v1); //2310
    }

//3.单个volatile变量的读
//这个肯定没有线程安全问题的
public static void main(String[] args) {
        new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                get();
            }
        }, "t1").start();

        new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                get();
            }
        }, "t2").start();

        System.out.println(v1); //0
    }

对比上面三个方法,我们发现对于 volatile 的单个变量读和单个变量的写是没有线程安全问题的,而对于多个 volatile 变量的写是有问题的,我们可以把这三个方法理解为下面的三个方法:

public class TestLock {
    // 易变
    static volatile long v1 = 0L;

    public static void set(long l){
        v1 = l;                     //单个volatile变量的写
    }

    public static void getAndIncrement(){   //复合(多个)volatile变量的写
        //v1 ++;
        long temp = get();
        temp += 1L;
        set(temp);
    }

    public static long get(){
        return v1;                  //单个volatile变量的读
    }
}

我们把多个 volatile 变量的写转化成这个操作之后就容易理解为什么不能保证线程安全了,因为不是原子性的,也会有线程上下文切换的影响。



总结一下:

一个 volatile 变量的单个读/写,和一个普通变量的读/写的的效果是一样的锁的 happens-before 规则保证了释放锁和获取锁的两个线程之间的内存可见性,这意味着,对于一个 volatile 变量的读操作,总是能看到(任意线程)对这个volatile 变量最后的写入。就是说线程对 volatile 变量的写操作的结果会立刻被其他线程获取到,缓存一致 + 嗅探锁的语义决定了临界区的代码具有原子性。临界区就是被多个线程共享的操作。对于单个 volatile 变量的读/写具有原子性,但是对于多个 volatile 变量的写就不具有原子性了但是 volatile 配合锁一起使用,这时候是可以保证线程安全的

 public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

                for (int i = 0; i < 5000; i++) {
                    synchronized (TestLock.class) {
                    System.out.println("线程1++");
                    getAndIncrement();
                }
            }
        }, "t1");

        Thread t2 = new Thread(() -> {

                for (int i = 0; i < 5000; i++) {
                    synchronized (TestLock.class) {
                    System.out.println("线程2++");
                    getAndIncrement();
                    }

            }
        }, "t2");
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(v1); //10000
    }




2. volatile写-读建立的happens-before关系

上面是 volatile 变量自身的特性,下面我们再来看看 volatile 变量的 happens-before关系是如何保证可见性的。

从 JDK 5开始,volatile 变量的 写 - 读 可以实现线程之间的通信volatile 变量的 写 - 读 和锁的 释放-获取 有相同的内存效果
1. volatile 的写和锁的释放有相同的内存语义
2. volatile 的读和锁的获取有相同的内存语义

代码说明
我们通过以下的代码然后结合 happens-before规则来看看 volatile 的读写顺序是什么样的,下面的代码其实是介绍指令重排序的时候的代码,我们用happens-before来看看最终的执行顺序是什么样的
public class TestVolatile {

    int a = 0;

    volatile boolean flag = false;

    public void writer(){
        a = 1;                  //1
        flag = true;            //2
    }

    public void reader(){
        if(flag){               //3
            int i = a;          //4
            ...
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            writer();
        }, "t1");

        Thread t2 = new Thread(() -> {
            writer();
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}


上面线程 t1 首先执行了 writer() 方法,之后线程 t2 接着执行了 reader() 方法。我们使用 happens-before 来说明上面的 1,2,3,4的执行顺序。当然,这里我们不考虑指令重排序,在后面会介绍 volatile 对指令重排序的影响,也就是内存屏障,这里单独说明由happens-before决定的执行顺序的规则

由程序次序规则(从上往下执行,橙色表示)

    1 happens - before 23 happens - before 4

根据volatile的规则(写对读可见,绿色箭头)

    2 happens-before 3

根据happens-before的传递性(a - b, b - c,则 a - c,黑色箭头)

    1 happens - before 2,2 happens-before 3, 3 happens - before 4,结果是 1 happens-before 3(可省去),1 happens-before 4,



根据上面的图可以看出:

这里 t1 线程写一个 volatile 变量之后,t2 线程读取同一个 volatile 变量。t1 线程在写 volatile 变量之间之前所写入的共享变量,在 t2 读取了 t1 写的 volatile 变量之后将立刻对 t2 线程可见,意思就是线程 t2 在读取了 volatile 变量之后就可以读取到共享变量的值



3. volatile 写-读的内存语义

这里我们来讨论为什么有结论线程 t1 修改的共享变量在线程 t2 读取了 volatile 变量之后就对 t2 可见了


内存语义

当写入一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新回主存中


我们以上面的代码为例,线程 t1 首先执行 writer() 方法,随后线程 t2 执行 reader() 方法,初始的时候两个线程的本地内存中 flag=false,a=0,下面就来看看当线程 t1 进行了 volatile 变量的写之后,共享变量的状态是什么


可以看到,当线程 A 在写入了 flag 变量之后,由于使用了 volatile 修饰符,前面说过,会马上把本地内存 t1 中被修改的两个值刷新回主存,这时候本地内存 t1 和主内存中的变量的值是一致的。根据缓存一致性协议,此时我们知道,这时候 t2 会嗅探到变量已经被改变,然后把当前缓存行中的数据设置为无效状态,下次进行操作的时候再从主存中获取新的数据。


所以,volatile 读内存语义就是:

当读一个 volatile 变量的时候,JMM会把该线程对应的本地内存置为无效。线程接下来将从主存中读取共享变量。


最终的效果:当线程 t2 要读取 flag 的时候,由于本地内存已经失效了,所以必须从主存中再读取一次,结果就是线程 t2 内存中变量值也和主存中的进行了同步,如果我们把读写步骤连起来看,就像是线程 t1 和 线程 t2 通信,线程 t2 在读取了线程 t1 修改的 volatile 变量之后其他修改的共享变量也对 t2 可见,看起来就像是线程 t1 和 线程 t2 通过volatile变量进行了通信,但是实际上还是 JMM 内存模型的效果,图解如下:



代码

下面看这一段代码:我们首先定义变量 a 和 变量 flag,然后线程 t1 执行写方法,线程 t2 执行读方法,注意我们首先让线程 t2 进行读方法一秒,这是为了让 CPU 感知到我们需要频繁操作 a 和 flag 这两个变量,就加载进 t2 的高速缓存中,这时候再让 t1 去修改 a 的值和 flag 的值,这时候我们发现结果是 t1 修改的值对 t2 不可见:

public class TestVo {
    static int a = 0;
    static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            System.out.println("修改flag之前:a的值 ==> " + a);
            System.out.println("修改flag之前:flag的值 ==> " + flag);
            a = 1;
            flag = true;
            System.out.println("修改flag之后:a的值 ==> " + a);
            System.out.println("修改flag之后:flag的值 ==> " + flag);
        }, "t1");

        Thread t2 = new Thread(() -> {
            System.out.println("读取flag之前:线程t2在读取flag之前a的值是 ==> " + a);
            System.out.println("读取flag之前:线程t2的flag的值是 ==> " + flag);
            while(true){
                if(flag){
                    int i = a;
                    System.out.println("读取flag之后:线程t2在读取flag之后a的值是 ==> " + a);
                    System.out.println("读取flag之后:线程t2的flag的值是 ==> " + flag);
                    break;
                }
            }
        }, "t2");


        t2.start();
        Thread.sleep(1000);
        t1.start();

        t1.join();
        t2.join();

    }
}





我们把 flag 变量用 volatile 修饰之后再运行试试,很明显可以看到这时候 t2 再读取 t1 修改的变量就变得可见了,也输出了修改后的值。我们最关心的共享变量 a 的值在读取 volatile 变量之后也修改了。注意一点就是测试的时候不要在 while(true) 里面使用 System.out.println(),因为这个方法内部是由 synchronized 修饰的,synchronized这个修饰符也会帮我们刷新缓冲区,一旦使用了,获取到了就是 t1 写回的最新的主存的值了。



总结一下 volatile 的读和写的内存语义:

线程 t1 写一个 volatile 变量,实际上是线程 t1 向接下来将要读这个 volatile 变量的某个线程发出了消息,通知其他线程 t1 要修改这个变量了,线程 t2 读一个 volatile 变量,实际上下是线程 t2 接受了之前某个 volatile 变量之前对共享变量所作修改的消息线程 t1 写一个 volatile 变量,随后线程 t2 读这个变量,这个过程实际上是线程 t1 向线程 t2 发消息



4. volatile 内存语义的实现

说到内存语义的实现,就不得不说下重排序了,关于重排序是什么,我在这篇文章中:指令重排序 也详细说了,可以说这篇 volatile 的文章和 重排序的文章应该是一起的。


1. 对重排序的限制
编译器限制

在上面这篇文章中,没有说 volatile 对重排序的影响,就是为了在这里说明,重排序分为编译器重排序和处理器重排序,为了实现 volatile 语义,JMM 会分别对这两种重排序进行限制,下面介绍对编译器制定的重排序规则:

是否能重排序第二个操作
第一个操作普通读/写volatile 读volatile 写
普通读/写 NO
volatile 读NONONO
volatile 写NONO

第一个操作的意思就是第一步,第二个操作的意思就是第二步,比如最后一个表格,第一步执行了 volatile写,如果第二步又执行了 volatile写,则编译器不可以对这两步进行重排序。基于以上几点,下面是几点总结:

当第二个操作时 volatile 写时,不管第一个操作是什么都不会重排序,这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。这个规则可以理解为,为了确保之前对不是 volatile 共享变量的写操作可以被及时刷新回主存中,就规定了在第二次写 volatile 的时候可以把前面所修改的及时刷新回主存。当第一个操作时 volatile 读的时候,不管第二个操作是什么,都不可以进行重排序,这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。 这里尝试来理解一下:还是指令重排序 这篇文章中的依赖控制那里,对于有控制依赖的数据比如 if(flag) 这种,编译器和处理器会采取猜测(Speculation)的方法先计算出 if 里面的数据,这时候发生了重排序,导致了 if 里面的语句先执行,这时候就会有问题了,详细的可以看这篇文章。这条规则中如果我们把 if(flag) 中的 flag 定义为 volatile 类型,就不会发生这样的情况。当第一个操作时 volatile 写,第二个操作时 volatile 读的时候,不能重排序。



处理器限制

处理器限制涉及到了汇编代码,汇编代码我也不太懂,但是核心思想都是指定某个操作是不可被重排序的。下面是在百度上找的一张图片,有兴趣可以自己去了解了解。



2.内存屏障

为了实现这种对重排序的限制规则,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的 处理器重排序。对于编译器来说,发现一个最优步置来最小化插入屏障的总数几乎不可能,意思就是说没办法提前预判出哪个操作到哪个操作之间需要用什么屏障,以此来实现精确插入。为此 JMM 采取了保守策略,在理解策略之前首先得知道两个指令 Store-> 保存指令,Load -> 装载指令。

在每个 volatile 写操作的前面插入一个 StoreStore 屏障

理解:在写之前插入一个屏障可以防止上面的普通写操作和下面的 volatile 写进行了重排序导致普通写无法及时刷新 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障

理解:这里插入一个内存屏障可以防止上面的 volatile 写操作和下面的 volatile 读/写操作进行重排序 在每个 volatole 读操作的后面插入一个 LoadLoad 屏障

理解:这里可以防止下面的普通读操作和上面的 volatile 读操作进行指令重排序 在每个 volatole 读操作的后面插入一个 LoadStore 屏障

理解:这里可以防止下面的普通写操作和上面的 volatile 读操作进行指令重排序



3.内存屏障理解图示

其实上面的策略非常保守,相当于当作下面一定会有这种情况发生来处理,而不是去预判,这样的一个好处就是能够正确得到 volatile 的内存语义,在哪个处理器平台都可以。


volatile 写屏障

我们首先来看看 volatile 写插入内存屏障之后生成的指令序列示意图:

可以看出 StoreStore屏障确保了在 volatile 写之前,前面的所有的普通写操作已经对任意处理器可间了。这样就保证了上面所有的普通写在 volatile 写之前刷新到主内存。

而后面的 StoreLoad 屏障作用就是避免了 volatile 写和后面可能有的 volatile 读/写操作进行重排序。至于为什么一定要插入一个这么的指令,是因为编译器常常无法确认在 volatile 写操作之后是不是需要插入这么一个 StoreLoad 屏障来防止指令重排序,有可能直接就 return 了,所以为了保证能正确实现 volatile 的内存语义,JMM 采取了保守策略:在每个 volatile 写操作后面或者 在每个 volatile 读操作前面加入一个 StoreLoad 屏障

从整体执行的角度考虑,JMM 最终选择在每个 volatile 写操作后面加了一个 StoreLoad 的屏障。因为 volatile 写 - 读 内存语义的常见模式是:一个线程写 volatile 变量,然后多个线程读取同一个 voaltile 变量。当读线程的数量远远大于写线程时,选择在 volatile 写操作后面插入 StoreLoad 屏障带来的收益比读操作之前加要高,执行效率提升也明显。因为只需要加入少数的 StoreLoad 屏障就得到正确的结果。

从这里也可以看到,JMM 在实现上遵循的一个特点:先追求准确再追求效率



volatile 读屏障

下面再来看看在保守策略下的读屏障:

图中 LoadLoad 指令可以用来禁止处理器把上面的 volatile 读和下面的普通读重排序。 LoadStore 屏障用来禁止处理器把上面的 volatile 读和下面的普通写重排序。理解一下:

LoadLoad 指令用来防止那种前面进行了普通变量的写操作,但是还没有刷新回主存,如果这时候重排序了,普通读跑到 volatile 读上面了,那么读到的还是本地内存中的数据。为了确保能在 volatile 读之后把普通变量的写刷新回主存再进行普通读拿到真正的数据。LoadStore 用来防止那种 控制依赖的情况,比如 if 的情况,具体原因可以看指令重排序 这篇文章了解 依赖控制是什么。



4.编译器的优化

其实到这也能看出来了,上述的 volatile 读和 volatile 写的内存屏障插入比较保守。实际情况下,编译器在处理这些代码的时候,只要不改变 volatile 写 - 读的内存语义,是会根据具体情况省去不必要的屏障的

代码说明
public class VolatileBarrierExample {
    
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
    
    void readAndWrite(){
        int i = v1;     //第一个 volatile 读
        int j = v2;     //第二个 volatile 读
        a = i + j;      //普通写
        v1 = i + 1;     //第一个 volatile 写
        v2 = j * 2;     //第二个 volatile 写
    }
    
    ....其他方法
}

在这个方法中,对于 readAndWrite方法,编译器在生成字节码的时候可以做如下的优化:

注意一点就是最后要加一StoreLoad屏障,是因为第二个volatile写完之后方法就结束了,而此时编译器是无法确认还有没有volatile的读/写操作的,为了安全起见就加了这一层StoreLoad屏障。



不同处理器的优化
而对于不同的处理器的优化程度又不同

内存屏障的插入可以根据具体的处理器内存模型继续优化比如 X86 处理器优化后,除了最后的 StoreLoad 屏障,其他都会被省略掉,因为 X86 处理器是不会对读-读、读-写、写-写操作做重排序的,所以就不用考虑在这三种操作中加上内存屏障,只需要在 写-读加上内存屏障就可以了,而上面的代码中是没出现先写后读的情况的,所以对于 X86 处理器,JMM 只需要在这个方法最后加一层 Storeoad 屏障就可以正确实现 volatile 写-读 的内存语义了。这样意味着在 X86 处理器中,对于 volatile 写操作的开销比读操作开销要大,因为写操作要考虑 StoreLoad 内存屏障。



5. JSR-133 为什么要增强 volatile 的内存语义

其实这个问题在重排序中已经讨论过了。在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile变量之间进行指令重排序,但是是允许 voaltile 变量和普通变量进行重排序的。比如一段这样的代码:

class ReorderExample {
    int a = 0;
    volatile boolean flag = false;
    
    public void writer(){
        a = 1;          //1
        flag = true;    //2
    }

    public void reader(){
       if(flag){            //3
           int i = a * a;   //4
           ....
       }
    }
}

这段代码中如果没有限制 volatile 变量普通变量的重排序,那么就会出现结果不正确的情况,给出三种情况,假设线程 A 执行 writer(),然后线程B执行 reader() 方法:




可以看到三种不同的情况,其中受指令重排序影响的是第二种,因为指令重排序带来了第二种的效果,当然了初次之外这里设以来控制依赖,这也是一种重排序,就不多说了。我们再看回第一、二种情况,这里想说明的一点就是:线程 B 执行 4 的时候是不一定能看到线程 A 在执行时候对共享变量的修改的。当然,上面这三种情况包括了线程上下文切换的效果,但用正这里也是为了说明了重排序的一个影响。

所以,在旧的缓存模型中,vlatile 的写 - 读 没有锁的释放 - 获取 所具有的内存语义。为了提供一种比锁更轻便的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对于 volatile 变量和普通变量的重排序,确保 volatile 的写 - 读和锁的 释放 - 获取具有相同的内存语义

从编译器的重排序规则和处理器的内存屏障插入策略来看,这两种处理方法都是针对 volatile 和普通变量之间的重排序进行的限制,也确保了内存语义的正确。

而对比 volatile 和锁,其实区别就在单个和整体上。volatile 只是对于当个变量的读/写具有原子性,而锁是对于整个临界区的代码都具有原子性。试想一下,一块临界区的代码使用了 synchronized 修饰之后,除非线程释放了锁,否则就算发生了上下文切换,锁还是在这个线程手中,所以其他线程是不会执行到临界区的代码的,这就是为什么使用 synchronized 可以保证临界区的线程安全了。此外,synchronized 也使得变量具有可见性,使用 synchronized 后,会立刻从主存中读取最新的数据。但是在性能和可伸缩性上,volatile 更加有优势,具体场景具体分析。



四. 总结

对于 volatile 的介绍就到这了,《Java并发编程》这本书也提到了,过多使用 volatile 也会降低程序执行的效率。其实在一些设计模式中也会见到 volatile 这个关键字,比如单例模式就是一个很典型的例子,关于为什么单例模式中会用到,其实还是防止指令重排序造成返回的单例实例是空的影响,后面还会继续写文章来说明的。




如有错误,欢迎指出!!!

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/723359.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号