Java内存模型
内容有4个部分:
- 基本概念
- 顺序一致性
- 同步原语的内存语义
- 内存模型的设计原理
3.1 基本概念
并发编程的两个关键问题:
- 线程之间通信: 何种机制交换数据,包括共享内存和消息传递;
- 线程之间同步: 不同线程的操作发生的顺序.
JAVA的线程通信: 共享内存(隐式).
JAVA的内存:
- 堆内存(共享): 实例域,静态域,数组元素;
- 栈内存(不共享): 局部变量,方法参数,异常参数.
线程A,B通信流程:
- 线程A把自己内存中更新过的共享变量刷新到主内存中;(把cpu缓存刷到内存)
- 线程B把到主内存中读取A更新后的共享变量. (把内存刷新到cpu缓存)
3.1.3 源代码到指令序列的重排序
3种重排类型:
- 编译器优化的重排序: 单线程程序语义不改变;
- 指令级并行的重排序: 多线程的指令重排并行执行;
- 内存系统的重排序: 指令实际执行的时候,由于缓存/缓冲的存在,加载和存储可能看起来是乱序执行.
2和3属于处理器重排序.
JAVA内存模型约束:
- 禁止某些编译器优化;
- 插入某些特定类型内存屏障(Memory Fence指令),禁止特定类型处理器重排序.
3.1.4 并发编程模型的分类
写缓冲区: 临时保存向内存写入的数据. (避免cpu等待io)
仅对该cpu可见.
内存屏障指令:
1.LoadLoad Barries
1 | Load1;LoadLoad;Load2 |
2.StoreStore Barries:
1 | Store1;StoreStore;Store2 |
3.LoadStore Barries:
1 | Load1;LoadStore;Store2 |
4.StoreLoad Barries:
1 | Store1;StoreLoad;Load2 |
其中第四个,StoreLoad屏障最严格,会把写缓冲区刷新到内存,开销最大,大部分cpu都支持.
3.1.5 Happens-before规则
- 程序顺序规则: 单线程内顺序一致性; // 有数据依赖的指令不重排. 保证结果和顺序执行一致即可.
- 锁规则: 解锁先于获得锁;
- volidate,原子变量: 写before读;
- 线程启动. Thread.start之后才会有run等其他操作发生.
- 中断. (1)A线程中断B线程;(2)B检测到中断. 保证(1)在(2)前面.
- 终结器. 构造函数在终结器之前执行完成.
- 传递性: 上述规则可以传递.
上述规则被JVM翻译成各种约束(具体来说就是在指令里加一些内存屏障),影响了编译器重排序和处理器重排序.
3.2 顺序一致性
顺序一致性
顺序一致性: 所有线程看到的执行顺序一致.
正确使用了同步机制,才能达到顺序一致性.
主要解决的是以下两者的矛盾:
(1) 程序员希望指令重排的约束越多越好, 保证执行结果好理解;
(2) 编译器/cpu希望约束越少越好,执行可以快一些.
顺序一致性: 约束的数量够用,让程序运行的结果与完全约束(顺序执行)一致.
3.2.1 数据依赖性
编译器和处理器:
- 单线程: 考虑数据依赖性; (写后读,写后写,读后写)
- 不同线程\不同处理器: 不考虑数据依赖性.
3.2.2 as-if-serial语义
编译器和处理器: 单线程语义不变.
// 约束够用,执行结果和串行执行结果一致.
一些约束的具体实现方案:
volatile变量
读: 读取最新的内存值(不一定是CPU缓存值); (有原子性)
写: 写入cpu缓存后,刷新到内存; (有原子性)
自增: 相当于先读后写两个操作, 不具有原子性.
用volatile
代替锁.(从内存语义上说,由于它与锁有相同内存效果,可以实现)
1 | volatile boolean flag=false; |
- 对A来说,由于单线程的语义符合程序顺序规则,flag=true发生在a=10之后;
- 对B来说,由于flag是volatile变量,flag=true之后才能取到i. 因此达到了通信的效果,B中能正确取到A中的a的10.
3.4.4 volatile内存语义的实现
内存屏障:(JMM内存模型采用的是保守策略,保证在所有cpu上都能正确)
- volatile写前面插入StoreStore屏障;
- volatile写后面插入StoreLoad屏障;
- volatile读后面插入LoadLoad屏障;
- volatile读后面插入LoadStore屏障.
volatile写指令序列:
1 | 普通读写 |
volatile读指令序列:
1 | volatile读 |
这俩屏障x86里没有.因为x86只有写-读重排序,没必要有.
此外,个人理解这俩屏障也没啥意义,唯一能想到的用处还是让voldatile
具有锁的功能. 毕竟是JSR-133之后才加的.
3.5 锁的内存语义
ReentrantLock
的实现依赖于:
1 | FariSync |
其中AQS里头有个volatile
变量:
1 | private volatile int state; |
锁的内存语义基本就靠这个volatile
变量和CAS操作.
1.加锁流程:(公平锁)
1 | 获取volatile变量state; |
2.解锁流程:(公平锁)
1 | 一些操作... |
CAS操作能保证(读-改-写)操作原子执行.
CAS操作的底层实现:
1 | LOCK总线; |
3.5.4 concurrent包的实现
实现层次如下:
1 | LOCK,同步器,阻塞队列,Executor,并发容器 |
3.6 final域的内存语义
3.6.1 final域的重排序规则
- 在构造函数内对一个final域的写入,与随和把这个对象的引用赋值给一个引用变量,这俩操作不能重排;
- 初次读包含final域的对象,初次读这个final域,这俩操作不能重排.
规则1的实现(构造函数中):
1 | final域的写 |
示例代码:
1 | FinalObj obj=inputObj; // 别的线程负责创建的对象 |
规则2的实现:
1 | LoadLoad屏障// 保证先Load到引用. (明明有数据依赖) |
大多数cpu不需要这个规则也能正确进行final读,但还是有少部分cpu会无视间接依赖,因此需要这个规则.(需要加入这个屏障)
示例代码:
1 | FinalObj obj=inputObj; // 别的线程负责创建的对象 |
最后,由于x86处理器很多重排并不会做,所以其实final域的内存屏障都不需要加,会被省略.
3.8.4 类初始化中的约束
类的初始化是啥:
Class加载=>连接=>初始化=>使用=>卸载;
其中连接分为:
验证=>准备=>解析.
具体来说:
- 加载
- 通过类全名获取二进制字节流;
- 将字节流中的静态存储(常量)转换为方法区的运行时数据;
- JAVA堆中生成代表类的Class对象,指向方法区的数据.
验证: 格式/元数据/访问验证;
准备: 类变量(非常量)分配到堆,赋予初值;
解析
4类符号引用的解析: 类/接口,字段,类方法,接口方法. 把这些引用解析到实际的目标对象.
这个和初始化过程可能混在一起.初始化
- 分配对象的内存空间
- 初始化对象
- 设置instance指向内存空间
初始化发生的时机://
T
是一个类- T的实例被创建;// 如
new
- T的静态方法被调用;//
static
- T的静态字段(非常量)被使用/赋值; //
static
成员被读写.
// 对于第3点,如果是类常量,加载阶段已经完成了.
- T的实例被创建;// 如
还有一个时机:
4.T是一个顶级类,而且一个断言语句嵌套在T内部被执行.// TODO
// 这个没懂.= = ||
小结:
static常量: 加载期完成,存储在方法区,从堆区Class对象指过去;
static变量: 准备期分配到堆,初始化阶段赋值.
成员变量: 初始化阶段完成.
类初始化存在竞态条件
多个线程可能同时对一个类进行初始化,因此需要锁.
JVM对于每一个类或接口都有唯一的初始化锁LC
.(放在Class
对象中)
初始化流程:
- 获取
Class
对象中的LC
锁; - 读取
Class
对象的state,发现是noInitialization
,设置为initializing
;// 表示从未初始化
改成正在初始化
,以便别的线程知道; - 释放
LC
锁. // 这个时候别的线程可以获取到锁,然后知道有人正在初始化,于是等待. - 初始化类的static变量;
- 分配实例对象内存空间,初始化对象,赋值地址给引用(解析);
- 设置state=
initializated
. // 初始化结束. 唤醒等待的线程.
基于上述过程的单例如下:
1 | public class A{ |
相比于双检,利用了Class
对象的初始化锁LC
. 达到线程安全的目的.
上述初始化流程的第5,6步顺序不会重排,因此可以不用volatile
来保证引用的解析晚于对象的构建;
之所以使用内部类B: 想达到懒汉的目的.
如果不用内部类, 可以这么写:
1 | public class A{ |
区别在于导致A被初始化的途径多,而上一个方法中导致B被初始化的途径少(只有getC一个途径,毕竟B是private).
对于第一个方法:
A被初始化=>没关系;
调用getC()方法=>B被初始化=> c对象构建.
对于第二个方法:
A被初始化=> c对象构建.
A被初始化的途径很多,(A的静态方法被访问,A的静态变量被访问,A被创建实例,A中有断言执行),如果要让第二个方法达到足够懒,需要额外做的事情:
- 只有getC()一个静态方法;
- 只有C一个静态变量;
- 不让创建对象(private构造函数)
- 没有断言执行嵌套在内部.
不过两种方法都不能抵挡序列化.(得用枚举才行)
3.9 JAVA内存模型综述
3.9.1 CPU内存模型
根据重排的剧烈程度,或者说约束的强弱,可以把CPU划分为几种类型:
(性能由低到高,约束逐渐减少)
- TSO模型(Total Store Order). 写-读顺序会进行重排;// 因为要使用写缓冲区
- PSO模型(Partial Store Order). 写-读,写-写都会进行重排.
- RMO模型(Relaxed Memory Order). 写-读,写-写,读-读,读-写都会进行重排.
(PowerPC内存模型也是)
上述重排都在遵守数据依赖的前提下,不然连单线程程序的正确性都无法保证了.
所有CPU都起码是TSO模型,因为都需要写缓冲区.
3.9.2 各种内存模型之间的关系
JAVA内存模型: JMM. 是语言级内存模型, 把底层的CPU的内存模型进行封装, 通过加入内存屏障, 让底层对于程序员来说变成一致的JMM. (平台无关)