Java内存模型与线程

主要参考《深入理解Java虚拟机》和《Java Concurrency in Practice》、以及各种官方非官方文档的的总结。

概述

硬件效率与一致性

  为了解决处理器与内存之间的速度矛盾,引入了基于高速缓存的存储交互
  但高速缓存的引入也带来了新的问题:缓存一致性,即多处理器中,每个处理器有各自的高速缓存,而他们又共享同一主内存。当多个处理器的运算任务额都涉及同一块主存区域的时候,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那么同步回到主存时以谁的缓存数据为准呢?
  为了解决一致性的问题,需要各个处理器在访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。在本章中将会多次提到“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问时的过程抽象。不同的物理机器可以拥有不同的内存模型。而Java虚拟机也拥有自己的内存模型,并且在这里的内存访问操作与硬件的访问操作具有很高的可比性。

Java内存模型

  Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在JDK1.5(实现了JSR-133)发布后,Java内存模型已经成熟和完善起来了。

主内存和工作内存

  Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量不包括局部变量和参数,因为其实线程私有的,不会被共享(但局部引用变量所指向的对象仍然是可共享的),自然不会存在竞争问题。
  Java内存模型规定了所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以相互类比,但此处仅是虚拟机内存的一部分),每条线程还有自己的工作内存(可与高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量(包括volatile变量也是这样)。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
《Java内存模型与线程》

内存间交互操作

  关于主内存和工作内存之间具体的交互协议:即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double、long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)。

  • lock
  • unlock
  • read
  • load
  • use
  • assign
  • store
  • write

       如果要把一个变量从主内存复制到工作内存,那么就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意:Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说read和write之间是可以插入其他指令的,如对主内存的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了在执行上述8中基本操作时所必须满足如下规则:

  • 不允许read和load、store和write之一单独出现,即不允许一个变量从主内存读取了但工作内存不接收,或者是从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未初始化(load或assign)的变量。换言之,就是对于一个变量实施use、store操作之前,必须先执行过assign和load操作。
  • 一个变量在同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中的此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前必须先把该变量同步回主内存中(执行store、write操作)。

       这八种内存访问操作以及上述规则限定,再加上稍后介绍的对volatile的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但确实比较繁琐,实践起来比较麻烦,所以后面会介绍这种定义的一个等效判断原则————先行发生原则,用来确定一个访问在并发环境下是否安全。

对于volatile型变量的特殊规则

  关键字可以说是Java虚拟机提供的最轻量级的同步机制,但它并不是很容易完全被正确、完整地理解。以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争的时候一律使用synchronized来进行同步。
  当将一个变量定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能保证这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另一条线程B在线程A回写完成了之后在从主内存进行读取操作,新变量的值才会对线程B可见。第二是禁止指令重排序优化,普通的变量仅仅会保证在该方法执行的过程中所有依赖赋值的结果都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知这点,这就是Java内存模型当中所描述的“线程内表现为串行”的语义(Within Thread As-If Serial Semantics)。

1.可见性

  关于volatile可见性:volatile变量是对所有线程可见的,对volatile变量所有的写操作都能立刻反映到其他线程之中,换言之,volatile变量在各个线程中是一致的。但一致并不代表基于volatile变量的运算在并发下是安全的。volatile变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但在每次使用之前都要先刷新,执行引擎看不到不一致的情况,所以可以认为不存在一致性的问题),但Java里面的运算并非是原子操作,导致volatile;变量在并发下一样是不安全的。比如下例:

/**
 * volatile变量自增测试运算
 * @author xpc
 * @date 2018年12月16日下午8:40:02
 */
public class VolatileTest {
    public static volatile int race=0;
    public static void increase() {
        race++;
    }
    public static final int THREADCOUNT=20;
    public static void main(String[] args) {
        Thread[] threads=new Thread[THREADCOUNT];
        for(int i=0;i<THREADCOUNT;i++) {
            threads[i]=new Thread(()->{
                for(int j=0;j<10000;j++)
                    increase();
                });
            threads[i].start();
        }
        while(Thread.activeCount()>1) {
            Thread.yield();
        }
        System.out.println(race);//最后打印的结果是小于20*10000即200000的数
    }
}

其自增部分对应的字节码为

  public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    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 11: 0
        line 12: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature

  之所以最后输出的结果小于200000,并且每次运行程序输出的结果都不一样。问题就出现在自增运算race++上,反编译后发现一个race++会产生4条字节码指令(不包括return),从字节码层面很容易分析出并发失败的原因:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值加大了,而在操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存之中。
  这里使用字节码来分析并发问题仍然是不严谨的,因为即使编译出来只有一条字节码指令,也不意味这执行这条指令就是一个原子操作。一个字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令可能转化为若干条本地机器码指令。
  由于volatile只能保证可见性,在不符合以下两条规则的运算场景中,仍然要通过加锁(使用synchronized或者java.util.concurrent中的原子类)来保证原子性。同时满足以下两条规则的运算场景才适合使用volatile去保证原子性

  • 运算结果并不依赖变量的当前值,或者,能够确保只有单一线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

