并发编程—— volatile(数据一致性、重排序、伪共享等)

导读:本篇文章讲解 并发编程—— volatile(数据一致性、重排序、伪共享等),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

Volatile

解决多线程环境中可见性问题

通过缓存锁及内存屏障

线程可见性问题

public class VolatileDemo {

    public static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int i = 0;
            // while(true)
            while (!stop) {
                i++;
       			//此处不能写 System.out.println();要不就没效果了
            }
        });

        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop = true;
    }
}

上述案例就是在t1线程中通过修改stop属性来停止线程,但是我们可以发现修改stop属性后,线程并未停止。

这就引出今天的话题,多线程之间的可见性问题。

可见性: 是指当一个线程修改了某一个共享变量的值,其他线程不能够立即知道这个值得修改,从而导致可见性问题。

**共享变量:**如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。

volatile

为了解决可见性问题,我们可以通过volatile关键字来实现。

public class VolatileDemo {

    public volatile 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;
    }
}

性能优化之路带来的问题

在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU的计算速度是非常快的,其次是内存、最后是IO设备(比如磁盘),也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。

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

  1. CPU增加了高速缓存

  2. 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率

  3. 编译器的指令优化,更合理的去利用好CPU的高速缓存

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

CPU层面的缓存

缓存行:缓存行是2的整数幂个连续字节,一般为32-256个字节。 最常见的缓存行大小是64个字节 。

伪共享:当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享 。

指令重排序:编译器或运行环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。

CPU在做计算时,和内存的IO操作是无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗时,基于这样一个问题,所以在CPU层面设计了高速缓存,这个缓存行可以缓存存储在内存中的数据,CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。

对于主流的x86平台,cpu的缓存行(cache)分为L1、L2、L3总共3级。
在这里插入图片描述

缓存一致性问题

CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题——缓存一致性问题,这个一致性问题体现在。

在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题,据图流程如下图所示:
在这里插入图片描述
举个简单的栗子,比如下面的这段代码:

i = i+1;

当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i指令进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

这个代码在单线程中运行时没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。我们以多核CPU为例。

比如同时有两个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但事实会是这样吗?

可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作后,i的值为1,然后线程2把i的值写入内存。

最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

为了解决缓存不一致问题,通常来说有以下2种解决方法:

1)通过在总线加LOCK,锁的方式;

2)通过缓存一致性协议

在早期的CPU中,是通过在总线上加LOCK锁的形式来解决缓存不一致的问题。**因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK锁的话,也就是说阻塞了其他CPU对其它部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。**比如上面例子中,如果一个线程在执行i = i +1,如果在执行这段代码的过程中,在总线上发出了LOCK锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

所以就出现了缓存一致性协议

缓存一致性协议

该协议保证了每个缓存中使用的共享变量的副本是一致的。它的**核心思想是:**当CPU向内存写入数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存是无效的,那么它就会从内存重新读取。

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI:

MESI表示缓存行的四种状态,分别是:

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

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

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

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

对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:

  • CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据。

  • CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

指令重排序

MESI 协议虽然可以实现缓存的一致性,但是也会导致指令重排序的问题。

场景

class ResortDemo {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;             //2
    }

    Public void reader() {
        if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

当两个线程 A 和 B,A 首先执行writer() 方法,随后 B 线程接着执行 reader() 方法。线程B在执行操作4时,能否看到线程 A 在操作1对共享变量 a 的写入?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

什么是数据依赖关系?

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。

重排序原因

在这里插入图片描述
基于上图中的原因,CPU又引入了storeBuffers的缓冲区。CPU0 只需要在写入共享数据时,直接把数据写入到 storebufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。

这个时候,我们再来看上述标题一中的重排序场景:

class ResortDemo {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;             //2
    }

    Public void reader() {
        if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

当执行1操作时,a的状态从S(共享)->M(修改),此时,线程A会先把变更写入到storebuffers,然后发送invalidate去异步通知其他CPU线程,紧接着就执行了下面的2操作。
此时,可能1的变更还在storebuffers中,并未提交到主内存。什么时候会提交到主内存,也不确定。所以,线程B调用read方法可能会出现,看到了flag的变更,但是看不到a的变更,就出现了重排序的现象。

伪共享问题

什么是伪共享

伪共享:两个不同的线程,操作同一缓存行中的不同字节,产生了不是真正共享的伪共享。

在这里插入图片描述
数据X、Y、Z被加载到同一Cache Line中,线程A在Core1上修改X,而修改X会导致其所在的所有核上的缓存行均失效;假设此时线程B在Core2上读取Y,由于X所在的缓存行已经失效,所有Core2必须从内存中重新读取。线程A的操作不会修改Y,但是由于X和Y共享的是一个缓存行,就导致线程B不能很好地利用Cache,这其实就是伪共享。简单来说,伪共享指的是由于共享缓存行导致缓存无效的场景。

如何解决伪共享

场景

public class Data {
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
    int value;
}

假如业务场景中,上述的类满足以下几个特点:

1)当value变量改变时,modifyTime肯定会改变

2)createTime变量和key变量在创建后,就不会再变化

