#星光计划2.0# 关于final的一些细节,我有话要说——深入理解final

Posted 最爱吃鱼罐头

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了#星光计划2.0# 关于final的一些细节,我有话要说——深入理解final相关的知识,希望对你有一定的参考价值。

关于final的一些细节,我有话要说

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

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

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

final的基本使用

首先你必须知道final如何去使用,才能更加深入的去了解它,剖析它。

final关键字的使用以下有三种特点:

  1. 如果一个类被声明为 final,意味着它不能再派生出新的子类,即不能被继承。
  2. 将变量声明为 final,可以保证它们在使用中不被改变。但是,被声明为final的变量必须在声明时给定初值,而在以后的引用中只能读取不可修改。
  3. 被声明为 final的方法也同样只能使用,不能在子类中被重写。

1. 修饰类

如果一个类被声明为 final,意味着它不能再派生出新的子类,即不能被继承。而在Java中,也可以看到许多类是final的,譬如String、Interger以及其他包装类。

// String类
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {...}

// Integer类
public final class Integer extends Number implements Comparable<Integer> {...}

那我们具体来看看是不是真的不能被继承呢?

package com.nz.test;

/**
 * 测试是否真的不可以修饰类!
 */
public class ClassFinalTest {
}

// 定义一个运动员父类,并使用final修饰
final class SportMan{}

// 定义一个田径运动员子类,看能不能继承父类
class AthleticsMan extends SportMan{}

我们可以代码结合图看出,在编译期间,父类会被final修饰,当子类继承该父类的时候,就会报错。

2. 修饰变量

将变量声明为 final,可以保证它们在使用中不被改变。但是,被声明为final的变量必须在声明时给定初值,而在以后的引用中只能读取不可修改。

package com.nz.test;

/**
 * 测试final修饰变量!
 */
public class VariableFinalTest {
    // 在被final修饰的变量,必须进行初始化操作。
    private final int variable;

    public static void main(String[] args) {
        final int num = 2;
        // 被final修饰的基本变量,表示不可再修改。
        num = 3;

        final Object obj = new Object();
        // 被final修饰的引用变量,也表示不可再修改。
        obj = new Object();
    }
}

我们可以看出,在未初始化变量、初始化后进行修改的变量的值、重新引用新的引用地址时,均编译期间爆红,都会报错滴!

3. 修饰方法

被声明为 final的方法也同样只能使用,不能在子类中被重写。

对于那些你认为一个方法的功能写得已经足够完善了,子类中也不需要定义改变的话,你可以声明此方法为finalfinal修饰的方法比非final修饰的方法要快一点,因为在编译的时候已经静态绑定了,不需要在运行时再动态绑定!

具体我们来看看是否真的不能重写

package com.nz.test;

/**
 * 测试final修饰方法!
 * 该类为父类,在子类继承该类时,看是否能不能重写final方法
 */
public class MethodFinalTest {

    // final修饰saySomething()方法
    public final String saySomething(){
        return "你想说点啥,湖人总冠军!!!";
    }

    // 没有被final修饰talkSomething()方法
    public String talkSomething(){
        return "我想说啥,老年夕阳红,快点红一把吧!!!";
    }
}

// 定义子类继承MethodFinalTest方法
class MethodSun extends MethodFinalTest{

    // 首先,看看能否重写未被final修饰的talkSomething()方法
    public String talkSomething(){
        return "我只能同意你的看法,老年夕阳红,快点红一把吧!!!";
    }

    // 接着,看看能否重写被final修饰的saySomething()方法
    public String saySomething(){
        return "我只能同意你的看法,湖人总冠军!!!";
    }
}

我们可以看到,我们没有talkSomething是没有被final修饰的,可以完美的重写过来,但是我们的saySomething方法被final修饰后,就不能被重写啦!

final的深入了解

关于final的特点和基本使用,我们都了解啦,但是深入final细节,你真的有了解过吗,当面试官问你,final的一些不一样的东西,你可以说上来一二吗?

不慌!现在马上来恶补下final的一些细节吧,增加知识底蕴、吹水、面试必备的呀!开冲!

深入了解修饰变量

在Java中,变量它可以分为成员变量以及方法局部变量

而对于成员变量,又可以分为类变量(静态变量)实例变量

final关键字在修饰不同类型的变量时,会些许细节上的的不同。具体的我们来看看呗!

修饰成员变量

对于每个Java类,它的成员变量可以分为类变量(静态变量)实例变量。而对于不同变量来说,其实它们在对变量初始化赋值的时间是略有不同滴!

  • 类变量:

    1. 可以在声明变量的时候可以直接初始化赋值操作

    2. 也可以在静态代码块中对类变量进行初始化赋值的操作
  • 实例变量:

    1. 可以在声明变量的时候直接初始化赋值操作
    2. 也可以在非静态代码块中对实例变量进行初始化赋值的操作
    3. 还可以在构造器中对实例变量进行初始化赋值的操作
  • 注意:
    1. 无论在何种时机下进行初始化赋值操作,最终都必须有初始化赋值的操作,不然就会抛出未初始化的编译期间的错误!
    2. final修饰成员变量后,无论在哪种时机下被初始化赋值之后,就不能再被赋值了。

