java并发编程-cpu的流水线


作为程序员,CPU在我们的工作中扮演了核心角色,因此了解处理器内部的工作方式对程序员来说不无裨益。CPU是如何工作的呢?一条指令执行需要多长时间?当我们讨论某个新款处理器拥有12级流水线还是18级流水线,甚至是更深的31级流水线时,这到些都意味着什么呢?应用程序通常会将CPU看作是黑盒子。程序中的指令按照顺序依次进入CPU,执行完之后再按顺序依次从CPU中出来,而内部到底发生了什么,我们通常并不了解。对我们程序员来说,尤其是对做程序性能调优工作的程序员来说,学习CPU内部的细节非常必要。前文讲到过为了尽量利用CPU的资源,使用高速缓存来缓解内存工作速度远小于CPU工作速度的矛盾。那CPU本身对于效率的优化有哪些方式呢?本文仅从软件层面来了解一下CPU的优化手段,至于硬件层面不作阐述。CPU里存在两样优化速度的技术,分别是 :指令流水线和指令乱序执行。

CPU技术发展历史

从一个非常广的角度来说,X86处理器架构在近35年来并没有变化太多。虽然X86架构被附加了很多新功能,但是最初的设计(包括几乎所有最初的指令集)仍然基本上是完整保留的,即使在最新的处理器上仍然被支持。最初的8086处理器支持14个寄存器,这些寄存器在如今最新的处理器中仍然存在。这14个寄存器中,有4个是通用寄存器:AX,BX,CX和DX;有4个是段寄存器,段寄存器用来辅助指针的实现:代码段(CS),数据段(DS),扩展段(ES)和堆栈段(SS);有4个是索引寄存器,用来指向内存地址:源引用(SI),目的引用(DI),基指针(BP),栈指针(SP);有1个寄存器包含状态位;最后是最重要的寄存器:指令指针(IP)。指令指针寄存器是一个拥有特殊功能的指针。指令指针的功能是指向将要运行的下一条指令。所有的X86处理器都按照相同的模式运行。首先,根据指令指针指向的地址取得下一条即将运行的指令并解析该指令(译码)。在译码完成后,会有一个指令的执行阶段。有些指令用来从内存读取数据或者向内存写数据,有些指令用来执行计算或者比较等工作。当指令执行完成后,这条指令会通过退出(retire)阶段并将指令指针修改为下一条指令。相较于现今的标准,最初的处理器设计显得太过简单。最初的8086处理器的执行过程可以简述为从当前指令指针取得指令,通过译码,执行最后退出,然后继续从指令指针指向的下一条指令处取得指令。新的处理器增加了新的功能,有些增加了新的指令,有些增加了新的寄存器。我将主要关注和本文主题有关系的改变,这些改变影响了CPU指令执行的流程。其他的一些变化比如虚拟内存或者并行处理虽然都很有意义而且有趣,但是并不在本文主题的范围内。

  • 1971年:世界上第一块微处理器4004在Intel公司诞生了。它出现的意义是划时代的,比起以前的CPU,4004显得很可怜,它是只有2300个晶体管4位CPU,功能相当有限,而且速度还很慢。

  • 1978年:Intel公司首次生产出16位的微处理器命名为i8086,同时还生产出与之相配合的数学协处理器i8087,这两种芯片使用相互兼容的指令集。由于这些指令集应用于i8086和i8087,所以人们也把这些指令集统一称之为X86指令集。这就是X86指令集的来历。

  • 1982年:指令缓存被加入到处理器中。通过指令缓存,处理器可以一次性从内存读取更多指令并放在指令缓存中,而不用每条指令都从内存中取。指令缓存仅有几个字节大小,只能容纳数条指令,但是因为消除了之后每次取指往返内存和处理器的时间,极大的提高的效率

  • 1985年:386处理器引入了数据缓存,而且扩展了指令缓存的设计。数据访存请求通过一次性读取更多的数据放在数据缓存中,从而提升了性能。而且,数据缓存和指令缓存都从几个字节扩大到几千字节。

  • 1989年:推出的i486处理器引入了五级流水线。这时,在CPU中不再仅运行一条指令,每一级流水线在同一时刻都运行着不同的指令。这个设计使得i486比同频率的386处理器性能提升了不止一倍。五级流水线中的取指阶段将指令从指令缓存中取出(i486中的指令缓存为8KB);第二级为译码阶段,将取出的指令翻译为具体的功能操作;第三级为转址阶段,用来将内存地址和偏移进行转换;第四级为执行阶段,指令在该阶段真正执行运算;第五级为退出阶段,运算的结果被写回寄存器或者内存。由于处理器同时运行了多条指令,大大提升了程序运行的性能。

  • 1993年:Intel推出了奔腾(Pentium)处理器。由于诉讼问题,Intel无法继续沿用原来的数字编号。因此,用奔腾替代了586作为新款处理器的代号。奔腾处理器相对i486处理器对流水线做出了更多修改。奔腾处理器架构增加了第二条独立的超标量流水线。主流水线工作方式类似于i486,第二条流水线则并行的运行一些较简单的指令,比如说定点算术,而且该流水线能更快的进行该运算。

  • 1995年:Intel推出了奔腾Pro(Pentium Pro)处理器。和之前的处理器相比,奔腾Pro采用了完全不同的设计。该处理器采用了诸多新特性以提高性能,包括乱序(Out-of-Order, OOO)执行的部件以及猜测执行。流水线扩展到了12级,而且引入了“超标量流水线”的概念,使得许多指令可以被同时处理。我们稍后将详尽的介绍乱序执行的部件。

  • 1995~2002年:乱序执行部件经过了数次重大改进。处理器中加入了更多的寄存器;单指令多数据(Single Instruction Multiple Data, or SIMD)的引入使得一条指令可以进行多组数据运算;现有的缓存变得更大而且引入了新的缓存;有些流水级被拆分成更多流水级,有些流水级被合并,使得更加适合实际的应用。这些改变对整体性能的提升有重要作用,但它们都没有从根本影响数据在处理器中的流动方式。

  • 2002年:Intel发布奔腾4处理器引入了超线程技术。乱序执行部件的设计使得指令被执行的速度比处理器能够提供指令的速度更快。因此对于大部分应用,CPU的乱序执行部件在大部分时间处于空闲状态,甚至在高负载的情况下也不能充分利用。为了让指令流能充分的流入乱序执行部件,Intel加入了第二套前端部件(译注:在处理器结构中,前端是指取指,译码,寄存器重命名等模块,经过前端部件的处理后,指令等待发射进入乱序执行部件)。虽然实际上只有一个乱序执行部件,但对于操作系统来说,它能看到两个处理器。前端部件包含两组同样功能的X86寄存器,两个指令译码器根据两个指令指针指向的地址分别处理。所有的指令被一个共享的乱序执行部件执行,但对应用程序来说并不知情。当乱序执行部件执行完成,像之前一样退出流水线后,最终结果返回虚拟的两个处理器。

  • 2006年:Intel发布了酷睿(Core)微架构。为了品牌效应,它被称做酷睿2(二总比一好)。令人惊讶的是,处理器频率不升反降,而且超线程也被去掉了。通过降低时钟频率,每一级流水线可以做更多工作。乱序执行部件也被扩展的更宽。各种不同的缓存和队列都相应做的更大。而且处理器被重新设计,以适应双核和四核的共享缓存结构。

  • 2008年:Intel开始用酷睿i3, i5, i7的方式来命名新的处理器。新处理器重新引入了超线程。这三个系列的处理器主要区别在于内部缓存大小不同。

