• Java并发编程-volatile


    一直以为多线程环境的同步只能通过这个来实现的,事实上Java还提供了另外一个更加轻量级的实现-volatile,如果说synchronized实现了数据在同一时刻只能有一个线程对数据访问的话,那么volatile实现的就是同时可以多个线程在访问数据,但是只要数据发生了变化,便确保其他线程及时“感知”这种变化。

    1、CPU、主存及高速缓存的概念

      计算机的硬件组成可以抽象为由总线、IO设备、主存、处理器(CPU)等组成。其中数据存放在主存中,CPU负责指令的执行,CPU的指令执行非常快,大部分简单指令的执行只需要一个时钟周期,而一次主内存数据的读取则需要几十到几百个时钟周期,那么CPU从主存中读写数据就会有很大的延迟。这个时候就产生了高速缓存的概念。

      也就是说,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据回写到主存当中,通过这种方式来降低CPU从主存中获取数据的延迟。大致的示意图如下:

    图一这个模型,可以简单的认为是单核模型,在这个模型里面,以i++这个操作为例,程序执行时,会先从主内存中获取i的值,复制到高速缓存,然后CPU从高速缓存中加载并执行+1操作,操作完成后回写到高速缓存,最后再从高速缓存回写到主内存。单核模型这样操作没有任何问题,但是计算机自产生以来,一直追求的两个目标,一个是如何做的更多,另一个就是如何计算得更快,这样带来的变化就是单核变成多核,高速缓存分级存储。大致的示意图如下:

    在图二示意图里面,i++这个操作就有问题了,因为多核CPU可以线程并行计算,在Core 0和Core 1中可以同时将i复制到各自缓存中,然后CPU各自进行计算,假设初始i为1,那么预期我们希望是2,但是实际由于两个CPU各自先后计算后最终主内存中的i可能是2,也可能是其他值。

      这个就是硬件内存架构中存在的一个问题,缓存一致性问题,就是说核1改变了变量i的值之后,核0是不知道的,存放的还是旧值,最终对这样的一个脏数据进行操作。

      为此,CPU的厂商定制了相关的规则来解决这样一个硬件问题,主要有如下方式:

      1)  总线加锁,其实很好理解总线锁,咱们来看图二,前面提到了变量会从主内存复制到高速缓存,计算完成后,会再回写到主内存,而高速缓存和主内存的交互是会经过总线的。既然变量在同一时刻不能被多个CPU同时操作,会带来脏数据,那么只要在总线上阻塞其他CPU,确保同一时刻只能有一个CPU对变量进行操作,后续的CPU读写操作就不会有脏数据。总线锁的缺点也很明显,有点类似将多核操作变成单核操作,所以效率低;

      2)  缓存锁,即缓存一致性协议,主要有MSI、MESI、MOSI等,这些协议的主要核心思想:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

      2、Java内存模型

      在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(C/C++等)直接使用物理硬件和操作系统的内存模型(可以理解为类似于直接使用了硬件标准),都或多或少的在不同的平台有着不一样的执行结果。 

      Java内存模型的主要目标是定义程序中各个变量的访问规则,即变量在内存中的存储和从内存中取出变量这样的底层细节。其规定了所有变量都存储在主内存,每个线程还有自己的工作内存,线程读写变量时需先复制到工作内存,执行完计算操作后再回写到主内存,每个线程还不能访问其他线程的工作内存。大致示意图如下:

    图三我们可以理解为和图二表达的是一个意思,工作内存可以看成是CPU高速缓存、寄存器的抽象,主内存可以看成就是物理硬件中主内存的抽象,图二这个模型会存在缓存一致性问题,图三同样也会存在缓存一致性问题。

      另外,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在Java内存模型中,还会存在指令重排序的问题。

      Java语言又是怎么来解决这两个问题的呢?就是通过volatile这个关键字来解决缓存一致性和指令重排问题,volatile作用就是确保可见性和禁止指令重排。

    3、volatile背后实现

      那么volatile又是怎样来确保的可见性和禁止指令重排呢?咱们先来写一段单例模式代码来看看。

     1 public class Singleton {
     2     private static volatile  Singleton instance;
     3 
     4     public static Singleton getInstance() {
     5         if (instance == null) {
     6             synchronized (Singleton.class) {
     7                 if (instance == null) {
     8                     instance = new Singleton();
     9                 }
    10             }
    11         }
    12         return instance;
    13     }
    14 
    15     public static void main(String[] args) {
    16         Singleton.getInstance();
    17     }
    18 }

    先看看字节码层面,JVM都做了什么。

    图四

    从图四可以看出,没有什么特别之处。既然在字节码层面我们看不出什么端倪,那下面就看看将代码转换为汇编指令能看出什么端倪。转换为汇编指令,可以通过-XX:+PrintAssembly来实现,window环境具体如何操作请参考此处(https://dropzone.nfshost.com/hsdis.xht)。不过比较可惜的是我虽然编译成功了hsdis-i386.dll(图五),放置在了JDK8下的多个bin目录,一致在报找不到这个dll文件所以我决定换个思路一窥究竟。

            图五

    这个思路就是去阅读openJDK的源代码。其实通过javap可以看到volatile字节码层面有个关键字ACC_VOLATILE,通过这个关键字定位到accessFlags.hpp文件,代码如下:

    bool is_volatile    () const         { return (_flags & JVM_ACC_VOLATILE    ) != 0; }

    再搜索关键字is_volatile,在bytecodeInterpreter.cpp可以看到如下代码:

     1 //
     2           // Now store the result
     3           //
     4           int field_offset = cache->f2_as_index();
     5           if (cache->is_volatile()) {
     6             if (tos_type == itos) {
     7               obj->release_int_field_put(field_offset, STACK_INT(-1));
     8             } else if (tos_type == atos) {
     9               VERIFY_OOP(STACK_OBJECT(-1));
    10               obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
    11               OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
    12             } else if (tos_type == btos) {
    13               obj->release_byte_field_put(field_offset, STACK_INT(-1));
    14             } else if (tos_type == ltos) {
    15               obj->release_long_field_put(field_offset, STACK_LONG(-1));
    16             } else if (tos_type == ctos) {
    17               obj->release_char_field_put(field_offset, STACK_INT(-1));
    18             } else if (tos_type == stos) {
    19               obj->release_short_field_put(field_offset, STACK_INT(-1));
    20             } else if (tos_type == ftos) {
    21               obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
    22             } else {
    23               obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
    24             }
    25             OrderAccess::storeload();
    26           }

    在这段代码中,会先判断tos_type,后面分别有不同的基础类型的实现,比如int就调用release_int_field_put,byte就调用release_byte_field_put等等。以int类型为例,继续搜索方法release_int_field_put,在oop.hpp可以看到如下代码:

    void release_int_field_put(int offset, jint contents);

    这段代码实际是内联oop.inline.hpp,具体的实现是这样的:

    inline void oopDesc::release_int_field_put(int offset, jint contents)       { OrderAccess::release_store(int_field_addr(offset), contents);  }

    其实看到这,可以看到上一篇文章很熟悉的oop.hpp和oop.inline.hpp,就是很熟悉的Java对象模型。继续看OrderAccess::release_store,可以在orderAccess.hpp找到对应的实现方法:

    static void     release_store(volatile jint*    p, jint    v);

    实际上这个方法的实现又有很多内联的针对不同的CPU有不同的实现的,在src/os_cpu目录下可以看到不同的实现,以orderAccess_linux_x86.inline.hpp为例,是这么实现的:

    inline void     OrderAccess::release_store(volatile jint*    p, jint    v) { *p = v; }

    可以看到其实Java的volatile操作,在JVM实现层面第一步是给予了C++的原语实现,接下来呢再看bytecodeInterpreter.cpp截取的代码,会再给予一个OrderAccess::storeload()操作,而这个操作执行的代码是这样的(orderAccess_linux_x86.inline.hpp):

    inline void OrderAccess::storeload()  { fence(); }

    fence方法代码如下:

    复制代码
    inline void OrderAccess::fence() {
      if (os::is_MP()) {
        // always use locked addl since mfence is sometimes expensive
    #ifdef AMD64
        __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
    #else
        __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
    #endif
      }
    }
    复制代码

    一样可以看到和通过-XX:+PrintAssembly来看到的背后实现:lock; addl,其实这个就是内存屏障,关于内存屏障的详细说明可以看下orderAccess.hpp的注释。内存屏障提供了3个功能:确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;强制将对缓存的修改操作立即写入主存;如果是写操作,它会导致其他CPU中对应的缓存行无效。这3个功能又是怎么做到的呢?来看下内存屏障的策略:

    在每个volatile写操作前面插入storestore屏障;

    在每个volatile写操作后面插入storeload屏障;

    在每个volatile读操作后面插入loadload屏障;

    在每个volatile读操作后面插入loadstore屏障;

    其中loadload和loadstore对应的是方法acquire,storestore对应的是方法release,storeload对应的是方法fence。

    4、volatile应用场景

     4.1 double check单例

     1 public class Singleton {
     2     private static volatile  Singleton instance;
     3     private Singleton() {};
     4     public static Singleton getInstance() {
     5         if (instance == null) {
     6             synchronized (Singleton.class) {
     7                 if (instance == null) {
     8                     instance = new Singleton();
     9                 }
    10             }
    11         }
    12         return instance;
    13     }
    14 }

    为什么要这样写,这个网上有很多资料,这里就不赘述了。

    4.2 java.util.concurrent

    大量的应用在j.u.c下的各个基础类和工具栏,构成Java并发包的基础。后续并发编程的学习就可以按照这个路线图来学习了。

    参考资料:

    https://github.com/lingjiango/ConcurrentProgramPractice

    https://stackoverflow.com/questions/4885570/what-does-volatile-mean-in-java

    https://stackoverflow.com/questions/106591/do-you-ever-use-the-volatile-keyword-in-java

    https://www.cnblogs.com/zhangj95/p/5647051.html

    http://download.oracle.com/otn-pub/jcp/memory_model-1.0-pfd-spec-oth-JSpec

    https://www.cs.umd.edu/~pugh/java/memoryModel/

  • 相关阅读:
    69.广搜练习:  最少转弯问题(TURN)
    51..分治算法练习:  4378 【Laoguo】循环比赛
    50.分治算法练习:  二分算法:  2703 奶牛代理商 XII
    [转载]双向广搜
    49.分治算法练习:  1497 取余运算
    48.贪心练习:  1621 混合牛奶
    47..贪心  失恋28天-追女孩篇
    46.贪心算法练习:  区间合并
    45.分支算法练习:  7622:求排列的逆序数
    44.分治算法练习:  一元三次方程求解
  • 原文地址:https://www.cnblogs.com/lgjava/p/11269610.html
Copyright © 2020-2023  润新知