重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
什么叫数据依赖性?如果两个操作访问同一个变量,且这两个操作中有一个是写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型。
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a=1; b=a; |
写一个变量之后,再读这个位置 |
写后写 | a=1; a=2; |
写一个变量之后,再写这个变量 |
读后写 | a=b; b=1; |
读一个变量之后,再写这个变量 |
上面三种情况,只要重排序两个操作的顺序,结果就会被改变。编译器和处理器重排序的时候,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改编。编译器、runtime和处理器都必须遵守as-if-serial语义。编译器不会对由数据以来的语句进行重排序,但是如果没有数据依赖的语句,是可以重排序的。
重排序操作A和操作B的执行结果,与操作A和操作B按照happens-before顺序执行的结果一致,JMM会认为这种重排序并不非法,允许这种重排序。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因),但是在多线程程序中,对存在控制以来的操作重排序,可能会改变程序的执行结果。
顺序一致性内存模型是一个理论参考模型,设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
顺序一致性内存模型有两大特性:
一个线程中的所有操作必须按照程序的顺序来执行。
(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法阈值。未同步程序在两个模型中的执行特性有如下几个差异:
一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
volatile变量自身具有下列特性:
从内存语义的角度说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义,volatile读与锁的获取有相同的内存语义。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
为了实现volatile语义,编译器生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略。
旧的内存模型中,虽然不允许volatile变量之间重排序,但是允许volatile变量和普通变量重排序。JSR-133中增强volatile的内存语义:严格限制编译器和处理器对volatile变量和普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保整个临界区代码的执行具有原子性。
锁除了让临界区互斥执行之外,还可以让释放锁的线程获取同一个锁的线程发送消息。
当线程释放锁的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。下面对锁释放和锁获取的内存语义做个总结:
to do…
首先,声明共享变量为volatile,然后使用CAS的原子条件更新来实现线程之间的同步,同时配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
AQS,J.U.C,这些concurrent包中的基础类都是使用这种模式实现的。concurrent包中的高层类又是依赖于这些基础类来实现。
对于final域,编译器和处理器要遵守2个重排序规则:
写final域的重排序规则禁止把final域的写重排序到构造函数之外。该规则的实现包含下面2个方面。
读final域的重排序规则是:在1个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。不过需要保证,在构造函数内部,不能让这个被构造对象的引用被其它线程所见(对象引用不能再构造函数中逸出)。
在X86处理器中,final域的读/写不会插入任何内存屏障(因为X86处理器不会对写-写操作和存在间接依赖关系的操作进行重排序)。
其实前面也提到过,只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),JMM对编译器和处理器不做要求,怎么优化都可以。
JSR-133使用happens-before概念指定两个操作之间的执行顺序。JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM保证a操作的执行结果对b操作可见)。
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序的结果和happens-before关系执行的结果一样,JMM也是允许这种重排序的。
JSR-133中对happens-before规则的定义如下:
1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
4.传递性:如果Ahappens-beforeB,且Bhappens-beforeC,那么Ahappens-beforeC
5.start()原则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
6.join()原则:如果线程A执行ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化方式,但它是个错误的用法。
人们想通过双重检查锁定来降低同步的开销,因为早期JVM,synchronized存在巨大的性能开销(包括无竞争的synchronized)。下面是一个例子。
public class Singleton {
private static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton(); //会出现问题!
}
}
}
return singleton;
}
}
当线程执行到第4行的时候,代码读取到singleton不为null时,instance引用的对象有可能还没有完成初始化。PS:可以通过使用volatile修饰变量,禁止指令重排序,这样就没问题了。
关于该问题的具体分析,可以看看之前写的一篇博客,在博客的最下方(volatile部分),清楚的解释了该问题。点我查看。
同上,可参考给出的博客,在这里给出改后的代码。
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
基于下面这个特性,可以实现另一种线程安全的延迟初始化方案。
JVM在类的初始化阶段(即在Class被加载之后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
下面是给出的代码。
public class SingletonFactory{
private static class SingletonHolder{
public static Singleton singleton=new Singleton();
}
public static Singleton getSingleton(){
return SingletonHolder.singleton; //这里将导致SingletonHolder类被初始化。
}
}
to do…
根据对不同类型的读/写操作组合执行顺序的方式,可以把常见处理器的内存模型划分为如下几种类型:
放松程序中写-读操作的顺序,由此产生了Total Store Ordering内存模型(TSO)
在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Order内存模型(PSO)
在前面2条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了Relaxed Memory Order内存模型和PowePC内存模型
这里的放松是以两个操作之间不存在数据依赖性为前提的。
性能越强,处理器内存模型就会越弱。常见的处理器内存模型比JMM要弱,Java编译器在生成字节码的时候,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。不过处理器不同,插入的内存屏障种类和数量也不同。
JMM是语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是个理论参考模型。
Java程序的内存可见性保证可以分为下面三类:
修补主要有2个: