Java并发编程--volatile关键字

前言

volatile 关键字相信大家不陌生,但是可能很多人都没有怎么用到过,很多人会觉得这仅仅是面试中会问到的问题,其实不然,在并发编程中, volatile 是很常见的一个关键字。这里想跟大家分享一下我对 volatile 的理解和使用。

正文

在百科上关于 volatile 的解释是:

“volatile是一个类型修饰符(type specifier).volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。”

既然是有关指令的关键字,我们需要先了解一下编译器和处理器在指令上的优化:

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
在这里插入图片描述
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

那 volatile 为啥能解决这种优化重排序的问题呢?我们接下来看下 volatile 的特性:

volatile 的特性

当我们声明共享变量为 volatile 后,对这个变量的读 / 写将会很特别。理解 volatile 特性的一个好方法是:把对 volatile 变量的单个读 / 写,看成是使用同一个监视器锁对这些单个读 / 写操作做了同步。下面我们通过具体的示例来说明,请看下面的示例代码:

class VolatileFeaturesExample {
    volatile long vl = 0L;  // 使用 volatile 声明 64 位的 long 型变量 

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

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


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

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

class VolatileFeaturesExample {
    long vl = 0L;               // 64 位的 long 型普通变量 

    public synchronized void set(long l) {     // 对单个的普通 变量的写用同一个监视器同步 
        vl = l;
    }