满足第一条但不满足第二条的一个例子:

volatile static int start = 3;
volatile static int end = 6;
//只有线程B修改变量的值,满足了第一条。尽管不满足运算结果不依赖变量的当前值,false||ture==ture

线程A执行如下代码:
while (start < end){
//do something
}

线程B执行如下代码:
start+=3;
end+=3;

适合使用volatile来控制并发的场景的例子,当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立即停止下来。

volatile boolean shutdownRequested;

public void shutdown(){
    shutdownRequested=ture;
}

public void doWork(){
    while(!shutdownRequested){
        //do stuff
    }
}

2.禁止指令重排序

指令重排序干扰程序的并发执行的例子

Map configOptions;
char[] configText;
//此变量必须定义为volatile
volatile boolean initialized=false;

//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完后将initialized设置为true以通知其他线程配置可用
configOptions=new HashMap();
configText=readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized=true;

//假设以下代码在线程B中执行
//等待initialized为true,代表线程A已经把配置信息处理化完成
while(!initialized){
    sleep();
}
//使用线程A初始化好的配置信息
doSomethingWithConfig();

  在这个例子中,如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中的最后一句的代码initialized=true;被提前执行(虽然使用java代码来作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是值这句话对应的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。
  在对于jdk1.5之前的volatile关键词而言,它只禁止了volatile变量指令之间的重排序,但在jdk1.5(JSR-133后)之后的volatile关键词,不光禁止了volatile变量指令之间的指令重排序,还对volatile变量指令与非volatile变量指令之间的重排序加强了约束。这种加强后的约束具体原理是由内存屏障来实现的,这里我们不需要知道它的具体实现,只要知道它的实现效果是什么。
  JSR-133增强了volatile的内存语义后的效果就是:严格限制编译器(在编译器)和处理器(在运行期)对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义(这也就是其达成的效果,正符合下面要讲到的happens-before规则)。在《Java Concurrency In Practice》中也有同样更完善的表述:volatile变量对可见性的影响比volatile变量本身(的可见性)更为重要。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值(这些可见的值都是由后面的happens-before规则所确定的),在B读取了volatile变量后,对B也是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。

Under the new memory model, it is still true that volatile variables cannot be reordered with each other. The difference is that it is now no longer so easy to reorder normal field accesses around them. Writing to a volatile field has the same memory effect as a monitor release, and reading from a volatile field has the same memory effect as a monitor acquire.

Effectively, the semantics of volatile have been strengthened substantially, almost to the level of synchronization. Each read or write of a volatile field acts like “half” a synchronization, for purposes of visibility.

  因此,在JSR133后的也就是现在的新的内存模型下,因为有着happens-before规则(包含了volatile所保证的可见性与指令重排序),DCL单例模式就能够是有效的了(虽然还是不推荐使用,因为有着更好的做法)

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;
    }
}

总结:在我的理解里,volatile只保证了可见性,当然这个可见性是广义的,并不单指volatile变量本身的可见性,还有上面提到的写入时所能见到的所有其他变量的值(这些可见的值都是由后面的happens-before规则所确定的)的可见性。所谓的禁止指令重排序也只是为了保证这个这个更广义的可见性的扩广部分。当然也可以说volatile变量具有狭义的可见性(仅其本身)和禁止指令重排序优化(实际上不是禁止而是严格约束,保证了扩广部分的可见性)。
  此外加锁机制既可以保证可见性又可以确保原子性,而volatile变量只能确保可见性。volatile是直接作用在变量上的,也就是说对于任何语句都是起效的。但锁不同,锁时直接作用在语句块上的,访问同样的变量,这些加了锁的语句块在被访问时是起效的,但那些没被加了锁的语句访问时,因为锁不在上面,自然就没有同步的效果。所以一旦加了锁的语句块和未加锁的语句块并发访问的是同一个变量的时候,就并不能保证可见性了。一个很好地例子就是下面的代码:虽然set方法进行了同步,但get方法没有进行同步。一个读一个写,并且两者之间没有happens-before关系,所以是存在数据竞争情况的,也就是说还是可能会看到失效值。

public class MutableInteger{
    private int value;
    public int get(){ return value;}
    public synchronized void set(int value){ this.value=value; }
}

