从零开始学Java_第二篇 final关键字引发的思考
final 关键字在日常开发中很常用,提到 final 就会想到最终的,不可变的,它修饰的类、变量或方法有如下特点:
-
final 修饰的类无法被继承
-
final 修饰的变量无法被修改
-
final 修饰的方法无法被覆盖
下面我们来看看 final 的两道经典面试题:
-
String 类为什么要声明为 final?
-
匿名内部类使用外部局部变量,局部变量为什么要声明为 final?
String 类我们再熟悉不过了,它是 java.lang 包下非常基础的类。那 String 类为什么要声明为 final 呢?
String 声明为 final 主要是为了安全性和效率。
JDK8 中 String 类部分源码如下:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. 用于存储字符串的值*/
private final char value[];
/** Cache the hash code for the string 用于缓存字符串的hashcode值*/
private int hash; // Default to 0
/** use serialVersionUID from JDK 1.0.2 for interoperability 用于java序列化*/
private static final long serialVersionUID = -6849794470754667710L;
……
}
通过源码可以看出 String 底层是用 char 数组(JDK9 中改成 byte 数组)进行的存储的。
说到数组,我们知道 final 修饰的数组,不可变的值是数组的引用所指向的内存地址,而数组里面的元素是可以任意变化,可以增加或减少的。
但是如果 private 和 final 一起修饰数组结果就不一样了,final 保证数组引用的地址不可变,private 保证数组私有化,外部无法访问,也就无法操作数组,这样一旦赋值之后,相应数组里面的值也就固定下来。
那有人会问了,你上面的说法也没说为什么用 final 修饰 String 类型啊,以上的确是没有明确说出问什么要用 final,下面我们就从正向分析一下原因:
-
从安全角度来看
-
String 是相当基础的类,里面封装了很多底层方法,如很多都是调用本地系统的方法,方法本身不应该被修改
-
一些敏感数据如用户名密码、端口号、数据库连接等都是用 String 类型传输的,如果不用 final 修饰就意味着任何一个人都可以去继承 String 从而去修改里面的属性或者方法,一些敏感数据可能就会在传输的过程中被改变,这样可能导致产生很多安全漏洞。
-
从效率角度来看
-
正因为字符串的不可变性才有了字符串常量池的用武之地。由于字符串使用非常频繁,当多个值相同的字符串全都指向同一个常量池对象,所节省的内存空间(字符串常量池位于方法区)是不容忽视的。
-
由于字符串本身的不可变性,在多线程并发情况下,即使不加锁线程间共享字符串也是安全的,这会节省很多系统资源。
-
由于字符串的不可变性,在它创建的时其对应的 hashcode 值就确定下来,不再变化,并被缓存起来,这也会带来一些便利,如使用字符串作为 Map 中的 key,由于对应的 hashcode 值可以从缓存中获得,效率也会增加不少。
以上便是 String 为什么被声明为 final 的正向解释。
下面我们来讨论下第二个问题:
在讨论之前,首先要说明 JDK6 和 JDK8 中针对第二个问题还是有一点点不同的:
-
在 JDK6 中匿名内部类使用方法局部变量时,局部变量必须要声明为 final,否则编译报错
-
在 JDK8 中匿名内部类使用方法局部变量时,局部变量即使不声明为 final,编译也可以通过
但是这并不意味着 JDK8 有了很大的改变,两者本质上都是一样的。
接着,再看下匿名内部类在编译时发生了哪些变化:
-
初始化内部类,必须要先初始化外部类。
-
内部类名称的变化
在 JDK6 中,当编译器编译的时候,匿名内部类会生成一个名称为 外部类名$x.class 的字节码文件, 其中x为正整数。
如外部类 OutClass (只有一个匿名内部类)中匿名内部类编译后的字节码文件名叫做 OutClass$1.class。
-
内部类构造方法的变化
使用 JDK 自带的反编译工具 ,在 cmd 窗口输入如下命令:
javap -c OutClass$1.class
可以看到反编译后生成的内部类的构造方法定义为: OutClass$1(OutClass,Object)
-
第一个参数:表示外部类的类型,实际上传过来的是外部类的引用,初始化内部类时,第一个参数将外部类的引用作为参数传递进来,所以内部类可以访问外部类中属性和方法;
-
第二个参数:表示内部类中引用的外部局部变量的类型。如果内部类用到了外部类的局部变量,那编译器会将使用变量的值复制一份,传递进来。
这里又有一个疑问:
编译器为什么要复制外部变量直接使用难道不可以吗?
由于外部局部变量在相应代码块执行后就会被销毁,如果在局部变量销毁后,内部类仍然想使用局部变量,那编译器在编译的时候就会复制一份局部变量的字面值或者地址值保存起来。
正是由于这个原因,如果外部的局部变量不加 final 修饰,那么变量值在传入内部类的时候就有可能被修改而导致内部类的变量值和外部的变量值不能同步,虽然内部类拥有的是局部变量的拷贝值,但是这两者理论上应该是一致的,所以编译器认为要加 final ,内外都不允许修改,来保证语法的严密性。
JDK8 中虽然去掉了 final 修饰局部变量,但本质上和 JDK6 是一致的,也就是说如果内外类使用同一个局部变量,不进行重新赋值.则编译通过,如果内部有重新赋值,还是会编译报错。
以上便是第二题的解释。
通过这两个经典题目的分享,我们加深了对 final 关键字的理解,final 一旦赋值,就不会改变的特性可以说是被应用到了很多底层方法当中,既方便了日常开发,增加了效率,也提升了系统的稳定性和安全性。
没学够?别急,下面还有扩展知识。
扩展知识: try-with-resource 和 multiple catch
说到 final 一般会联想到 finally,虽然它们两个看上去很像,但实际上却没有任何的关系。
对于 finally,我们常用 try-catch-finally 来处理异常代码块,在 finally 中执行一些必须保证运行的代码如关闭流、回收资源等。
这样做本身是没问题的,但是代码会有些冗余,导致开发起来有些繁琐。所以在 JDK7 之后加入了一些更加方便的处理方式,如 try-with-resource 和 multiple catch 代码如下:
try(FileInputStream fis= new FileInputStream ();
FileOutputStream fos= new FileOutputStream ();
){//try-with-resource
……
}catch ( IOException | AException ){//multiple catch
……//multiple catch:可以通过 “|” 符号来catch多个异常
}
任何实现了 java.lang.AutoCloseable 接口的对象,和实现了 java.io.Closeable 接口的对象,都可以当做资源使用(放在try的 () 中)。如代码中的 FileInputStream 和 FileOutputStream 。
使用 try-with-resource 的好处是在编译期就自动生成相应的处理逻辑,如自动关闭那些实现了 AutoCloseable 和或 Closeable (AutoCloseable 是 Closeable 的父类)的对象。
关注一下,我写的就更来劲儿啦