商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > Java编译期常量与运行时常量的区别详解

Java编译期常量与运行时常量的区别详解

  发布于2026-07-01 阅读(0)

扫一扫,手机访问

引言

聊到Ja va开发,“常量”这两个字几乎每天都离不开。从接口超时时间到业务枚举值,再到全局配置参数,把常量用好了,代码的可读性和可维护性都能上一个台阶,甚至还能顺带优化一下性能。但说实话,很多开发者只知道用final来修饰常量,却讲不清“编译期常量”和“运行时常量”到底差在哪。

Ja va编译期常量与运行时常量的区别详解

举个例子:同样是static final修饰的变量,为什么有的被引用时压根不触发类初始化?有的改个值就必须重新编译所有引用它的类?还有的加了transient却完全没效果?这些问题如果答不上来,说明对常量的理解还停留在表面。

一、什么是Ja va常量?

Ja va里的常量,说白了就是“初始化之后再也不许改的变量”,这个“不许改”靠的是final关键字——基础类型不能再赋值,引用类型不能再指向别的对象。就这么简单。

不过,按照值确定的时间点,常量其实可以分成两大类:编译期常量(Compile-time Constant)和运行时常量(Run-time Constant)。这两者在底层实现、使用规则、性能表现上差异巨大,也是面试官最爱深挖的考点。

Oracle官方文档写得非常清楚:判断常量的核心标准就是“值是否能在编译阶段确定”。后面所有的知识点都是围绕这个核心展开的,记住了这一条,后面的内容就好理解了。

二、编译期常量(Compile-time Constant)——编译时就把值定死

2.1 定义与核心特征

所谓编译期常量,就是“在Ja va代码编译阶段,编译器就能算出确切值”的常量。程序根本不用跑起来,编译器就已经知道它等于多少了,甚至还能顺手做一些优化(比如常量折叠)。

要成为编译期常量,必须同时满足以下条件,一个都不能少:

  • 修饰符:必须用 static final 一起修饰(接口里的字段默认就是 public static final,所以接口中的常量默认都是编译期常量);
  • 数据类型:只能是基本类型(byte、short、int、long、float、double、boolean、char)或 String,不能是引用类型(比如 IntegerObject、数组之类的);
  • 初始化值:必须是“编译期能算出来的常量表达式”,不能依赖运行时的计算结果(比如方法调用、new对象、随机数等);
  • 底层存储:编译后,这个值会直接嵌入到调用类的字节码里,同时也会进入方法区的运行时常量池,运行时根本不用去原类里读;
  • 类初始化:访问编译期常量时,不会触发它所在类的初始化——因为值在编译期就定死了,不需要加载类就能拿到。

2.2 合法与非法示例

合法示例(全满足,属于编译期常量):

// 1. 基本类型字面量(最常见)
public static final int MAX_AGE = 100;
public static final boolean FLAG = true;
public static final char CH = 'A';
public static final double PI = 3.1415926;
// 2. String字面量
public static final String NAME = "Ja va常量";
// 3. 编译期可计算的表达式(只包含编译期常量和合法运算符)
public static final int SUM = 10 + 20; // 编译期就算出30
public static final String COMBINE = "Hello" + "World"; // 编译期直接拼成"HelloWorld"
public static final int DIFF = MAX_AGE - 50; // 引用了别的编译期常量,照样能算
public static final boolean LOGIC = FLAG && true; // 逻辑运算也OK(但不包含instanceof、++/--)
// 4. 接口中的常量(默认public static final)
interface Constant {
    String URL = "https://xxx.com"; // 编译期常量
    int TIMEOUT = 3000;
}

非法示例(不满足条件,不是编译期常量):

// 1. 引用类型(包装类、枚举都不行)
public static final Integer NUM = 100; // Integer是引用类型,排除
public static final List LIST = new ArrayList<>(); // 引用类型,排除
public static final EnumType TYPE = EnumType.A; // 枚举也是引用类型,排除
// 2. 初始化值依赖运行时计算
public static final int RANDOM = new Random().nextInt(); // 运行时才随机,排除
public static final String UUID = UUID.randomUUID().toString(); // 方法调用,运行时才确定
public static final int CURRENT_TIME = (int) System.currentTimeMillis(); // 运行时获取时间
// 3. 缺少static修饰(只有final,没资格当编译期常量)
public final int AGE = 20; // 这是运行时常量
// 4. 使用了非法运算符(++/--)
public static final int COUNT = 10++; // ++是运行时自增,编译期算不了,直接报错