CPU指令流水线

根据之前描述的基础,指令进入流水线,通过流水线处理,从流水线出来的过程,对于我们程序员来说,是比较直观的。对于CPU性能有以下公式:

处理器性能 = 主频 X IPC

由上述公式我们可以知道,提高CPU性能要么就提高主频,要么就提高IPC(CPU每一时钟周期内所执行的指令数量),提升IPC有两种做法,一个是增加单核并行的度,一个是加多几个核。早期一些采用非常简单的指令集的电脑是采用单周期设计的,取指、解码、执行、写回都是放在同一个节拍(周期)内顺序完成此时的 CPI(每条指令执行时所花费的平均时钟周期数)基本上是 1,但是这样设计的效率很低:当取指的时候,其余工位都只能瞎瞪眼等开饭,这样的设计也被称作非流水线化执行。其执行图如下

因此也可得出一个结论,想要提供CPU的工作效率,就要让CPU在每个时钟周期内尽可能多的执行指令。I486拥有五级流水线。分别是:取指(Fetch),译码(D1, main decode),转址(D2, translate),执行(EX, execute),写回(WB)。某个指令可以在流水线的任何一级。在理想情况下,流水线级数越多,可同时流水执行的指令数越多,正比增长。这就是推出该问题的理论基础。并行度越大,在宏观上看其实就等效于每一条指令的执行速度都变快了。但现实不是这样理想的。流水的时候,会遇见各种冒险机制(某硬件不支持同时钟周期被多个资源访问,数据依赖或逻辑关系不被满足的情况,跳转指令等),造成流水设计的困难。下面是流水线工作的流程图

