Java并发以内存模型(JMM)浅析

背景

         学习Java并发编程,JMM是绕不过的槛。在Java规范里面指出了JMM是一个比较开拓性的尝试,是一种试图定义一个一致的、跨平台的内存模型。JMM的最初目的,就是为了可以支多线程程序设计的,每一个线程能够是和其余线程在不一样的CPU核心上运行,或者对于多处理器的机器而言,该模型须要实现的就是使得每个线程就像运行在不一样的机器、不一样的CPU或者自己就不一样的线程上同样,这种状况实际上在项目开发中是常见的。简单来讲,就是为了屏蔽系统和硬件的差别,让一套代码在不一样平台下能到达相同的访问结果。           固然你要是想作高性能运算,这个仍是要和硬件直接打交道的,博主以前搞高性能计算,用的通常都是C/C++,更老的语言还有Fortran,不过如今并行计算也是有不少计算框架和协议的,如MPI协议、基于CPU计算的OpenMp,GPU计算的Cuda、OpenAcc等。JMM在设计之初也是有很多缺陷的,不事后续也逐渐完善起来,还有一个算不上缺陷的缺陷,就是有点难懂。html

什么是JMM

        JMM即为JAVA 内存模型(java memory model)。Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节的实现规则。它其实就是JVM内部的内存数据的访问规则,线程进行共享数据读写的一种规则,在JVM内部,多线程就是根据这个规则读写数据的。java

         注意,此处的变量与Java编程里面的变量有所不一样步,它只是包含了实例字段、静态字段和构成数组对象的元素,但不包含局部变量方法参数(局部变量和方法参数线程私有的,不会共享,固然不存在数据竞争问题,若是局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,可是reference引用自己在Java栈的局部变量表中,是线程私有的)。为了得到较高的执行效能,Java内存模型并无限制执行引发使用处理器的特定寄存器或者缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施。编程

JMM和JVM有什么区别

  • JVM: Java虚拟机模型 主要描述的是Java虚拟机内部的结构以及各个结构之间的关系,Java虚拟机在执行Java程序的过程当中,会把它管理的内存划分为几个不一样的数据区域,这些区域都有各自的用途、建立时间、销毁时间。
  • JMM:Java内存模型 主要规定了一些内存和线程之间的关系,简单的说就是描述java虚拟机如何与计算机内存(RAM)一块儿工做。

      JMM中的主内存、工做内存与jJVM中的Java堆、栈、方法区等并非同一个层次的内存划分,数组

JMM核心知识点

        Java线程之间的通讯由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入什么时候对另外一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:JMM规定了全部的变量都存储在主内存(Main Memory)中。每一个线程还有本身的工做内存(Working Memory),线程的工做内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的全部操做(读取、赋值等)都必须在工做内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工做内存的拷贝,可是因为它特殊的操做顺序性规定,因此看起来如同直接在主内存中读写访问通常)。不一样的线程之间也没法直接访问对方工做内存中的变量,线程之间值的传递都须要经过主内存来完成。缓存

JMM内存模型

图:JMM内存模型安全

        这上如能够看见java线程中工做内存是经过cache来和主内存交互的,这是由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,因此现代计算机系统都不得不加入一层或多层读写速度尽量接近处理器运算速度的高速缓存(cache)来做为内存与处理器之间的缓冲:将运算须要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中没这样处理器就无需等待缓慢的内存读写了。   多线程

  线程和线程之间想进行数据的交换通常大体要经历两大步骤:1.线程1把工做内存1中的更新过的共享变量刷新到主内存中去;2.线程2到主内存中去读取线程1刷新过的共享变量,而后copy一份到工做内存2中去。(固然具体实现没有这么简单,具体的操做步骤在下文细讲)并发

