美文网首页
深入解析volatile关键字

深入解析volatile关键字

作者: 干天慈雨 | 来源:发表于2021-07-07 16:13 被阅读0次

    1. 初步认识volatile

    下面这段代码,演示了一个使用了volatile和没有使用volatile关键字对变量更新的影响。

    public class VolatileDemo {
    
        public static void main(String[] args) throws InterruptedException {
            VolatileTest test = new VolatileTest();
            test.start();
            for (;;) {
                if (test.isFlag()) {
                    System.out.println("hi");
                }
            }
        }
    }
    class VolatileTest extends Thread {
        private /*volatile*/ boolean flag = false;
    
        public boolean isFlag() {
            return flag;
        }
    
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (Exception ex) {
                ex.printStackTrace();
            }
            flag = true;
            System.out.println("flag = " + flag);
        }
    }
    
    

           运行之后会发现,如果没加volatile关键字就不会输出 hi 这个结果,但是线程中明明改了flag变量的值,为什么主线程却访问不到呢?这里就要提到volatile在这段代码中所起的作用了。

    2. volatile的特性一:保证可见性

    volatile可以使得在多处理器环境下保证了共享变量的可见性,那么到底什么是可见性?
           一般在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那么这个时候读取到的这个变量的值应该是之前写入的那个值。 这本来是一件很正常的事件,但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现以下的情况:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性,为了实现跨线程写入的内存可见性,必须要使用一些机制来实现,而volatile就是这样的一种机制。

    2.1 volatile关键字是如何保证可见性的?

           我们在查看上述代码的汇编指令的时候,会发现,在修改带有volatile修饰的成员变量时,会多出一个lock指令。lock指令是一种控制指令,在多线程环境下,lock汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的效果。

    2.2 从JMM来看可见性

           Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
    JMM有以下规定:
           所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
           每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
    线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
    不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。


    本地内存和主内存的关系

    2.3 从硬件层面了解可见性的本质

           一台计算机最核心的组件是CPU、内存、以及I/O设备。在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常矛盾的点,就是这三者在处理速度的差异。CPU的处理速度非常快,内存次之,最后才是I/O设备比如磁盘。为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化:

    • 1.CPU 增加了高速缓存
    • 2.操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率
    • 3.编译器的指令优化,更合理的去利用好 CPU 的高速缓存然后每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程

    2.3.1 CPU高速缓存

           线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。


    CPU高速缓存

           上图中L1 高速缓存和 L2 高速缓存都只能被一个单独的 CPU 内核使用,L3 高速缓存可以被同一个插槽上的 CPU 内核共享,主存由全部插槽上的所有 CPU 核共享。CPU 读取数据时,先从 L1 中读取,如果没有命中,再到 L2、L3 中读取,假如这些高速缓存都没有命中,它就会到主存中找所需要的数据。通过高速缓存的存储交互很好的解决了处理器与内存的速
    度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。

    2.3.2 缓存一致性

           首先,有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法:
    1.总线锁
    总线锁,简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的。如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行。所以引入了缓存锁,它核心机制是基于缓存一致性协议来实现的。

    1. 缓存锁
      相比总线锁,缓存锁即降低了锁的力度。为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。最常见的就是 MESI 协议。

    2.3.3 MESI 协议

           目前主流缓存一致性协议为 MESI 写入失效协议,在 MESI 协议中,每个缓存行(Cache line)有 4 种状态,M、E、S 和 I(全名是 Modified、Exclusive、 Share、Invalid)代表使用缓存行所处的 4 种状态,可用 2 个 bit 表示。缓存行(Cache line)是缓存操作的基本单位,在 Intel 的 CPU 上一般是 64 字节。


    MESI 阐述原理

    MESI 协议是以缓存行的 4 种状态的首字母缩写来命名的。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于 M、E、S 和 I 这 4 种状态之一,各种状态含义如下:
    (1)M: 被修改(Modified)
    该缓存行的数据只在本 CPU 的私有 Cache 中进行了缓存,而其他 CPU 中没有,并且是被修改过(Dirty),即与主存中的数据不一致,且没有更新到内存中。该缓存行中的内存需要在未来的某个时间点(允许其他 CPU 读取请主存中相应内存之前)写回(Write Back)到主存。当被写回主存之后,该缓存行的状态会变成独享(Exclusive)状态。简单来说:处于 Modified 状态的缓存行数据,只有在本 CPU 中有缓存,且其数据与内存中的数据不一致,数据被修改过。
    (2)E: 独享的(Exclusive)
    该缓存行的数据只在本 CPU 的私有 Cache 中进行了缓存,而其他 CPU 中没有,缓存行的数据是未被修改过的(Clean),并且与主存中数据一致。该状态下的缓存行,如果在任何时刻被其 他 CPU 读取之后,其状态变成共享状态(Shared)。在本 CPU 修改了缓存行中数据后,该缓存行状态可以变成 Modified 状态。简单来说:处于 Exclusive 状态的缓存行数据,只有在本 CPU 中有缓存,且其数据与内存中一致,没有被修改过。
    (3)S: 共享的(Shared)
    该缓存行的数据可能在本 CPU 以及其他 CPU 的私有 Cache 中进行了缓存,并且各 CPU 私 有 Cache 中的数据与主存数据一致(Clean),当有一个 CPU 修改该缓存行,其他 CPU 中该缓存行将被作废,变成无效状态(Invalid)。简单的说:处于 Shared 状态的缓存行的数据在多个 CPU 中都有缓存,且与内存一致。
    (4)I: 无效的(Invalid)
    该缓存行是无效的,可能有其他 CPU 修改了该缓存行。
    CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状 态 CPU 只能从主存中读取数据
    CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写
    任意一个 CPU Core 的私有缓存行,与其他 CPU Core 私有缓存行的相容关系,如图所示:


    缓存行的相容关系

    2.3.4 缓存一致性小结

           当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
    每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
    由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值,会引发总线风暴,所以不要大量使用volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。

    3. volatile的特性二:禁止指令重排

    3.1 什么是指令重排?

    重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

    3.2 重排序的类型有哪些呢?源码到最终执行会经过哪些重排序呢?

    指令重排

           一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标,而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。
    JMM对底层尽量减少约束,使其能够发挥自身优势。
    因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。
    一般重排序可以分为如下三种:
    编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
    指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
    内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
    这里还得提一个概念,as-if-serial。
    不管怎么重排序,单线程下的执行结果不能被改变。
    编译器、runtime和处理器都必须遵守as-if-serial语义。

    3.3 volatile如何保证不会被执行重排序

           java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
    为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:


    volatile重排序规则表

    需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。

    3.3.1 volatile写

    volatile写

    3.3.2 volatile读

    volatile读

    4. volatile的特性三:不保证原子性

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

    public class VolatileAtomic {
    
        public static void main(String[] args) {
            MyTest myTest= new MyTest();
            for(int i = 1; i <= 20; i++) {
                new Thread(() -> {
                    for (int j = 1; j <= 1000; j++) {
                        myTest.addNum();
                    }
                }, String.valueOf(i)).start();
            }
            while(Thread.activeCount()>2){
                Thread.yield();
            }
            System.out.println(Thread.currentThread().getName()+"\t number= "+myTest.num);
        }
    }
    
    class MyTest {
        public volatile int num = 0;
        public void addNum() {
            num++;
        }
    }
    
    

    添加了volatile,最终结果应该为20000,然而结果小于等于20000,说明volatile不保证原子性。


    运行结果

    4.1 为什么volatile不保证原子性?

    是因为num++在多线程下是非线程安全的。
    num++方法编译成字节码后,分为以下三步运行的:

      1.从主存中复制 i 的值并复制到 CPU 的工作内存中。
      2.CPU 取工作内存中的值,然后执行 i++操作,完成后刷新到工作内存。
      3.将工作内存中的值更新到主存。
    

    原本线程1在自己的工作空间中将num改为1,写回主内存,主内存由于内存可见性,通知线程2 3,num=1;线程2通过变量的副本拷贝,将num拷贝并++,num=2;再次写入主内存通知线程3,num=2,线程3通过变量的副本拷贝,将num拷贝并++,num=3;
    然而 多线程竞争调度的原因,1号线程刚刚要写1的时候被挂起,2号线程将1写入主内存,此时应该通知其他线程,主内存的值更改为1,由于线程操作极快,还没有通知到其他线程,刚才被挂起的线程1 将num=1 又再次写入了主内存,主内存的值被覆盖,出现了丢失写值;

    4.2 如何解决原子性问题?

            这种问题可以使用synchronized 或者使用原子变量 来解决。原子变量通过调用unsafe类的cas方法实现了原子操作,由于CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致的问题。

    5. volatile的应用场景

    DCL(Double Check Lock双端检索机制)
           双端检索机制不一定安全,原因是有指令重排序的存在,加入volatile可以禁止指令重排。
    在某一个线程执行到第一次检测时,此时instance不为null,但是insatnce的引用对象可能没有初始化完成

    public class Singleton {
        public volatile static Singleton singleton;
    
        public static Singleton getInstance() {
            if (singleton == null) {
                synchronized (Singleton.class) {
                    // 若为空,才进行对象创建,防止对象重复创建
                    if (singleton == null) {
                        // 使用volatile防止重排序造成的异常
                        singleton = new Singleton();
                    }
                }
            }
            return singleton;
        }
    }
    

    在某一个线程执行第一次检测时,此时读取到的instance不为空,但是instance得到引用对象可能没有完成初始化。

      Instance = new Singleton();可以分为以下三步:
      Memory = allocate();//1.分配对象内存空间
      Instance(memory)//2.初始化对象
      Instance = memeory //3.设置初始化的对象指向刚分配的内存地址,此时instacne ! =null
    

    步骤2和步骤3不存在数据依赖关系,所以这种重排序是允许的

      Memory = allocate();//1.分配对象内存空间
      Instance = memeory //3.设置初始化的对象指向刚分配的内存地址,此时instacne ! =null
      Instance(memory)//2.初始化对象
    

    所以这个时候出现的问题为多个线程在这里获得单例对象,第一个访问者在instance = new Singleton();这一步骤时由于指令重排序,底层先给对象分配好了地址,此时不为空,这个时候其他线程访问,instacne不为空,但是得不到实例对象。

    6.小结

    volatile是java虚拟机提供的轻量级的同步机制,主要有以下几点:
    1.保证可见性
    2.禁止指令重排
    3.不保证原子性
    4.volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
    5.volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。

    相关文章

      网友评论

          本文标题:深入解析volatile关键字

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