流水线是将组合逻辑分割成多个小块,因为每段的关键路径变短了,所以能提高系统主频。同时能让任务以类似并行方式处理,提高硬件模块的利用率,提高系统频率,提高吞吐量。这里可能有些朋友会有疑问,流水线的级数是不是越高,CPU的利用率就越高呢?理想很丰满,现实很骨感。理论上流水线级数越多,每级所花的时间越短,时钟周期就可以设计的越短,指令速度越快,指令平均执行时间也就越短。但实际上,流水线越长,重叠执行的指令就越多,那么发生竞争冲突得可能性就越大,一般来说流水线越长,整体性能越差,而且还有前车之鉴。著名的奔腾四,就是因为流水线过长,而落得个高频低能的名声。而且,拜糟糕的奔腾四所赐, AMD 在那个时代全面超越了 intel,这也是历史上唯一的一小段 AMD 主营 CPU 在性能上完全超越 intel 主营 CPU 的年代。(这个年代持续得不长,就因为酷睿的出世而结束了。)流水线过长对性能没有帮助,只对提升主频有帮助。长流水线能够使你提升主频更容易,所以容易造就高频低能的芯片。另一方面,流水线技术是以空间换时间的技术,如果流水线过长,就需要更多的寄存器来保存流水线中产生的中间结果,对于芯片的制作成本也会有所提升。

主流处理器 流水线级数
1993年,Pentium 5级
1995年,Pentium Pro 12级
1999年,ARM9 5级
2002年,ARM11 8级
2004年,Pentuim4(Prescott) 31级
2006年,Core2 Duo(Merom) 14级
2008年,Core i7(Nehalem) 16级

流水线的冒险思想

流水线技术之所以能提高性能 究其本质是利用了时间上的并行性,那它让原本应该先后执行的指令在时间上一定程度的并行起来,然而这也会带来一些冲突和矛盾,进而可能引发错误。流水线处理中,由于各个阶段的依赖关系、硬件资源的竞争等原因,会出现操作无法执行的情况。造成流水线故障的原因称为冒险。冒险分为三种:

  • 结构冒险:如果一条指令需要的硬件部件还在为之前的指令工作,而无法为这条指令提供服务,那就导致了结构冒险。(这里结构是指硬件当中的某个部件)
  • 数据冒险:由于指令执行所需要的数据还未准备好所引起的冒险情况。当即将执行的指令依赖于还未处理完成的数据时,会导致指令无法立刻开始执行,引发数据冒险。
  • 控制冒险:如果现在要执行哪条指令,是由之前指令的运行结果决定,而现在那条之前指令的结果还没产生,就导致了控制冒险。

分支预测

虽然同一时间,流水线中执行了多条指令,但是分支判断带来的跳转,导致无法正确顺序地载入对应的跳转代码进流水线。如果没有分支预测器,处理器将会等待分支指令通过了指令流水线的执行阶段,才把下一条指令送入流水线的第一个阶段—取指令阶段(fetch stage)或者将后续流水线全部清空。这种技术叫做流水线停顿(pipeline stalled)或者流水线冒泡(pipeline bubbling)或者分支延迟间隙,这个非常影响流水线并行执行的效率。

