JAVA存储模型
14.1什么是存储模型?
假设一个线程为变量aVariable赋值:aVariable = 3;存储模型要回答这个问题:“在什么条件下,读取aVariable的线程会看到3这个值?”
Java语言规范规定了JVM要维护内部线程类似顺序化语义:只要程序的最终结果等同于它在严格的顺序化环境中执行的结果,那么所有的行为都是允许的(处理器可以乱序或者并行地执行指令)。
存储在处理器本地缓存中的值,对于其他处理器并不可见。
在单线程中,重新排序后的指令使得程序在计算性能上得到了很大的提升。
在多线程环境中,为维护正确的顺序不得不产生很大的性能开销。因为额外的线程间协调只会降低程序的运行效率。
协调是通过使用同步完成的,JVM依赖于程序明确地指出何时需要协调线程的活动。
JVM规定了JVM的一种最小保证:什么时候写入一个变量会对其他线程可见。
处理器和编译器为了从你的程序中榨取性能,会用尽手段。
14.1.1平台的存储模型
在**可共享内存的多处理器体系架构**中,**每个处理器都有它自己的缓存,并且周期性地与主内存协调一致**。
提出:
处理器架构提供了不同级别的缓存一致性:有些只提供最小的保证,几乎在任何时间内,都允许不同的处理器在相同的存储位置上看到不同的值。
解释:
想要保证每个处理器能在任意时间内获得其他处理器正在进行的工作,其代价非常高昂。所以处理器会牺牲存储一致性的保证,来换取性能的提升。
一种**架构的存储模型**告诉了应用程序可以从它的存储系统中获得何种担保,同时详细定义了一些特殊的指令称为**存储关卡或栅栏**,**用以在需要共享数据时,得到额外的存储协调保证**。
为了帮助Java开发者屏蔽这些跨架构的存储模型之间的不同,**Java提供了自己的存储模型**,**JVM会通过在适当的位置上插入存储关卡,来解决JVM与底层存储模型之间的差异化**。
**顺序化一致性**模型:
操作执行的顺序是唯一的,那就是它们出现在程序中的顺序,这与执行它们的处理器无关。变量的每一次读取操作,都能得到执行序列上这个变量的最新的写入值,无论这个值是那个处理器写入的。
现在,没有多处理器架构会提供顺序化一致性。所以,不加假设它存在。
结论:
**跨线程共享数据时,现代可共享内存的多处理器(和编译器)架构会做出一些令人惊讶的事情,除非你已经使用存储关卡,通知它们不要这样做。**
**在Java中,只需要通过正确地使用同步,就可以做到这些**。
14.1.2重排序
**调度器交互调用存取操作**的“偶发时序”,会在**没有正确同步的程序中引发错误的结果**。
**JVM还允许从不同的角度观看一个动作,此时该动作会以不同的次序执行**。
各种能引起操作延迟或者错序执行的不同原因,都可以归结为一类重排序。
**同步抑制了编译器、运行时和硬件对存储操作的各种方式的重排序**。
没有同步,就不要随意去推断执行的次序。
14.1.3Java存储模型的简介
**Java存储模型**的定义是**通过动作的形式进行描述的**,所谓**动作**,**包括变量的读和写、监视器加锁和释放锁、线程的启动和拼接**。
JVM 为所有程序内部的动作定义了一个**偏序关系**,叫做happens-before。**要想保证执行动作B的线程看到动作A的结果(无论A和B是否发生在同一个线程中),A和B之间就必须happens-before关系。否则JVM就可以对它们随意的重排序**。
当一个变量被多个线程读取,且至少被一个线程写入时,如果读写操作并未依照happens-before排序,就会产生**数据竞争**。
happens-before的法则:
程序次序法则
- 线程中的每个动作A都happends-before于该线程中的每一个动作B。(所有的动作B都出现在动作A之后。)
监视器锁法则
- 对一个监视器锁的解锁happens-before于每一个后续对同一监视器锁的加锁。
volatile变量法则
- 对volatile域的写入操作happens-before于每一个后续对同一域的读操作。
线程启动法则
- 在一个线程里,对Thread.start的调用会happens-before于每一个启动线程中的动作。
线程终结法则
- 线程中的任何动作都happens-before于其他线程监测到这个线程已经终结、或者从Thread.join调用中成功返回,或者Thread.isAlive返回false。
中断法则
- 一个线程调用另一个线程的interrupte happens-before于被中断的线程发现中断。
终结法则
- 一个对象的构造函数的结束happens-before于这个对象finalizer(终结器)的开始。
传递性
- 如果A happens-before 于 B,却B happens-before 于C,则A happens-before 于C。
虽然动作仅仅需要满足偏序关系,但是同步动作——锁的获取与释放,以及volatile变量的读取与写入——却是满足全序关系。
全序关系:偏序集中的任意两个元素都可比时,成该偏序满足全序关系。
14.2发布
为不正确发布带来风险的真正原因,是在“发布共享对象“与从”另一个线程访问它“之间,缺少happens-before排序。
14.2.1不安全的发布
在没有充分同步的情况下就发布一个对象,会导致另外的线程看到一个部分创建的对象。
新对象的初始化涉及到写入变量——新对象的域,引用的发布涉及到写入另一个变量——新对象的引用。如果不能保证发布共享引用happens-before于另外的线程加载这个共享引用,那么写入新对象的引用与写入对象域可以被重排序。这种情况下,另一个线程可以看到对象引用的最新值,不过也可以看到一些或全部对象状态的过期值——一个部分创建对象。
示例:
//这是一个不安全的发布
public class UnsafeLazyInitialization{
private static Resource resource;
public static Resource getInstance(){
if(resource == null){
resource = new Resource();
}
return resource;
}
}
除了不可变对象以外,使用被另一个线程初始化的对象,是不安全的。除非对象的发布是happens-beafore于对象的消费线程使用它。
14.2.2安全发布
如果线程A将X置入BlockingQueue,线程B从队列中获取X,可以保证B看到的X就是A留下的那个。因为在BlockingQueue的实现中,B进行了充足的内部同步,确保了put happens-before 于 take。
类似的,使用锁保护共享变量,或者使用共享的volatile类型变量,也可以保证对该变量的读取和写入是按照happens-before排序的。
happens-before事实上比安全发布承诺更强的可见性与排序性。
如果X从A到B是安全发布的,安全发布可以保证X的状态的可见性,但不包括A可能触及到的其他变量状态。
但是,如果A将X置入队列happens-before于B从队列中获取X。B不仅仅能看到X的状态是A留下的,还能看到A移交X前所作的每件事情。
为什么有了happens-before还要@GuardedBy和安全发布?
因为,从移交对象所有权和发布的角度考虑问题,相比于从独立的内存写入的可见性的角度考虑问题,能更好地适合大多数的程序设计。
14.2.3安全初始化技巧
示例:
//线程安全的惰性初始化
@ThreadSafe
public class SafeLazyInitialization{
private static Resource resource;
public synchronized static Resource getInstance(){
if(resource == null){
resource = new Resource();
}
return resource;
}
}
//主动初始化
//静态初始化的对象,无论是构造期间还是被引用的时候,都不需要显式地进行同步。然后,这仅仅适用于构造当时的状态。————如果对象是可变的,为了保证后续修改的可见性,避免数据变脏,读者和写者仍然需要同步。
@ThreadSafe
public class EagerInitialization{
private static Resource resource = new Resource();
public static Resource getResource(){
return resource;
}
}
//惰性初始化holdr类技巧
//将主动初始化于惰性初始化相结合。
public class ResourceFactory{
private static class ResourceHolder{
public static Resource resource = new Resource();
}
public static Resource getResource(){
return ResourceHolder.resource;
}
}
14.2.4双检查锁(double-checked locking)
DCL运作的方式:
首先检查在没有同步的情况下检查是否需要初始化,如果resource != null,就用它。<br />
否则,就进行同步,并再次检查Resource是否需要同步,以保证只有唯一的线程真正地初始化了共享的Resource。
DCL问题:
DCL是基于这样的假设:当没有使用同步时读取一个共享变量,可能发生的最坏的事情不过是错误地看到过期值(具体而言是null)。这种情况下,DCL方式通过占有锁后再检查一次,希望这样能够避免风险。<br />
但是,最坏情况比这还要糟糕,线程可能看到引用的当前值,但是对象的状态值却是过期的。这意味着对象可以被观察到,但却处于无效或错误的状态。
解决方法:
把resource声明为 volatile类型,DCL就可以正确的工作。
示例:
public class DoubleCheckedLocking{
//主要是为了抑制重排序
private volatile static Resource resource;
public static Resource getInstance(){
if(resource == null){
synchronized(DoubleCheckedLocking.class){
if(resource == null){
resource = new Resource();
}
}
}
return resource;
}
}
无论怎样,DCL这个技巧已经被广泛的废弃了。因为它提供的优化效果已经越来越不明显了,惰性初始化容器的模式提供了同样的好处,而且更容易理解。
14.3初始化安全性
保证了初始化安全性,就可让正确创建的不可变对象在没有同步的情况下,可以被安全地跨线程共享。
对于含有final域的对象,初始化安全性可以抑制重排序,否则这些重排序会发生在对象的构造期间以及内部加载对象引用的时刻。所有构造函数要写入值的final域,以及任何通过这些域可以到达的任何变量,都会在构造函数完成后被“冻结”。
示例:
@ThreadSafe
public class SafeStates{
private final Map<String,String> states;
public SafeStates(){
states = new HashMap<String,String>();
states.put("alaska","AK");
states.put("alabama","AL");
......
states.put("wyoming","WY");
}
public String getAbbreviation(String s){
return states.get(s);
}
}
如果SafeStates还有其他的非final域,其他线程仍然可能看到这些域上的不正确的值。
初始化安全性保证只有以final域触及的值,在构造函数完成时才是可见的。
对于通过非final域触及的值,或者创建完成后可能改变的值,必须使用同步来确保可见性。
总结
Java存储模型明确地规定了在什么时机下,操作存储器的线程的动作可以保证被另外的动作看到。
规范还规定了要保证操作是按照一种偏序关系进行排序。happens-before。
如果缺少充足的同步,线程在访问共享数据时就会发生非常无法预期的事情。
使用高层规则,比如@GuardedBy和安全发布,可以在不考虑happens-before的底层细节的情况下,也能确保线程安全性。
