5个案例和流程图让你从0到1搞懂volatile关键字

volatile

随着硬件的提升,机器的核心数从曾经的单核变为多核,为了提升机器的利用率,现在的并发编程变得越来越重要,成为工作中、面试中的重中之重,而为了能够更好的理解、使用并发编程,就应该构建出自己的Java并发编程知识体系。

本篇文章将围绕Java中的volatile关键字,深入浅出的描述原子性、可见性、有序性,volatile的作用、实现原理、使用场景以及涉及到的JMM、伪共享等问题

为了更好的描述volatile,我们先来聊聊它的前置知识:有序性、可见性、原子性

有序性

什么是有序性?

当我们使用高级语言和简单的语法进行编程时,最终还要将语言翻译为CPU认识的指令

由于干活的是CPU,为了加快CPU的利用率,会对我们流程控制的指令进行重排序

在Java内存模型中,指令重排序的规则需要满足先行先发生(happens-before)规则,比如说对一个线程的启动要先行发生于这个线程的其他操作,不能把启动线程的指令重排序到线程执行的任务后面

也就是说在Java内存模型中,指令重排序不会影响我们规定好的单线程执行流程,但在多线程的情况下不能预估各个线程的执行流程

为了更贴切的描述,看下面这一段代码

    static int a, b, x, y;

    public static void main(String[] args){
        long count = 0;
        while (true) {
            count++;
            a = 0;b = 0;x = 0;y = 0;
            Thread thread1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread thread2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            thread1.start();
            thread2.start();

            try {
                thread1.join();
                thread2.join();
            } catch (Exception e) {}

            if (x == 0 && y == 0) {
                break;
            }
        }
        //count=118960,x=0,y=0
        System.out.println("count=" + count + ",x=" + x + ",y=" + y);
    }

初始化 a,b,x,y四个变量都是0

以我们的思维认为执行顺序为

//线程1
a = 1;
x = b;

//线程2
b = 1;
y = a;

可是经过指令重排序后,可能出现的四种情况:

//线程1
//1           2           3           4     
a = 1;      a = 1;      x = b;      x = b;        
x = b;      x = b;      a = 1;      a = 1;  

//线程2
//1           2           3           4 
b = 1;      y = a;      b = 1;      y = a;
y = a;      b = 1;      y = a;      b = 1;

当出现第4种情况时,x与y都可能为0

那么如何可以保证有序性呢?

使用volatile修饰变量就可以保证有序性

为了提升CPU利用率,会对指令进行重排序,重排序只能保证单线程下流程的运行逻辑

在多线程下无法预估执行顺序,及不能保证有序性,如果想在多线程下保证有序性可以使用volatile,volatile会使用内存屏障禁止指令重排序来实现有序性

也可以直接加锁去保证同步执行

同时可以使用并发包中Unsafe类的内存屏障禁止重排序

//线程1
a = 1;
unsafe.fullFence();
x = b;

//线程2
b = 1;   
unsafe.fullFence();
y = a;

可见性

什么又是可见性呢?

在Java内存模型中,每个线程有一份自己的工作内存和主内存,读取数据时需要先从主内存拷贝到工作内存,修改数据时只在自己的工作内存中进行修改,如果多个线程同时操作某个数据,进行修改后未写回主内存,那么其他线程无法感知该数据变动

image-20230823223141451.png

比如下面这一段代码,创建的线程会一直循环,因为它感知不到其他线程对变量进行修改

    //nonVolatileNumber 是未被volatile修饰的
    new Thread(() -> {
        while (nonVolatileNumber == 0) {
    
        }
    }).start();
    
    TimeUnit.SECONDS.sleep(1);
    nonVolatileNumber = 100;

那么如何让该变量具有可见性呢?

对该变量使用volatile修饰即可保证可见性,也可以通过加锁synchronized的方式,加锁后相当于要重新读取主内存的数据

原子性

什么是原子性?

实际上就是一个或一组操作能否同时完成,如果不能,那他们就必须都失败,而不能一部分执行成功一部分执行失败

Java内存模型中的read、load(上图)指令的原子性由虚拟机实现

