线程安全性之可见性和有序性

什么是线程的可见性

在以下案例中,就是t1线程中用到了stop这个属性,接在在main线程中修改了 stop 这个属性的值来使得t1线程结束,但是t1线程并没有按照期望的结果执行。

public class VolatileDemo01 {
    private 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("子线程即将结束------");
        });

        t1.start();
        System.out.println("子线程即将运行-------");
        Thread.sleep(1000);
        stop = true;
    }
}

运行结果并没有像预期的那样停止,而是一直运行。
在这里插入图片描述

volatile解决可见性问题

在上面的程序中,可以增加 volatile 这个关键字来解决,代码如下:

public class VolatileDemo01 {
    private volatile 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("子线程即将结束------");
        });

        t1.start();
        System.out.println("子线程即将运行-------");
        Thread.sleep(1000);
        stop = true;
    }
}

运行结果符合预期:
在这里插入图片描述

缓存一致性问题

缓存一致性问题导致了可见性问题的出现。在此之前,需要了解什么是缓存一致性

为提升性能,cpu所做的优化

在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。如下图所示,计算机是利用CPU进行数据运算的,但是CPU只能对内存中的数据进行运算,对于磁盘中的数据,必须要先读取到内存,CPU才能进行运算,也就是CPU和内存之间无法避免的出现了IO操作。
而cpu的运算速度远远高于内存的IO速度,比如在一台2.4GHz的cpu上,每秒能处理2.4x109次,每次处理的数据量,如果是64位操作系统,那么意味着每次能处理64位数据量。
在这里插入图片描述
虽然CPU从单核升级到多核甚至到超线程技术在最大化的提高CPU的处理性能,但是仅仅提升CPU性能是不够的,如果内存和磁盘的处理性能没有跟上,就意味着整体的计算效率取决于最慢的设备,为了平衡这三者之间的速度差异,最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很多的优化

  1. CPU增加了高速缓存
  2. 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率
  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存
    每一种优化,都会带来相应的问题,而这些问题是导致线程安全性问题的根源,那接下来我们逐步去了解这些优化的本质和带来的问题。

CPU层面上的缓存

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

缓存行

cpu上的缓存是由缓存行构成的。x86平台,每个缓存行是64个字节。cpu每次读取数据会读取64个字节的数据(不管这些数据是否都会用到,都会一次性读取这64个字节)

伪共享

缓存行主要是为了减少cpu与内存过多的交互。所以每次加载会加载64个字节。但是这里又存在一个问题。如下图所示:cpu0想要获取x数据,缓存行一次性加载了x,y,z,而cpu0只需要运算x数据。如果此时cpu1加载y数据,此时也要加载缓存行数据,假如这时的缓存行数据也是x,y,x。虽然cpu1只运算y,但是cpu0与cpu1存在缓存行竞争。如果cpu0获得了执行权限,那么就会使得cpu1的缓存失效。这种情况如果大量存在,就会影响性能,因为增加了cpu与内存的交互。这时就存在了伪共享的问题。

对齐填充

对齐填充就是如果加载x数据,那就只加载x数据,缓存行多出的部分直接填满64个字节。这样就不需要加载多余的数据。也就不会出现上述的伪共享的问题了。对齐填充也是一种用空间换时间的操作方式。
在这里插入图片描述

缓存一致性问题

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

缓存一致性协议

为了达到数据访问的一致,这里引入了总线锁和缓存锁的概念。缓存锁就是通过缓存一致性协议来保证数据一致性的,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。
MESI表示缓存行的四种状态,分别是

1. M(Modify[ˈmɒdɪfaɪ]) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的
数据和主内存中的数据不一致\
2. E(Exclusive[ɪkˈskluːsɪv]) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
3. S(Shared[ʃerd]) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
4. I(Invalid[ˈɪnvəlɪd]) 表示缓存已经失效

在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一

Shared
Exclusive
Invalid

cpu基于缓存一致性协议,通过storebuffer和storeforwarding技术使得cpu能够异步执行,既能解决缓存一致性问题,又能使得其他指令异步执行。但是因为是异步的,这样会导致指令重排问题。
在这里插入图片描述

指令重排问题

public class VilatitleDemo2 {
    private static int a = 0;
    private static int b = 0;
    private static int x = 0;
    private static int y = 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();

            if (x == 0 && y == 0) {
                System.out.println("本次运算共运算:" + i);
                break;
            }
        }

如果不存在指令重排问题,x == 0 && y == 0是永远不会成立的,但是实际我们运行之后才发现,x == 0 && y == 0等式是有可能成立的。结果如下:
在这里插入图片描述
这就是StoreBuffer和Storeforwarding异步优化之后导致的指令重排问题。基于以上问题,诞生了内存屏障的概念。cpu提供了内存屏障的指令,开发者可以在需要的地方使用,从而解决指令重排序问题。
以上优化需要在代码层面给cpu一个指令来完成。这个“指令”就是volatile。(用volatile修饰的变量,生成字节码指令时,该变量下会有一个标签记录该变量存在volatile属性。jvm会根据这个属性给cpu下发相应的指令来解决可见性问题和指令重排序问题)

内存屏障

CPU在性能优化道路上导致的顺序一致性问题,在CPU层面无法被解决,原因是CPU只是一个运算工具,它只接收指令并且执行指令,并不清楚当前执行的整个逻辑中是否存在不能优化的问题,也就是说硬件层面也无法优化这种顺序一致性带来的可见性问题。
因此,在CPU层面提供了写屏障、读屏障、全屏障这样的指令,在x86架构中,这三种指令分别是
SFENCE、LFENCE、MFENCE指令,

sfence:也就是save fence,写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作前完成。
lfence:也就是load fence,读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作前完成。
mfence:也就是modify/mix,混合屏障指令,在mfence前得读写操作必须在mfence指令后的读写操作前完成。

在Linux系统中,将这三种指令分别封装成了, smp_wmb-写屏障 、 smp_rmb-读屏障 、 smp_mb-读写屏障 三
个方法
cpu优化之路:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值