计算机为什需要并发?
计算机的运行速度与它的存储和通信系统速度的差距太大,大量的时间都花费在IO,网络通信或者数据库访问上。如果不希望处理器大部分时间都处于等待其他资源的状态,就必须使用一些手段去压榨处理器的运算能力。而最最容易想到的,也是非常有效的手段就是计算机同时处理多个任务。
除了要充分利用处理器的计算资源以外,我们也确实有这样的具体的并发场景。比如:一个服务端同时对多个客户端提供服务。
在并发编程中,需要解决两个关键问题:1线程之间是如何通信,2线程间如何同步。在命令式编程中,线程间的通信机制有两种:a共享内存,b消息传递。
如何通信
a共享内存模型,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
b消息传递模型,线程之间没有公共状态,线程之间必须通过发送消息来显示的通信。
如何同步(同步--程序中用于控制不同线程间操作发生的相对顺序的机制)
a共享内存模型,同步是显式进行的,程序员要显示的指出某个方法或代码段要在线程之间互斥执行。
b消息传递模型,由于消息的发送必须在消息的接收之前,因此同步时隐式进行的。
而Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式的进行,整个通信过程对程序员完全透明。
为什么需要了解java内存模型?
服务端是Java语言最擅长的领域之一,并且占很大一部分市场份额。虽然Java语言和虚拟机提供了许多工具将并发编程的门坎降低,并且各种中间件服务器,各类框架都努力的替程序员处理尽可能多的线程并发细节,但是无论语言,中间件和框架如何先进,我们都不能期望它们能独立完成所有并发处理的事。了解并发的内幕也是成为一个高级程序员不可缺少的课程。
解决一个硬件矛盾增加一个软件矛盾
我们知道绝大多数的运算任务都不可能只靠处理器“计算”就能完成的,处理器至少要与内存交互,如读取运算数据,存储运算结果等,这个IO操作是很难消除的(无法仅靠寄存器来完成所有的运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都必须加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。这个硬件问题就解决了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性(cache coherence)。这个软件矛盾就产生了,在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主内存(main memory)。它们看起来像这样:
当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议(多个),在读写时都要根据协议来进行操作。我们将特定的操作协议下,特定内存或高速缓存进行读或写的访问过程进行抽象——称之为“内存模型”。
Java内存模型
由于各种硬件和操作系统的内存访问存在差异,为了实现Java程序在各种平台下都能达到一致的内存访问效果。所以需要Java虚拟机定义一种统一的内存模型——Java内存模型(Java Memory Model,JMM)。
Java内存模型的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。
值得注意的是,这里的变量与Java编程中的所说的变量有所区别,它包括实例字段,静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为这两个是线程私有的,不会被共享,也就不存在竞争的问题。
Java内存模型规定了所有的变量都存储在主内存(main memory)中——此主内存只是名字跟物理内存一样,可互相类比,但此处仅仅是虚拟机内存的一部分。每条线程还有自己的工作内存(work memory)——可与物理机中的高速缓存类比。线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同的线程之间也无法直接访问对方工作内存忠的变量,线程间变量值的传递均需要通过主内存来完成。线程,主内存,工作内存三者之间的关系如下图:
这里需要说明的是:这里所说的线程,主内存,工作内存和Java内存区域中的Java堆,栈,方法区不是同一个层次的划分,这两者基本上是没有关系的。如果非要勉强的去对应起来:
主内存--->Java堆中的实例数据
工作内存--->虚拟机栈中的部分区域
从更底层来说:
主内存--->物理内存
工作内存--->寄存器或高速缓存或物理内存
注:虚拟机优化后,会尽可能的将工作内存去对应寄存器或高速缓存而不是物理内存,这是为了获得更好的运行速度。
内存间交互操作
现在的问题是浅紫色部分是如何工作的,即一个变量如何从main memory拷贝到working memory,working memory如何同步回main memory。
Java内存模型中定义了8中操作来完成这种原子操作。
lock(锁定):
unlock(解锁):
read(读取):
load(载入):
use(使用):
assign(赋值):
store(存储):
write(写入):
8种操作也不是没有规则的乱用。具体说来:
read和load操作是顺序执行
store和write操作是顺序执行
虽然是顺序执行,但是不保证连续执行,也就是说read与load之间,store与write之间是可插入其他指令的,如对主内存的变量a,b进行访问时,一种可能出现的顺序是read a,read b,load b,load a。
不允许read/load,store/write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load,assign)的变量,换句话说,就是对一个变量实施use,strore操作之前,必须先执行过了assign和load操作。
一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次lock后,只有执行相同次数的unlock操作,变量才会被解锁。
如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
对一个变量执行unlock操作之前,必须把此变量同步回主内存中(执行store,write操作)。
这8种内存访问操作以及这些规定,再加上对volatile的一些特殊规则,就可以解决缓存一致性问题,并完全确定了Java程序中哪些内存访问操作在并发情况下是安全的。
volatile型变量的特殊规则
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但不容易被完整并正确的理解。所以很多程序员遇到多线程竞争问题直接采用sychronized进行同步。
volatile具有两种特性:
1保证此变量对所有线程的可见性。这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。(普通变量的值在线程间传递均需要通过主内存来完成)由于Java里面的运算并非原子操作,导致volatile变量的运算在并发下并不是安全的。
2禁止指令重排优化。普通的变量仅仅会保证在该方法的执行过程中所依赖赋值结果的地方都能获取到正确的结果,而不保证变量赋值操作的顺序与程序代码中的顺序一致。为什么说volatial可以禁止指令重排呢?因为给这个被volatial修饰的变量X赋值后,多执行了一个操作(+0),这个操作并没有改变值得大小,但是将值立即同步回主内存(store和write操作),其他的线程的工作内存的值失效了,要重新从主内存中读取。这就相当于X被赋值之前的所有操作的顺序(代码的顺序)已经确定了,并且所有的变化已经更新到主存了。换句话说就相当于有一个内存屏障。屏障之前的的操作已经确定了,屏障之后的操作再也不能排在屏障之前了。这便形成了“指令重排序无法越过内存屏障”的效果。
借机会聊一聊指令重排
在执行程序程序时,为了提高性能,编译器和处理器常常会对指令做排序。
重排序分3种类型:
1编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可以是在乱序执行。
所以源代码会经历以下的过程:
源码-->1编译器优化的重排序--> 2指令级并行的重排序-->3内存系统的重排序-->最终执行指令序列
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。
对于处理器,JMM的处理器重排序会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供了一致的内存可见性保证。
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类。
内存屏障类型:
LoadLoad Barriers 前面数据比后面数据先加载
StroreStore Barriers 前面数据比后面数据先存储
LoadStore Barriers 前面数据加载先于后面数据存储
StoreLoad Barriers 前面数据存储先于后面数据加载
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代多处理器大多支持该屏障。执行该屏障的开销很昂贵,因为当前处理器通常要把写缓冲区(chache或working memory)中的数据全部刷新到内存中。
什么时候使用volatile呢?
volatile比锁(synchronized或其他的锁)快?某些情况下volatile的同步机制性能确实要优于锁(synchronized或java.util.concurrent包里面的锁)。但是虚拟机会对锁实行许多的消除和优化,所以我们很难量化的认为volatile就会比synchronized快多少。
volatile变量与普通变量的比较呢?读操作的性能几乎无异,写操作可能会慢一点点(多了一个插入内存屏障指令——+0操作,来保证处理器不发生乱序)
大多数场景下volatile的总开销仍然要比锁低,但是我们在volatile与锁之间的选择的唯一标准是volatile的语意是否满足使用场景的需求。
volatile变量定义的特殊规则
我们这里先不使用枯燥的文字说明,仅仅从一张图开始:
规则来了,黑体部分就是你需要关注的:
现T表示一个线程,V,W表示两个被volatile修饰的两个变量
Aa分别表示线程T对变量V实施的use和assing动作
Ff分别表示动作A相关联的load操作和动作a相关联的f动作
Pp分别表示动作F相关连的read动作和动作f相关联的write动作
Bb分别表示线程T对变量W实施的use和assing动作
Gg分别表示动作A相关联的load操作和动作a相关联的f动作
Qq分别表示动作F相关连的read动作和动作f相关联的write动作
规则1:use和load操作必须是关联的,(实线箭头中间不能插入其他的操作,保证V被使用前必须从主内存刷新最新值)
规则2:assign和store操作必须是关联的,(实线箭头中间不能插入其他的操作,保证每次修改V后都必须立即同步到主内存)
规则3:
如果A动作先于B动作,则P动作先于Q动作,如果a动作先于b动作,则p动作先于q动作。(这条规则要求volatile修饰的volatile修饰的变量不会被指令重排优化,保证代码的执行顺序与程序的顺序相同)
原子性,可见性与有序性
回顾Java内存模型,我们发现该模型围绕着并发过程中如何处理原子性,可见性和有序性这3个特征来建立的。
原子性(Atomicty):由Java内存模型保证的原子性操作包括read,load,assign,use,store,write以及基本数据类型的访问读写。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这个场景,尽管虚拟机并直接未开发这两个操作给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具有原子性。
可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他的线程能够立即得知这个修改。volatile,synchronized,final都能实现可见性。
volatile:新值立即同步到主存,其他线程使用前必须从主存刷新,实现可见性。
synchronized:同步块是从“对一个变量的unlock操作之前,必须先把此变量同步回主存中(store,write操作)”这条规则获得可见性的。
final:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那其他线程就能看到final字段的值。
有序性(Ordering):如果本程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“指令内表现为串行语义(as-if-serial语义)”,后半句表示“指令重排”现象和工“作内存与主内存同步延时”现象。
Java语言提供了volatile和synchronized两关键字来保证线程之间操作的有序性。volatile本身包含了禁止指令重排序的语义,而synchronized则是由“一个变量 在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步快只能串行的进入。
介绍完了并发的3个重要特性,我们发现synchronized可以满足这三个特性。所以synchronized的“万能”也间接造就了它被程序员滥用的局面。
先行发生原则(happens-before)
如果Java内存模型中所有的有序性都仅仅靠volatile和synchronized来完成,那么有一些操作将会变得很繁琐,但是我们在编写Java并发代码的时候并没有感觉很繁琐,应因为Java语言中有一个先行发生(happens-before)的原则在支撑。它是判断数据是否存在竞争,线程是否安全的主要依据,不满足这个原则的两个操作之间很有可能存在并发冲突(错误)。
下面是Java内存模型下的“天然的”先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从以下规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
程序次序规则:一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。更准确的说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支,循环。
管程锁定规则:一个unlock操作先行发生于后面的同一个锁的lock操作,后面是指的时间上的先后。
volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,后面是指的时间上的先后。
线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
线程终止规则:线程中的所有操作都先行发生于对线程的终止检测。
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法。
传递性:操作A先行发生于操作B,操作B先行发生于操作C,那操作A先行发生于操作C。
注释:加粗的规则是与程序员密切相关的。两个操之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行! happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
happens-before与JMM的关系
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免了Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
结论:时间先后顺序与线性发生原则之间基本没有太大的关系,所以我们衡量并发安全 问题时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
至此,所有跟Java内存模型有关的东西都说明清楚了。本文基本上是深入理解Java虚拟机的Java内存模型章节的浓缩。通过配上个自己理解消化后的手工绘图以尽可能简洁的方式来说明Java内存模型是个什么东西,希望对读者有一定的帮助。