第十一章 多线程编程的硬件基础与java内存模型--《java多线程编程实战指南-核心篇》

目录

 

填补处理器与内存之间的鸿沟:高速缓存

 数据世界的交通规则:缓存一致性协议

硬件缓冲区:写缓冲器和无效化队列

存储转发

再探内存重排序

再探可见性

基本内存屏障

java同步机制与内存屏障

volatile关键字的实现

synchronized关键字的实现

java虚拟机对内存屏障使用的优化

java内存模型

什么是java内存模型(java内存模型可以参见深入理解JVM第12章)


填补处理器与内存之间的鸿沟:高速缓存

为了弥补处理器与主内存处理器能力之间的鸿沟,硬件设计者在主内存和处理器之间引入高速缓存,如下图所示:

 高速缓存是一种存取速度比主内存大而容量远比主内存小的存储部件,每个处理器都有其高速缓存。引入高速缓存后,处理器在执行内存读、写操作的时候并不直接与主内存打交道,而是通过高速缓存进行的。高速缓存相当于一个由硬件实现的容量极小的散列表,其key是一个内存地址,其值是内存数据的副本或者准备写入内存的数据。从内存结构来看,高速缓存相当于一个拉链散列表,他包含若干桶,每个桶又可以包含缓存条目,结构有点类似于hashmap,如下图所示:

 缓存条路可被进一步划分为Tag、Data Block以及Flag这三个部分,如下图所示。其中,Data Block也被称为缓存行,它是高速缓存与主内存之间的数据交换最小单元,用于存储从内存中读取的或者准备写往内存的数据。Tag则包含了缓存行中数据相应的内存地址的部分信息。Flag用于表示相应缓存行的状态信息。缓存行的容量(也被称为缓存行宽度)通常是2的倍数,其大小在16-256字节之间。

处理器在执行内存访问操作时会将相应的内存地址解码。内存地址的解码结果包括tag、index以及offset这三部分数据。其中,index相当于桶编号,它可以用来定位内存地址对应的桶;一个桶可能包含多个缓存条目,tag相当于缓存条目的相对编号,其作用在于用来与同一个桶中的各个缓存条目中的tag部分进行比较,以定位一个具体的缓存条目;一个缓存条目中的缓存行可以用来存储多个变量,offset是缓存行内的位置偏移,其作用在于确定一个变量在一个缓存行中的存储起始位置。根据这个内存地址的解码结果,如果高速缓存子系统能够找到相应的缓存行并且缓存行所在的缓存条目的Flag表示相应缓存条目是有效的,那么我们就称相应的内存操作产生了缓存命中;否则,我们就称相应的内存操作产生了缓存未命中。

具体来说,缓存未命中包括读未命中和写未命中,分别对应内存读和写操作。当读未命中产生时,处理器所需要读取的数据会从主内存中加载并被存入相应的缓存行之中。这个过程会导致处理器停顿而不能执行其它指令,这不利于发挥处理器的处理能力。因此,从性能的角度来看我们应该尽可能的减少缓存未命中。

现代处理器一般具有多个层次的高速缓存,如下图所示。在这个层级中,相应的高速缓存通常被称为一级缓存,二级缓存,三级缓存等。一级缓存可能直接被集成在处理器的内核里,因此其访问效率非常高。

 数据世界的交通规则:缓存一致性协议

多个线程并发访问同一个变量的时候,这些线程的执行处理器上的高速缓存各自都会保留一份该共享变量的副本,这就带来一个问题-一个处理器对其副本数据进行更新之后,其他处理器如何察觉到该更新并做出适当反应,以确保这些处理器后续读取该共享变量时能够读取到这个更新。这就是缓存一致性问题,其实质就是如何防止读脏数据和丢失更新的问题。为了解决这个问题,处理器之间需要一种通信机制--缓存一致性协议。

MESI协议是一种广为使用的缓存一致性协议。MESI协议对内存数据访问的控制类似于读写锁,它使得针对同一地址的读内存操作是并发的,而针对同一地址的写操作是独占的,即针对同一内存地址进行的写操作在任意一个时刻只能够由一个处理器执行。在MESI协议中,一个处理器往内存中写数据时必须持有该数据的所有权。

为了保障数据的一致性,MESI将缓存条目的状态划分为Modified,Exclusive,Shared和Invalid这四种,并在此基础上定义了一组消息用于协调各个处理器的读、写内存操作。

