volatile关键字

1 一个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws Exception {
Thread thread = new Thread(() -> {
int i = 0;
while(!stop) {
i++;
//(1)System.out.println("rs: " + i);
try {
//(2)Thread.sleep(0);
} catch (Exception e){
e.printStackTrace();
}
}
});
thread.start();
Thread.sleep(1000);
stop = true;
}

怎样让上述代码停下:

  1. print就可以导致循环结束

    println底层用到了synchronized这个同步关键字,这个同步会防止循环期间对于stop值的缓存;因为println有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存;从IO角度来说,print本质上是一个IO的操作,磁盘IO的效率一定要比CPU 的计算效率慢得多,所以IO可以使得CPU有时间去做内存刷新的事情,从而导致这个现象。比如可以在里面定义一个new File()

  2. sleep(0)导致循环结束。

    官方文档上是说,Thread.sleep没有任何同步语义,编译器不需要在调用Thread.sleep之前把缓存在寄存器中的写刷新到给共享内存、也不需要在Thread.sleep之后重新加载缓存在寄存器中的值。编译器可以自由选择读取stop的值一次或者多次,这个是由编译器自己来决定的。但是:Thread.sleep(0)导致线程切换,线程切换会导致缓存失效从而读取到了新的值。

2 可见性

保证可见性,通过对上述代码查看汇编指令,使用HSDIS工具。 可以了解到,使用volatile关键字之后,多了一个Lock指令。

在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。

2.1 硬件层面

1.CPU层面增加了高速缓存

操作系统,进程、线程、CPU时间片来切换

编译器的优化 ,更合理的利用CPU的高速缓存。因为高速缓存的存在,会导致一个缓存一致性问题。

2.总线锁和缓存锁:

总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出 一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。

最好的方法就是控制锁的保护粒度,只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。

所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。

总线锁和缓存锁怎么选择,取决于很多因素,比如CPU是否支持、以及存在无法缓存的数据时(比较大或者快约多个缓存行的数据),必然还是会使用总线锁。

3.缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。MESI表示缓存行的四种状态,分别是

  1. M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致

  2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改

  3. S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致

  4. I(Invalid) 表示缓存已经失效

4.MESI的一个优化:Store Bufferes

Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到Store Bufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Ack,继续往下执行其他指令,直到收到CPU0收到 Ack再更新到缓存,再从缓存同步到主内存。

5.指令重排序

通过内存屏障禁止了指令重排序

X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)

Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的

Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的

Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

2.2 软件层面

JMM

简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。

导致可见性问题有两个因素,一个是高速缓存导致的可见性问题, 另一个是指令重排序。

对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。

被volatile修改的变量有以下特点:

(1)线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存

(2)线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存

happens-before

除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。 从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。

· 程序顺序规则(as-if-serial语义):一个线程中的每个操作,happens-before于该线程中的任意后续操作。

· 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

· volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

· 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

· start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

· join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

final关键字提供了内存屏障的规则


volatile关键字
http://www.zivjie.cn/2023/03/12/java基础/多线程/volatile关键字/
作者
Francis
发布于
2023年3月12日
许可协议