• Java并发volatile三大特性探究


    volatile是Java虚拟机提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级。

    volatile具有三大特性:

    • 保证可见性
    • 不保证原子性
    • 禁止指令重排序

    1. JMM(Java内存模型)

    Java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

    JMM决定一个线程对共享变量的写入何时对其他线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下:

    2. volatile保证可见性

    2.1 可见性

    通过前面对JMM的介绍,我们知道,各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后在写回到主内存中的。

    这就可能存在一个线程A修改了共享变量X的值,但是还未写入主内存时,另外一个线程B又对主内存中同一共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说是不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

    public class TestVolatile {
        boolean status = false;
    
        /**
         * 状态切换为true
         */
        public void changeStatus(){
            status = true;
        }
    
        /**
         * 若状态为true,则running。
         */
        public void run(){
            if(status){
                System.out.println("running....");
            }
        }
    }

    上面这个例子,在多线程环境里,假设线程A执行changeStatus()方法后,线程B运行run()方法,可以保证输出"running....."吗?

    答案是NO! 

    这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,先运行changeStatus方法,再执行run方法,自然是可以正确输出"running...."的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量status来说,线程A的修改,对于线程B来讲,是"不可见"的。也就是说,线程B此时可能无法观测到status已被修改为true。

    所谓可见性,是指当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。很显然,上述的例子中是没有办法做到内存可见性的。

    对于普通的共享变量来讲,比如我们上文中的status,线程A将其修改为true这个动作发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B缓存了status的初始值false,此时可能没有观测到status的值被修改了,所以就导致了上述的问题。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,有点炮打蚊子的意思。比较合理的方式其实就是volatile。

    volatile具备两种特性,第一就是保证共享变量对所有线程的可见性。将一个共享变量声明为volatile后,会有以下效应:

    • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;
    • 这个写会操作会导致其他线程中的缓存无效。

    上面的例子只需将status声明为volatile,即可保证在线程A将其修改为true时,线程B可以立刻得知

     volatile boolean status = false;

    2.2 缓存一致性

    为什么volatile修饰后,当主线程中某个值被更改,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术

    在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。

    为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。

    2.3 MESI

    当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。

    2.4 总线嗅探

    那么是如何发现数据是否失效呢?

    这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。

    2.5 总线风暴

    总线嗅探技术有哪些缺点?

    由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及syschonized都是需要根据实际场景的。

    3. volatile不保证原子性

    3.1 不保证原子性分析

    原子性即不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。

    为了测试volatile是否保证原子性,我们创建了20个线程,然后每个线程分别循环1000次,来调用number++的方法;最后通过 Thread.activeCount(),来感知20个线程是否执行完毕,这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程;最后在线程执行完毕后,我们查看number的值,假设volatile保证原子性的话,那么最后输出的值应该是:

    20 * 1000 = 20000

    完整代码如下所示:

    import java.util.concurrent.TimeUnit;
    
    /**
     * 假设是主物理内存
     */
    class MyData {
        /**
         * volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
         */
        volatile int number = 0;
    
        public void addTo60() {
            this.number = 60;
        }
    
        /**
         * 注意,此时number 前面是加了volatile修饰
         */
        public void addPlusPlus() {
            number ++;
        }
    }
    
    /**
     * 验证volatile的可见性
     * 1、 假设int number = 0, number变量之前没有添加volatile关键字修饰
     * 2、添加了volatile,可以解决可见性问题
     *
     * 验证volatile不保证原子性
     * 1、原子性指的是什么意思?
     */
    public class VolatileDemo {
    
        public static void main(String args []) {
    
            MyData myData = new MyData();
    
            // 创建10个线程,线程里面进行1000次循环
            for (int i = 0; i < 20; i++) {
                new Thread(() -> {
                    // 里面
                    for (int j = 0; j < 1000; j++) {
                        myData.addPlusPlus();
                    }
                }, String.valueOf(i)).start();
            }
    
            // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
            // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
            while(Thread.activeCount() > 2) {
                // yield表示不执行
                Thread.yield();
            }
    
            // 查看最终的值
            // 假设volatile保证原子性,那么输出的值应该为:  20 * 1000 = 20000
            System.out.println(Thread.currentThread().getName() + "	 finally number value: " + myData.number);
    
        }
    }

    最终结果我们会发现,number输出的值并没有20000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性:

    为什么会出现数值丢失?我们通过对addPlusPlus()这个方法的字节码文件进行分析:

    //源代码
    public void addPlusPlus() {
            number ++;
    }
    
    //转化后的字节码
    public void addPlusPlus();
        Code:
           0: aload_0
           1: dup
           2: getfield      #2    // Field n:I
           5: iconst_1
           6: iadd
           7: putfield      #2    // Field n:I
          10: return

    我们能够发现 n++这条命令,被拆分成了3个指令

    • 执行getfield 从主内存拿到原始n
    • 执行iadd 进行加1操作
    • 执行putfileld 把累加后的值写回主内存

    假设我们没有加 synchronized那么第一步就可能存在着,三个线程同时通过getfield命令,拿到主存中的 n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000。

    3.2 解决不保证原子性

    因此这也说明,在多线程环境下 number ++ 在多线程环境下是非线程安全的,解决的方法有哪些呢?

    1. 在方法上加入 synchronized

    public synchronized void addPlusPlus() {
        number ++;
    }

    引入synchronized关键字后,保证了该方法每次只能够一个线程进行访问和操作,最终输出的结果也就为20000。

    2. 上面的方法引入synchronized,虽然能够保证原子性,但是为了解决number++,而引入重量级的同步机制,不是最合理的方式。除了引用synchronized关键字外,还可以使用JUC下面的原子包装类,即上面例子中 int类型的number,可以使用AtomicInteger来代替。

    /**
      *  创建一个原子Integer包装类,默认为0
      */
    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic() {
       // 相当于 atomicInter ++
       atomicInteger.getAndIncrement();
    }

    4. volatile禁止指令重排序

    4.1 指令重排序

    计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,主要流程如下:

    源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

    重排序也需要遵守一定规则:

      1.重排序操作不会对存在数据依赖关系的操作进行重排序。

        比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

      2.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

        比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

    多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

    4.2 volatile禁止指令重排

    volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象。

    首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

    • 保证特定操作的顺序

    • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

    由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。

    其实现是在volatile修饰变量的写 和 读的时候,加入屏障,防止出现指令重排。

    volatile内存语义的实现——JMM对volatile的内存屏障插入策略:
    在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。
    在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障。

    volatile禁止指令重排序也有一些规则,如下:

    • 当第二个操作是voaltile写时,无论第一个操作是什么,都不能进行重排序
    • 当第一个操作是volatile读时,不管第二个操作是什么,都不能进行重排序
    • 当第一个操作是volatile写时,第二个操作是volatile读时,不能进行重排序
  • 相关阅读:
    MySQL数据库小结
    使用Python操作MySQL数据库
    MySQL索引原理
    MySQL性能分析之Explain
    201907 TIOBE 编程语言排行榜-Python坐稳第三
    MySQL索引的数据结构-B+树介绍
    MySQL多表查询综合练习答案
    MySQL记录操作
    MySQL多表查询
    javascript实现无缝上下滚动(转)
  • 原文地址:https://www.cnblogs.com/zjfjava/p/13352732.html
Copyright © 2020-2023  润新知