使用一个变量的自增来说,实际上需要先从主内存进行读取再修改最后写回主内存

那么volatile能否保证原子性呢?

我们使用两个线程对用volatile修饰的同一变量进行一万次自增

        private volatile int num = 0;
    
        public static void main(String[] args) throws InterruptedException {
            C_VolatileAndAtomic test = new C_VolatileAndAtomic();
            Thread t1 = new Thread(() -> {
                forAdd(test);
            });
    
            Thread t2 = new Thread(() -> {
                forAdd(test);
            });
    
            t1.start();
            t2.start();
    
            t1.join();
            t2.join();
    
            //13710
            System.out.println(test.num);
        }
    
        /**
         * 循环自增一万次
         *
         * @param test
         */
        private static void forAdd(C_VolatileAndAtomic test) {
            for (int i = 0; i < 10000; i++) {
                test.num++;
            }
        }

很可惜,结果并不是20000,说明volatile修饰的变量并不能保证其原子性

那么什么方式能保证原子性呢?

synchronized加锁的方式就可以保证原子性,因为拿到锁同一时间只有它能进行访问

使用原子类,底层使用CAS的方式也可以保证原子性,什么是CAS呢?我们后续文章再来叨叨

volatile原理

经过有序性、可见性、原子性的描述与测试,我们可以知道volatile能够保证有序性和可见性,但不能保证原子性的特点

那么volatile底层又是如何实现有序性与可见性的呢?

JVM会对使用volatile修饰的变量,加上volatile的访问标识,在字节码指令运行时会使用操作系统的内存屏障来禁止指令进行重排序

常用的万能内存屏障是storeload,store1 storeload load2 它禁止写的指令重排到屏障之下,禁止读的指令重排到屏障之上,也就是store写回内存对其他处理器可见(能够感知),后续load 读就从内存中读

volatile汇编指令实现实际上是lock前缀指令

lock前缀指令在单核下没啥影响,因为单核可以保证有序性、可见性以及原子性

lock前缀指令在多核下会在修改数据时,将其写回内存中,写回内存需要确保同时只有一个处理器操作,可以通过锁总线的方式,但其他处理器就不能访问

后续为了提升并发粒度,处理器支持锁缓存时(只锁住缓存行),并通过缓存一致性协议保证不能同时修改同一缓存行数据

写回内存后,通过嗅探技术让其他处理器感知数据变动,在后续使用前重新读取内存

伪共享问题

由于每次读取都是对一个缓存行操作,那么如果多线程频繁对同一缓存行的两个变量进行修改,会不会导致其他用到该缓存行的处理器总是需要重新进行读数据呢?

这其实就是所谓的伪共享问题

比如两个变量i1,i2都在同一个缓存行中,其中处理器1频繁对i1进行写,处理器2频繁对i2进行写,并且i1与i2都被volatile修饰,这也就导致i1被修改时,处理器2感知到缓存行变脏,于是要重新读内存获取最新缓存行,但这样的性能开销对处理器2对i2进行写操作没有任何意义

image-20230824210222603.png

解决伪共享问题的常用办法,就是在这两个字段之间增加足够多的字段,让它们不在同一缓存行上,这样也就会导致浪费空间

为了解决伪共享问题,JDK也提供了@sun.misc.Contended注解来帮我们填充字段

下面的代码,让两个线程循环10亿次去进行自增,当发生伪共享问题时总耗时三十多秒,未发生伪共享问题耗时才几秒

需要注意的是,使用@sun.misc.Contended注解时,需要携带JVM参数-XX:-RestrictContended

        @sun.misc.Contended
        private volatile int i1 = 0;
        @sun.misc.Contended
        private volatile int i2 = 0;
        
        public static void main(String[] args) throws InterruptedException {
            D_VolatileAndFalseSharding test = new D_VolatileAndFalseSharding();
            int count = 1_000_000_000;
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < count; i++) {
                    test.i1++;
                }
            });
    
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < count; i++) {
                    test.i2++;
                }
            });
    
            long start = System.currentTimeMillis();
    
            t1.start();
            t2.start();
    
            t1.join();
            t2.join();
    
            //31910 i1:1000000000 i2:1000000000
    
            //使用@sun.misc.Contended解决伪共享问题  需要携带JVM参数 -XX:-RestrictContended
            //5961 i1:1000000000 i2:1000000000
            System.out.println((System.currentTimeMillis() - start) + " i1:"+ test.i1 + " i2:"+ test.i2);
        }

