《菜鸟读并发》java内存模型之final

final 关键字的字面意思是最终的,不可修改的。这似乎是一个看见名字就大概知道怎么用的语法,但你是否有深究过final在各个场景中的具体用法,注意事项,以及背后涉及的Java设计思想呢?

final可以修饰什么

  1. 成员变量
  2. 方法

被final修饰的类并不可以被继承,所以到这里我们就能知道String类是不能被继承的。而且被final 修饰的类所有成员方法都将被隐式修饰为final方法。

感觉被final修饰像是被加上一把锁一样,被禁锢了一样。所以在设计类的时候如果真的是不想这个类被其他 类继承或者考虑一些安全因素,否则尽量不要把类设计成final的。

以上三种情况我们一个一个来看下被final修饰有什么作用

final修饰成员变量

对于变量我们知道分为两种:

  1. 基础数据类型
  2. 引用数据类型
  3. 调方法时基本类型传值对象传引用

我们应该都知道被final修饰的变量是不能够被改变的。但是这里的"不能够被改变"对于不同的数据类型是有不同的含义的。

  • 当 final 修饰的是一个基本数据类型数据时(八种基础数据类型:byte,short,int,long,char,float,double,boolean),这个数据的值在初始化后将不能被改变;

  • 当 final 修饰的是一个引用类型数据时,也就是修饰一个对象时,引用在初始化后将永远指向一个内存地址,不可修改。但是该内存地址中保存的对象信息,是可以进行修改的。

上一段话可能比较抽象,希望下面的图能有助于你理解,你会发现虽说有不同的含义,但本质还是一样的。

  1. 比如我们有一个final修饰的基础数据类型a
final int a = 100;

变量 a 在初始化后将永远指向003这块内存,而这块内存在初始化后将永远保存数值 100。

  1. 比如我们有一个final修饰的引用数据类型
final Car car = new Car(“白色”,“高配”,“敞篷”);

他的内存示意图如下所示:

这里我们先要关注几个名词:变量,对象的引用(句柄),对象的实际数据

在上图中,变量 car 指向了 0003 这块内存,0003 内存中保存的是对象car的句柄【引用】(存放对象car数据的内存地址),这个句柄值是不能被修改的,也就是变量 car永远指向car对象引用,car对象的引用会指向car对象的实际数据,car对象的实际数据是可以修改的。

不难看出 final修饰变量的本质:final修饰的变量会指向一块固定的内存,这块内存中的值不能改变。

引用类型变量所指向的对象之所以可以修改,是因为引用变量不是直接指向对象的数据,而是指向对象的引用。所以被 final 修饰的引用类型变量将永远指向一个固定的对象,不能被修改;对象的数据值可以被修改。

进阶思考:final修饰的成员变量和普通变量有什么区别吗?

final 是用于定义常量的,定义常量的好处是:不需要重复地创建相同的变量。

而常量池是 Java 的一项重要技术,由 final 修饰的变量会在编译阶段放入到调用类的常量池中。

举个例子:
final String b = "大师兄";       
String c = "大师兄";

看了上面的问题我们很容易得出结论,不就是b不可被修改,而c可以被修改嘛?那我们看下面的代码输出结果是什么

public class Test {   
    public static void main(String[] args) { 
        int n1 = 2019; //普通变量
        final int n2 = 2019;  //final修饰的变量

        String s = "20191215";//普通变量

        String s1 = n1+"1215";//普通变量拼接
        String s2 = n2+"1215";//final变量拼接

        System.out.println(s == s1); //false
        System.out.println(s == s2); //true
        System.out.println(n1 == n2); //true

        System.out.println("______测试2_____");
        
        String b = "大师兄";//普通变量
        final String c ="大师兄";//final修饰的变量

        String a = "大师兄1";   //普通变量

        String e = b + 1;//普通变量拼接
        String d = c + 1;//final变量拼接
        
        System.out.println(a == e);//false
        System.out.println(a == d);//true
        System.out.println(c == b); //true

    }
}

运行结果:

false
true
true
______测试2_____
false
true
true

温馨提示:整数 -127 - 128 是默认加载到常量池里的,也就是说如果涉及到 -127 - 128 的整数操作,默认在编译期就能确定整数的。所以这里我故意选用数字2019(大于128),避免数字默认就存在常量池中。

我们看一下编译好的class文件:

上面的代码运作过程是这样的:

  1. 由 final 修饰的变量n2会在编译阶段放入到调用类的常量池中,就是2019放入了常量池中

  2. n1没有加final,所以对应的数据没有放入常量池中,运行的时候发现2019已经存在常量池中,n1直接指向2019的内存地址,所以n1和n2指向同一个内存地址

  3. 然后普通s变量所对应的"20191215"字符串会放入到字符串常量池中,并对外提供一个引用返回给s变量,s变量在程序执行时,才具体指向引用(这里注意一下和基本数据类型的区别,只要String编译的时候就能确定的值是会直接放入字符串常量池中的)

  4. s1表达式中的变量 n1 = 2019 需要等程序加载运行时才能确定s1对应的值

  5. 这时候拼接字符串s1,由于n1对应的数据没有放入常量池中,所以 s1暂时无法拼接,需要等程序加载运行时才能确定 s1 对应的值

  6. 当运行的时候知道了n1的值,然后拼接s1,会创建一个新的String类型对象,也就是说字符串常量池中的20190522 会对外提供一个新的引用

  7. 所以当 s1 与 s用"=="判断时,由于对应的引用不同, 会返回 false。而 s2 和s指向同一个引用,返回true。

  8. 在拼接 s2 的时候,由于n2已经存在于常量池,所以可以直接与"1215"拼接,拼接出的结果是"20191215", 这时系统会查看字符串常量池,发现已经存在字符串20191215,所以直接返回20191215的引用。

  9. 所以 s2 和s指向的是同一个引用,这个引用指向的是字符串常量池中的20190522。

  10. 测试2部分也是一样的道理,自己尝试解读一下吧!

