关于final的一些细节,我有话要说—— 从内存模型中了解final|CSDN创作打卡

茫茫人海千千万万,感谢这一秒你看到这里。希望我的文章对你的有所帮助!

愿你在未来的日子,保持热爱,奔赴山海!!

题记:关于final关键字,它也是我们一个经常用的关键字,可以修饰在类上、或者修饰在变量、方法上,以此看来定义它的一些不可变性!

像我们经常使用的String类中,它便是final来修饰的类,并且它的字符数组也是被final所修饰的。但是一些final的一些细节你真的了解过吗?

从这篇文章开始,带你深入了解final的细节!

? 从内存模型中了解final

在上面,我们了解在单线程情况下的final,但对于多线程并发下的final,你有了解吗?多线程并发的话,我们又必须知道一个内存模型的概念:JMM

? JMM

JMM是定义了线程和主内存之间的抽象关系:线程之间的共享变量存在主内存(MainMemory)中,每个线程都有一个私有的本地内存(LocalMemory)即共享变量副本,本地内存中存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

而在这一内存模型下,计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。那么问题又来了,重排序是什么?

? 重排序

其实对于我们程序来说,可以分为不同指令,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。我们可以将每个指令拆分为五个阶段:

想这样如果是按顺序串行执行指令,那可能相对比较慢,因为需要等待上一条指令完成后,才能等待下一步执行:

而如果发生指令重排序呢,实际上虽然不能缩短单条指令的执行时间,但是它变相地提高了指令的吞吐量,可以在一个时钟周期内同时运行五条指令的不同阶段。

我们来分析下代码的执行情况,并思考下:

a = b + c;

d = e - f ;

按原先的思路,会先加载b和c,再进行b+c操作赋值给a,接下来就会加载e和f,最后就是进行e-f操作赋值给d。

这里有什么优化的空间呢?我们在执行b+c操作赋值给a时,可能需要等待b和c加载结束,才能再进行一个求和操作,所以这里可能出现了一个停顿等待时间,依次后面的代码也可能会出现停顿等待时间,这降低了计算机的执行效率。

为了去减少这个停顿等待时间,我们可以先加载e和f,然后再去b+c操作赋值给a,这样做对程序(串行)是没有影响的,但却减少了停顿等待时间。既然b+c操作赋值给a需要停顿等待时间,那还不如去做一些有意义的事情。

总结:指令重排对于提高CPU处理性能十分必要。但是会因此引发一些指令的乱序。那么我们的final它对指令重排序有什么作用呢?接下来我们来看看吧!

? final域重排序规则

对于JMM内存模型来说,它对final域有以下两种重排序规则:

  1. 写:在构造函数内对final域写入,随后将构造函数的引用赋值给一个引用变量,操作不能重排序。

  2. 读:初次读一个包含final域的对象的引用和随后初次写这个final域,不能重排序。

具体我们根据代码演示一边来讲解吧:

代码:

package com.ygt.test;

/**
 * 测试JMM内存模型对final域重排序的规则
 */
public class JMMFinalTest {

    // 普通变量
    private int variable;
    // final变量
    private final int variable2;
    private static JMMFinalTest jmmFinalTest;

    // 构造方法中,将普通变量和final变量进行写的操作
    public JMMFinalTest(){
        variable = 1;  // 1. 写普通变量
        variable2 = 2; // 2. 写final变量
    }

    // 模仿一个写操作 --> 假设线程A进行来写操作
    public static void write() {
        // new 当前类对象 --> 并在构造函数中完成赋值操作
        jmmFinalTest = new JMMFinalTest();
    }

    // 模仿一个读操作 --> 假设线程B进行来读操作
    public static void read() {
        // 读操作:
        JMMFinalTest test = jmmFinalTest; // 3. 读对象的引用
        int localVariable = test.variable;
        int localVariable2 = test.variable2;
    }
}

写final域重排序规则

final域重排序规则在构造函数内对final域写入,随后将构造函数的引用赋值给一个引用变量,操作不能重排序。代表禁止对final域的初始化操作必须在构造函数中,不能重排序到构造函数之外,这个规则的实现主要包含了两个方面:

  1. JMM内存模型禁止编译器把final域的写重排序到构造函数之外;
  2. 编译器会在final域写入和构造函数return返回之前,插入一个storestore内存屏障。这个内存屏障可以禁止处理器把final域的写重排序到构造函数之外。

我们再来分析write方法,虽然只有一行代码,但他实际上有三个步骤:

  1. 在JVM的堆中申请一块内存空间
  2. 对象进行初始化操作
  3. 将堆中的内存空间的引用地址赋值给一个引用变量jmmFinalTest。

对于普通变量variable来说,它的初始化操作可以被重排序到构造函数之外,即我们的步骤不是本来1-2-3吗,现在可能造成1-3-2这样初始化操作在构造函数返回后了!

而对于final变量variable2来说,它的初始化操作一定在构造函数之内,即1-2-3。

我们来看一个可能发生的图:

对于变量的可见性来说,因为普通变量variable可能会发生重排序的一个现象,读取的值可能会不一样,可能是0或者是1。但是final变量variable2,它读取的值一定是2了,因为有个StoreStore内存屏障来保证与下面的操作进行重排序的操作。

