引:并发处理的广泛应用是使得阿姆德尔定律替代摩尔定律成为计算机性能发展原动力的根本原因。由于计算机的运算速度和它的存储和通信子系统速度差距太大,所以才出现了并发,而并发绝对是Java运用很大的优势。我们绝对需要理解!
硬件的效率与一致性
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的告诉缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为引入了一个新的问题:缓存一致性。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。下面是常用的内存模型图:
除了增加高速缓存之外,为了使得处理器的内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化。
Java内存模型
目的:让Java程序在各种平台下都能达到一致的内存访问效果。
主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享。
Java内存模型规定了所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时的主内存可以类比,但是此处仅仅是虚拟机内存的一部分)。每条线程都有自己的工作内存(可以与处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图:
这里的Java内存模型如果和Java运行时内存勉强对应,可以这样理解:主内存主要对应Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存直接对应于物理硬件内存,而工作内存优先存储于寄存器和高速缓存中。
内存间的交互操作
Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按照顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。Java内存模型规定了在执行上述八种基本操作时必须满足时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现。
- 不允许一个线程丢弃它的最近的assign操作。
- 不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步到主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定住的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write)。
这8种内存访问操作以及上述规则限定,再加上volatile的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作是并发是安全的。
对于volatile修饰的变量的特殊规则
关键字volatile是Java虚拟机提供的最轻量级的同步机制。Java内存模型堆volatile专门定义了一些特殊的访问规则。当一个变量定义为volatile之后,它将具备以下两种特性:
- 保证此变量对所有线程的可见性:当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性。
- 运算结果不依赖变量的当前值,或者只能保证只有单一的线程来保证原子性。
- 变量不需要与其他状态变量共同参与不变约束。(目前不理解)
- 禁止指令重排序优化:如果有两个或者更多CPU访问同一块内存,且其中有一个在观测另一个,那么它会通过设置内存屏障来使重排序时不能把后面的指令重排序到内存屏障之前访问。
volatile的意义:volatile变量读操作的消耗与普通变量几乎没有什么差别,但是写操作则可能慢一点,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行,不过即便如此,大多数场景下volatile得总开销仍然比锁低。
volatile的特殊规则就是:use/assign、load/store、read/write操作必须连续一起出现,即volatile修饰的变量不会被指令重排序优化。
对于long和double类型的变量的特殊规则
Java内存模型要求对于lock、unlock、read、load、assign、use、store和write这八个操作都具有原子性,但是对于64位的数据类型(long和double),允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,但是强烈建议虚拟机实现为具有原子性的操作。
原子性、可见性与有序性
原子性
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为基本数据类型的访问读写是具备原子性的。synchronized关键字可以实现原子性。
可见性
可见性就是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile、synchronized以及final关键字都能实现可见性。
有序性
Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。volatile和synchronized关键字都能实现有序性。
先行发生原则
先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了小写、调用了方法等。
下面是Java内存模型下一些“天然的”先行发生关系,这些先行发送关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,他们就没有顺序性保障,虚拟机可以对它们进行随意重新排序:
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前的操作先于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作。“后面”是指时间上的先后顺序。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中所有操作都先行发生于此线程的终止检测。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得到操作A先行发生于操作C。
Java与线程
在Java里面谈论并发,大多数都与线程脱不开关系。我们需要知道在Java线程在虚拟机的实现。
线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源,又可以独立调度(线程是CPU调度的基本单位)。
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级线程混合实现。
Java线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。
- 协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完毕后,要主动通知系统切换到另一个线程上去。好处是实现简单,没有线程同步问题;缺点是:线程执行时间不可控制,容易导致整个系统崩溃。
- 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。好处是线程执行之间可控,不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。这里说一下就是java线程优先级不太靠谱,原因是Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统。
线程状态转换
Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这5种状态分别是:新建,运行,无限期等待,限期等待,阻塞,结束。上述5种状态在遇到特定时间发生的时候回互相转换,他们的转换关系如下图:
总结
虚拟机本身具有一套和线程相关内存模型,我们需要利用好它,特别是要理解线性发生原则以及volatil的一些特殊规则。
参考
- 《深入理解Java虚拟机》
- Java内存模型