目录
1.硬件的效率与一致性:
缓存一致性(Cache Coherence)
2.Java内存模型
2.1主内存与工作内存
2.2内存间的交互
2.3 volatile型变量的特殊规则
2.3.1 保证此变量对所有线程的可见性;
2.3.2 禁止指令重排序优化
2.3.4 在volatile与锁之中选择的唯一依据
2.3.4 JMM中对volatile变量定义的特殊规则
2.4对于double和long型变量的特殊规则
2.5 原子性、可见性和有序性
2.5.1原子性atomicity
2.5.2 可见性visibility
2.5.3 有序性 ordering
2.6 先行发生原则happens-before
JMM的天然的先行发生关系
并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展原动力的根本原因。
多任务处理:让计算机同时去做几件事情。
每秒事务处理数(transacrions per seconds,TPS):衡量一个服务性能的高低好坏。
物理计算机中的并发:
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但也引入了一个新问题:
百度百科:又译为缓存连贯性、缓存同调,是指保留在高速缓存中的共享资源,保持数据一致性的机制。
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),如图所示:
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
这就需要各个处理器访问缓存时都遵循一些协议:MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。
内存模型:在特定操作协议下,对特定的内存或者告诉缓存进行读写访问的过程抽象。
除了增加高速缓存以外,为了使得处理器内部的运算单元尽量被充分利用,处理器可能会对输入代码进行乱序执行(out-of-order execution)优化,处理器会在计算之后将乱序的执行结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序各个语句计算的先后顺序与输入代码中的顺序一致。
所以——如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序保证。
JVM的即时编译器中也有和乱序执行优化类似的指令重排序(instruction reorder)优化。
JMM,来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。
JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
为了获得较高的执行效能,JMM并没有限制执行引擎适用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施。
JMM规定,所有的变量都存储在主内存(Main memory)(可类比,物理硬件是的主内存,虚拟机内存中的一部分)。
每条线程有自己的工作内存(working memory)(可类比。处理器高速缓存)。
线程、主内存、工作内存三者之间的交互关系如图:
这里所提到的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分。基本上没有联系。
JMM中定义了一下8种操作来完成主内存与工作内存之间具体的交互协议(即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步会主内存之类的实现细节),虚拟机必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台商允许有例外):
除此之外,JMM还规定了在执行者8种基本操作必须满足如下规则:
关键字volatile是JVM提供的最轻量级的同步机制。
当一个变量定义为volatile之后,它将具备两种特性:
/*
*volatile变量自增运算的测试
*/
public class Test{
public static volatile int race=0;
public static void increase() {
race++;
}
public static final int THREADS_COUNT=20;
public static void main(String[] args) {
Thread[] threads=new Thread[THREADS_COUNT];
for(int i=0;i1)
Thread.yield();
System.out.println(race);
}
}
运行结果:
这段代码发起了20个线程,每个线程对race变量进行1000次自增操作。
如果这段代码能够正确并发的话,最后输出的结果应该是200000.
但运行后并不会得到期望的结果,总小于200000.
问题:自增运算race++
使用Javap反编译这段代码后,得到:
public static void increase();
Code:
Stack=2,Locals=0,Args_size=0
0: getstatic #13;//Field race:I
3: iconst_1
4: iadd
5:putstatic #13;//Field race:I
8: return
LineNumberTable:
line 14:0
line 15:8
只有一行代码的increase()方法在Class文件中是由4字节码指令构成的(return 不是有race++产生的,这条指令可以不计算),从字节码层面上很容易就分析出并发失败的原因了:
即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是一个原子操作。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍需要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性:
很适合使用volatile变量来控制并发的场景:
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested=true;
}
public void dowork() {
while(!shutdownRequested) {
//do stuff
}
}
当shutdown()方法被调用时,能保证所有线程中执行的dowork()方法都立即停下来。
普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。——线程内表现为串行的语义(within-thread as-if-serial semantics)。
如下例子展示了为何指令重排序会干扰并发的执行:
Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized=false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后将initialized设置为true以通知其它线程配置可用
configOptions=new HashMap();
configTest=readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized=true;
//假设以下代码在线程B中执行
//等待initialized为true,代表线程A已经把配置信息初始化完成;
while(!initialized) {
sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();
如果initialized变量没有用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A的最后一句代码initialized=true被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器及的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样,在线程B中使用配置信息的代码就可能出现错误,而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修饰前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock addl $ 0x0, (%esp)"操作,这个操作相当于一个内存屏障(memory barrier或memory fence,指重排序是不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;
但如果有两个或更多CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
addl $ 0x0, (%esp)这句指令,把ESP寄存器的值加0,显然是一个空操作。采用这个空操作而不是空操作指令nop是因为IA32手册规定lock前缀不允许配合nop指令使用。
关键在于Lock前缀。它的作用是使得本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化(Invalidae)其cache,这种操作相当于对cache中的变量做了一次前面介绍的JMM中所说的“store和write”操作。所以通过这样一个空操作,可以让前面的volatile变量的修改对其他CPU立即可见。
volatile禁止重排序:
从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各个响应的电路单元处理。但并不是说,指令任意重排,CPU需要能正确处理指令以来情况以保障程序能得到正确的执行结果。
所以在本CPU内,重排序看起来依然是有序的。因此lock addl $ 0x0, (%esp)指令把修改同步到内存中,意味着之前所欲的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作可能会慢点,因为他需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
仅仅是volatile的语义能否满足使用场景的需求。
① 在工作内存中每次使用变量前都需要从主内存中刷新最新值;
② 每次修改变量的值之后都必须立刻同步到主内存中;
③ 要求volatile修饰的变量不会被指令重新排序。
假定T表示一个线程,V和W分别表示两个volatile型变量。那么在进行read、load、use、assign、store和write操作时需要满足一下规则:
JMM要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性。
对于64位的数据类型(long和double)在模型中特别定义了一条相对较宽松的规定:
允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。即允许虚拟机实现选择可以不保证64位数据类型的load、read、store和write这4个操作的原子性。——long和double的非原子性协定(Nonatomic treament of double and long variables)。
如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对他们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其它线程修改值的代表了“半个变量”的数值(很罕见)。
由JMM来直接保证的原子性变量操作包括:
可以大致认为基本数据类型的访问和读写是具备原子性的(例外就是long和double的非原子性协定)。
如果应用场景中需要更大范围的原子性保证(经常会遇到),JMM还提供了lock和unlock操作来满足这种需求。
尽管虚拟机未把lock和unclock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitoerenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反应在Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。
JMM是通过在变量修改后将新值同步回主内存中,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。
无论是普通变量还是volatile变量都是如此。
普通变量和volatile的区别是:
Java还有synchronized和final关键字可以实现可见性。
public static fibal int i;
public final int j;
static {
i=0;
//do something
}
{
//也可以选择在构造函数中初始化
j=0;
//do something
}
Java程序的天然有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性
synchronized关键字在这三种特性都可以作为一种解决方案。
它是判断数据是否存在竞争、线程是否安全的重要依据。
它是JMM中定义的两项操作之间的偏序关系
//以下操作在线程A中执行
i=1;
//以下操作在线程B中执行
j=i;
//以下操作在线程C中执行
i=2;
分析:
假设线程A的操作“i=1”先行发生于线程B的操作“j=i”,那么可以确定在线程B的操作执行之后,变量j的值一定等于1。得出这个结论的依据有两个:
再考虑线程C,依然保持A和B之间的先行发生关系,而C出现在A和B之间,但是C和B没有先行发生关系,那j的值会使多少?
答案是不确定。1和2都有可能。
无需任何同步器的协助就已经存在,可在编码中直接利用。
如果两个操作之间的关系不在此列,并且无法从下列推导出来的话,它们就没有顺序性保障,虚拟机可以随意地对它们进行重排序。
private int value=0;
public void setValue(int value){
this.value=value;
}
public int getValue(){
return value;
}
假设存在线程A和B,A先(时间上的先后)调用了setValue(1),然后B调用了同一个对象的getValue(),那么B的返回值是什么?
一次分析下先行发生原则中的各项规则:
所以可以断定,即使A在操作时间上先行发生于B,但是无法确定B中的gertValue()方法的返回结果。即这里面的操作不是线程安全的。
修复这个问题的方法:
由上面的例子可以得出一个结论:
//以下操作在同一个线程中执行
int i=1;
int j=2;
依据程序次序规则,“int i=1”的操作先行发生于“int j=2”。但是“int j=2”的代码完全可能被处理器先执行,这并不影响先行发生原则的正确性。因为我们无法再这条线程中感知到这一点。
上面两个例子,综合起来可得到:
时间先后顺序和先行发生原则之间基本没有太大的关系。
所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以现行发生原则为准。
具体见《java并发编程实战》P31
对于服务器应用程序,无论是开发阶段还是测试阶段,当启动JVM时一定都要指定-server命令行选项。
Server模式的JVM比client模式的JVM进行更多的优化,例如将循环中未被修改的变量提升到循环外部,因此在开发模式(client模式的JVM)中能正确运行的代码,可能会在部署环境(server模式的JVM)中运行失败。如下代码:
volatile boolean asleep;
while(!asleep){
…
}
如果在代码中忘记把asleep变量声明为volatile变量,则Server模式的JVM会把asleep变量的判断提升到循环体外部(这将导致一个无限循环),但Client模式的JVM不会这么做。
在解决开发环境中出现无限循环问题时,解决这个问题的开销远小于解决在应用环境中出现无限循环的开销。
整理自《深入理解Java虚拟机——JVM高级特性与最佳实践》