[Java] 详细解说final关键字
final
final
可以修饰变量、方法和类,表示所修饰的内容一旦赋值之后就不会再被改变。例如String
类就是一个final
类型的类。
1.具体使用场景
1.1 变量
1.1.1 成员变量
每个类中的成员变量可以分为类变量(static
修饰的变量)以及实例变量。针对这两种类型的变量赋初值的时机是不同的。
类变量(两个赋初值时机):
- 在声明变量的时候直接赋初值
- 在静态代码块中给类变量赋初值
实例变量(三个赋初值时机):
- 可以在声明变量的时候给实例变量赋初值
- 在非静态初始化块中赋初值
- 在构造器中赋初值
初始化报错情况:
- 当
final
在变量未初始化的时候,系统不会进行隐式的初始化,则会出现报错情况 - 被
final
修饰的变量一经赋值,就不可再更改,否则报错。 - 实例变量不可以在静态初始化块中赋初值,否则报错。
- 实例方法不能为
final
类型变量赋值,否则报错
总结:
类变量:必须要在静态初始化块中指定初始值或者声明该类变量时指定初始值,而且只能在这两个地方之一进行指定;
实例变量:必要要在非静态初始化块,声明该实例变量或者在构造器中指定初始值,而且只能在这三个地方进行指定。
1.1.2 局部变量
final
局部变量由程序员进行显式初始化,如果final局部变量已经进行了初始化则后面就不能再次进行更改,如果final
变量未进行初始化,可以进行赋值,当且仅有一次赋值,一旦赋值之后再次赋值就会出错。
方法形参:
- 调用方法,给参数列表传入实参时,完成初始化
方法局部变量:
- 在使用前 进行初始化
初始化报错情况:
- 变量当且仅有一次赋值,若赋值后进行修改则会报错。
- 没有对局部变量进行初始化操作就引用,则会报错。
1.1.3 基本数据类型
同上成员变量/局部变量。
1.1.4 引用数据类型
重新初始化赋值操作,会报错。因为引用地址发生了变化。
对引用类型中的属性进行修改,可以,因为指向地址没有发生变化。
当final
修饰基本数据类型变量时,不能对基本数据类型变量重新赋值,因此基本数据类型变量不能被改变。而对于引用类型变量而言,它仅仅保存的是一个引用,final
只保证这个引用类型变量所引用的地址不会发生改变,即一直引用这个对象,但这个对象属性是可以改变的。
1.2 宏变量
宏变量是指,可以执行宏替换的变量。也就是说,编译器会将程序中用到该变量的地方全部替换成该变量的值。
利用final
变量的不可更改性,在满足一下三个条件时,该变量就会成为一个“宏变量”,即是一个常量:
- 使用
final
修饰符修饰 - 在定义该final变量时就指定了初始值
- 该初始值在编译时就能够唯一指定
宏变量已经不再是变量范畴,而是直接量。因为使用宏变量的地方,编译器会直接替换成对应的直接量。
1.3 方法
重写:
当父类的方法被 final
修饰的时候,子类不能重写父类的方法。如 Object
类中的 getClass()
方法就是 final
的,不可重写。但是 hashCode()
不是被 final
所修饰的,所以可以重写。
也就是说,被final
修饰的方法不能够被子类所重写
重载:
被 final
修饰的方法可以被重载。
1.4 类
当一个类被final
修饰时,表名该类是不能被子类继承的。是最终类。
另,
final
不允许和abstract
同时修饰一个方法或类。因为final
不允许被重写和继承,而abstract
是抽象的,没有实现,所以必须被子类继承重写。
2. 不可变类
2.1 概念
不可变类:不变类的意思是创建该类的实例后,该实例的实例变量是不可改变的。
可变类:创建该对象后,该对象的实例变量是可变的。
2.2 设计规则
不可变类有以下几个设计规则:
- 无法扩展。是最终类(将类声明为
final
) - 所有成员都是
private final
的 - 不提供对成员的改变方法,如
set
方法 - 通过构造器初始化成员,若构造器传入引用数据类型,需要进行深拷贝(复制该类的新实例而不是引用),确保类的不可变。
- 在公开修改类状态的方法时,必须始终返回该类的新实例。
- 必要时重写
hashCode
和equals
方法,保证两个equals
方法判断为相等的对象,其hashCode
也应该相等
2.3 举例
比如,java
中八个基本类型的包装类,和 String
类都属于不可变类。举例看看String
的实现:
/** The value is used for character storage. */
private final char value[];
可以看出String
的value
就是final
修饰的,上述其他几条性质也是吻合的。
2.4 优点
优点:
- 高效率
- 拷贝对象内容不用复制本身,而是复制地址,复制地址只需要很小的内存空间,具有非常高的效率。
- 保证了
hashCode
的唯一性,因此可以放心的进行缓存而不必每次重新计算新的哈希码。可以提高在HashMap
等以不可变类实例为key
的容器的性能
- 线程安全。避免值被其他进程修改的情况,同时省去了同步加锁等过程。
3. 多线程中的final
3.1 抛出问题
在java
内存模型中,为了能让编译器和处理器底层发挥他们的最大优势,所以对其的约束很少。那么处理器和编译器为了性能优化就会对指令序列有编译器和处理器的重排序操作。那么问题来了,在多线程情况下, final 会进行怎样的重排序?会导致线程安全的问题吗?
3.2 final 域的重排序规则
3.2.1 final 域为基本类型
示例代码:
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;
public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}
public static void writer() {
finalDemo = new FinalDemo();
}
public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
假设线程A在执行writer()
方法,线程B执行reader()
方法。
3.2.1.1 写操作
写final
域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
-
JMM
禁止编译器把final域的写重排序到构造函数之外; -
编译器会在
final
域写之后,构造函数return之前,插入一个storestore
屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
那么对于writer
方法,就存在的一种可能执行时序图:
因此,写final
域的重排序规则可以确保在对象引用为任意线程可见之前,对象的 final
域已经被正确的初始化过了,而普通域就不具有这个保障。(也就是说,final
可以保证正在创建中的对象不能被其他线程访问到。)
但是这其实是有个前提条件的:在构造函数中,不能让这个被构造的对象被其他线程可见。也就是说该对象不能再构造函数中"溢出".
如下代码:
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;
public FinalReferenceEscapeDemo() {
a = 1; //1
referenceDemo = this; //2
}
public void writer() {
new FinalReferenceEscapeDemo();
}
public void reader() {
if (referenceDemo != null) { //3
int temp = referenceDemo.a; //4
}
}
}
假设一个线程A执行writer
方法线程B执行reader
方法。
因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo
是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this
”逸出,该代码依然存在线程安全的问题。
3.2.1.2 读操作
读final
域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final
域,JMM
会禁止这两个操作的重排序。也就是说,确保在读一个对象的 final
域之前,一定会先读包含这个 final
域的对象的引用。(注意,这个规则仅仅是针对处理器),处理器会在读final
域操作的前面插入一个LoadLoad
屏障。实际上,读对象的引用和读该对象的final
域存在间接依赖性,一般处理器不会重排序这两个操作。
3.2.2 final 域为引用类型
3.2.2.1 写操作
针对引用数据类型,在之前基本类型的规则基础删,增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。这句话是比较拗口的,下面结合实例来看。
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;
public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}
public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}
public void writerTwo() {
arrays[0] = 2; //4
}
public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}
线程线程A执行wirterOne
方法,执行完后线程B执行writerTwo
方法,然后线程C执行reader
方法。下图就以这种执行时序出现的一种情况来讨论。
3.2.2.2 读操作
JMM
可以确保线程C至少能看到写线程A对final
引用的对象的成员域的写入,即能看下arrays[0] = 1
,而写线程B对数组元素的写入可能看到可能看不到。JMM
不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile
。
4.扩展
4.1 final、finally、finalize的区别
final
:用于声明属性,方法和类,表示属性不可变性,方法不可覆盖,类不可继承。
finally
:是异常处理语句结构的一部分,表示总是会执行。
finalize
:是Object
类中的一个方法,在垃圾收集器执行的时候,会去调用被回收对象的此方法,供垃圾收集时其他资源的回收。比如关闭文件等。
本文来自博客园,作者:knqiufan,转载请注明原文链接:https://www.cnblogs.com/knqiufan/p/16177260.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本