• Java高并发学习笔记(四):volatile关键字


    1 来源

    • 来源:《Java高并发编程详解 多线程与架构设计》,汪文君著
    • 章节:第十二、十三章

    本文是两章的笔记整理。

    2 CPU缓存

    2.1 缓存模型

    计算机中的所有运算操作都是由CPU完成的,CPU指令执行过程需要涉及数据读取和写入操作,但是CPU只能访问处于内存中的数据,而内存的速度和CPU的速度是远远不对等的,因此就出现了缓存模型,也就是在CPU和内存之间加入了缓存层。一般现代的CPU缓存层分为三级,分别叫L1缓存、L2缓存和L3缓存,简略图如下:

    在这里插入图片描述

    • L1缓存:三级缓存中访问速度最快,但是容量最小,另外L1缓存还被划分成了数据缓存(L1ddata首字母)和指令缓存(L1iinstruction首字母)
    • L2缓存:速度比L1慢,但是容量比L1大,在现代的多核CPU中,L2一般被单个核独占
    • L3缓存:三级缓存中速度最慢,但是容量最大,现代CPU中也有L3是多核共享的设计,比如zen3架构的设计

    在这里插入图片描述

    缓存的出现,是为了解决CPU直接访问内存效率低下的问题,CPU进行运算的时候,将需要的数据从主存复制一份到缓存中,因为缓存的访问速度快于内存,在计算的时候只需要读取缓存并将结果更新到缓存,运算结束再将结果刷新到主存,这样就大大提高了计算效率,整体交互图简略如下:

    在这里插入图片描述

    2.2 缓存一致性问题

    虽然缓存的出现,大大提高了吞吐能力,但是,也引入了一个新的问题,就是缓存不一致。比如,最简单的一个i++操作,需要将内存数据复制一份到缓存中,CPU读取缓存值并进行更新,先写入缓存,运算结束后再将缓存中新的刷新到内存,具体过程如下:

    • 读取内存中的i到缓存中
    • CPU读取缓存i中的值
    • i进行加1操作
    • 将结果写回缓存
    • 再将数据刷新到主存

    这样的i++操作在单线程不会出现问题,但在多线程中,因为每个线程都有自己的工作内存(也叫本地内存,是线程自己的缓存),变量i在多个线程的本地内存中都存在一个副本,如果有两个线程执行i++操作:

    • 假设两个线程为A、B,同时假设i初始值为0
    • 线程A从内存中读取i的值放入缓存中,此时i的值为0,线程B也同理,放入缓存中的值也是0
    • 两个线程同时进行自增操作,此时A、B线程的缓存中,i的值都是1
    • 两个线程将i写入主内存,相当于i被两次赋值为1
    • 最终结果是i的值为1

    这个就是典型的缓存不一致问题,主流的解决办法有:

    • 总线加锁
    • 缓存一致性协议

    2.2.1 总线加锁

    这是一种悲观的实现方式,具体来说,就是通过处理器发出lock指令,锁住总线,总线收到指令后,会阻塞其他处理器的请求,直到占用锁的处理器完成操作。特点是只有一个抢到总线锁的处理器运行,但是这种方式效率低下,一旦某个处理器获取到锁其他处理器只能阻塞等待,会影响多核处理器的性能。

    2.2.2 缓存一致性协议

    图示如下:

    在这里插入图片描述

    缓存一致性协议中最出名的就是MESI协议,MESI保证了每一个缓存中使用的共享变量的副本都是一致的。大致思想是,CPU操作缓存中的数据时,如果发现该变量是一个共享变量,操作如下:

    • 读取:不做其他处理,只是将缓存中数据读取到寄存器中
    • 写入:发出信号通知其他CPU将该变量的缓存行设置为无效状态(Invalid),其他CPU进行该变量的读取时需要到主存中再次获取

    具体来说,MESI中规定了缓存行使用4种状态标记:

    • MModified,被修改
    • EExclusive,独享的
    • SShared,共享的
    • IInvalid,无效的

    有关MESI详细的实现超出了本文的范围,想要详细了解可以参考此处此处

    3 JMM

    看完了CPU缓存再来看一下JMM,也就是Java内存模型,指定了JVM如何与计算机的主存进行工作,同时也决定了一个线程对共享变量的写入何时对其他线程可见,JMM定义了线程和主内存之间的抽象关系,具体如下:

    • 共享变量存储于主内存中,每个线程都可以访问
    • 每个线程都有私有的工作内存或者叫本地内存
    • 工作内存只存储该线程对共享变量的副本
    • 线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存
    • 工作内存和JMM内存模型一样也是一个抽象概念,其实并不存在,涵盖了缓存、寄存器、编译期优化以及硬件等

    简略图如下:

    在这里插入图片描述

    MESI类似,如果一个线程修改了共享变量,刷新到主内存后,其他线程读取工作内存的时候发现缓存失效,会从主内存再次读取到工作内存中。

    而下图表示了JVM与计算机硬件分配的关系:

    在这里插入图片描述

    4 并发编程的三个特性

    文章都看了大半了还没到volatile?别急别急,先来看看并发编程中的三个重要特性,这对正确理解volatile有很大的帮助。

    4.1 原子性

    原子性就是在一次或多次操作中:

    • 要么所有的操作全部都得到了执行,且不会受到任何因素的干扰而中断
    • 要么所有的操作都不执行

    一个典型的例子就是两个人转账,比如A向B转账1000元,那么这包含两个基本的操作:

    • A的账户扣除1000元
    • B的账户增加1000元

    这两个操作,要么都成功,要么都失败,也就是不能出现A账户扣除1000但是B账户金额不变的情况,也不能出现A账户金额不变B账户增加1000的情况。

    需要注意的是两个原子性操作结合在一起未必是原子性的,比如i++。本质上来说,i++涉及到了三个操作:

    • get i
    • i+1
    • set i

    这三个操作都是原子性的,但是组合在一起(i++)就不是原子性的。

    4.2 可见性

    另一个重要的特性是可见性,可见性是指,一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。

    一个简单的例子如下:

    public class Main {
        private int x = 0;
        private static final int MAX = 100000;
        public static void main(String[] args) throws InterruptedException {
            Main m = new Main();
            Thread thread0 = new Thread(()->{
                while(m.x < MAX) {
                    ++m.x;
                }
            });
    
            Thread thread1 = new Thread(()->{
                while(m.x < MAX){
                }
                System.out.println("finish");
            });
    
            thread1.start();
            TimeUnit.MILLISECONDS.sleep(1);
            thread0.start();
        }
    }
    

    线程thread1会一直运行,因为thread1x读入工作内存后,会一直判断工作内存中的值,由于thread0改变的是thread0工作内存的值,并没有对thread1可见,因此永远也不会输出finish,使用jstack也可以看到结果:

    在这里插入图片描述

    4.3 有序性

    有序性是指代码在执行过程中的先后顺序,由于JVM的优化,导致了代码的编写顺序未必是代码的运行顺序,比如下面的四条语句:

    int x = 10;
    int y = 0;
    x++;
    y = 20;
    

    有可能y=20x++前执行,这就是指令重排序。一般来说,处理器为了提高程序的效率,可能会对输入的代码指令做一定的优化,不会严格按照编写顺序去执行代码,但可以保证最终运算结果是编码时的期望结果,当然,重排序也有一定的规则,需要严格遵守指令之间的数据依赖关系,并不是可以任意重排序,比如:

    int x = 10;
    int y = 0;
    x++;
    y = x+1;
    

    y=x+1就不能先优于x++执行。

    在单线程下重排序不会导致预期值的改变,但在多线程下,如果有序性得不到保证,那么将可能出现很大的问题:

    private boolean initialized = false;
    private Context context;
    public Context load(){
        if(!initialized){
            context = loadContext();
            initialized = true;
        }
        return context;
    }
    

    如果发生了重排序,initialized=true排序到了context=loadContext()的前面,假设两个线程A、B同时访问,且loadContext()需要一定耗时,那么:

    • 线程A通过判断后,先设置布尔变量的值为true,再进行loadContext()操作
    • 线程B中由于布尔变量被设置为true,会直接返回一个未加载完成的context

    5 volatile

    好了终于到了volatile了,前面说了这么多,目的就是为了能彻底理解和明白volatile。这部分分为四个小节:

    • volatile的语义
    • 如何保证有序性以及可见性
    • 实现原理
    • 使用场景
    • synchronized区别

    先来介绍一下volatile的语义。

    5.1 语义

    volatile修饰的实例变量或者类变量具有两层语义:

    • 保证了不同线程之间对共享变量操作时的可见性
    • 禁止对指令进行重排序操作

    5.2 如何保证可见性以及有序性

    先说结论:

    • volatile能保证可见性
    • volatile能保证有序性
    • volatile不能保证原子性

    下面分别进行介绍。

    5.2.1 可见性

    Java中保证可见性有如下方式:

    • volatile:当一个变量被volatile修饰时,对共享资源的读操作会直接在主内存中进行(准确来说也会读取到工作内存中,但是如果其他线程进行了修改就必须从主内存重新读取),写操作是先修改工作内存,但是修改结束后立即刷新到主内存中
    • synchronizedsynchronized一样能保证可见性,能够保证同一时刻只有一个线程获取到锁,然后执行同步方法,并且确保锁释放之前,变量的修改被刷新到主内存中
    • 使用显式锁LockLocklock方法能保证同一时刻只有一个线程能够获取到锁然后执行同步方法,并且确保锁释放之前能够将对变量的修改刷新到主内存中

    具体来说,可以看一下之前的例子:

    public class Main {
        private int x = 0;
        private static final int MAX = 100000;
        public static void main(String[] args) throws InterruptedException {
            Main m = new Main();
            Thread thread0 = new Thread(()->{
                while(m.x < MAX) {
                    ++m.x;
                }
            });
    
            Thread thread1 = new Thread(()->{
                while(m.x < MAX){
                }
                System.out.println("finish");
            });
    
            thread1.start();
            TimeUnit.MILLISECONDS.sleep(1);
            thread0.start();
        }
    }
    

    上面说过这段代码会不断运行,一直没有输出,就是因为修改后的x对线程thread1不可见,如果在x的定义中加上了volatile,就不会出现没有输出的情况了,因为此时对x的修改是线程thread1可见的。

    5.2.2 有序性

    JMM中允许编译期和处理器对指令进行重排序,在多线程的情况下有可能会出现问题,为此,Java同样提供了三种机制去保证有序性:

    • volatile
    • synchronized
    • 显式锁Lock

    另外,关于有序性不得不提的就是Happens-before原则。Happends-before原则说的就是如果两个操作的执行次序无法从该原则推导出来,那么就无法保证有序性,JVM或处理器可以任意重排序。这么做的目的是为了尽可能提高程序的并行度,具体规则如下:

    • 程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生与编写在前面的操作之后
    • 锁定规则:如果一个锁处于锁定状态,则unlock操作要先行发生于对同一个锁的lock操作
    • volatile变量规则:对一个变量的写操作要早于对这个变量之后的读操作
    • 传递规则:如果操作A先于操作B,操作B先于操作C,那么操作A先于操作C
    • 线程启动规则:Thread对象的start()方法先行发生于对该线程的任何动作
    • 线程中断规则:对线程执行interrupt()方法肯定要优于捕获到中断信号,换句话说,如果收到了中断信号,那么在此之前必定调用了interrupt()
    • 线程终结规则:线程中所有操作都要先行发生于线程的终止检测,也就是逻辑单元的执行肯定要发生于线程终止之前
    • 对象终结规则:一个对象初始化的完成先行发生于finalize()之前

    对于volatile,会直接禁止对指令重排,但是对于volatile前后无依赖关系的指令可以随意重排,比如:

    int x = 0;
    int y = 1;
    //private volatile int z;
    z = 20;
    x++;
    y--;
    

    z=20之前,先定义x或先定义y并没有要求,只需要在执行z=20的时候,可以保证x=0,y=1即可,同理,x++y--具体先执行哪一个并没有要求,只需要保证两者执行在z=20之后即可。

    5.2.3 原子性

    Java中,所有对基本数据类型变量的读取赋值操作都是原子性的,对引用类型的变量读取和赋值也是原子性的,但是:

    • 将一个变量赋值给另一个变量的操作不是原子性的,因为涉及到了一个变量的读取以及一个变量的写入,两个原子性操作结合在一起就不是原子性操作
    • 多个原子性操作在一起就不是原子性操作,比如i++
    • JMM只保证基本读取和赋值的原子性操作,其他的均不保证,如果需要具备原子性,那么可以使用synchronizedLock,或者JUC包下的原子操作类

    也就是说,volatile并不能保证原子性,例子如下:

    public class Main {
        private volatile int x = 0;
        private static final CountDownLatch latch = new CountDownLatch(10);
    
        public void inc() {
            ++x;
        }
    
        public static void main(String[] args) throws InterruptedException {
            Main m = new Main();
            IntStream.range(0, 10).forEach(i -> {
                new Thread(() -> {
                    for (int j = 0; j < 1000; j++) {
                        m.inc();
                    }
                    latch.countDown();
                }).start();
            });
            latch.await();
            System.out.println(m.x);
        }
    }
    

    最后输出的x的值会少于10000,而且每次运行的结果也并不相同,至于原因,可以从两个线程A、B开始分析,图示如下:

    在这里插入图片描述

    • 0-t1:线程A将x读入工作内存,此时x=0
    • t1-t2:线程A时间片完,CPU调度线程B,线程B将x读入工作内存,此时x=0
    • t2-t3:线程B对工作内存中的x进行自增操作,并更新到工作内存中
    • t3-t4:线程B时间片完,CPU调度线程A,同理线程A对工作内存中的x自增
    • t4-t5:线程A将工作内存中的值写回主内存,此时主内存中的值为x=1
    • t5以后:线程A时间片完,CPU调度线程B,线程B也将自己的工作内存写回主内存,再次将主内存中的x赋值为1

    也就是说,多线程操作的话,会出现两次自增但是实际上只进行一次数值修改的操作。想要x的值变为10000也很简单,加上synchronized即可:

    new Thread(() -> {
        synchronized (m) {
            for (int j = 0; j < 1000; j++) {
                m.inc();
            }
        }
        latch.countDown();
    }).start();
    

    5.3 实现原理

    前面已经知道,volatile可以保证有序性以及可见性,那么,具体是如何操作的呢?

    答案就是一个lock;前缀,该前缀实际上相当于一个内存屏障,该内存屏障会为指令的执行提供如下几个保障:

    • 确保指令重排序时不会将其后面的代码排到内存屏障之前
    • 确保指令重排序时不会将其前面的代码排到内存屏障之后
    • 确保执行到内存屏障修饰的指令时前面的代码全部执行完成
    • 强制将线程工作内存中的值修改刷新到主存中
    • 如果是写操作,会导致其他线程工作内存中的缓存数据失效

    5.4 使用场景

    一个典型的使用场景是利用开关进行线程的关闭操作,例子如下:

    public class ThreadTest extends Thread{
        private volatile boolean started = true;
    
        @Override
        public void run() {
            while (started){
                
            }
        }
    
        public void shutdown(){
            this.started = false;
        }
    }
    

    如果布尔变量没有被volatile修饰,那么很可能新的布尔值刷新不到主内存中,导致线程不会结束。

    5.5 与synchronized的区别

    • 使用上的区别:volatile只能用于修饰实例变量或者类变量,但是不能用于修饰方法、方法参数、局部变量等,另外可以修饰的变量为null。但synchronized不能用于对变量的修饰,只能修饰方法或语句块,而且monitor对象不能为null
    • 对原子性的保证:volatile无法保证原子性,但是synchronized可以保证
    • 对可见性的保证:volatilesynchronized都能保证可见性,但是synchronized是借助于JVM指令monitor enter/monitor exit保证的,在monitor exit的时候所有共享资源都被刷新到主内存中,而volatile是通过lock;机器指令实现的,迫使其他线程工作内存失效,需要到主内存加载
    • 对有序性的保证:volatile能够禁止JVM以及处理器对其进行重排序,而synchronized保证的有序性是通过程序串行化执行换来的,并且在synchronized代码块中的代码也会发生指令重排的情况
    • 其他区别:volatile不会使线程陷入阻塞,但synchronized
  • 相关阅读:
    HOT Scene!
    Windows Live Beta ONLINE!
    Shanda EZ Mini
    HDR Lighting & Bloom Lighting
    将整张网页存成png图片
    What's NEW in C++/CLI Language
    [快讯] Visual Studio 2005和SQL Server 2005来了!
    [原创] 一劳永逸:关于C/C++中指针、数组与函数复合定义形式的直观解释
    使用模版列来控制在一个GridView中某些列有热连接,某些列无热连接
    七种武器——.NET工程师求职面试必杀技
  • 原文地址:https://www.cnblogs.com/6b7b5fc3/p/14774649.html
Copyright © 2020-2023  润新知