3.使用volatile的开销

  在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难量化地认为volatile就会比synchronized快多少。 如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。 不过即便如此,大多数场景下volatile的总开销仍然要比锁低,我们在volatile与锁之中选择的唯一依据仅仅是volatile的语义能否满足使用场景的需求。

4.普通变量与volatile变量的比较

  对于非volatile变量,每当虚拟机遇到一个需要使用到变量值的字节码指令的时候会执行use原子操作,当工作内存中不包含该变量值的时候就需要按顺序执行read、load、use系列原子操作,如果工作内存中已经存在该变量的值了,那么就不会再执行read、load重新从主内存中读取该变量了(哪怕主内存中该变量的值已经被修改了);当虚拟机遇到一个给变量赋值的字节码指令的时候回执行assign操作,对于assign操作,则一定会有按顺序的assign、store、write。注意上面都是说的按顺序但并不是连续的,在指令重排序优化的情况下中间可能穿插着其他指令。
  
  对于volatile变量,虚拟机遇到同样的字节码指令,read、load、use/assign、store、write这些原子操作一定是按顺序连续发生的。这样就保证了立即可见性。立即可见既指能够立刻见到其他线程对该volatile变量的最新更新(读操作),也能让该线程对volatile变量的更新立即被其他线程所见(写操作)。
  但注意立即可见性和原子性也是两码事(比如可见性里面那个例子),这也是为什么上面会有关于volatile变量保证原子性使用场景的规则了,因为粗略地说,当只有是volatile变量只是被赋值如flag=true并且仅被作为单一条件时如while(flag)时的时候才能保证原子性,因为这些操作对应的就只是按序连续的原子操作(并不包含自增或者比较之类的其他操作),相当于一个整个大的原子操作。所以在Java并发编程实战里也举了个使用volatile变量的典型用法:检查某个状态标记以判断是否退出循环

使用volatile变量的例子:

volatile boolean asleep;
...
    while(!asleep){
        countSomeSheep();

5.对volatile变量定义的特殊规则

  假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read, load, use, assign, store和write时需要满足以下三条规则:

  • 只有当线程T对变量V执行的前一个动作是load时,T才能对V执行use; 并且,只有当T对V执行的后一个动作是use时,T才能对V执行load。T对V的use动作可以认为是和线程T对V的load,read动作相关联,必须连续一起出现(这条规则要求 在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)。
  • 只有当线程T对变量V执行的前一个动作是assign时,T才能对V执行store动作;并且,只有当T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可认为是和线程T对变量V的store,write动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)。
  • 假定动作A是线程T对变量V实施的use或assign操作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的变量W的read或write动作。如果A先于B,那P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。

对long和double型变量的特殊规则

  java内存模型允许可以将没有被volatile修饰的64位的数据的读写操作(load、store、read、write)划分为两次32位的操作来进行,这样的话,多线程并发,就会存在线程可能读取到“半个变量”的值,也就是非原子性协定。不过,这种情况非常罕见,目前各平台的商用虚拟机几乎都选择把64位的读写作为原子操作来实现规范的,这也是Java内存模型锁强烈建议的。
  因此,虽然你知道了java内存模型对long和double型的变量定义了特殊规则,但是你也不用专门对这两种类型的变量声明为volatile,因为上面说了,没有虚拟机真的这样实现了。

原子性、可见性、有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,我们来看下哪些操作实现了这3个特性。

原子性(atomicity):
  由Java内存模型来直接保证原子性变量操作包括read, load, assign, use, store和write。大致可以认为基本数据类型的访问读写是具有原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足需求,尽管虚拟机没有把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

  顺便贴下《Java Concurrency In Practice》中对于原子性的定义:

  假定有两个操作A B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行。那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

可见性(visibility):
  可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存、在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点。
  除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把”this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

有序性(Ordering):
  Java程序天然的有序性可以总结为一句话:如果本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
  Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

  小结:综上所述,synchronized关键字在需要这3种特性的时候都可以作为其中一种的解决方案,看起来是比较万能的,也的确大部分的并发控制操作都能使用synchronized来完成。这种万能也造成了被程序员所滥用,越万能也伴随着越大的性能影响。

先行发生原则

  在Java语言中有一个“先行发生”(happens-before)的原则,有了这个原则我们就可以通过其中的规则来一揽子地解决并发环境下两个操作之间可能存在的冲突的所有问题。
  Java 内存模型是通过各种操作来定义,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程启动和合并操作。JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行操作B的线程能看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。
  上面的结果一词包括:修改了内存中共享变量的值、发送了消息、调用了方法等。

  数据竞争:当一个变量被多个线程读取并且至少一个线程写入时,如果在读操作写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确的同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中所有操作都会按照一种固定的和全局的顺序执行。

