目录
一、内存可见性问题(并发编程之美)
谈到内存可见性,我们首先来看看在多线程下处理共享变量时 Java 的内存模型,如图所示:
Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自
己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。
Java 内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?
如图所示,是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作
控制器,运算器执行算术逻辑运算。
每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 都共享的二级缓存。
那么 Java 内存模型里面的工作内存,就对应这里的L1或者L2缓存或者CPU的寄存器。
当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行
处理,处理完后将变量值更新到主内存。
那么假如线程A和线程B同时处理一个共享变量,会出现什么情况?我们使用如图 所示 双核CPU 系统架构,假
设线程 A 和线程B使用不同 CPU 执行,并且当前两级Cache 都为空,那么这时候由于 Cache 的存在,将会导
致内存不可见问题,具体看下面的分析。
- 线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中 X 的值,假如为 0。然
后把X=0的值缓存到两级缓存,线程 A 修改 X 的值为 1,然后将其写入两级 Cache,并且刷新到主内存。
线程 A 操作完毕后,线程 A 所在的CPU的两级 Cache 内和主内存里面的X的值都是1。
- 线程 B 获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里
一切都是正常的,因为这时候主内存中也是X=1。然后线程 B 修改X的值为2,并将其存放到线程2所在的一
级 Cache 和共享二级 Cache 中,最后更新主内存中X的值为2;到这里一切都是好的。
- 线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把
X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程 B 写入的值
对线程 A 不可见。那么如何解决共享变量内存不可见问题?使用Java 中的volatile 关键字就可以解决这
个问题。
二、Java内存模型(深入理解JVM第三版)
1. 简介
在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是
计算机的运算速度与 它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数
据库访问上。
如果不希望处理器在大部分时间里都处于等待其他资源的空闲状态,就必须使用一些手段去把处理器 的运算
能力“压榨”出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想 到,也被证
明是非常有效的“压榨”手段。
除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更 具体的并发
应用场景。
衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second,TPS)是重要的指标之一,它
代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力 又有非常密切的关系。对于计算量相
同任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞甚至
死锁,将会大大降低程序的并发能力。
服务端的应用是Java语言最擅长的领域之一,这个领域的应用占了Java应用中最大的一块份额,不过如何写好
并发应用程序却又是服务端程序开发的难点之一,处理好并发方面的问题通常需要 更多的编码经验来支持。
幸好Java语言和虚拟机提供了许多工具,把并发编程的门槛降低了不少。
各种中间件服务器、各类框架也都努力地替程序员隐藏尽可能多的线程并发细节,使得程序员在编码时 能更
关注业务逻辑,而不是花费大部分时间去关注此服务会同时被多少人调用、如何处理数据争用、 协调硬件资
源。
但是无论语言、中间件和框架再如何先进,开发人员都不应期望它们能独立完成所有并发处理的事情,了解并
发的内幕仍然是成为一个高级程序员不可缺少的课程。
2. 硬件的效率与一致性
在了解Java虚拟机并发相关的知识之前,我们先花费一点时间去了解一下物理计算机中的并 发问题。
物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当
大的参考意义。“
让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看 起来理所
当然,实际上它们之间的关系并没有想象中那么简单,其中一个重要的复杂性的来源是绝大 多数的运算任务
都不可能只靠处理器“计算”就能完成。
处理器至少要与内存交互,如读取运算数据、 存储运算结果等,这个I/O操作就是很难消除的
(无法仅靠寄存器来完成所有运算任务)。
由于计算机 的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层
或多层读写速度,尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲,将运算
需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处 理器就
无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来 更高的复杂
度,它引入了一个新的问题:缓存一致性(Cache Coherence)。
在多路处理器系统中,每 个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种
系统称为共享内存多核系统(Shared Memory Multiprocessors System)。
当多个处理器的运算任务都涉及 同一块主内存区域时,将可能导致各自的缓存数据不一致。
如果真的发生这种情况,那同步回到主内 存时该以谁的缓存数据为准呢?
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协 议,在读写时要根据协议来进行操作,
这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
处理器、高速缓存、主内存间的交互关系
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入 代码进行乱
序执行(Out-Of-OrderExecution)优化,处理器会在计算之后将乱序执行的结果重组,保 证该结果与顺序
执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺 序一致,因此如果存在
一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码 的先后顺序来保证。与处理器
的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化。
3. Java内存模型
《Java虚拟机规范》中曾试图定义一种“Java内存模型” (Java Memory Model,JMM)来屏蔽各种
硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效 果。
在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于 不同
平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发 访问却经常
出错,所以在某些场景下必须针对不同的平台来编写程序。
定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让Java的并发内存访 问操
作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬 件的各种特
性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。
经过长时间 的验证和修补,直至JDK 5(实现了JSR-133 )发布后,Java内存模型才终于成熟、完善起
来了。
3.1 主内存与工作内存
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到 内存和
从内存中取出变量值这样的底层细节。
此处的变量(Variables)与Java编程中所说的变量有所区 别,它包括了实例字段、静态字段和构成数组
对象的元素,但是不包括局部变量与方法参数,因为后 者是线程私有的,不会被共享,自然就不会存在竞争
问题。为了获得更好的执行效能,Java内存模 型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主
内存进行交互,也没有限制即时编译器 是否要进行调整代码执行顺序这类优化措施。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,(此处的主内存与介绍物理 硬
件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程 还有自己
的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保 存了被该线程
使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内 存中进行,而不能直接
读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变 量,线程间变量值的传递均需要
通过主内存来完成,线程、主内存、工作内存三者的交互关系如图所示。
线程、主内存、工作内存三者的交互关系
这里所讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一 个层次的对内存的
划分,这两者基本上是没有任何关系的。
如果两者一定要勉强对应起来,那么从变 量、主内存、工作内存的定义来看,主内存主要对应于Java堆
中的对象实例数据部分,而工作内存 则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对
应于物理硬件的内存,而为了 获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能
会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。
3.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从 工作内
存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实 现时必须保证
下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read
和write操作在某些平台上允许有例外
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的 变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的 变量中。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从 工作
内存同步回主内存,就要按顺序执行store和write操作。
注意:
Java内存模型只要求上述两个操作 必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store
与write之间是可插入其他指令 的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、
read b、load b、load a。除此 之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了,但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量 前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就已经能准确
地描述出Java程序中哪些内存访问操作在并发下才是安全的。这种定义相当严谨,但也是 极为烦琐,实践起
来更是无比麻烦。可能部分读者阅读到这里已经对多线程开发产生恐惧感了,后来Java设计团队大概也意识到
了这个问题,将Java内存模型的操作简化为read、write、lock和unlock四 种,但这只是语言描述上的等价化
简,Java内存模型的基础设计并未改变,即使是这四操作种,对于普通用户来说阅读使用起来仍然并不方便。
不过读者对此无须过分担忧,除了进行虚拟机开发的团队 外,大概没有其他开发人员会以这种方式来思考并
发问题,我们只需要理解Java内存模型的定义即可。
3.3 对于volatile型变量的特殊规则
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地 理解,
以至于许多程序员都习惯去避免使用它,遇到需要处理多线程数据竞争问题的时候一律使用synchronized来
进行同步。
了解volatile变量的语义对后面理解多线程操作的其他特性很有意义,在本节 中我们将多花费一些篇幅介
绍volatile到底意味着什么。
Java内存模型为volatile专门定义了一些特殊的访问规则,在介绍这些比较拗口的规则定义之前, 先用一
些不那么正式,但通俗易懂的语言来介绍一下这个关键字的作用。
当一个变量被定义成volatile之后,它将具备两项特性:
第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新
值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要
通过主内存来完成。比如, 线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A
回写完成了之后再对 主内存进行读取操作,新变量值才会对线程B可见。
关于volatile变量的可见性,经常会被开发人员误解,他们会误以为下面的描述是正确的:“volatile变
量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他线程之中。
换句话 说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是线程安全
的”。这句话 的论据部分并没有错,但是由其论据并不能得出“基于volatile变量的运算在并发下是线程安全
的”这样 的结论。
volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线 程的工作内
存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看 不到不一致的情
况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作, 这导致volatile变量的运
算在并发下一样是不安全的,由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们
仍然要通过加锁 (使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程 中所有
依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的 执行顺序一
致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的 所谓“线程内表现
为串行的语义”(Within-Thread As-If-Serial Semantics)。
那为何说它禁止指令重排序呢?
从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令 不按程序规定的顺序分开发送给各个相
应的电路单元进行处理。但并不是说指令任意重排,处理器必 须能正确处理指令依赖情况保障程序能得出正
确的执行结果。
譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和
指令2是有依赖的,它们之间的顺序 不能重排—(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、2
之前或者中间,只要保证 处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。
所以在同一个处理器中,重排序 过的代码看起来依然是有序的。
因此,lock addl$0x0,(%esp)指令把修改同步到内存时,意味着所有之 前的操作都已经执行完成,这
样便形成了“指令重排序无法越过内存屏障”的效果。
解决了volatile的语义问题,再来看看在众多保障并发安全的工具中选用volatile的意义。
它能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile的同步机制的性能确实要优于锁 (使用synchronized关键字或
java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和 优化,使得我们很难确切地说
volatile就会比synchronized快上多少。
如果让volatile自己与自己比较,那 可以确定一个原则:
volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能 会慢上一些,因为它需
要在本地代码中插入许多内存障指令来保证处理器不发生乱序执行。
不过即 便如此,大多数场景下volatile的总开销仍然要比锁来得更低。我们在volatile与锁中选择的唯一
判断依 据仅仅是volatile的语义能否满足使用场景的需求。
再回头来看看Java内存模型中对volatile变量定义的特殊规则的定义。
假定T表示 一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、
store和write操作 时需要满足如下规则:
- 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且, 只有当
线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动
作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现。 这条规则要求在工作
内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其 他线程对变量V所做的修改。
- 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并 且,只有
当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的
assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出 现。 这条规则
要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以 看到自己对变量V
所做的修改。
- 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动 作,
假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W实施的
use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的 对变量
W的read或write动作。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序
优化,从而保证代码的执行顺序与程序的顺序 相同。
3.4 针对long和double型变量的特殊规则
Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子
性, 但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:
允许虚拟机将没有 被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机
实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的
“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。
如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取 和
修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变 量”的数
值。不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试,在目前主流平台下商 用的64位Java
虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位x86平台下的
HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。
从JDK 9起,HotSpot增加了一个实验性的参数-XX:+AlwaysAtomicAccesses(这是JEP 188对Java内
存模型更新的 一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。而针对double类型,由于现代
中央处 理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处
理 单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也 证实
了这一点。
笔者的看法是,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写 代码时一般不需要因
为这个原因刻意把用到的long和double变量专门声明为volatile。
3.5 原子性、可见性与有序性
介绍完Java内存模型的相关操作和规则后,我们再整体回顾一下这个模型的特征。
Java内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个
来看一下哪些 操作实现了这三个特性。
原子性(Atomicity)
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,
我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性 协
定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。
如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操
作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放
给用户使用,但是却提供了更 高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操
作。
这两个字节码指令反映到Java代码中就是同步块—synchronized关键字,因此在synchronized块之间的操作
也具备原子性。
可见性(Visibility)
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。上文在讲解volatile
变量的时候我们已详细讨论过这一点。
Java内存模型是通过在变量修改后将新值同步回主内 存,在变量读取前从主内存刷新变量值这种依赖主
内存作为传递媒介的方式来实现可见性的,无论是 普通变量还是volatile变量都是如此。普通变量与volatile
变量的区别是,volatile的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。
因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。 除了
volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见 性是由“对
一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操 作)”这条规则获得
的。
而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完 成,并且构造器没有把
“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通 过这个引用访问到“初始化
了一半”的对象),那么在其他线程中就能看见final字段的值。
如代码清单所示,变量i与j都具备可见性,它们无须同步就能被其他线程正确访问。
final与可见性
public static final int i;
public final int j;
static {
i = 0;
// 省略后续动作
}
{
// 也可以选择在构造函数中初始化
j = 0;
// 省略后续动作
}
有序性(Ordering)
Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以 总结
为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作
都是无序的。
前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指
“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本 身
就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对 其进行
lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
介绍完并发中三种重要的特性,读者是否发现synchronized关键字在需要这三种特性的时候都可以 作为
其中一种的解决方案?
看起来很“万能”吧?的确,绝大部分并发控制操作都能使用synchronized来 完成。synchronized的
“万能”也间接造就了它被程序员滥用的局面,越“万能”的并发控制,通常会伴随 着越大的性能影响,关
于这一点,我们将在下一章讲解虚拟机锁优化时再细谈。
3.6 先行发生原则
如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变 得
非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一 个“先行发
生”(Happens-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是 否安全的非常有
用的手段。
依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所
有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。 现在就来看看“先行发生”原则指的是什么。
先行发生是Java内存模型中定义的两项操作之间的偏 序关系,比如说操作A先行发生于操作B,其实就是
说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了
消息、调用了方法等。
EE这句话不难理解,但 它意味着什么呢?我们可以举个例子来说明一下。如代码清单所示的这三条伪代
码先行发生原则示例1
/ 以下操作在线程A中执行
i = 1;
// 以下操作在线程B中执行
j = i;
// 以下操作在线程C中执行
i = 2;
假设线程A中的操作“i=1”先行发生于线程B的操作“j=i”,那我们就可以确定在线程B的操作执行
后,变量j的值一定是等于1,得出这个结论的依据有两个:
一是根据先行发生原则,“i=1”的结果可以 被观察到;
二是线程C还没登场,线程A操作结束之后没有其他线程会修改变量i的值。
现在再来考虑 线程C,我们依然保持线程A和B之间的先行发生关系,而C出现在线程A和B的操作之间,
但是C与B没 有先行发生关系,那j的值会是多少呢?答案是不确定!
1和2都有可能,因为线程C对变量i的影响可能 会被线程B观察到,也可能不会,这时候线程B就存在读取
到过期数据的风险,不具备多线程安全性。
下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已 经存
在,可以在编码中直接使用。
如果两个操作之间的关系不在此列,并且无法从下列规则推导出 来,则它们就没有顺序性保障,虚拟机可
以对它们随意地进行重排序。
程序次序规则(Program Order Rule)
在一个线程内,按照控制流顺序,书写在前面的操作先行 发生于书写在后面的操作。
注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循 环等结构。
管程锁定规则(Monitor Lock Rule)
一个unlock操作先行发生于后面对同一个锁的lock操作。这 里必须强调的是“同一个锁”,而“后面”
是指时间上的先后。
volatile变量规则(Volatile Variable Rule)
对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先
后。
线程启动规则(Thread Start Rule)
Thread对象的start()方法先行发生于此线程的每一个动作。
线程终止规则(Thread Termination Rule)
线程中的所有操作都先行发生于对此线程的终止检 测,我们可以通过Thread::join()方法是否结束、
Thread::isAlive()的返回值等手段检测线程是否已经终止 执行。
线程中断规则(Thread Interruption Rule)
对线程interrupt()方法的调用先行发生于被中断线程 的代码检测到中断事件的发生,可以通过
Thread::interrupted()方法检测到是否有中断发生。
对象终结规则(Finalizer Rule)
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
传递性(Transitivity)如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行
发生于操作C的结论。
Java语言无须任何同步手段保障就能成立的先行发生规则有且只有上面这些,
下面演示一下如何 使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全。
读 者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。
先行发生原则示例2
private int value = 0;
pubilc void setValue(int value){
this.value = value;
}
public int getValue(){
return value;
}
代码清单中显示的是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时 间上的先
后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?
我们依次分析一下先行发生原则中的各项规则。
由于两个方法分别由线程A和B调用,不在一个线 程中,所以程序次序规则在这里不适用;
由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;
由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;
后面的线 程启动、终止、中断规则和对象终结规则也和这里完全没有关系。
因为没有一个适用的先行发生规 则,所以最后一条传递性也无从谈起,
因此我们可以判定,尽管线程A在操作时间上先于线程B,但是 无法确定线程B中getValue()方法的返回
结果,换句话说,这里面的操作不是线程安全的。
那怎么修复这个问题呢?
我们至少有两种比较简单的方案可以选择:
要么把getter/setter方法都定 义为synchronized方法,这样就可以套用管程锁定规则;
要么把value定义为volatile变量,由于setter方 法对value的修改不依赖value的原值,满足volatile关键
字使用场景,
这样就可以套用volatile变量规则来 实现先行发生关系。
通过上面的例子,我们可以得出结论:
一个操作“时间上的先发生”不代表这个操作会是“先行发 生”。
那如果一个操作“先行发生”,是否就能推导出这个操作必定是“时间上的先发生”呢?</
很遗憾,这 个推论也是不成立的。
一个典型的例子就是多次提到的“指令重排序”,演示例子如代码清单所示
先行发生原则示例3
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
代码清单所示的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行 发生于
“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,
因为我们在这条线程之中没有办法感知到这一点。
上面两个例子综合起来证明了一个结论:
时间先后顺序与先行发生原则之间基本没有因果关系, 所以我们衡量并发安全问题的时候不要受时间顺
序的干扰,一切必须以先行发生原则为准。
标签:Java,变量,ThreadLocal,线程,内存,操作,volatile,多线程 From: https://blog.csdn.net/qq_51226710/article/details/141807545