《java并发编程的艺术》读书笔记-第三章Java内存模型(二)

一概述

本文属于《java并发编程的艺术》读书笔记系列,第三章java内存模型第二部分。

二final的内存语义

final在Java中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。可以参照之前整理的关键字final。这里作者主要介绍final域的内存语义。

对于final域,编译器和处理器要遵守两个重排序规则:

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

下面,我们通过一些示例性的代码来分别说明这两个规则:

public class FinalExample {
    int i;                            //普通变量
    final int j;                      //final变量
    static FinalExample obj;

    public void FinalExample () {     //构造函数
        i = 1;                        //写普通域
        j = 2;                        //写final域
    }

    public static void writer () {    //写线程A执行
        obj = new FinalExample ();
    }

    public static void reader () {       //读线程B执行
        FinalExample object = obj;       //读对象引用
        int a = object.i;                //读普通域
        int b = object.j;                //读final域
    }
}

这里假设一个线程A执行writer ()方法,随后另一个线程B执行reader ()方法。下面我们通过这两个线程的交互来说明这两个规则。

2.1写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外。
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

现在让我们分析writer ()方法。writer ()方法只包含一行代码:finalExample = new FinalExample ()。这行代码包含两个步骤:

  1. 构造一个FinalExample类型的对象;
  2. 把这个对象的引用赋值给引用变量obj。

假设线程B读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序:

《《java并发编程的艺术》读书笔记-第三章Java内存模型(二)》

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程B“看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值2还没有写入普通域i)。

2.2读final域的重排序规则

读final域的重排序规则如下:

  • 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

reader()方法包含三个操作:

  1. 初次读引用变量obj;
  2. 初次读引用变量obj指向对象的普通域j。
  3. 初次读引用变量obj指向对象的final域i。

现在我们假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

《《java并发编程的艺术》读书笔记-第三章Java内存模型(二)》

在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。

2.3final域是引用类型

面我们看到的final域是基础数据类型,下面让我们看看如果final域是引用类型。

public class FinalReferenceExample {
final int[] intArray;                     //final是引用类型
static FinalReferenceExample obj;

public FinalReferenceExample () {        //构造函数
    intArray = new int[1];              //1
    intArray[0] = 1;                   //2
}

public static void writerOne () {          //写线程A执行
    obj = new FinalReferenceExample ();  //3
}

public static void writerTwo () {          //写线程B执行
    obj.intArray[0] = 2;                 //4
}

public static void reader () {              //读线程C执行
    if (obj != null) {                    //5
        int temp1 = obj.intArray[0];       //6
    }
}
}

这里final域为一个引用类型,它引用一个int型的数组对象。对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:

  1. 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

对上面的示例程序,我们假设首先线程A执行writerOne()方法,执行完后线程B执行writerTwo()方法,执行完后线程C执行reader ()方法。下面是一种可能的线程执行时序:

《《java并发编程的艺术》读书笔记-第三章Java内存模型(二)》

在上图中,1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。

JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看的到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。

2.4final语义在处理器中的实现

现在我们以x86处理器为例,说明final语义在处理器中的具体实现。

上面我们提到,写final域的重排序规则会要求译编器在final域的写之后,构造函数return之前,插入一个StoreStore障屏。读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。

由于x86处理器不会对写-写操作做重排序,所以在x86处理器中,写final域需要的StoreStore障屏会被省略掉。同样,由于x86处理器不会对存在间接依赖关系的操作做重排序,所以在x86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说在x86处理器中,final域的读/写不会插入任何内存屏障!

三双重检查锁定与优化

这里作者介绍了双重检查锁定,其实就是单例模式,参照之前我之前整理的
单例模式。 Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过。作者还介绍了类初始化的过程,可以参照原书3.8.4章节这里不再贴出来。

四 java内存模型综述

本章节偏理论,这里摘取主要几条原则。

4.1 处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

由于常见的处理器内存模型比JMM要弱,java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为java程序员呈现了一个一致的内存模型。

4.2JMM的设计

一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松,需求平衡。

JMM把happens- before要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。

下面是JMM的设计示意图:

《《java并发编程的艺术》读书笔记-第三章Java内存模型(二)》

从上图可以看出两点:

  • JMM向程序员提供的happens- before规则能满足程序员的需求。JMM的happens- before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens- before B)。
  • JMM对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个volatile变量仅仅只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

4.3JMM的内存可见性保证

Java程序的内存可见性保证按程序类型可以分为下列三类:

  1. 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  2. 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  3. 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在JMM中与在顺序一致性内存模型中的执行结果的异同:

《《java并发编程的艺术》读书笔记-第三章Java内存模型(二)》

只要多线程程序是正确同步的,JMM保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

4.4JSR-133对旧内存模型的修补

JSR-133对JDK5之前的旧内存模型的修补主要有两个:

  • 增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
  • 增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。现在,final具有了初始化安全性。
    原文作者:java内存模型
    原文地址: https://blog.csdn.net/bohu83/article/details/51706334
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