深入理解JMM和并发三大特性

并发和并行

并发和并行的目的都是为了使CPU的使用率最大化,这两个概念也是我们容易混淆的。

并行(Parallel)

并行是指在同一时刻,有多条指令在多个处理器上同时执行,因为并行要求程序能同时执行多个操作,因此只在多处理器系统中存在。无论从微观还是从宏观来看,并行的多条指令都是同时执行的。

并发(Concurrency)

并发是指在同一时刻只能有一条指令执行,但会有多个进程执行被快速地轮换执行,这就使得在宏观上看好像多个进程在同时执行,但在微观上看同一时刻只有一条指令在执行。并发可以在单处理器系统中存在,因为并发只是要求程序假装同时执行多个操作,实际上只是把时间分为若干段,多个进程快速交替地执行。

并发三大特性

并发的三大特性分别是可见性、原子性和有序性,我们平时并发编程出现的bug大多数与这三个特性有关。

可见性

可见性是指当一个线程修改了某个共享变量的值,其他的线程也能看到这个共享变量修改后的值。在Java内存模型的设计中,是通过在共享变量被修改后将修改后的值同步回主内存,当其他线程读取变量时先从主内存刷新共享变量的值这种依赖主内存作为传递媒介的方法来实现可见性的。

我们在使用多个线程时如果不做任何处理,一个线程修改了共享变量的值,其他线程是看不到修改后的值的,例如下面的程序:

public class VisibilityTest {

	private boolean flag = true;

	public void refresh(){
		flag = false;
		System.out.println(Thread.currentThread().getName() + "修改flag为false");
	}

	public void load(){
		System.out.println(Thread.currentThread().getName() + "开始执行");
		int count = 0;
		while (flag) {
			count++;
		}
		System.out.println(Thread.currentThread().getName() + "跳出循环,count = " + count);
	}

	public static void main(String[] args) throws InterruptedException {
		VisibilityTest visibility = new VisibilityTest();
		new Thread(visibility::load,"A线程").start();
		Thread.sleep(1000);
		new Thread(visibility:: refresh,"B线程").start();
	}
}

执行之后发现程序一直没有结束,控制台打印如下图,明显A线程没有“看到”B线程修改的flag的值,这就是可见性带来的问题。

为了保证共享变量的可见性,我们可以采用下面任意一种方式:

  • volatile关键字;
  • 内存屏障;
  • synchronized关键字;
  • Lock锁;
  • final关键字。

有序性

有序性是指程序执行的顺序按照代码的先后顺序执行。存在有序性问题的原因是JVM在有些情况下为了提高执行效率会对代码进行指令重排等操作,这种操作在单线程环境下是没问题的,但在多线程环境就会出现意料之外的问题。

为了保证程序的有序性,我们可以采用下面任意一种方式:

  • volatile关键字;
  • 内存屏障;
  • synchronized关键字;
  • Lock锁。

原子性

原子性是指一个或多个操作要么全部执行且在执行过程中不被任何因素打断,要不全部不执行。

为了保证原子性,我们可以采用下面任意一种方式:

  • synchronized关键字;
  • Lock锁;
  • CAS。

Java内存模型(Java Memory Model,JMM)

Java虚拟机规范中定义了Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步地访问共享变量。JMM描述的是一种抽象的概念,或者说是一组规则,通过这组规则控制程序中各个变量在共享数据区和私有数据区域的访问方式。

JMM规定了所有的共享变量都存储在主内存中,每个线程拥有自己的本地内存,线程在本地内存中保存了被该线程使用的共享变量的主内存的副本,线程对共享变量的所有操作都必须在本地内存中进行,不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递都需要通过主内存来完成。线程、本地内存和主内存的关系可以表示如下图:

JMM与硬件内存架构

需要注意的是,JMM与硬件内存架构没有关系,或许JMM的实现参照了硬件内存架构(CPU缓存和主存)的设计。线程和堆可以出现在硬件架构的主内存、CPU缓存和CPU内存的寄存器中,因此如果硬要说JMM与硬件内存架构有关系的话,可以表示如下图:

内存交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存以及如何从工作内存同步到主内存之间的实现细节,JMM定义了以下8中操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态;
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便于随后的load动作使用,这里可以理解为只是将变量值放到工作内存中,但并没有为其赋值;
  • load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中,这一步才是真正为工作内存的变量副本赋值;
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时都将会执行这个操作;
  • assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时会执行这个操作;
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便于随后的write操作;
  • write(写入):作用于主内存的变量,把store操作从工作内存中一个变量的值传送到主内存的变量中。

