自学内容网 自学内容网

Java系列-valitile

背景

        volatile这个关键字可以说是面试过程中出现频率最高的一个知识点了,面试官的问题也是五花八门,各种刁钻的角度。之前也是简单背过几道八股文,什么可见性,防止指令重拍等,但面试官一句:volatile原理是什么,其他的线程是如果判断栈内存的值不可信的。Java面试好卷啊,然后也是查漏补缺吧。

在整理资料的过程中也收集了几个常见的面试题

volatile关键字的作用是什么?

volatile能保证原子性吗?

i++为什么不能保证原子性?

之前32位机器上共享的long和double变量的为什么要用volatile? 现在64位机器上是否也要设置呢?

volatile是如何实现可见性的? 内存屏障。

volatile是如何实现有序性的? happens-before等

说下volatile的应用场景?


著作权归@pdai所有 原文链接:https://pdai.tech/md/Java/thread/Java-thread-x-key-volatile.html

        volatile关键字通常被比喻为”轻量级的synchronized’,与synchronized不同的是,它是一个变量修饰待,只能用来修饰变量,无法修饰方法及代码块等。        

        Java中有两种自带的同步机制:锁机制(如synchronized,Lock)和volatile变量。相对于synchronized为代表的锁机制,volatile则更加轻量,因为它不会涉及线程上下文的切换。它的作用有以下两点:

保证可见性

      正如前面所写,当一个变量被volatile修饰时,如果某个对这个变量进行写操作,该线程会将此共享变量更新到主内存中,其他线程也会从主内存中读取该变量。通过这样,从而保证该变量在各个线程中的可见性。

保证有序性

JVM为了优化性能,会进行指令重排。对于单线程而言,即使有指令重排,也一定可以保证结果的正确性。可以在多线程下,指令重排就可能影响最终的结果。通过禁止指令重排来保证多线程下的有序性。

原理

保证可见性和有序性 

        在多线程环境下,一个线程对共享变量的操作,其他线程是不可见的,往往就会导致线程安全的问题。

        Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,需要从主内存中获取。当一个线程修改共享变量后,共享变量会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和LocK能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

        跟锁机制来保证可见性不同的是,volatile是基于内存屏障(Memory Barrier)实现可见性的。

        在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序。插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。

通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:

  0x000000000295158c: lock cmpxchg %rdi,(%rdx)  //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令

被volatile修改的变量进行写操作时会多出来lock 前缀指令。对于操作系统和CPU处理器遇到lock指令会做两件事:

  • 将当前处理器(CPU)缓存(寄存器)的数据写回到主内存。

  • 使在其他处理器里缓存了该内存地址的数据无效。

        通过lock指令保证了内存一致性,从而实现了多线程下的共享变量的可见性。这里需要补充一下关于内存的知识。思考:Java内存模型和硬件内存模型-CSDN博客

        此外内存屏障也能保证有序性。(一般都是说是happens-before 关系,其实本质还是内存屏障)。当Java在指令重排序时,不会把后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;也即是,在执行到内存屏障这个指令时,在他前面的操作已经完成。

具体来说,寄存器和内存之间数据数据交互有两个指令

Load

用于把内存中的数据装载到寄存器中。--- 读操作(对内存而言)

Store

用于把寄存器中的数据存入内存。 --- 写操作(对内存而言)

通过这个两个指令,Java可以实现了四种内存屏障,完成一系列的屏障和数据同步功能

LoadLoad

Load1; LoadLoad; Load2

在Load2及后续读操作执行前,保证Load1要读取完毕。

StoreStore

Store1; StoreStore; Store2,

在Store2及后续写操作执行前,保证Store1的写操作对其它处理器可见。

LoadStore

Load1; LoadStore; Store2,

在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

StoreLoad

Store1; StoreLoad; Load2,

 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

举个例子,如下图所示,在store1指令和store5指令之间加上StoreStore屏障。通过StoreStore屏障将指令分成了区域1和区域2,。无论指令如何重排序,Store1总是会先于store5执行。StoreLoad同理。

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

保证单次读写的原子性

这里涉及到了两个面试题

问题一:i++为什么不能保证原子性?

对于编译器和操作系统而言,i++是读,写两次操作。并不是单次读写。包含三步骤

1. 从内存中读取i的值

2. 对i加1

3. 将i的值写回内存

当并发量大的情况下,多个线程同时进行i++的时候,会出现同时从内存中读取到同一个值,然后进行运算,从而出现不符合预期的结果。所以volatile是无法保证复合操作的原子性,可以通过使用线程安全类或者加锁的方式来保证+1操作的原子性。

问题二:之前32位机器上共享的long和double变量的为什么要用volatile? 现在64位机器上是否也要设置呢?

long和double是64位的,在32位的jdk中完成write操作是需要两次操作的(每次执行32位)。也就是long和double的write操作是非原子性的。非原子的操作在多线程环境下会有线程安全问题。

比如A,B两个线程同时的去修改long类型x的值,可能x的高32位是A设置的,低32位是B设置的,导致结果不是程序想要的。因此,将共享long和double变量设置为volatile类型能保证任何情况下单次读/写操作的原子性。

但是最新JDK实现还是实现了原子操作的。

应用场景

根据volatile的原理和作用,找了以下几个应用场景

场景1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

场景2:开销较低的读-写锁策略

volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}

场景3:双重检查(double-checked)

就是我们上文举的例子。

单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。

class Singleton {
    private volatile static Singleton instance;
    private Singleton() {
    }
    public static Singleton getInstance() {
        if (instance == null) {
            syschronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}

资料

https://pdai.tech/md/Java/thread/Java-thread-x-key-volatile.html

Java volatile关键字最全总结:原理剖析与实例讲解(简单易懂)-CSDN博客

https://zhuanlan.zhihu.com/p/35386457

https://zhuanlan.zhihu.com/p/43526907

https://pdai.tech/md/Java/jvm/Java-jvm-struct.html

https://juejin.cn/post/7125709539596304420

https://www.jianshu.com/p/ef8de88b1343

为何在volatile写 之前加storestore内存屏障即可,不需要 loadstore么?_jvm volatile写操作前为什么不插入loadstore屏障-CSDN博客


原文地址:https://blog.csdn.net/weixin_42754905/article/details/140309499

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!