MESI协议中一个缓存条目的Flag值有以下四种可能:

  • Invalid(无效的,记为1)。该状态下表示相应缓存行中不包含任何内存地址对应的有效副本数据。该状态是缓存条目的初始状态。
  • Shared(共享的,记为S)。该状态表示相应缓存行包含相应内存地址所对应的副本数据。并且,其他处理器上的高速缓存中也可能包含相同内存地址对应的副本数据。因此,一个缓存条目的状态如果为Shared,并且其他处理器上也存在Tag值与该缓存条目的Tag值相同的缓存条目,那么这些缓存条目的状态也为Shared,处于该状态的缓存条目,其缓存行中包含的数据与主内存包含的数据一致。
  • Exclusive(独占的,记为E)。该状态表示相应缓存行包含相应内存地址所对应的副本数据。并且,该缓存行以独占的方式保留了相应内存地址的副本数据,即其他所有处理器上的高速缓存当前都不保留该数据的有效副本。处于该状态的缓存条目,其缓存行中包含的数据与主内存中包含的数据一致。
  • Modified(更改过的,记为M)。该协议表示相应缓存行包含对相应内存地址所做的更新结果数据。由于MESI协议中的任意一个时刻只能够有一个处理器对同一内存地址对应的数据进行更新,因此在多个处理器上的高速缓存中Tag值相同的缓存条目中,任意一个时刻只能够有一个缓存条目处于该状态。处于该状态的缓存条目,其缓存行中包含的数据与主内存中包含的数据不一致。

 MESI协议定义了一组消息用于协调各个处理器的读、写内存操作,如下图所示。比照HTTP协议,我们可以将MESI协议中的消息分为请求消息和响应消息。处理器在执行内存读、写操作时在必要的情况下会往总线(Bus)中发送特定的请求消息,同时每个处理器还嗅探(也称拦截)总线中由其他处理器发出的请求消息并在一定条件下往总线中回复相应的响应消息。

下面看看使用MESI协议的处理器是如何实现内存读、写操作的。假设内存地址A上的数据S是处理器Processor0和Processor1可能共享的数据。

下面讨论在Processor0上读取数据S的实现。Processor0会根据地址A找到对应的缓存条目,并读取该缓存条目的Tag和Flag值。为了方便讨论,这里我们不讨论Tag值的匹配问题。Processor0找到的缓存条目的状态如果是M、E或者S,那么该处理器可以直接从相应的缓存行中读取地址A所对应的数据,而无需往总线中发送任何消息。Processor0找到的缓存条目的状态如果为I,则说明该处理器的高速缓存中并不包含S的有效副本数据,此时Processor0需要往总线发送Read消息以读取地址A对应的数据,而其他处理器Processor1或者主内存则需要回复Read Response以提供相应的数据,如下图所示:

Processor0接收到Read Response消息时,会将其中携带的数据(包含数据S的数据块)存入相应的缓存行并将相应缓存条目的状态更新为S。Processor0接收到的Read Response消息可能来自主内存也可能来自其他处理器(Processor1)。Processor1会嗅探总线中由其他处理器发送的消息。Processor1嗅探到Read消息的时候,会从该消息中取出待读取的内存消息,并根据该地址在高速缓存中查找对应的缓存条目。如果Processor1找到的缓存条目的状态不为I,则说明该处理器的高速缓存中有待读取的数据的副本,此时Processor1会构造相应的Read Response消息并将相应缓存行所存储的整块数据(而不仅仅是Processor0所请求的数据S)塞入该消息。如果Processor1找到的相应缓存条目的状态是M,那么Processor1可能在往总线发送发送Read Response消息前将相应缓存行中的数据写入主内存。Processor1往总线发送发送Read Response之后,相应缓存条目的状态会被更新为S。如果Processor1找到的高速缓存条目的状态为I,那么Processor0所接收到的Read Response消息就来自于主内存。可见,在Processor0读取内存的时候,即便Processor1对相应的内存数据进行了更新且这种更新还停留在Processor1的高速缓存中而造成高速缓存数据与主内存中的数据不一致,在MESI消息的协调下这种不一致也并不会导致Processor0读取到一个过时的旧值。

下面讨论Processor0往地址A写数据的实现。任何一个处理器执行内存写操作时必须拥有相应数据的所有权。在执行内存写操作时,Processor0会先根据内存地址A找到相应的缓存条目。Processor0所找到的缓存条目的状态若为E或者M,则说明该处理器已经拥有相应数据的所有权,此时该处理器可以直接将数据写入相应的缓存行并将相应缓存条目的状态更新为M。Processor0所找到的缓存条目的状态如果不为E、M,则该处理器需要往总线发送Invalidate消息以获得数据的所有权。其他处理器接收到Invalidate消息后会将其高速缓存中相应的缓存条目状态更新为I(相当于删除相应的副本数据)并回复Invalidate Acknowledge消息。发送Invalidate消息的处理器(即内存写操作的执行处理器),必须在接收到其他所有处理器回复的所有Invalidate Acknowledge消息之后再将数据更新到相应的缓存行中,如下图所示:

