Wilder's Blog.

并发编程总结二

字数统计: 5.2k阅读时长: 18 min
2018/02/05 Share

java关于并发的总结之二

内存模型基础

    在并发编程中,需要处理两个关键问题:线程之间如何通信以及线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
    java的并发采用的是共享内存模型,java线程之间的通信总是隐式的进行,整个通信过程对程序员完全透明。

java内存模型的抽象结构

java线程之间的通信由java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入合适对另一个线程可见。抽象角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本(但本地内存仅是一个抽象概念,并不真实存在,这么说是模拟了一种存储方式)。
image
如果线程之间要进行通信的话,必须经历下面两个步骤(这里的通信是指线程A写入的数据会被线程B读取到):

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

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

重排序分为三种类型:

  • 编译器优化的重排序。
  • 指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

1属于编译器重排序,2和3属于处理器重排序。重排序会导致多线程程序出现内存可见性问题,所以对于编译器,JMM的重排序规则会禁止某些特定类型的重排序。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令时插入特定类型的内存屏障指令,通过内存屏障来禁止特定类型的处理器重排序。

并发编程模型的分类

写缓冲区

  • 优点:写缓冲区可以保证指令流水线持续运行,同时通过以批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,减少对内存总线的占用。
  • 缺点:只对当前处理器可见,也就是说处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的顺序一致。比如说:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    {
    a = 1 //步骤1
    x = b; //步骤2
    }
    {
    b = 2;
    y = a;
    }
    }

当线程A运行上半部分,线程B运行下半部分。我们希望得到的结果是x=2,y=3,但因为处理器允许写读重排序(Store-Load重排序),也就是说步骤1和步骤2的运行顺序会发生改变,所以有可能发生一下的情况:
内存先初始化了变量a和b,初始化之后a和b赋值为0,这时候步骤1和2进行了重排序,所以x得到的是初始化为0的b,同样y也得到的是0,x和y赋值之后,a=1和b=2才被写入到主内存中。
出现这个问题的关键原因就是写缓冲区仅对自己的处理器可见,导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此都会允许写-读操作重排序。

处理器\规则 Load-Load Load-Store Store-Store Store-Load 数据转换
SPARC-TSO N N N Y N
x86 N N N Y N
IA64 Y Y Y Y N
SPARC-TSO Y Y Y Y N

可以看到,不同的处理器都允许写-读重排序,也就是Store-load重排序,对数据转换都不允许,这和上文提到的数据依赖性有关

为了避免这种错误的发生,保证内存可见性,变了一起在生成指令序列的适当位置会插入内存屏障指令来进行特定类型的处理器重排序。JMM把内存屏障分为四类:

  • LoadLoad Barriers:确保Load1数据的装载先于Load2以及所有后续装载指令
  • StoreStore Barriers:确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令的装载
  • LoadStore Barriers:确保Load1数据装载先于Store2及所有后续存储指令刷新到内存
  • StoreLoad Barriers:确保Store1数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Load2及所有后续装载指令的装载。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。

happens-before简介

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happ-before关系。两个操作可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的happens-before规则如下:

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

注意: 两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行。仅仅要求前一个执行的结果对后一个操作可见。也就是说,假如 A happens-before B 和 B happens-before A 两个执行的结果是相同的,JMM认为这种重排序是合法的。

重排序

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
image

as-if-serial语义

as-if-serial语义的意思是: 不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器和处理器都应该遵循as-if-serial语义

1
2
3
int a = 1;  //A
int b = 2; //B
int c = 1 + 2; //C

因为as-if-serial语义,所以程序执行的顺序只会有两种,分别是 A-B-C 和 B-A-C 执行的结果不会改变。但要注意的是,这种语义仅是针对单线程对重排序的处理,对于多线程来说,重排序依然会对程序产生一些奇奇怪怪的影响。

顺序一致性

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

  • 一个线程中的所有操作都必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个造作都必须原子执行且立刻对所有线程可见

image
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。

假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:
image
假设这两个线程没有同步,那么在顺序一致性模型中执行的结果也会是一致的:
image
但是,这仅仅是一个理想化的模型,JMM在执行中并没有这个保证,因此在多线程执行的过程中还是存在着重排序带来的一系列问题。

volatile 内存语义