2.3 底层原理:编译期优化(常量折叠)

编译器对编译期常量有一个看家本领:常量折叠(Constant Folding)。在编译阶段,所有涉及编译期常量的表达式都会被直接算出结果,然后用这个结果替换掉原来的表达式。运行时代码根本不用再算一遍,性能自然就上去了。

来看个例子:

public class CompileConstant {
    public static final int A = 5;
    public static final int B = 10;
    public static final int C = A * B + 1; // 表达式:5*10+1
}

编译之后去反编译字节码就会发现,C 的值已经被直接替换成 51 了,原来的 A * B + 1 这个表达式直接被编译器干掉了。运行时拿到的是现成的 51,不再需要任何计算。这就是常量折叠的威力。

同理,字符串拼接也一样:"Hello" + "World" 在编译期就会直接变成 "HelloWorld",运行时根本不需要拼接操作。

2.4 关键特性:访问不触发类初始化

这是编译期常量最核心的特性,也是面试高频考点。因为值已经嵌入到调用类的字节码里了,访问时压根不需要加载常量所在的类,所以类的初始化(包括静态代码块、静态变量赋值这些)统统都不会执行。

看代码:

// 常量类
public class ConstantClass {
    // 编译期常量
    public static final String COMPILE_CONST = "编译期常量";
    // 静态代码块(类初始化时执行)
    static {
        System.out.println("ConstantClass 被初始化了");
    }
}
// 测试类
public class Test {
    public static void main(String[] args) {
        // 访问编译期常量
        System.out.println(ConstantClass.COMPILE_CONST);
    }
}

运行结果:只输出 编译期常量,完全看不到 ConstantClass 被初始化了 那句话。

原因很简单:JVM 发现这是个编译期常量,直接从当前类的字节码里就拿出了值,根本不需要去加载 ConstantClass,自然也就不会触发初始化。

三、运行时常量(Run-time Constant)——运行时才见分晓

3.1 定义与核心特征

运行时常量,顾名思义,“在程序运行阶段(类加载或者对象实例化时)才能确定最终值”。编译阶段压根不知道它等于多少,所以编译器也没法做常量折叠之类的优化。

运行时常量的特征就宽松多了,满足任意一条即可:

  • 修饰符:可以只用 final(实例常量),也可以用 static final(但初始化值依赖运行时计算);
  • 数据类型:基本类型、String、引用类型(Integer、Object、数组、枚举等)都可以;
  • 初始化值:依赖运行时的计算结果,比如方法调用、new对象、读配置文件、随机数等;
  • 底层存储:实例常量(仅 final)跟着对象走,存在堆里;静态运行时常量(static final)存在方法区的运行时常量池,但值是运行时才确定的;
  • 类初始化:访问静态运行时常量时,会触发其所在类的初始化(因为必须加载类才能确定值);实例运行时常量则需要先 new 对象(触发对象初始化)才能访问。

3.2 常见示例

// 1. 仅final修饰的实例常量(运行时常量)
public class RuntimeConstant {
    // 实例常量,每次new对象时初始化,每个对象可以不同
    public final int INSTANCE_CONST;
    // 在构造方法里初始化(运行时才确定)
    public RuntimeConstant(int value) {
        this.INSTANCE_CONST = value;
    }
}
// 2. static final修饰,但初始化值依赖运行时计算
public class RuntimeConstant2 {
    // 运行时常量:值由方法调用确定(运行时算)
    public static final int RANDOM_NUM = new Random().nextInt(100);
    // 运行时常量:值从配置文件读(运行时加载)
    public static final String CONFIG_VALUE = readConfig("config.key");
    // 运行时常量:引用类型(枚举)
    public static final EnumType TYPE = EnumType.B;
    // 运行时常量:包装类(引用类型)
    public static final Integer WRAP_NUM = 100;
    // 静态代码块(访问时会触发)
    static {
        System.out.println("RuntimeConstant2 被初始化了");
    }
    // 模拟读配置文件的方法(运行时执行)
    private static String readConfig(String key) {
        return "config_value";
    }
}
// 3. 局部final变量(也是运行时常量)
public class RuntimeConstant3 {
    public void test() {
        // 局部final变量,方法执行时初始化,属于运行时常量
        final int LOCAL_CONST = 100;
        // 局部final变量,值由参数决定(运行时传入)
        final String LOCAL_STR = new String("局部常量");
    }
}