Processor0所找到的缓存条目的状态若为S,则说明Processor1上的高速缓存可能也保留了地址A对应的数据副本(场景1),此时Processor0需要往总线发送Invalidate消息。Processor0在接受到其他所有处理器所回复的Invalidate Acknowledge消息之后会将相应的缓存条目的状态更新为E,此时Processor0获得了地址A上数据的所有权。接着,Processor0便可以将数据写入相应的缓存行,并将相应的缓存条目的状态更新为M。Processor0所找到的缓存条目的状态若为I,则表示该处理器不包含地址A对应的有效副本数据(场景2),此时Processor0需要往总线发送Read Invalidate消息。Processor0在接收到Read Resonse消息以及其他所有处理器所回复的Invalidate Acknowledge消息之后,会将相应缓存条目的状态更新为E,这表明该处理器已经获得相应数据的所有权。接着,Processor0便可以往相应的缓存行中写入数据了并将相应缓存条目的状态更新为M。其他处理器在接收到Invalidate消息或者Read Invalidate消息之后,必须根据消息中包含的内存地址在该处理器的高速缓存中查找相应的高速缓存条目。若Processor1所找到的高速缓存条目的状态不为I(场景2),那么Processor1必须将相应缓存条目的状态更新为I,以删除相应的副本数据并给总线回复Invalidate Acknowledge消息。可见,Invalidate消息和Invalidate Acknowledge消息使得针对同一个内存地址的写操作在任意一个时刻只能有一个处理器执行,从而避免了多个处理器同时更新同一数据可能导致的数据不一致问题。

从上述例子来看,在多个线程共享变量的情况下,MESI协议已经能够保障一个线程对共享变量的更新对其他处理器上运行的线程来说是可见的;既然如此,第二章所说的可见性又可以存在?这需要从写缓冲器和无效化队列的角度来解释。

硬件缓冲区:写缓冲器和无效化队列

MESI协议解决了缓存一致性问题,但是其自身也存在一个性能弱点--处理器执行写内存操作时,必须等待其他所有处理器将其高速缓存中的相应副本数据删除并接受到这些处理器所回复的Invalidate Acknowledge/Read Response消息之后才能将数据写入高速缓存。为了规避和减少这种等待造成的写操作的延迟,硬件设计者引入了写缓冲器和无效化队列,如下图所示:

写缓冲器(Store Buffer)是处理器内部的一个容量比高速缓存还小的私有高速存储部件,每个处理器都有其写缓冲器,写缓冲器内部可能包含若干条目。一个处理器无法读取另一个处理器上的写缓冲器中的内容。

引入写缓冲器之后,处理器在执行写操作时会做这样的处理:如果相应的缓存条目状态为E或者M,那么处理器可能会直接将数据写入相应的缓存行而无需发送任何消息;如果相应的缓存条目状态为S,那么处理器会先将写操作的相关数据(包括数据和待操作的内存地址)存入写缓冲器的条目中,并发送Invalidate消息;如果相应的缓存条目状态为I,我们就称相应的写操作遇到了写未命中,那么此时处理器会先将写操作相关数据存入写缓冲器的条目之中,并发送Read Invalidate消息。我们知道在其他所有处理器的高速缓存都未保存指定地址的副本数据的情况下,Read消息回复者是主内存,也就是说Read消息可能导致内存读操作。因此,写未命中的开销是比较大的,内存写操作的执行处理器再将写操作的相关数据写入写缓冲器之后便认为该写操作已经完成,即该处理器并不等待其他处理器返回Invalidate Acknowledge/Read Response消息而是继续执行其他指令(比如执行读操作)。一个处理器接收到其他处理器所回复的针对同一个缓存条目的所有Invalidate Acknowledge消息的时候,该处理器会将写缓冲器中针对相应地址的写操作的结果写入相应的缓存行中,此时写操作对于其他执行处理器之外的其他处理器来说才算是完成。

由此可见,写缓冲器的引入使得处理器在执行写操作的时候可以不等待Invalidate  Acknowledge消息,从而减少了写操作的延时,这使得写操作的执行处理器在其他处理器回复Invalidate Acknowledge/Read Response消息这段时间内能够执行其他指令,从而提高了处理器的指令执行效率。

引入无效化队列之后,处理器在接受到Invalidate消息之后并不删除消息中指定地址对应的副本数据,而是将消息存入无效化队列之后并不删除消息中指定地址对应的副本数据,而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息,从而减少了写操作执行处理器所需的等待时间。