vloatile 的特性

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,他们之间的执行效果相同。
简而言之,volatile变量自身具有以下特性:

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读/写具有原子性,对于符合操作便不具有原子性。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class VolatileFeaturesExample{
    volatile long v1 = 0L;
    public void set(long l){
    v1 = l;
    }

    public void getAndIncrement(){
    v1++;
    }
    public long get(){
    return v1;
    }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
class VolatileFeaturesExample{
long v1 = 0L;
public synchronized void set(long l){
v1 = l;
}

public void getAndIncrement(){
v1++;
}
public synchronized long get(){
return v1;
}
}

volatile 写-读的内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存的共享变量刷新到主内存

    image
    由图可知,flag是一个volatile变量,当线程A书写变量flag为true时,会将变量值写到共享内存中,这时候本地内存B中包含的变量值已经无效,线程B必须从共享内存中读取flag的值。
    将volatile写和读两个步骤综合起来看的话,在线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对线程B可见。

  • 总结(线程之间通信的实现):

    • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(对共享变量所做修改的)消息。
    • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(写在这个volatile变量之前对共享变量所做修改的)消息。

volatile 内存语义的实现

image
从图中可以看出:
当第二个操作为volatile写时,一律不允许重排序;
当第一个操作为volatile写时,一律不允许重排序操作;
当第一个操作是volatile写,第二个操作是volatile读时,不允许重排序操作

因此为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  • 在每个volatile写操作前插入StoreStore屏障
  • 在每个volatile写操作后插入StoreLoad屏障
  • 在每个volatile读操作前面插入LoadLoad屏障
  • 在每个volatile读操作后面插入StoreLoad屏障

image

实际执行的时候,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
image
最后的StoreLoad屏障编译器在编译的时候都会加上,因为不知道在volatile写之后到底会执行什么操作(假如是return操作的话就没有必要写这个屏障),但是编译器保险起见都会加上这个屏障。
从这里可以看出,编译器会自动的省略掉没有必要的屏障,但要保障结果的正常。
由于不同的处理器有不同的“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。
例如X86的处理器仅仅会对写-读操作进行重排序,因此对于上述的图中的叙述,LoadLoad屏障、LoadStore屏障和SotreStore屏障将不会被处理器加入,只留下一个StoreLoad屏障。

锁和final的内存语义

锁的内存语义

锁的释放-获取建立的happens-before关系

1
2
3
4
5
6
7
8
9
10
class MonitorExample{
int a = 0;
public synchronized void writer(){ //1
a++; //2
} //3

public synchronized void reader(){ //4
int i = a ; //5
} //6
}

当线程A执行writer()方法,线程B执行reader()方法,根据happens-before规则可知:

  • 根据程序次序规则,1 happens-before 2,2>3 , 4>5 , 5>6
  • 根据监视器锁规则,3>4
  • 根据happens-before 的传递性,2>5,因此会当线程A写入a的值之后,线程B才会读取到最新的a值
    这里写图片描述
    也就是说,线程A在释放锁之前所有可见的共享变量,在线程B获得同一个锁之后,将立刻变得对B线程可见

    锁的释放和获取的内存语义

    当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。在另一个线程获取锁时,JMM会把线程对应的本地内存置为无效。从而使得被监视器保护的临界代码必须从住内存中读取共享变量。
    可以看出,锁的释放-获得内存语义和volatile写-读的内存语义有着相同之处:
  • 线程A释放一个锁,实际上是线程A向接下来将要获取这个锁的某个线程发出(线程A对共享变量所做的修改)消息
  • 线程B获取一个锁,实质上是线程B接受了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

锁内存语义的实现

完美解释ReentrantLock的博客
从这篇博客中可以看到,在ReentrantLock中分为公平锁和非公平锁,两者的区别在于多个Node连起来的虚拟队列,队列中有head和tail。针对公平锁,head是不带线程的特殊Node,只有next,而最新一个请求锁的线程取锁失败时就把它添加到队尾,也就是tail。但是对于非公平锁,新请求锁的线程就会插队,也许会插到最前面,也许不会。
总的来说,公平锁和非公平锁的不同在于:

  • 公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放所的线程在写volatile变量之前可见的共享变量,在获得锁的线程读取同一个volatile变量之后将立即变得对获取锁的线程可见。
  • 非公平锁中实现真正开始加锁的源代码为

    1
    2
    3
    protected final boolean compareAndSetState(int expect , int update){
    return unsafe.compareAndSwapInt(this,stateOffset,expect,update);
    }

    此操作具有volatile读和写的内存语义,从处理器的角度来看的话,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序在多处理器上运行则加上lock前缀,如果在单处理器上运行则不需要加上lock前缀。由于汇编代码加上lock前缀正是volatile语义的实现,因此具有volatile读和写的内存语义。

  • 总的来说,锁的释放-获取内存语义的实现至少有以下两种方式:
    • 利用volatile变量的写-读所具有的内存语义
    • 利用CAS(compareAndSet)所附带的volatile读和写的内存语义。

final域的内存语义

final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

通过一段代码来说明这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinalExample{
int i;
final int j;
static FinalExample obj;

public FinalExample(){ //构造函数
i = 1;
j = 2; //写final域
}

public static void writer(){ //线程A执行
obj = new FinalExample();
}

public static void reader(){ //线程B执行
FinalExample object = obj; //读对象引用
int a = object.i;
int b = object.j; //读final域
}
}

由上面的两个重排序规则可以得到,在线程A执行将构造对象的引用赋值给一个引用变量,也就是new一个FinalExample时,会先将2赋值给final域j之后,再将这个new的对象赋值为obj,这两个步骤不会重排序。在线程B执行reader方法时,读对象的引用和读final域这两个步骤也不会进行重排序,否则如果先执行读final域时,这个时候object还未被赋值,便有可能会产生异常。

写final域的重排序规则

写final域的重排序规则禁止把两个final域的写重排序到构造函数之外,包含两个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障会禁止处理器把final域的写重排序到构造函数之外。
    上面的代码中,final域有这样的重排序规则,而对于普通变量则没有这样的规则,所以i = 1 可能会被重排序到构造函数之外。
    image

读final域的重排序规则

规则为:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(仅仅针对编译器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

在上面那个图可以看到,假如读对象普通域i被重排序到读对象引用obj之前的话,那将会产生错误。所以有final读这个重排序规则之后,假如obj先被线程A初始化了,那么final域的j一定会读取到2,假如还未被线程A初始化,那么读取的将会是null,也不会产生异常。

final域为引用类型

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用对象的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FinalExample{
final int[] intArray;
static FinalExample obj;
public FinalExample(){
intArray = new intp[1];
intArray[0] = 1;
}
public static void writerOne(){ //线程A执行
obj = new FinalExample();
}
public static void writeTwo(){ //线程B执行
obj.intArray[0] = 2;
}
public static void reader(){ //线程C执行
if(obj != null){
int templ = obj.intArray[0];
}
}
}

从上面的程序可以看到,writerOne()方法执行的时候会先将构造函数执行完毕之后再将之赋值给obj对象,这两个步骤不会进行重排序。由于线程B和线程C存在竞争关系,JMM不能保证线程B的写入对线程C可见,所以templ的结果有可能时1也有可能是2,要解决这个办法,可以加上锁机制,或者使用volatile变量来确保内存的可见性。

CATALOG
  1. 1. java关于并发的总结之二
    1. 1.1. 内存模型基础
      1. 1.1.1. java内存模型的抽象结构
      2. 1.1.2. 从源代码到指令序列的重排序
      3. 1.1.3. 并发编程模型的分类
      4. 1.1.4. happens-before简介
    2. 1.2. 重排序
      1. 1.2.1. 数据依赖性
      2. 1.2.2. as-if-serial语义
    3. 1.3. 顺序一致性
    4. 1.4. volatile 内存语义
      1. 1.4.1. vloatile 的特性
      2. 1.4.2. volatile 写-读的内存语义
      3. 1.4.3. volatile 内存语义的实现
    5. 1.5. 锁和final的内存语义
      1. 1.5.1. 锁的内存语义
        1. 1.5.1.1. 锁的释放-获取建立的happens-before关系
      2. 1.5.2. 锁的释放和获取的内存语义
      3. 1.5.3. 锁内存语义的实现
      4. 1.5.4. final域的内存语义
        1. 1.5.4.1. final域的重排序规则
        2. 1.5.4.2. 写final域的重排序规则
      5. 1.5.5. 读final域的重排序规则
      6. 1.5.6. final域为引用类型