- 16.1 什么是内存模型,为什么需要它
- 16.2 发布
- UnsafeLazyInitialization">16.2.1 不安全的发布 - UnsafeLazyInitialization
- 16.2.2 安全的发布
- safeLazyInitialization,EagerInitialization,ResourceFactory">16.2.3 安全初始化模式 - safeLazyInitialization,EagerInitialization,ResourceFactory
- DoubleCheckedLocking">16.2.4 双重检查加锁 - DoubleCheckedLocking
- 16.3 初始化过程中的安全性
- 小节
这一章可以配合敖丙的Java内存模型一起食用 - 这篇文章是讲volatile的,提到了Java内存模型。
https://zhuanlan.zhihu.com/p/137193948
16.1 什么是内存模型,为什么需要它
假设一个线程为变量aVariable赋值: aVariable = 3; 内存模型需要解决这个问题:“在什么条件下,读取aVariable的线程将看到这个值为3 ?”这昕起来似乎是一个愚蠢的问题,但如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;处理器可以采用乱序或并行等方式来执行指令;缓存可能会改变将写入变量提交到主内存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得-一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行一 如果没有使用正确的同步。
在多线程环境中,维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步操作来找出这些协调操作将在何时发生。
16.1.1 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同-一个存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至包括应用程序)需要弥合这种在硬件能力与线程安全需求之间的差异。
性能的提升。在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java 还提供了自己的内存模型,并且JVM通过在适当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。
16.1.2 重排序 - PossibleReordering
在第2章中介绍竞态条件和原子性故障时,我们使用了交互图来说明:在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。更糟的是,JMM还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。
16.1.3 Java内存模型简介
Java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。
Happens-Before的规则包括:
- 程序顺序规则。如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
- 监视器锁规则。在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。显式锁和内置锁在加锁和解锁等操作上有着相同的内存语义。
- volatile变量规则。对volatile变量的写入操作必须在对该变量的读操作之前执行。原子变量与volatile变量在读操作和写操作上有着相同的语义。
- 线程启动规则。在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行。
- 线程结束规则。线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false。.
- 中断规则。当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。
- 终结器规则。对象的构造函数必须在启动该对象的终结器之前执行完成。
- 传递性。如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
图16-2 在Java内存模型中说明Happens-Before关系
16.1.4 借助同步
:::info
书中程序清单16-2中FutureTask的内部类并没有找到,可能是使用了新的实现,使用了UNSAFE类实现比较并交换。
ok,刚刚翻找了Jdk1.5的源码,发现JDK5和JDK8的实现果然不同,JDK5中选择继承AQS实现自己的CAS。而JDK8使用了Unsafe的CAS。
:::
:::info
java.util.concurrent.FutureTask.UNSAFE
:::
16.2 发布
16.2.1 不安全的发布 - UnsafeLazyInitialization
除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。
16.2.2 安全的发布
16.2.3 安全初始化模式 - safeLazyInitialization,EagerInitialization,ResourceFactory
16.2.4 双重检查加锁 - DoubleCheckedLocking
16.3 初始化过程中的安全性
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写人操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写人操作,将不会与构造过程后的操作一起被重排序。
初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性,对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须未用同步来确保可见性。
—个人对final域可达的值的理解,拿final Map map来举例,final的可达性是指map本身的引用,而对于通过Map.put进去的值(也就是map中的元素),final是不可达的,也就是说在没有安全性保障的情况下,map中的元素是不安全的。
像 final int i = 3; 那么对于i来说,final是可达的,不需要同步确保可见性。
小节
Java内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种Happens-Before的偏序关系进行排序,而这种关系是基于内存操作和同步操作等级别来定义的。如果缺少充足的同步,那么当线程访向共享数据时,会发生一些非常奇怪的问题。然而,如果使用第2章与第3章介绍的更高级规则,例如@GuardedBy和安全发布,那么即使不考虑Happens-Before的底层细节,也能确保线程安全性。