为了解决上述问题,流水线中引入了分支预测器来完成分支预测机制。分支预测就是通过预测,把接下来最有可能执行的分支获取进入流水线,就像不存在对比较结果的依赖那样直接执行,这么一来就保持了指令的流畅执行,这也被称为Speculative Execution。不过这种通过预测获取进入流水线的分支终究只是预测分支,实际上不一定是执行这一分支,因此这部分指令的执行结果不应该从流水线中输出。在得到比较结果后,就能知道预测的分支是否为实际应该执行的分支,如果是,流水线中的预测分支指令就能继续执行下去,否则就需要把预测分支的指令排空,重新获取正确分支的指令进入流水线继续执行。 分支预测,可能的方法有很多种。如:分支判断可能的结果是A和B,最差的情况是永远将分支判断的A指令加入流水线,如果真实判断时是A,那就直接执行,如果是B那么再重新加入B也可以,效率能够提高。实际的分支预测中,可能存入很多种方法,也会有固件的模式(Pattern)来适应。如改进上术的分支预判,当有一次实际判断为B时,后面的全部就预判断为B。上面只是简单的介绍一下分支预测的最简单模式,实际现在CPU的分支预测越来越强大,适应的模式越来越多。像AMD的新款Ryzen处理器,已经使用神经网络的机器学习来强化分支预测了。 主流的分支预测器主要有两种:静态预测器(Static Predictor)和动态预测器(Dynamic Predictor)

  1. 静态预测器:预测条件跳转不发生,因此总是顺序取下一条指令推测执行。仅当条件跳转指令被求值确实发生了跳转,则非顺序的代码地址被加载执行。另外一种,则预测条件跳转总会发生,因CPU而异。对于这种静态预测如果产生错误,则惩罚就是清空后续的流水线中的指令。
  2. 动态预测器:基于之前执行的分支信息,处理器对于正在执行的程序所做的决定。比如根据某个分支指令上次是否跳转来预测此次是否跳转。如果上次分支跳转了,则预测此次也会跳转。

分支预测在很大程度上提高了流水线的运行效率,在明白了CPU的分支预测原理后,对于我们提高代码的运行效率也具有指导意义,通过动态分支预测器我们可以知道本次是否跳转是根据上次的结果来判断的,那么在编写代码时可以通过处理来提高判断的准确率。下面是一个Java例子,通过对一个无序数组和一个有序数组的分支处理,从结果可以看出对有序数组的处理时间明显小于无序数组的处理时间

public class Test {

    public static void main(String[] args){
        randomTest();

        SortTest();
    }

    private static void randomTest(){
        int arraySize = 32768;
        int data[] = new int[arraySize];
        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;
        int sum=0;

        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++){
            for (int j = 0; j < arraySize; j++){
                if (data[j] < 64) {
                    sum += data[j];
                }else if(data[j]>=64 && data[j]<128){
                    sum +=1;
                }else {
                    sum -= data[j];
                }
            }
        }
        System.out.println();
        System.out.println("random time");
        System.out.println(System.currentTimeMillis() - start);
        System.out.println("sum = " + sum);
    }

    private static void SortTest(){
        int arraySize = 32768;
        int data[] = new int[arraySize];
        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;
        int sum=0;
        Arrays.sort(data);

        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++){
            for (int j = 0; j < arraySize; j++){
                if (data[j] < 64) {
                    sum += data[j];
                }else if(data[j]>=64 && data[j]<128){
                    sum +=1;
                }else {
                    sum -= data[j];
                }
            }
        }
        System.out.println();
        System.out.println("sort time");
        System.out.println(System.currentTimeMillis() - start);
        System.out.println("sum = " + sum);
    }
}

执行结果

random time
12355
sum = -460681728

sort time
2490
sum = -460681728

这里把排序时间排除在外了,就算把排序时间算在内,得到的结果仍然是对于排序数组的处理时间仍然小于非排序数组的处理时间,结果如下

random time
12355
sum = -460681728

sort time
7225
sum = -460681728

指令的乱序执行

