并发编程 - volatile关键字底层详解

一、volatile能干什么?

如下所示的代码里,有t1线程和主线程,主线程改变了stop的值,但是t1线程并不知道,一直在执行。

public class Test {
    public static boolean stop = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("i = " + i);
        });
        t1.start();
        Thread.sleep(1000);
        stop = true;
        System.out.println("结束");
    }
}

在这里插入图片描述
但是如果给变量stop加上volatile关键字,t1显然是感知到了。
在这里插入图片描述
所以说,volatile的作用就是:使得在多个处理器环境下去保证共享变量的可见性。

二、volatile是如何保证可见性的呢?

通过观察代码的汇编指令可知,加了volatile关键字,则会多出一个Lock的汇编指令。

三、可见性到底是什么?

3.1 硬件层面

在绝大部分程序里,都会依赖CPU、内存、I/O设备这三种硬件,而这三种硬件的速度差异是很大的,那么程序的运行时间则取决于最慢的那个(木桶原理嘛),所以,为了平衡三者之间的速度,就需要最大化的利用CPU资源(毕竟cpu是跑的最快的,不能让它等着),有三种方式(cpu增加高速缓存、引入了线程、进程 和 指令优化(重排序))。

第一阶段:cpu和主内存直接交互

在这里插入图片描述
如上,cpu通过总线和主内存交互,那么cpu在等待主内存的返回的时候,很有可能就阻塞了,就会造成CPU的严重浪费,所以,就引入cpu高速缓存了,利用高速缓存来解决内存和cpu速度不匹配的问题。

第二阶段:cpu和主内存之间增加高速缓存

在这里插入图片描述但是利用缓存就会带来新的问题,即缓存一致性问题,cpu是怎么解决的呢?

第三阶段:cpu引入锁解决缓存一致性问题

(1)、总线锁,即在总线上要加锁,因为cpu和内存交互一定要经过总线,那么,如果有一方cpu获得了总线锁,那么在它拿到锁的这段时间内,其他cpu都无法操作内存的数据,这完全违背了cpu并行的原则,严重影响性能,所以,就引入了缓存锁。
(2)、缓存锁,对缓存资源加锁,cpu里有一种方式叫做缓存一致性协议,来达到缓存数据的一致性。不同的cpu架构里,实现缓存一致性协议是不一样的,x86是MESI,具体含义如下:

状态 描述 含义 监听任务
modify 修改 数据被修改了,和内存中的数据不一致,数据只存在于本Cache中 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
Exclusive 独享 数据和内存中的数据一致,数据只存在于本Cache中 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
Shared 共享 数据已经被多个cpu缓存,并且缓存的数据和主内存的数据是一致的 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
Invalid 无效 该Cache line无效

状态变化如下:
在这里插入图片描述
但是,MESI可能会带来新的问题,cpu可能会存在短时间被阻塞的情况。
在这里插入图片描述

第四阶段:引入storeBuffer解决cpu被阻塞的问题

所以,引入了storeBuffer(存储缓冲),把修改指令放在stroreBuffer里去异步执行,解放cpu。
在这里插入图片描述然后,storeBuffer也会带来新的问题,会存在短暂的可见性问题,stroreBuffer需要等到其他线程的ack,才会把数据同步到缓存行和主内存中去,所以在这个过程中,stroreBuffer里是已修改的值,而缓存行和主存里还是旧的值。
写个假程序哈:

value = 3;

void cpu0(){
     value = 10;  // s-》M状态
     isFinish = true; // E状态
}

void cpu1(){
	if(isFinish){ //true
		assert value == 10; false
	}
}

假设cpu0已经缓存了isFinish的值,且状态为E独占,那么修改isFinish的值就可以直接执行,无需通知其他CPU,但是value是S共享状态,把value的值改成10,即从共享状态改变成修改状态,需要通知其他cpu的,这时候会把指令放在storeBuffer中去,而stroreBuffer需要等到消息ack才会执行指令,那么,isFinish这行的指令可能就会先于修改指令而执行,所以cpu1下会有可能出现,isFinish等于true,而value不等于10的情况。
所以说,CPU可以对阻塞的指令进行优化,可以认为是CPU的乱序执行,也就是 重排序,而重排序就会带来可见性问题。

第五阶段:内存屏障解决cpu重排序问题

硬件层面最终都无法彻底解决可见性问题,所以,cpu提供了指令,即提供内存屏障。
内存屏障用来解决可见性问题,伪代码如下哈:

value = 3;
void cpu0(){
     value = 10;  // s-》M状态
     storeMemoryBarrier(); // 屏障指令 把指令强制刷到主内存中去;
     isFinish = true; // E状态
}

