美文网首页Java并发
Java并发编程——volatile原理

Java并发编程——volatile原理

作者: 小波同学 | 来源:发表于2021-12-01 01:10 被阅读0次

    前言

    Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

    在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

    当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

    而声明变量是 volatile 时,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

    一、volatile

    1.1、volatite特性

    • 可见性
      能够保证线程可见性,当一个线程修改共享变量时,能够保证对另外一个线程可见性。

    • 顺序性
      程序执行程序按照代码的先后顺序执行。

    • 防止指令重排序
      通过插入内存屏障在cpu层面防止乱序执行。
      有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。

    1.2、volatile可见性

    public class VolatileTest extends Thread {
    
        /**
         * volatile关键字底层通过 汇编 lock指令前缀 强制修改值,
         * 并立即刷新到主内存中,另外一个线程可以马上看到刷新的主内存数据
         */
        private static volatile boolean FLAG = true;
    
        @Override
        public void run() {
            while (FLAG){
                try {
                    TimeUnit.MILLISECONDS.sleep(300);
                    System.out.println("==== test volatile ====");
                } catch (InterruptedException ignore) { }
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            new VolatileTest().start();
            TimeUnit.SECONDS.sleep(1);
            FLAG = false;
        }
    }
    

    二、volatile 的定义

    Java 语言规范第三版对 volatile 的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排它锁单独获得这个变量。排它锁可以使用 synchronized 实现,但 Java 提供了 volatile,在某些情况下比锁更加方便。如果一个字段被声明成 volatile,Java 线程内存模型将确保所有线程看到这个变量的值是一致的。

    2.2、volatile 的实现原理

    在 Java 中我们可以直接使用 volatile 关键字,被 volatile 变量修饰的共享变量进行写操作的时候会多生成一行汇编代码,这行代码使用了 Lock 指令。Lock 指令在多核处理器下会引发两件事情:

    • 1、将当前处理器缓存行的数据写回到系统内存。
    • 2、这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

    为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完后不知道何时会写到内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但其他处理器的缓存还是旧值,为了保证各个处理器的缓存是一致的,每个处理器会通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读到处理器缓存里。

    2.3、volatile 性能

    volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

    2.4、volatile 的应用

    volatile 在多处理器开发中保证了共享变量的可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能立即读取到修改过后的值。

    三、原理分析

    3.1、CPU多核硬件架构剖析

    CPU的运行速度非常快,而对磁盘的读写IO速度却很慢,为了解决这个问题,有了内存的诞生;而CPU的速度与内存的读写速度之比仍然有着100 : 1的差距,为了解决这个问题,CPU又在内存与CPU之间建立了多级别缓存:寄存器、L1、L2、L3三级缓存。

    3.2、产生可见性的原因

    因为我们CPU读取主内存共享变量的数据时候,效率是非常低,所以对每个CPU设置对应的高速缓存 L1、L2、L3 缓存我们共享变量主内存中的副本。

    相当于每个CPU对应共享变量的副本,副本与副本之间可能会存在一个数据不一致性的问题。比如线程B修改的某个副本值,线程A的副本可能不可见,导致可见性问题。

    3.3、JMM内存模型

    Java内存模型定义的是一种抽象的概念,定义屏蔽java程序对不同的操作系统的内存访问差异。

    主内存:存放我们共享变量的数据

    工作内存:每个CPU对共享变量(主内存)的副本。堆+方法区

    3.4、JMM八大同步规范

    • 1、lock(锁定):作用于 主内存的变量,把一个变量标记为一条线程独占状态。

    • 2、unlock(解锁):作用于 主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

    • 3、read(读取):作用于 主内存的变量,把一个变量值从主内存传输到线程的 工作内存中,以便随后的load动作使用。

    • 4、load(载入):作用于 工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

    • 5、use(使用):作用于 工作内存的变量,把工作内存中的一个变量值传递给执行引擎。

    • 6、assign(赋值):作用于 工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。

    • 7、store(存储):作用于 工作内存的变量,把工作内存中的一个变量的值传送到 主内存中,以便随后的write的操作。

    • 8、write(写入):作用于 工作内存的变量,它把store操作从工作内存中的一个变量的值传送到 主内存的变量中。

    JMM对这八种指令的使用,制定了如下规则:

    • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write。
    • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存。
    • 不允许一个线程将没有assign的数据从工作内存同步回主内存。
    • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作。
    • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁。
    • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
    • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
    • 对一个变量进行unlock操作之前,必须把此变量同步回主内存。

    JMM对这八种操作规则和对volatile的一些特殊规则,就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。

    Happen-Before(先行发生规则)

    在常规的开发中,如果我们通过上述规则来分析一个并发程序是否安全,估计脑壳会很疼。因为更多时候,我们是分析一个并发程序是否安全,其实都依赖Happen-Before原则进行分析。Happen-Before被翻译成先行发生原则,意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

    Happen-Before的规则有以下几条
    • 程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。

    • 管程锁定规则(Monitor Lock Rule):一个Unlock的操作肯定先于下一次Lock的操作。这里必须是同一个锁。同理我们可以认为在synchronized同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。

    • volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作。

    • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

    • 线程中止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必须晚于线程中所有操作。

    • 线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生。

    • 对象中止规则(Finalizer Rule):一个对象的初始化方法先于一个方法执行Finalizer()方法。

    • 传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C。

    以上就是Happen-Before中的规则。通过这些条件的判定,仍然很难判断一个线程是否能安全执行,毕竟在我们的时候线程安全多数依赖于工具类的安全性来保证。想提高自己对线程是否安全的判断能力,必然需要理解所使用的框架或者工具的实现,并积累线程安全的经验。

    3.5、volatile汇编lock指令前缀

    • 1、将当前处理器缓存行数据立刻写入主内存中。
    • 2、写的操作会触发总线嗅探机制,同步更新主内存的值。

    3.5.1 通过Idea工具查看java汇编指令

      1. jdk安装包\jre\bin\server 放入 hsdis-amd64.dll
      1. idea 配置 VM options, 最后一个参数 xxxxx. 就是一个我们的需要查看汇编的class类
    -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*
    
      1. 查看结果 ,会发现在 volatile 关键字 修饰的变量,在写操作时,对应的汇编指令,都有一个lock指令前缀

    四、volatile的底层实现原理

    通过汇编lock前缀指令触发底层锁的机制,锁的机制两种:总线锁/MESI缓存一致性协议,主要帮助我们解决多个不同cpu之间缓存之间数据同步的问题。

    4.1、总线锁

    当一个cpu(线程)访问到我们主内存中的数据时候,往总线总发出一个Lock锁的信号,其他的线程不能够对该主内存做任何操作,变为阻塞状态。该模式,存在非常大的缺陷,就是将并行的程序,变为串行,没有真正发挥出cpu多核的好处。

    4.2、MESI协议

    • 1、M 修改 (Modified) 这行数据有效,数据被修改了,和主内存中的数据不一致,数据只存在于本Cache中。

    • 2、E 独享、互斥 (Exclusive) 这行数据有效,数据和主内存中的数据一致,数据只存在于本Cache中。

    • 3、S 共享 (Shared) 这行数据有效,数据和主内存中的数据一致,数据存在于很多Cache中。

    • 4、I 无效 (Invalid) 这行数据无效。

    E: 独享:

    当只有一个cpu线程的情况下,cpu副本数据与主内存数据如果,保持一致的情况下,则该cpu状态为E状态 独享。

    S: 共享:

    在多个cpu线程的情况了下,每个cpu副本之间数据如果保持一致的情况下,则当前cpu状态为S。

    M: 修改:

    如果当前cpu副本数据如果与主内存中的数据不一致的情况下,则当前cpu状态为M。

    I: 无效:

    总线嗅探机制发现 状态为m的情况下,则会将该cpu改为i状态 无效。

    如果状态是M的情况下,则使用嗅探机制通知其他的CPU工作内存副本状态为I无效状态,则刷新主内存数据到本地中,从而多核cpu数据的一致性。

    该cpu缓存主动获取主内存的数据同步更新。


    总线:维护解决cpu高速缓存副本数据之间一致性问题。

    五、volatile不能保证原子性原因

    public class VolatileTest extends Thread {
    
        private static volatile int count = 0;
    
        public static void add() {
            count++;
        }
    
        public static void main(String[] args) throws InterruptedException {
            ArrayList<Thread> threads = new ArrayList<>();
            for (int i= 0;i<100;i++){
                Thread test =  new Thread(() -> {
                    for (int k=0;k<1000;k++){
                        add();
                    }
                });
                threads.add(test);
                test.start();
            }
            threads.forEach(v -> {
                try {
                    v.join();
                } catch (InterruptedException ignore) { }
            });
            System.out.println("<><><><> count: "+ count);
        }
    }
    

    volatile为了能够保证数据的可见性,但是不能够保证原子性,及时的将工作内存的数据刷新主内存中,导致其他的工作内存的数据变为无效状态,其他工作内存做的count++操作等于就是无效丢失了,这是为什么我们加上Volatile count结果在小于100000以内。

    六、volatile存在的伪共享的问题

    CPU会以缓存行的形式读取主内存中数据,缓存行的大小为2的幂次数字节,一般的情况下是为64个字节。如果该变量共享到同一个缓存行,就会影响到整理性能。

    例如:线程1修改了long类型变量A,long类型定义变量占用8个字节,在由于缓存一致性协议,线程2的变量A副本会失效,线程2在读取主内存中的数据的时候,以缓存行的形式读取,无意间将主内存中的共享变量B也读取到内存中,而该主内存中的变量B没有发生变化。

    解决缓存行伪共享问题 ,使用缓存行填充方案避免伪共享。

    @sun.misc.Contended

    可以直接在类上加上该注解@sun.misc.Contended,启动的时候需要加上该参数-XX:-RestrictContended,该方案在JDK8有效,JDK12中被优化掉了。

    例如 ConcurrentHashMap中的CounterCell,就是使用了缓存行填充方案避免为共享


    七、JMM中的重排序及内存屏障

    public class ReorderThread {
        private static int a,b,x,y;
    
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            while (true) {
                i++;
                a = 0;
                b = 0;
                x = 0;
                y = 0;
    
                Thread thread1 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        a = 1;
                        x = b;
                    }
                });
                Thread thread2 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        b = 1;
                        y = a;
                    }
                });
                thread1.start();
                thread2.start();
                thread1.join();
                thread2.join();
                System.out.println("第" + i + "次(" + x + "," + y + ")");
                if (x == 0 & y == 0) {
                    break;
                }
            }
        }
    }
    

    当我们的CPU写入缓存的时候发现缓存区正在被其他cpu站有的情况下,为了能够提高CPU处理的性能可能将后面的读缓存命令优先执行。注意:不是随便重排序,需要遵循as-ifserial语义。

    as-ifserial:不管怎么重排序(编译器和处理器为了提高并行的效率)单线程程序执行结果不会发生改变的。也就是我们编译器与处理器不会对存在数据依赖的关系操作做重排序。

    CPU指令重排序优化的过程存在问题

    as-ifserial 单线程程序执行结果不会发生改变的,但是在多核多线程的情况下,指令逻辑无法分辨因果关系,可能会存在一个乱序中心问题,导致程序执行结果错误。

    如同上面图,所示会出现会有机会两个线程中,A线程执行顺序1逻辑,而B线程执行顺序2逻辑。

    7.1、内存屏障解决重排序

    处理器提供了两个内存屏蔽指令,解决以上存在的问题

    • 1.写内存屏障:在指令后插入Stroe Barrier,能够让写入缓存中的最新数据更新写入主内存中,让其他线程可见。这种强制写入主内存,这种现实调用CPU就不会因为性能的考虑对指令重排序。

    • 2.读内存屏障:在指令前插入load Barrier ,可以让告诉缓存中的数据失效,强制从新主内存加载数据强制读取主内存,让CPU缓存与主内存保持一致,避免缓存导致的一致性问题。

    7.2、手动插入内存屏障

    public class ReorderThread {
        private static int a,b,x,y;
    
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            while (true) {
                i++;
                a = 0;
                b = 0;
                x = 0;
                y = 0;
    
                Thread thread1 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        a = 1;
                        // 添加写屏障
                        ReorderThread.getUnsafe().storeFence();
                        x = b;
                    }
                });
                Thread thread2 = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        b = 1;
                        // 添加写屏障
                        ReorderThread.getUnsafe().storeFence();
                        y = a;
                    }
                });
                thread1.start();
                thread2.start();
                thread1.join();
                thread2.join();
                System.out.println("第" + i + "次(" + x + "," + y + ")");
                if (x == 0 & y == 0) {
                    break;
                }
            }
        }
    
        /**
         * 通过Unsafe 插入内存屏障
         * @return
         */
        public static Unsafe getUnsafe(){
            try {
                Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
                theUnsafe.setAccessible(true);
                return (Unsafe)theUnsafe.get(null);
            } catch (Exception e) {
                return null;
            }
    
        }
    }
    

    八、双重检验锁为什么需要加上volatile

    public class LazyDoubleCheckSingleton {
    
        public volatile static LazyDoubleCheckSingleton singleton = null;
    
        private LazyDoubleCheckSingleton(){
    
        }
    
        public static LazyDoubleCheckSingleton getInstance(){
            //先判断是否存在,不存在再加锁处理
            if(singleton == null){
                //在同一个时刻加了锁的那部分程序只有一个线程可以进入
                synchronized (LazyDoubleCheckSingleton.class){
                    if(singleton == null){
                        singleton = new LazyDoubleCheckSingleton();
                        //1、分配内存给这个对象
                        //2、初始化对象
                        //3、设置singleton指向刚分配的内存地址
                        //singleton利用volatile关键字防止指令重排序
                    }
                }
            }
            return singleton;
        }
    }
    

    注意:在声明public volatile static LazyDoubleCheckSingleton singleton = null;中 ,如果去掉volatile关键字,我们在new操作存在重排序的问题。

    getInstance() 获取对象过程精简为3步如下

      1. 分配对象的内存空间
      1. 调用构造函数初始化
      1. 将对象复制给变量

    如果没有volatile关键字修饰 singleton 变量,则有可能先执行将对象复制给变量,再执行调用构造函数初始化,导致另外一个线程获取到该对象不为空,但是该构造函数没有初始化的半初始化对象,会导致报错 。就是另外一个线程拿到的是一个不完整的对象。

    参考:
    https://www.liangzl.com/get-article-detail-231991.html

    https://www.cnblogs.com/zhengbin/p/5654805.html

    https://www.cnblogs.com/hlkawa/p/13320619.html

    https://blog.csdn.net/chihaihai/article/details/105229698

    https://www.cnblogs.com/null-qige/p/9481900.html

    相关文章

      网友评论

        本文标题:Java并发编程——volatile原理

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