java并发编程的艺术笔记-第三章

Java内存模型

内容有4个部分:

  1. 基本概念
  2. 顺序一致性
  3. 同步原语的内存语义
  4. 内存模型的设计原理

3.1 基本概念

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

  1. 线程之间通信: 何种机制交换数据,包括共享内存和消息传递;
  2. 线程之间同步: 不同线程的操作发生的顺序.

JAVA的线程通信: 共享内存(隐式).

JAVA的内存:

  1. 堆内存(共享): 实例域,静态域,数组元素;
  2. 栈内存(不共享): 局部变量,方法参数,异常参数.

线程A,B通信流程:

  1. 线程A把自己内存中更新过的共享变量刷新到主内存中;(把cpu缓存刷到内存)
  2. 线程B把到主内存中读取A更新后的共享变量. (把内存刷新到cpu缓存)

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

3种重排类型:

  1. 编译器优化的重排序: 单线程程序语义不改变;
  2. 指令级并行的重排序: 多线程的指令重排并行执行;
  3. 内存系统的重排序: 指令实际执行的时候,由于缓存/缓冲的存在,加载和存储可能看起来是乱序执行.

2和3属于处理器重排序.

JAVA内存模型约束:

  1. 禁止某些编译器优化;
  2. 插入某些特定类型内存屏障(Memory Fence指令),禁止特定类型处理器重排序.

3.1.4 并发编程模型的分类

写缓冲区: 临时保存向内存写入的数据. (避免cpu等待io)
仅对该cpu可见.

内存屏障指令:
1.LoadLoad Barries

1
2
Load1;LoadLoad;Load2
// 确保Load1的装载先于Load2及后续Load指令

2.StoreStore Barries:

1
2
3
Store1;StoreStore;Store2
// 确保Store1的数据先于Store2及后续Store指令
// 刷新到内存

3.LoadStore Barries:

1
2
Load1;LoadStore;Store2
// 确保Load1的装载先于Store2及后续Store指令

4.StoreLoad Barries:

1
2
3
4
Store1;StoreLoad;Load2
// 确保Store1的装载先于Load2及后续Load指令
// 刷新到内存.
// 且会使它之前的所有内存访问指令(Load和Store)都完成后,才执行之后的.

其中第四个,StoreLoad屏障最严格,会把写缓冲区刷新到内存,开销最大,大部分cpu都支持.

3.1.5 Happens-before规则

  1. 程序顺序规则: 单线程内顺序一致性; // 有数据依赖的指令不重排. 保证结果和顺序执行一致即可.
  2. 锁规则: 解锁先于获得锁;
  3. volidate,原子变量: 写before读;
  4. 线程启动. Thread.start之后才会有run等其他操作发生.
  5. 中断. (1)A线程中断B线程;(2)B检测到中断. 保证(1)在(2)前面.
  6. 终结器. 构造函数在终结器之前执行完成.
  7. 传递性: 上述规则可以传递.

上述规则被JVM翻译成各种约束(具体来说就是在指令里加一些内存屏障),影响了编译器重排序和处理器重排序.

3.2 顺序一致性

顺序一致性

顺序一致性: 所有线程看到的执行顺序一致.
正确使用了同步机制,才能达到顺序一致性.
主要解决的是以下两者的矛盾:
(1) 程序员希望指令重排的约束越多越好, 保证执行结果好理解;
(2) 编译器/cpu希望约束越少越好,执行可以快一些.
顺序一致性: 约束的数量够用,让程序运行的结果与完全约束(顺序执行)一致.

3.2.1 数据依赖性

编译器和处理器:

  1. 单线程: 考虑数据依赖性; (写后读,写后写,读后写)
  2. 不同线程\不同处理器: 不考虑数据依赖性.

3.2.2 as-if-serial语义

编译器和处理器: 单线程语义不变.
// 约束够用,执行结果和串行执行结果一致.


一些约束的具体实现方案:

volatile变量

读: 读取最新的内存值(不一定是CPU缓存值); (有原子性)
写: 写入cpu缓存后,刷新到内存; (有原子性)
自增: 相当于先读后写两个操作, 不具有原子性.