但这些操作并不是随意执行的,JMM规定了在执行这8种基本操作时,还需满足以下规则:

  • 如果要把一个变量从主内存复制到工作内存中,必须按顺序地执行read和load操作;如果要把一个变量从工作内存同步回主内存中,必须按顺序地执行store和write操作。但JMM只规定了上述操作必须顺序执行,没有规定必须是连续执行;
  • 不允许read和load、store和write操作之一单独出现;
  • 不允许一个线程丢弃它最近的assign赋值的操作,即变量在工作内存中改变了之后必须同步到主内存;
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存;
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量,即对一个变量实施use和store操作之前,必须先执行过assign和load的操作;
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁,即lock和unlock必须成对地出现;
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量值前需要重新执行load或assign操作初始化变量的值;
  • 如果一个变量事先没有被lock操作锁定,不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量;
  • 对一个变量执行unlock操作之前,必须先执行store和write操作将变量值同步到主内存。

顺序一致性模型

顺序一致性模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性模型有以下两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。即一个线程看到的执行顺序与程序代码的编写顺序是一致的。即便是改变程序代码执行顺序不会影响程序运行结果,也必须按照程序代码顺序执行。
  • 所有的线程都只能看到一个单一的操作执行顺序。即每个操作都必须原子执行且立刻对所有线程可见,多个线程之间不管程序是否同步,它们看到的执行顺序都是一致的。

JMM的内存可见性保证

按程序类型,Java程序的内存可见性保证可以分为以下三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同;
  • 正确同步的多线程程序。正确同步的多线程程序的执行具有顺序一致性,即执行结果与该程序在顺序一致性模型中的执行结果相同。JMM通过限制编译器和处理器的重排序来提供内存可见性保证;
  • 未同步或未正确同步的多线程程序。JMM为它们提供了最小安全保障,即线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值。未同步程序在JMM中执行时,整体上是无序的,其执行结果无法预知。JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。

顺序一致性模型和JMM内存可见性保证对比

未同步程序在两个模型中的执行特性有如下差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序;
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序;JMM不保证所有线程能看到一致的操作执行顺序;
  • 顺序一致性模型保证对所有的内存读/写操作都具有原子性;JMM不保证在32位机器上对64位的long型和double型变量的写操作具有原子性。因为JVM在32位处理器上运行时,可能会把一个64位long/double变量的写操作拆分为两个32位的写操作来执行,这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。JDK5开始,仅允许把一个64位的long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作都必须具有原子性。

CPU多级缓存架构

CPU的计算速度是很快的,相比之下内存的读取速度就显得很慢了,因此CPU如果直接从内存读取数据就需要等待一定的时间周期,无疑会降低CPU的效率。为了解决这个问题,在CPU和内存中间加入了一种容量很小但速度很高的存储器,即高速缓冲存储器(CPU缓存)。CPU缓存中保存着CPU刚用过或者循环使用的一部分数据,当CPU再次使用该部分数据时可从CPU缓存中直接调用,这样就减少了CPU的等待时间,从而达到提高系统效率的目的。

现代CPU为了提升执行效率,减少CPU与内存的交互,一般在CPU上集成了多级缓存架构,常见的是三级缓存架构,可以表示如下图:

  • L1 Cache:一级缓存又分为数据缓存和指令缓存,分别用来缓存数据和指令,是逻辑核独占的。一级缓存是最接近CPU的,容量最小但速度最快;
  • L2 Cache:二级缓存是物理核独占、逻辑核共享的,比一级缓存更大一些,但速度也更慢一些。二级缓存可以理解为一级缓存的缓冲器,一级缓存由于制造成本高导致容量有限,二级缓存的作用就是存储那些CPU处理时需要用到但一级缓存又放不下的数据;
  • L3 Cache:三级缓存是所有物理核共享的,比一级缓存和二级缓存的容量都大,但速度也是最慢的。三级缓存可以看作是二级缓存的缓冲器,这三级缓存容量递增,但单位制作成本递减。

当CPU执行运算需要某些数据时,首先会去L1寻找,找不到再依次去L2、L3和内存中寻找,寻找的路径越长耗时也越长。用CPU的时钟(CPU自带的时钟,时钟周期通常为节拍脉冲或T周期,是处理操作的最基本的单位)表示从这些区域读取数据的耗时可以表示如下图:

