Java并发学习(二)-JMM

来源:互联网 发布:君知故乡来楷书图 编辑:程序博客网 时间:2024/06/15 03:44

JMM,全称是Java Memory Modle,即Java内存模型。
这些天在学习JMM,这里就记下来和大家一起学习。

JMM?

下面看看为什么会有JMM?

cpu高速缓存带来不一致问题

其实我感觉对于初学者而言,基本都不会接触到JMM,我自己当初也一样,别人问我JMM我都不知道是什么。那么为什么会出现JMM呢?
众所周知,现在电脑基本都是多任务多处理器的了,并且指令都是放入主存里面,而执行则是在cpu里面的 ,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。
在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。
这样一来,试想,如果两个线程同时执行一段程序,会出现数据不一致么?当然的。
这样就出现了缓存数据不一致的情况,所以出现了两种同步的方法:
为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  • 1)通过在总线加LOCK#锁的方式
  • 2)通过缓存一致性协议

第一种方式,是在总线上加上lock锁,也就是一个线程占有cpu时,会把总线锁住,防止其他线程进入,这样带来的问题就是:在锁住总线期间,其他CPU无法访问内存,导致效率低下。

所以这里看第二种方式,用协议的方式进行,它的核心思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

JMM提供了什么?

在开始学Java的时候,都知道Java是一种跨平台的语言,JVM帮我们屏蔽了底层平台带来的不一致性,而让我们把精力几种在程序的思想上,这也是Java语言吸引人的一方面。同样对于操作cpu和内存的方面,Java提供JMM,也帮我们屏蔽了CPU以及OS内存的使用问题,能够使程序在不同的CPU和OS内存上都能够达到预期的效果。
另一方面,JMM也给我们提供了内存可见性的一些保证,当进行并发编程时,能够通过JMM一些规则知道哪些变量对于当前线程是否可见。
下面我就主要从happens-before规则,重排序,和as-if-serial语言,以及volatile和final关键字进行分析。

as-if-serial

什么是as-if-serial语义呢?
as-if-serial语义是针对单线程条件下,程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
那么,什么是数据依赖呢?具体点

名称 代码示例 说明 写后读 a = 1;b = a; 写一个变量之后,再读这个位置。 写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。 读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

所以数据以来方面主要就包括上面三种情况,当有数据依赖时,就不会发生重排序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

happens-before规则

happens-hefore就是一组规则,可以这样想,前面说过,JMM帮助程序员屏蔽了底层os和内存的使用,但是,为了更加高效的编程,还是需要知道哪些线程资源何时对其他线程可见,这就通过happens-before来实现,我们可以通过happens-before规则推到出:某个线程修改的变量何时对其他线程可见
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。

那首先来看看happens-before的定义:
1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见(只是结果),而且第一个操作的执行顺序排在第二个操作之前。
2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

接下来看happens-before具体的规则:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作(只对单线程下有效);
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • **线程启动规则:**Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

其中第一条是只针对单线程的,其他的规则则是单线程,多线程下都可以,这也就给我们编写并发程序时候,有了资源代码可见性一些理解。下文会给出具体例子。
程序员-JMM-os可以看下面这张图:
这里写图片描述

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

所以,Java从源代码到最终执行的指令序列,会分别经历下面三种重排序:
这里写图片描述

前面说过,由于有as-if-serial语义,重排序不会影响单线程执行结果,但是对于多线程,则会影响多线程执行结果。
下面看一个具体例子,说明重排序:

class ReorderExample {    int a = 0;    boolean flag = false;    public void writer() {        a = 1;                   //1        flag = true;             //2    }    Public void reader() {        if (flag) {                //3            int i =  a * a;        //4            ……        }    }}

假设有两个线程A和B,线程A执行writer方法,线程B执行reader方法,那么,线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?
并不能!
由于1喝2,3和4之间没有存在属于依赖,所以编译器和处理器可以对它们进行重排序。
执行顺序为2341:
假设对writer进行重排序,程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!
可以参看下面一张图理解:
这里写图片描述

执行顺序为3124:
由于操作3和操作4之间存在控制依赖,编译器和处理器会使用猜测(speculation)的方式,在一定程度上克服控制相关性对并行度的影响。
以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
就相当于假设了一个临时变量,如果最后判断成功就赋值,否则不赋值。
而以3124的顺序去执行的时候,很明显最开始a是没有被赋值的,所以重排序破坏了多线程的语义。
这里写图片描述

内存屏障

虽然说,重排序会增加执行的效率,并且对效率要求越高的语言,就会越允许重排序,这样才能更好的发挥cpu的功能。
但是呢,就会有抑制某些重排序的功能,这就是内存屏障。
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

屏障类型 例子 解释 LoadLoad Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。 StoreStore Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 LoadStore Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。 StoreLoad Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

volatile关键字

在进行多线程编程时,肯定会遇到volatile关键字,那么volatile关键字有什么作用呢?