1. 三大特征

        Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来创建的,那咱们依次看一下这三个特征app

 1. 原子性

  • 定义:   一个或者多个操做不能被打断,要么所有执行完毕,要么不执行。在这点上有点相似于事务操做,要么所有执行成功,要么回退到执行该操做以前的状态。
  • 注意点:   通常来讲在java中基本类型数据的访问大都是原子操做,可是对于64位的变量如long 和double类型,在32位JVM中,分别处理高低32位,两个步骤就打破了原子性,这就致使了long、double类型的变量在32位虚拟机中是非原子操做,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。因此如今官方建议最好仍是使用64JVM,64JVM在安全上和性能上都有所提高。
  • 总结:  对于别的线程而言,他要么看到的是该线程尚未执行的状况,要么就是看到了线程执行后的状况,不会出现执行一半的场景,简言之,其余线程永远不会看到中间结果。
  • 解决方案
    • 锁机制锁具备排他性,也就是说它可以保证一个共享变量在任意一个时刻仅仅被一个线程访问,这就消除了竞争;
    • CAS(compare-and-swap)

2.可见性

      定义:可见性是指当多个线程访问同一个变量时,当一个线程修改了这个变量的值,其余线程可以当即得到修改的值。框架

      实现原理:JMM是经过将在工做内存中的变量修改后的值同步到主内存,在读取变量前须要从主内存获取最新值到工做内存中,这种只从主内存的获取值的方式来实现可见性的 。

      存在问题:多线程程序在可见性方面存在问题,这意味着某些线程可能会读到旧数据,即脏读。

      解决方案

    • volatile变量:volatile的特殊规则保证了volatile变量值修改后的新值会马上同步到主内存,因此每次获取的volatile变量都是主内存中最新的值,所以volatile保证了多线程之间的操做变量的可见性
    • synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工做内存中(即从主内存中读取最新值到线程私有的工做内存中),在同步方法/同步块结束时(Monitor Exit),会将工做内存中的变量值同步到主内存中去(即将线程私有的工做内存中的值写入到主内存进行同步)。
    • Lock接口的最经常使用的实现ReentrantLock(重入锁)来实现可见性:当咱们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即便用共享变量时会从主内存中刷新变量值到工做内存中(即从主内存中读取最新值到线程私有的工做内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工做内存中的变量值同步到主内存中去(即将线程私有的工做内存中的值写入到主内存进行同步)。
    • final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,而且在构造函数中并无把“this”的引用传递出去(“this”引用逃逸是很危险的,其余的线程极可能经过该引用访问到只“初始化一半”的对象),那么其余线程就能够看到final变量的值。

 3.有序性

        定义: 即程序执行的顺序按照代码的前后顺序执行。这个在单一线程中天然能够保证,可是多线程中就不必定能够保证。

       问题缘由: 首先处理器为了提升程序运行效率,可能会对目标代码进行重排序。重排序是对内存访问操做的一种优化,它能够在不影响单线程程序正确性的前提下进行必定的调整,进而提升程序的性能。其保证依据是处理器对涉及依赖关系的数据指令不会进行重排序,没有依赖关系的则可能进行重排序,即一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2以前执行。(PS:并行计算优化中最基本的一项就是去除数据的依赖关系,方法有不少。)可是在多线程中可能会对存在依赖的操做进行重排序,这可能会改变程序的执行结果。

       Java有两种编译器,一种是Javac静态编译器,将源文件编译为字节码,代码编译阶段运行;另外一种是动态编译JIT,会在运行时,动态的将字节码编译为本地机器码(目标代码),提升java程序运行速度。一般javac不会进行重排序,而JIT则极可能进行重排序

图:java编译

        总结:在本线程内观察,操做都是有序的;若是在一个线程中观察另一个线程,全部的操做都是无序的。这是由于在多线程中JMM的工做内存和主内存之间存在延迟,并且java会对一些指令进行从新排序。

        解决方案

    • volatile关键字自己经过加入内存屏障来禁止指令的重排序。
    • synchronized关键字经过一个变量在同一时间只容许有一个线程对其进行加锁的规则来实现。
    • happens-before 原则:java有一个内置的有序规则,无需加同步限制;若是目标代码能够从这个原则中推测出来顺序,那么将会对它们进行有序性保障;若是不能推导出来,换句话说不与这些要求相违背,那么就可能会被重排序,JVM不会对其有序性进行保障。

2.八种基本内存交互操做

      JMM定义了8种操做来完成主内存与工做内存的交互细节,虚拟机必须保证这8种操做的每个操做都是原子的,不可再分的。(对于double和long类型的变量来讲,load、store、read和write操做在某些平台上容许例外)

  • lock (锁定):做用于主内存的变量,把一个变量标识为线程独占状态
  • unlock (解锁):做用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能够被其余线程锁定
  • read (读取):做用于主内存变量,它把一个变量的值从主内存传输到线程的工做内存中,以便随后的load动做使用
  • load (载入):做用于工做内存的变量,它把read操做从主存中变量放入工做内存中
  • use (使用):做用于工做内存中的变量,它把工做内存中的变量传输给执行引擎,每当虚拟机遇到一个须要使用到变量的值,就会使用到这个指令
  • assign (赋值):做用于工做内存中的变量,它把一个从执行引擎中接受到的值放入工做内存的变量副本中
  • store (存储):做用于主内存中的变量,它把一个从工做内存中一个变量的值传送到主内存中,以便后续的write使用
  • write (写入):做用于主内存中的变量,它把store操做从工做内存中获得的变量的值放入主内存的变量中

      如今咱们模拟一下两个线程修改数据的操做流程。线程1 读取主内存中的值oldNum为1,线程2 读取主内存中的值oldNum,而后修改值为2,流程以下

       从上图能够看出,实际使用中在一种有可能,其余线程修改完值,线程的Cache尚未同步到主存中,每一个线程中的Cahe中的值副本不同,可能会形成"脏读"。缓存一致性协议,就是为了解决这样的问题还现,(在这以前还有总线锁机制,可是因为锁机制比较消耗性能,最终仍是被逐渐取代了)。它规定每一个线程中的Cache使用的共享变量副本是同样的,采用的是总线嗅探技术,流程大体以下

       当CPU写数据时,若是发现操做的变量式共享变量,它将通知其余CPU该变量的缓存行为无效,因此当其余CPU须要读取这个变量的时候,发现本身的缓存行为无效,那么就会从主存中从新获取。

       volatile 会在store时加上一个lock写完主内存后unlock,这样保证变量在回写主内存时保证变量不被别的变量修改,并且锁的粒度比较小,性能较好。

3.Volatile关键字

  做用

        保证了多线程操做下变量的可见性,即某个一个线程修改了被volatile修饰的变量的值,这个被修改变量的新值对其余线程来讲是当即可见的。

        线程池中的许多参数都是采用volatile来修饰的 如线程工厂threadFactory,拒绝策略handler,等到任务的超时时间keepAliveTime,keepAliveTime的开关allowCoreThreadTimeOut,核心池大小corePoolSize,最大线程数maximumPoolSize等。由于在线程池中有若干个线程,这些变量必需保持对全部线程的可见性,否则会引发线程池运行错误。

 缺点

        对任意单个volatile变量的读/写具备原子性,但相似于volatile++这种复合操做(自增操做是三个原子操做组合而成的复合操做)不具备原子性,缘由就是因为volatile会在store操做时加上lock,其他线程在执行store时,因为获取不到锁而阻塞,会致使当线程对值的修改失效。

原理

      底层实现主要是经过汇编的lock的前缀指令,他会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存,lock前缀指令实际上至关于一个内存屏障(也能够称为内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障以前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操做已经所有完成;
  2. 它会强制将对缓存的修改操做当即写入主存;
  3. 若是是写操做,它会致使其余CPU中对应的缓存行无效(MESI缓存一直性协议)。

总结

    JMM模型则是对于JVM对于内存访问的一种规范,多线程工做内存与主内存之间的交互原则进行了指示,他是独立于具体物理机器的一种内存存取模型。
对于多线程的数据安全问题,三个方面,原子性、可见性、有序性是三个相互协做的方面,不是说保障了任何一个就万事大吉了,另外也并不必定是全部的场景都须要所有都保障才可以线程安全。
参考资料 https://www.cnblogs.com/lewis0077/p/5143268.html 《java并发编程》
相关文章
相关标签/搜索