并发编程的两个关键问题

线程之间如何通信和线程之间如何同步

线程之间通信的方式有两种:共享内存和消息传递

在共享内存的并发模型中,线程之间共享公共状态,通过读-写内存公共状态进行隐式通信
在消息传递的并发模型中,线程之间没有公共状态,必须通过发送消息来显式通信

同步是指程序之间用于控制不同线程间操作发生相对顺序的机制。
在共享内存的并发模型中,必须显式指定某个方法或某段代码需要线程之间互斥执行。
在消息传递的并发模型中,由于消息的发送必须在消息接收前,因此同步的隐式进行的。

JAVA内存模型的抽象结构

在Java中,所有实例域,静态域,数组元素都存储在堆内存中,堆内存在线程间是共享的。
局部变量,方法定义参数和异常处理器不会在线程间共享。

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定线程对共享变量的写入何时对另一个变量可见。
线程间的共享变量存储在主存,每个线程都有一个私有的本地内存,本地内存中存储了该线程读/写共享变量的副本。本地内存是一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲器,寄存器以及其他硬件和编译器优化。
示意图如下:
image.png

从上图来看,如果线程A和线程B要进行通信的话,必须经过下面两个步骤:

  1. 线程A将本地内存A中修改过的共享变量刷新到主存
  2. 线程B去主存中读取共享变量新的值

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

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

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

Java代码到最终执行会经过以下三种重排序:
image.png
1属于编译器重排序,2,3属于处理器重排序
对于编译器重排序,JMM会禁止特定类型到编译器重排序。
对于处理器重排序,JMM会要求Java编译器在生成指令序列时,加入特定类型的内存屏障,通过内存屏障来禁止特定类型的处理器重排序

并发编程模型的分类

现代处理器使用写缓冲区临时保存向内存写入的数据,写缓冲区可以避免由于处理器等待向内存写数据而产生的延迟,通过批量向主存写入,减少对内存总线的占用。
虽然有这么多好处,但是每个处理器上的写缓冲区只对自己所在的处理器可见,这个特性对内存操作可能会产生影响。
image.png假设处理器A和B并行执行,可能会发生x=y=0的结果。
原因如下:
image.png
写缓冲区还没刷新到主存,而且读取并没有数据依赖,因此就先行读取了主存中未更新的数据。

为了保证内存可见性,Java编译器会在生成指令序列的适当位置加上内存屏障来禁止指令的重排序。
JMM把内存屏障分为四类:
image.png
StoreLoad Barriers是一个全能型的屏障,具有其他三个屏障的效果。因此大多数现代处理器都会支持该屏障。
执行该屏障的代价很高,因为需要把全部写缓冲区都刷新到主存。

happens-before

JSR-133使用happens-before来阐述操作之间的内存可见性。在JMM中,如果一个操作的结果必须要对另一个操作可见,那么两个操作之间必须存在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关系,并不意味着前一个操作必须在后一个操作之前执行。happens-before仅仅要求前一个操作的结果对后一个操作可见,且前一个操作按顺序排在后一个操作之前。