volatile的使用场景

volatile通过内存屏障来禁止指令重排序,从而保证可见性与有序性

基于可见性的特点,volatile非常适合用在并发编程中的读场景,因为volatile保证可见性,配合不用加锁的读操作,开销非常小

比如:并发包中AQS队列的同步状态status会用volatile修饰

而写操作常常需要进行加锁的同步保证

基于有序性的特点,volatile可以在双重检测锁时,禁止创建对象指令重排序,从而避免其他线程获取到的对象还未初始化

创建对象可以分为三个步骤:

//1.分配内存
//2.初始化对象
//3.将对象指向分配的空间

由于2、3步骤都会依赖1步骤,所以1步骤不能重排序,而2、3步骤没有依赖关系,因此重排序可能会导致先将对象指向分配的空间,再去初始化

如果此时在双重检测锁中,有线程正好判断不为空去使用这个对象,但是此时还未初始化,就可能发生获取到的对象还未初始化的问题

因此正确的双重检测锁需要加上volatile禁止重排序

        private static volatile Singleton singleton;
    
        public static Singleton getSingleton(){
            if (Objects.isNull(singleton)){
                //有可能很多线程阻塞到拿锁,拿完锁再判断一次
                synchronized (Singleton.class){
                    if (Objects.isNull(singleton)){
                        singleton = new Singleton();
                    }
                }
            }
    
            return singleton;
        }

总结

本篇文章围绕volatile关键字深入浅出的描述有序性、可见性、原子性、JMM、volatile原理、使用场景、伪共享问题等等

为了提升CPU的利用率,会对指令进行重排序,重排序不影响单线程下的指向流程,但多线程下的执行流程不能预测

在Java内存模型中,每个线程都有自己的工作内存,读取数据需要从主内存读取,修改数据需要写回主内存;在并发编程中,当其他线程无法感知到变量被修改时还继续使用就可能出错

volatile通过内存屏障禁止指令重排序以达到满足有序性和可见性,但不能满足原子性

volatile底层汇编使用lock前缀指令实现,在多核下在修改数据时会锁总线将数据写回内存中,由于锁总线开销大,后续使用锁缓存行,同时使用缓存一致性协议保证同时只能一个处理器修改同一缓存行,通过嗅探技术让其他拥有该缓存行的处理器感知到缓存行变脏,后续重新读取

如果多线程频繁写操作的变量在同一缓存行,会出现伪共享问题,此时需要通过填充字段,让它们不处于同一缓存行

volatile基于可见性的特点,常在并发编程中实现无锁的读操作;基于有序性的特点,可以在双重检测锁中保证获取到的实例不是还没初始化的

最后(不要白嫖,一键三连求求拉~)

本篇文章被收入专栏 由点到线,由线到面,深入浅出构建Java并发编程知识体系,感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 gitee-StudyJavagithub-StudyJava 感兴趣的同学可以stat下持续关注喔~

案例地址:

Gitee-JavaConcurrentProgramming/src/main/java/A_volatile

Github-JavaConcurrentProgramming/src/main/java/A_volatile

有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

雷军:小米全新操作系统澎湃 OS 正式版已完成封包 国美 App 抽奖页面弹窗辱骂其创始人 美国政府限制向中国出口 NVIDIA H800 GPU 小米澎湃OS界面曝光 大神用 Scratch 手搓 RISC-V 模拟器,成功运行 Linux 内核 RustDesk 远程桌面 1.2.3 发布,增强 Wayland 支持 拔出罗技 USB 接收器后,Linux 内核竟然崩溃了 DHH 锐评“打包工具”:前端根本不需要构建 (No Build) JetBrains 推出 Writerside,创建技术文档的工具 Node.js 21 正式发布
{{o.name}}
{{m.name}}

猜你喜欢

转载自my.oschina.net/u/6903207/blog/10112325