1、并发编程的核心问题

在并发编程中需要处理两个关键的问题:线程之间如何通信以及线程之间如何同步

  • 通信指的是线程之间以何种机制来交换信息,在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递
  1. 在共享内存的并发模型里,线程之间共享程序的公共状态。线程之间通过读写内存的公共状态来进行隐式通信
  2. 在消息传递的并发模型的线程之间没有公共状态,线程之间必须通过发送消息来显式的进行通信
  • 同步指的是程序中用于控制不同线程间操作发生相对顺序的机制。
  1. 在共享内存并发模型,同步是显示进行的,开放人员必须显示的指定某个方法或者某段代码需要在县城之间互斥执行
  2. 在消息传递的并发模型里,由于消息的发送必须在消息接收之前因此同步是隐式进行的

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

众所周知,在java中所有的实例域,静态域以及数组元素均保存在堆内存中,堆内存在线程之间共享。局部变量,方法定义的参数以及异常表等参数在属于线程私有的,不会个各个线程之间共享,所以其不存在可见性的问题。

Java线程之间的通讯由JMM控制(Java Memory Model ,Java内存模型) 。JMM 决定一个线程对共享变量的修改何时对其他线程可见。

线程之间共享的变量存储在主内存(Main Memory)中,每个线程都有一份本地内存(Local Memory) ,保存着对公共变量的读、写的拷贝数据。其模型如下所示:

Java 内存模型基础 - 图1

所以从这个图示中来看的话,线程1 和线程2 通讯的话,则必须要经过几个步骤

  • 线程1 对本地内存进行更新,并将本地内存的共享变量同步到主内存中
  • 线程2 到主内存中读取线程1 更新之后的共享变量

从整体上来看,线程1 和线程2 通讯势必需要经过主内存(Main Memory) 。JMM 控制了每个线程的本地内存和主内存之间的交互,来为线程之间的可见性提供保证

3、指令重排序

在执行程序时,为了提高程序性能,编译器和处理器都会对程序进行指令重排。重排序有三种:
1、 编译器的优化重排序 。编译器在不改变单线程的语义的前提下可以安排语句的执行顺序。
2、指令级并行的重排序。现代处理器采取了指令级并行技术,可以将多个指令重叠执行,如果不存在数据依赖,处理器可以改变指令的执行顺序。
3、内存重排序。由于处理器使用CPU缓存执行,这使得变量的加载和保存可能是乱序执行。

Java 内存模型基础 - 图2

三种重排序可以分为两类: 其中1属于编译器重排序 & 2,3 输入处理器重排序。

1、对于编译器,JMM 规则会禁止部分指令的重排序(并不是所有的指令都需要禁止重排序)

2、对于处理器重排序,JMM 会在在生成指令序列的时候插入特定的内存屏障(Memory Barriers/Memory Fence)指令,通过内存屏障指令来禁止特定的类型的重排序。

4、并发编程模型的分类

现代的处理器使用缓存区临时保存向内存写入的数据。 写缓存区可以保证指令流水线持续运行,他可以避免由于向内存中写入数据导致的处理器停顿。同时通过合批量写入缓存数据到主内存中,以及合并写入到内存地址的方式减少了对主存总线的占用。

虽然写内存缓存具有多种好处,但每个处理器都具有多个内存缓冲区,这写内存缓存器只对自己的处理器可见。所以会导致一个问题: 处理器对内存的读写操作顺序并不一定与内存实际读写的顺序一致。下面使用具体的例子说明。

  1. // 初始状态
  2. a=b=0;
  3. // 线程A 运行
  4. a=1; // A1
  5. x=b; // A2
  6. FLUSH_CACHE; // A3 同步到主内存
  7. // 线程B 运行
  8. b=2; // B1
  9. y=a; // B2
  10. FLUSH_CACHE; // B3 同步到主内存

处理器按照顺序执行指令,最终可能会输出 x=y=0; 的结果,这是因为

  1. 处理器将共享变量写入到自己的内存缓冲区域(A1,B1)
  2. 然后冲内存中读取另外的共享变量(A2,B2) 此时由于A1,B1操作的并未写入到主内存中,所以读取到的值a=b=0;
  3. 最后将a=b=0 的数据写入到x&y 中,得到x=y=0 的结果。

此内存的角度来看,只有当A3 或者B3 执行的时候才会真正的写入到主内存中,但是对于A线程而言,有可能A1/A2会发现指令重排,导致A2限制性,x被赋值为0. 线程B也是类似的道理,这里不再赘述。

所以犹豫写缓存区进队自己的处理器可见,所以他会导致处理器指令执行顺序可能与内存执行顺序不一致,导致处理器之间出现内存不可见的问题。由于现代处理器都会使用缓存区,所以现代处理器都会语序写-读记性重排序。

所以在处理器执行的时候为了保证内存可见性,JMM 会根据指令重排的规则插入内存屏障指令来禁止重排序,JMM 包内存屏障指令分为4类:

  • LoadLoad 确保Load1 的数据转载优先于Load2
  • LoadStore 确保Load数据装载优先于Store以及后续指令
  • StoreStore 确保Store1对其他处理器可见优先于Store2
  • StoreLoad 确保Store数据对其他线程可见优先于Load指令状态

5、AS-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序)。为了具体说明,请看下面计算圆面积的代码示例:

  1. double pi = 3.14; //A
  2. double r = 1.0; //B
  3. double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

image.png

如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:

image.png

  • 按程序顺序的执行结果:area = 3.14
  • 重排序后的执行结果:area = 3.14

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器, runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

6、 Happens-Before 原则

从JDK5开始,Java使用心得JSR-133内存模型。JSR-133 使用Happens-Before 用于描述操作之间的内存可见性 。笔者一般喜欢称之为先于发横原则。如果一个操作的结果需要对另外一个操作可见,那么这两个操作之间必须要存在Happend-Before 关系。

常见的Happens-Before 原则有以下:

  1. 程序顺序执行规则: 一个线程中的每个操作,先于发生该线程的后续操作
  2. 监视器锁原则: 对一个锁的解锁先于发生随后对这个锁的加锁操作。
  3. Volatile 原则:对于volatile变量的写操作先于发生任意后续这个变量的读操作。
  4. 传递性原则: A 先于发生 B, B先于发生C,那么可以得出,A先于发生C
  5. 线程启动原则: 主线程A启动线程B,线程B可以看到在启动之前线程A的操作
  6. 对象构造原则: 对象的构造方法先于发生finalize() 方法的执行
  7. 线程中断原则: 线程A调用interrupt() 方法先于发生线程B检测到中断
  8. 线程Join规则: 线程A调用join操作,当线程B操作完成后,可以看到线程B的结果

Happens-Before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要一句,依靠这个原则,我们可以解决并发环境下两个操作之间是否存在冲突的所有问题。同时Happend-Before 原则还将JMM的指令重排序规则以及处理器的重排序规则简单化,避免了开发人员去学习复杂的重排序规则预计这些规则的具体实现方式。