写缓冲器和无效化队列的引入又引入了内存重排序和可见性问题。

存储转发

引入写缓冲器之后,处理器在执行读操作的时候不能根据相应的内存地址直接读取相应缓存行中的数据作为该操作的结果。这是因为一个处理器在更新一个变量之后紧接着又读取该变量的值的时候,由于该处理器先前对该变量的更新结果可能仍然还停留在写缓冲器之中,因此该变量相应的内存地址所对应的缓存行中存储的值是该变量的旧值。这种情况下为了避免读操作所返回的结果是一个旧值,处理器在执行读操作的时候会根据相应的内存地址查询写缓冲期。如果写缓冲器存在相应的条目,那么该条目所代表的写操作的结果数据就会直接作为该读操作的结果返回;否则,处理器才会从高速缓存中读取数据。这种处理器直接从磁轭缓冲器中读取数据来实现内存读操作的技术被称为存储转发。存储转发使得写操作的执行处理器能够在不影响该处理器执行读操作的情况下将写数据的结果存入写缓冲器。

再探内存重排序

写缓冲器和无效化队列都可能导致内存重排序。

写缓冲器可能导致StoreLoad重排序(Stores Reordered After Loads)。StoreLoad重排序是绝大多处理器都允许的一种内存重排序。假设处理器Processor0和Processor1上的两个线程都未使用任何同步措施而各自按照程序顺序并依照表11-4所示的线程交错顺序执行。其中变量X、Y为共享变量,其初始值均为0,r1、r2为局部变量。当Processor0上的线程执行到L2时,虽然在此之前S3已经被Processor1执行完毕,但是由于S3的执行结果可能仍然还停留在Processor1的写缓冲器中,而一个处理器无法读取到另外一个处理器的写缓冲器中的内容,因此Processor0此时读取到的Y的值仍然是其高速缓存中存储的该变量的初始值0。同理,Processor1执行到L4时所读取到变量X的值也可能是该变量的初始值0。因此,从Processor1的角度来看,Processor1执行L4的那一刻Processor0已经执行了L2而S1却像是尚未被执行,即Processor1对Processor0执行的两个操作的感知顺序是L2-->S1,也就是说此时写缓冲器导致了S1(写操作)被重排序到了L2(读操作)之后。

StoreLoad重排序可能导致某些算法失效。例如,Peterson算法中两个线程的操作序列与表11-4类似,因此StoreLoad重排序就可能导致该算法失效。

写缓冲器可能导致StoreStore重排序(Stores Reordered After Stores)。假设处理器Processor0和Processor1上的两个线程未使用任何同步措施而各自按照程序顺序并依照表11-5所示的线程交错顺序执行。其中变量data、ready为共享变量,其初始值分别为0和false。假设Processor0执行S1、S2时该处理器的高速缓存中包含变量ready的副本但不包含变量data的副本,那么S1的执行结果会先被存入写缓冲器而S2的执行结果会直接被存入高速缓存(因为写缓冲器中没有该变量的副本)。L3被执行时S2对ready的更新通过一致性协议可以被Processor1读取到,于是,由于ready值已经变为true,因此Processor1继续执行L4.L4被执行的时候,由于S1对data的更新结果可能停留在Processor0的写缓冲器之中,因此Processor1此时读取到的变量data的值可能仍然是其初始值0,即L4的输出结果可能仍然是0而不是Processor1所期望的新值(Processor0更新之后的值)。从Processor1的角度来看,这就造成了一种现象--S2像是先于S1被执行,即S1(写操作)被重排序(内存重排序)到S2(写操作)之后。同样StoreStore重排序也可能导致某些算法失效。

另外,某些处理器为了充分利用总线带宽以提高将写缓冲器中的内容冲刷(写入)到高速缓存的效率,会将针对连续内存地址的写操作并入同一个写缓冲器条目中,这种处理就被称为写合并。写合并也可能导致StoreStore重排序。

无效化队列可能导致LoadLoad重排序(Loads Reordered After Loads)。假设处理器Processor0和Processor1上的两个线程未使用任何同步措施而各自按照程序顺序并依照表11-5所示的线程交错顺序执行。其中变量data、ready为共享变量,其初始值分别为0和false,进一步假设Processor0的高速缓存中存有变量data和ready的副本,Processor1仅存有变量data的副本而未存有变量ready的副本。那么,Processor0和Processor1有可能按照如下顺序执行一系列操作:

①Processor1执行S1。此时由于Processor1上也存有变量data的副本,因此Processor0会发出Invalidate消息并将S1的操作结果存入写缓冲器。

②Processor1接收到Processor0发出的Invalidate消息时将该消息存入无效化队列并回复Invalidate Acknowledge消息。