由此可见,写final域的重排序规则可以哪怕保证我们在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障

读final域重排序规则

初次读一个包含final域的对象的引用和随后初次写这个final域,不能重排序。怎么实现呢?

它其实处理器会在读final域操作的前面插入一个LoadLoad内存屏障。

我们再来分析read方法,他实有三个步骤:

  1. 初次读引用变量jmmFinalTest;
  2. 初次读引用变量jmmFinalTest的普通域变量variable;
  3. 初次读引用变量jmmFinalTest的final域变量variable2;

我们以写操作正常排序的情况,对于读情况可能发生图解:

对于读对象的普通域变量variable可能发生重排序的现象,被重排序到了读对象引用的前面,此时就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。

而对于final域的读操作通过LoadLoad内存屏障保证在读final域变量前已经读到了该对象的引用,从而就可以避免以上情况的发生。

由此可见,读final域的重排序规则可以确保我们在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用,而普通域就不具有这个保障。

? final对象是引用类型

上面我已经了解了final域对象是基本数据类型的一个重排序规则了,但是对象如果是引用类型呢?我们接着来:

final域对象是一个引用类型,写final域的重排序规则增加了如下的约束:

在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外将被构造对象的引用赋值给引用变量之间不能重排序。 听起来还是有点难懂是吧,没事,代码看看!

注意一点:之前的写final域的重排序规则一样存在,只是对引用类型对象增加了一条规则。

代码:

package com.ygt.test;

/**
 * 测试final引用类型对象时的读写情况
 */
public class ReferenceFinalTest {

    // 定义引用对象
    final Person person;
    private ReferenceFinalTest referenceFinalTest;

    // 在构造函数中初始化,并进行赋值操作
    public ReferenceFinalTest(){
        person = new Person(); // 1. 初始化
        person.setName("詹姆斯!"); // 2. 赋值
    }

    // 线程A进来进行写操作,实现将referenceFinalTest初始化
    public void write(){
        referenceFinalTest = new ReferenceFinalTest(); // 3. 初始化构造函数
    }

    // 线程B进来进行写操作,实现person重新赋值操作。
    public void write2(){
       person.setName("戴维斯"); // 4. 重新赋值操作
    }

    // 线程C进来进行读操作,读取当前person的值
    public void read(){
        if(referenceFinalTest != null) { // 5. 读取引用对象
            String name = person.getName(); // 6. 读取person对象的值
        }
    }
}

class Person{
    private String name;
    private int age;

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

首先,我们先画个可能发生情况的图解:

我们线程的执行顺序:A ——> B ——> C

接着我们对读写操作方法进行详解:

写final域重排序规则

从之前我们就知道,我们final域的写禁止重排序到构造方法外,因此1和3是不能发生重排序现象滴。

而对于我们新增的约束来说,在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外将被构造对象的引用赋值给引用变量之间不能重排序。即final域的引用对象的成员属性写入setName("詹姆斯")是不可以与随后将这个被构造出来的对象赋给引用变量jmmFinalTest重排序,因此2和3不能重排序。

所以我们的步骤是1-2-3。

读final域重排序规则

对于多线程情况下,JMM内存模型至少可以确保线程C在读对象person的成员属性时,先读取到了引用对象person了,可以读取到线程A对final域引用对象person的成员属性的写入。

可能此时线程B对于person的成员属性的写入暂时看不到,保证不了线程B的写入对线程C的可见性,因为可能线程B与线程C存在了线程抢占的竞争问题,此时的结果可能不同!

当然,如果想要保存可见,我们可以使用Volatile或者同步锁。

? 小结

我们可以根据数据类型分类:

基本数据类型:

  1. 写:在构造函数内对final域写入,随后将构造函数的引用赋值给一个引用变量,操作不能重排序。即禁止final域写重排序到构造方法之外。
  2. 读:初次读一个包含final域的对象的引用和随后初次写这个final域,不能重排序。

引用数据类型:

在基本数据类型上额外增加约束:

禁止在构造函数对一个final修饰的对象的成员域属性的写入与随后将这个被构造的对象的引用赋值给引用变量进行重排序。

?总结

相信各位看官都对final这一个关键字有了一定了解吧,其实额外扩展自己的知识面也是相当有必要滴,不然别人追问你的时候,你会哑口无言,而一旦你自己每天都深入剖析知识点后,你在今后的对答中都会滔滔不绝,绽放光芒的!!!对吧,我们还有一把东西等着我们探索和摸索中!接下来就是潜心学习一段时间,不浮躁,不气馁!

让我们也一起加油吧!本人不才,如有什么缺漏、错误的地方,也欢迎各位人才大佬评论中批评指正!当然如果这篇文章确定对你有点小小帮助的话,也请亲切可爱的人才大佬们给个点赞、收藏下吧,一键三连,非常感谢!

学到这里,今天的世界打烊了,晚安!虽然这篇文章完结了,但是我还在,永不完结。我会努力保持写文章。来日方长,何惧车遥马慢!

感谢各位看到这里!愿你韶华不负,青春无悔!

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>