并发编程之三线程安全之可见性有序性

本文探讨了多线程环境下,由于CPU优化导致的可见性问题,例如线程不安全的循环。通过案例分析了volatile关键字如何确保变量的可见性,以及println和sleep如何意外地解决这个问题。文章还介绍了CPU高速缓存、MESI协议、内存屏障和指令重排序的概念,指出volatile关键字在内存屏障的作用下解决可见性问题。

线程安全之可见性有序性

线程的可见性问题

在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值,这就是所谓的可见性。
本文只是个人在学习中的个人理解以及搜罗的知识点的组合,如有疑惑或者不对的地方还请查阅资料.
本文会通过CPU在不断的优化的过程中产生的问题进行分析Volatile关键字的作用
先看案例

// 可见性案例 场景1
public class VolatileDemo {
   	
    public static boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            int i=0;
            while(!stop){
                i++;
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}
打印:
begin start thread


分析:就是t1线程中用到了stop这个属性,接着在main线程中修改了 stop 这个属性的值来使得t1线程结束,但是t1线程并没有按照期望的结果执行。
始终处于运行状态,是什么原因导致的呢?[思考1]
// while循环中加入输出语句println 或者 sleep   场景二
public class VolatileDemo {
    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);
                Thread.sleep(0);
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}
打印:
begin start thread

Process finished with exit code 0

通过测试发现,我们通过加入
// System.out.println("i:"+i);
// Thread.sleep(0);
上方任意一行代码也是可以终止t1线程的执行的,也就是不加[volatile]也能达到可见性的效果,为什么呢?[思考2]

// 在变量i上添加volatile 关键字  场景三
public class VolatileDemo {

    public static boolean stop=false;
    public static volatile int i=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{

            while(!stop){
                i++;
//                System.out.println("i:"+i);
//                Thread.sleep(0);
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}
打印:
begin start thread

Process finished with exit code 0
输出结果也是可以使循环结束,又是为什么呢[思考3]

上述问题在变量stop加上[volatile]关键字也可以解决问题
public static volatile boolean stop=false;
volatile的作用:
volatile可以使得在多处理器环境下保证了共享变量的可见性。

以上三个场景书面语叫:活性失败,这是在HostSpot Server 虚拟机里面,深度优化之后的一种结果,上述执行结果等价于下面的代码。

// 
if(!stop){
	while(true){
	i++;
	}
}

【volatile】
所以造成上述问题的根本原因是stop这个变量没有通过volatile修饰,而且在主线程睡眠的1000ms中,while循环中的stop一直处于false状态,当循环到一定的次数之后,会触发JVM的即时编译功能,导致循环表达式外提从而导致死循环。如果加了volatile,会保证stop这个变量的可见性,从而避免JIT编译的优化。
这个深度优化是即时编译器(Just In Time,JIT)帮我们做的,我们可以通过增加一下JVM参数来禁止JIT的优化的。
【println】
为什么加上System.out.println(“i”+i)也可以避免呢?

	//println 底层代码
    public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }
1.println底层用到了synchronized这个同步关键字,这个同步会防止循环期间对于stop值的缓存。
2.因为println有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存,可以通过如下代码去证明。

public class JITDemo {
    public static boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            int i=0;
            while(!stop){
                i++;
                synchronized (JITDemo.class){
                }
            }
        });
        thread.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}
打印:
begin start thread

Process finished with exit code 0
可以正常停止

3.第三个角度,从IO角度来说,print本质上是一个IO的操作,我们知道磁盘IO的效率一定要比CPU的计算效率慢得多,所以IO可以使得CPU有时间去做内存刷新的事情,从而导致这个现象。比如我们可以在里面定义一个new File()。同样会达到效果。

【sleep】
也可能会失效
因为Thread.sleep没有任何同步语义,编译器不需要在调用Thread.sleep之前把缓存在寄存器中的写刷新到给共享内存、
也不需要在Thread.sleep之后重新加载缓存在寄存器中的值。
编译器可以自由选择读取stop的值一次或者多次,这个是由编译器自己来决定的。

