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

举个例子:同样是static final修饰的变量,为什么有的被引用时压根不触发类初始化?有的改个值就必须重新编译所有引用它的类?还有的加了transient却完全没效果?这些问题如果答不上来,说明对常量的理解还停留在表面。
Ja va里的常量,说白了就是“初始化之后再也不许改的变量”,这个“不许改”靠的是final关键字——基础类型不能再赋值,引用类型不能再指向别的对象。就这么简单。
不过,按照值确定的时间点,常量其实可以分成两大类:编译期常量(Compile-time Constant)和运行时常量(Run-time Constant)。这两者在底层实现、使用规则、性能表现上差异巨大,也是面试官最爱深挖的考点。
Oracle官方文档写得非常清楚:判断常量的核心标准就是“值是否能在编译阶段确定”。后面所有的知识点都是围绕这个核心展开的,记住了这一条,后面的内容就好理解了。
所谓编译期常量,就是“在Ja va代码编译阶段,编译器就能算出确切值”的常量。程序根本不用跑起来,编译器就已经知道它等于多少了,甚至还能顺手做一些优化(比如常量折叠)。
要成为编译期常量,必须同时满足以下条件,一个都不能少:
static final 一起修饰(接口里的字段默认就是 public static final,所以接口中的常量默认都是编译期常量);String,不能是引用类型(比如 Integer、Object、数组之类的);// 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 ListLIST = 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++; // ++是运行时自增,编译期算不了,直接报错
编译器对编译期常量有一个看家本领:常量折叠(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",运行时根本不需要拼接操作。
这是编译期常量最核心的特性,也是面试高频考点。因为值已经嵌入到调用类的字节码里了,访问时压根不需要加载常量所在的类,所以类的初始化(包括静态代码块、静态变量赋值这些)统统都不会执行。
看代码:
// 常量类
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,自然也就不会触发初始化。
运行时常量,顾名思义,“在程序运行阶段(类加载或者对象实例化时)才能确定最终值”。编译阶段压根不知道它等于多少,所以编译器也没法做常量折叠之类的优化。
运行时常量的特征就宽松多了,满足任意一条即可:
final(实例常量),也可以用 static final(但初始化值依赖运行时计算);final)跟着对象走,存在堆里;静态运行时常量(static final)存在方法区的运行时常量池,但值是运行时才确定的;// 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("局部常量");
}
}
与编译期常量正好相反,运行时常量的值需要在运行时才能拿到,所以访问它时一定会触发对应的初始化:
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 类,自然就触发了类初始化,静态代码块也就跟着执行了。
为了方便对比,整理了一张表格,把两者在各维度上的差异讲得明明白白。
对比维度 | 编译期常量 | 运行时常量 |
核心定义 | 编译阶段确定值,编译器可优化 | 运行阶段确定值,编译器无法优化 |
修饰符要求 | 必须是 | 可仅 |
数据类型 | 仅基本类型 + String类型 | 基本类型、String、引用类型(枚举、包装类等)均可 |
初始化值要求 | 编译期可计算的常量表达式(字面量、合法运算) | 可依赖运行时计算(方法调用、new对象、配置读取等) |
底层存储 | 值嵌入调用类字节码,同时存入运行时常量池 | 静态:运行时常量池;实例:堆内存 |
类初始化触发 | 访问时不触发所在类初始化 | 静态:访问时触发类初始化;实例:new对象时触发 |
编译器优化 | 支持常量折叠,减少运行时开销 | 无优化,运行时计算值 |
transient修饰效果 | 无效(编译期常量会被直接嵌入字节码,不受transient影响) | 有效(引用类型的运行时常量,加transient可排除序列化) |
修改后影响范围 | 修改后需重新编译所有引用类(否则引用旧值) | 修改后仅需重新编译自身类,引用类无需重新编译 |
典型使用场景 | 全局固定值(如PI、接口地址、枚举字面量) | 动态配置(如配置文件读取、随机值、对象唯一标识) |
编译期常量:编译时确定值,不触发类初始化,可优化,修改需全量编译;
运行时常量:运行时确定值,触发初始化,无优化,修改仅需编译自身。
很多开发者习惯性地到处用 static final,结果踩了不少坑——比如“常量修改了却不生效”、“莫名其妙触发类初始化”等等。归根结底,就是没选对常量类型。下面结合实际开发,给出明确的选型建议。
示例:工具类中的常量定义
public class MathUtil {
// 编译期常量:固定不变,可优化
public static final double PI = 3.1415926;
public static final int DEFAULT_SCALE = 2;
public static final String EMPTY_STR = "";
}
示例:配置类中的运行时常量
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;
}
}
这个误区非常普遍。有人觉得只要加上了 static final,就一定是编译期常量,其实不然。
// 错误:以为这是编译期常量,其实是运行时常量 public static final Integer NUM = 100; public static final String UUID = UUID.randomUUID().toString();
原因在于:Integer 是引用类型,UUID 的值由方法调用确定(运行时),所以两个都是运行时常量。访问它们会触发类初始化,也不会被常量折叠。
判断是否为编译期常量,不能只看修饰符,还得看“数据类型”和“初始化值是否能在编译期确定”。
场景:类A定义了编译期常量 MAX_NUM = 100,类B引用了 A.MAX_NUM。后来把A类的常量改成 200,但只重新编译了A类,没编译B类。结果运行时B类拿到的还是旧值 100。
原因:编译期常量的值会嵌入到引用类的字节码里。B类编译时,字节码里已经写死了 100。A类改了之后,如果不重新编译B类,B类永远用的是嵌入的那个旧值。
避坑方案:修改编译期常量后,必须重新编译所有引用它的类。如果常量值可能频繁修改,最好直接改成运行时常量(比如从配置文件读取)。
public class SerializeTest implements Serializable {
// 错误:transient对编译期常量无效
private transient static final String SECRET = "123456";
}
原因:编译期常量的值是直接嵌入字节码的,transient 根本拦不住它。序列化后依然能获取到这个值。
正确做法:如果真想排除某个常量的序列化,不要指望 transient(它对编译期常量无效)。可以实现 Externalizable 接口,手动控制序列化逻辑,不写这个字段。
public void test() {
final int a = new Random().nextInt();
// 错误:以为a是编译期常量,其实是运行时常量
System.out.println(a + 10);
}
原因:局部 final 变量的初始化值如果依赖运行时计算,那它就是运行时常量,编译器不会做常量折叠。只有初始化值是字面量或编译期可计算表达式时,才会被优化。
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底层的优化逻辑和不少开发细节。大厂面试中,这道题往往是区分“初级开发者”和“中级开发者”的分水岭。
很多开发者因为分不清这两者,吃过“常量修改不生效”“类初始化异常”“序列化漏洞”之类的亏。把这篇文章吃透,基本就能避开所有高频坑了。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8