3.3 关键特性:访问触发类/对象初始化

与编译期常量正好相反,运行时常量的值需要在运行时才能拿到,所以访问它时一定会触发对应的初始化:

  • 静态运行时常量(static final):访问时触发所在类的初始化(静态代码块、静态变量赋值都会执行);
  • 实例运行时常量(仅 final):必须先 new 对象(触发对象初始化),才能访问这个常量。

验证一下:

public class Test {
    public static void main(String[] args) {
        // 访问静态运行时常量,触发类初始化
        System.out.println(RuntimeConstant2.RANDOM_NUM);
    }
}

运行结果:

RuntimeConstant2 被初始化了
45(随机值,每次可能不同)

原因很清楚:RANDOM_NUM 的值是通过 new Random().nextInt(100) 在运行时算出来的,所以访问它时必须先加载 RuntimeConstant2 类,自然就触发了类初始化,静态代码块也就跟着执行了。

四、编译期常量与运行时常量 核心区别

为了方便对比,整理了一张表格,把两者在各维度上的差异讲得明明白白。

对比维度

编译期常量

运行时常量

核心定义

编译阶段确定值,编译器可优化

运行阶段确定值,编译器无法优化

修饰符要求

必须是 static final 共同修饰

可仅 final,也可 static final(值依赖运行时)

数据类型

仅基本类型 + String类型

基本类型、String、引用类型(枚举、包装类等)均可

初始化值要求

编译期可计算的常量表达式(字面量、合法运算)

可依赖运行时计算(方法调用、new对象、配置读取等)

底层存储

值嵌入调用类字节码,同时存入运行时常量池

静态:运行时常量池;实例:堆内存

类初始化触发

访问时不触发所在类初始化

静态:访问时触发类初始化;实例:new对象时触发

编译器优化

支持常量折叠,减少运行时开销

无优化,运行时计算值

transient修饰效果

无效(编译期常量会被直接嵌入字节码,不受transient影响)

有效(引用类型的运行时常量,加transient可排除序列化)

修改后影响范围

修改后需重新编译所有引用类(否则引用旧值)

修改后仅需重新编译自身类,引用类无需重新编译

典型使用场景

全局固定值(如PI、接口地址、枚举字面量)

动态配置(如配置文件读取、随机值、对象唯一标识)

编译期常量:编译时确定值,不触发类初始化,可优化,修改需全量编译;
运行时常量:运行时确定值,触发初始化,无优化,修改仅需编译自身。

五、如何选择两种常量?

很多开发者习惯性地到处用 static final,结果踩了不少坑——比如“常量修改了却不生效”、“莫名其妙触发类初始化”等等。归根结底,就是没选对常量类型。下面结合实际开发,给出明确的选型建议。

5.1 优先使用编译期常量的场景

  • 值固定不变,而且在编译期就能确定(比如数学常量、固定的业务基准值、接口地址);
  • 需要被多个类引用,且希望减少运行时开销(常量折叠能提升性能);
  • 不希望触发类初始化(比如工具类里的常量,避免无谓的类加载)。

示例:工具类中的常量定义

public class MathUtil {
    // 编译期常量:固定不变,可优化
    public static final double PI = 3.1415926;
    public static final int DEFAULT_SCALE = 2;
    public static final String EMPTY_STR = "";
}

5.2 优先使用运行时常量的场景

  • 值需要动态确定(比如从配置文件读、数据库查、随机生成、方法返回值);
  • 常量是引用类型(枚举、包装类、数组、对象);
  • 每个对象需要独立的常量值(比如对象唯一标识、实例级别的固定配置);
  • 常量值可能会修改,又不想每次改完都重新编译所有引用类(降低维护成本)。