我觉得这个CPU缓存也有关系,也就是说线程t1执行到while (!stop)的时候,还是会从主动从主存获得最新值刷新自己的副本的,
但是进入while循环后,每次刷新主存的值如果都一样,达到一定次数后cpu为了提高效率(CPU有缓存优化)可能会把该值存入自己的寄存器(避免频繁刷新主存)导致陷入死循环,
有Thread.sleep的时候线程t1的while还没达到stop的值被cpu放到寄存器前就获得了新值而退出了while循环,所以没有造成死循环。

### volatile关键字为什么能够解决可见性问题?可见性问题的本质是什么?
这个问题要想了解得非常清楚,就必须要从硬件以及操作系统的优化来讲,为了能够更好的理解可见性问题的本质,
由于涉及到操作系统底层的知识,所以理解起来比较抽象一点,对于部分内容大家只要知道和记住就行,不需要在去把操作系统原理翻一遍,这个确实有点浪费时间。

可见性的本质是CPU在不断优化的过程中产生的

首先了解一下下图
在这里插入图片描述
通常我们运行程序都是从磁盘开始,加载到内存,再由CPU来运行
这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。
假如某段程序被加载到内存中然后由内存交给CPU来执行,此时CPU的执行效率是非常快的,当CPU响应给内存的时候,会等待内存的响应进行下一步动作,这时CPU就会发生阻塞,这种阻塞对于CPU来说是一种浪费
虽然CPU从单核升级到多核甚至到超线程技术在最大化的提高CPU的处理性能,但是仅仅提升CPU性能
是不够的,如果内存和磁盘的处理性能没有跟上,就意味着整体的计算效率取决于最慢的设备,为了平
衡这三者之间的速度差异,最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很
多的优化

  1. CPU增加了高速缓存
  2. 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率
  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存

每一种优化,都会带来相应的问题,而这些问题是导致线程安全性问题的根源,那接下来我们逐步去了
解这些优化的本质和带来的问题。

CPU层面的高速缓存

是为了解决CPU和IO设备的运行速度问题

CPU在做计算时,和内存的IO操作是无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗
时,基于这样一个问题,所以在CPU层面设计了高速缓存,这个缓存行可以缓存存储在内存中的数据,
CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过
这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。
对于主流的x86平台,cpu的缓存行(cache)分为L1、L2、L3总共3级。
在这里插入图片描述
同时它导致了缓存一致性问题

缓存一致性问题

CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题–缓存一致性问题,
这个一致性问题体现在。
在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓
存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间
不可见,就会导致缓存的一致性问题,据图流程如下图所示:
了解高速缓存可参考大佬文章: https://blog.youkuaiyun.com/ITer_ZC/article/details/41979189.
在这里插入图片描述

上图,当Process0和Process1在BUS总线中加载到x = 20的时候 Process1 对x值进行更新,如何让 0 知道1对x进行更新了呢?
粒度1:
需要1在对x进行访问的时候在BUS总线中加锁,让其在进行更新的时候不允许其他线程的访问
粒度2
针对缓存行的方式加锁,就是只对变量x加锁 --就是基于缓存一致性协议
两者本质上是一样的,只是控制了不同加锁的粒度
如何去加锁呢?
1.总线锁定
前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。在Process1要做 x的更新操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。
但我们只需要对此共享变量的操作是原子就可以了,而总线锁定把CPU和内存的通信给锁住了,使得在锁定期间,其他处理器不能操作其他内存地址的数据,从而开销较大,所以后来的CPU都提供了缓存一致性机制(协议)
2.缓存锁
-缓存一致性协议(MESI)
简言之是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取

