Java并发编程(1)-Java内存模型

本文主要是学习Java内存模型的笔记以及加上本身的一些案例分享,若有错误之处请指出。html

一 Java内存模型的基础

一、并发编程模型的两个问题

  在并发编程中,须要了解并会处理这两个关键问题:java

  1.一、线程之间如何通讯?

   通讯是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递程序员

  a) 在共享内存的并发模型里,线程之间共享程序的公共状态,经过写-读内存中的公共状态进行隐式通讯。(重点)编程

  b) 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须经过发送消息来显式进行通讯。数组

  1.二、线程之间如何同步?

  同步是指程序中用于控制不一样线程间操做发生相对顺序的机制。缓存

  在共享内存的并发模型里,同步是显示进行的。由于程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。多线程

  在消息传递的并发模型里,因为消息的发送必须在消息的接收以前,所以同步是隐式进行的。并发

  知道并了解上面两个问题后,对java内存模型的了解,就打下了基础。由于Java的并发模型采用的是共享内存模型,java线程之间的通讯老是隐式进行,整个通讯过程对程序员彻底透明。app

二、Java内存模型的抽象结构

  在Java中,全部实例域、静态域和数组元素都存储在堆内存中, 堆内存在线程之间是共享的(详细能够参考JVM运行时数据区域的划分及其做用)。而虚拟机栈(其中包括局部变量、方法参数定义等..)是线程私有的,不会在线程之间共享,因此它们不会有内存可见性的问题,也不受内存模型的影响。ide

  Java线程之间的通讯由Java内存模型(简称JMM)控制。JMM决定一个线程对共享变量的写入什么时候对另外一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每一个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本。本地内存是JMM的抽象概念,并不真实存在。Java内存模型的抽象示意图:

  从上图来看,若是线程A和线程B之间要通讯的话,必需要经历下面两个步骤:

  1)线程A把本地内存A中更新过的共享变量刷新到主内存中去

  2)线程B到主内存中去读取线程A以前已更新过的共享变量

  举个例子:线程A与线程B进行通讯,以下图:

  假设初始时,这三个内存中x的值都为0,线程A在执行时,把更新后的x值临时放在本地内存。当线程A与线程B须要通讯时,

  步骤1:线程A首先会把本身本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。

  步骤2:线程B到主内存中读取线程A更新后的X值,此时线程B的本地内存x的值也变为了1。

  从总体(不考虑重排序,按顺序执行)来看,这两个步骤实质上是线程A在向线程B发送消息,并且这个通讯过程必需要通过主内存。JMM经过控制主内存与每一个线程的本地内存之间的交互,提供内存可见性的保证。

三、从源代码到指令序列的重排序

  在执行程序时,为了提升性能,编译器和处理器经常会对指令作重排序。重排序分3种:

  1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。(编译器重排序

  2)指令级并行的重排序。现代处理采用了指令级并行技术来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应及其指令的执行顺序。处理器重排序

  3)内存系统的重排序。因为处理器使用缓存和读/写缓冲区,这使得加载和存储操做看上去多是在乱序执行。处理器重排序

  这些重排序可能会致使多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(并非全部的编译器重排序都要禁止)。对于处理器重排序,JMM的处理重排序规则会要求Java编译器在生成指令序列时,经过内存屏障(后面会解释)指令来禁止特定类型的处理重排序。

  如今的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区能够保证指令流水线持续运行,它能够避免因为处理器停顿下来等待向内存写入数据而产生的延迟。同时,经过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的屡次写,减小对内存总线的占用。虽然写缓冲区有这么多好处,但每一个处理器的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操做的执行顺序产生重要的影响:处理器对内存的读/写操做的执行顺序,不必定与内存实际发生的读/写操做顺序一致下面请看下案例:

class Pointer {
    int a = 0;
    int b = 0;
    int x = 0;
    int y = 0;

    public void set1() {
        a = 1;
        x = b;
    }

    public void set2() {
        b = 1;
        y = a;
    }
}

/**
 * 重排序测试
 */
