文章目录
Java内存模型
提出问题
- 线程之间如何进行通信
- 线程之间如何进行同步
通信
两个通信方式
- 共享内存
- 消息传递
同步
- 共享内存之中,线程间是显示同步的
- 消息传递是隐式同步因为接收消息是发生在发送之后
Java并发使用的是共享内存的模型,其中的通信总是隐式进行的,整个过程对程序员全程透明。
JMM
Java中的实例域,静态域,数组元素都存储在堆内存中,堆内存在线程之间是共享的
JMM定义了一个线程和主存之间的抽象关系:线程之间的共享变量是存储在主内存之中的,每个线程有一个虚拟的本地内存(一个抽象概念),其中包含了缓存,写缓冲区,寄存器等硬件的优化
简易的通信模型
线程A写-》本地内存A-》主存
主存-》本地内存B-》线程B读
写是相当于发送消息,而读就是获得消息
线程之间发送消息必须要经过主存
从源代码到指令序列的重排序
为了提供性能,处理器和编译器都会进行重排序:
以下按顺序进行:
- 编译器重排序
- 指令级重排序
- 内存系统重排序(其中2,3统称为处理器重排序
内存屏障
为了防止一些特定情况下的处理器重排序,JMM需要在一些操作中加入内存屏障指令!!! (Memory Barriers , Intel称之为Memory Fence)
LoadLoad | LoadStore | StoreStore | StoreLoad | 数据依赖 | |
---|---|---|---|---|---|
SPARC-TSO | N | N | N | Y | N |
x86 | N | N | N | Y | N |
IA64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
以上是不同处理器对于内存屏障的支持情况,可以看到Store-Load是基本上处理器都会支持的,它也十分强大,可以代替其他的内存屏障,就是消耗大,同时出现数据依赖情况的话处理器是都不支持内存屏障的。内存屏障是用来支持内存可见性的!
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | 在volatile的读操作后会有此指令 | 确保volatile的读指令先于后续的普通读指令以及其他操作 |
LoadStore | 在volatile的读指令后会有此指令 | 确保volatile的读指令先于后续的普通写指令和其他操作 |
StoreStore | 在volatile的写指令前 | 确保volatile的写指令之前的的Store指令已经对其他处理器可见(刷新到内存) |
StoreLoad | 在volatile的写指令后 | 确保volatile写指令执行完 |
Happens before思想
规则:
- 程序顺序规则:线程中的每个操作,happens-before线程的后续操作
- 监视器操作:一个锁的解锁是happens-before一个锁的加锁
- volatile变量规则:一个volatile变量的写优先于一个volatile变量的读
- 传递性:A - B B-C A-C
注意:这个思想并没有要求这个指令一定按照这个顺序来,这个思想的目的是前一个操作的结果对于下一个操作是可见的。也就是程序员的角度来看,只要最后的结果和按顺序来的一致就好
重排序
数据依赖性
- 写后读
- 读后写
- 写后写
数据依赖性也就是如果数据依赖于前面的数据,如果执行顺序发生改变将会影响程序的结果
所以编译器和处理器就不会对有数据依赖性的数据进行重排。但是这种对数据依赖性的保护只是在一个线程中,在多线程的情况下做不到保护数据依赖性,这时候就需要自己引入同步机制。
as-if-serial
这个语义其实就一个核心,程序执行的结果不能改变,其实执行的顺序可能发生了变化,但是给了程序员一种假象,这个是按他写的代码顺序来执行的。
程序顺序规则
如前文提到,happens-before思想并不会强求去按顺序执行,JMM仅仅是要求前一个的结果对后一个操作可见,并且改变顺序并不会影响执行的结果,那么JMM就会允许这样的重排序
其实软硬件就一个目标:不改变程序的结果的前提下去实现高效
重排序会影响多线程
重排序很有可能会影响多线程的结果
这里有一个格外的点:存在控制依赖关系时,编译器和处理器会采用猜测的方法,先提前读取计算值,并存在重排序缓冲中(Reorder Buffer)。
但是在多线程中,这样的做法,很有可能就会影响计算结果
顺序一致性
数据竞争的概念:
- 在一个线程中写一个变量
- 在另一个线程中读取同一个变量
- 而且写和读没有正确的同步
这样的数据竞争就很容易引起错误的结果
如果程序正确同步了,那么就把这样的执行称之为具有顺序一致性
顺序一致性内存模型
- 在同一个线程中,所有操作按代码顺序执行
- 所有程序都只能看到一个单一的操作执行顺序。在顺序一致性的内存模型中,每一个操作都必须原子执行并且立刻对所有线程可见;
可以理解为在一个时间,内存只允许一个线程使用,使用完以后就要把数据同步
线程A :1, 2, 3
线程B :4, 5, 6
Type1:线程间正确的同步了
1, 2, 3, 4, 5,6
这种执行方式一看就是符合顺序一致性
对于JMM来说,这时候可能会做出一些优化,而这时候的重排序并不会影响程序的结果,而且效率更高;
Type2:没有同步
4, 1, 2, 3, 5, 6
可以看到从整个程序上来看,这是无序的,但是从单个线程来看,这是有序的。
两个线程都能看到这个顺序;这是因为其中每一个操作都是对任意线程立刻可见的
但是在JMM中,并没有保障这个操作都是对任意线程立刻可见的,只有在当前线程的本地内存刷新到了主存中去。那么才会可见。
对于没有同步的线程,JMM只提供最小安全性,也就是你读取到的不能是无中生有的值,都是之前赋值的值或者初始值。
两个模型的差异:
- 顺序一致性保证单线程内的操作按程序的顺序执行,JMM不保证
- 顺序一致性保证所有操作都是对所有线程可见的,JMM不保证
- JMM不保证对于64位的long double的写原子性,而顺序一致性保证。这是因为对32位计算机来说,64位的读写要分成两次来进行。
volatile的内存语义
先进行一个总结之后与锁对比学习
- volatile的写操作相当于消息传递的发送消息
- volatile的读操作相当于获得消息
对volatile的新理解:
volatile long v1 = 0L;
public void set(long l) {
v1 = 1;
}
public void getAndIncreme() {
v1 ++;
}
public long get() {
return v1;
}
相当于把读写上锁:
long v1 = 0L;
public synchronized void set(long l) {
v1 = 1;
}
public void getAndIncreme() {
v1 ++;
}
public synchronized long get() {
return v1;
}
其中两段代码的效果是一致的
总而言之
volatile变量自身特性:
- 可见性。对一个volatile变量的读,总是能看到这个volatile变量最后的写入
- 原子性。对于单个的volatile变量的读/写具有原子性,但是类似于volatile++就不具有
volatile的可见性主要是来自于volatile底层使用了缓存锁定,busring + MESI
从JSR-133开始volatile的写-读实现了线程的通信
写:线程A-》本地内存-》主内存
读:主内存-》本地内存-》线程B
写的时候就会给其他的线程发送消息,我已经对主存中的这个变量进行修改了,你们的本地内存里面的已经无效啦。(也就是MESI协议的操作
其他的线程需要根据主存去修改自己的本地内存;
volatile内存语义的实现
主要还是内存屏障!!!
- 在volatile写之前插入StoreStore
- 在volatile写后面插入StoreLoad
- 在volatile读后面插入LoadLoad
- 在volatile读后面插入LoadStore
内存屏障的插入策略很保守,但可以处理任意的处理器平台。
也体现了JMM的策略,保证程序的正确性的前提下,再去追求执行效率,但是也会根据具体情况去优化缩减内存屏障
JSR-133增强了volatile的内存语义
主要也就是增强了volatile变量的读写和普通变量的读写的重排序规则
锁的内存语义
这个可以对比着前面的volatile来学习
- 释放锁相当于发送消息
- 获得锁相当于获得消息
释放锁happens-before获得锁
volatile写-读 和 锁释放-获得有一样的内存语义
锁内存语义的实现
可重入锁reentrantLock
通过lock()方法获得锁,unlock()去释放锁。底层是依赖了AQS(AbstractQueuedSynchronized)
AQS后期还要细分整理,其中还有共享类型。
它是定义了一个双端队列,队列的每个结点有不同的状态;同时维护一个volatile变量state,去判断锁有多少线程在请求;
ReentrantLock分为公平锁和非公平锁
公平锁
可以理解为排队的锁,一个个排在队列中,等待着去调用,但是需要有唤醒线程的消耗
lock()的调用轨迹:
- ReentrantLock:lock()
- FairSync:lock()
- AbstractQueuedSynchronizer:acquire(int arg)
- ReentrantLock:tryAcquire(int acquires)
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
可以看到,其中获得锁就是在getState这个上,state是一个volatile变量。
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
释放锁就是写State变量
非公平锁
可以理解为有插队的锁,锁到了之后先用CAS去插队,插队成功就占有锁,失败就滚回去排队;
compareAndSetState就是CAS可以代替volatile的读写操作
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
为什么CAS可以实现读写volatile的内存语义
还是根据了cmpxchg这个指令,这个指令帮助实现;
同时会用os::is_MP()做一个判断是否为多处理器机器,如果是的话,CAS的指令需要加lock指令
intel手册上对lock前缀的说明:
- 确保对内存读-改-写的原子执行,在Pentium以及之前的处理器,是进行总线锁,但是这样开销太大,在P6处理器开始,Intel使用缓存锁。
- 禁止该指令之前与之后的读写指令重排
- 把写缓冲区的数据全部刷新到内存
其中2, 3就是内存屏障效果,以及可以完全代替volatile
所以,锁释放-获得的内存语义
- 可以通过volatile的写-读操作
- CAS所附带的volatile的写读内存语义
concurrent包的实现
通用实现模式:
- 声明共享变量为volatile
- 使用CAS的原子条件更新来实现线程之间的同步
- 同时配合volatile的读写和CAS的同样效果来实现线程之间的通信
基本上所有的线程同步和锁都是基于volatile写-读和CAS。