在按序执行中,一旦遇到指令依赖的情况,流水线就会停滞,如果采用乱序执行,就可以跳到下一个非依赖指令并发布它。这样,执行单元就可以总是处于工作状态,把时间浪费减到最少。乱序执行是一种应用在高性能微处理器中来利用指令周期以避免特定类型的延迟消耗的范式。在这种范式中,处理器在一个由输入数据可用性所决定的顺序中执行指令,而不是由程序的原始数据所决定。在这种方式下,可以避免因为获取下一条程序指令所引起的处理器等待,提前处理下一条可以立即执行的指令。其实很多设计思想都来源于生活,只是应用到不同的领域,就有了它的专业术语,让不懂的人觉得很深奥很高大上,在搞明白怎么回事后,会发现其实也没那么难。举个生活中例子,假设晚上下班回家饿了,想煮碗面做宵夜。需要做以下操作:1.洗水锅,2.烧开水,3.煮面条,4.热菜,5,洗碗筷,6.吃面。其中比较耗时的操作就是烧开水了,如果按CPU的流水线顺序处理这些操作,那我们进行到第2步时就只能等水烧好然后才进去后面的步骤,这要做比较符合常规思维,但是太浪费资源了。因为后面的操作不依赖于前面操作的结果,因此我们完全可以在烧水的同时,把菜热好,把碗筷洗干净准备盛面。这就是CPU指令乱序执行

同理类比在CPU的一条流水线中,需要执行多条指令,比如有a,b,c三条指令组成的流水线,执行顺序是a->b->c,这三条指令在数据上没有依赖关系,其中b指令需要去主存中读取数据,c指令是为变量分配内存地址,我们知道在CPU在缓存未命中情况下去主存读取数据是比较慢的,因此b指令会占用比较长的时钟周期,而指令c只能干等,这会使程序的执行效率大打折扣。这时指令乱序就会发挥出它的作用了,因为b,c指令没有依赖关系,b,c指令执行顺序调换不会影响最终结果,所以CPU在执行b指令时先会向数据总线发起向主存中读数据的请求,在等待数据加载到CPU的高速缓存期间,会继续执行c指令(注意此时b指令并没有执行完,因为它还没有真正从主存中读到数据),然后再执行继续b指令,这时指令的执行顺序就变成了a->c->b。在理解了乱序执行,那么对编码有什么帮助吗?有的,对于开发人员来说,单例模式应该都不陌生,由于频繁创建对象比较浪费资源,就考虑将所有用到某个类的实例时,公用同一个实例,于是就有了单例模式,然而单例模式并不是想象的那么简单。单例模式写法有很多,在保证并发同步的单例模式中,有如下一种写法:

public class Singleton {

    private Singleton() {}

    private static Singleton singleton = null;

    public static Singleton getSingleton() {
        if (singleton == null) {
            // 若singleton为空,则加锁,再进一步判空
            synchronized (Singleton.class) {
                // 再判断一次是否为null
                if (singleton == null) {
                    //若为空,则创建一个新的实例
                    singleton = new Singleton();      
                }
            }
        }
        return Singleton;
    }
}

这种写法算是一个考虑比较周全的设计了 为了防止多线程调用产生多个实例,采用了同步锁 ,为了降低多线程竞争锁所带来的性能损耗,使用了两次判断,加锁位置得当,尽可能降低了锁对性能的影响 ,但是这个例子在单线程中没有任何问题,但在多线程中,如果出现指令乱序就会返回错误的结果。上面的单例模式中,出现问题的核心代码只有一行,就是第15行。即 singleton = new Singleton(); 因为这行代码不是一个原子操作,他实际上包含三个操作:

  1. 为对象 new Singleton() 分配内存空间
  2. 调用类 Singleton 的构造方法,初始化成员变量
  3. 将变量 singleton 的引用指向Singleton 对象的内存地址

在这三步中,第2步依赖于第1步,但2和3并没有依赖关系,因此第2步和第3步可能会先指令乱序。即当CPU流水线执行第2步时,由于类的初始化需要的时间比较长,为避免流水线阻塞,CPU会先执行第3步,将singleton 先指向对象的地址,因此线程有可能得到一个不为null,但是构造不完全的对象(对于不完全的对象的理解:即全部变量的初始化没有执行完)。对于这种情况有两种解决方案:

方案一:使用volatile关键字,在java5以前,volatile原语不怎么强大,只能保证对象的可见性。但在java5之后,volatile语义加强了,被volatile修饰的对象,将禁止该对象上的读写指令重排序这样,就保证了线程B读对象时,已经初始化完全了

方案二:这也是官方比较推荐的一种方案,利用类加载机制来创建单例模式,不一样的是,它是在内部类里面去创建对象实例。这样的话,只要应用中不使用内部类,JVM就不会去加载这个单例类,也就不会创建单例对象