public class ReorderTest {
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            final Pointer counter = new Pointer();
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    counter.set1();
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    counter.set2();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("i="+(++i)+",x=" + counter.x + ", y=" + counter.y);
            if (counter.x == 0 && counter.y == 0) {
                break;
            }
        }
    }
}

  运行结果:

i=1,x=0, y=1
i=2,x=1, y=0 . . . i=5040,x=0, y=0

   表格示例图:

  假设处理器A和处理B按程序的顺序并行执行内存访问,最终可能获得x=y=0的结果。具体缘由以下图:

  解释下为何会出现这样的结果:这里处理A和处理B能够同时把共享变量写入本身的写缓冲区(A1,B1),而后从内存中读取另外一个共享变量(B1,B2),最后才把本身写入缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就能够获得x=y=0的结果。

  问题分析:从内存操做实际发生的顺序来看,虽然处理A执行内存操做顺序为:A1->A2,但内存操做实际发生的顺序确实A2->A1。此时,处理器A的内存操做顺序被重排序了(处理器B也是同样)。因此因为写缓冲区仅对本身的处理器可见,它会致使处理器执行内存操做的顺序可能会与内存实际的操做执行顺序不一致。因为现代的处理器都会使用写缓冲区,所以如今的处理器都会容许对写 - 读操做进行重排序。重排序的具体内容后续会说明,下图表是常见处理器容许的重排序状况(N不容许重排序,Y表示容许重排序):

四、内存屏障

  为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,以下表 

  从上表能够看出StoreLoad Barriers是一个“全能型”的屏障,它同时具备其余3个屏障的效果。现代的多处理大多支持该屏障(其余类型屏障不必定支持)。执行该屏障开销会很昂贵,由于当前处理一般要把写缓冲区的数据所有刷新到内存中

五、happens-before简介

  后续会详细介绍,这里只是提出点,声明这是JMM中存在的概念。

二 重排序

  重排序是指编译器和处理器为了优化程序性能而对指令序列进行从新排序的一种手段。并非全部都会进行重排序,除了上面提到Java编译器会在适当的时候插入内存屏障来禁止重排外,还得遵循如下几个特性:

一、存在数据依赖性禁止重排(单线程)。

  前面提到过编译器和处理器可能会操做作重排序。可是编译器和处理器在重排序序时,会遵照数据依赖性,不会改变存在数据依赖关系的两个操做的执行顺序。

  数据依赖分为下列三种类型:

  这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操做,不一样处理器之间和不一样线程之间的数据依赖性不编译器和处理器考虑。

二、遵循as-if-serial语义

  as-if-serial语义的意思是:无论怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵照as-if-serial语义。为了遵照as-if-serial语义,编译器和处理不会对存在数据依赖关系的操做作重排序,由于这种重排序会改变执行结果。例如:

        double pi = 3.14;       // A
        double r = 1.0;         // B
        double area = pi * r;   // C    

  从代码中能够看出, A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。所以在最终执行的指令序列中,C不能被重排序到A和B的前面。可是A和B没有数据依赖关系,编译器和处理器能够重排序A和B之间的执行顺序。如下就是程序可能执行的两种顺序。

  在单线程程序中,对存在控制依赖的操做重排序,不会改变执行结果。可是在多线程程序中,对存在控制依赖的操做重排序,可能会改变程序执行的结果(上面已说明:不会保证对多线程的数据依赖禁止重排),上面有个例子也提到过,下面再写个案例加深印象:

package com.yuanfy.gradle.concurrent.volatiles;

/**
 * 重排序测试
 */
public class ReorderExample { int sum = 0; int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2  } public void reader() { if (flag) { // 3 sum = a * a;// 4  } } public static void main(String[] args) throws InterruptedException { int i = 0; while (true) { final ReorderExample example = new ReorderExample(); Thread t1 = new Thread(new Runnable() { @Override public void run() { example.writer(); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { example.reader(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("i="+(++i)+",sum=" + example.sum); if (example.sum == 0) { break; } } } }