上面提到了物理核和逻辑核,下面我们简单介绍下相关概念。

  • 物理CPU:物理CPU就是插在主机上的真实的CPU硬件,在Linux下可以数不同的physical id来确认主机物理CPU的个数;
  • 核心数:多核处理器的核指的就是核心数,在Linux下可以通过cores来确认主机的物理CPU的核心数;
  • 逻辑CPU:逻辑CPU与超线程技术有关,如果物理CPU不支持超线程技术,逻辑CPU的数量与核心数相同;如果物理CPU支持超线程,那么逻辑CPU的数量是核心数量的两倍,在Linux下可以通过processors的数量来确认逻辑CPU的数量;
  • 物理核:可以看得到的真实的CPU核心,有独立的电路元件以及L1和L2缓存,尅独立地执行命令;
  • 逻辑核:在同一个物理核内,逻辑层面的核。物理核通过高速运算,让应用程序以为有两个CPU在运算,多出来的那个CPU就是逻辑核;
  • 超线程(Hyper-threading):超线程可以在一个逻辑核等待指令执行的间隔(等待从cache或内存中获取下一条指令),把时间片分配给另一个逻辑核,高速在这两个逻辑核之间切换,让应用程序感知不到这个间隔,以为自己独占了一个核。

一个CPU可以有多个物理核,如果支持超线程技术,一个物理核可以分为n个逻辑核,其中n为超线程的数量。

缓存一致性(Cache coherence)

CPU多级缓存架构解决了内存速度远低于CPU速度造成CPU没有被充分使用的问题,但又带来了另一个问题:由于每个CPU都有自己的缓存,共享资源数据会被存储在这多个本地缓存中,当系统中的客户机维护公共内存资源的缓存时,可能会出现数据不一致的问题。

例如主内存中有变量x=1,CPU1和CPU2都有x变量的副本,某一时刻CPU1和CPU2都对变量x进行计算,CPU1对x+1,CPU2对x+10,最终主内存的x变量的值变成了一个不确定的值(2或11),这明显不是我们想看到的。

为了解决以上问题,计算机科学家们提出了缓存一致性的概念。缓存一致性是指存储在多个本地缓存中的共享资源数据的一致性,是确保共享数据的变化能够及时地在整个系统中传播的规程。

缓存一致性对共享数据的修改有以下要求:

  • 写传播(Write Propagation):对任何缓存中的数据的更改都必须传播到对等缓存中的其他副本中,这里的副本指的是整个缓存行的副本。
  • 事务串行化(Transaction Serialzation):对单个位置的读/写必须被所有处理器以相同的顺序看到。

确保缓存一致性的两种最常见的机制是窥探机制和基于目录的机制。如果有足够的带宽可用,基于协议的窥探机制往往更快,因为所有事务都是所哟处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须光波导系统的所有节点,这意味着随着系统的变大,总线的带宽及其提供的带宽也必须增加。而基于目录的机制延迟较高,但使用更少的带宽,因为消息是点对点的,而不是广播的,因此许多较大的系统(>64位处理器)使用这种类型的缓存一致性。

总线裁决机制

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务和写事务,读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,会禁止其他的处理器和I/O设备执行内存读/写。

总线可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

原子操作指的是不可被中断的一个或一组操作。处理器会自动保证基本的内存操作的原子性,即一个处理器从内存中读取或写入一个字节时,其他处理器是不能访问这个字节的内存地址的。最新的处理器可以自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但处理器对复杂的内存操作是不能自动保证其原子性的,例如跨总线宽度、跨多个缓存行和跨页表的访问。处理器提供了总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

总线锁定

总线锁定是使用处理器提供的一个LOCK#信号,当其中一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,该处理器可以独占共享内存。

总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁定特定的一块内存区域,因此总线锁定的开销较大。

缓存锁定

缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作协会内存时,处理器不会在总线上发出LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会组织同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使该缓存行无效。

总线窥探(Bus Snooping)

总线窥探是缓存中的一致性控制器监视或窥探总线事务的一种方案,其目标是在分布式共享内存系统中维护缓存一致性。包含一致性控制器的缓存称为snoopy缓存。

当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到其他所有具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成,所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本,如果有,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效,这里还会涉及到缓存块状态的改变,这取决于缓存一致性协议。

窥探协议类型

根据管理写操作的本地副本的方式,有两种窥探协议:

Write-invalidate

当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能一个数据的一个副本,其他缓存中的所有其他副本都无效。这是最常用的窥探协议,MSI、MESI、MOSI、MOESI和MESIF协议都属于这种类型。

Write-update

当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这种方式将写数据广播到总线上的所有缓存中,比Write-invalidate协议需要更大的总线流量,因此这种方式不常见。Dragon和firefly协议属于这种类型。

MESI一致性协议

