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

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

一 Java内存模型的基础

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

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

  1.1、线程之间如何通信?

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

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

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

  1.2、线程之间如何同步?

  同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

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

  在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

  知道并了解上面两个问题后,对java内存模型的了解,就打下了基础。因为Java的并发模型采用的是共享内存模型,java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

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

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

  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、从源代码到指令序列的重排序

  在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分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表示允许重排序):

4、内存屏障

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

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

5、happens-before简介

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

二 重排序

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

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

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

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

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

2、遵循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中1、2步骤没有数据依赖,那么编译器和处理器就有可能将其进行重排序,如果排序结果成下图,那么线程2就看不到线程1对共享变量a的操作了。

三 顺序一致性

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

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

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

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

    而且写和读没有通过同步来排序。

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

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

2、顺序一致性模型

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

  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的关键。下面我们从三方面去理解。

1、JMM的设计

  1.1 设计考虑的因素:

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

  b)需要考虑编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

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

  1.3 设计结果

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

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

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

  设计示意图如下:

  从上图可以看出两点:

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

2、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并发编程的艺术》

 

posted @ 2018-07-22 14:45  玉树临枫  阅读(2663)  评论(3编辑  收藏  举报