  简单描述下上面代码:flag变量是个标记,用来标志变量a是否已被写入。线程1先执行writer()方法,随后线程2接着执行reader()方法。当线程2在执行操做4时,可否看到线程1在操做1对共享变量a的写入呢。答案是:不必定。先看下运行结果:

i=1,sum=1
i=2,sum=1 i=3,sum=0

  问题分析:经过前面对重排序的了解,线程1中一、2步骤没有数据依赖,那么编译器和处理器就有可能将其进行重排序,若是排序结果成下图,那么线程2就看不到线程1对共享变量a的操做了。

三 顺序一致性

一、数据竞争与顺序一致性

  当程序为正确同步时,就可能存在数据竞争。Java内存模型规范对数据竞争的定义以下:

    在一个线程中写一个变量,

    在另外一个线程读同一个变量,

    并且写和读没有经过同步来排序。

  当代码中包含数据竞争时,程序的执行每每产生违反直觉的结果(譬如重排序案例中的ReorderExample )。若是一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。JMM对正确同步的多线程程序的内存一致性作了以下保证:

  若是程序是正确同步的,程序的执行将具备顺序一致性-----即程序的执行结果与该程序在顺序一致性内存模型中的执行的结果相同

二、顺序一致性模型

  顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。它有两大特性:

  1)一个线程中的全部操做必须按照程序的顺序来执行。

  2)(无论程序是否同步)全部线程都只能看到一个单一的操做执行顺序。在顺序一致性内存模型中,每一个操做都必须原子执行且马上对全部线程可见。

  下面参考下顺序一致性内存模型的视图:

  上面也说顺序一致性是基于程序是正确同步的,对于未正确同步的多线程程序,JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。由于若是想要保证执行结果一致,JMM须要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。

  未同步程序在两个模型中的执行特性差别以下:

  1) 顺序一致性模型保证单线程内的操做会按照程序的顺序执行(特性1),而JMM不保证单线程内的操做会按程序的顺序执行(可能会发生重排序)。

  2)顺序一致性模型保证全部线程只能看到一致的操做执行顺序(特性2),而JMM不保证全部线程能看到一致的操做执行顺序(一样是重排序)。

  3)JMM不保证对64位的long类型和double类型变量的写操做具备原子性,而顺序一致性模型保证对全部的内存读/写操做都具备原子性(特性2)。

  主要分析下第三点:

  在一些32位的处理器上,若是要求对64位数据的写操做具备原子性,会有比较大的开销。为了照顾这些处理器,Java语言规范鼓励但不强求JVM对64位的long类型变量和double类型变量的写操做具备原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double类型变量的写操做拆分为两个32位的写操做来执行。这两个32位的写操做可能会被分配到不一样的总线事务中执行,此时对这个64位变量的写操做不具备原子性。

  当单个内存操做不具备原子性时,可能会产生意想不到的后果。

  如上图所示,假设处理器A写一个long型变量,同时初期B要读取这个long型变量。处理器A中64位的写操做被拆分两个32位的写操做,且这两个32位的写操做分配到不一样的事务中执行。同时处理器B中64位的读操做被分配到单个的读事务中执行。当处理器A和B按上图来执行时,处理器B将看到仅仅被处理器A“写了一半”的无效值。

  注意:在JSR-133规范以前的旧内存模型中,一个64位long/double型变量的读/写操做能够被拆分两个32位的读/写操做来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只容许一个64位long和double型变量的写操做拆分为两个32位的写操做来执行,任意的读操做在JSR-133中都必须具备原子性(即任意读操做必需要在单个事务中执行)。

四 happens-before

  happens-before是JMM最核心的概念,因此理解happens-before是理解JMM的关键。下面咱们从三方面去理解。