我们具体代码来相见吧,听都听不懂,那就用代码来看!

类变量:

package com.nz.test;

/**
 * 对修饰类变量操作详解
 */
public class ClassVariableFinalTest {

    // 定义一个类变量即静态变量
    // 1. 可以在声明变量的时候可以直接初始化赋值操作
    private final static int classVariable = 0;

    // 2. 也可以在静态代码块中对类变量进行初始化赋值的操作
    private final static int classVariable2;

    // 但是如果你不写这个的话,classVariable2就会报未被初始化异常!
    static {
        classVariable2 = 0; 

        // classVariable被初始化值后,就不能修改值了!
        classVariable = 1; // 编译出错
    }
}

可以看到,我们对类变量进行初始化赋值的时候,进行两个不同时机的赋值操作,都是可以滴!但是对于final的特性还是一样会存在!比如未被初始化、初始化后就不能修改值了

实例变量:
接下来我们再看看实例变量中的不同时机吧!

package com.nz.test;

/**
 * 对修饰实例变量操作详解
 */
public class InstanceVariableFinalTest {

    // 定义实例变量
    // 1. 可以在声明变量的时候可以直接初始化赋值操作
    private final int instanceVariable = 0;

    // 2. 也可以在代码块中对类变量进行初始化赋值的操作
    private final int instanceVariable2;

    // 3. 还可以在构造器中对实例变量进行初始化赋值的操作
    private final int instanceVariable3;

    // 但是如果你不写这个的话,instanceVariable2就会报未被初始化异常!
    {
        instanceVariable2 = 0;

        // instanceVariable被初始化值后,一样不能修改值了!
//        instanceVariable = 1; // 编译出错
    }

    public InstanceVariableFinalTest(){
        // 但是如果你不写这个的话,instanceVariable3就会报未被初始化异常!
        instanceVariable3 = 0;
    }
}

可以看到,我们对实例变量进行初始化赋值的时候,进行三个不同时机的赋值操作,都是可以滴!但是对于final的特性还是一样会存在!比如未被初始化、初始化后就不能修改值了

修饰局部变量

修饰局部变量的特点:

  • 对于final修饰局部变量时,如果局部变量已经进行了初始化赋值,则后面就不能对局部变量再次进行更改。
  • 如果final修饰局部变量未进行初始化,则可以进行赋值,当且仅有一次赋值,一旦赋值之后再次赋值就会出错。

我们还是继续以代码来相会,唯有一看,才能更深入了解!

对于变量来说,它可以有两种数据类型修饰:引用类型和基本数据类型。接下来就以此两种类型来详细说明。

基本数据变量:

我们来看下当final修饰基本数据类型变量时,是否真的如以上特点所说的一样。

package com.nz.test;

/**
 * 对修饰局部变量操作详解
 */
public class LocalVariableFinalTest {

    // 测试局部变量
    public void localVariableTest(){
        // 定义基本数据类型变量
        final int localVariable;

        // 可以看到如果我们不去进行初始化操作,也不会有一个编译期间错误
        // 但是如果你没有进行初始化操作,就进行引用的话,就会报未被初始化错误。
        System.out.println("localVariable = " + localVariable);

        // 进行初始化赋值操作
        localVariable = 1;

        // 一旦进行初始化操作后,就不能再进行赋值操作了
        localVariable = 2;
    }
}

我们可以发现,如果我们定义了一个final局部变量的话,发现了两个问题:

  1. 我们可以不必进行初始化赋值操作也不会报错,但是呢,你不能在没有初始化赋值操作时,进行引用,因为它没有默认值,所以你必须进行初始化赋值操作后才能将其引用。
  2. 在被final修饰的局部变量,当且仅有一次赋值,一旦赋值之后再次赋值就会出错,这个跟之前修饰成员变量的错误一样滴!

引用类型变量:

通过上面的基本数据类型的案例,我们发现一旦final修饰的基本数据类型变量进行初始化赋值后,再次赋值就会出错。这代表了基本数据类型的变量是不能再被修改的,那么引用类型变量呢?它能改变吗?接下来我们就来瞧瞧吧!

package com.nz.test;

/**
 * 对修饰局部变量操作详解
 */
public class LocalVariableFinalTest {

    // 在接刚才的方法下,注意得注释掉有报错的地方!

    // 测试引用类型局部变量
    public static void main(String[] args) {

        // 定义一名篮球运动员——勒布朗詹姆斯
        final BasketballPlayer james = new BasketballPlayer(36, "勒布朗詹姆斯");

        // 接下来我们对其进行一系列操作!
        // 1. 重新重新初始化赋值!—— 可以看到,引用类型变量也是不能重新赋值
        // 要编译的话,这里得注释掉!
//        james = new BasketballPlayer(36, "老皇帝!");

        // 2. 对james里的参数进行重新赋值
        james.setName("夕阳红队队长!");

        // 可以看到没有报错!那我们试着打印一番,看名字是勒布朗詹姆斯还是夕阳红队队长呢?
        System.out.println("james = " + james.getName());

    }
}

