并发编程的目的是为了让程序运行的更快,可是,并非启动更多的线程就能让程序最大限度的并发执行。若是但愿经过多线程执行任务让程序运行的更快,会面临很是多的挑战:
(1)上下文切换
(2)死锁
(3)资源限制(硬件和软件)
即便是单核处理器也支持多线程执行代码,CPU经过给每一个线程分配CPU时间片来实现这个机制。时间片通常只有几十毫秒(ms)。
CPU经过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。可是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,能够再加载这个任务的状态。因此任务从保存到再加载的过程就是一次上下文切换。上下文切换会影响多线程执行的速度。
使用Lmbench3能够测量上下文切换的时长。
使用vmstat能够测量上下文切换的次数。
vmstat 1 :测试一秒钟上下文切换的次数。
CS(Context Switch)表示上下文切换的次数。java
如何减小上下文切换?
(1)无锁并发编程(将数据的ID按照Hash算法取模分段,不一样线程处理不一样段的数据)
(2)CAS算法(Java的Atomic包使用CAS算法来更新数据,而不须要枷锁)
(3)使用最少线程(避免建立不须要的线程,好比任务不多,却建立了不少线程,致使大量线程处于等待状态)
(4)协程(在单线程里实现多任务的调度,并在单线程里维护多个任务间的切换)程序员
实战:减小上下文切换?
经过减小线上大量WAITING的线程,来减小上下文切换次数。
第一步:用jstack命令dump线程信息,看看pid为3117的进程里的线程都在作什么。
/java/bin/jstack 31177 > /home/dump17
第二步:统计全部线程分别处于什么状态,发现300多个线程处于WAITING状态。
grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c
39 RUNNABLE
21 TIMED_WAITING(onobjectmonitor)
6 TIMED_WAITING(parking)
51 TIMED_WAITING(sleeping)
305 WAITING(onobjectmonitor)
3 TIMED_WAITING(parking)
第三步:打开dump文件查看处于WAITING(onobjectmonitor)的线程在作什么。发现这些线程基本全是JBOSS的工做线程,说明JBOSS线程池里线程接收的任务太少,大量线程都闲着。
第四步:减小JBOSS的工做线程数,找到JBOSS线程池配置信息,将maxThreads降到100。
第五步: 重启,发现WAITING减小了175个。算法
一旦出现死锁,业务是可感知的,由于不能继续提供服务了,那么只能经过dump线程查看到底哪一个线程出现了问题。
避免死锁的几个常见方法:
(1)避免一个线程同时获取多个锁
(2)避免一个线程在锁内同时占用多个资源,尽可能保证每一个锁只占用一个资源。
(3)尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
(3)对于数据库锁,加锁和解锁必须在一个数据库链接里,不然会出现解锁失败的状况。数据库
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。
硬件资源限制:带宽,硬盘读写速度,CPU处理速度。
软件资源限制:数据库的链接数和socket链接数。
对于java开发工程师而言,强烈建议多使用JDK并发包提供的并发容器和工具类来解决并发问题,觉得这些类都已经经过了充分的测试和优化,都可解决本章提到的几个挑战。编程
Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
Java代码——>Java字节码——>JVM——>汇编指令——>CPU上执行。数组
Volatile的应用
可见性:当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。
在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的"可见性"。
volatile比synchronized的使用和执行成本更低,觉得它不会引发线程上下文的切换和调度。
volatile定义:
Java语言规范第3版中对volatile的定义以下:Java编程语言容许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保经过排它锁单独得到这个变量。Java语言提供了volatile,在某些状况下比锁更加方便。若是一个字段被声明称volatile,Java线程内存模型确保全部线程看到这个变量的值是一致的。缓存
术语 | 描述 |
---|---|
内存屏障 | 是一组处理器指令,用于实现对内存操做的顺序限制 |
原子操做 | 不可中断的一个或一些列操做 |
缓存行填充 | 当处理器识别到从内存中读取的操做数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或全部) |
缓存命中 | 若是进行高速缓存行填充操做的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操做数,而不是从内存读取 |
写命中 | 当处理器将操做数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,若是存在一个有效的缓存行,则处理器将这个操做数写回到缓存,而不是写回到内存,这个操做被称为写命中。 |
写缺失 | 一个有效的缓存行被写入到不存在的内存区域。 |
volatile是如何来保证可见性的呢?让咱们在X86处理器下经过工具获取JIT编译器生成的
汇编指令来查看对volatile进行写操做时,CPU会作什么事情。
Java代码以下
instance = new Singleton(); // instance是volatile变量
转变成汇编代码,以下
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
有volatile变量修饰的共享变量进行写操做的时候会多出第二行汇编代码,经过查IA-32架
构软件开发者手册可知,Lock前缀的指令在多核处理器下会引起了两件事情。1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操做会使在其余CPU里缓存了该内存地址的数据无效。
为了提升处理速度,处理器不直接和内存进行通讯,而是先将系统内存的数据读到内部
缓存(L1,L2或其余)后再进行操做,但操做完不知道什么时候会写到内存。若是对声明了volatile的变量进行写操做,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。可是,就算写回到内存,若是其余处理器缓存的值仍是旧的,再执行计算操做就会有问题。因此,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每一个处理器经过嗅探在总线上传播的数据来检查本身缓存的值是否是过时了,当
处理器发现本身缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状
态,当处理器对这个数据进行修改操做的时候,会从新从系统内存中把数据读处处理器缓存
里。
下面来具体讲解volatile的两条实现原则
1)Lock前缀指令会引发处理器缓存回写到内存。Lock前缀指令致使在执行指令期间,声
言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器能够
独占任何共享内存。可是,在最近的处理器里,LOCK#信号通常不锁总线,而是锁缓存,毕
竟锁总线开销的比较大。在8.1.4节有详细说明锁定操做对处理器缓存的影响,对于Intel486和
Pentium处理器,在锁操做时,老是在总线上声言LOCK#信号。但在P6和目前的处理器中,若是访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操做被称为“缓存锁
定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
2)一个处理器的缓存回写到内存会致使其余处理器的缓存无效。IA-32处理器和Intel 64处
理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其余处理器缓存的一致
性。在多核处理器系统中进行操做的时候,IA-32和Intel 64处理器能嗅探其余处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其余处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,若是经过嗅探一个处理器来检测其余处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。安全
在多线程并发编程中synchronized一直是元老级角色,不少人都会称呼它为重量级锁。可是,随着Java SE 1.6对synchronized进行了各类优化以后,有些状况下它就并不那么重了。本文详细介绍Java SE 1.6中为了减小得到锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。多线程
先来看下利用synchronized实现同步的基础:Java中的每个对象均可以做为锁。具体表现
为如下3种形式。并发
当一个线程试图访问同步代码块时,它首先必须获得锁,退出或抛出异常时必须释放锁
。
那么锁到底存在哪里呢?锁里面会存储什么信息呢?
从JVM规范中能够看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但二者的实现细节不同。代码块同步是使用monitorenter
和monitorexit
指令实现的,而方法同步是使用另一种方式实现的,细节在JVM规范里并无详细说明。可是,方法的同步一样可使用这两个指令来实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每一个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行monitorenter指令时,将会尝试获取对象所对应的monitor的全部权,即尝试得到对象的锁。
Java对象头
synchronized用的锁是存在Java对象头里的。
锁的升级与对比
Java SE 1.6为了减小得到锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争状况逐渐升级。锁能够升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提升得到锁和释放锁的效率,下文会详细分析。
1.偏向锁
HotSpot 的做者通过研究发现,大多数状况下,锁不只不存在多线程竞争,并且老是由同
一线程屡次得到,为了让线程得到锁的代价更低而引入了偏向锁。当一个线程访问同步块并
获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程在进入和退出
同步块时不须要进行CAS操做来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否
存储着指向当前线程的偏向锁。若是测试成功,表示线程已经得到了锁。若是测试失败,则需
要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):若是没有设置,则使用CAS竞争锁;若是设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
(1)偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,因此当其余线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,须要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,而后检查持有偏向锁的线程是否活着,若是线程不处于活动状态,则将对象头设置成无锁状态;若是线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么从新偏向于其余线程,要么恢复到无锁或者标记对象不适合做为偏向锁,最后唤醒暂停的线程。
(2)偏向锁的撤销
偏向锁在Java 6和Java 7里是默认启用的,可是它在应用程序启动几秒钟以后才激活,若有必要可使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。若是你肯定应用程序里全部的锁一般状况下处于竞争状态,能够经过JVM参数关闭偏向锁:XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
2.轻量级锁
(1)轻量级锁加锁
线程在执行同步块以前,JVM会先在当前线程的栈桢中建立用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。而后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。若是成功,当前线程得到锁,若是失败,表示其余线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操做将Displaced Mark Word替换回到对象头,若是成功,则表示没有竞争发生。若是失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
由于自旋会消耗CPU,为了不无用的自旋(好比得到锁的线程被阻塞住了),一旦锁升级
成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其余线程试图获取锁时,
都会被阻塞住,当持有锁的线程释放锁以后会唤醒这些线程,被唤醒的线程就会进行新一轮
的夺锁之争。
3.锁的优缺点对比
锁 | 优势 | 缺点 | 使用场景 | |
---|---|---|---|---|
偏向锁 | 加锁和解锁不须要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 若是线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 | |
轻量级锁 | 竞争的线程不会阻塞,提升了程序的响应速度 | 若是始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度很是快 | |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 最求吞吐量,同步块执行速度较慢 |
原子操做的实现原理
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操做(atomic operation)意
为“不可被中断的一个或一系列操做”。在多处理器上实现原子操做就变得有点复杂。让咱们
一块儿来聊一聊在Intel处理器和Java里是如何实现原子操做的。
1.术语定义
术语名称 | 英文 | 解释 | |
---|---|---|---|
缓存行 | Cache line | 缓存的最小操做单位 | |
比较并交换 | Compare And Swap | CAS操做须要输入两个数值,一个旧值(指望操做前的值)和一个新值,在操做期间先比较旧值有没有发生变化,若是没有发生变化,才交换成新值,发生了变化则不交换 | |
CPU流水线 | CPU pipeline | CPU流水线的工做方式就像工业生产上的装配流水线,在CPU中由5~6个不一样功能的电路单元组成一条指令处理流水线,而后将一个X86指令分红5~6步后再由这些电路单元分别执行,这样就能实如今一个CPU时钟周期完成一条指令,所以提升CPU的运算速度 | |
内存顺序冲突 | Memory order violation | 内存顺序冲突通常是由假共享引发的,假共享是指多个CPU同时修改同一个缓存行的不一样部分而引发其中一个CPU的操做无效,当出现这个内存顺序冲突时,CPU必须清空流水线 |
2.处理器如何实现原子操做
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操做。
首先处理器会自动保证基本的内存操做的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其余处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操做是原子的,可是复杂的内存操做处理器是不能自动保证其原子性的,好比跨总线宽度、跨多个缓存行和跨页表的访问。可是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操做的原子性。
(1)使用总线锁保证原子性
第一个机制是经过总线锁保证原子性。若是多个处理器同时对共享变量进行读改写操做(i++就是经典的读改写操做),那么共享变量就会被多个处理器同时进行操做,这样读改写操做就不是原子的,操做完以后共享变量的值会和指望的不一致。举个例子,若是i=1,咱们进行两次i++操做,咱们指望的结果是3,可是有可能结果是2。
缘由多是多个处理器同时从各自的缓存中读取变量i,分别进行加1操做,而后分别写入系统内存中。那么,想要保证读改写共享变量的操做是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操做缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其余处理器的请求将被阻塞住,那么该处理器能够独占共享内存。
(2)使用缓存锁保证原子性
第二个机制是经过缓存锁定来保证原子性。在同一时刻,咱们只需保证对某个内存地址的操做是原子性便可,但总线锁定把CPU和内存之间的通讯锁住了,这使得锁按期间,其余处理器不能操做其余内存地址的数据,因此总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
频繁使用的内存会缓存在处理器的L一、L2和L3高速缓存里,那么原子操做就能够直接在处理器内部缓存中进行,并不须要声明总线锁,在Pentium 6和目前的处理器中可使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域若是被缓存在处理器的缓存行中,而且在Lock操做期间被锁定,那么当它执行锁操做回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并容许它的缓存一致性机制来保证操做的原子性,由于缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其余处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如图2-3所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。
可是有两种状况下处理器不会使用缓存锁定。
第一种状况是:当操做的数据不能被缓存在处理器内部,或操做的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
第二种状况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
针对以上两个机制,咱们经过Intel处理器提供了不少Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其余一些操做数和逻辑指令(如ADD、OR)等,被这些指令操做的内存区域就会加锁,致使其余处理器不能同时访问它。
3.Java如何实现原子操做
在Java中能够经过锁和循环CAS的方式来实现原子操做。
(1)使用循环CAS实现原子操做
JVM中的CAS操做正是利用了处理器提供的CMPXCHG
指令实现的。自旋CAS实现的基本思路就是循环进行CAS操做直到成功为止。
(2)CAS实现原子操做的三大问题
在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操做好比LinkedTransferQueue类的Xfer方法。CAS虽然很高效地解决了原子操做,可是CAS仍然存在三
大问题。ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操做。
1)ABA问题。由于CAS须要在操做值的时候,检查值有没有发生变化,若是没有发生变化
则更新,可是若是一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它
的值没有发生变化,可是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面
追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从
Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的做用是首先检查当前引用是否等于预期引用,而且检查当前标志是否等于预期标志,若是所有相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2)循环时间长开销大。自旋CAS若是长时间不成功,会给CPU带来很是大的执行开销。若是JVM能支持处理器提供的pause指令,那么效率会有必定的提高。pause指令有两个做用:第一,它能够延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它能够避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引发CPU流水线被清空(CPU Pipeline Flush)从而提升CPU的执行效率。
3)只能保证一个共享变量的原子操做。当对一个共享变量执行操做时,咱们可使用循环CAS的方式来保证原子操做,可是对多个共享变量操做时,循环CAS就没法保证操做的原子性,这个时候就能够用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操做。好比,有两个共享变量i=2,j=a,合并一下ij=2a,而后用CAS来操做ij。从Java 1.5开始,JDK提供了AtomicReference
类来保证引用对象之间的原子性,就能够把多个变量放在一个对象里来进行CAS操做。
(3)使用锁机制实现原子操做
锁机制保证了只有得到锁的线程才可以操做锁定的内存区域。JVM内部实现了不少种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
Java线程之间的通讯对程序员彻底透明,内存可见性问题很容易困扰Java程序员。
Java内存模型的基础
在并发编程中,须要处理两个关键问题:线程之间如何通讯及线程之间如何同步(这里的线程是指并发执行的活动实体)。通讯是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,经过写-读内存中的公共状态进行隐式通讯。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须经过发送消息来显式进行通讯。
同步是指程序中用于控制不一样线程间操做发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的
。程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。在消息传递的并发模型里,因为消息的发送必须在消息的接收以前,所以同步是隐式进行的。
Java的并发采用的是共享内存模型,Java线程之间的通讯老是隐式进行
,整个通讯过程对程序员彻底透明。若是编写多线程程序的Java程序员不理解隐式进行的线程之间通讯的工做机制,极可能会遇到各类奇怪的内存可见性问题。
Java内存模型的抽象结构
在Java中,全部实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享
(本章用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量
(Local Variables),方法定义参数
(Java语言规范称之为Formal Method Parameters)和异常处理器参数
(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通讯由Java内存模型(本文简称为JMM)控制
,JMM决定一个线程对共享变量的写入什么时候对另外一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每一个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。Java内存模型的抽象示意如图3-1所示。
从图3-1来看,若是线程A与线程B之间要通讯的话,必需要经历下面2个步骤。
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A以前已更新过的共享变量。
下面经过示意图(见图3-2)来讲明这两个步骤。
如图3-2所示,本地内存A和本地内存B由主内存中共享变量x的副本。假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在本身的本地内存A中。当线程A和线程B须要通讯时,线程A首先会把本身本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
从总体来看,这两个步骤实质上是线程A在向线程B发送消息,并且这个通讯过程必需要通过主内存。JMM经过控制主内存与每一个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
从源代码到指令序列的重排序
在执行程序时,为了提升性能,编译器和处理器经常会对指令作重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level
Parallelism,ILP)来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应
机器指令的执行顺序。
3)内存系统的重排序。因为处理器使用缓存和读/写缓冲区,这使得加载和存储操做看上去多是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图3-3所示。
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会致使多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是全部的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,经过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不一样的编译器和不一样的处理器平台之上,经过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
并发编程模型的分类
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类。
StoreLoad Barriers是一个“全能型”的屏障,它同时具备其余3个屏障的效果。现代的多处
理器大多支持该屏障(其余类型的屏障不必定被全部处理器支持)。执行该屏障开销会很昂
贵,由于当前处理器一般要把写缓冲区中的数据所有刷新到内存中(Buffer Fully Flush)。
happens-before简介
JSR-133使用happens-before的概念来阐述操做之间的内存可见性。在JMM中,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必需要存在happens-before关系。这里提到的两个操做既能够是在一个线程以内,也能够是在不一样线程之间。
与程序员密切相关的happens-before规则以下。
程序顺序规则:一个线程中的每一个操做,happens-before于该线程中的任意后续操做。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的
读。
传递性:若是A happens-before B,且B happens-before C,那么A happens-before C。
注意两个操做之间具备happens-before关系,并不意味着前一个操做必需要在后一个操做以前执行!happens-before仅仅要求前一个操做(执行的结果)对后一个操做可见,且前一个操做按顺序排在第二个操做以前(the first is visible to and ordered before the second)。happens-before的定义很微妙,后文会具体说明happens-before为何要这么定义。
happens-before与JMM的关系如图3-5所示。
如图3-5所示,一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来讲,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存
可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行从新排序的一种手段。
数据依赖性
若是两个操做访问同一个变量,且这两个操做中有一个为写操做,此时这两个操做之间就存在数据依赖性。数据依赖分为下列3种类型,如表3-4所示。
名称 | 示例代码 | 说明 |
---|---|---|
写后读 | a=1;b=a; | 写一个变量以后,再读这个 位置 |
写后写 | a=1;a=2; | 写一个变量以后,再写这个变量 |
读后写 | a=b;b=1; | 读一个变量以后,再写这个变量 |
上面3种状况,只要重排序两个操做的执行顺序,程序的执行结果就会被改变。
前面提到过,编译器和处理器可能会对操做作重排序。编译器和处理器在重排序时,会遵照数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操做的执行顺序。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操做,不一样处理器之间和不一样线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义
as-if-serial语义的意思是:无论怎么重排序(编译器和处理器为了提升并行度),(单线程)
程序的执行结果不能被改变。编译器、runtime和处理器都必须遵照as-if-serial语义。
为了遵照as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操做作重排序,由于这种重排序会改变执行结果。可是,若是操做之间不存在数据依赖关系,这些操做就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi r r; // C
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。所以在最终执行的指令序列中,C不能被重排序到A和B的前面。但A和B之间没有数据依赖关系,编译器和处理器能够重排序A和B之间的执行顺序。
程序顺序规则
根据happens-before的程序顺序规则,上面计算圆的面积的示例代码存在3个happens-before关系。
1)A happens-before B。
2)B happens-before C。
3)A happens-before C。
这里A happens-before B,但实际执行时B却能够排在A以前执行(看上面的重排序后的执
行顺序)。若是A happens-before B,JMM并不要求A必定要在B以前执行。JMM仅仅要求前一个操做(执行的结果)对后一个操做可见,且前一个操做按顺序排在第二个操做以前。这里操做A的执行结果不须要对操做B可见;并且重排序操做A和操做B后的执行结果,与操做A和操做B按happens-before顺序执行的结果一致。在这种状况下,JMM会认为这种重排序并不非法(not illegal),JMM容许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽量提升并行度。编译器和处理器听从这一目标,从happens-before的定义咱们能够看出,JMM一样听从这一目标。
重排序对多线程的影响
class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } Public void reader() { if (flag) { // 3 int i = a * a; // 4 …… } } }
flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行
writer()方法,随后B线程接着执行reader()方法。线程B在执行操做4时,可否看到线程A在操做
1对共享变量a的写入呢?
答案是:不必定能看到。
在单线程程序中,对存在控制依赖的操做重排序,不会改变执行结果(这也是as-if-serial语义容许对存在控制依赖的操做作重排序的缘由);但在多线程程序中,对存在控制依赖的操做重排序,可能会改变程序的执行结果。
顺序一致性
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型做为参照。
数据竞争与顺序一致性
当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义以下。
在一个线程中写一个变量,在另外一个线程读同一个变量,并且写和读没有经过同步来排序。
当代码中包含数据竞争时,程序的执行每每产生违反直觉的结果。若是一个多线程程序能正确步,这个程序将是一个没有数据竞争的程序。
JMM对正确同步的多线程程序的内存一致性作了以下保证。
若是程序是正确同步的,程序的执行将具备顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。立刻咱们就会看到,这对于程序员来讲是一个极强的保证。这里的同步是指广义上的同步,包括对经常使用同步原语(synchronized、volatile和final)的正确使用。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。
1)一个线程中的全部操做必须按照程序的顺序来执行。
2)(无论程序是否同步)全部线程都只能看到一个单一的操做执行顺序。在顺序一致性内存模型中,每一个操做都必须原子执行且马上对全部线程可见。
假设有两个线程A和B并发执行。其中A线程有3个操做,它们在程序中的顺序是:A1→A2→A3。B线程也有3个操做,它们在程序中的顺序是:B1→B2→B3。
假设这两个线程使用监视器锁来正确同步:A线程的3个操做执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如图3-11所示。
如今咱们再假设这两个线程没有作同步,下面是这个未同步程序在顺序一致性模型中的执行示意图,如图3-12所示。
未同步程序在顺序一致性模型中虽然总体执行顺序是无序的,但全部线程都只能看到一个一致的总体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1→A1→A2→B2→A3→B3。之因此能获得这个保证是由于顺序一致性内存模型中的每一个操做必须当即对任意线程可见。
可是,在JMM中就没有这个保证。未同步程序在JMM中不但总体的执行顺序是无序的,并且全部线程看到的操做执行顺序也可能不一致。好比,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存以前,这个写操做仅对当前线程可见;从其余线程的角度来观察,会认为这个写操做根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存以后,这个写操做才能对其余线程可见。在这种状况下,当前线程和其余线程看到的操做执行顺序将不一致。
同步程序的顺序一致性效果
class SynchronizedExample { int a = 0; boolean flag = false; public synchronized void writer() { // 获取锁 a = 1; flag = true; } // 释放锁 public synchronized void reader() { // 获取锁 if (flag) { int i = a; …… } // 释放锁 } }
顺序一致性模型中,全部操做彻底按程序的顺序串行执行。而在JMM中,临界区内的代码能够重排序(但JMM不容许临界区内的代码“逸出”到临界区以外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点作一些特别处理,使得线程在这两个时间点具备与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程A在临界区内作了重排序,但因为监视器互斥执行的特性,这里的线程B根本没法“观察”到线程A在临界区内的重排序。这种重排序既提升了执行效率,又没有改变程序的执行结果。
从这里咱们能够看到,JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽量地为编译器和处理器的优化打开方便之门。
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。由于若是想要保证执行结果一致,JMM须要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。
未同步程序在两个模型中的执行特性有以下几个差别。
1)顺序一致性模型保证单线程内的操做会按程序的顺序执行,而JMM不保证单线程内的操做会按程序的顺序执行(好比上面正确同步的多线程程序在临界区内的重排序)
2)顺序一致性模型保证全部线程只能看到一致的操做执行顺序,而JMM不保证全部线程能看到一致的操做执行顺序。
3)JMM不保证对64位的long型和double型变量的写操做具备原子性,而顺序一致性模型保证对全部的内存读/写操做都具备原子性。
第3个差别与处理器总线的工做机制密切相关。在计算机中,数据经过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是经过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。读事务从内存传送数据处处理器,写事务从处理器传送数据到内存,每一个事务会读/写内存中一个或多个物理上连续的字。
在一些32位的处理器上,若是要求对64位数据的写操做具备原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操做具备原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操做拆分为两个32位的写操做来执行。这两个32位的写操做可能会被分配到不一样的总线事务中执行,此时对这个64位变量的写操做将不具备原子性。
注意,在JSR-133以前的旧内存模型中,一个64位long/double型变量的读/写操做能够被拆分为两个32位的读/写操做来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只容许把一个64位long/double型变量的写操做拆分为两个32位的写操做来执行,任意的读操做在JSR-133中都必须具备原子性(即任意读操做必需要在单个读事务中执行)。
volatile的内存语义
当声明共享变量为volatile后,对这个变量的读/写将会很特别。为了揭开volatile的神秘面纱,下面将介绍volatile的内存语义及volatile内存语义的实现。锁的语义决定了临界区代码的执行具备原子性。这意味着,即便是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具备原子性。若是是多个volatile操做或相似于volatile++这种复合操做,这些操做总体上不具备原子性。
简而言之,volatile变量自身具备下列特性。
可见性。对一个volatile变量的读,老是能看到(任意线程)对这个volatile变量最后的写入。
原子性。对任意单个volatile变量的读/写具备原子性,但相似于volatile++这种复合操不具备原子性。
volatile内存语义的实现
重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来讲,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采起保守策略。下面是基于保守策略的JMM内存屏障插入策略。
在每一个volatile写操做的前面插入一个StoreStore屏障。
在每一个volatile写操做的后面插入一个StoreLoad屏障。
在每一个volatile读操做的后面插入一个LoadLoad屏障。
在每一个volatile读操做的后面插入一个LoadStore屏障。
上述内存屏障插入策略很是保守,但它能够保证在任意处理器平台,任意的程序中都能获得正确的volatile内存语义。