一、JMM的设计

  1.1 设计考虑的因素:

  a) 须要考虑程序员对内存模型的使用。程序员但愿内存模型易于理解、易于编程,但愿基于一个强内存模型来编写代码。

  b)须要考虑编译器和处理器对内存模型的实现。编译器和处理器但愿内存模型对它们的束缚越少越好,这样它们就能够作尽量多的优化来提升性能。编译器和处理器但愿实现一个弱内存模型。

  1.2 设计目标:因为这两个因素相互矛盾,这两个点也就成了设计JMM的核心目标:一方面,要为程序员提供足够强的内存可见性保证;另外一方面,对编译器和处理器的限制要尽量地放松。

  1.3 设计结果

  JMM把happens-before要求禁止的重排序分为下面两类,并采起了不一样的策略:

  a) 会改变程序执行结果的重排序,对于这种JMM要求编译器和处理器必须禁止这种重排序(在重排序应该有体现)。

  b) 不会改变程序执行结果的重排序,对于这种JMM对编译器和处理器不做要求(JMM容许这种重排序)。

  设计示意图以下:

  从上图能够看出两点:

  • JMM提供的happens-before规则能知足程序员的要求:它不只简单易懂,并且提供了足够强的内存可见性保证。
  • JMM对编译器和处理器的束缚已经尽量少。从上图来看,JMM实际上是在遵循一个基本原则:只要不改变程序的执行结果(指单线程或正确同步的多线程),编译器和处理器怎么优化都行。例如:若是编译器通过细致的分析后,认定一个锁只会被单线程访问,那么这个锁能够被消除。

二、happens-before的定义

  JSR-133对happens-before关系的定义以下:

  1) 若是一个操做happens-before另外一个操做,那么第一个操做的执行结果将对第二个操做可见,并且第一个操做的执行顺序排在第二个操做以前。

  2) 两个操做之间存在happens-before关系,并不意味者Java平台的具体实现必需要按照happens-before关系执行的顺序来执行。若是重排序以后的执行结果与按happens-before关系来执行的结果一致,那么这种重排序并不非法,也就是说JMM容许这种重排序。

  上面的第一点是JMM对程序员的承诺。从程序员的角度来讲,能够这样理解happens-before关系:若是A happens-before B, 那么Java内存模型将向程序员保证--A操做的结果将对B可见,且A的执行顺序排在B以前。注意这是Java内存模型作出的保证(若是没有禁止编译器和处理器对其重排序且重排序不非法那么就不必定是这个执行循序)。

  上面的第二点是JMM对编译器和处理器重排序的约束规则。JMM这么作的缘由是:程序员对于这两个操做是否真的被重排序并不关心,关心的是程序执行时的语义不能被改变即执行结果不能被改变。所以,happens-before关系本质上和前面说的as-if-serial语义是一回事。

  a) as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

  b) as-if-serial语义给编写单线程程序的创造了一个幻境:单线程程序时按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

  这二者都是为了在不改变执行结果的前提下,尽量地提供程序执行的并行度。

3 happens-before规则

  JSR-133定义了以下happens-before规则:

  1) 程序顺序规则:一个线程中的每一个操做,happens-before于该线程中的任意后续操做。(通俗的说:单线程中前面的动做发生在后面的动做以前)

  2) 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。(通俗的说:解锁操做发生在加锁操做以后)

  3) volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读。(通俗的说:对volatile变量的写发生在读以前)

  4) 传递性规则:若是A happens-before B,且B happens-before C,那么A happens-before C.

  5) start()规则:若是线程A执行操做ThreadB.start()(启动线程B),那么线程B中的任意操做happens-before于线程A从ThreadB.join()操做返回成功。

  6) join()规则:若是线程A执行操做Thread.join()并成功返回,那么线程B中的任意操做happens-before于线程A从ThreadB.join操做成功返回。

  下面经过程序流程图分析下:

  上图说明程序顺序规则、volatile变量规则和传递性规则,下图说明start()规则。

  下图说明join规则:

五 volatile内存语义

  Java内存模型-volatile的内存语义

六 锁的内存语义

  Java内存模型-锁的内存语义

七 final域的内存语义

  Java内存模型-final域的内存语义

八 参考文献 

  《Java并发编程的艺术》

相关文章
相关标签/搜索