示例:配置类中的运行时常量

public class ConfigConstant {
    // 运行时常量:从配置文件读取(动态确定值)
    public static final String DB_URL = ConfigLoader.load("db.url");
    public static final int DB_PORT = Integer.parseInt(ConfigLoader.load("db.port"));
    // 运行时常量:引用类型(枚举)
    public static final DataSourceType DATA_SOURCE_TYPE = DataSourceType.MYSQL;
    // 实例运行时常量:每个对象独立值
    public final String INSTANCE_ID;
    public ConfigConstant(String instanceId) {
        this.INSTANCE_ID = instanceId;
    }
}

六、注意事项

1:误以为“static final修饰的都是编译期常量”

这个误区非常普遍。有人觉得只要加上了 static final,就一定是编译期常量,其实不然。

// 错误:以为这是编译期常量,其实是运行时常量
public static final Integer NUM = 100;
public static final String UUID = UUID.randomUUID().toString();

原因在于:Integer 是引用类型,UUID 的值由方法调用确定(运行时),所以两个都是运行时常量。访问它们会触发类初始化,也不会被常量折叠。

判断是否为编译期常量,不能只看修饰符,还得看“数据类型”和“初始化值是否能在编译期确定”。

2:编译期常量修改后,引用类未重新编译,导致旧值残留

场景:类A定义了编译期常量 MAX_NUM = 100,类B引用了 A.MAX_NUM。后来把A类的常量改成 200,但只重新编译了A类,没编译B类。结果运行时B类拿到的还是旧值 100。

原因:编译期常量的值会嵌入到引用类的字节码里。B类编译时,字节码里已经写死了 100。A类改了之后,如果不重新编译B类,B类永远用的是嵌入的那个旧值。

避坑方案:修改编译期常量后,必须重新编译所有引用它的类。如果常量值可能频繁修改,最好直接改成运行时常量(比如从配置文件读取)。

3:用transient修饰编译期常量,误以为能排除序列化

public class SerializeTest implements Serializable {
    // 错误:transient对编译期常量无效
    private transient static final String SECRET = "123456";
}

原因:编译期常量的值是直接嵌入字节码的,transient 根本拦不住它。序列化后依然能获取到这个值。

正确做法:如果真想排除某个常量的序列化,不要指望 transient(它对编译期常量无效)。可以实现 Externalizable 接口,手动控制序列化逻辑,不写这个字段。

4:局部final变量误认为是编译期常量

public void test() {
    final int a = new Random().nextInt();
    // 错误:以为a是编译期常量,其实是运行时常量
    System.out.println(a + 10);
}

原因:局部 final 变量的初始化值如果依赖运行时计算,那它就是运行时常量,编译器不会做常量折叠。只有初始化值是字面量或编译期可计算表达式时,才会被优化。

5:接口中的常量不是编译期常量

interface MyConstant {
    // 错误:以为这是编译期常量,其实是运行时常量
    String CONFIG = readConfig();
    static String readConfig() {
        return "config";
    }
}

原因:接口中的常量默认是 public static final,但初始化值 readConfig() 是方法调用(运行时确定),所以是运行时常量。访问时会触发接口的初始化。

七、全文总结

1. 两种常量的核心区别:值确定的时机(编译期 vs 运行期);

2. 编译期常量:static final + 基本类型/String + 编译期表达式,不触发类初始化,支持常量折叠;

3. 运行时常量:可以仅 final,支持引用类型,值依赖运行时计算,触发初始化;

4. 坑点核心:不要混淆 static final 和编译期常量,修改编译期常量需要全量编译;

5. 选型原则:固定值用编译期,动态值用运行期;

6. 面试关键:类初始化触发、常量折叠、transient 效果、修改后影响范围。

编译期常量与运行时常量,乍看是个很基础的概念,但背后藏着JVM底层的优化逻辑和不少开发细节。大厂面试中,这道题往往是区分“初级开发者”和“中级开发者”的分水岭。

很多开发者因为分不清这两者,吃过“常量修改不生效”“类初始化异常”“序列化漏洞”之类的亏。把这篇文章吃透,基本就能避开所有高频坑了。

本文转载于:https://www.jb51.net/program/364185nzc.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注