Java多线程:Java内存模型2

Java内存模型1:https://blog.csdn.net/qq_40378034/article/details/86800091

Java内存模型2

6、final域的内存语义

1)、final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则:

1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

public class FinalExample {
	int i;// 普通变量
	final int j;// final变量
	static FinalExample obj;

	public FinalExample() {// 构造函数
		i = 1;// 写普通域
		j = 2;// 写final域
	}

	public static void writer() {// 写线程A执行
		obj = new FinalExample();
	}

	public static void reader() {// 读线程B执行
		FinalExample object = obj;// 读对象引用
		int a = obj.i;// 读普通域
		int b = obj.j;// 读final域
	}
}

2)、写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面

1)JMM禁止编译器把final域的写重排序到构造函数之外

2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障

在这里插入图片描述

3)、读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与处理读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。有少数处理器允许对存在间接依赖关系的操作做重排序,这个规则就是专门针对这种处理器的

在这里插入图片描述

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用

4)、final域为引用类型

public class FinalReferenceExample {
	final int[] intArray;
	static FinalReferenceExample obj;

	public FinalReferenceExample() {// 构造函数
		intArray = new int[1];// 1
		intArray[0] = 1;// 2
	}

	public static void writerOne() {// 写线程A执行
		obj = new FinalReferenceExample();// 3
	}

	public static void writerTwo() {// 写线程B执行
		obj.intArray[0] = 2;// 4
	}

	public static void reader() {// 读线程C
		if (obj != null) {// 5
			int temp1 = obj.intArray[0];// 6
		}
	}
}

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知

如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性

5)、为什么final引用不能从构造函数内逸出

写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该变量指向的对象的final域已经在构造函数中被正确初始化过了。在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中逸出

public class FinalReferenceExample {
	final int i;
	static FinalReferenceExample obj;

	public FinalReferenceExample() {
		i = 1;// 1写final域
		obj = this;// 2this引用在此逸出
	}

	public static void writer() {
		new FinalReferenceExample();
	}

	public static void reader() {
		if (obj != null) {// 3
			int temp = obj.i;// 4
		}
	}
}

在这里插入图片描述

在这里插入图片描述

在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值

6)、JSR-133 为什么要增强final的语义?

通过为final域增强写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有逸出),那么不需要使用同步就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值

7、happens-before

1)、JMM的设计

JMM把happens-before要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序
  • 不会改变程序执行结果的重排序

JMM对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求

在这里插入图片描述

  • JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证
  • JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行

2)、happens-before的定义

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证

happens-before关系的定义如下:

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法

happens-before关系本质上和as-if-serial语义是一回事

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的

as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度

3)、happens-before规则

happens-before规则:

1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作

2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作

6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

8、双重检查锁定与延迟初始化

1)、双重检查锁定的由来

public class DoubleCheckedLocking {
	private static Instance instance;

	public static Instance getInstance() {
		if (instance == null) {
			synchronized (DoubleCheckedLocking.class) {
				if (instance == null) {
					instance = new Instance();
				}
			}
		}
		return instance;
	}
}
  • 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象
  • 在对象创建好之后,执行getInstance()方法降不需要获取锁,直接返回已创建好的对象

2)、问题的根源

前面的双重检查锁定实例代码中instance = new Instance();创建了一个对象。第一行代码可以分解为如下的3行伪代码

memory=allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance=memory; //3:设置instance指向刚分配的内存地址

上面3行伪代码中的2和3之间,可能会被重排序。2和3之间重排序之后的执行时序如下:

memory=allocate(); //1:分配对象的内存空间
instance=memory; //3:设置instance指向刚分配的内存地址
ctorInstance(memory); //2:初始化对象

intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面3行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能

在这里插入图片描述

在这里插入图片描述

当线程A和B按上图的时序执行时,B线程将看到一个还没有被初始化的对象

在这里插入图片描述

这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象

通过两个办法来实现线程安全的延迟初始化

1)不允许2和3重排序

2)允许2和3重排序,但不允许其他线程看到这个重排序

3)、基于volatile的解决方案

public class SafeDoubleCheckedLocking {
	private volatile static Instance instance;

	public static Instance getInstance() {
		if (instance == null) {
			synchronized (DoubleCheckedLocking.class) {
				if (instance == null) {
					instance = new Instance();
				}
			}
		}
		return instance;
	}
}

当声明对象的引用为volatile后,禁止2和3之间的重排序,来保证线程安全的延迟初始化

在这里插入图片描述

4)、基于类初始化的解决方案

JVM在类的初始化阶段,会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化

public class InstanceFactory {
	private static class InstanceHolder {
		public static Instance instance = new Instance();
	}

	public static Instance getInstance() {
		return InstanceHolder.instance;// 这里将导致InstanceHolder类被初始化
	}
}

在这里插入图片描述

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化

1)T是一个类,而且一个T类型的实例被创建

2)T是一个类,且T中声明的一个静态方法被调用

3)T中声明的一个静态字段被赋值

4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段

5)T是一个顶级类,而且一个断言语句嵌套在T内部被执行

在InstanceFactory示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化

Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了

猜你喜欢

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