目录
一. final 修饰变量
1. 基础: final 修饰基本数据类型变量和引用数据类型变量.
2. 进阶: 被 final 修饰的常量在编译阶段会被放入常量池中
3. 探索: 为什么局部 / 匿名内部类在使用外部局部变量时, 只能使用被 final 修饰的变量?
二. final 修饰方法
三. final 修饰类
final 关键字的字面意思是最终的, 不可修改的. 这似乎是一个看见名字就大概能知道怎么用的语法, 但你是否有深究过 final 在各个场景中的具体使用方法, 注意事项, 以及背后涉及的 Java 设计思想呢?
一. final 修饰变量
1. 基础: final 修饰基本数据类型变量和引用数据类型变量.
相信大家都具备基本的常识: 被 final 修饰的变量是不能够被改变的. 但是这里的 "不能够被改变" 对于不同的数据类型是有不同的含义的.
当 final 修饰的是一个基本数据类型数据时, 这个数据的值在初始化后将不能被改变; 当 final 修饰的是一个引用类型数据时, 也就是修饰一个对象时, 引用在初始化后将永远指向一个内存地址, 不可修改. 但是该内存地址中保存的对象信息, 是可以进行修改的.
上一段话可能比较抽象, 希望下面的图能有助于你理解, 你会发现虽说有不同的含义, 但本质还是一样的.
首先是 final 修饰基本数据类型时的内存示意图
如上图, 变量 a 在初始化后将永远指向 003 这块内存, 而这块内存在初始化后将永远保存数值 100.
下面是 final 修饰引用数据类型的示意图
在上图中, 变量 p 指向了 0003 这块内存, 0003 内存中保存的是对象 p 的句柄 (存放对象 p 数据的内存地址), 这个句柄值是不能被修改的, 也就是变量 p 永远指向 p 对象. 但是 p 对象的数据是可以修改的.
- // 代码示例
- public static void main(String[] args) {
- final Person p = new Person(20, "炭烧生蚝");
- p.setAge(18); // 可以修改 p 对象的数据
- System.out.println(p.getAge()); // 输出 18
- Person pp = new Person(30, "蚝生烧炭");
- p = pp; // 这行代码会报错, 不能通过编译, 因为 p 经 final 修饰永远指向上面定义的 p 对象, 不能指向 pp 对象.
- }
不难看出 final 修饰变量的本质: final 修饰的变量会指向一块固定的内存, 这块内存中的值不能改变.
引用类型变量所指向的对象之所以可以修改, 是因为引用变量不是直接指向对象的数据, 而是指向对象的引用的. 所以被 final 修饰的引用类型变量将永远指向一个固定的对象, 不能被修改; 对象的数据值可以被修改.
2. 进阶: 被 final 修饰的常量在编译阶段会被放入常量池中
final 是用于定义常量的, 定义常量的好处是: 不需要重复地创建相同的变量. 而常量池是 Java 的一项重要技术, 由 final 修饰的变量会在编译阶段放入到调用类的常量池中.
请看下面这段演示代码. 这个示例是专门为了演示而设计的, 希望能方便大家理解这个知识点.
- public static void main(String[] args) {
- int n1 = 2019; // 普通变量
- final int n2 = 2019; //final 修饰的变量
- String s = "20190522";
- String s1 = n1 + "0522"; // 拼接字符串 "20190512"
- String s2 = n2 + "0522";
- System.out.println(s == s1); //false
- System.out.println(s == s2); //true
- }
首先要介绍一点: 整数 - 127-128 是默认加载到常量池里的, 也就是说如果涉及到 - 127-128 的整数操作, 默认在编译期就能确定整数的值. 所以这里我故意选用数字 2019(大于 128), 避免数字默认就存在常量池中.
上面的代码运作过程是这样的:
首先根据 final 修饰的常量会在编译期放到常量池的原则, n2 会在编译期间放到常量池中.
然后 s 变量所对应的 "20190522" 字符串会放入到字符串常量池中, 并对外提供一个引用返回给 s 变量.
这时候拼接字符串 s1, 由于 n1 对应的数据没有放入常量池中, 所以 s1 暂时无法拼接, 需要等程序加载运行时才能确定 s1 对应的值.
但在拼接 s2 的时候, 由于 n2 已经存在于常量池, 所以可以直接与 "0522" 拼接, 拼接出的结果是 "20190522". 这时系统会查看字符串常量池, 发现已经存在字符串 20190522, 所以直接返回 20190522 的引用. 所以 s2 和 s 指向的是同一个引用, 这个引用指向的是字符串常量池中的 20190522.
当程序执行时, n1 变量才有具体的指向.
当拼接 s1 的时候, 会创建一个新的 String 类型对象, 也就是说字符串常量池中的 20190522 会对外提供一个新的引用.
所以当 s1 与 s 用 "==" 判断时, 由于对应的引用不同, 会返回 false. 而 s2 和 s 指向同一个引用, 返回 true.
总结: 这个例子想说明的是: 由于被 final 修饰的常量会在编译期进入常量池, 如果有涉及到该常量的操作, 很有可能在编译期就已经完成.
3. 探索: 为什么局部 / 匿名内部类在使用外部局部变量时, 只能使用被 final 修饰的变量?
提示: 在 JDK1.8 以后, 通过内部类访问外部局部变量时, 无需显式把外部局部变量声明为 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(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 的反编译代码.
- Compiled from "Outter.java"
- final class forTest.Outter$1 extends java.lang.Thread {
- forTest.Outter$1();
- Code:
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Thread."<init>":()V
- 4: return
- public void run();
- Code:
- 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
- 3: bipush 10
- 5: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
- 8: return
- }
定位到 run() 方法反编译代码中的第 3 行:
3: bipush 10
我们看到 a 的值在内部类的 run() 方法执行过程中是以压栈的形式存储到本地变量表中的, 也就是说在内部类打印变量 a 的值时, 这个变量 a 不是外部的局部变量 a, 因为如果是外部局部变量的话, 应该会使用 load 指令加载变量的值. 也就是说系统以拷贝的形式把外部局部变量 a 复制了一个副本到内部类中, 内部类有一个变量指向外部变量 a 所指向的值.
但研究到这里好像和 final 的关系还不是很大, 不加 final 似乎也可以拷贝一份变量副本, 只不过不能在编译期知道变量的值罢了. 这时该思考一个新问题了: 现在我们知道内部类的变量 a 和外部局部变量 a 是两个完全不同的变量, 那么如果在执行 run() 方法的过程中, 内部类中修改了 a 变量所指向的值, 就会产生数据不一致问题.
正因为我们的原意是内部类和外部类访问的是同一个 a 变量, 所以当在内部类中使用外部局部变量的时候应该用 final 修饰局部变量, 这样局部变量 a 的值就永远不会改变, 也避免了数据不一致问题的发生.
二. final 修饰方法
使用 final 修饰方法有两个作用, 首要作用是锁定方法, 不让任何继承类对其进行修改.
另外一个作用是在编译器对方法进行内联, 提升效率. 但是现在已经很少这么使用了, 近代的 Java 版本已经把这部分的优化处理得很好了. 但是为了满足求知欲还是了解一下什么是方法内敛.
方法内敛: 当调用一个方法时, 系统需要进行保存现场信息, 建立栈帧, 恢复线程等操作, 这些操作都是相对比较耗时的. 如果使用 final 修饰一个了一个方法 a, 在其他调用方法 a 的类进行编译时, 方法 a 的代码会直接嵌入到调用 a 的代码块中.
- // 原代码
- public static void test(){
- String s1 = "包夹方法 a";
- a();
- String s2 = "包夹方法 a";
- }
- public static final void a(){
- System.out.println("我是方法 a 中的代码");
- System.out.println("我是方法 a 中的代码");
- }
- // 经过编译后
- public static void test(){
- String s1 = "包夹方法 a";
- System.out.println("我是方法 a 中的代码");
- System.out.println("我是方法 a 中的代码");
- String s2 = "包夹方法 a";
- }
在方法非常庞大的时候, 这样的内嵌手段是几乎看不到任何性能上的提升的, 在最近的 Java 版本中, 不需要使用 final 方法进行这些优化了. --《Java 编程思想》
三. final 修饰类
使用 final 修饰类的目的简单明确: 表明这个类不能被继承.
当程序中有永远不会被继承的类时, 可以使用 final 关键字修饰
被 final 修饰的类所有成员方法都将被隐式修饰为 final 方法.
参考资料
- https://www.cnblogs.com/ChenLLang/p/5316662.html
- http://www.cnblogs.com/xrq730/p/4857820.html
- https://gitbook.cn/books/5c6e1937c73f4717175f7477/index.html
- http://www.cnblogs.com/xrq730/p/4844915.html
- http://www.cnblogs.com/dolphin0520/p/3811445.html
- https://www.cnblogs.com/dolphin0520/p/3736238.html
来源: https://www.cnblogs.com/tanshaoshenghao/p/10908771.html