final 基本用法
先来大致回顾一下 final 的作用:
- 当使用 final 修饰类的时候,该类不能被继承
- 当使用 final 修饰方法的时候,该方法不能够被子类覆盖
- 当使用 final 修饰变量的时候,该变量不能被二次赋值
由 final 引发的意外
日常开发中经常会使用到常量类,类中的常量通常会使用 final 进行修饰。
在一次常规版本更新时,部署完成之后才想起来要修改一个不太重要的常量。于是直接通过修改常量类的 .class 文件的方式修改常量。
但是后来通过日志发现,程序中仍然使用的是修改前的常量值。
静下来思考和查阅资料发现,问题在于修改的常量是使用 final 修饰。 Java 编译器会认为使用 final 修饰的基本类型和 String 类型是稳定态(Immutable Status),于是将这些 final 修饰的基本类型和 String 类型的值直接编译到使用它们的类的字节码中。以避免运行时引用(Runtime Reference)。
// 常量类 public class Constants { public final static String TOKEN_PREFIX = "token:"; } // 使用到常量的类 public class Main { public static void main(String[] args) { System.out.println(Constants.TOKEN_PREFIX); } } 复制代码
也就是在编译 Main 类的时候,由于使用到的 Constants.TOKEN_PREFIX 是经过 final 修饰的。所以会直接将该变量的值("token:")直接编译进 Main.class 文件,而不会在运行时再通过 Constants 类引用获取值。这里有点像 C 语言中的宏替换,是 Java 针对 final 做的合理优化。
但是由于我的 “偷懒” ,想直接通过修改常量类的 .class 文件类更新常量。这时候的效果相当于我只是修改了 Constants 类的源代码并重新编译 Constants 类,而没有重新编译使用到 Constants.TOKEN_PREFIX 的 Main 类。所以这时候 Main.class 没有任何变化,使用的仍然是旧的常量值。
结论,final 变量和普通变量的区别为:当 final 变量是基本数据类型以及 String 类型时,如果在编译期间能知道它的确切值,则编译器会把它当做编译期常量使用。也就是说在用到该 final 变量的地方,相当于直接访问的这个常量,不需要在运行时确定。以及当源码发生修改还是重新打包发布更加稳妥。
final 的其他注意事项
- 使用 final 修饰不能保证所指向的对象中的内容不变
当使用 fianl 修饰对象的时候,只能保证变量不能再指向其他对象,但不能保证所指向的对象中的内容不变:
public static void main(String[] args) { final List<String> list = new ArrayList<String>(); // list = new ArrayList<>(); // 不允许指向新的对象 list.add("1"); // 允许修改对象中的内容 } 复制代码
- final & static 区别
static 主要强调变量的存储位置。当类被加载的时候,JVM 会为所有被 static 修饰的内容分配空间(存储),内存中只有一个拷贝,不会被分配多次。变量的地址不会发生改变,但是变量指向的内容可变。
final 主要强调的是可变性。当变量被 final 修饰之后,该变量不能指向其他内容。
- final 不是导致参数不变的原因
不少人有这么一个习惯,就是出于防止在方法内修改了外部变量的目的。喜欢使用 final 对方法的形参进行修饰。
但其实变量 final 不是导致参数不变的原因,我们从基本类型、String类型和引用类型几个方面进行测试。
先看看基本类型 & String 类型:
public class Main { public static void main(String[] args) { TestFinalClass testFinalClass = new TestFinalClass(); // 测试基本数据类型 int int i = 30; System.out.println(i); // 打印 30 testFinalClass.testIntMethod(i); System.out.println(i); // 打印 30 // 使用包装类型 Integer integer = new Integer(30); System.out.println(integer); // 打印 30 testFinalClass.testIntegerMethod(integer); System.out.println(integer); // 打印 30 // 测试字面量 string String string = "origin string"; // String string = new String("origin string"); // 测试 String 对象,打印结果一致 System.out.println(string); // 打印 origin string testFinalClass.testStringMethod(string); System.out.println(string); // 打印 origin string } } class TestFinalClass { public void testIntMethod(int i) { i++; } public void testIntegerMethod(Integer integer) { integer = new Integer(60); } public void testStringMethod(String string) { string = "change string"; } } 复制代码
由此可知,无论是使用基本数据类型或是其包装类型,字面量的字符串还是 String 对象。外部变量转换为实参传递给方法都是通过“值复制传递”的方式,无论方法形参是否使用 final 修饰都不影响外部变量。
只是使用 fianl 修饰时,会对方法内修改的代码报语法错误。
再看看引用类型:
public class Main { public static void main(String[] args) { TestFinalClass testFinalClass = new TestFinalClass(); List<String> list = new ArrayList<String>(); list.add("1"); System.out.println(list); // 打印 [1] testFinalClass.testMethod(list); // testFinalClass.testFinalMehod(list); // 打印结果一致 System.out.println(list); // 打印 [1, 2] } } class TestFinalClass { public void testMethod(List list) { list.add("2"); } public void testFinalMehod(final List list) { list.add("2"); } } 复制代码
无论形参是否被 final 修饰,方法内都可以通过调用 List 的方法来实现对外部变量进行修改,说明引用类型的参数传递是通过“地址传递”。
只是使用 fianl 修饰时,无法对变量进行二次赋值(让 list 指向一个新的对象)。
再来看看如果是在非 final 修饰形参的方法里,重新赋值 list 会发生什么:
public class Main { public static void main(String[] args) { TestFinalClass testFinalClass = new TestFinalClass(); List<String> outside = new ArrayList<String>(); outside.add("1"); System.out.println(outside); testFinalClass.testMethod(outside); System.out.println(outside); } } class TestFinalClass { public void testMethod(List inside) { // inside = new ArrayList<String>(); // 先重新赋值再调用 add 方法,打印结果是 [1] 和 [1] inside.add("2"); inside = new ArrayList<String>(); // 先调用 add 方法再重新赋值,打印结果是 [1] 和 [1, 2] } } 复制代码
为什么会出现这样的结果?在调用 add 方法的前后重新赋值会导致结果不一致?
其实这还是因为对于参数是引用类型的时候,是使用“地址传递”的方式所致的:
- 对于先调用 add 方法再重新赋值的情况:
- 首先 outside 变量先指向一个 ArrayList 对象的堆内存地址,举例为 0x00ff4233
- outside 传递到方法内,由于是引用类型,所以进行的是“地址传递”。这时候 inside 和 outside 变量都指向 0x00ff4233 堆内存地址
- 使用 inside 调用 add 方法,实际上是操作 0x00ff4233 内存里的 ArrayList 对象,和外部变量 outside 是同一对象,所以对外部变量产生了影响
- 重新 new 一个 ArrayList 对象(在堆内存重新申请了 ArrayList 对象的新地址),并将 inside 指向它
- 对于先重新赋值再调用 add 方法的情况:
- 首先 outside 变量先指向一个 ArrayList 对象的堆内存地址,举例为 0x00ff4233
- outside 传递到方法内,由于是引用类型,所以进行的是“地址传递”。这时候 inside 和 outside 变量都指向 0x00ff4233 堆内存地址
- 直接重新 new 一个 ArrayList 对象(在堆内存重新申请了 ArrayList 对象的新地址),并将 inside 指向它,这时候相当于丢失了对原地址的引用
- 使用 inside 调用 add 方法,实际操作的是新创建的 ArrayList 对象,不是刚开始传入的外部变量
总结:方法的形参是否用 final 修饰,不是决定外部变量能否被修改的原因。外部变量能否被修改是由 Java 的参数传递机制决定的。该机制限定了当传递的是基本数据类型(包括其包装类型)或是字符串(字面量或是 String 对象)类型时是通过“值复制传递”方式进行传递,而引用类型是通过“地址传递”方式进行传递。