③Processor0接收到Invalidate Acknowledge消息,随即将S1的操作结果写入高速缓存。然后,Processor0执行S2。此时由于只有Processor0上存有变量ready的副本,因此Processor0无需发送任何消息,直接将S2的操作结果存入高速缓存即可。

④Processor1执行L3.此时由于Processor1的高速缓存中并没有存储变量ready的副本,因此Processor1会发出一个Ready消息。

⑤Processor0接收到Processor1发出的Read消息并回复Read Response消息。由于此时Processor0已经执行过S2,因此该Read Response消息包含的ready变量值为true。

⑥Processor1接收到Read Response消息并从中取出ready变量的新值(true),此时L3中的循环语句可以结束。

⑦Processor1执行L4。此时,由于Processor0为了更新变量data而发出的Invalidate消息可能仍然还停留在Processor1的无效化队列中,因此Processor1从其高速缓存中读取的变量仍然还停留在Processor1的无效化队列中,因此Processor1从其高速缓存中读取的变量data的值仍然是其初始值。因此,L4所打印的变量值可能是一个旧值。

由此可见,尽管Processor0对共享变量data、ready的更新是按照程序顺序先后到达高速缓存的,但是由于无效化队列的作用Processor1像是在ready变量不为true的情况下提前读取了变量data的值,然而,程序的实际处理逻辑是仅在ready变量之为true的情况下才读取变量data,因此这里Processor1实际读取到的变量(data)值是一个旧值。也就是说,从Processor0的角度来看,L4(读操作)被重排序到了L3(读操作)之前。可见,LoadLoad重排序会导致类似StroeStore重排序的效果。

再探可见性

写缓冲器是处理器内部的私有存储部件,一个处理器中的写缓冲器所存储的内容是无法被其他处理器锁读取的。因此,一个处理器上运行的线程更新了一个共享变量之后,其他处理器上运行的线程再来读取该变量时这些线程可能仍然无法读取到前一个线程对该变量所做的更新,因为这个更新可能还停留在前一个线程所在的处理器上的写缓冲器之中。这种线程就是前面章节所说的可见性问题。因此,我们说写缓冲器是可见性问题的硬件根源。为了使一个处理器上运行的线程对共享变量所做的更新可以被其他处理器上运行的其他线程所读取,我们必须将写缓冲器中的内容写入其所在的处理器上的高速缓存之中,从而使得该更新在缓存一致性协议的作用下可以被其他处理器读取到。实现着一点就是前面章节所说的保证一个处理器上运行的线程对其共享变量所做的更新可以被其他处理器(及其上运行的线程)同步。处理器在一些特定条件下(比如写缓冲器、I/O指令被执行)会将写缓冲器排空或者冲刷,即将写缓冲器中的内容写入到高速缓存,但是从程序对一个或者一组变量更新的角度来看,处理器本身无法保证这种冲刷对程序来说是及时的。因此,为了保证一个处理器对共享变量所做的更新可以被其他处理器同步,编译器等底层系统需要借助一类被称为内存屏障的特殊指令。内存屏障中的存储屏障(Store Barrier)可以使执行该指令的处理器冲刷其写缓冲器。

然而,冲刷写缓冲器只是解决了可见性问题的一半。因为可见性问题的另一半是无效化队列导致的。无效化队列的引入本身也会导致新的问题--处理器在执行内存读取操作前如果没有根据无效化队列中的内容将该处理器上的高速缓存中的相关副本数据删除,那么就可能导致该处理器读取到的数据是过时的旧数据,从而使得其他处理器所做的更新丢失。因此,为了使一个处理器上运行的线程能够读取到另一个处理器上运行的线程对共享变量所做的更新,该处理器必须先根据无效化队列中存储的Invalidate消息删除其高速缓存中的相应副本数据,从而使其他处理器上运行的线程对共享变量所做的更新在缓存一致性协议的作用下能够被同步到该处理器的高速缓存中。内存屏障中的加载屏障(Load Barrier)正是用来解决这个问题的。加载屏障会根据无效化队列内容所指定的内存地址,将相应处理器上的高速缓存中相应的缓存条目的状态都标记为I,从而使该处理器后续执行针对相对地址(无效化队列内容中指定的地址)的读内存操作时必须发送Read消息,以将其他处理器对相关共享变量所做的更新同步到该处理器的高速缓存中。