- MESI 缓存的四种状态  M -modified 修改 E -exclusive 独占  S -shared共享  I -invalid失效
状态描述监听任务
M 修改(Modify)该缓存行有效,数据被修改了,会和内存中的数据不一致,数据只存在于本缓存行中缓存行必须时刻监听所有试图读该缓存行相对应的内存的操作,其他缓存须在本缓存行写回内存并将状态置为E之后才能操作该缓存行对应的内存数据
E 独享、互斥(Exclusive)该缓存行有效,数据和内存中的数据一致,数据只存在于本缓存行中缓存行必须监听其他缓存读主内存中该缓存行相对应的内存的操作,一旦有这种操作,该缓存行需要变成S状态
S 共享(Shared)该缓存行有效,数据和内存中的数据一致,数据同时存在于其他缓存中缓存行必须监听其他缓存是该缓存行无效或者独享该缓存行的请求,并将该缓存行置为I状态
I 无效(Invalid)该缓存行数据无效

备注:
1.MESI协议只对汇编指令中执行加锁操作的变量有效,表现到java中为使用voliate关键字定义变量或使用加锁操作
2.对于汇编指令中执行加锁操作的变量,MESI协议在以下两种情况中也会失效:
一、CPU不支持缓存一致性协议。
二、该变量超过一个缓存行的大小,缓存一致性协议是针对单个缓存行进行加锁,此时,缓存一致性协议无法再对该变量进行加锁,只能改用总线加锁的方式。
MESI工作原理:(此处统一默认CPU为单核CPU,在多核CPU内部执行过程和一下流程一致)
1、CPU1从内存中将变量a加载到缓存中,并将变量a的状态改为E(独享),并通过总线嗅探机制对内存中变量a的操作进行嗅探
在这里插入图片描述
2、此时,CPU2读取变量a,总线嗅探机制会将CPU1中的变量a的状态置为S(共享),并将变量a加载到CPU2的缓存中,状态为S
在这里插入图片描述
3、CPU1对变量a进行修改操作,此时CPU1中的变量a会被置为M(修改)状态,而CPU2中的变量a会被通知,改为I(无效)状态,此时CPU2中的变量a做的任何修改都不会被写回内存中(高并发情况下可能出现两个CPU同时修改变量a,并同时向总线发出将各自的缓存行更改为M状态的情况,此时总线会采用相应的裁决机制进行裁决,将其中一个置为M状态,另一个置为I状态,且I状态的缓存行修改无效)
在这里插入图片描述
4、CPU1将修改后的数据写回内存,并将变量a置为E(独占)状态
在这里插入图片描述
5、此时,CPU2通过总线嗅探机制得知变量a已被修改,会重新去内存中加载变量a,同时CPU1和CPU2中的变量a都改为S状态
在这里插入图片描述
在上述过程第3步中,CPU2的变量a被置为I(无效)状态后,只是保证变量a的修改不会被写回内存,但CPU2有可能会在CPU1将变量a置为E(独占)状态之前重新读取内存中的变量a,这个取决于汇编指令是否要求CPU2重新加载内存。

系统会在涉及到可见性问题的时候生成 #lock的汇编指令保证解决可见性问题
我们加 volatile 关键字的最终目的也是为了生成这个 #lock指令

总结
以上就是MESI的执行原理,MESI协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题,所以只靠MESI协议是无法完全解决多线程中的所有问题。

指令重排序问题

指令重排序和可见性不是同一个问题,volatile可以同时解决这两个问题
什么是指令重排序?
先看案例

// An highlighted block
public class SeqExample {