Two accesses to (reads of or writes to) the same variable are said to be conflicting if at least one of the accesses is a write.When a program contains two conflicting accesses that are not ordered by a happens-before relationship, it is said to contain a data race.

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。 准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、 循环等结构。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、 Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

下面会对前三条规则来进行举例或者说明,从而理解先行发生这几条规则的含义:

1.程序次序规则

  对于程序顺序规则需要注意到的是要在同一个线程内,不是同一个线程之中的两个操作是无法直接通过程序次序规则来判断出具有happens-before关系的。

private int value=0;

public void setValue(){
    this.value=value;
}

public int getValue(){
    return value;
}

 上述代码中显示的一普通的getter/setter方法,假设存在线程A和线程B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程A收到的返回值是多少?
  依次分析下先行发生原则的各项规则,由于两个方法分别由线程A和线程B调用,不再一个线程中,所以程序次序规则在这里不适用;没有同步块,自然也就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile 关键字修饰,所以volatile变量不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条的传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上优于B,但无法确定线程B中的getValue()方法的返回结果,换句话说,这里面的操作是线程不安全的。
  那么如何修复该问题呢?以下两种方法均可:

  1. 把getter和setter方法都定义为synchronized方法,这样就可以套用管程锁定规则。
  2. 把value定义为volatile变量,由于setter对value方法的修改不依赖原值,并且也没有与其他的状态变量共同参与不变约束,满足volatile变量使用场景,加上之后就能套用volatile变量规则来实现先行发生关系。

  另外有个例子,可以更具体地说明先行发生与实际执行时间先后的关系:即实际执行时间的先后关系有时可以根据某些规则推导出对应的先行发生关系,如第二条、第三条规则。但先行发生关系并不能确定时间上的先后,除非在先行发生关系就是根据执行时间的先后来确定的(就是前一句所说的)或者先行发生关系的后者操作一定会观察前者操作的结果(或者成为造成的影响)(当然有文章说cpu有着预测处理机制,所以即使观察了也可能是先执行的)

//以下操作在同一个线程中执行
int i=1;
int j=2;

  上述代码的赋值语句在同一个线程当中,根据程序次序规则,int i=1的操作先行发生于int j=2。但是先行发生的含义是后者操作能够观察到前者操作的结果。能够是一种能力,后者操作不一定会去观察,那么如果后者操作没有观察的话(比如本例代码中),即使先行发生关系的后者操作实际上先执行也没有关系,还是符合先行发生原则的。总而言之,先行发生关系描述的是一种后者操作能够见到前者操作结果的一种能力,而不是说后者操作一定要见到前者操作的结果(如果后者操作真的要观察才会一定观察到前者操作的结果)。打个比喻就是你有伤人的能力,但不是说你一定会去伤人。
  在此例中,int j=2完全可能先被处理执行,但这并不影响先行发生原则的正确性,因为我们在这条线程之中没办法感知到这点,如果换成int j=i当然就不一样了。

2.管程锁定规则

《Java内存模型与线程》
  上图给出了当两个线程使用同一个锁(M)进行同步时,在它们之间的Happens-Before关系。在线程A内部的所有操作都按照它们在源码程序中的先后顺序来排序,在线程B内部的操作也是如此。由于A释放了锁M,并且B随后获得了锁M,因此A在释放锁之前的操作,也就位于B请求锁之后的所有操作之前。
  如果这两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为在这两个线程的操作之并不存在Happens-Before关系。

3.volatile变量规则

  对于volatile变量的读和写而言,如果在实际执行时间上有写在读前的话(如线程A的assign在先于线程B的use执行,参看对volatile变量定义的特殊规则第三条),那么就有写在读前的先行发生关系,这样就保证了一切对于volatile变量写操作可见的变量(即happens before volatile写操作的其他变量操作所造成的一切影响),对于后面的volatile变量读操作也是可见的。如果换成可普通变量,即使是有时间上的写在读前,但如不是同一线程就没有happens关系,这样就不能保证可见性。

传递性规则

  在实际编程中,通常让我们要判断的是两个对同一个变量读写访问方法之间的是否具有happens-before关系,也就是说一系列操作与另一系列操作之前的happens-before关系,但先行发生原则中一些规则只是确定了两个操作之间的先行发生关系,这个时候就有可能要用到传递性来判断两个操作系列之间的先行发生关系。
参考文章:
双重检查锁定与延迟初始化

    原文作者:java内存模型
    原文地址: https://segmentfault.com/a/1190000017395235
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