JVM试图定义一种Java内存模型(Java Memory Model JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前如c/c++等直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另一套平台上并发访问却经常出错。JDK1.5后,JMM已经完善和成熟起来了。
主内存与工作内存
JMM定义了程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段、构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然不存在竞争问题。
JMM规定了所有变量存储在主内存(Main Memory)中(主内存仅仅是虚拟机内存的一部分)。每条线程中还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本copy,线程对变量的所有操作(读取、赋值)等都必须在工作内存中进行,而不能直接读写主内存中变量。不同的线程之间也无法直接访问对方工作内存中的变量。线程间变量值的传递均需要通过主内存来完成。
副本拷贝的疑问:假设线程中访问一个10MB的对象,也会把这个10MB的内存复制一份出来吗?事实上不会如此,这个对象的引用、对象中某个在线程访问到的字段是可能存在copy的但不会有虚拟机实现成把整个对象copy一次。
这里的主内存、工作内存与第2章所讲的JMM中的堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应,那主内存主要对应堆中的对象实例数据部分,二工作内存则对应于虚拟机栈中部分区域。从更低层次上来说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。
内存间交互操作
一个变量如何从主内存copy到工作内存、如何从工作内存同步回主内存,JMM定义了8种操作来完成,虚拟机实现时必须保证下面的每种操作都是原子的:
lock、unlock、read、load、use、assign、store、write。
如果把变量从主内存复制到工作内存,那就要顺序执行read和load操作。如果要把变量从工作内存同步回主内存,就要顺序执行store(存储)和write操作。 注意,JMM只要求上述两个操作是按顺序执行的,而没有保证是连续执行的。也就是说,read和load之间,store与write之间可插入其他指令。 如对主内存中变量a b访问时,一种可能出现的顺序是:read a read b load b load a。除此之外,JMM还规定了在执行上述操作时必须满足的规则:
不允许read和load store和write操作之一单独出现,即不允许变量从主内存读取了但工作内存不接受,或者从工作内存发起了回写但主内存不接受的情况出现。
不允许一个线程丢弃他最近的assign操作,即变量在工作内存中改变后必须同步回主内存。
对于一个变量的lock操作,会清空工作内存中此变量的值,在执行引擎使用到这个变量前,需要重新load或assign
有unlock必有lock,unlock之前把变量同步回主内存(store、write)
等等
这8中内存访问规定,与稍后的volatile的一些特殊规定,就差不多能完全确定Java中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但又十分繁琐,实践起来很麻烦,所以后面会介绍这种定义的等效判断原则-----先行先发生原则,用来确定一个访问在并发环境下是否安全。
volatile变量的特殊规则
volatile可以说是JVM提供的最轻量级的同步机制,当一个变量定义为volatile后,他将具备两种特性,第一是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量是不能做到这一点的,普通变量的值在线程间传递需要通过主内存来完成,例如,线程A修改了一个普通变量的值,人后向内存进行回写,另外一条线程B在线程A回写完成之后再从主内存进行读取操作,新变量值才会对线程B可见。
但是不要误解,保证了可见性并没有保证基于volatile变量的运算在并发下是线程安全。volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可能存在不一致情况,但是由于每次使用前都要刷新,执行引擎看不到不一致情况,因此可以认为不存在一致性问题),但是Java里面运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。举例:
public class Main {
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;
public static void increase(){
race++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i=0;i<THREADS_COUNT;i++){
threads[i] = new Thread(new Runnable() {
public void run() {
for (int i=0;i<1000;i++){
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount()>2){
/*
为什么是2,不是1,因为通过下面第一局打印的结果发现,存在两个线程,
activeCount自带一个守护线程
Thread.currentThread().getThreadGroup().list();
System.out.println(Thread.activeCount());*/
Thread.yield();
}
System.out.println(race);
}
}
这段代码发起了20个线程,每个线程对race变量进行10000次操作,如果代码正确并发,最后输出的结果应该是200000.但事实并不如愿,问题就出在自增运算race++,发现只有一行代码的increase方法在class文件中是由4条字节码指令构成的,从字节码上很容易分析出并发失败的原因,当getstatic指令将race的值取到栈顶时,volatile保证了race的值此时是正确的,但是在执行iconst_1、iadd这些指令时,其他线程可能已经把rece的值加大了,而在操作栈顶的值就变成了过期数据,所以putstaitc指令后,就可能把较小的race值同步回主内存了。
客观的说,使用字节码分析并发问题,仍然不严谨,因为即使编译出来只有一条的字节码指令,也并不意味着执行这条指令就是一个原子操作。一条字节码在解释执行时,解释器将要运行许多行代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转化为若干本地机器码指令,此处使用-XX:+PrintAssembly参数输出反汇编来分析更加严谨一些。
由于volatile变量只能保证可见性,必要时还是使用synchronized或者concurrent中原子类来保证原子性。
下面这种场景就很适合使用volatile变量来控制并发:
volatile boolean shutdownRequested;
public void shntdown(){
shutdownRequested = true;
}
public void doWork(){
while(!shutdownRequested){
//do stuff
}
}
volatile的第二个语义是禁止指令重排序优化,普通变量仅仅保证得到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型描述的所谓的“线程内表现为串行的语义”。举例:
如果定义initialzed变量时没有使用volatile修饰,就可能会由于指令重排序优化,导致线程A中最后一句代码initialzed=true被提前执行(重排序也分几种,这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是这句话对应的汇编代码被提前执行),这样线程B中使用配置文件的代码就可能出错,而volatile关键字可以避免此类情况发生。
volatile屏蔽指令重排序的语义在JDK1.5中才被完全修复,此前的JDK中即时将变量声明为volatile也仍然不能完全避免重排序导致的问题,这点也是JDK1.5之前Java中无法安全使用DCL(双锁检测)来实现单例模式的原因。
再举一个例子分析下volatile关键字如何禁止指令重排序优化。下面是一段标准单例模式,可以观察加入volatile和未加入volatile关键字时汇编代码的差别:
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance(){
if(instance == null){
synchronized (Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
编译后,这段代码对instance变量赋值部分:
关键在于volatile修饰的变量,赋值后多执行了一个lock操作,这个操作相当于一个内存屏障(Memory Barrier,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;如果有两个或更多CPU共同访问时,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。这句指令中add1 $0x0显然是一个空操作,关键在于lock‘1前缀,它的作用是使得本CPU的cache写入内存,该写入动作也会引起别的CPU或者内核无效化其cache,这种操作相当于对cache中的变量做了一次前面Java内存模型中所说的store和write操作。所以通过这样一个空操作,可以让前面volatile变量的修改对其他CPU立即可见。
为何说他禁止指令重排序呢?
volatile禁止重排序有两层含义,一是程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定已经全部进行,且结果对后面操作可见,且在其后面的操作还未进行;二是,进行指令优化时,不能将volatile前面的语句放到其后执行,不能将volatile后面吗的语句放到其前执行。 例如:
指令1:int a = x+1;
指令2:int b = 1;
指令3:int c = a*2;
volatile int y;
1和3不能重排序,但2的顺序无所谓,所以在本CPU中,重排序看起来依然是有序,只要保证CPU后面依赖到A B值的操作时能获取到正确的A和B的值即可。因此,lock指令把修改同步到内存时,意味着所有值钱的操作都已完成,这样便形成了“指令重排序无法越过的内存屏障”的效果!!!
volatile能让我们的代码比其他同步工具更快吗?某些情况下,volatile的同步机制的性能确实要优于锁,但是由于虚拟机对锁实行许多消除和优化,使得我们很难量化的认为volatile会比synchronized快多少。如果让volatile自己与自己比较,可以确定一件事:volatile变量的读操作性能与普通变量读操作几乎没有区别,但是写操作则可能慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。即便如此,大多数场景下volatile总开销仍然要比锁低,我们选择volatile与锁的唯一依据仅仅是volatile语义能否满足使用场景需求。
对于long和double型变量的特殊规则
Java内存模型要求lock unlock read load assign use store write 这8个操作都具有原子性,但是对于64位数据(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位操作,即允许虚拟机实现选择可以不保证64位·数据类型的load store read write这4个操作的原子性,这就是所谓的long和double的非原子协定。如果多个线程共享一个并未声明为volatile的long或double类型变量,并且同时对他们进行读取和修改,那么某些线程可能会读取到一个即非原值,也不是其他线程修改值的代表了“半个变量的数值”
这种操作十分罕见(目前商用JVM中不会出现),因为JMM虽然允许JVM不把long和double的读写实现为原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,目前JVM都选择把64位数据的读写操作作为原子操作来对待,因此编写代码时无需将long和double专门声明为volatile。
原子性、可见性与有序性
原子性:由Java内存模型来直接保证原子性变量操作包括read load assign use store write,大致可以认为基本数据类型的访问读写具备原子性(long和double是例外,非原子性协定,几乎不会发生)。
如果应用场景需要一个更大的原子性保证,JMM提供了lock和unlock操作来满足需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反应到代码中就是同步块synchronized。
可见性:可见性指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。通过lock指令,除了volatile外,Java还有两个关键字能实现可见性,即synchronized与final。同步块的可见性是由对一个变量执行unlock操作前,必须把此变量同步回主内存中(执行store、write操作),而final关键字的可见性是指:被final修饰的子弹在构造器中一旦初始化完成,并且构造器没有把this引用传递出去,那其他线程中就能看见final字段的值。下面的变量i与j都具备可见性,无需同步
有序性:Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指“线程内表现为串行的语义”,后半句指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则决定。
先行先发生原则
如果Java内存模型中所有的有序性都仅仅依靠volatile和synchronized来完成,那么有一些操作将会很繁琐,但是我们在编写并发代码时并没有感觉到这一点,这个因为Java中有一个“先行先发生”原则。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。
先行发生原则指什么?先行发生在Java内存模型中定义的两项操作之间的偏序关系,如果A先行发生于B,那么A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。
假设A先行发生于B,那可确定B的操作执行后,j一定=1。得出结论的依据有两个:一是根据先行发生原则,i=1的结果可以被观察到;二是线程C还没登场。A结束后没有其他线程会修改i的值。现在再看c,依然保存A和B之间的先行发生关系。而线程C出现在A和B之间,但是C与B没有先行发生关系,那J的值就不确定了。1和2都有可能。因为C对变量i的影响可能会被B观察到,也可能不会。
下面是JMM下天然的先行发生关系,这些先行关系无需任何同步器协助就已存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,他们就没有顺序性保证,虚拟机可以对他们随意重排序。
8个规则:
程序次序规则:一个线程内,按照程序代码顺序,书写在前面的操作先行发生于后面的操作。准确说应该是控制流顺序而不是程序代码顺序。
管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调是同一个锁。后面指时间上的顺序。
volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面同样指时间上的先后顺序。
线程启动规则:线程的start方法先行于此线程的每一个工作。
线程终止规则:线程中所有操作都先行发生于对此线程的终止检查。
线程中断规则:对线程interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted方法检测到是否有中断发生。
对象终结规则:一个对象的初始化完成先行发生于它的finalize方法的开始。
传递性:A先行于B,B先行于C,则A先行于C。
Java无需任何同步操作保证就能成立的先行发生规则只有上面这些了,下面演示下如何使用这些规则去判定操作时间是否具备顺序性,对于读写共享变量的操作来说,线程是否安全,可以通过下面的例子感受一下,时间上的先后顺序与先行发生之间有什么不同。
private int value = 0;
public void setValue(int value){
this.value = value;
}
public int getValue(){
return value;
}
上面是一组再普通不过的get set方法,假设存在线程A和B,A先(时间上的先后)调用了setValue,然后线程B调用了同一个对象的getValue,那么线程B收到的返回值是什么?
我们分析下先行发生原则中各项规定,由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,所以自然不会发生lock和unlock操作,所以管程锁定规则不适用,由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用,后面的线程启动、终止、中断规则和对象终结规则和传递性,更无从谈起,因此我们判断尽管A在操作时间上先于B,但是无法确定线程B中getValue方法的返回结果,也就是说这里的操作不是线程安全的。
结论:一个操作时间上先发生 不代表这个操作会是先行发生,那如果一个操作先行发生,是否就能推导出这个操作必定是时间上的先发生呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的指令重排序。
//以下操作在同一个线程中执行
int i=1;
int j=2;
上面的两条赋值语句在同一个线程中,根据程序次序规则,itn i=1的操作先行发生于int j=2,但是int j=2的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们这条线程中没有办法感知到这点。
所以,时间先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题时不要受时间顺序干扰,一切必须以先行发生原则为准。
Java与线程
并发并不一定要依赖多线程(如PHP中很常见的多进程并发),但是在Java里谈论并发,大多数与线程脱不开干系。讲到Java线程,我们从Java线程在虚拟机中的实现开始讲起。
线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件IO等),又可以独立调度(线程是CPU调度的基本单位)。
主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行start且还未结束的Thread类的实例就代表了一个线程。我们注意到Thread类与大部分Java API有显著差别,它的所有关键方法都是声明为Native的。在Java API中,一个Native方法往往意味着这个方法无法使用平台无关的手段实现,正因如此,将本节标题定位线程的实现而不是Java线程的实现。
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
使用内核线程实现
内核线程(KIT)是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。每一个内核线程都可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。
程序一般不会直接去使用内核线程,而是使用内核线程的一种高级接口---轻量级进程(LWP),轻量级进程就是我们通常意义上讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核进程之间1:1的关系称为一对一线程模型。
由于是基于内核线程实现,所以各种线程操作都需要进行系统调用。而系统调用的代价较高,需要在用户态和内核态中来回切换。其次,每一个轻量级进程都需要一个内核线程支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量有限。
使用用户线程实现
广义上讲,一个线程只要不是内核线程,就可以认为是用户线程,因此,从这个定义上讲,轻量级进程也属于用户线程,但轻量级进程的实现使用是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。
而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,可以支持规模更大的线程数量不烦高性能数据库的多线程就是由用户线程实现。这种进程与用户线程之间1:N的关系称为一对多线程模型。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换、调度都是需要考虑的问题。而且由于操作系统只把处理器资源分配到进程,那诸如“堵塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类的问题解决起来将会异常困难。因而使用用户线程实现的程序一般都比较复杂,使用用户线程的程序越来越少了,Java之前使用过用户线程,最后还是放弃了。
使用用户线程加轻量级进程混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,即存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换等操作依然廉价,并且可以支持大规模用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度与处理器映射,并且大大降低了整个进程被完全堵塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M,这种称为多对多的线程模型。 许多UNIX系列操作系统,都提供了N:M的线程模型实现。
Java线程的实现
Java线程在JDK1.2之前,是基于称为“绿色线程”的用户线程实现的,而1.2中,线程模型替换为基于操作系统原生线程模型来实现。因此,目前JDK中,操作系统支持怎样的线程模型,很大程度上决定了JVM的线程是怎么映射的。
对于Sun JDK来说,它的windows版与Linux版都是使用一对一的线程模型实现,一条Java线程就映射到一条轻量级进程中,因为windows与linux提供的线程模型就是一对一的。
Java线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,协同式线程调度与抢占式线程调度。
协同式线程调度:如果使用协同式调度的多线程系统,线程的执行时间是由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上,协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完之后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步问题。坏处也很明显:线程执行时间不可控制,甚至如果一个线程编写的有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那。
抢占式线程调度:
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种线程调度方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程堵塞的问题,Java使用的线程调度方式就是抢占式调度。
虽然Java线程调度是系统自动完成的,但是我们还可以建立系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点--这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的线程优先级(Thread.min_priority至Thread.max_priority),在两个线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行。
不过线程优先级并不靠谱,线程调度最终还是取决于操作系统。
状态转换
Java定义了5种线程状态,任意一个时间点,一个线程只能有且只有其中一种状态,这5种状态如下:
新建(New):创建后尚未启动的线程处于这种状态。
运行(Runnable):Runnable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待CPU为它分配执行时间。
无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显示的唤醒,以下方法会让线程陷入无限期的等待状态:
没有设置timeout参数的object.wait()方法。
没有设置timeout参数的Thread.join()方法。
LockSupport.park()方法。
限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显示唤醒,在一定时间之后他们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
Thread.sleep()方法。
设置了Timeout参数的Object.wait()方法。
设置了Timeout参数的Thead.join()方法。
LockSupport.parkNanos()方法。
LockSupport.parkUntil()方法。
阻塞(blocked):线程被阻塞了,阻塞状态与等待状态的区别是:阻塞状态在等待着获取到一个排他锁,这个事件将在另一个线程放弃这是锁的时候发生:而等待状态则是在等待一段时间,或者唤醒动作的发生,在程序等待进入同步区域时,线程将进入这种状态。
结束(Terminated):已经终止线程的线程状态,线程已经结束执行。
上述5种状态在遇到特定事件发生时将会互相转换。