volatile代替锁.(从内存语义上说,由于它与锁有相同内存效果,可以实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
volatile boolean flag=false;
A:
a=10;
flag=true;

B:
int i;
while(true){
if(flag){
i=a;
break;
}
}
  1. 对A来说,由于单线程的语义符合程序顺序规则,flag=true发生在a=10之后;
  2. 对B来说,由于flag是volatile变量,flag=true之后才能取到i. 因此达到了通信的效果,B中能正确取到A中的a的10.

3.4.4 volatile内存语义的实现

内存屏障:(JMM内存模型采用的是保守策略,保证在所有cpu上都能正确)

  1. volatile写前面插入StoreStore屏障;
  2. volatile写后面插入StoreLoad屏障;
  3. volatile读后面插入LoadLoad屏障;
  4. volatile读后面插入LoadStore屏障.

volatile写指令序列:

1
2
3
4
5
普通读写
StoreStore屏障// 先把前面的Store刷新到内存,再执行volatile写.(其实没必要,只是新版本赋予了volatile锁的内存语义)
volatile写
StoreLoad屏障 // 先把volatie写的store刷新到内存,再执行读.(免得读到污染值)
volatile读

volatile读指令序列:

1
2
3
4
volatile读
LoadLoad屏障 // 禁止下面的读越过volatile
LoadStore屏障// 禁止下面的写越过volatile
普通读写

这俩屏障x86里没有.因为x86只有写-读重排序,没必要有.
此外,个人理解这俩屏障也没啥意义,唯一能想到的用处还是让voldatile具有锁的功能. 毕竟是JSR-133之后才加的.

3.5 锁的内存语义

ReentrantLock的实现依赖于:

1
2
3
4
FariSync
NonfaiSync
Sync
AQS(AbstrackQueuedSynchronizer)

其中AQS里头有个volatile变量:

1
private volatile int state;

锁的内存语义基本就靠这个volatile变量和CAS操作.

1.加锁流程:(公平锁)

1
2
3
获取volatile变量state;
CAS操作更改state变量的值.
若更改成功则获取锁成功.

2.解锁流程:(公平锁)

1
2
3
一些操作...
写state变量.
其他线程立刻发现了state变化,可以竞争这个锁了.

CAS操作能保证(读-改-写)操作原子执行.
CAS操作的底层实现:

1
2
3
LOCK总线;
禁止该指令与前后读写指令重排;
将写缓冲区数据刷新到内存.

3.5.4 concurrent包的实现

实现层次如下:

1
2
3
LOCK,同步器,阻塞队列,Executor,并发容器
AQS,非阻塞数据结构,原子变量类
volatile变量的读写,CAS

3.6 final域的内存语义

3.6.1 final域的重排序规则

  1. 在构造函数内对一个final域的写入,与随和把这个对象的引用赋值给一个引用变量,这俩操作不能重排;
  2. 初次读包含final域的对象,初次读这个final域,这俩操作不能重排.

规则1的实现(构造函数中):

1
2
3
final域的写
StoreStore屏障 // 先把上述写(Store)的结果刷新到内存. 再写引用.
构造函数return // 把对象地址赋值给引用

示例代码:

1
2
3
FinalObj obj=inputObj; // 别的线程负责创建的对象
int a=obj.i;// 如果i是final的,那一定能读到初始化以后的值;
int b=obj.j;// 如果j不是final的,那可能读到还没初始化的值.

规则2的实现:

1
2
LoadLoad屏障// 保证先Load到引用. (明明有数据依赖)
final域的读

大多数cpu不需要这个规则也能正确进行final读,但还是有少部分cpu会无视间接依赖,因此需要这个规则.(需要加入这个屏障)
示例代码:

1
2
3
FinalObj obj=inputObj; // 别的线程负责创建的对象
int a=obj.i;// 如果i是final的,那会等obj读到对象引用后再去读i;
int b=obj.j;// 如果j不是final的,那可能没等到读到对象obj,就直接取读j了,读取错误.

最后,由于x86处理器很多重排并不会做,所以其实final域的内存屏障都不需要加,会被省略.

3.8.4 类初始化中的约束

类的初始化是啥:

Class加载=>连接=>初始化=>使用=>卸载;
其中连接分为:
验证=>准备=>解析.

具体来说:

  • 加载
  1. 通过类全名获取二进制字节流;
  2. 将字节流中的静态存储(常量)转换为方法区的运行时数据;
  3. JAVA堆中生成代表类的Class对象,指向方法区的数据.
  • 验证: 格式/元数据/访问验证;

  • 准备: 类变量(非常量)分配到堆,赋予初值;

  • 解析

    4类符号引用的解析: 类/接口,字段,类方法,接口方法. 把这些引用解析到实际的目标对象.
    这个和初始化过程可能混在一起.

  • 初始化

    1. 分配对象的内存空间
    2. 初始化对象
    3. 设置instance指向内存空间
  • 初始化发生的时机:// T是一个类

    1. T的实例被创建;// 如new
    2. T的静态方法被调用;// static
    3. T的静态字段(非常量)被使用/赋值; // static成员被读写.
      // 对于第3点,如果是类常量,加载阶段已经完成了.

还有一个时机:

4.T是一个顶级类,而且一个断言语句嵌套在T内部被执行.// TODO
// 这个没懂.= = ||

小结:

static常量: 加载期完成,存储在方法区,从堆区Class对象指过去;
static变量: 准备期分配到堆,初始化阶段赋值.
成员变量: 初始化阶段完成.

类初始化存在竞态条件
多个线程可能同时对一个类进行初始化,因此需要锁.
JVM对于每一个类或接口都有唯一的初始化锁LC.(放在Class对象中)
初始化流程:

  1. 获取Class对象中的LC锁;
  2. 读取Class对象的state,发现是noInitialization,设置为initializing;// 表示从未初始化改成正在初始化,以便别的线程知道;
  3. 释放LC锁. // 这个时候别的线程可以获取到锁,然后知道有人正在初始化,于是等待.
  4. 初始化类的static变量;
  5. 分配实例对象内存空间,初始化对象,赋值地址给引用(解析);
  6. 设置state=initializated. // 初始化结束. 唤醒等待的线程.

基于上述过程的单例如下:

1
2
3
4
5
6
7
8
9
10
11
public class A{ 
private static class B {
public static C c=New C();
}

public static C getC(){
return B.c; // 导致B类初始化.
}
}
// 使用时:
C c=A.getC(); // 导致A被初始化.

相比于双检,利用了Class对象的初始化锁LC. 达到线程安全的目的.
上述初始化流程的第5,6步顺序不会重排,因此可以不用volatile来保证引用的解析晚于对象的构建;

之所以使用内部类B: 想达到懒汉的目的.
如果不用内部类, 可以这么写:

1
2
3
4
5
6
7
8
9
public class A{ 
private final static C c=new C();
private A(){}
public static C getC(){
return c;
}
}
// 使用:
C c=A.getC(); // 导致A被初始化.

区别在于导致A被初始化的途径多,而上一个方法中导致B被初始化的途径少(只有getC一个途径,毕竟B是private).
对于第一个方法:

A被初始化=>没关系;
调用getC()方法=>B被初始化=> c对象构建.

对于第二个方法:

A被初始化=> c对象构建.

A被初始化的途径很多,(A的静态方法被访问,A的静态变量被访问,A被创建实例,A中有断言执行),如果要让第二个方法达到足够懒,需要额外做的事情:

  1. 只有getC()一个静态方法;
  2. 只有C一个静态变量;
  3. 不让创建对象(private构造函数)
  4. 没有断言执行嵌套在内部.

不过两种方法都不能抵挡序列化.(得用枚举才行)

3.9 JAVA内存模型综述

3.9.1 CPU内存模型

根据重排的剧烈程度,或者说约束的强弱,可以把CPU划分为几种类型:
(性能由低到高,约束逐渐减少)

  1. TSO模型(Total Store Order). 写-读顺序会进行重排;// 因为要使用写缓冲区
  2. PSO模型(Partial Store Order). 写-读,写-写都会进行重排.
  3. RMO模型(Relaxed Memory Order). 写-读,写-写,读-读,读-写都会进行重排.
    (PowerPC内存模型也是)

上述重排都在遵守数据依赖的前提下,不然连单线程程序的正确性都无法保证了.
所有CPU都起码是TSO模型,因为都需要写缓冲区.

3.9.2 各种内存模型之间的关系

JAVA内存模型: JMM. 是语言级内存模型, 把底层的CPU的内存模型进行封装, 通过加入内存屏障, 让底层对于程序员来说变成一致的JMM. (平台无关)

推荐文章