因此,解决可见性问题首先要使写线程对共享变量所做的更新能够到达(被存储到)高速缓存,从而使该更新对其他处理器是可同步的。其次,读线程所在的处理器要将其无效化队列中的内容应用到其高速缓存上,这样才能够将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中。而这两点是通过存储屏障与加载屏障的成对使用实现的:写线程在执行处理器所执行的存储屏障保障了该线程对共享变量所做的更新对读线程来说是同步的;读线程的执行处理器所执行的加载屏障将写线程对共享变量所做的更新同步到该处理器的高速缓之中。

存储转发技术也可能导致可见性问题。假设处理器Processor0在t1时刻更新了某个共享变量,随后又在t2时刻读取了该变量。在t1时刻到t2时刻之间的这段时间内其他处理器可能已经更新了该共享变量,并且这个更新的结果已经到到该处理器的高速缓存。但是如果Processor0在t1时刻所做的更新仍然停留在该处理器的写缓冲器之中,那么存储转发技术会使Processor0直接从其写缓冲器读取该共享变量的值。也就是说Processor0此时根本不从高速缓存中读取该变量的值,也就使得另外一个处理器对该共享变量所做的更新无法被该处理器读取,从而导致Processor0在t2时刻读取到的变量值是一个旧值。因此,考虑到存储转发技术的这个副作用,从读线程的角度来看,为了使读线程能够将其他线程对共享变量所做的更新同步到该线程所在的处理器的高速缓存中,我们需要清空该处理器上的写缓冲器以及无效化队列。

基本内存屏障

基本内存屏障--LoadLoad屏障、LoadStore屏障、StoreStore屏障和SotreLoad屏障。基本内存屏障可以统一使用XY来表示,其中的X和Y可以代表Load或者Store。基本内存屏障是对一类指令的称呼,这类指令的作用是禁止该指令左侧的任何X操作与该指令右侧的任何Y操作之间进行重排序,从而确保该指令左侧的所有X操作先于该指令右侧的Y操作被提交,即内存操作作用到主内存(或者高速缓存)上,如下图所示。

比如,StoreLoad屏障(即X代表Store,Y代表Load)能够禁止其左侧的任何写操作与其右侧的任何读操作之间进行重排序,因此StoreLoad屏障就保障了该指令之前的写操作的结果在该指令之后的任何读操作的数据被加载之前对其他处理器来说可同步,即这些写操作的结果会在该屏障之后的读操作的数据被加载前被写入高速缓存(或者主内存)。

基本内存屏障的作用只是保障其左侧的X操作(比如读,即X代表Load)先于其右侧的Y操作(比如写,即Y代表Store)被提交,它并不会全面禁止重排序。XY屏障两侧的内存操作仍然可以在不越过内存屏障本身的情况下在各自的范围内进行重排序,并且XY屏障左侧的非X操作与屏障右侧的非Y操作之间仍然可以进行重排序(即越过内存屏障本身)。

编译器(JIT编译器)、运行时(java虚拟机)和处理器 都会尊重内存屏障,从而保障其作用得以落实。

LoadLoad屏障是通过清空无效化队列来实现禁止LoadLoad重排序的。LoadLoad屏障会使其执行处理器根据无效化队列中的Invalidate消息删除其高速缓存中相应的副本。这个过程被称为将无效化队列应用到高速缓存,也被称为清空无效化队列,他使处理器有机会将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中,从而消除了LoadLoad重排序的根源而实现了禁止LoadLoad重排序。

StoreStore屏障可以通过对写缓冲器中的条目进行标记来实现禁止StoreStore重排序。StoreStore屏障会将写缓冲器中的现有条目做一个标记,以表示这些条目代表的写操作需要先于该屏障之后的写操作被提交。处理器在执行写操作的时候如果发现写缓冲器中存在被标记的条目,那么即使这个写操作对应的高速缓存条目的状态为E或者M,此事处理器也不直接将写操作的数据写入高速缓存,而是将其写入写缓冲器,从而使得StoreStore屏障之前的任何写操作先于该屏障之后的写操作被提交。

就处理器的具体实现而言,许多处理器往往将StoreLoad屏障时限为一个通用基本内存屏障,即StoreLoad屏障能够实现其他三种基本内存屏障的效果。StoreLoad屏障能够替代其他基本内存屏障,但是他的开销也是最大的--StoreLoad屏障会清空无效化队列,并将写缓冲器中的条目冲刷高速缓存。因此,StoreLoad屏障既可以将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中,又可以使执行处理器对共享变量所做的共享对其他处理器来说可同步。

java同步机制与内存屏障

java虚拟机synchronized、volatile和final关键字的语义的实现就是借助内存屏障的。第三章介绍的获取屏障和释放屏障相当于由基本内存屏障组合而成的复合屏障。获取屏障相当于LoadLoad屏障和LoadStore屏障的组合,它能够禁止该屏障之前的任何读操作与该屏障之后的任何读、写操作之间的重排序。释放屏障相当于LoadStore屏障和StoreStore屏障的组合,它能够禁止该屏障之前的任何读、写操作与该屏障之后的任何写操作之间进行重排序。