一致性协议在多处理器系统中应用于高速缓存一致性,为了保持一致性,计算机科学家们设计了各种模型和协议。这里我们重点介绍其中一个较常见的MESI协议。

MESI协议是一个基于写失效的缓存一致性协议,是支持回写缓存的最常用协议。MESI协议规定缓存行有以下四种不同的状态:

  • 已修改Modified(M):表示缓存行是脏(dirty)的,与主存的值不同,如果别的处理器要读取主存的这块数据,该缓存行必须写回主存,随后状态修改为S;
  • 独占Exclusive(E):缓存行只在当前缓存中,但是是干净的,即缓存数据与主存数据的值相同。当别的缓存读取它时,状态变为S;当当前缓存写该数据时,变为M;
  • 共享Shared(S):缓存在存在于两个及以上的缓存中,且所有缓存行的数据都是未修改的,缓存行可以在任意时刻抛弃;
  • 无效Invalid(I):由于某个缓存修改了某个缓存行,这个缓存行在其他缓存中就变成无效状态。

用我们刚开始介绍缓存一致性时的例子来使用MESI协议再来看下会发生什么,如下图。

  1. 最开始时x在CPU1缓存和CPU2的缓存的状态都是S;
  2. 随后CPU1对x进行加1操作,CPU2对x进行加10操作,但CPU1先将x写回缓存,此时CPU1的x所在缓存行状态为M,CPU2x所在缓存行状态为I;
  3. CPU2将x写回缓存时发现缓存行状态是I无效的,因此再次从主存读取x的值;
  4. CPU2读取x的值之前CPU1需要将x的值同步回主存,CPU2再次对x进行计算,最后得到12。

伪共享

如果多个核的线程在操作同一个缓存行的不同变量数据,就会出现频繁的缓存失效的情况,即使在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况就是伪共享(Flase Sharing)。

例如core1只对变量x做处理,core2只对变量y做处理,x和y在同一个缓存行内。由于变量x和变量y在这两个核的L1和L2缓存中都存在,当core1对变量x做修改时,其他缓存有变量x的缓存行失效,就导致core2的变量y的缓存也失效了,此时就需要再次从主存读取变量y的值,反过来也是一样。

伪共享不会影响程序运行的结果,但会降低系统的效率。例如下面的程序中两个线程分别对变量x和变量y自增1亿次,最终得到的结果是正确的,但耗费的时间较长。需要注意的是x和y必须要是volatile修饰的,因为只有volatile修饰的变量才会具有将缓存行设为无效的作用。

public class FalseSharingTest {

    public static void main(String[] args) throws InterruptedException {
        testPointer(new Pointer());
    }

    private static void testPointer(Pointer pointer) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.x++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.y++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(pointer.x+","+pointer.y);
        System.out.println(System.currentTimeMillis() - start);


    }
}


class Pointer {
     volatile long x;
     volatile long y;
}

运算结果:

为了避免伪共享,可以采用以下两种方式。

缓存行填充

我们可以在变量x和变量y之间加入一些变量,使得变量x和变量y不在同一个缓存行中,就不会出现伪共享问题了。例如对于64个字节大小的缓存行,在变量x和变量y的中间加入7个long类型的变量。

class Pointer {
     volatile long x;
     long a1;
     long a2;
     long a3;
     long a4;
     long a5;
     long a6;
     long a7;
     volatile long y;
}

再次运行上面的程序,就会发现效率明显提升了很多。

使用@sun.misc.Contended注解

@Contended注解可以让被修饰的字段与其他字段隔离开,放在单独的缓存行中。需要注意的是,使用该注解时需要配置JVM参数:-XX:-RestricContended。

使用@Contended注解,再次运行上面的程序可以得到以下结果。

指令重排序

Java语言规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,这个过程就叫指令的重排序。

重排序的意义在于JVM可以根据处理器特性(例如CPU多级缓存系统、多核处理器等) 适当地对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度地发挥机器性能。

在编译器与CPU处理器中都能执行指令的重排序操作,从源代码到最终执行可能会经过以下重排序:

  • 编译器重排序:对于程序代码,编译器可以在不改变单线程程序语义的情况下,对代码语句的顺序进行重新排序;
  • 指令集并行的重排序:对于CPU指令,处理器采用了指令集并行技术来将多条指令重叠执行,如果不存在数据依赖,处理器可以改变机器指令的执行顺序;
  • 内存系统重排序:由于CPU缓存使用缓冲区的方式延迟写入,这个过程会造成多个CPU缓存可见性问题,这种可见性问题导致结结果对于指令的先后执行显示不一致,表面结果看来好像指令的顺序被改变了,内存重排序是造成可见性问题的主要原因所在。

