物理内存模型
物理机遇到的并发问题域虚拟机中的情况有很多相似之处,物理机对并发的处理方案对于虚拟机具备参考意义。
硬件处理效率
绝大多数的运算任务都不可能只靠处理器“计算”完成,处理器至少需要与内存交互,如读取运算数据、存储运算结果,这类IO操作无法仅依赖寄存器完成;
由于计算机的存储设备与处理器的运算速度存在多个数量级的差距,这种速度上的矛盾,会降低硬件的处理效率。所以现代计算机都不得不加入高速缓存(Cache
)来作为内存与处理器之间的缓冲。将需要的数据复制到缓存中,让运算能快速进行,当运算结束后,再将缓存数据同步到内存中,避免处理器等待内存读写。
缓存一致性
高速缓存解决了硬件效率问题的同时,也引入了新的问题:缓存一致性(Cache Coherence
)。
在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一份主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时根据协议来进行操作。
代码乱序执行
除了高速缓存外,为了是的处理器内部的运算单元尽量被 充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Memory
)优化。处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。
乱序执行技术是处理器为提高运算速度做出违背代码原有顺序的优化。
- 单核环境下,处理器保证做出的优化不会导致执行结果远离预期目标;
- 多核环境下,如果存在一个核心的计算任务依赖另一个核心的计算任务的中间结果,而且对相关数据读写没有做任何保护措施,那么其顺序并不能靠代码的先后顺序来保证。
Java内存模型
内存模型这个概念,可以理解为:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理计算机可以有不一样的内存模型,JVM也有自己的内存模型。
JVM中视图定义一种Java内存模型(Java Momory Model
,简称JMM)来 屏蔽各种硬件和操作系统的内存访问间差异,以实现让Java程序 在各种平台下都能达到一致的内存访问效果。
并发安全需要满足可见性、有序性、原子性。其中,导致可见性的原因是缓存,导致有序性的原因是编译优化。禁用缓存与编译优化可以解决可见性与有序性,但会损失性能;合理的方案应该是按需禁用缓存以及编译优化。Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方案,具体来说,就是volatile
、Synchronized
和final
三个关键字及Happens-Before
规则。
主内存与工作内存
JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处所说的变量(variables
)与Java编程中的变量有所区别,它包括实例字段、静态字段和构成数值对象的元素,但不包含局部变量和方法参数,因为后者是线程私有的,不会被共享,自然也就不存在竞争问题。为了获得较好的执行效率,JMM并没有限制执行引擎使用处理器的特定寄存器或者缓存来与主内存进行交互,也没有限制编译器进行调整代码执行顺序这类优化措施。
JMM规定了所有变量都存储在主内存中。
每个线程都有自己的工作内存,工作内存中保留了该线程使用到的变量的主内存的副本。工作内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器及其他硬件及编译器优化。
线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程间也无法直接方位对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
JMM内存操作问题
工作内存数据一致性:各个线程操作数据时会保存使用到的内存中的共享变量副本,当多个线程的运算任务都涉及到同一个共享变量时,将导致各自的共享变量副本不一致。Java内存模型主要通过一系列的同步协议、规则来保证数据的一致性。
指令重排序优化:Java中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分分为两类:编译器重排序和运行期重排序,分别对应编译时和运行时环境。重排序需满足以下两个条件:
- 单线程环形下不能改变程序运行结果。即编译器需要保证程序能够遵守
as-if-serial
属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象,即经过重排序执行结果与顺序执行结果保持一致。 - 存在数据依赖关系的任务不允许重排序。
内存交互操作
JMM定义了8个操作来完成主内存与工作内存之间的交互操作。JVM实现时必须保证以下操作的原子性。(对于double
和long
型变量来说,某些平台允许load
、store
、read
、write
操作例外 )
lock
:锁定,作用于主内存的变量,它将一个变量表示为一条线程独占的状态。unlock
:解锁,作用于主内存的变量,它将一个锁定状态的变量释放出来供其他线程锁定。read
:读取,作用于主内存变量,它将一个变量的值从主内存传输到线程的工作内存中,以便随后的load
操作使用。write
:写入,作用于主内存的变量,它把store
操作从工作内存中回去的变量值放入主内存的变量中。load
:载入,作用于工作内存的变量,它把read
操作从主内存中获取的变量值放入工作内存的变量副本中。use
:使用,作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。assign
:赋值,作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码执行时执行这个操作。store
:存储,作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,一遍随后的write
操作使用。
JMM还规定上述8种操作,需要满足以下规则:
read
与load
必须成对出现,store
与write
必须成对出现;即不允许将变量从主内存读取了但工作内存不接受,或从工作内存发起回写了单主内存不接受的情况出现。- 不允许一个线程丢弃它的最近
assign
操作;即变量在工作内存中改变了之后必须将变化同步到主内存中。 - 不允许一个线程无原因(未发生任何
assign
操作)把数据从工作内存同步回主内存。 - 一个新的变量只允许在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量。
- 一个变量在同一时刻只允许一个线程对其进行
lock
操作,但lock
操作可以被同一线程重复执行多次,多次lock
后需调用同样次数unlock
变量方可解锁。所以lock
和unlock
必须成对出现。 - 如果对一个变量进行
lock
操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load
或assign
操作初始化变量的值。 - 如果一个变量实现没有被
lock
操作锁定,则不允许对它执行unlock
,也不允许对一个被其他线程lock
的变量进行unlock
。 - 对一个变量执行
unlock
操作之前,必须先把此变量同步到主内存中。
并发安全特性
Java内存交互的8种基本操作遵循Java内存的三大特性:原子性、可见性、有序性。而三大特性,归根结底,是为了实现多线程的数据一致性,使得程序在多线程并发、指令重排序优化的环境中能如预期运行。
原子性
原子性即一个操作或者多个操作,要么全部执行,要么全部不执行。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter
和monitorexit
。这两个字节码,在Java中对应的关键字就是synchronized
。
因此,在Java中可以使用synchronized
来保证方法和代码块内的操作是原子性的。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其它线程能够立即看到修改后的值。
JMM是通过“变量修改后将新值同步回主内存,变量读取前从主内存刷新变量值”这种依赖主内存作为传递媒介的方式来实现的。
Java实现多线程可见性的方式有:
volatile
synchronized
final
有序性
有序性规则变现在以下两种场景:线程内和线程间
- 线程内:从某个线程的角度看方法的执行,指令会按照串行(
as-if_serial
)的方式执行,此种方法已经应用于顺序编程语言。 - 线程间:这个线程“观察”到其他线程并发地执行非同步代码块时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法,同步块以及
volatile
字段的操作仍维持相对有序。
在Java中,可以使用synchronized
和volatile
来保证多线程之间操作的有序性。实现方式有所区别:
volatile
关键字会禁止指令重排序;synchronized
关键字通过互斥保证同一时刻只允许 一条线程操作;
Happens-Before
JMM为程序中所有操作定义了一个偏序关系,称之为先行发生关系(Happens-Before
)。
Happens-Before
是指前面一个操作的结果对后续操作是可见的。
Happens-Before
非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作间是否可能存在冲突的所有问题。
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作限行发生于书写在后面的操作。
- 锁定规则:一个
unlock
操作先行发生于后面对对同一个锁的lock
操作。 volatile
变量规则:对一个volatile
变量的写操作先行发生于后面对这个变量的读操作。- 线程启动规则:
Thread
对象的start()
方法先行发生于此线程的每一个动作。 - 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过
Thread.join()
方法结束,Thread.isAlive()
的返回值手段检测到线程已经终止执行。 - 线程中断规则:对线程
interrupt()
方法的调用线程发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测到是否有中断发生。 - 对象终结规则:一个对象的初始化完成先行发生于它的
finalize()
方法的开始。 - 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
内存屏障
Java可以通过内存屏障(memory barrier
)来保证底层操作的有序性和可见性。
内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序,从而保证有序性。为了达到屏障效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性。
常见有4中屏障:
LoadLoad
屏障:对于这样的语句Load1;LoadLoad;Load2
,在Load2
及后续读取操作要读取的数据被访问前,保证Load1
要读取的数据被读取完毕。StoreStore
屏障:对于这样的语句Store1;StoreStore;Store2
,在Store2
及后续 写入操作执行前,保证Store1
的写入操作对其他处理器可见。LoadStore
屏障:对于这样的语句Load1;LoadStore;Store2
,在Store2
及后续写入操作被执行前,保证Load1
要读取的数据被读取完毕。StoreLoad
屏障:对于这样的语句Store1;StoreLoad;Load2
,在Load2
及后续所有读取操作执行前,保证Store1
的写入对所有处理器可见。它的开销是4中屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具另外3中内存屏障的功能。
Java中对内存屏障的使用在一般的代码中不太容易见到,常见的有volatile
和synchronized
关键字修饰的代码块,也可以通过Unsafe
类来使用内存屏障。
volatile
volatile
是JVM提供的最轻量级的同步机制。
volatile
的中文意思是不稳定的,易变的,用volatile
修饰变量是为了保证变量在多线程中的可见性。
volatile特性
保证变量对所有线程的可见性
- 可见性是指当一条线程修改了
volatile
变量的值,新值对于其他线程来说是可以立即得知的。普通变量的值在线程间传递需要通过主内存来完成。 - 线程写
volatile
变量:- 改变线程工作内存中的
volatile
变量副本的值; - 将改变后的副本值从工作内存刷新到主内存;
- 改变线程工作内存中的
- 线程读
volatile
变量:- 从主内存读取
volatile
变量的最新值到工作内存中; - 从工作内存中读取
volatile
变量的副本;
- 从主内存读取
volatile
保证变量的可见性,并不等同于保证变量的并发安全性;非以下两种情况需通过加锁来保证原子性:- 运算结果不依赖变量的当前值,或者能够确保只有单一线程修改变量的值;
- 变量不需要与其它状态变量共同参与不变约束;
- 可见性是指当一条线程修改了
禁止进行指令重排序;
- 当程序执行到
volatile
变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,其结果对后面的操作可见;且其后面的操作肯定还没有进行; - 进行指令优化时,不能将在对
volatile
变量访问的语句放在其后面执行,也不能把volatile
变量后面的语句放到其前面执行;
普通变量仅仅会保证方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致。
- 当程序执行到
volatile原理
具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个
volatile
写操作的前面插入一个StoreStore
屏障。该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证volatile
写操作之前,任何读写操作都会咸鱼volatile
被提交。 - 在每个
volatile
写操作后面插入一个StoreLoad
屏障。该屏障除了使volatile
写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使volatile
变量的写更新对其他线程可见。 - 在每个
volatile
读操作前面插入一个LoadLoad
屏障。该屏障除了使volatile
读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使volatile
变量读取的为最新值。- 在每个
volatile
读操作的后面插入一个LoadStore
屏障。该屏障除了禁止volatile
读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其它线程volatile
变量的写更新对volatile
读操作的线程可见。
- 在每个
volatile使用场景
概括来说,就是一次写入、到处读取
,某一线程负责更新变量,其它线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新、观察者模式变量值发布。
synchronized
long和double变量的特殊规则
JMM要求8中基本操作都具备原子性,但是对于64位的数据类型(long和double),在模型中特别定义相对宽松的规定:允许虚拟机将没有被volatile
修饰的64位数据的读写操作分为2次32位操作来进行,即允许虚拟机可选择不保证64位数据类型的load
、store
、read
和write
这4个操作的原子性。
实际开发中,JMM强烈建议虚拟机把64位数据的读写实现为具有原子性,目前各种平台下的商用虚拟机都选择把64位数据的读写操作作为原子操作来对待。
final型变量的特殊规则
final
成员变量必须在声明的时候初始化或者在构造器中初始化,否则会报编译错误。
final
关键字的可见性是指:被final
修饰的字段在声明时或在构造器中,一旦初始化完成,那么在其它线程无需同步就能正确看见final
字段的值。这是因为一旦初始化完成,final
变量的值立刻回写到主内存中。