volatile关键字的实现

java虚拟机(JIT编译器)在volatile变量写操作之前插入的释放屏障使得该屏障之前的任何读、写操作都先于这个volatile变量写操作被提交,而java虚拟机(JIT编译器)在volatile变量读操作之后插入的获取屏障使得这个volatile变量读操作先于该屏障之后的任何读、写操作被提交。写线程和读线程通过各自执行的释放屏障和获取屏障保障了有序性。

假设写线程、读线程依照下表中的线程交错执行(即读线程执行时,写线程对共享变量的更新操作已经完成)执行,A、B是普通共享变量,V是volatile变量。释放屏障确保了写线程对共享变量A、B的更新会先于对V的更新被提交,这就意味着读线程在读取到写线程对V的更新情况下也能够读取到写线程对A和B的更新。为了保障读线程对写线程所执行的写操作的感知顺序和程序顺序一致,读线程必须依照与写线程的程序顺序的相反顺序即先读取V在读取A或者B来执行读操作。由于读线程中的读操作(或写操作)也可能会被重排序(包括指令重排序和内存重排序),因此java虚拟机会在读线程中的volatile读操作之后插入一个获取屏障,以保证该线程对共享变量B的读取操作先于对A、B的读取操作被提交。写线程、读线程通过释放屏障和获取屏障这种配对使用保障了读线程对写线程执行的写操作的感知顺序与程序顺序一致,即保障了有序性。

值得注意的是,释放屏障只是确保了该屏障之间的读、写操作先于该屏障之后的任何写操作被提交,因此释放屏障之前的操作之间,其提交顺序可以与程序顺序不一致。例如:上图中写线程对A、B的更新,处理器并无需保证对A的更新先于对B的更新被提交,而只需要保障对A以及B的更新先于对B的更新被提交即可。类似的,获取屏障只是确保了该屏障之前的任何读操作先于该屏障之后的任何读、写操作被提交。因此获取屏障之后的操作之间,其提交顺序可以与程序顺序不一致。写线程和读线程通过配对使用释放屏障和获取屏障,使得上述内存操作提交顺序与程序顺序的不一致并不会对有序性产生影响。

java虚拟机(JIT编译器)会在volatile变量写操作之后插入一个StoreLoad屏障。该屏障不仅禁止该屏障之后的任何读操作与该屏障之前的任何写操作(包括该volatile写操作)之间进行重排序,他还起到以下两个作用。

  • 充当存储屏障。StoreLoad屏障是一个通用存储屏障,其功能涵盖了其他3个基本内存屏障。StoreLoad屏障通过清空其执行处理器的写缓冲器是的该屏障前的所有写操作(包括volatile写操作以及其他任何写操作)的结果得以到达高速缓存,从而使这些更新对其他处理器而言是可同步的。
  • 充当加载屏障,以消除存储转发的副作用。假设处理器Processor0在t1时刻更新了某个volatile变量,在随后的t2时刻又读取了该变量。由于存储转发技术可能使得一个处理器无法将其他处理器对共享变量所做的更新同步到该处理器的高速缓存上,而java语言规范又要求volatile读操作总是可以读取到其他处理器对响应变量所做的更新,因此java虚拟机需要在volatile变量写操作和随后的volatile变量读操作之间插入一个StoreLoad屏障。这是利用了StoreLoad屏障既能够清空写缓冲器还能够清空无效化队列的功能,从而使其他处理器对volatile变量所做的更新能够被同步到volatile变量读线程的执行处理器上

java虚拟机(JIT编译器)在volatile变量读操作前插入的一个加载屏障相当于LoadLoad屏障,他通过清空无效化队列来使得其后的读操作(包括volatile读操作)有机会读取到其他处理器对共享变量所做的更新。读线程能够读取到写线程对volatile变量所做的更新,有赖于写线程在volatile写操作后锁执行的存储屏障。可见,volatile对可见性的保障是通过写线程、读线程配对使用存储屏障和加载屏障实现的。

java虚拟机对synchronized关键字的实现方式与对volatile的实现方式类似。java虚拟机在monitorenter(申请锁)字节码指令对应的机器码指令之后的临界区开始之前的地方所插入的获取屏障以及在monitorexit(释放锁)字节码指令对应的机器码指令之前临界区结束之后的地方所插入的释放屏障确保了临界区中的任何读、写操作无法被重排序到临界区之外,这一点再加上锁的排他性确保了临界区中的操作成为一个原子操作。

