Java多线程:Java内存模型1

Java内存模型1

1、Java内存模型的基础

1)、并发编程模型的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行的

2)、Java内存模型的抽象结构

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

在这里插入图片描述

如果线程A与线程B之间要通信的话

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去

2)线程B到主内存中取读取线程A之前已更新过的共享变量
在这里插入图片描述

本地内存A和本地内存B都有主内存中共享变量x的副本,假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1

实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

3)、从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

在这里插入图片描述

1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序

4)、并发编程模型的分类

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。每个处理器上的写缓冲区仅仅对它所在的处理器可见。处理器对内存的读/写操作的执行顺序不一定与内存实际发生的读/写操作顺序一致

在这里插入图片描述

假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0

在这里插入图片描述

处理器A和处理器B可以同时把共享内存变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓冲区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓冲区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是A2->A1。此时,处理器A的内存操作顺序被重排序了

由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类

在这里插入图片描述

StoreLoad Barriers是一个全能型的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障。执行该屏障开销会很昂贵,因为当前处理器通常把写缓冲区中的数据全部刷新到内存中

5)、happens-before简介

从JDK5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以在一个线程之内,也可以是在不同线程之间

happens-before规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个线程的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前

在这里插入图片描述

一个happens-before规则对应一个或多个编译器和处理器重排序规则

2、重排序

重排序是指编译器和处理器为了优化程序性能而堆指令序列进行重新排序的一种手段

1)、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

在这里插入图片描述

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

2)、as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果

		double pi = 3.14;// A
		double r = 1.0;// B
		double area = pi * r * r;// C

在这里插入图片描述

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序

在这里插入图片描述

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

3)、程序顺序规则

按照happens-before的程序顺序规则,上面计算圆的面积的实例代码存在3个happens-before关系:

1)A happens-before B

2)B happens-before C

3)A happens-before C

如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens-before顺序执行的结果一致。在这种情况下,JMM会认为重排序并不非法,JMM允许这种重排序

4)、重排序对多线程的影响

public class ReorderExample {
	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没有数据依赖关系,编译器和处理器也可以对这两个操作重排序

在这里插入图片描述

操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了

在这里插入图片描述

操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中

猜测执行实质上对操作3和操作4做了重排序。重排序在这里破坏了多线程程序的语义

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果

3、顺序一致性

1)、数据竞争与顺序一致性

Java内存模型规范对数据竞争的定义:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序

如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同

2)、顺序一致性内存模型

顺序一致性内存模型有两大特性:

1)一个线程中的所有操作必须按照程序的顺序来执行

2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见

在这里插入图片描述

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化

假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有3个操作,它们在程序中的顺序是:B1->B2->B3

假设两个线程使用监视器锁来正确同步:A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁

在这里插入图片描述

假设两个线程没有做同步:

在这里插入图片描述

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。已上图为例,线程A和B看到的执行顺序都是B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致

3)、同步程序的顺序一致性效果

public class SynchronizedExample {
	int a = 0;
	boolean flag = false;

	public synchronized void writer() { // 获取锁
		a = 1;
		flag = true;
	} // 释放锁

	public synchronized void reader() {// 获取锁
		if (flag) {
			int i = a;
			......
		} // 释放锁
	}
}

顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法观察到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果

在这里插入图片描述

JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能为编辑器和处理器的优化打开方便之门

4)、未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致

未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异:

1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行

2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序

3)JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性

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

在这里插入图片描述

假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间,处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止

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

在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性

在这里插入图片描述

处理器B看到仅仅被处理器A写了一半的无效值

在JSR-133之前的内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)

4、volatile的内存语义

1)、volatile的特性

public class VolatileFeaturesExample {
	volatile long v1 = 0L; // 使用volatile声明64位的long型变量

	public void set(long l) {
		v1 = 1; // 单个volatile变量的写
	}

	public void getAndIncrement() {
		v1++;// 复合(多个)volatile变量的读/写
	}

	public long get() {
		return v1;// 单个volatile变量的读
	}
}

假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价

public class VolatileFeaturesExample {
	long v1 = 0L; // 64位的long型普通变量

	public synchronized void set(long l) {// 对单个的普通变量的写用同一个锁同步
		v1 = 1;
	}

	public void getAndIncrement() {// 普通方法调用
		long temp = get();// 调用已同步的读方法
		temp += 1L;// 普通写操作
		set(temp);// 调用已同步的写方法
	}

	public synchronized long get() {// 对单个的普通变量的读用同一个锁同步
		return v1;
	}
}

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入

锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性

volatile变量自身具有下列特性:

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性

2)、volatile写-读建立的happens-before关系

从JSR-133开始,volatile变量的写-读可以实现线程之间的通信

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义

