volatile
简介
- 举个例子:下面这段程序是没有加volatile关键字的
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException{
Thread t = new Thread(()-> {
int i=0;
while(!stop) {
i++;
}
});
t.start();
Thread.sleep(1000);
stop = true;
}
- 结果就是:线程 t 并没有停止,说明后面更改的 stop 值并没有进入线程 t 中
- 如果加上volatile关键字又会怎么样呢
public class app {
public volatile static boolean stop = false;
public static void main(String[] args) throws InterruptedException{
Thread t = new Thread(()-> {
int i=0;
while(!stop) {
i++;
}
});
t.start();
Thread.sleep(1000);
stop = true;
}
}
- 结果:不一会线程 t 就停止了,这说明 stop 的值的更改被线程 t 可见了。
- 总结:volatile变量,用来确保将变量的更新操作通知到其他线程。当变量声明为volatile类型之后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。所以在读取volatile类型的变量的时候,总会返回最新的值。
- 但volatile是如何保证其他线程的可见性的呢?
- 通过底层工具的探索,我们会发现,在修改带有 volatile 修饰的成员变量时,会多一个 lock 指令 。 lock是一种控制指令, 在多处理器环境下, lock 汇编指令可以基于总线锁或者缓存锁的机制来 达到可见性的一个效果。
从硬件层面上去了解可见性
- 首先我们要知道一台计算机最核心的部分是cpu,内存和I/O设备。而这三者之间有个矛盾点,就是这三者之间的速度差异。CPU是最快的,内存次之,最后I/O设备是最慢的。所以为了平衡三者之间速度差异,做出了一些优化:
- CPU增加了高速缓存
- 操作系统增加了进程、线程。通过 C PU 的时间片切换最大化的提升 C PU 的使用率
- 编译器的指令优化 ,更合理的去利用好 C PU 的高速缓存
- 但是这些优化也使得出现了一些问题就是线程安全性的问题,所以我们先首先了解一下cpu的高速缓存。下图就是简单的演示
- 通过这几个缓存来解决速度不匹配的问题,打开任务管理器就可以看得出来容量逐级递增:L3>L2>L1
- 虽然这样子解决的CPU与主存之间的运行速度问题,但是又有一个问题出现,就是多个CPU里面有多个线程,每个线程都有自己的高速缓存,所以在不同的CPU内不同的线程可能看到同一份内存的缓存值就有可能不同。如何解决?
- 首先,有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。 在整个运算过程完成后,再把缓存中的数据同步到主内存 。这是CPU大致处理的过程,所以解决缓存不一致的问题,有两种解决方式:
- 总线锁
- 缓存锁
总线锁
- 总线锁,简单来说就是,在多cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK信号,这个信号使得其他处理器 无法通过总线来访问到共享内存中的数据, 总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处 理器不能操作其他内存地址的数据,所以总线锁定的开销比较大 这种机制显然是不合适的
- 但是又如何优化呢?
- 最好的方法就是控制锁对于缓存的粒度,也就是说我们只需要保证被多个CPU处理的缓存的数据是一致的。所以引入了缓存锁
缓存锁
- 缓存锁的核心机制就是缓存的一致性协议
- 为了达到数据访问的一致,需要各个处理器在访问缓存时,遵循一些协议,在读写时根据协议来操作。(因为在不同的CPU中所用到的协议是不一样的,我们系统常用的协议就是MESI协议)就比如常见的MESI协议。
- MESI协议
- 首先先来介绍MESI协议的四种状态:
- M:Modify 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E:Exclusive 表示缓存的独占状态,数据只缓存当前的CPU缓存中,并且没有被修改
- S:Shared 表述数据被多个CPU缓存共享,并且各个缓存与主存之间的数据一致
- I : Invalid 表示缓存中的数据失效了
- 再来讲讲这四种状态是如何改变的
- 在MESI 协议中,每个 缓存 的 缓存 控制器不仅知道自己的读写操作,而且也监听其它 Cache(缓存) 的读写操作
-
当CPU中的缓存跟主存的数据一致的时候,各个缓存都是Shared的状态
-
但是当CPU0想要改变缓存的时候,这个时候状态就发生改变了
-
然后状态就再发生改变,这个时候CPU0就可以修改缓存中的数据,CPU1的缓存钟的数据就失效了,如果要用就需要从主存中重新获取
-
- 对于MESI 协议, 从 CPU 读写角度来说 会遵循以下原则
- CPU读请求:缓存处于 M 、 E 、 S 状态都可以被读取, I 状态 CPU 只能从主存中读取数据
- CPU写请求:缓存处于 M 、 E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写使用
- 首先先来介绍MESI协议的四种状态:
- 使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构 ,从而达到缓存一致性效果:
MESI协议优化的问题
- 上面完成讲解硬件上的一致性的解决方法,但是为什么我们写代码的时候还是需要写volatile关键字呢?
- MESI协议虽然可以实现缓存的一致性,但是也会存在一些问题。就是各个 CPU 缓存行的状态是通过消息传递来进行的。 如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU 。并且要等到他们的确认回执。 CPU0 在这段时间内都会处于阻塞状态。这对于CPU来说是资源的一种浪费。
- 所以为了解决阻塞带来的浪费,又引入了新的机制在 cpu 中引入了 Store Bufferes 。
- 这个机制就是,当CPU0需要写入共享数据的时候,只需要在写入共享数据时,直接把数据写入到 store bufferes 中 同时发送 invalidate 消息 ,然后继续去处理其他指令。当收到其他所有 CPU0 发送了 invalidate acknowledge 消息时 再将 store bufferes 中的数据 数据存储至 cache line中 。最后再从缓存行同步到主内存。
- 这样就解决了资源浪费的问题,但是这种方法有问题,具体看下面的例子
- method1 和 method2 分别在两个独立的 CPU 上执行。假如 C PU0 的缓存行中缓存了 isFinish 这个共享变量,并且状态为( E )、而 Value 可能是( S )状态。那么这个时候,CPU0 在执行的时候,会先把 value =10 的指令写入到 store buffer 中。并且 通知给其他缓存了该 value 变量的 CPU 。 在等待其他 CPU 通知结果的时候, CPU0 会继续执行 isFinish=true 这个指令 。而因为当前CPU0 缓存了 isFinish 并且是 Exclusive 状态所以可以直接修改isFinish=true。这个时候 CPU1 发起 read 操作去读取 isFinish 的 值可能为 true ,但是 value 的值不等于 10 。这种情况我们可以认为是 CPU 的乱序执行, 也可以认为是一种重排序, 而这种重排序会带来可见性的问题.
- 所以这种优化存在两个问题:
- 数据什么时候提交是不确定的 ,因为需要等待其他 cpu 给回复才会进行数据同步 。这里其实是一个异步操作
- 引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,如果 store buffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取
- 而这种问题也有解决的方式:在 CPU 层面提供了 memory barrier( 内存屏障 的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。
CPU内存屏障
- 内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
- 内存屏障大致分为:
- 读屏障:处理器在读屏障之后的读操作 都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- 写屏障:告诉处理器在写屏障之前的所有已经存储在存储缓存 (store buffers)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
- 全屏障:确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
- 总的来说,内存屏障的作用可以通过防止CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性,但是内存屏障、重排序这些东西好像是和平台以及硬件架构有关系的。 作为 Java 语言的特性,一次编写多处运行。 我们不应该考虑平台相关的问题,并且这些所谓的内存屏障也不应该让程序员来关心 。可我们可以研究一下如何完成的