Java并发编程_Java内存模型(2)

final域的内存语义

与前面介绍的锁和volatile相比,对final域的读和写更像是普通的变量访问。

final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则。
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用 变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能 重排序。
在这里插入图片描述

写final域的重排序规则

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

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

读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final 域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final 域操作的前面插入一个LoadLoad屏障。
在这里插入图片描述
而读final域的重排序规则会把读对象 final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了。

final域为引用类型

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

为什么final引用不能从构造函数内“溢出”

写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该 引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果, 还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对 象引用不能在构造函数中“逸出”。
在这里插入图片描述
在这里插入图片描述
从图3-32可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此 时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初 始化之后的值。

happens-before

JMM的设计

JMM把happens-before 要求禁止的重排序分为了下面两类。
会改变程序执行结果的重排序。
不会改变程序执行结果的重排序。
JMM对这两种不同性质的重排序,采取了不同的策略,如下:
对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种 重排序)。

JMM其实是在遵 循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序), 编译器和处理器怎么优化都行。
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
as-if-serial语义和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()操作成功返回。

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

双重检查锁定的由来

在这里插入图片描述
B线程执行代码2。此时,线 程A可能会看到instance引用的对象还没有完成初始化。
在这里插入图片描述
由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方 法被多个线程频繁的调用,将会导致程序执行性能的下降。
在这里插入图片描述
如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始 化操作。因此,可以大幅降低synchronized带来的性能开销。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行,代码读 取到instance不为null时,instance引用的对象有可能还没有完成初始化。

问题的根源

前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一 行代码可以分解为如下的3行伪代码。
在这里插入图片描述
上面3行伪代码的2和3之间虽然被重排序了,但这个重排序 并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以 提高程序的执行性能。
在这里插入图片描述
在这里插入图片描述
当线程A和B按图3-38的时序执行时,B线程将看到一个还没有被初始化的对象。

在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
1)不允许2和3重排序。
2)允许2和3重排序,但不允许其他线程“看到”这个重排序。

基于volatile的解决方案

在这里插入图片描述
当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程 环境中将会被禁止。
在这里插入图片描述

基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在 执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
在这里插入图片描述
在这里插入图片描述
把类初始化的处理过程 分为了5个阶段:
第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始 化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
在这里插入图片描述
第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
在这里插入图片描述
第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
第4阶段:线程B结束类的初始化处理。
在这里插入图片描述
第5阶段:线程C执行类的初始化的处理。
在这里插入图片描述

Guess you like

Origin blog.csdn.net/qq_42148307/article/details/121314544