Skip to content

Instantly share code, notes, and snippets.

@simonwoo
Last active May 25, 2016 04:06
Show Gist options
  • Select an option

  • Save simonwoo/7ca23911c2831ceb2315 to your computer and use it in GitHub Desktop.

Select an option

Save simonwoo/7ca23911c2831ceb2315 to your computer and use it in GitHub Desktop.

并发编程的两个关键问题:

  • 线程之间通信
  • 线程之间同步

线程通信通常有两种方式:共享内存和消息传递。 线程同步是指程序用于控制不同线程之间操作发生的相对顺序。 共享内存模型中,需显示指定某个方法或某段代码需要在线程之间互斥执行。Java并发采用的是共享内存模型。

Java内存模型(JMM)

在Java中,所有的实例域,静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量,方法参数和异常处理器参数不在内存之间共享,因此不受内存模型影响。 Java线程之间通信是由Java内存模型控制,它决定一个线程对共享变量的写入何时对另一个线程可见。 JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程读写共享变量的副本。 本地内存是一个抽象概念,并不真实存在。 JMM模型如下:

JMM

实现两个线程通信需要两个步骤:

  • 线程A把本地内存A中更新过的共享变量刷新到主内存
  • 线程B到主内存中去读出线程A更新过的共享变量

JMM通过控制主内存与线程本地内存之间的交互,来为Java程序与提供内存可见性保证。

Happens-before

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间存在happens-before关系。这里提到的两个操作可以在同一个线程内,也可以在不同线程之间。

  • 程序顺序规则:一个线程中的每个操作,happens-before与该线程中任意后续操作。
  • 监视器锁规则:对于一个监视器解锁,happens-before于随后对这个监视器的加锁。
  • volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个域的读。
  • 传递性规则:A happens-before B, B happens-before C -> A happens before C

Happens-before与JMM的关系如下:

happens-before

一个happens-before规则通常对应于多个编译器重排序和处理器规则。

重排序

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

  • 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级别并行重排序(Instruction-Level-Parallelism, ILP)将多条指令重叠执行。
  • 内存系统重排序。由于处理器使用缓存和读写缓冲,这使得加载和储存操作看上去可能是在乱序执行。

重排序

第一种属于编译器重排序,第二种和第三种属于处理器重排序。 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。 对于处理器,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的重排序。 JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序为程序员提供一致的内存可见性保证。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。共分为以下三种类型:

  • 写后读, 如a=1;b=a;
  • 写后写, 如a=1;a=2;
  • 读后写, 如a=b;b=1;

上述三个操作如果重排序两个操作的执行顺序,执行结果将会被改变。编译器和处理器在重排序时会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。这里指的只针对单个处理器执行的指令顺序和单个线程中执行的操作,不同处理器之间和不同线程之间数据依赖性不被考虑。

as-if-serial

不管怎么重排序,单线程执行的结果不能改变。如果不存在数据依赖关系,操作可能会被编译器和处理器重排序。

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

数据依赖关系如下: C <- B, C <-A 在最终的指令顺序中,C不能被重排序到A和B之前。但A和B可能会被重排序。所以有以下两种可能:A-B-C, B-A-C 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
            ……
        }
    }
}

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。 当操作1和操作2重排序时, 程序执行如下:

reorder1

当操作3和操作4重排序后, 程序执行如下:

reorder2

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

数据竞争与顺序一致性

当程序未正确同步时,就会存在数据竞争。java内存模型对数据竞争定义如下:

  • 在一个线程中写入一个变量;
  • 在另一个线程中读取该变量;
  • 读和写没有通过同步来排序。 当代码中出现数据竞争时,程序执行结果未知。如果一个多线程程序能够正确同步,这个程序将是一个没有数据竞争的程序。 JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性--程序的执行结果与该程序在顺序一致内存模型中的执行结果相同。这里的同步是广义的同步,包括对常用同步原语(lock,volatile和final)的使用。

同步程序的顺序一致性效果

下面我们对前面的示例程序ReorderExample用监视器来同步,看看正确同步的程序如何具有顺序一致性。

class SynchronizedExample {
    int a = 0;
    boolean flag = false;

    public synchronized void writer() {
        a = 1;
        flag = true;
    }
    
    public synchronized void reader() {
        if (flag) {
            int i = a;
            ……
        }
    }
}

上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment