记一次由 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 对象)类型时是通过“值复制传递”方式进行传递,而引用类型是通过“地址传递”方式进行传递。

相关文章
|
19天前
|
设计模式 Java 关系型数据库
【Java笔记+踩坑汇总】Java基础+JavaWeb+SSM+SpringBoot+SpringCloud+瑞吉外卖/谷粒商城/学成在线+设计模式+面试题汇总+性能调优/架构设计+源码解析
本文是“Java学习路线”专栏的导航文章,目标是为Java初学者和初中高级工程师提供一套完整的Java学习路线。
174 37
|
20天前
|
设计模式 Java 关系型数据库
【Java笔记+踩坑】设计模式——原型模式
对比原型模式和传统方式的实现思路、代码方案、优缺点,阐述原型模式的使用场景,以及深拷贝、浅拷贝等相关概念,并扩展原型模式在Spring源码中的应用。
【Java笔记+踩坑】设计模式——原型模式
|
6天前
|
JSON Java Maven
关于使用Java-JWT的笔记
这篇文章介绍了使用Java-JWT库来生成和验证JSON Web Tokens (JWT) 的方法。文中解释了JWT的组成,包括头部、载荷和签名,并提供了如何使用java-jwt库生成和验证token的示例代码。此外,还提供了Maven依赖和一些关于token的标准声明和自定义声明的解释。
关于使用Java-JWT的笔记
|
20天前
|
Java 开发者 数据格式
【Java笔记+踩坑】SpringBoot基础4——原理篇
bean的8种加载方式,自动配置原理、自定义starter开发、SpringBoot程序启动流程解析
【Java笔记+踩坑】SpringBoot基础4——原理篇
消息中间件 缓存 监控
81 0
|
20天前
|
运维 Java 关系型数据库
【Java笔记+踩坑】SpringBoot基础2——运维实用
SpringBoot程序的打包与运行、临时配置、多环境配置、日志
【Java笔记+踩坑】SpringBoot基础2——运维实用
|
20天前
|
Java 数据库连接 API
【Java笔记+踩坑】Spring Data JPA
从常用注解、实体类和各层编写方法入手,详细介绍JPA框架在增删改查等方面的基本用法,以及填充用户名日期、分页查询等高级用法。
【Java笔记+踩坑】Spring Data JPA
|
20天前
|
SQL Java 数据库连接
【Java笔记+踩坑】MyBatisPlus基础
MyBatisPlus简介、标准数据层开发CRUD、业务层继承IService、ServiceImpl、条件查询、LambdaQueryWrapper、id生成策略、逻辑删除、乐观锁@Version、代码生成器、ActiveRecord
【Java笔记+踩坑】MyBatisPlus基础
|
20天前
|
前端开发 Java 数据库连接
【Java笔记+踩坑】SpringBoot——基础
springboot三种配置文件及其优先级、多环境配置、springboot整合junit,mybatis、ssmp综合图书案例
【Java笔记+踩坑】SpringBoot——基础
|
20天前
|
Java 数据库连接 Maven
【Java笔记+踩坑】Maven高级
分模块开发、依赖传递与冲突问题、 可选依赖和排除依赖、聚合和继承、属性、多环境配置与应用、私服安装和使用
【Java笔记+踩坑】Maven高级
下一篇
无影云桌面