final变量和普通变量的区别?

比如上面的b是被final修饰的成员变量,所以当用到b的时候可以直接替换成b的值,也就是说这里的

String d = b + 1;

其实直接就等价于

String d = "大师兄" + 1;

这样d的值也就是“大师兄1”,就像你说的e的值也是“大师兄1”啊,但是这里不同的是什么呢?

对于d这种在编译期就可以确定下来的值,也就是说在编译阶段已经确定d就是字符串“大师兄1”,那么这个值就会被放到字符串常量池中,但是对于e就不一样了,因为在编译阶段b是一个变量,也就是说在编译阶段并不能确定e的值,必须等到运行时才能确定e的值是“大师兄1”,然后拼接,会创建一个新的String类型对象,但是这个时候这个值因为不是在编译阶段确定下来的就不会被放到字符串常量池中,a和e的引用是不同的,自然就是false!

知识点小结:
  • 对于被final修饰的变量,在用的时候是可以直接替换的,因为其本身是固定不可变的
  • 只有在编译阶段确定下来的字符串才是会被放到字符串常量池中的
  • 由于被 final修饰的常量会在编译期进入常量池,如果有涉及到该常量的操作,很有可能在编译期就已经完成
探索: 为什么局部/匿名内部类在使用外部局部变量时,只能使用被 final 修饰的变量?
public class Outter {   
    public static void main(String[] args) { 
  
    final int a =10;
    new Thread(){
        @Override
        public  void run(){
            System.out.println("Test2.run————————"+a);
        }
    }.start();
        
        
    }
}

在上面这段代码, 如果没有给外部局部变量 a 加上 final 关键字,是无法通过编译的。可以试着想想:当 main 方法已经执行完后,main方法的栈帧将会弹出,如果此时 Thread对象的生命周期还没有结束,还没有执行打印语句的话,将无法访问到外部的 a 变量。

那么为什么加上 final 关键字就能正常编译呢?

我们通过查看反编译代码看看内部类是怎样调用外部成员变量的。

我们可以先通过javac编译得到.class文件(用IDE编译也可以),然后在命令行输入javap-c.class文件的绝对路径,就能查看 .class 文件的反编译代码。

以上的 Outter类经过编译产生两个.class文件,分别是 Outter.class和Outter$1.class

也就是说内部类会单独编译成一个.class文件。

下面给出 Outter$1.class的反编译代码。

系统以拷贝的形式把外部局部变量a复制了一个副本到内部类中,内部类有一个变量指向外部变量a所指向的值。

但研究到这里好像和 final 的关系还不是很大,不加 final 似乎也可以拷贝一份变量副本,只不过不能在编译期知道变量的值罢了。这时该思考一个新问题了:

现在我们知道内部类的变量a和外部局部变量a是两个完全不同的变量,

那么如果在执行 run() 方法的过程中, 内部类中修改了 a 变量所指向的值,就会产生数据不一致问题。

正因为我们的原意是内部类和外部类访问的是同一个a变量,所以当在内部类中使用外部局部变量的时候应该用 final 修饰局部变量,这样局部变量a的值就永远不会改变,也避免了数据不一致问题的发生。

提示: 在JDK1.8以后,通过内部类访问外部局部变量时,无需显式把外部局部变量声明为final。不是说不需要声明为final了,而是这件事情系统在编译期间帮我们做了。 但是我们还是有必要了解为什么要用 final 修饰外部局部变量。

final修饰方法

《java编程思想》中的定义:使用final方法的原因有两个。第一个原因是把方法锁定,不能被重载覆盖,以防任何继承类修改它的含义;第二个原因是效率。在早期的Java实现版本中,会将final方法转为内嵌调用,因为我们知道当调用一个方法时,系统需要进行保存现场信息,建立栈帧,恢复线程等操作,这些操作都是相对比较耗时的。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升。在最近的Java版本中,不需要使用final方法进行这些优化了。

被final修饰的方法可以总结如下几点
  • 在子类中不能被覆盖了
  • 可以实现重载(注意实现重载是可以的)
  • 子类依旧可以正常调用
进阶:

前面我们讲 volatile为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?

这个可以有,就是final关键字。final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。

问题类似于之前提到的利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到final变量的值会变化。

当然了,在 1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量global.obj,这就是“逸出”,线程通过 global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。


 final int x; 
 // 错误的构造函数 
 public FinalFieldExample() { 
     x = 3; 
     y = 4; 
     // 此处就是讲 this 逸出, 
     global.obj = this; 
 }
 

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

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

final 域的重排序规则

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

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

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

/**
 * @author 二师兄
 * @create 2019-10-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 包含了写 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 域的写重排序到构造函数之外

图1

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

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

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

读 final 域的重排序规则

读 final 域的重排序规则是:

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

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

    图2

图 2 中,写线程 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屏障。

如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

原创不易,欢迎转发,关注公众号“码农进阶之路”,获取更多面试题,源码解读资料!
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值