美文网首页闲碎多线程3星
Java并发 --- volatile关键字

Java并发 --- volatile关键字

作者: _code_x | 来源:发表于2021-06-19 21:34 被阅读0次

    写在前

    在并发编程中,最需要处理的就是线程之间的通信和线程间的同步问题,JMM中可见性、原子性、有序性也是这两个问题带来的。volatile 是java虚拟机提供的轻量级的同步机制

    在并发编程中,需要解决的两个问题:

    通信:在命令式编程中,线程之间的通信包括共享内存和消息传递 而 java并发采用的是共享内存模型,线程之间共享程序的公共状态,通过读写内存总的公共状态来隐式通信

    JMM关于同步的规定:

    1.线程解锁前,必须把共享变量的值刷回主内存
    2.线程加锁前,必须读取共享内存的最新值到自己的本地内存
    3.加锁解锁是同一把锁

    volatile关键字主要作用

    保证内存可见性

    • 我们已经知道Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都必须经过主内存的传递来完成。

    • 这样就会存在一个情况,工作内存值改变后到主内存更新一定是需要一定时间的,所以可能会出现多个线程操作同一个变量的时候出现取到的值还是未更新前的值。这样的情况我们通常称之为「可见性」,而我们加上 volatile 关键字修饰的变量就可以保证对所有线程的可见性。

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

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

    为什么 volatile 关键字可以有可见性?

    volatile是如何保证有序性呢?答案是内存屏障Memory Barrier

    Memory Barrier 可以保证内存可见性和特定操作的执行顺序

    volatile写操作之后都会插入一个store屏障,将工作内存中的值刷回到主内存,在读操作之前都会插入一个load屏障,从主内存读取最新的数据(可见性),而无论是stroe还是load都会告诉编译器和cpu,屏障前后的指令都不要进行重排序优化(禁止指令重排)

    • 这得益于 Java 语言的先行发生原则(happens-before)。简单地说,就是先执行的事件就应该先得到结果。但是! volatile 并不能保证并发下的安全。当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值

    • Java 里面的运算并非原子操作,比如 i++ 这样的代码,实际上,它包含了 3 个独立的操作:读取 i 的值,将值加 1,然后将计算结果返回给 i。这是一个「读取-修改-写入」的操作序列,并且其结果状态依赖于之前的状态,所以在多线程环境下存在问题。

      要解决自增操作在多线程下线程不安全的问题,可以选择使用 Java 提供的原子类,如 AtomicInteger 或者使用 synchronized 同步方法。

    原子性:在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量)才是原子操作。(变量之间的相互赋值不是原子操作,比如 y = x,实际上是先读取 x 的值,再把读取到的值赋值给 y 写入工作内存)

    禁止指令重排

    什么是数据依赖性?

    对同一数据的两个操作中只要有一个是写操作,那么就存在数据依赖性,比如写后写,读后写,写后读。

    • 指令重排:处理器为了提高程序效率,可能对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
    • 指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,却会影响到多线程的执行结果。

    比如下面的代码:

    boolean contextReady = false;
    //在线程A中执行:
    context = loadContext();    // 步骤 1
    contextReady = true;        // 步骤 2
    
    //在线程B中执行:
    while(!contextReady ){ 
       sleep(200);
    }
    doAfterContextReady (context);
    

    以上程序看似没有问题。线程 B 循环等待上下文 context 的加载,一旦 context 加载完成,contextReady == true 的时候,才执行 doAfterContextReady 方法。

    但是,如果线程 A 执行的代码发生了指令重排,也就是上面的步骤 1 和步骤 2 调换了顺序,那线程 B 就会直接跳出循环,直接执行 doAfterContextReady() 方法导致出错。而 volatile 采用「内存屏障」这样的 CPU 指令就解决这个问题,不让它指令重排。

    使用 volatile 变量进行写操作,汇编指令带有 lock 前缀,相当于一个内存屏障,后面的指令不能重排到内存屏障之前。使用 lock 前缀引发两件事:① 将当前处理器缓存行的数据写回系统内存。②使其他处理器的缓存无效。相当于对缓存变量做了一次 store 和 write 操作,让 volatile 变量的修改对其他处理器立即可见。

    使用场景

    从上面的总结来看,我们非常容易得出 volatile 的使用场景:

    1. 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    2. 变量不需要与其他的状态变量共同参与不变约束。

    比如下面的场景,就很适合使用 volatile 来控制并发,当 shutdown() 方法调用的时候,就能保证所有线程中执行的 work() 立即停下来。

    volatile boolean shutdownRequest;
    private void shutdown(){
        shutdownRequest = true;
    }
    private void work(){
        while (!shutdownRequest){
            // do something
        }
    }
    

    总结与补充

    • 对于 volatile 主要特性:保证可见性、禁止指令重排、解决 long 和 double 的 8 字节赋值问题。
    • 还有一个比较重要的是:它并不能保证并发安全(不能保证原子性),不要和 synchronized 混淆。

    可以创建Volatile数组吗?

    • Java 中可以创建 volatile类型数组,不过只是一个指向数组的引用,而不是整个数组。如果改变引用指向的数组,将会受到volatile 的保护,但是如果多个线程同时改变数组的元素,volatile标示符就不能起到之前的保护作用了。

    volatile能使得一个非原子操作变成原子操作吗?

    虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。

    • 一种实践是用 volatile 修饰 long 和 double 变量,使其能按原子类型来读写。double 和 long 都是64位宽,因此对这两种类型的读是分为两部分的,第一次读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,但 Java 中 volatile 型的 long 或 double 变量的读写是原子的。
    • volatile 修复符的另一个作用是提供内存屏障(memory barrier),例如在分布式框架中的应用。简单的说,就是当你写一个 volatile 变量之前,Java 内存模型会插入一个写屏障(write barrier),读一个 volatile 变量之前,会插入一个读屏障(read barrier)。意思就是说,在你写一个 volatile 域时,能保证任何线程都能看到你写的值,同时,在写之前,也能保证任何数值的更新对所有线程是可见的,因为内存屏障会将其他所有写的值更新到缓存。

    volatile和synchronized的区别与联系

    • 本质不同volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取,主要用于解决变量在多个线程之间的可见性问题;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住,只要解决多个线程访问资源的同步性

    • 作用域不同:volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的;

    • 是否原子性volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;volatile不保证原子性的原因:线程A修改了变量还没结束时,另外的线程B可以看到已修改的值,而且可以修改这个变量,而不用等待A释放锁,因为volatile 变量没上锁

    • 是否加锁(阻塞)volatile不会造成线程的阻塞(没有上锁);synchronized可能会造成线程的阻塞;

    • 是否指令重排:volatile标记的变量不会被编译器优化(即禁止指令重排);synchronized标记的变量可以被编译器优化。

    volatile可以保证线程安全?

    单纯使用 volatile 关键字是不能保证线程安全的!线程安全必须保证原子性,可见性,有序性。而volatile只能保证可见性和有序性!

    • volatile 只提供了一种弱的同步机制,用来确保将变量的更新操作通知到其他线程;
    • volatile 语义是禁用 CPU 缓存,直接从主内存读、写变量。语义表现为:更新(写) volatile 变量时,JMM 会把线程对应的本地内存中的共享变量值刷新到主内存中;读 volatile 变量时,JMM 会把线程对应的本地内存设置为无效,直接从主内存中读取共享变量;
    • 当把变量声明为 volatile 类型后,JVM 增加内存屏障,禁止 CPU 进行指令重排。

    volatile底层的实现机制?

    “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

    • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
    • 它会强制将对缓存的修改操作立即写入主存;
    • 如果是写操作,它会导致其他CPU中对应的缓存行无效。

    拓展:缓存一致性?

    • cpu和内存之间是有高速缓存的,一般分为多级。cpu首先是要从内存中读取一个数据进缓存,然后从缓存中读取进行操作,将结果返回给缓存,再把缓存写回内存。

    • 比如:如果同一个变量i=0,有两个线程执行i++方法,线程1把i从内存中读取进缓存,而现在线程2也把i读取进缓存,两个线程执行完i++后,线程1写回内存,i = 1,线程2也写回内存i = 1,两次++结果最终值为1,这就是著名的缓存一致性问题。为了解决这个问题,前人给了两种方案:

    巨人的肩膀

    https://blog.csdn.net/yuyecsdn/article/details/103454244
    https://www.jianshu.com/p/7ca933a716a9
    https://www.jianshu.com/p/be5e7c355d78
    https://www.javanav.com/interview/534e046986274288b71684704cb68162.html
    https://blog.csdn.net/qq_33330687/article/details/80990729

    相关文章

      网友评论

        本文标题:Java并发 --- volatile关键字

        本文链接:https://www.haomeiwen.com/subject/vvbdyltx.html