在并发编程中,须要解决两个关键问题:java
- 线程之间如何通讯;
- 线程之间如何同步;
线程通讯是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递。程序员
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间经过写-读内存中的公共状态来隐式进行通讯。编程
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须经过明确的发送消息来显式进行通讯。数组
线程同步是指程序用于控制不一样线程之间操做发生相对顺序的机制。缓存
在共享内存的并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。bash
在消息传递的并发模型里,因为消息的发送必须在消息的接收以前,所以同步是隐式进行的。多线程
Java的并发采用的是共享内存模型,Java线程之间的通讯老是隐式进行,整个通讯过程对程序员彻底透明。若是你想设计表现良好的并发程序,理解Java内存模型是很是重要的。Java内存模型规定了如何和什么时候能够看到由其余线程修改事后的共享变量的值,以及在必须时如何同步的访问共享变量。并发
在介绍Java内存模型以前,先来看一下到底什么是计算机内存模型,而后再来看Java内存模型在计算机内存模型的基础上作了哪些事情。要说计算机的内存模型,就要说一下一段古老的历史,看一下为何要有内存模型。app
咱们应该都知道,计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存啦。编程语言
刚开始,还相安无事的,可是随着CPU技术的发展,CPU的执行速度愈来愈快。而因为内存的技术并无太大的变化,因此从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会愈来愈大,这就致使CPU每次操做内存都要耗费不少等待时间。
这就像一家创业公司,刚开始,创始人和员工之间工做关系其乐融融,可是随着创始人的能力和野心愈来愈大,逐渐和员工之间出现了差距,普通员工原来越跟不上CEO的脚步。老板的每个命令,传到到基层员工以后,因为基层员工的理解能力、执行能力的欠缺,就会耗费不少时间。这也就无形中拖慢了整家公司的工做效率。
但是,不能由于内存的读写速度慢,就不发展CPU技术了吧,总不能让内存成为计算机处理的瓶颈吧。
因此,人们想出来了一个好的办法,就是在CPU和内存之间增长高速缓存。缓存的概念你们都知道,就是保存一份数据拷贝。它的特色是速度快,内存小,而且昂贵。
那么,程序的执行过程就变成了:当程序在运行过程当中,会将运算须要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就能够直接从它的高速缓存读取数据和向其中写入数据,当运算结束以后,再将高速缓存中的数据刷新到主存当中。
以后,这家公司开始设立中层管理人员,管理人员直接归CEO领导,领导有什么指示,直接告诉管理人员,而后就能够去作本身的事情了。管理人员负责去协调底层员工的工做。由于管理人员是了解手下的人员以及本身负责的事情的。因此,大多数时候,公司的各类决策,通知等,CEO只要和管理人员之间沟通就够了。
而随着CPU能力的不断提高,一层缓存就慢慢的没法知足要求了,就逐渐的衍生出多级缓存。
按照数据读取顺序和与CPU结合的紧密程度,CPU缓存能够分为一级缓存(L1),二级缓存(L3),部分高端CPU还具备三级缓存(L3),每一级缓存中所储存的所有数据都是下一级缓存的一部分。这三种缓存的 技术难度和制形成本是相对递减的,因此其容量也是相对递增的。
那么,在有了多级缓存以后,程序的执行就变成了:当CPU要读取一个数据时,首先从一级缓存中查找,若是没有找到再从二级缓存中查找,若是仍是没有就从三级缓存或内存中查找。
随着公司愈来愈大,老板要管的事情愈来愈多,公司的管理部门开始改革,开始出现高层,中层,底层等管理者。一级一级之间逐层管理。
单核CPU只含有一套L1,L2,L3缓存。若是CPU含有多个核心,即多核CPU,则每一个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
公司也分不少种,有些公司只有一个大Boss,他一我的说了算。可是有些公司有好比联席总经理、合伙人等机制。
单核CPU就像一家公司只有一个老板,全部命令都来自于他,那么就只须要一套管理班底就够了。
多核CPU就像一家公司是由多个合伙人共同创办的,那么,就须要给每一个合伙人都设立一套供本身直接领导的高层管理人员,多个合伙人共享使用的是公司的底层员工。
还有的公司,不断壮大,开始差分出各个子公司。各个子公司就是多个CPU了,互相以前没有共用的资源。互不影响。
随着计算机能力不断提高,开始支持多线程。那么问题就来了。咱们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。
单线程:CPU核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
单核CPU,多线程:进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不一样线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即便发生线程的切换,缓存仍然不会失效。但因为任什么时候刻只能有一个线程在执行,所以不会出现缓存访问冲突。
多核CPU,多线程:每一个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不一样的核心上执行,则每一个核心都会在各自的caehe中保留一份共享内存的缓冲。因为多核是能够并行的,可能会出现多个线程同时写各自的缓存的状况,而各自的cache之间的数据就有可能不一样。
在CPU和主存之间增长缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每一个核的本身的缓存中,关于同一个数据的缓存内容可能不一致。
若是这家公司的命令都是串行下发的话,那么就没有任何问题。
若是这家公司的命令都是并行下发的话,而且这些命令都是由同一个CEO下发的,这种机制是也没有什么问题。由于他的命令执行者只有一套管理体系。
若是这家公司的命令都是并行下发的话,而且这些命令是由多个合伙人下发的,这就有问题了。由于每一个合伙人只会把命令下达给本身直属的管理人员,而多个管理人员管理的底层员工多是公用的。
好比,合伙人1要辞退员工a,合伙人2要给员工a升职,升职后的话他再被辞退须要多个合伙人开会决议。两个合伙人分别把命令下发给了本身的管理人员。合伙人1命令下达后,管理人员a在辞退了员工后,他就知道这个员工被开除了。而合伙人2的管理人员2这时候在没获得消息以前,还认为员工a是在职的,他就欣然的接收了合伙人给他的升职a的命令。
上面提到在CPU和主存之间增长缓存,在多线程场景下会存在缓存一致性问题。除了这种状况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元可以尽可能的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了如今不少流行的处理器会对代码进行优化乱序处理,不少编程语言的编译器也会有相似的优化,好比:Java虚拟机的即时编译器(JIT)也会作指令重排。
可想而知,若是任由处理器优化和编译器对指令重排的话,就可能致使各类各样的问题。
关于员工组织调整的状况,若是容许人事部在接到多个命令后进行随意拆分乱序执行或者重排的话,那么对于这个员工以及这家公司的影响是很是大的。
前面说的和硬件有关的概念你可能听得有点蒙,还不知道他到底和软件有啥关系。可是关于并发编程的问题你应该有所了解,好比:原子性问题,可见性问题和有序性问题。
其实,原子性问题,可见性问题和有序性问题,是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的 缓存一致性问题、处理器优化问题和指令重排问题 等。缓存一致性问题其实就是可见性问题。而处理器优化是能够致使原子性问题的。指令重排即会致使有序性问题。
原子性 是指在一个操做中就是CPU不能够在中途暂停而后再调度,既不被中断操做,要不执行完成,要不就不执行。
可见性 是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值。
有序性 是指程序执行的顺序按照代码的前后顺序执行。
前面提到的,缓存一致性问题、处理器器优化的指令重排问题是硬件的不断升级致使的。那么,有没有什么机制能够很好的解决上面的这些问题呢?
最简单直接的作法就是废除处理器和处理器的优化技术、废除CPU缓存,让CPU直接和主存交互。可是,这么作虽然能够保证多线程下的并发问题。可是,这就有点因噎废食了。
因此,为了保证并发编程中能够知足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型,定义了共享内存系统中多线程程序读写操做行为的规范。
经过这些规则来规范对内存的读写操做,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了CPU多级缓存、处理器优化、指令重排等致使的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不一样的编程语言,在实现上可能有所不一样。
咱们知道,Java程序是须要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各类硬件和操做系统的访问差别的,保证了Java程序在各类平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型规定了 全部的变量都存储在主内存中,每条线程还有本身的工做内存,线程的工做内存中保存了该线程中用到的变量的主内存副本拷贝,线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存。不一样的线程之间也没法直接访问对方工做内存中的变量,线程间变量的传递均须要本身的工做内存和主存之间进行数据同步进行。
而JMM就做用于工做内存和主存之间数据同步过程。他规定了如何作数据同步以及何时作数据同步。
特别须要注意的是,主内存和工做内存与JVM内存结构中的Java堆、栈、方法区等并非同一个层次的内存划分,没法直接类比。
再来总结下,JMM是一种规范,规范了Java虚拟机与计算机内存是如何协同工做的,目的是解决因为多线程经过共享内存进行通讯时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。
因此,若是你想设计表现良好的并发程序,理解Java内存模型是很是重要的。Java内存模型规定了如何和什么时候能够看到由其余线程修改事后的共享变量的值,以及在必须时如何同步的访问共享变量。
在Java中,全部实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量(Local variables),方法定义参数(formal method parameters)和异常处理器参数(exception handler parameters) 不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通讯由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入什么时候对另外一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
线程之间的共享变量存储在主内存(main memory)中,每一个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其余的硬件和编译器优化。
从上图来看,线程A与线程B之间如要通讯的话,必需要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去;
- 而后,线程B到主内存中去读取线程A以前已更新过的共享变量;
下面经过示意图来讲明这两个步骤:
如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在本身的本地内存A中。当线程A和线程B须要通讯时,线程A首先会把本身本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
从总体来看,这两个步骤实质上是线程A在向线程B发送消息,并且这个通讯过程必需要通过主内存。JMM经过控制主内存与每一个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
在执行程序时为了提升性能,编译器和处理器经常会对指令作重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对应机器指令的执行顺序。
- 内存系统的重排序。因为处理器使用缓存和读/写缓冲区,这使得加载和存储操做看上去多是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序均可能会致使多线程程序出现内存可见性问题。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是全部的编译器重排序都要禁止)。
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,经过内存屏障指令来禁止特定类型的处理器重排序(不是全部的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不一样的编译器和不一样的处理器平台之上,经过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
现代的处理器使用 写缓冲区 来临时保存向内存写入的数据。写缓冲区能够保证指令流水线持续运行,它能够避免因为处理器停顿下来等待向内存写入数据而产生的延迟。同时,经过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的屡次写,能够减小对内存总线的占用。虽然写缓冲区有这么多好处,但每一个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操做的执行顺序产生重要的影响:
处理器对内存的读/写操做的执行顺序,不必定与内存实际发生的读/写操做顺序一致!
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能获得 x = y = 0。具体的缘由以下图所示:
处理器 A 和 B 同时把共享变量写入在写缓冲区中(A一、B1),而后再从内存中读取另外一个共享变量(A二、B2),最后才把本身写缓冲区中保存的脏数据刷新到内存中(A三、B3)。当以这种时序执行时,程序就能够获得 x = y = 0 的结果。
从内存操做实际发生的顺序来看,直处处理器 A 执行 A3 来刷新本身的写缓存区,写操做 A1 才算真正执行了。虽然处理器 A 执行内存操做的顺序为:A1 -> A2,但内存操做实际发生的顺序倒是:A2 -> A1。此时,处理器 A 的内存操做顺序被重排序了。
这里的关键是,因为写缓冲区仅对本身的处理器可见,它会致使处理器执行内存操做的顺序可能会与内存实际的操做执行顺序不一致。因为现代的处理器都会使用写缓冲区,所以现代的处理器都会容许对内存写-读操做重排序。
下面是常见处理器容许的重排序类型的列表:
上表单元格中的“N”表示处理器不容许两个操做重排序,“Y”表示容许重排序。从上表咱们能够看出:常见的处理器都容许Store-Load重排序;常见的处理器都不容许对存在数据依赖的操做作重排序。sparc-TSO和x86拥有相对较强的处理器内存模型,它们仅容许对写-读操做作重排序(由于它们都使用了写缓冲区)。
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
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规则一般对应于多个编译器和处理器重排序规则。对于Java程序员来讲,happens-before规则简单易懂,它避免java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。
若是两个操做访问同一个变量,且这两个操做中有一个为写操做,此时这两个操做之间就存在数据依赖性。数据依赖分下列三种类型:
上面三种状况,只要重排序两个操做的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操做作重排序。编译器和处理器在重排序时,会遵照数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操做的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操做,不一样处理器之间和不一样线程之间的数据依赖性不被编译器和处理器考虑。
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 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器能够重排序 A 和 B 之间的执行顺序。下图是该程序的两种可能执行顺序:
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽量的开发并行度。编译器和处理器听从这一目标,从 happens-before 的定义咱们能够看出,JMM 一样听从这一目标。
了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,好比:volatile
、synchronized
、final
、concurrent
包等。其实这些就是 Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
在开发多线程的代码的时候,咱们能够直接使用 synchronized
等关键字来控制并发,历来就不须要关心底层的编译器优化、缓存一致性等问题。因此,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
在Java中,为了保证原子性,提供了两个高级的字节码指令 monitorenter
和 monitorexit
,这两个字节码,在Java中对应的关键字就是 synchronized
。
所以,在Java中可使用 synchronized
来保证方法和代码块内的操做是原子性的。
Java内存模型是经过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存做为传递媒介的方式来实现的。
Java中的 volatile
关键字提供了一个功能,那就是被其修饰的变量在被修改后能够当即同步到主内存,被其修饰的变量在每次是用以前都从主内存刷新。所以,可使用 volatile
来保证多线程操做时变量的可见性。
除了 volatile
,Java中的 synchronized
和 final
两个关键字也能够实现可见性。只不过实现方式不一样,这里再也不展开了。
在Java中,可使用 synchronized
和 volatile
来保证多线程之间操做的有序性。实现方式有所区别:
volatile关键字会禁止指令重排;
synchronized关键字保证同一时刻只容许一条线程操做;