  1. volatile可见性;对一个volatile的读,总可以看到对这个变量最终的写;
  2. volatile原子性;volatile对单个读/写具有原子性(32位Long、Double),但是复合操作除外,例如i++; 不能类比于AtomicInteger 等原子类。
  3. 有序性,JVM底层采用“内存屏障”来实现volatile语义,禁止重排序。

可见性:
volatile的内存语义是:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

也就是说,volatile类型变量,所有操作都会立刻回归于主存。
另一个方面,它不仅会看到对该volatile变量的写入操作,A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,都将立即变得对B线程可见。
有序性:
volatile能够在一定程度抑制编译器和处理器的重排序,具体细节如下:

  1. 如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前;
  2. 当第二个操作为volatile写,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
  3. 当第一个操作volatile写,第二操作为volatile读时,不能重排序。

JVM对volatile类型变量的限制比较严格的,当然只有这样了,volatile才能更好的发挥它在并发编程方面的作用。

那么具体是如何的去限制重排序的呢?应该还记得前面讲过的内存屏障,没错,JVM通过在源码中插入不同内存屏障来抑制重排序,具体为:

  • 在每一个volatile写操作前面插入一个StoreStore屏障,可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
  • 在每一个volatile写操作后面插入一个StoreLoad屏障,避免volatile写与后面可能有的volatile读/写操作重排序。
  • 在每一个volatile读操作后面插入一个LoadLoad屏障,禁止处理器把上面的volatile读与下面的普通读重排序。
  • 在每一个volatile读操作后面插入一个LoadStore屏障,禁止处理器把上面的volatile读与下面的普通写重排序。

接下来还是看看上面那个例子,将flag改为volatile类型,多线程条件下运行情况:

class ReorderExample {    int a = 0;    volatile boolean flag = false;    public void writer() {        a = 1;                   //1        flag = true;             //2    }    Public void reader() {        if (flag) {                //3            int i =  a * a;        //4            ……        }    }}

这样一来,根据happens-before规则,我们可以推导出:

  • 1 happens-before 2、3 happens-before 4;
  • 根据happens-before的volatile原则:2 happens-before 3;
  • 根据happens-before的传递性:1 happens-before 4;

操作1、操作4存在happens-before关系,那么1一定是对4可见的。volatile除了保证可见性外,还有就是禁止重排序。所以A线程在写volatile变量之前所有可见的共享变量,在线程B读同一个volatile变量后,将立即变得对线程B可见。
所以最终,多线程下,也能保证程序语义不被破坏了。

最后来说说如何正确使用volatile变量,要想使volatile变量提供理想的线程安全,必须同时满足以下两个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

上面两方面说的比较抽象,但有一点是肯定的,volatile不能保证复合操作的原子性。

正确使用volatile关键字的模式:

  • 状态标志,例如通过一个volatile的boolean类型去判断是否应该停止一个线程。
  • 一次性安全发布(one-time safe publication),例如单例模式。但是有个条件:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。volatile 类型的引用可以确保对象的发布形式的可见性,但是如果对象的状态在发布后将发生更改,那么就需要额外的同步。
  • 独立观察(independent observation),保存最后的关键值
  • volatile bean” 模式,在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。
  • 开销较低的读-写锁策略,因为对volatile的读是具有实时性的,但是写就必须加锁从而保证原子性。对写加锁,读则用volatile变量。

参考文章:
1. 方腾飞:《Java并发编程的艺术》
2. https://www.ibm.com/developerworks/cn/java/j-jtp06197.html
3. http://www.infoq.com/cn/profile/%E7%A8%8B%E6%99%93%E6%98%8E
4. http://blog.csdn.net/chenssy/article/details/56679728