内存一致性
并发编程三要素
- 原子性(atomic): 操作不能再分割.
- 有序性(ordering): 执行结果有序. 无序, 每次结果可能不一样. 有序, 就能在其中间建立一时间节点, 能够知道一些操作的先后顺序.
- 可见性(visibility): 和有序关联. 因为有序所以可见. 所有都能观察到.
经典计算机 不存在同一个时刻某个变量有多种状态, 而 量子计算机 允许一个事物同时存在多种状态.
不同观测者对历史的理解不一致. 如果处理器对某个变量进行了修改, 可能只是体现在该核心的缓存里, 而运行在其它核心上的线程, 可能加载的是旧状态, 这就很可能导致一致性的问题, 数据的正确性.
在某个时刻, 线程1, 线程2观察内存, 会不会得到不同的版本? 图中, 线程2认为版本3的写操作已经发生; 线程1认为版本3的写操作还没有发生. 产生分歧, 这样称为不一致.
- 线性一致性: 任何时刻都一致. Sequential Consistency. 最强, 程序员不需要在意并发产生的一致性问题. 单线程模型就是线性一致性的.
- 弱一致: 部分时刻一致. Weak Consistency. 需要同步元语(primitives).
- 锁(Lock), 信号量(Semaphore)
happens-before
关系,volatile
…- 不使用元语(工具), Java是弱一致性的
- 没有一致: 无法确定何时一致
有序性: 任何时刻观察到的历史是一致的.
指令重排和分级缓存策略的存在导致读取不一致.
L1/L2 一般都是每个核心都有的, 而L3是公用的. 逐级读取.
分级缓存策略: CPU, 缓存 和 内存 的关系.
如果其它CPU核心去读取, 可能还是会读到a=0的, 缓存间有同步机制的, 但是有不确定性.
这样就导致了, 线程A和线程B对a, b的值理解是不一致的.
CAS
解决原子性, volatile
解决可见性, happens-before
原则保证有序性。
happens-before
static int a = 0;
public static void main(String[] argv) {
Runnable r1 = () -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 10000;
System.out.println("a="+a);
};
Runnable r2 = () -> {
System.out.println("enter a =" + a);
while(a < 100) {
}
System.out.println("end" + a);
};
new Thread(r1).start();
new Thread(r2).start();
}
偶尔有无限循环, 原因是线程1,2执行在多个CPU时, 线程2的缓存数据没有更新.
本质: a=10000
没有 happens-before
a<100
的判断.
happens-before
: 如果事件A逻辑上发生早于事件B, 那么事件B发生时应该可以看到事件A的结果, 也就是事件A的结果对于事件B可见.
volatile static int a = 0;
这样如果写入事件a=10000
从时间上早于读取时间a<100
, 那么最终观察到的结果就是a=10000
.
happends-before
的八大原则
- 程序次序规则 : 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于书写在后面的操作.
- 锁定规则 : 一个
unLock
操作先行发生于后面对同一个锁的lock
操作. volatile
变量规则 : 对一个变量的写操作先行发生于后面对这个变量的读操作.- 传递规则 : 如果 操作A 先行发生于 操作B, 而 操作B 又先行发生于 操作C, 则可以得出 操作A 先行发生于 操作C.
- 线程启动规则 :
Thread
对象的start()
方法先行发生于此线程的每一个动作. - 线程中断规则 : 对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测中断事件的发生. - 线程终结规则 : 线程中所有的操作都先行发生于线程的终止检测, 可以通过
Thread.join()
方法结束,Thread.isAlive()
方法的返回值检测到已经终止执行. - 对象终结规则 : 一个对象的初始化完成先行发生于他的
finalize()
方法的开始.
happens-before关系
- 单线程规则: 单线程对内存的访问符合
happens-before
规则. Monitor
规则:synchronized
对锁的释放happens-before
对锁的获取.volatile
规则: volatile 变量的写happens-before
读.Thread Start
规则: start() 调用前的操作happens-before
线程内的程序.Thread.join
规则: 线程的最后一条指令happens-before
join后的第一条指令.happens-before
传递性: 如果 Ahappens-before
B, Bhappens-before
C, 那么 Ahappens-before
C.- Relaxed Atomic acquire/release 规则(Java 9)
总结: happens-before
不是时间关系.
- happens-before 是发生顺序和观察到的结果关系.
- A happens-before B. 如果 A 在 B 前发生, 那么 A 带来的变化在 B 可以观察到(对B时刻在观察的线程可见).
- happens-before 是 parties ordering(部分有序). 参考 Relaxed Atomics, 重要的顺序保证, 其它仍然可以重排.
如果两个操作不满足上述任意一个happens-before
规则, 那么这两个操作就没有顺序的保障, JVM 可以对这两个操作进行重排序; 如果 操作A happens-before
操作B, 那么 操作A 在内存上所作的操作对 操作B 都是可见的.
指令重排
是一种CPU策略, 通过交换指令执行的顺序获得最佳性能, 处理器和编译器都能执行指令重排优化. 例如, 能先读到缓存数据的指令先被计算, 一个指令读取一个数值, 在 L3 才有的, 而另外一个指令读取的值在 L1 就有了, 那么这条指令就会被放在前面, 先执行.
Java虚拟机在模拟计算机, 因此也引入了指令重排技术. 部分指令Java虚拟机明确知道性能差异的情况下可以进行优化. 要满足以下条件:
- 在单线程环境下不能改变程序运行的结果.
- 存在数据依赖关系的不允许重排序.
也就是说无法通过 happens-before
原则推导出来的, 才能进行指令重排序.
总结: 内存不一致是相对的, 需要观察者; 成因, 分级缓存策略和指令重排.
volatile
volatile 关键字. 确保语义上对变量的读写操作顺序被观察到.
- 对
volatile
变量的读写不会被重排到对它后续的读写之后.(阻止指令重排) - 保证写入的值可以马上同步到 CPU 缓存中(写入后要求CPU马上刷新缓存)
- 保证读取到最新版本的数据(读L3, 主存等, 甚至使用内存屏障, 不同架构方式不同)
如果逻辑上, 变量的写在读之前发生, 那么确保观察到的结果, 写也在读之前发生.
volatile 作用:
volatile
变量读写时会增加内存屏障(Memory Barrier).volatile
变量读写时会禁用局部指令重排.- 保证对
volatile
的操作happens-before
另一个操作.
读屏障: 就是在读取volatile
变量之前增加一条将变量的值从 内存 读到 CPU缓存 的指令.
写屏障: 就是在写volatile
变量之后, 将变量的值从 CPU缓存 写入 内存.
happens-before 关系: 如果事件A应该在时间B之前发生, 那么观察到的结果也是如此; 时间关系的一致性.
确保可见性, 有序性.
指令重排只保证串行语义的执行一致性, 即是单线程执行的一致性. 不会关心多线程语义的一致性.
volatile应用举例: 双检查单例模型
class Foo {
static volatile DbConnection mysqlConnection;
public static DbConnection getDb(){
DbConnection localRef = mysqlConnection;
if(localRef == null) {
synchronized(Foo.class) {
localRef = mysqlConnection;
if(localRef == null) {
mysqlConnection = localRef = new MysqlConnection(...);
}
}
}
return mysqlConnection;
}
}
另外, 其实有一个更好的做法, 是利用Atomic
类. Atomic
利用cas
直接可以让进程进步, 例如这样实现:
static AtomicReference<DbConnection> ref = new AtomicReference<>();
public static DbConnection getDb(){
// 读取当前的ref内存中的真实值
var localRef = ref.getAcquire();
if(localRef == null) {
synchronized (Foo.class) {
localRef = ref.getAcquire();
if(localRef == null) {
localRef = new DbConnection();
// 设置新的ref
ref.setRelease(localRef);
}
}
}
return localRef;
}
volatile
是一个更强的排序, 影响范围更大, 是阻止指令重排; acquire/release
约束范围更小(松弛技术), 允许一定范围的指令重排.
指令1
指令2
acquire/release/volatile
指令3
指令4
- 上面的场景
volatile
会保证顺序: 1, 2, volatile, 3, 4. acquire/release
只保证自己在 1, 2 之后, 在3, 4之前.
volatile 与 synchronized
volatile
本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的, 需要从主存中读取;synchronized
则是锁定当前变量, 只有当前线程可以访问该变量, 其它线程被阻塞住, 直到该线程完成变量操作为止.- 作用范围不同,
volatile
仅能使用在变量级别;synchronized
则可以使用在变量, 方法和类级别. volatile
仅能实现变量的修改可见性, 不能保证原子性; 而synchronized
则可以保证变量修改的可见性和原子性.volatile
不会造成线程的阻塞;synchronized
可能会造成线程阻塞.volatile
标记的变量不会被编译器优化;synchronized
标记的变量可以被编译器优化.