背景
并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展的源动力的根本原因,也是人类压榨计算机运算能力最有力的武器
- Amdahl 定律通过系统中的并行化与串行化的比重来描述多处理器系统能获得的运算加速能力。
- 摩尔定律则用于描述处理器晶体管数量与运行效率之间的发展关系。
- 这两个定律的更替代表了近年来硬件发展从追求处理器频率到追求多核心并行处理的发展过程。
高效并发
物理机上的并发解决方案
在当前这个多核处理器时代,”让计算机并发执行若干个运算任务”和”更充分地利用计算机处理器的效能”是每个计算机追求的目标。那么在当前的计算机系统里是怎么解决的呢?
大家知道在计算机系统中,所有的运算任务都不可能只靠处理器”计算”就能完成,至少与内存的交互是很难消除的,如读取运算数据、存储运算结果等。一般来说,处理器进行”计算”,存储设备(内存等)进行数据存储。运算过程是,处理器从存储设备读取数据进行处理,然后将处理结果存储在存储器里。
那么这么处理的问题在什么地方呢?
一般来说,处理器的运算速度和存储设备的运算速度存在几个数量级的差距
那么怎么解决呢?
对于现代计算机系统的解决方案:在内存和处理器之间加一层读写速度接近处理器运算速度的告诉缓存(Cache)来作为缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存中,这样就避免了处理器等待缓慢的内存进行读写,从而提高效率。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是引入了新的问题:缓存一致性
缓存一致性: 如果是单核系统的话,那么就不存在缓存一致性的问题,但是在多核处理器系统中,每个处理器都有自己对应的高速缓存,而对于所有的处理器来说都共享同一主内存(Main Memory).当多个处理器的运算任务都涉及同一块主内存区域时,即有可能导致各自的缓存数据不一致的情况,那么当同步回主内存的时候,以那个处理器的数据为准呢?
为了解决一致性的问题,需要每个处理器访问缓存时都需要遵循一些协议,在读写的时候根据协议来进行操作。
为了使处理器内部的运算单元尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行结果进行重组,保证该结果与顺序执行结果是一致的。但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不靠代码的先后顺序来保证。
Java内存模型
了解了物理机如何处理并发的基础后,我们来看看在Java里如何处理。
Java虚拟机规范中试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致性的并发效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件(或者说操作系统的内存模型),因此,会由于不同平台上内存模型的差异,导致程序在一套平台上并发完全正常,而在另一套平台上并发访问却经常出错。这就是Java语言号称跨平台一致性的原因之一。
JVM运行时数据区
从图中看到,JVM内存分为两个主要区域,一个是所有线程共享的数据区,一个是线程隔离数据区(线程私有)
线程隔离数据区
程序计数器(Program Counter Register):一小块内存空间,单前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
JVM虚拟机栈(Java Virtual Machine Stacks):Java方法执行内存模型,用于存储局部变量,操作数栈,动态链接,方法出口等信息。是线程私有的。
本地方法栈(Native Method Stacks):为JVM用到的Native方法服务,Sun HotSpot 虚拟机把本地方法栈和JVM虚拟机栈合二为一。是线程私有的。
线程共享的数据区
方法区(Method Area):用于存储JVM加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。
运行时常量池(Runtime Constant Pool):是方法区的一部分,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法取得运行时常量池中。具备动态性,用的比较多的就是String类的intern()方法。
JVM堆( Java Virtual Machine Heap):存放所有对象实例的地方。
新生代,由Eden Space 和大小相同的两块Survivor组成
旧生代,存放经过多次垃圾回收仍然存活的对象
直接内存(Direct Memory):它并不是虚拟机运行时数据区的一部分,也不是JAVA虚拟机规范中定义的内存区域。在JDK1.4中加入了NIO类,引入了一种基于通道(Channel)于缓冲区(Buffer)的I/O方式,他可以使用Native函数库直接分配堆外内存,然后通过一个存储在JAVA堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在JAVA堆中和Native堆中来回复制数据。
JMM
主内存和工作内存
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处主内存与物理硬件内存可以相互类比),每条线程还有自己的工作内存(Working Memory,可与前面所讲的处理器告诉缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量不同线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递均需要通过主内存来完成。
内存间相互操作
Lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
Unlock(解锁):作用于主内存的变量,他把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
Read(读取): 作用于主内存的变量,他把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
Load(载入): 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
Use(使用): 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
Assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
Store(存储):作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中,以便随后的write操作。
Write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果把一个变量从主内存复制到工作内存,那就要安装顺序地执行read和load操作。
如果要把变量从工作内存同步到主内存,就要按顺序的执行store和write操作。
Java内存模型规定了在执行上述八种基本操作时必须满足如下规则:
- 不允许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操作)
先行发生原则
先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,”影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
// 以下操作在线程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的值呢? 答案是不确定
先行发生原则告诉一个结论:
时间上的先后顺序与先行发生原则之间基本没有太大的关系,所以衡量并发安全问题时不要受到时间顺序的干扰,一切必须以先行发生原则为准。
参考
中生代技术群微信公众号
本文作者 李晓晴