Java内存模型

  衡量一个服务器性能的好坏高低,每秒事务处理数(Transactions Per Second,TPS)是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力又有非常密切的关系。

1、硬件内存模型

  在计算机硬件体系中,程序运行过程的临时数据是存放在主存(物理内存)中的,而运算、指令的执行是在CPU中。CPU执行速度很快,相对的从内存中读写数据就要慢的多,因此如果对数据的操作总是通过与内存交互,会大大降低指令的执行速度。因此在CPU里有了高速缓存。

  程序运行的时候,会将操作需要的数据从主存复制一份到CPU的高速缓存中,CPU执行运算的时候直接与高速缓存进行交互(读、写),运算完成后,再将高速缓存中的数据刷到主存中。整个流程在单线程中是没有问题的,但是在多线程中就会有问题。比如执行i=i+1;这行代码,先从内存读取i的值到高速缓存,运算结束后把i+1的值刷回内存。假设有N个线程同时执行这段代码,在它们读取的时候i都为原始值,每个线程对i+1写到内存后,i的值只增加了1,而不是N。这就是缓存一致性问题。为了解决这个问题,通常有以下两个方法:

  1)       通过总线加LOCK#锁

    CPU和其他部件通过总线进行通信,对总线加LOCK#锁会阻塞其他CPU对其他部件的访问,所以声言LOCK期间只有该CPU能够访问内存,就解决了缓存不一致问题。但是效率非常低下。

  2)       通过缓存一致性协议

  缓存一致性协议保证了每个缓存中使用的共享变量的副本是一致的,最出名的是Intel的MESI协议。核心思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

  处理器、高速缓存、主内存的交互关系如下图:

2、Java内存模型概述

  Java内存模型(Java Memory Model,JMM):Java虚拟机规范中定义的一种试图来屏蔽掉各种硬件之间和操作系统之间的访问差异的规则,以实现Java程序在各个平台下都能达到一致的内存访问效果。它定义了程序中变量(包括实例字段,静态字段和构成数组对象的元素)的访问规则,往大一点说是定义了程序执行的次序。

  Java并发采用的是共享内存模型,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。线程通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

  JMM将内存划分为主内存和工作内存:JMM规定所有的变量都存储在主内存中;每个线程创建时JVM都会为其创建一个工作内存,工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

  JMM抽象示意如下图所示:

  A、B线程间要实现通信,线程A需要将本地线程A中更新的共享变量刷新到主内存中去,线程B则需要到主内存中读取线程A更新后的共享变量。

 

3、重排序

  在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序会遵守数据的依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。重排序分三种类型,从Java代码到最终执行的指令,会依次经历这三种重排序:

  1)       编译器优化的重排序

编译器在不改变单线程程序语义的情况下,可以重新安排程序的执行顺序。

2)       指令并行的重排序

  现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性(后面的语句不依赖前面语句的执行结果),处理器可以改变语句对应机器指令的执行顺序。

3)       内存系统的重排序

    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

         1属于编译器重排序,2、3属于处理器重排序,指令重排序会干扰并发程序的执行。举个例子:

  看上面这段代码,线程1初始化一些配置,线程2拿到初始化的配置去做一些事情。假设有以下两种情况,暂时先不考虑可见性问题(线程1修改了initialized的值后,线程2是否能立即看到最新的值):

  • init()按照代码顺序① -> ②:程序正常执行。
  • 重排序后②在①前面执行:此时initialized为true,而initConfig()还未执行完成,线程2跳出循环执行workWithConfig(),workWithConfig()拿到未初始化或未完全初始化的配置信息进行操作,程序就会出现异常。称之为有序性问题。

  为了解决重排序引起的并发问题,JMM定义了一组规则。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

4、Volatile的特殊规则

  关于Volatile在Java并发编程之volatile关键字中有介绍。

5、long和double变量的特殊规则

  JMM要求lock、unlock、read、load、assign、use、store、write这8个操作都必须具有原子性,但对于64为的数据类型(long和double)具有非原子协定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32位操作进行。如果多个线程共享一个没有声明为volatile的long或double变量,可能会出现“半个变量”的情况。但是目前绝大多数商用虚拟机都64位数据的读写操作实现为原子操作,所以一般不用将他们区别对待。

6、并发编程的特征

  在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。针对这几个特征在Java并发编程之volatile关键字中大概介绍了一下。针对这些问题,在JMM中都提供一套解决方案。

  • 原子性问题:除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。
  • 工作内存与主内存同步延迟现象导致的可见性问题:可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
  • 指令重排导致的可见性问题和有序性问题:可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。

  除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

 

7、先行发生原则(happens-before)

  如果在开发过程中,所有的有序性都通过sychronized和volatile来保证,那么有的操作会很麻烦,幸运的是JMM中提供了先行发生原则。先行并发原则指Java内存模式中定义两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改内存中的共享变量的值、发送了消息、调用了方法等。

  • 程序次序规则:同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
  • 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。
  • 线程启动规则:Thread的start( )方法先行发生于这个线程的每一个操作。
  • 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过Thread.join( )方法结束、Thread.isAlive( )的返回值等手段检测线程的终止。 
  • 线程中断规则:对线程interrupt( )方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt( )方法检测线程是否中断
  • 对象终结规则:一个对象的初始化完成先行于发生它的finalize( )方法的开始。
  • 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

  一个操作“时间上的先发生”不代表这个操作先行发生;一个操作先行发生也不代表这个操作在时间上是先发生的(重排序的出现)。时间上的先后顺序对先行发生没有太大的关系,所以衡量并发安全问题的时候不要受到时间顺序的影响,一切以先行发生原则为准。

 

posted @ 2018-11-01 13:57  小劉同学  阅读(150)  评论(0编辑  收藏  举报