synchronized关键字的实现

java虚拟机(JIT)编译器会在monitorenter(用于申请所的字节码指令)对应的指令后临界区开始前的地方插入一个获取屏障。java虚拟机会在临界区结束后monitorexit(用于释放锁的字节码指令)对应的指令前的地方插入一个释放屏障。这里,获取屏障和释放屏障一起保证了临界区内的任何读、写操作都无法被重排序到临界区之外,再加上锁的排他性,这使得临界区内的操作具有原子性。

synchronized关键字对有序性的保障与volatile关键字对有序性的保障实现原理是一样的,也是通过释放屏障和获取屏障的配对使用实现的。释放屏障使得写线程在临界区中执行的读、写操作先于monitorexit对应的指令(相当于写操作)被提交,而获取屏障使得读线程必须在获得锁之后才能够执行临界区中的操作。写线程以及读线程通过这种释放屏障和获取屏障的配对使用实现了有序性。

java虚拟机也会在monitorexit对应的指令之后插入一个StoreLoad屏障。这个处理器的目的与在volatile写操作之后插入一个StoreLoad屏障类似。该屏障充当了存储屏障,从而确保锁的持有线程在释放锁之前锁执行的所有操作的结果能够到达高速缓存,并消除了存储转发的副作用。另外,该屏障禁止了monitorexit对应的指令与其他同步块的monitorenter对应的指令进行重排序,这保障了monitorenter与monitorexit总是成对的,从而使synchronized块的并列(一个synchronized块之后又有其他synchronized块)成为可能。

java虚拟机对内存屏障使用的优化

内存屏障部分禁止重排序的代价就是他会阻止编译器(JIT编译器)、处理器做一些性能优化。这就好比我们在日常生活中打乱预定的顺序往往可以提高办事效率,而一味的按照预定的顺序办事反而可能降低办事效率。

内存屏障的另外一种代价就是其实现往往涉及的冲刷写缓冲器和清空无效化队列,而这两个动作可能是比较耗时的。

因此java虚拟机对内存屏障的使用往往会做一些优化。这些优化包括省略、合并等。例如,对于两个连续的volatile写操作,java虚拟机可能只在最后一个volatile写操作之后插入StoreLoad屏障,而不是在每个volatile写操作后插入一个StoreLoad屏障。

java内存模型

缓存一致性协议确保了一个处理器对某个内存地址进行写操作的结果最终能够被其他处理器所读取。所谓最终就是带有不确定性,换言之,即一个处理器对共享变量所做的更新具体在什么时候能够被其他处理器读取到这一点,缓存一致性协议本身是不保证的。写缓冲器、无效化队列都可能导致一个处理器在某一个时刻读取到共享变量的旧值。因此,从底层的角度来看,计算机必须解决这样一个问题---一个处理器对共享变量所做的更新在什么时候或者说什么情况下才能够被其他处理器所读取,即可见性问题。可见性问题有衍生出一个新问题---一个处理器先后更新多个共享变量的情况下,其他处理器是以何种顺序读取到这些更新的,即有序性问题。

用于回答上述问题的模型就被称为内存一致性模型,也被称为内存模型。java作为一个跨平台的语言,为了屏蔽不同处理器的内存模型的差异,以便java应用开发人员不系根据不同的处理器编写不同的代码,它必须定义自己的内存模型,这个模型就被称为java内存模型。

什么是java内存模型(java内存模型可以参见深入理解JVM第12章)

java内存模型定义了final、volatile和synchronized关键字的行为并确保正确同步的java程序能够正确的运行在不同架构的处理器之上。从应用开发人员的角度来看,java内存模型作为一个模型,它从什么的角度为我们解答以下几个线程安全方面的问题。

  • 原子性问题。针对实例变量、静态变量(即共享变量而非局部变量)的读、写操作,那些具备原子性,那些可能不具备原子性。
  • 可见性问题。一个线程对实例变量、静态变量(即共享变量)进行的更新在什么情况下能够被其他线程锁读取?
  • 有序性问题。一个线程对多个实例变量、静态变量(即共享变量)进行的更新在什么情况下其他线程看来可以是乱序的(即感知顺序与程序顺序不同)

在原子性方面、java内存模型规定对long/double型以外的基本数据类型以及引用类型的共享变量进行读、写操作都具有原子性。另外,java内存模型还特别规定对volatile修饰的long/double型共享变量进行读、写操作也具有原子性。换言之,对引用类型以及几乎所有基本数据类型的共享变量进行读、写操作,java内存模型都能保证他们具有原子性,而对long/double型的共享变量进行的读、写操作是否具有原子性则取决于具体的java虚拟机实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值