Java 基础 (4) -- final 关键字

1. 基础

1. final 修饰基本类型和引用类型

当 final 修饰的变量是基本数据类型时,表示数值不可变,当 final 修饰的变量是引用类型时,表示引用地址值不可变,但该引用所指向的对象的内容仍然是可以被修改的,如果要保证该对象不可变,可以参考 String 类底层的实现,大概步骤就是,用 fianl 修饰该对象的引用值,保证引用不可变,用 private 修饰该引用值,保证该引用不会暴露,但此时外部是无法访问该字段的,所以此时我们可以向外部提供该对象的一些访问接口,这些访问接口的权限是只读不能修改的,最后呢,将该对象对应的类声明为 final class,保证该类不会因为被继承而遭到修改

2. final 修饰常量

final 和 static 一起使用可以用来声明常量,需要注意的是,因为常量是存在于静态常量池中的,常量的值在编译期就已经是确定了的,也就是说他不会留到类初始化的时候才确定,因此声明常量的同时需要初始化常量的值

3. final 修饰成员变量和局部变量,匿名函数,接口

final 声明成员变量时,可以同时进行初始化,也可以留到构造器中再初始化,final 声明局部变量时需要同时进行初始化,当匿名内部类需要使用到外部方法所传入的参数时,该参数需要声明为 fianl (当然我们可以不用显式声明,因为编译器会帮我们声明为 final 修饰),接口中的变量默认是用 public static fianl (也可以写成 public final static)修饰的

4. final 修饰方法和类

final 修饰的方法在编译时就已经静态绑定了,所以被 final 修饰的方法不可以被重写,因为重写是运行时多态,但可以被重载,final 修饰的类不可以被继承,像 String 类和包装类都是 final class

5. final 和 abstract 反相关

fianl 和 abstract 是反相关的,因为 final 修饰的类是不可以被继承的,而 abstract 修饰的类不可以被实例化,他需要子类去继承他,才能被使用;同理,final 修饰的方法是不可以被重写的,而 abstract 修饰的方法是需要被重写的。

6. final 空白

final 有一个很有趣的东西,final 空白,当我们用 final 声明成员变量时,先不进行初始化,而是把初始化留到构造器中进行,这样的变量就叫做 final 空白,我们可以通过不同的构造器给 final 空白赋予不同的值,甚至,我们可以直接把构造器的参数赋值给 final 空白,这样,我们就可以通过创建不同的对象来给 final 空白赋予不同的值

如:

class Test {
	private final int i; //i 就是 final 空白
	public Test(int i){
		this.i = i;
	}
}

7. 编译器优化

编译器会对 final 函数进行优化,我们都知道,调用函数的执行开销除了函数的执行时间外,还包括查找函数所需的时间,如果我们减少了函数的调用次数,那么就能减少性能开销

编译器优化涉及到了一个内嵌机制,在使用 final 修饰方法的时候,编译器会将被 final 修饰的方法的方法体直接插入到调用者代码处,进而提高了运行速度和效率,但被 final 修饰的方法体不能过大,否则编译器会放弃内嵌

当然,编译器实际上是在字节码的层面上进行优化的,上面的内容只是推理出来的

2. 进阶

1. final 与 JVM 的关系

1. 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;       // 读对象引用 1步骤
        int a = object.i;                // 读普通域 
        int b = object.j;                // 读 final 域 2步骤
    }
}
  • 写 final 域的重排序规则

    • JMM 禁止编译器把 final 域的写重排序到构造函数之外
    • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外
    • 写 final 域的重排序规则可以确保:在对象引用被任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障,如:在读线程 B“看到”对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域 i 的写操作被重排序到构造函数外,此时初始值 2 还没有写入普通域 i)
  • 读 final 域的重排序规则

    • 在一个线程中,初次读对象引用(1步骤)与初次读该对象包含的 final 域(2步骤),JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障
    • 为什么只是针对处理器:初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,所以大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器
    • 读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了
2. 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 域的重排序规则对编译器和处理器额外增加了如下约束:

  • 在构造函数内对一个 final 域的写入(1步骤),与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量(3步骤),这两个操作之间不能重排序
  • 对上面的示例程序,我们假设首先线程 A 执行 writerOne() 方法,执行完后线程 B 执行 writerTwo() 方法,执行完后线程 C 执行 reader () 方法。下面是一种可能的线程执行时序:
    • 1 步骤是对 final 域的写入,2 步骤是对这个 final 域引用的对象的成员域的写入,3 步骤是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序
    • JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入(2步骤)。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入(即4步骤)对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知
    • 如果想要确保读线程 C 看到写线程 B 对数组元素的写入(即4步骤),写线程 B 和读线程 C 之间需要使用同步原语(lock 或 volatile)来确保内存可见性

2. 如何保证学生号唯一

这个问题其实不涉及 final,要保证学生号唯一其实有很多方式,例如:使用 UUID、推特开源的雪花算法等等。

这里我用一个简单的 Java 程序来实现
代码如下:

public class Student {
    private static int count = 0; //使用static保证Student类所有对象共享
    private int id;
    private String name;

    public Student() {
        id = ++count; // 模仿主键自增
    }

    public int getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }
}

//main方法
public static void main(String args[]) throws Exception {
    Student s1 = new Student();
    Student s2 = new Student();
    Student s3 = new Student();
    Student s4 = new Student();
    Student s5 = new Student();
    System.out.println(s1.getId());
    System.out.println(s2.getId());
    System.out.println(s3.getId());
    System.out.println(s4.getId());
    System.out.println(s5.getId());
}

输出结果:
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值