Java并发探索之final探秘

众所周知,final 关键字在 java 语言中可以用来修饰类、方法和成员变量:final 关键字修饰一个类,表示这个类不能被继承;final 关键字修饰的方法不能被重写;final 关键字修饰的成员变量必须要赋初始值,而且是只能初始化一次。不过本文的重点并不在这,而是讲解 final 在并发领域中的应用,总结起来就是两个关键词:重排序逸出

tips:Java 中有两个词叫做发布(publish)和逸出(Escape),发布是指发布一个对象,使对象能够在当前作用域之外的代码中使用。而逸出和发布是相对的,即当某个不应该发布的对象被发布时,这种情况就被称为逸出。(什么,没听懂,好吧,我再举个栗子,二师兄年少不经事,妈妈总说要做完作业才能出去玩,如果二师兄听妈妈的话,做完作业才出去玩,那就会得到妈妈爱的抱抱。然而二师兄天性顽皮,只要妈妈一不留神就溜出去玩了,后果就是遭受毒打,没做完作业就出去玩这里对应的就是对象没初始化完成就被引用了,遭受毒打对应的就是引用到了还没初始化完成的对象导致出问题了。final 关键词的作用就是说,先把门锁死,做完作业了才放出去玩。)

final 域的重排序规则

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

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

2、初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

 

/**
 * @author SpringRoot
 * @create 2019-11-08 21:01
 */
public class FinalExample {
    int i;                            //普通变量
    final int j;                      //final变量
    static FinalExample obj;

    public FinalExample() {           //构造函数开始(2)
        i = 1;                        //写普通域i(3)
        j = 2;                        //写final域y(4)
    }                                 //构造函数结束(5)

    public static void writer() {
        obj = new FinalExample();     //构造一个FinalExample类型 的对象(1);把构造对象的引用赋值给引用变量obj(6)
    }

    public static void reader() {
        FinalExample object = obj;    //读对象引用obj(7)
        int a = object.i;             //读对象的普通域i(8)
        int b = object.j;             //读对象的final域y(9)
    }
}
		    代码1.1

1、代码 1.1 包含了写 final 域和读 final 域,接下来用这段代码分别分析写 final 域和读 final 域的重排序规则。
2、writer( )方法只包含一行代码: obj = new FinalExample(),这行代码包含两个步骤,如下:
1)构造一个 FinalExample 类型 的对象(1)。
2)把构造对象的引用赋值给引用变量 obj(6)。
3、reader()方法包含 3 个操作:
1)初次读引用变量 obj。(7)
2)初次读引用变量 obj 指向对象的普通域 j。(8)
3)初次读引用变量 obj 指向对象的 final 域 i。(9)
4、这里假设一个线程 A 执行 writer()方法,随后另一个线程 B 执行 reader()方法。

写 final 域的重排序规则

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

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

上图中,线程 B 读对象引用与读对象的成员域之间没有重排序,写线程 A 发生了重排序。

  • 绿色箭头表示 final 变量正确读取初始化之后的值,因为写 final 域的操作被写 final 域的重排序规则限定在了构造函数之内,线程 B 可以正确地读取。
  • 红色箭头表示普通变量 i错误地读取了初始化之前的值。这是因为写普通域的操作被编译器重排序到了构造函数之外,导致被线程 B 错误地读取了。

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

读 final 域的重排序规则

读 final 域的重排序规则是:

  • 在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作,编译器会在读 final 域操作的前面插入一个LoadLoad屏障。
  • 初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器的。

 

上图中,写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,而线程 B 读对象引用与读对象的成员域之间发生了重排序。

  • 绿色箭头表示 final 变量正确读取初始化之后的值,因为读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,线程 B 可以正确地读取。
  • 红色箭头表示普通变量 i 错误地读取了初始化之前的值。这是因为读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,导致被线程 B 错误地读取了。

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

为什么 final 引用不能从构造函数内“逸出”

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

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample () { //构造函数开始(2)
        i = 1;                              //写final域(3)
        obj = this;                         //this引用在此“逸出”(4)
    }                                       //构造函数结束(5)

    public static void writer() {
        new FinalReferenceEscapeExample (); //构造一个FinalExample类型的对象(1)
    }

    public static void reader {
        if (obj != null) {                  //读取不为null的对象引用(6)
            int temp = obj.i;               //读final域(7)
        }
    }
}

假设线程A执行writer()方法,线程B执行reader()方法。

  • 红色箭头表示读final域(7)错误地读取了初始化之前的值。这是因为这里的操作(4)使得对象还未完成构造前就为线程B可见。即使这里的操作(4)是构造函数的最后一步,且在程序中操作(4)排在操作(3)后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的操作(3)和操作(4)之间可能被重排序,导致 final 域在被正确初始化之前对象引用就暴露了,从而在线程B的 reader 中访问到未初始化的 final 域。

final 语义在处理器中的实现

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

JSR-133 为什么要增强 final 的语义

在旧的 Java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整型 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变。

为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 Java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有逸出),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。

总结

final关键词在并发中禁止重排序依赖的就是内存屏障

  • 写final域:编译器会在 final 域的写之后,构造函数return之前,插入一个StoreStore屏障。
  • 读final域:在一个线程中,初次读对象引用与初次读该对象包含的 final 域,编译器会在读 final 域操作的前面插入一个LoadLoad屏障。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值