// 定义一个篮球运动员
class BasketballPlayer {

    // 有姓名年龄属性
    private int age;
    private String name;

    // 设计一个有参构造
    public BasketballPlayer(int age, String name) {
        this.age = age;
        this.name = name;
    }

    public String getName() {
        return name;
    }

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

得到的结果:

james = 夕阳红队队长!

我们可以发现有两点问题:

  1. 我们如果要对final引用类型变量进行重新初始化赋值操作的话,一样给你报错,因为被final修饰的引用类型变量一旦初始化赋值后,其指向的对象就是不可变的,而你的new操作,是重新赋予一个新地址,这就当然不行啦!
  2. 而我们当想要对引用类型中的参数进行修改操作,即我们对final修饰的引用类型变量james中的name属性改成夕阳红队队长,是可以修改成功滴!虽然上面的引用地址不能发生变化,但是该地址下的对象属性却是可以改变滴!

小结:

final修饰的引用类型变量一旦初始化赋值后,其指向的对象是不可变的,但是该对象的属性是可变滴!

深入了解修饰方法

我们从基本使用中,知道了被final修饰的方法是不可以被重写的,那么重载可以吗?

什么,你不知道重载和重写的区别?

重写:

我们还是对重写再看看吧:

package com.nz.test;

/**
 * 测试final修饰方法!
 * 该类为父类,在子类继承该类时,看是否能不能重写final方法
 */
public class MethodFinalTest {

    // final修饰saySomething()方法
    public final String saySomething(){
        return "你想说点啥,湖人总冠军!!!";
    }

    // 没有被final修饰talkSomething()方法
    public String talkSomething(){
        return "我想说啥,老年夕阳红,快点红一把吧!!!";
    }
}

// 定义子类继承MethodFinalTest方法
class MethodSun extends MethodFinalTest{

    // 首先,看看能否重写未被final修饰的talkSomething()方法
    public String talkSomething(){
        return "我只能同意你的看法,老年夕阳红,快点红一把吧!!!";
    }

    // 接着,看看能否重写被final修饰的saySomething()方法
//    public String saySomething(){
//        return "我只能同意你的看法,湖人总冠军!!!";
//    }
}

可以看到我们是不能对被final修饰的方法进行重写。

重载:
那么重载呢?我们对MethodFinalTest类里的saySomething方法进行重载试试?

public class MethodFinalTest {

    // final修饰saySomething()方法
    public final String saySomething(){
        return "你想说点啥,湖人总冠军!!!";
    }

    // final修饰saySomething()方法
    public final String saySomething(String name){
        return "我只说5个字,湖人总冠军!!!" + name;
    }

    // 没有被final修饰talkSomething()方法
    public String talkSomething(){
        return "我想说啥,老年夕阳红,快点红一把吧!!!";
    }
}

可以看到,被final修饰的方法对重载没有任何抵抗力,是可以进行重载的。

深入了解修饰类

我们从基本使用中,知道了被final修饰的类是不可以被继承的,那么我们对于那些类才会使用到final关键字呢?这样说,什么场景才能被使用到!

对于类来说,它是可以被子类随意继承被重写父类里面的方法或者改变父类的属性,这就会导致一定的安全隐患。就好比你现在的资本资金有100W,你每天会花1000左右,但是因为你可以被继承,这导致了你资金的流失的效果加速了,导致了你的资金不安全了!

(对于我,好像不会有这种问题呀!手里只有几块钱???)

所以对于安全性,是我们考虑点!如果当一个类不希望被继承时,那我们就可以使用final来修饰。

而对于我们常使用String类,它就是被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.nz.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域的初始化操作必须在构造函数中,不能重排序到构造函数之外,这个规则的实现主要包含了两个方面:

  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域操作的前面插入一个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.nz.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域的写禁止重排序到构造方法外,因此1和3是不能发生重排序现象滴。

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

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

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

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

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

小结

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

基本数据类型:

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

引用数据类型:

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

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

完结

相信各位看官看到这里,都对final关键字的这些小细节有了一定了解和认识吧,相信我,看完这个,面试官再问你的final的时候,它一定会叹为观止滴!毕竟,咱们抠的这么细致,一定会这场面试所加分的。

到这里,咱们学这个final,其实不只是为了面试,也是我们自己积累经验,自己总结知识,提升自我的其中一个点!我们永远不能单单满足一个点的细节抠到细致,对吧,我们还有一把东西等着我们探索和摸索中!

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

以上是关于#星光计划2.0# 关于final的一些细节,我有话要说——深入理解final的主要内容,如果未能解决你的问题,请参考以下文章

#星光计划2.0# 构建HarmonyOS 3D游戏

#星光计划2.0# openHarmony轻松连接华为云物联网平台

#星光计划2.0# linux内核增加HDF驱动子系统

#星光计划2.0#Openharmony 单元测试1: 测试用例指导大全

#星光计划2.0#基于3861智能开发套件软件开发环境搭建

#星光计划2.0#HarmonyOS自定义组件之图层的使用