as-if-serial

as-if-serial语义的意思是无论怎么重排序,单线程程序的执行结果都不能被改变。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

happens-before原则

happens-before原则主要用于判断数据是否存在竞争、线程是否安全,依靠happens-before原则我们可以解决在并发环境下两个操作之间是否可能存在冲突的所有问题。

happens-before原则的主要定义如下:

  1. 如果一个操作happens-before另一个操作,则第一个操作的结果对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前;
  2. 需要注意的是两个操作之间存在happens-before关系并不意味着最终一定会按照happens-before原则定义的执行顺序执行,如果这两个操作经过重排序之后的执行结果与按照happens-before原则执行的结果一致,那么这种重排序也是被允许的。

happens-before原则包含的具体的规则如下:

  1. 程序次序原则:一段代码在单线程内的执行结果是有序的,虽然编译器、虚拟机和处理器会进行指令的重排序,但这种重排序不会影响程序的执行结果,因此在单线程内程序顺序执行的结果与重排序后执行的结果是一致的;
  2. 锁定规则:一个unlock操作优先发生与后面对同一个锁的lock操作;
  3. volatile变量规则:某个线程对volatile变量的写操作优先于另一个线程对这个变量的读操作,这一点是volatile对可见性的保证;
  4. 传递规则:如果操作Ahappens-before操作B,操作Bhappens-before操作C,那么操作A同样happens-before操作C;
  5. 线程启动规则:某个线程在执行过程中启动了另一个线程,那么第一个线程对共享变量的修改在另一个线程开始执行后确保对该线程可见,例如下面的程序中main线程对flag的修改对子线程是可见的:
public class VisibilityTest {

    private boolean flag = true;

    public void modifyFlag() {
        flag = false;

    }

    public void print() {
        while (flag) {
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();

        new Thread(() -> test.print()).start();
        Thread.sleep(1000);
        test.modifyFlag();
    }
}

程序在运行1S后自动终止了,如果该规则不成立则不会终止。

  1. 线程终结规则:某个线程在执行过程中调用另一个线程的join()方法等待其终止,那么另一个线程对共享变量的修改在第一个线程等待返回后是可见的。

volatile

根据JMM模型我们可以知道一个线程对共享变量的修改对其他线程是不可见的(不满足happens-before规则的情况下),例如下面的程序中第二个线程对flag变量的修改对第一个线程是不可见的,因此此程序不会停止。

public class Test {
    private boolean flag = true;

    public void modifyFlag() {
        flag = false;

    }

    public void print() {
        while (flag) {
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();

        new Thread(() -> test.print()).start();
        Thread.sleep(1000);
        new Thread(() -> test.modifyFlag()).start();
    }
}

要使得第二个线程对flag变量的修改对第一个线程可见,最简单的方式就是使用volatile关键字修饰flag,修改后运行可以发现程序可以被正常终止。

需要注意的是,不要在线程中使用System.out.println()来打印一些东西,否则即便不使用volatile关键字修饰flag,第二个线程对flag的修改对第一个线程依然是可见的,例如下面的程序:

public class Test {
    private boolean flag = true;

    public void modifyFlag() {
        flag = false;
        System.out.println("***************flag=flase********************");
    }

    public void print() {
        while (flag) {
            System.out.println("嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿嘿");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();

        new Thread(() -> test.print()).start();
        Thread.sleep(1000);
        new Thread(() -> test.modifyFlag()).start();
    }
}

可以看到在第二个线程修改flag的值之后,第一个线程就终止了,这表示第二个线程对flag的修改对第一个线程是可见的。

出现这种情况的原因是println()方法会使用synchronized上锁,清空线程本地内存,随后重新从主内存复制共享变量,这时第一个线程本地内存中的flag变量已经变成第二个线程修改后的了,所以才会出现这种情况。

回到volatile关键字,它具有以下特性:

  1. 可见性:某个线程读一个volatile修饰的共享变量时总能读到所有线程对该变量最后的写入值;
  2. 有序性:volatile修饰的变量在其读写操作的前后都会被添加多个内存屏障来禁止指令的重排序以保证有序性;
  3. 原子性:volatile可以保证其修饰单个变量读写的原子性,不能保证一些复合操作的原子性。

当某个线程对一个volatile变量写时,JMM会将该线程对应的本地内存中的共享变量刷新回主内存;当某个线程对一个volatile读时,JMM会将该线程对应的本地内存中的该变量的副本置为无效,然后重新从主内存读取共享变量。这也就保证了volatile变量的可见性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值