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

  • 2019 年 10 月 3 日
  • 笔记

背景

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

什么是JMM

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

         注意,此处的变量与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内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的,那我们依次看一下这三个特征

 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并发编程》