3)flag也会经常变化,不过与modifyTime和value变量毫无关联

当上面的对象需要由多个线程同时的访问时,从Cache角度来说,就会有一些有趣的问题。当我们没有加任何措施时,Data对象所有的变量极有可能被加载在L1缓存的一行Cache Line中。在高并发访问下,会出现这种问题:

一个线程修改某个值后,根据MESI协议,对象其他CPU上相关的Cache Line全部被设置为失效。其他的处理器想要访问未变化的数据(key和createTime)时,必须从内存中重新拉取数据,增大了数据访问的开销。

我们可以通过以下两种方法解决伪共享的问题。

对齐填充

通过对齐填充解决伪共享的问题;

使对象是64个字节或者是64的倍数;

空间换时间,让同一个地址块不会被多个线程竞争。

正确的方式应该将该对象属性分组,将一起变化的放在一组,与其他属性无关的属性放到一组,将不变的属性放到一组。这样当每次对象变化时,不会带动所有的属性重新加载缓存,提升了读取效率。在JDK1.8以前,一般是在属性间增加长整型变量来分隔每一组属性。被操作的每一组属性占的字节数加上前后填充属性所占的字节数,不小于一个cache Line的字节数就可以达到要求,通过填充变量,使不相关的变量分开:

public class DataPadding {
    long a1, a2, a3, a4, a5, a6, a7, a8;//防止与前一个对象产生伪共享
    int value;
    long modifyTime;
    long b1, b2, b3, b4, b5, b6, b7, b8;//防止不相关变量伪共享
    boolean flag;
    long c1, c2, c3, c4, c5, c6, c7, c8;//防止不相关变量伪共享
    long createTime;
    char key;
    long d1, d2, d3, d4, d5, d6, d7, d8;//防止与下一个对象产生伪共享
}

Contended注解方式

在JDK1.8中,新增了一种注解@sun.misc.Contended来使各个变量在Cache Line中分隔开。其原理是在使用此注解的对象或字段的前后各增加128字节大小的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。可以在类前或属性前加上此注释:

//类前加上代表整个类的每个变量都会在单独的Cache Line中
@sun.misc.Contended
@SuppressWarnings("restriction")
public class ContendedData {
    int value;
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
}

或者

//属性前加上时需要加上组标签
@SuppressWarnings("restriction")
public class ContendedGroupData {
    @sun.misc.Contended("group1")
    int value;
    @sun.misc.Contended("group1")
    long modifyTime;
    @sun.misc.Contended("group2")
    boolean flag;
    @sun.misc.Contended("group3")
    long createTime;
    @sun.misc.Contended("group3")
    char key;
}

在默认情况下,@Contended注解只用于Java核心类,比如rt包下的类。 如果用户类路径下的类需要使用这个注解,则需要添加JVM参数:-XX:-RestrictContended。填充的宽度默认为128,要自定义宽度则可以设置-XX:ContendedPaddingWidth参数

测试代码

public class CacheLineExample implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex = 0;

    private static ValuePadding[] longs;

    public CacheLineExample(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception {
        for (int i = 1; i < 10; i++) {
            System.gc();
            final long start = System.currentTimeMillis();
            runTest(i);
            System.out.println(i + " Threads, duration = " + (System.currentTimeMillis() - start));
        }
    }

    private static void runTest(int NUM_THREADS) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        longs = new ValuePadding[NUM_THREADS];
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValuePadding();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new CacheLineExample(i));
        }

        for (Thread t : threads) {
            t.start();
        }

        for (Thread t : threads) {
            t.join();
        }
    }

    @Override
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = 0L;
        }
    }

    public final static class ValuePadding {
        protected long p1, p2, p3, p4, p5, p6, p7;
        protected volatile long value = 0L;
        protected long p9, p10, p11, p12, p13, p14;
        protected long p15;
    }

    //@Contended //实现对齐填充
    public final static class ValueNoPadding {
        // protected long p1, p2, p3, p4, p5, p6, p7;
        //8字节
        protected volatile long value = 0L;
        // protected long p9, p10, p11, p12, p13, p14, p15;
    }

}

通过切换ValuePadding和ValueNoPadding体验速度。

内存屏障

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-读写屏障 三个方法。

volatile关键字原理

使用该关键字后,在修改stop值的时候,会添加一个lock汇编指令。

该指令在我们X86系统架构下,相当于一个Full Barrier(全屏障),执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。

所以volatile关键字既可以解决重排序问题又可以解决数据一致性问题。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之家整理,本文链接:https://www.bmabk.com/index.php/post/16837.html

(0)
小半的头像小半

相关推荐

极客之家——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!