public class VolatileFeaturesExample {
	int a = 0;
	volatile boolean flag = false;

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

	public void reader() {
		if (flag) {// 3
			int i = a;// 4
            ......
		}
	}
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类

1)根据程序次序规则,1 happens-before 2;3 happens-before 4

2)根据volatile规则,2 happens-before 3

3)根据happens-before的传递性规则,1 happens-before 4

在这里插入图片描述

A线程写入一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见

3)、volatile写-读的内存语义

volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

在这里插入图片描述

线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的

volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变为一致

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

在这里插入图片描述

4)、volatile内存语义的实现

在这里插入图片描述

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序
  • 当第一个操作时volatile写,第二个操作时volatile读时,不能重排序

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

在这里插入图片描述

StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存

volatile写后面的StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读程序读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。JMM在实现上的一个特点,首先确保正确性,然后再去追求执行效率

在这里插入图片描述

LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序,LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序

在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障

public class VolatileBarrierExample {
	int a;
	volatile int v1 = 1;
	volatile int v2 = 2;

	void readAndWrite() {
		int i = v1;// 第一个volatile读
		int j = v2;// 第二个volatile读
		a = i + j;// 普通写
		v1 = i + 1;// 第一个volatile写
		v2 = j * 2;// 第二个volatile写
	}
	......// 其他方法
}

在这里插入图片描述

最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障

X86处理器仅会对写-读操作做重排序。所以在X86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中,volatile写的开销比volatile读的开销会大很多

5)、JSR-133为什么要增强volatile的内存语义

严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势

5、锁的内存语义

1)、锁的释放-获取建立的happens-before关系

public class MonitorExample {
	int a = 0;

	public synchronized void writer() {// 1
		a++;// 2
	}// 3

	public synchronized void reader() {// 4
		int i = a;// 5
	}// 6
}

假设线程A执行writer()方法,随后线程B执行reader()方法。根据happens-before规则,这个过程包含的happens-before关系可以分为3类

1)根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5;5 happens-before 6

2)根据监视器锁规则,3 happens-before 4

3)根据happens-before的传递性,2 happens-before 5

在这里插入图片描述

2)、锁的释放和获取的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中

在这里插入图片描述

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

在这里插入图片描述

锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

3)、锁内存语义的实现

public class ReentrantLockExample {
	int a = 0;
	ReentrantLock lock = new ReentrantLock();

	public void writer() {
		lock.lock();// 获取锁
		try {
			a++;
		} finally {
			lock.unlock();// 释放锁
		}
	}

	public void reader() {
		lock.lock();// 获取锁
		try {
			int i = a;
		} finally {
			lock.unlock();// 释放锁
		}
	}
}

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键

ReentrantLock分给公平锁和非公平锁

A.公平锁

使用公平锁,加锁方法lock()调用轨迹如下:

1)ReentrantLock:lock()

2)FairSync:lock()

3)AbstractQueuedSynchronizer:acquire(int arg)

4)FairSync:tryAcquire(int acquires)

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();//获取锁的开始,首先读volatile变量state
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

加锁方法首先读volatile变量state

使用公平锁,解锁方法unlock()调用轨迹如下:

1)ReentrantLock:unlock()

2)AbstractQueuedSynchronizer:release(int arg)

3)Sync:tryRelease(int releases)

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);//释放锁的最后,写volatile变量state
            return free;
        }

在释放锁的最后写volatile变量state

公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见

B.非公平锁

非公平锁的释放和公平锁完全一样,这里仅仅分析非公平锁的获取。在使用非公平锁时,加锁方法lock()调用轨迹如下:

1)ReentrantLock:lock()

2)NonfairSync:lock()

3)AbstractQueuedSynchronizer:compareAndSetState(int expect, int update)

    protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

该方法以原子操作的方式更新state变量,如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。compareAndSetState(int expect, int update)方法(CAS)具有volatile读和写的内存语义

编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序

程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀。反之,如果程序是在单处理器上运行,就省略lock前缀

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state
  • 公平锁获取时,首先会去读volatile变量
  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义

锁释放-获取的内存语义的实现至少有下面两种方式

1)利用volatile变量的写-读所具有的的内存语义

2)利用CAS所附带的volatile读和volatile写的内存语义

4)、concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现在有了下面4种方式

1)A线程写volatile变量,随后B线程读这个volatile变量

2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量

3)A线程用CAS更新一个volatile变量,随后B线程利用CAS更新这个volatile变量

4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石

首先,声明共享变量为volatile,然后,使用CAS的原子条件更新来实现线程之间的同步,同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的

在这里插入图片描述

Java内存模型2:https://blog.csdn.net/qq_40378034/article/details/86822753

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/86800091