1.1 内存屏障的延申
上章我们讲到了volatile之所以会阻止重排序的发生是因为。使用了两个写内存屏障。关于内存屏障可以查看:
volatial探讨(一)-重排序和内存屏障
内存屏障 | 伪代码 | 说明 |
---|---|---|
LoadLoad Barrier | Load; barrier; load | 在A指令执行load的时候,B指令的load不能插队 |
StoreStore Barrier | store ;barrier; store | 在A指令执行写的时候,B指令的写操作不能插队。刷新缓存 |
LoadStore Barrier | load; barrier;store | 在A指令执行读的时候,B指令的写不能插队 |
StoreLoad Barrier | store:load;barrier | 在A指令执行写的时候,B指令的读不能插队。刷新缓存 |
volatitle写操作加的内存屏障
------store-store-barrier--------
volatile-写
------store-Load-barrier--------
volatitle读操作加的内存屏障
----load-load-barrier----
volatitle-读
----load-store-barrier—
我们能看到两个写内存屏障会发起一个刷新缓存的操作。
那么这个缓存刷新的过程是什么呢?
1.1.2 barrier发起的刷新缓存的过程
想要详细的理解刷新的过程,首先我们要先了解一下cpu的一些组成
我们首先要理解一下cpu的缓存架构
L1i:cpu的指令缓存
L1D:cpu的数据缓存
L2Cache:cpu的级缓存
L3-cache:多个cpu共享的3级缓存
Bus:cpu总线,用来控制与cpu外部硬件进行沟通,例如内存等部位
每层缓存都要从上层缓存区数据。
L1和L2的缓存都在cpu内部,所以就会引来一个新的问题,即缓存一致性的问题,例如cpu1对L2的缓存做了修改,这个时候对cpu2来说是不可见的。在某些情况下这中结果会是灾难性的。
那么我们来看一个由于缓存不同步引起的经典的案例
1.1.1
先看一个样例,在volatile修饰下不同线程的的表现形式
示例-1
package com.gxw.first.code.volite;
public class Visibility {
public boolean flag = true; // 注释1-1
public volatile boolean flag = true; //注释1-2
public static void main(String[] args) throws InterruptedException {
Visibility visibility = new Visibility();
//t1负责执行死循环
Thread t1 = new Thread(() ->
{
System.out.println("execute begin!");
//死循环执行器
visibility.execute();
System.out.println("execute end");
}
);
//t2负责结束t1的死循环
Thread t2 = new Thread(() -> {
visibility.updateFlag();
System.out.println("update flag false!");
}
);
t1.start();
Thread.sleep(5000);
t2.start();
t1.join();t2.join();
}
public void execute() {
int i=1 ,j=0;
while (flag) {
i++;
/* 注释1-3
if (flag&&((i%100)==0)){
j++;
System.out.print(".");
if (j%150==0){
System.out.println("");
}
}
*/
}
}
public void updateFlag() {
this.flag = false;
}
}
解析 :
1、在flag没有被volatile修饰的时候 打开注释1-1
执行结果
>execute begin!
>update flag false!
**程序未退出**
2、在有volatile修饰的时候 打开注释1-2
执行结果
>execute begin!
>update flag false!
>execute end
>
>Process finished with exit code 0
>**程序退出**
3、在无volatile修饰的时候,执行方法中有System.out.println() 打开注释1-3,1-1
执行结果
>execute begin!
>......................................................................................................................................................
>......................................................................................................................................................
>.....................................................................................................................
>update flag false!
>execute end
>
>Process finished with exit code 0
>**程序退出**
可以看到变量在没有volital修饰的情况下,一个线程对于flag的修改对于另一个线程不可见。
原理:在使用了StoreStoreBarrier和StoreLoadBarrier这两种内存屏障的的时候,会强制cpu刷新cache区中的变量,重新从内存中读取。
根据jvm规范:
1、lock 解锁时会触发刷新
2、volatile 写操作时
3、final 写操作的时候
在操作系统的定义中,一个cpu核心可以并发的来执行多个线程,但是不能并行处理。
cpu在每个线程的时间片执行完毕后,会保存线程的上下文进行切换。所以对线程来
说即使两条线程是同一个CPU来执行,对于线程来说是无感知的(这个说法并不完全
精确,但是可以这样理解,有兴趣的可以详细的查看计算机组成原理和操作系统的
相关教材),所以下文举例中所有的CPU都是逻辑意义上线程感知到的CPU。
我们来看一下cpu交互的过程
可以看到,cpu2在执行另一条线程的时候,已经把从内存中读取的变量改为了flase值,而这个时候他的修改对cpu1并不可见,所以cpu-1仍在继续执行循环逻辑
这个时候细心的小朋友已经发现,开头的表格中说,内存屏障可以用于刷新缓存,那么是cpu是怎么通过内存屏障刷新缓存的呢?
我们来看一种解决方案
1.1.3 缓存一致性协议
在开始读取的时候因为值没有被修改,所有的缓存都是一致的
图:A-1
当其中一个线程修改后
这个时候cpu-1和cpu-2中的缓存由于没有感知到变化所以依旧会继续执
图:A-2
cpu会向总线发出一个Invalid指令,此时CPU-1和CPU-2就感知到flag这个变量发生了改变
图A-3
这个时候,CPU-1和CPU-2收到Invalid指令后,会反馈一个ack给CPU-3。当CPU-3收到所有的ACK后,CPU-1和CPU-2就会重新同步数据。
图A-4
最终3个cpu的缓存通过S指令的重新读取缓存后,就变为一致的
图A-5
以上的过程叫做缓存同步,或者叫做缓存一致过程
当然真正的缓存一致定制了一个比较复杂的协议,叫做缓存一致性协议
这个协议制定了4种缓存状态
M状态(修改) | E状态(独享) | S状态(共享) | I状态(无效) |
---|
M状态:在图A-2中,CPU-3在flag值发生修改的时候会将缓存修改为M状态
E状态:当图A-1中CPU-2未加载Flag值到缓存中的时候,缓存状态未E状态
S状态:在图A-1中两个cpu同时使用了flag的缓存,缓存状态被称为S状态
I状态:在图A-3中flag的值被修改的情况下,CPU-3会把自己的缓存修改为I状态
以上步骤就是完整的缓存同步步骤,是不是还有些疑惑?同步缓存要通知到每个CPU,等待ACK的时间这么久?CPU-3在等ACK的时候岂不是浪费了很多时间?
还是那句老话,解决问题的时候总是会引发新的问题。
我们来看看缓存同步的过程是如何优化的
1.1.3 优化后的缓存一致性协议
1.1.3.1Store Buffer的引入
cpu发出Invalid指令后要等待各个CPU发出的ACK回执时间较长,浪费了CPU的资源。所以,引入了Store Buffer(写入缓存) 使CPU进行异步写操作。
StoreBuffer是一个环形(Ring)的buffer,每个cpu持有StoreBuffer的一个节点,
当一个CPU要进行修改操作的时候,会将修改的结果写入StoreBuffer中同时
对其他cpu发出Invalid指令,其他CPU在进行ack回复后,会先尝试从StoreB
uffer中取出修改后的值。
1.2.1 总线和位宽
再说一说CPU内部的缓存和总线的位宽
cpu总线分为三个部分
1、地址总线
2、数据总线
3、控制总线
地址总线:CPU用来寻找内存的地址
地址总线的位宽决定了,cpu寻找内存地址的速度
数据总线:数据总线的宽度决定了CPU与其他器件进行数据传送时,一次可以传送多少数据。
数据总线:一个数据总线只能传输一个二进制位
数据总线
可以看到控制总线负责发出读写信号,而后地址总线负责寻址,数据总线负责发出数据流进行写入。相互之间协调进行内存数据的读写。
1.1.4 位宽与volatile之间的关联
根据前面的缓存一致性协议我们可以了解到,在发出Invalid指令后会导致
其他cpu将自己缓存内的值置为失效。那么问题来了,需要失效的缓存有哪些?
最好的情况 flag=true,只使true这一个变量失效,但是不同数据类型的长度
是不同的,cpu无法感知自己运算的每个数据类型。那么问题来了。要失效哪些
内存效率可以达到最高呢?
从上面的cpu总线图可以知道,cpu读取数据总是按照位宽来读取的,常见的pc和服务器,位宽都在32或者64位。cpu读取数据后会放入L2和L3的缓存中L2和L3缓存通常是64字节一行,也就是在64位宽的情况下,L2或者L3缓存的一行数据,cpu要读取8次。在多种语言当中,很少有单个类型的数据长度超过64字节。所以大部分情况L2和L3的一行缓存当中可以存放2个以上的数据。
下面我们来看一段代码:
是java-juc包的作者,著名的并发编程大师Doug lea在JDK7的并发包总新增的LinkedTransferQueue中使用volatile补全位缓存行来进行性能优化的代码
PaddedAtomicReference这个类只做了一件事,就是定义了p0-pe 这15个引用类型的变量,java中引用类型变量长度位4个字节,15个变量占用60字节。在加上父类的value变量,这个队列中的node节点共占用64字节。
如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。
看到这里是不是惊叹与Doug lea的操作方法。不亏为真正的大师。
介绍到这里关于volatile的底层机制和常见的使用情况基本可以做个总结
1、volatile可以通过内存屏障出发缓存一致性协议来做到不同线程(cpu)的可见性
2、volatile可以解决重排序问题,进行一些简单的并发控制
3、在使用volatile清楚明白缓存行宽度且写入频繁的情况下尽可能的要使用缓存行补齐
这章我们基本介绍到这里,下章我们探讨一下单例中的volatile使用,并引发锁机制的探讨