    public void getAndIncrement () { // 普通方法调用 
        long temp = get();           // 调用已同步的读方法 
        temp += 1L;                  // 普通写操作 
        set(temp);                   // 调用已同步的写方法 
    }
    public synchronized long get() { 
    // 对单个的普通变量的读用同一个监视器同步 
        return vl;
    }
}

如上面示例程序所示,对一个 volatile 变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个监视器锁来同步,它们之间的执行效果相同。

监视器锁的 happens-before 规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入,这里就可以看出volatile的可见性

监视器锁的语义决定了临界区代码的执行具有原子性。这意味着即使是 64 位的 long 型和 double 型变量,只要它是 volatile 变量,对该变量的读写就将具有原子性如果是多个 volatile 操作或类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性

简而言之,volatile 变量自身具有下列特性:

可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

volatile 防止指令重排序

指令重排的目的是为了最大化的提高CPU利用率以及性能,CPU的乱序执行优化在单核时代并不影响正确性,但是在多核时代的多线程能够在不同的核心上实现真正的并行,一旦线程之间共享数据,就可能会出现一些不可预料的问题。
指令重排序必须要遵循的原则是,不影响代码执行的最终结果,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序(这里所说的数据依赖性仅仅是针对单个处理器中执行的指令和单个线程中执行的操作.)。
这个语义,实际上就是as-if-serial语义,不管怎么重排序,单线程程序的执行结果不会改变,编译器、处理器都必须遵守as-if-serial语义。

内存屏障

volatile 实现有序性、原子性及可见性从根本上是解决编译器的优化乱序和CPU的执行乱序的问题,我们可以使用内存屏障的机制来解决。

从CPU层面来了解一下什么是内存屏障

CPU的乱序执行,本质还是,由于在多CPU的机器上,每个CPU都存在cache,当一个特定数据第一次被特定一个CPU获取时,由于在该CPU缓存中不存在,就会从内存中去获取,被加载到CPU高速缓存中后就能从缓存中快速访问。当某个CPU进行写操作时,它必须确保其他的CPU已经将这个数据从他们的缓存中移除,这样才能让其他CPU安全的修改数据。显然,存在多个cache时,我们必须通过一个cache一致性协议来避免数据不一致的问题,而这个通讯的过程就可能导致乱序访问的问题,也就是运行时的内存乱序访问。
现在的CPU架构都提供了内存屏障功能,在x86的cpu中,实现了相应的内存屏障写屏障(store barrier)、读屏障(load barrier)和全屏障(Full Barrier),主要的作用是:
Ø 防止指令之间的重排序
Ø 保证数据的可见性

store barrier

store barrier称为写屏障,相当于storestore barrier, 强制所有在storestore内存屏障之前的所有执行,都要在该内存屏障之前执行,并发送缓存失效的信号。所有在storestore barrier指令之后的store指令,都必须在storestore barrier屏障之前的指令执行完后再被执行。也就是进制了写屏障前后的指令进行重排序,是的所有store barrier之前发生的内存更新都是可见的(这里的可见指的是修改值可见以及操作结果可见)。
在这里插入图片描述

load barrier

load barrier称为读屏障,相当于loadload barrier,强制所有在load barrier读屏障之后的load指令,都在load
barrier屏障之后执行。也就是进制对load barrier读屏障前后的load指令进行重排序, 配合store barrier,使得所有store barrier之前发生的内存更新,对load barrier之后的load操作是可见的。
在这里插入图片描述

Full barrier

full barrier成为全屏障,相当于storeload,是一个全能型的屏障,因为它同时具备前面两种屏障的效果。强制了所有在storeload barrier之前的store/load指令,都在该屏障之前被执行,所有在该屏障之后的的store/load指令,都在该屏障之后被执行。禁止对storeload屏障前后的指令进行重排序。
在这里插入图片描述

总结:内存屏障只是解决顺序一致性问题,不解决缓存一致性问题,缓存一致性是由cpu的缓存锁以及MESI协议来完成的。而缓存一致性协议只关心缓存一致性,不关心顺序一致性。

编译器层面如何解决指令重排序问题

在编译器层面,通过volatile关键字,取消编译器层面的缓存和重排序。保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。这就保证了编译时期的优化不会影响到实际代码逻辑顺序。

如果硬件架构本身已经保证了内存可见性,那么volatile就是一个空标记,不会插入相关语义的内存屏障。如果硬件架构本身不进行处理器重排序,有更强的重排序语义,那么volatile就是一个空标记,不会插入相关语义的内存屏障。

在JMM中把内存屏障指令分为4类,通过在不同的语义下使用不同的内存屏障来进制特定类型的处理器重排序,从而来保证内存的可见性;

LoadLoad Barriers, load1 ; LoadLoad; load2 , 确保load1数据的装载优先于load2及所有后续装载指令的装载;

StoreStore Barriers,store1; storestore;store2 , 确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储;

LoadStore Barries, load1;loadstore;store2, 确保load1数据装载优先于store2以及后续的存储指令刷新到内存;

StoreLoad Barries, store1; storeload;load2, 确保store1数据对其他处理器变得可见, 优先于load2及所有后续装载指令的装载;这条内存屏障指令是一个全能型的屏障,它同时具有其他3条屏障的效果。

JMM对volatile的实现

为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下面是 JMM 针对编译器制定的 volatile 重排序规则表:

|是否能重排序 | 	第二个操作-|--第一个操作	| 普通读 / 写	| volatile 读 |	volatile 写普通读 / 写	| 	 	NOvolatile 读 |	NO |	NO | NOvolatile 写	 |	NO |	NO |

举例来说,第二行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

从上表我们可以看出:

当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:

在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图:
在这里插入图片描述
上图中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM 在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写 - 读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。

从这里我们可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图:
在这里插入图片描述
上图中的 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:

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 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器常常会在这里插入一个 StoreLoad 屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 x86 处理器为例,上图中除最后的 StoreLoad 屏障外,其它的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 x86 处理器平台可以优化成:
在这里插入图片描述
由于x86 处理器仅会对写 - 读操作做重排序,而X86 不会对读 - 读,读 - 写和写 - 写操作做重排序,因此在 x86 处理器中会省略掉这三种操作类型对应的内存屏障。在 x86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写 - 读的内存语义。这意味着在 x86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 屏障开销会比较大)。

总结

由于 volatile 仅仅保证对单个 volatile 变量的读 / 写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果想在程序中用 volatile 代替监视器锁,请一定谨慎。

参考文献
JSR 133 (Java Memory Model) FAQ
JSR-133: Java Memory Model and Thread Specification
The JSR-133 Cookbook for Compiler Writers
Java 理论与实践: 正确使用 Volatile 变量
Java theory and practice: Fixing the Java Memory Model, Part 2
深入理解 Java 内存模型——volatile

By the way

有问题?可以给我留言或私聊
有收获?那就顺手点个赞呗~

当然,也可以到我的公众号下「6曦轩」,输入“学习”,即可领取一份
【Java工程师进阶架构师的视频教程】~
在这里插入图片描述
由于我咧,科班出身的程序员,php,Android以及硬件方面都做过,不过最后还是选择专注于做 Java,所以有啥问题可以到公众号提问讨论(技术情感倾诉都可以哈哈哈),看到的话会尽快回复,希望可以跟大家共同学习进步,关于服务端架构,Java 核心知识解析,职业生涯,面试总结等文章会不定期坚持推送输出,欢迎大家关注~~~

发布了29 篇原创文章 · 获赞 9 · 访问量 9962

猜你喜欢

转载自blog.csdn.net/weixin_42669785/article/details/89025058