public class Singleton{  

    private static class SingletonHolder{  
        public static Singleton instance = new Singleton();  
    }  

    private Singleton(){}  

    public static Singleton newInstance(){  
        return SingletonHolder.instance;  
    }  
}  

as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行的结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的(实际上没有依赖的指令会出现重排序,但最终结果与顺序执行的结果一致)。as-if-serial语义使单线程程序员无需担心重排序会 干扰他们,也无需担心内存可见性问题。

内存屏障(Memory Barrier )

内存屏障,又称内存栅栏,是一个CPU指令,它有如下特点:1.保证特定操作的执行顺序。2.影响某些数据(或者是某条指令的执行结果)的内存可见性。主要有两个指令:

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存的数据拷贝到处理器的缓存中。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。java内存模型volatile是基于Memory Barrier实现的。如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。这意味着,如果写入一个volatile变量,就可以保证:1.一个线程写入变量a后,任何线程访问该变量都会拿到最新值。2.在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。简单来说内存屏障(Memory Barrier,或内存栅栏,Memory Fence)就是从本地或工作内存到主存之间的拷贝动作。如下图:

上面三种颜色的箭头,就是跨越内存栅栏。在多线程并发过程中,仅当写操作线程先跨越内存栅栏而读线程后跨越内存栅栏的情况下,写操作线程所做的变更才对其他线程可见。内存屏障分为以下3类:

屏障名称 作用
写屏障(store barrier) 所有在写屏障之前的所有执行,都要在该屏障之前执行,并发送缓存失效的信号
所有在写屏障之后的store指令,都必须在该屏障之前的指令执行完后再被执行
读屏障(load barrier) 所有在读屏障之后的load指令,都在读屏障之后执行
全屏障(Full Barrier) 所有在该屏障之前的store/load指令,都在该屏障之前被执行
所有在该屏障之后的的store/load指令,都在该屏障之后被执行
  1. Store barrier,是x86的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。这会使得程序状态对其它CPU可见,这样其它CPU可以根据需要介入。
  2. Load barrier,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。这使得从其它CPU暴露出来的程序状态对该CPU可见,这之后CPU可以进行后续处理。
  3. Full barrier,是x86上的”mfence“指令,复合了load和save屏障的功能。

原子指令和Software Locks

原子指令,如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。对应的在java内存模型的内存屏障分为以下4类:

  1. StoreStore Barriers:确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
  2. LoadStore Barriers:确保Load1数据装载之前于Store2及所有后续存储指令的存储。
  3. StoreLoad Barriers:确保Store1数据对其他处理器可见(刷新到内存)之前于Load2及所有后续装载指令的装载。
  4. LoadLoad Barriers:确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。

其中比较特殊的是StoreLoad Barriers会使该屏障之前的所有内存访问指令(装载和存储指令)完成之后才执行该屏障之后的内存访问指令,是一个”全能型”的屏障,它同时具有其他三个屏障的效果。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到各个CPU的缓存(lazy型刷新, CPU监听数据总线将缓存数据标记为invalid,用到时再重内存中载入),这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。java中的volatile关键字正是使用了内存屏障。如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。内存屏障作为另一个CPU级的指令,没有锁那样大的开销。内核并没有在多个线程间干涉和调度。但凡事都是有代价的。内存屏障的确是有开销的——编译器/cpu不能重排序指令,导致不可以尽可能地高效利用CPU,另外刷新缓存亦会有开销。所以不要以为用volatile代替锁操作就一点事都没有。


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
java并发编程-JMM与JSR133 java并发编程-JMM与JSR133
Java平台自动集成了线程以及多处理器技术,这种集成程度比Java以前诞生的计算机语言要厉害很多,该语言针对多种异构平台的平台独立性而使用的多线程技术支持也是具有开拓性的一面,有时候在开发Java同步和线程安全要求很严格的程序时,往往容易混
2019-04-04
Next 
java并发编程-cpu的高速缓存 java并发编程-cpu的高速缓存
CPU缓存是CPU一个重要的组成部分,CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存,基于此,现在CPU大多数情况下读写都不会直接访问
2019-03-24
  TOC