    private static int x=0,y=0;
    private static int a=0,b=0;
    public static void main(String[] args) throws InterruptedException {
        int i = 0 ;
        for(;;){
            i ++ ;
            x=0;y=0;
            a=0;b=0;
            Thread t1 = new Thread(()->{
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(()->{
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第"+i + "次("+x+","+y+")";
            if(x==0&&y==0){
                System.out.println(result);
                break;
            }else{
            }
        }
    }
}
打印:(运行时间比较久)
第1572820次(0,0)
CPU层面的指令重排序

在这里插入图片描述
假如CPU0要去更新数据,它需要通知其他CPU并等待其他CPU给它一个响应,这个时候才会往主内存中写入,这段时间CPU0处于阻塞状态,如何让这个过程异步呢 来提高利用率
这个时候引入了

1.store buffer

在这里插入图片描述
就是CPU0要修改的时候先写入store buffer并发送一个通知的消息让其他CPU失效,然后CPU0就可以继续运行后面的指令,等收到其他CPU的响应之后再去写入到内存中

int a = 0;
function(){
	a=1;
	b=a+1;
	assert(b==2);//false
}
正常来讲会先运行
a=1;
b最终会  = 2
//指令重排序后
b=a+1;
a=1;
最终b=1

由上图案例模拟解析一下下图
在这里插入图片描述
假设
首先a=0/Shared 共享状态同时存在CPU 0 和1中
1.CPU0先将a=1写入到store buffer并发送一个失效的指令 read invalidate a,CPU1收到后返回一个response表示我收到你发的通知了,此时CPU0回去CPU1的缓存中读取 a=0
2.然后将a=0更新到CPU0的缓存中
3.这个时候开始执行CPU0中的b=a+1的操作,这个时候缓存行中还没有加载b,然后发起read b去读取b的值,读取之后放入cpu0的缓存行,这个时候b=0/E是独占状态
4.此时cpu0的缓存行中已经有a=0的值,所以执行b=a+1时结果时 b=1/M
5.此时CPU0收到了CPU1给的反馈,说CPU1现在已经将a失效了,这个时候cpu0会将store buffer中的a=1更新到缓存行中
就导致了重排序
那问题来了,问什么不直接中CPU0的store buffer中读取数据呢?

2.store Forwarding
int a = 0,b = 0;
executeToCPU0{
	a = 1;
	b = 1;
}
executeToCPU1{
	while(b=1){
		asser(a==1)
	}
}
可能的结果:asser(a==1) 为false
executeToCPU0和executeToCPU1之间没有强依赖关系是允许重排序的

由上方案例模拟解析一下下图
在这里插入图片描述
假设cpu0中 b=1是独占状态 cpu1中的 a=0是独占状态
1.cpu0发起read b操作读取 cpu0中的b的值
2.executeToCPU0修改a的值并告诉cpu1我要修改了,
3.executeToCPU0执行b=1的操作
4.5.cpu1读取到了cpu0修改后的 b=1的值并写入到自己的缓存中 此时 while(b=1)成立
6.此时cpu1执行asser(a=1) 判断的时候a还是0,cpu0中的a值还没有返回给cpu1,asser(a==1)不成立
7.cpu1收到了cpu0给的通知让a=0失效,此时已经晚了

这就需要引入Invalid queue 失效队列

3.Invalid queue

就是CPU在执行任何一个MESI操作的时候都需要去队列中查看是否有消息需要处理,有的话先处理

在这里插入图片描述
但这同样也会导致一致性问题
在这里插入图片描述
直接看
1.cpu0区更新a值并告诉cpu1去把a值失效掉,cpu1接受消息后将消息放入Invalid queue中
。。。。。
7.此时去读到while(b=1)成立
8.此时从本地缓存中读取到a=0所以asser(a==1)不成立
失效队列最后才进行处理

导致指令重排序的根本原因是1.store buffer 2.store Forwarding 3.Invalid queue

这个问题如何解决呢 --通过内存屏障

内存屏障

CPU层面不知道什么时候允许优化什么时候不允许优化

  • 读屏障(lFence)load
  • 写屏障(sfence)save
  • 全屏障(mfence)mix
    就是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

在这里插入图片描述
面试题:volatile关键是干嘛的?
答:是可以解决多线程环境下的可见性问题,如何解决的呢? 用到了内存屏障,缓存锁,总线锁
内存屏障是。。。
缓存锁总线锁是。。。。

因为JVM是跨平台的它需要支持不同的平台通过不同的指令来解决可见性问题
引出下篇内容 JMM Java Memory Modle

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值