在许多情况下,让计算机同时处理多个任务,不仅是因为计算机的处理能力太强大,另一个重要的原因是计算机的计算速度与它的存储和通信系统速度差距太大,大量时间被浪费在磁盘及网络IO上
虚拟机在处理并发时遇到的问题与物理机有不少类似,因此我们先来简单看一下物理机针对并发的处理方案
由于计算机存储设备与计算速度之间有几个数量级的差距(即使内存也是如此),因此在内存与处理器之间,往往还有一层高速缓存来作为缓冲:将运算需要用到的数据复制到缓存中,让运算能够快速进行,运算结束后再将结果同步回内存,这样处理器就无须等待缓慢的内存读写
高速缓存很好地解决了处理器与内存之间速度的不匹配,但却带来了另一个问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主内存。因此,当多个处理器访问同一块主内存区域时,就有可能导致各自的缓存数据不一致。为了解决一致性问题,需要各处理器在读写缓存数据时都遵循一些缓存一致性协议(例如MESI等)
除此之外,为了更加充分的利用处理器内部的运算单元,处理器有可能会对输入的代码进行乱序执行(Out-Of-Order Execution)优化。处理器在计算之后会将乱序执行的结果进行重组,保证结果与顺序执行一致
在大致了解了硬件处理并发时遇到的问题及解决方案后,我们再回到主题,继续了解Java虚拟机在这方面所做的工作。首先我们先来了解一下Java内存模型
Java内存模型(Java Memory Model,JMM)的目的是:屏蔽各种硬件和操作系统的内存访问差异,使Java程序在各平台下能够达到一致的内存访问效果
Java内存模型的目标是定义程序中变量的访问规则。这里的变量,不包含局部变量和方法参数,因为这些变量是线程私有的(关于这方面内容,请参考本系列文章:Java内存区域),不会涉及到并发问题
Java内存模型规定,所有变量都存储在主内存,每个线程还有自己的工作内存。线程对变量所做的读写操作,都必须在自己的工作内存中进行,而不能直接读写主内存。不同的线程间也不能直接访问其他线程的工作内存。线程间传递变量值均需要通过主内存完成
关于主内存与工作内存之间具体的交互协议,Java内存模型定义了8种操作来完成,这些操作每一种都是院子的、不可再分的(对于double和long类型有些例外,后面再说)
(1)lock(锁定):作用于主内存,它将一个变量标识为一个线程独占的状态
(2)unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3)read(读取):作用于主内存,它将一个变量的值从主内存传输到工作内存,以便随后的load操作使用
(4)load(加载):作用于工作内存,它将read操作从主内存中传输过来的变量值放入工作内存的变量副本中
(5)use(使用):作用于工作内存,它将工作内存中的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行此操作
(6)assign(赋值):作用于工作内存,它将执行引擎传递过来的变量值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行此操作
(7)store(存储):作用于工作内存,它将一个变量的值从工作内存传输到主内存,以便随后的write操作使用
(8)write(写入):作用于主内存,它将store操作从工作内存中传输过来的变量值放入主内存的变量中
要把一个变量从主内存复制到工作内存,需要顺序执行read和load。要把一个变量从工作内存同步回主内存,需要顺序执行store和write。注意:这里只是要求顺序执行,而不是连续执行。除此之外,Java内存模型规定在执行上述这8种操作时还必须满足如下规则
(1)不允许read和load、store和write单独出现,即不允许一个变量从主内存读取但工作内存不接受,或者从工作内存回写但主内存不接受
(2)不允许一个线程丢弃它最近的assign操作,即不允许变量在工作内存改变之后却不同步回主内存
(3)不允许一个线程未进行任何assign操作就将数据从工作内存同步回主内存
(4)一个新的变量只能在主内存中“诞生”,即不允许在工作内存中直接使用一个未被初始化的变量
(5)一个变量在同一时刻只允许被一个线程lock,但lock操作可以被同一个线程重复多次执行,但是随后需要执行相同次数的unlock操作,变量才会被解锁
(6)执行lock操作会将工作内存中该变量的值清空,在执行引擎使用这个变量之前,需要重新执行load或者assign操作初始化变量值
(7)如果一个变量没有被lock,那么就不允许unlock,同样也不允许unlock被其他线程锁定的变量
(8)对一个变量unlock前,必须将此变量同步回主内存(执行store、write)
上述限定十分严谨且繁琐,后续将介绍一种等效判断原则--先行发生原则(happen-before),用来确定一个访问在并发环境下是否安全
volatile可以说是Java虚拟机提供的最轻量级的同步规则,但是它的特征并不容易被正确完整的理解。当一个变量被定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,第二是禁止指令重排序优化
保证可见性
可见性是指当一个线程对此变量值进行了修改,新值对于其他线程来说是立即可见的,但是普通变量无法做到这点。对于volatile变量的可见性,主要体现在下面两点:
(1)每次读取volatile变量时,都要从主内存获取最新值
(2)每次修改volatile变量时,都要将修改后的值立即同步回主内存
volatile可以保证可见性,但是由于Java里面运算并非原子操作,因此volatile并不能保证原子性,也就是说volatile变量在并发环境下仍然是不安全的。请看下面这个例子:
在这个例子中,我们声明了一个volatile变量,之后创建了10个线程,每个线程对该变量自增1w次。如果一切正常,那么最终该变量值会被自增为10w,但是从实际输出结果来看并非如此,并且每次都会不同,但是都小于期望值。通过这个例子,我们可以看出,volatile变量在并发环境下是不安全的。为什么上例会出现这样的结果?
结合上面讲到的8种内存操作,我们可以推测,对于a++这一条语句,很可能需要顺序执行read、load、use、assign、store和write这些操作,虽然这些操作每一种都是原子的,但是这一组操作并不具备原子性
如果觉得还是很抽象,我们可以通过另一种更加直观的方式(javap反汇编代码)来查看下a++的处理过程
从这里不难看出,简单的a++并非一条字节码指令就能完成,大致需要经过读取、加法操作、赋值才能完成。字节码层面尚且如此,底层机器指令将会更加复杂,而这一系列指令并不具备原子性
由于volatile变量只能保证可见性,因此在不符合以下两条规则的场景中,依然需要使用锁来保证原子性:
(1)运算结果并不依赖变量当前的值
(2)变量不需要与其他的状态变量共同参与不变约束
第一点在上例中已经有所展示(自增操作显然会依赖于变量当前的值)。对于第二点,我们再来看下面这个例子:
在这个例子中,我们声明了两个volatile变量a和b。随后启动两个线程,第一个线程在“正常情况(a
说了这么多,那么volatile究竟适用于什么场景?来看下面这个例子:
在这个例子中,在shutdown==true之后,线程能够立刻停止工作。更多关于volatile的适用场景,可以参看这篇文章:正确使用volatile的模式
禁止指令重排序
volatile的第二个作用是能够禁止指令重排序优化。在Java中关于指令重排序导致错误最经典的案例之一是双重检查(Double-Checked Locking,DCL)失效(详细内容可以参考:The "Double-Checked Locking is Broken" Declaration)
双重检查常用于单例模式,来看下面这个例子:
这个例子中,我们通过双重检查创建单例。运行该样例,getA()方法有时候有可能会得到出乎意料的结果a=0,也就是说我们通过getInstance()方法有可能会获得到一个尚未被完全初始化的对象(对象不为null,但是某些属性可能还没被正确赋值)
原因就在于上图中红框中的代码,这行代码实例化一个TestDCL对象,并将其赋值给引用instance。这行简单的代码在底层粗略的分至少也要完成三件事:1.为对象申请一块内存;2.在这块内存上进行初始化;3.将内存地址赋值给引用。但是实际运行中,这三步并不一定是按照上述顺序执行的,很有可能将2和3进行调换,也就是按照1、3、2的顺序执行。如果发生这种情况,假如恰巧第二个线程在第一个线程完成步骤3之后(此时instance已经不为null),正好执行到第25行代码,发现instance不为null,那么第二个线程此时拿到的instance就是一个尚未被完全初始化的对象(a还没有被赋值为5)
关于指令重排序在汇编语言层面的表现,我们借用了上面文章(The "Double-Checked Locking is Broken" Declaration)中的一张图:
对于上例,其中一个解决办法就是将instance声明为volatile(要求JDK大于等于1.5版本)。volatile依赖底层硬件提供的内存屏障(Memory Barriers)指令来达到禁止指令重排序的目的。关于内存屏障,我们来看一个简单形象的例子:
假设有如下4个操作:
1.read A
2.read B
3.write C
4.write D
在这种情形下,完成write D的时候,read A、read B、write C不一定都完成了,原因就在于指令重排序,只要前后指令间没有依赖关系,就有可能被重排序
但是假如我们就想在read A、read B、write C都完成之后再执行write D,该如怎么做?这个时候就可以在write D之前插入一个内存屏障:
1.read A
2.read B
3.write C
4.memory barriers
5.write D
此时,内存屏障会把后面的write D挡住,保证前面三条指令完成后再执行write D,这样就保证了write D不会被重排序到前面三条指令之前
实际上,前面我们通过Java内存模型和volatile已经多次引出了这三个特性,这三个特性也是并发编程中重点考虑的三个问题
原子性(Atomicity)
Java内存模型保证前面讲到的8种内存操作都具备原子性,因此对于基本类型数据的读写可以认为是具备原子性的(long和double的读写严格来说不具备原子性,但是目前来看无需过分在意,关于这部分内容,可以参考:Non-Atomic Treatment of double and long)
除此之外,如果需要更大范围的原子性保证,Java内存模型提供了前面讲到的8种内存操作中的lock和unlock来满足,对应到字节码层面的指令是monitorenter和monitorexit,反映到Java语言层面的语法是synchronized
可见性(Visibility)
可见性是指一个线程修改了共享变量的值,其他线程能够立即看到这个修改。前面我们通过较大篇幅讲过了volatile对于保证可见性方面的内容。除此之外,synchronized和final也可以保证可见性
对于synchronized的可见性:虚拟机保证在进入同步块时清空工作内存中变量的值,并且在退出同步块时将工作内存中变量的值同步回主内存
对于final的可见性:被final修饰的字段在构造器中一旦初始化完成,其他线程就可以看到final字段的值(关于这部分内容,可以参考:final Field Semantics)
有序性(Ordering)
关于有序性,前面在讲解volatile的时候也介绍过。影响有序性的主要因素来自于两点:指令重排序、工作内存与主内存的同步延迟
volatile和synchronized能够保证有序性,但是个人理解它们的粒度还是不大一样。volatile能够在指令层面禁止重排序。而synchronized能够保证持有同一个锁的两个同步块只能够串行的进入
先行发生原则描述的是操作之间的偏序关系:假如操作A先行发生于操作B,那么在B发生之前,A产生的影响就能够被B所观察到
符合先行发生原则的操作,在Java中就是天然有序的,无需通过volatile、synchronized等手段来额外保证。如果两个操作之间的关系不符合先行发生原则,并且无法据此推导出来,那么它们之间就没有顺序性保证,虚拟机可以对对它们进行任意的重排序。这个原则非常重要,它是判断数据是否存在争用,线程是否安全的依据
先行发生原则主要包括下面几点:
(1)程序次序规则(Program Order Rule):
在同一个线程内,按照控制流顺序,前面的操作先行发生于后面的操作
Each action in a thread happens before every action in that thread that comes later in the program order
(2)monitor锁定规则(Monitor Lock Rule):
一个unlock操作先行发生于后面对同一个锁的lock操作
An unlock on a monitor lock happens before every subsequent lock on that same monitor lock
(3)volatile变量规则(Volatile Variable Rule):
对一个volatile变量的写操作先行发生于后面对这个变量的读操作
A write to a volatile field happens before every subsequent read of that same field
(4)线程启动规则(Thread Start Rule):
线程的start方法先行发生于此线程的每一个动作
A call to Thread.start on a thread happens before every action in the started thread
(5)线程终止规则(Thread Termination Rule):
线程中所有操作都先行发生于对此线程的终止检测,可以通过join方法结束和isAlive方法的返回值等手段检测到线程已经终止执行
Any action in a thread happens before any other thread detects that thread has terminated, either by successfully return from Thread.join or by Thread.isAlive returning false
(6)线程中断规则(Thread Interruption Rule):
对线程interrupt方法的调用先行发生于被中断线程检测到中断事件的发生,可以通过捕获InterruptedException或者执行isInterrupted/interrupted检测到是否有中断发生
A thread calling interrupt on another thread happens before the interrupted thread detects the interrupt, either by having InterruptedException thrown, or invoking isInterrupted or interrupted
(7)对象终止规则(Finalizer rule):
一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize方法的开始
The end of a constructor for an object happens before the start of the finalizer for that object
(8)传递性(Transitivity):
如果操作A先行发生于操作B,操作B先行发生于操作C,那么A就先行发生于操作C
If A happens before B, and B happens before C, then A happens before C
下面通过一个简单的例子来看一下如何应用上述规则来判断操作间是否具备顺序性:
假设线程A先调用setValue方法,然后线程B调用了同一个对象的getValue方法,那么线程B得到的value是多少?
我们依次应用上述先行发生规则来分析一下这两个操作的行为:
首先不在一个线程中,因此程序次序规则不适用
由于没有使用同步,因此monitor锁定规则不适用
没有使用volatile关键字,所以volatile变量规则不适用
后面的线程启动、终止、中断以及对象的终止规则也跟这里完全没有关系
传递性更是无从谈起
因此我们可以判定:虽然线程A操作发生于线程B操作,但是无法确定线程B得到的value值,也就是说这里的操作不是线程安全的。修复办法相信大家根据上述规则也可以推断出来
Java中一共定义了5种线程状态,在任意时刻,一个线程只能处于其中一种状态:
(1)新建(New):创建后尚未启动的线程处于这种状态
(2)运行(Runable):处于运行状态的线程,有可能正在执行,也有可能正在等待CPU为它分配执行时间
(3)无限期等待(Waiting):处于这种状态的线程不会被分配CPU时间,它们要等待被其他线程显式的唤醒。以下方法可使线程进入这种状态:没有设置Timeout参数的Object.wait()方法、没有设置Timeout参数的Object.join()方法、LockSupport.park()方法
(4)限期等待(Timed Waiting):处于这种状态的线程不会被分配CPU时间,不过无需等待其他线程显式的唤醒,超过一定时间之后会被系统自动唤醒。以下方法可使线程进入这种状态:Object.sleep()方法、设置Timeout参数的Object.wait()方法、设置Timeout参数的Object.join()方法、LockSupport.parkNanos()方法、LockSupport.parkUntil()方法
(5)阻塞(Blocked):处于这种状态的线程在等待进入同步区域。“阻塞状态”和“等待状态”的区别是:前者在等待获取一个排他锁,而后者则是在等待一段时间或者被唤醒
(6)结束(Terminated):已经终止的线程的状态
笔记8结束