void cpu1(){
	if(isFinish){ //true
	    loadMemoryBarrier(); // 屏障指令 强制从主内存去读取数据
		assert value == 10; false
	}
}

cpu层面提供了三种屏障:
读屏障(store barrier):处理器在读屏障之后的读操作都能读取到最新的数据,直接从主内存中获取;
写屏障(load barrier):告知处理器在写屏障之前所有storeBuffer里的指令要同步到主内存;
全屏障(full barrier):读屏障 + 写屏障;
内存屏障的作用是通过防止cpu对于指令的乱序访问来保证共享数据在多线程下的可见性
所以 volatile 会加 lock指令(缓存锁) 而这个指令达到内存屏障的目的,从而实现可见性;
由此可见,内存屏障、重排序是和平台以及硬件有关系。但是java程序本身不需要关心这些,所以看看JMM如何解决可见性问题。

3.2 JMM层面

JMM内存模型

导致可见性问题的根本原因是:高速缓存、重排序。
JMM合理的禁止缓存和重排序来解决可见性问题,最核心的价值是解决了有序性和可见性。
JMM:语言级别的抽象内存模型。在JMM里,通过内存屏障去禁止重排序,编译器会根据底层具体的硬件或者操作系统平台去把内存屏障替换成具体的指令,从而实现可见性的效果。
JMM内存模型如下:
在这里插入图片描述主内存是所有线程共享的,比如说:实例对象,静态字段等存储在堆内存的,而工作内存是每个线程私有的,所以主内存和工作内存之间的问题其实也就类似于如上所述的CPU了。
那么,JMM就提供了一些关键字来解决可见性问题,比如:volatile、synchronized、final、(happens-before)等。
volatile:语言级别的内存屏障解决可见性;
synchronized:加同步锁解决可见性;
final:防止指令重排序解决可见性;

所以说,代码的执行过程就变成了:源代码->编译器的重排序->CPU层面的重排序(指令级、内存)->最终执行的指令
但是也不是所有的程序都会进行重排序,想要重排序必须满足数据依赖原则(as - if - serial),也就是说不管怎么重排序,对于单个线程的执行结果不能变;

JMM内存屏障

主要有如下四种:
在这里插入图片描述
loadload,读读,load1肯定先于load2执行;
storestore,写写,store1肯定先于store2执行;
loadstrore,读写,load肯定先于strore执行;
stroreload,写读,store肯定先于load执行;
注释:内存屏障的具体实现在不同的平台上都是不一样的。

带有volatile关键字修饰的,会生成JVM_ACC_VOLATILE指令,如果有这个指令的话呢,就会触发内存屏障(storeload),保证后续的读操作一定可以读到最新的值(语言级别的),结束之后,会再调用cpu的内存屏障。

所以说针对程序重排序,JMM层面,提供的内存屏障,一种是语言级别的内存屏障;另一种是cpu层面的内存屏障。

happens-before规则

对于可见性的保障,除了volatile以外,还有另一种方式啦,那就是happens-before;
A happens-before B:A操作的结果对于B一定是可见的。

哪些操作会建立happens-before规则

demo:

public class APP {
    int a = 0;
    volatile boolean flag = false;
    
    public void writer(){
        a = 1; //1
        flag = true; //2 
    }
    
    public void reader(){
        if(flag){ //3 
            int x = a; //4
        }
    }
}

(1)、程序的顺序规则,即同一个线程里,前面的操作结果一定对后面的程序可见;
要求:1 happens-before 2 , 3 happens-before 4
(2)、volatile规则;
要求: 2 happens-before 3
(3)、传递性规则
要求:1 happens-before 4
(4)、start规则
主线程的start操作 happens-before t1 线程的任何操作;
在这里插入图片描述
在这里插入图片描述
(5)、join规则
t1线程的任何操作 happens-before 主线程的join操作;
在这里插入图片描述
(6)、锁的规则
前一个线程释放锁的操作 happens-before 后续线程加锁的操作

public class sync {
        public void demo(){
            synchronized (this){
            }
        }
    }
如何实现多个线程的顺序执行?

在这里插入图片描述
在线程里,如果后续线程需要依赖前面的线程的执行结果,可以使用join方法;
在这里插入图片描述

四、总结

volatile 三大特性:保证可见性、不保证原子性、禁止指令重排序。
synchronized:可以解决原子性、有序性、可见性。

原创文章 88 获赞 21 访问量 3万+

猜你喜欢

转载自blog.csdn.net/cfy1024/article/details/98341476