深入Java多线程——Java内存模型深入(2)

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>> 深入Java多线程——Java内存模型深入(2)
文章图片

5. final域的内存语义 5.1 final域的重排序规则
1.对于final域,编译器和处理器要遵守两个重排序规则:
(1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
(2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
以下列代码为例进行解释,假设线程A执行执行writer方法,线程B执行reader方法。

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 = object.i; // 读普通域 int b = object.j; // 读final域 } }

5.2 写final域的重排序规则
1. 写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:
(1)JMM禁止编译器把final域的写重排序到构造函数之外。
(2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。所以在上面的代码中就可能会发生写普通域的操作被编译器重排序到了构造函数之外,读线程B错误地读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确地读取了final变量初始化之后的值。
5.3 读final域的重排序规则
1. 读final域的重排序规则是:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序 (比如alpha处理器),这个规则就是专门用来针对这种处理器的。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用,但是读普通域就没有这个保证。所以,在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了,而如果读普通域的指令被排在了读obj对象引用之前,就会导致空指针异常,因为此时对于局部变量引用object还没有赋予对象。
5.4 若final域为引用类型
以下列代码为例
public class FinalReferenceExample { final int[] intArray; // final是引用类型 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 } } }

1.在上例代码中,final域为一个引用类型,它引用一个int型的数组对象。对于引用类型:
(1)写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。也就是说,1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被 构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。
(2)假设线程A先执行,执行完成后再执行线程B和C,那么,JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看得到, 也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。 如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。
5.5 final域所在对象的引用不能从构造函数中溢出
写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果, 还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。如果没有这个保证会发生什么?如下面代码所示
public class FinalReferenceEscapeExample { final int i; static FinalReferenceEscapeExample obj; public FinalReferenceEscapeExample() { i = 1; // 1写final域 obj = this; // 2 this引用在此"逸出" } public static void writer() { new FinalReferenceEscapeExample(); } public static void reader() { if (obj != null) { // 3 int temp = obj.i; // 4 } } }

假设一个线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作2使得对象还未完成构造前就为线程B可见。即使这里的操作2是构造函数的最后一步,且在程序中操作2排在操作1后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的操作1和操作2之间可能被重排序。
所以,在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。
5.6 final语义在处理器中的实现
写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障。读final域的重排序规则要求编译器在读final域的操作前面插入 一个LoadLoad屏障。
但是某些处理器,以X86处理器为例,它不支持写-写重排序,所以写final域操作后插入的StoreStore屏障会被省略。其次X86处理器不会对存在间接关系依赖的数据操作进行重排序,所以读final域前的LoadLoad屏障也会被省略。也就是说X86处理器中,final的读写不需要插入任何内存屏障。
6. happens-before 6.1 JMM的设计
1.在设计JMM时,需要考虑两个关键因素:
(1)程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
(2)编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
所以,设计JMM时,一定要在以上两个因素之间找到一个平衡点,一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理 器的限制要尽可能地放松。
2. 以下列代码为例:
double pi = 3.14; // A double r = 1.0; // B double area = pi * r * r; // C

上面计算圆的面积的示例代码存在3个happens-before关系:
(1)A happens-before B。
(2)B happens-before C。
(3)A happens-before C。
在3个happens-before关系中,2和3是必需的,但1是不必要的。因此,JMM把happens-before 要求禁止的重排序分为了下面两类:
(1)会改变程序执行结果的重排序。
(2)不会改变程序执行结果的重排序。
JMM对这两种不同性质的重排序,采取了不同的策略:
(1)对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
(2)对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
也就是说
(1)JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens-before B)。
(2)JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序), 编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变 量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。
6.2 happens-before的定义
1.定义如下:
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
对于程序员来说,定义(1)的关系表示:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见, 且A的执行顺序排在B之前。
定义(2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵 循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序), 编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重 排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因 此,happens-before关系本质上和as-if-serial语义是一回事。
2. as-if-serial与happens-before对比:
(1)as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
(2)as-if-serial语义给编写单线程程序的程序员一个假象,即单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员一个假象,即正确同步的多线程程序是按happens-before指定的顺序来执行的。
6.3 happens-before的规则
1. happens-before是JMM最核心的概念。其四个规则分别是
(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()操作成功返回。
2. 以volatile写-读建立的happens-before关系为例:
深入Java多线程——Java内存模型深入(2)
文章图片


首先,在volatile写操作前会插入一个StoreStore屏障,保证在volatile变量写操作之前的其他写操作一定会先被执行,也就是:操作1happens-before 操作2。
而在volatile变量写操作之后,会插入一个StoreLoad屏障,其保证了在volatile变量写操作之后的所有读写操作一定会在volatile变量写操作执行完毕之后在执行,也就是:操作2 happens-before 操作3
在每个volatile读操作的后面插入一个LoadLoad屏障,保证了读操作一定会在其后的读操作之前执行,也就是 操作3 happens-before 操作4。
根据happens-before规则的传递性,可以得出,操作1 happens-before 操作4。也就是操作1一定对操作4可见。
3.以线程方法 Thread.start() 建立的happens-before关系为例:假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B。同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会读这些共享变量。
深入Java多线程——Java内存模型深入(2)
文章图片

1 happens-before 2由程序顺序规则产生。2 happens-before 4由start()规则产生。根据传递性,将有1 happens-before 4。这实意味着,线程A在执行ThreadB.start()之前对共享变量所做的修改,接下来在线程B开始执行后都将确保对线程B可见。
7. 双重检查锁定与延迟初始化 在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。
7.1 双重检查锁定的由来
1. 在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。
public class UnsafeLazyInitialization { private static Instance instance; public static Instance getInstance() { if (instance == null) // 1:A线程执行 instance = new Instance(); // 2:B线程执行 return instance; } }

在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化。我们可以对getInstance()方法做同步处理来实现线程安全 的延迟初始化。示例代码如下。
public class SafeLazyInitialization { private static Instance instance; public synchronized static Instance getInstance() { if (instance == null) instance = new Instance(); return instance; } }

由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
为了避免锁带来的巨大开销,双重检查锁定便出现了(Double-Checked Locking),通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
public class DoubleCheckedLocking { // 1 private static Instance instance; // 2 public static Instance getInstance() { // 3 if (instance == null) { // 4:第一次检查 synchronized (DoubleCheckedLocking.class) { // 5:加锁 if (instance == null) // 6:第二次检查 instance = new Instance(); // 7:问题的根源出在这里 } // 8 } // 9 return instance; // 10 } // 11 }

【深入Java多线程——Java内存模型深入(2)】如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。但是,问题会出现在第7行代码处。若一个在另一个线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
对于instance = new Instance()的这行代码的过程为:
(1)分配内存空间
(2)初始化对象
(3)将instance的引用指向开辟的内存地址
但是,对于2,3步,编译器可能会进行重排序,也就是说会先将instance的引用指向刚开辟的内存地址,这个行为就意味着instance = new Instance()这行代码执行完毕,接下来会释放锁(这也就会导致其他线程此时就有可能开始执行,判断instance引用不为空,此时,其他线程就会看到一个未被初始化的对象),然后再初始化对象。
对于2,3步骤的重排序,只要在初次访问对象之前执行完两个步骤,单线程内的执行结果并不会改变,但是多线程时,就有可能造成其他线程看到的是一个未被初始化的对象。
因此,有两个办法来实现线程安全的延迟初始化:
(1)不允许2和3重排序。
(2)允许2和3重排序,但不允许其他线程“看到”这个重排序。
以下的两个解决方法依据这两个原理。
7.2 基于volatile的解决方案
1.对于前面的基于双重检查锁定来实现延迟初始化的方案(指DoubleCheckedLocking示例代 码),只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。请看下面的示例代码:
public class SafeDoubleCheckedLocking { private volatile static Instance instance; public static Instance getInstance() { if (instance == null) { synchronized (SafeDoubleCheckedLocking.class) { if (instance == null) instance = new Instance(); // instance为volatile,现在没问题了 } } return instance; } }

这个解决方案需要JDK 5或更高版本(因为从JDK 5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。
当声明对象的引用为volatile后,instance = new Instance()这行代码的三个步骤中的2和3之间的重排序,在多线程环境中将会被禁止。
7.3 基于类初始化的解决方案
1. JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在 执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
public class InstanceFactory { private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化 } }

假设两个线程并发执行getInstance()方法,下面是执行的示意图
深入Java多线程——Java内存模型深入(2)
文章图片

这个方案的实质是:允许2和3重排序,但不允许非构造线程(这里指线程B)“看到”这个重排序。
2. 初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。
(1)T是一个类,而且一个T类型的实例被创建。
(2)T是一个类,且T中声明的一个静态方法被调用。
(3)T中声明的一个静态字段被赋值。
(4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
(5)T是一个顶级类,而且一个断言语句嵌套在T内部被执行。 在InstanceFactory示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。
3. 对于类或接口的初始化,Java语言规范制定了精巧而复杂的类初始化处理过程。Java初始化一个类或接口的处理过程如下:
(1)通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。假设Class对象当前还没有被初始化(初始化状态state,此时被标记为state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象。如图
深入Java多线程——Java内存模型深入(2)
文章图片

(2)线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。线程A执行初始化后,线程B检查到state已经初始化后,则释放初始化锁。线程A执行初始化的过程中就包括对静态字段的初始化,以及对静态内部类中静态字段的初始化,在对静态字段的对象初始化步骤中的2,3步步骤可以被重排序,但是无法被其他线程看到。
深入Java多线程——Java内存模型深入(2)
文章图片

(3)线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
(4)线程B结束类的初始化处理。
深入Java多线程——Java内存模型深入(2)
文章图片

(5)线程C执行类的初始化的处理。
深入Java多线程——Java内存模型深入(2)
文章图片

在第3阶段之后,类已经完成了初始化。因此线程C在第5阶段的类初始化处理过程相对简单一些(前面的线程A和B的类初始化处理过程都经历了两次锁获取-锁释放,而线程C的类初 始化处理只需要经历一次锁获取-锁释放)。
如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。


















    推荐阅读