记一次由 final 引发的小意外 | Java Debug 笔记

简介: 记一次由 final 引发的小意外 | Java Debug 笔记

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 方法再重新赋值的情况:
  1. 首先 outside 变量先指向一个 ArrayList 对象的堆内存地址,举例为 0x00ff4233
  2. outside 传递到方法内,由于是引用类型,所以进行的是“地址传递”。这时候 inside 和 outside 变量都指向 0x00ff4233 堆内存地址
  3. 使用 inside 调用 add 方法,实际上是操作 0x00ff4233 内存里的 ArrayList 对象,和外部变量 outside 是同一对象,所以对外部变量产生了影响
  4. 重新 new 一个 ArrayList 对象(在堆内存重新申请了 ArrayList 对象的新地址),并将 inside 指向它
  • 对于先重新赋值再调用 add 方法的情况:
  1. 首先 outside 变量先指向一个 ArrayList 对象的堆内存地址,举例为 0x00ff4233
  2. outside 传递到方法内,由于是引用类型,所以进行的是“地址传递”。这时候 inside 和 outside 变量都指向 0x00ff4233 堆内存地址
  3. 直接重新 new 一个 ArrayList 对象(在堆内存重新申请了 ArrayList 对象的新地址),并将 inside 指向它,这时候相当于丢失了对原地址的引用
  4. 使用 inside 调用 add 方法,实际操作的是新创建的 ArrayList 对象,不是刚开始传入的外部变量


总结:方法的形参是否用 final 修饰,不是决定外部变量能否被修改的原因。外部变量能否被修改是由 Java 的参数传递机制决定的。该机制限定了当传递的是基本数据类型(包括其包装类型)或是字符串(字面量或是 String 对象)类型时是通过“值复制传递”方式进行传递,而引用类型是通过“地址传递”方式进行传递。

相关文章
|
1月前
|
存储 缓存 安全
除了变量,final还能修饰哪些Java元素
在Java中,final关键字不仅可以修饰变量,还可以用于修饰类、方法和参数。修饰类时,该类不能被继承;修饰方法时,方法不能被重写;修饰参数时,参数在方法体内不能被修改。
28 2
|
2月前
|
Java 开发工具 Android开发
Kotlin语法笔记(26) -Kotlin 与 Java 共存(1)
本系列教程笔记详细讲解了Kotlin语法,适合需要深入了解Kotlin的开发者。若需快速学习Kotlin,建议查看“简洁”系列教程。本期重点介绍了Kotlin与Java的共存方式,包括属性、单例对象、默认参数方法、包方法、扩展方法以及内部类和成员的互操作性。通过这些内容,帮助你在项目中更好地结合使用这两种语言。
53 1
|
2月前
|
Java 开发工具 Android开发
Kotlin语法笔记(26) -Kotlin 与 Java 共存(1)
Kotlin语法笔记(26) -Kotlin 与 Java 共存(1)
37 2
|
23天前
|
安全 Java 编译器
Kotlin教程笔记(27) -Kotlin 与 Java 共存(二)
Kotlin教程笔记(27) -Kotlin 与 Java 共存(二)
|
23天前
|
Java 开发工具 Android开发
Kotlin教程笔记(26) -Kotlin 与 Java 共存(一)
Kotlin教程笔记(26) -Kotlin 与 Java 共存(一)
|
1月前
|
Java 编译器 Android开发
Kotlin教程笔记(28) -Kotlin 与 Java 混编
Kotlin教程笔记(28) -Kotlin 与 Java 混编
32 2
|
22天前
|
Java 数据库连接 编译器
Kotlin教程笔记(29) -Kotlin 兼容 Java 遇到的最大的“坑”
Kotlin教程笔记(29) -Kotlin 兼容 Java 遇到的最大的“坑”
40 0
|
1月前
|
安全 Java 编译器
Kotlin教程笔记(27) -Kotlin 与 Java 共存(二)
Kotlin教程笔记(27) -Kotlin 与 Java 共存(二)
|
1月前
|
Java 开发工具 Android开发
Kotlin教程笔记(26) -Kotlin 与 Java 共存(一)
Kotlin教程笔记(26) -Kotlin 与 Java 共存(一)
|
1月前
|
Java 编译器 Android开发
Kotlin教程笔记(28) -Kotlin 与 Java 混编
Kotlin教程笔记(28) -Kotlin 与 Java 混编