如果你正在优化 Android 应用,或者准备高级 Kotlin 开发工程师面试,那么你很可能研究过Kotlin 中 const 与 val 的区别。
从表面上看,两者都用于声明只读属性(read-only properties)。它们都不允许被重新赋值,对于初级开发者而言,看起来几乎没有区别。
然而,在更底层的实现中------也就是 Java Virtual Machine(JVM)字节码(Bytecode)层面------它们的处理方式存在本质差异。
本文将深入解析 Kotlin 中 const val 的字节码机制(Kotlin bytecode const val mechanics),探讨编译期优化(compile-time optimization)是如何影响应用性能、内存占用以及二进制兼容性(binary compatibility)的。
快速概览
- 使用
const val:适用于严格意义上的 Kotlin 编译期常量(compile-time constants) 。其值会被直接内联(inline)到字节码中 ,执行效率更高,但在跨模块(module)使用时存在二进制兼容性(binary compatibility)风险。 - 使用
val:适用于普通的运行时只读属性。它更加安全、灵活,支持自定义对象类型,并且能够避免因常量内联导致的代码重复和版本兼容问题。
核心区别:内联(Inlining) vs 属性存储(Property Backing)
两者最本质的区别在于: 编译期内联(Compile-Time Inlining)与运行时属性访问(Runtime Property Lookup)。
const val
Kotlin 编译器会将 const val 视为一个字面量常量(literal constant)。 在编译过程中,编译器会直接把属性访问替换为实际的常量值,并将该值写入使用它的字节码指令中。
val
即使一个普通的 val 在编译时已经能够确定其值,编译器通常也不会进行内联。 它会被视为一个真正的属性(Property),拥有实际的内存存储位置(Backing Field),并通过字段访问或 Getter 方法获取值。
Android 实际案例
为了理解这一差异为什么重要,我们来看一个典型的 Android 配置类,在同一个场景下同时使用 const val 和 val。
kotlin
package com.example.app
// 1. Excellent use of const val (Intent Key)
const val EXTRA_USER_ID = "com.example.app.EXTRA_USER_ID"
object NetworkConfig {
// 2. Standard val for dynamic or configurable fields
val baseUrl: String = if (BuildConfig.DEBUG) {
"https://sandbox.api.example.com"
} else {
"https://api.example.com"
}
}
明位置与严格限制(Placement and Strict Constraints)
由于 const val 必须在编译期间(compile time)就能完全确定其值,因此 Kotlin 对它施加了严格的限制。
换句话说:
编译器必须能够在不运行任何代码的情况下,直接计算出常量的最终值。
因此,编译期常量不能随意声明在任何位置,也不能依赖运行时计算结果。
kotlin
// 1. Top-level declarations (Allowed)
const val GLOBAL_TIMEOUT = 5000
object AppConfig {
// 2. Inside named objects (Allowed)
const val PREFS_NAME = "app_settings"
}
class MainActivity : AppCompatActivity() {
companion object {
// 3. Inside companion objects (Allowed)
const val REQUEST_CODE_AUTH = 101
}
// 4. Standard class properties (FORBIDDEN)
// const val SCREEN_TITLE = "Dashboard" // ❌ Compile Error
val screenTitle = "Dashboard" // Allowed
}
深入字节码
为了真正理解 const val 和 val 的区别,我们需要看看 Kotlin 编译器在生成 JVM 字节码时究竟做了什么。
下面通过一个简单示例来观察两者在编译后的差异。
kotlin
// Physics.kt
package com.example
const val SPEED_OF_LIGHT = 299792458
class PhysicsEngine {
val gravitationalConstant = 9.81
fun calculateEnergy(mass: Double): Double {
return mass * SPEED_OF_LIGHT * SPEED_OF_LIGHT
}
fun calculateWeight(mass: Double): Double {
return mass * gravitationalConstant
}
}
1.const val 在底层是怎样实现的
当编译器编译 Physics.kt 时,会为这个常量生成一个 静态字段(static field) ,并将其放入一个特殊的 File Facade Class(文件门面类) 中。
kotlin
public static final int SPEED_OF_LIGHT = 299792458;
但是反编译后的 Java 代码中的 calculateEnergy 方法 你会注意到,SPEED_OF_LIGHT 字段实际上从未被访问过。 编译器直接将常量值内联(inline)到了表达式中:
arduino
public final class PhysicsEngine {
public final double calculateEnergy(double mass) {
// Bypassed the field entirely; hardcoded directly into instructions!
return mass * (double)299792458 * (double)299792458;
}
}
在原始字节码层面,JVM 会根据常量值的大小和类型,使用不同的优化指令来加载这些被内联的值:
ICONST_*:用于加载较小的整数(0 到 5)。BIPUSH或SIPUSH:用于加载byte或short范围内的整数。LDC(Load Constant :用于加载较大的数字、浮点数以及字符串常量。
2. val 在底层是如何实现的
现在再来看 gravitationalConstant。 由于它是一个普通的 val,因此会保留一个私有的后备字段(backing field)。 它在字节码中的访问方式完全取决于调用者所处的上下文:
- 内部访问(同一个类内部) :当
calculateWeight读取该属性时,字节码会直接使用GETFIELD指令访问后备字段。此时不会通过 Getter,而是直接读取字段值。 - 外部访问(类外部) :如果其他类调用
engine.gravitationalConstant,字节码会使用INVOKEVIRTUAL指令调用编译器自动生成的公共 Getter 方法(getGravitationalConstant())来获取属性值。
kotlin
public final class PhysicsEngine {
private final double gravitationalConstant = 9.81;
// Generated for external callers
public final double getGravitationalConstant() {
return this.gravitationalConstant;
}
public final double calculateWeight(double mass) {
// Internal access reads the field directly via GETFIELD instruction
return mass * this.gravitationalConstant;
}
}
内存权衡:需要注意的细节(Memory Trade-offs: The Fine Print)
一个常见的误区是认为 const val 总是优于 val,因为它消除了运行时对象开销。
实际上,const val 与 val 在性能上的差异本质上是一种权衡:
val的内存占用:会在每个对象实例中为该字段分配运行时存储空间。const val的内存占用 :不需要在对象实例中为字段分配运行时存储空间,但它们仍然会占用类文件(Class File)和常量池(Constant Pool)中的空间。由于常量值会被复制到每一个调用点,如果将较长的字符串或较大的值定义为const val并被大量使用,可能会轻微增加编译后.class或.dex文件的体积。
隐藏的陷阱:二进制兼容性
由于 const val 的值会在编译期间被直接复制到调用方的字节码中,因此在多模块项目或公共库(Library)中,它会带来一个非常大的架构风险。
假设你有一个库模块(:network)和一个应用模块(:app)。
kotlin
// In the :network module (Version 1.0)
const val API_VERSION = 1
kotlin
// In the :app module
fun connect() {
println("Connecting to version: $API_VERSION")
}
当 :app 模块编译时,整数值 1 会被直接写入(baked into)它自身的字节码中。
如果之后你将 :network 库升级到 2.0 版本,并把:
kotlin
const val API_VERSION = 2
进行了修改,但在发布新版库时并没有强制对主应用模块 :app 进行完整重新编译,那么 :app 仍然会继续使用旧的内联值 1 来执行代码。
高级技巧:使用 @JvmField 抑制 Getter 的生成
如果你希望对于普通的 val 属性,在类外部访问时不让 Kotlin 编译器生成 Getter 方法(同时又不希望像 const val 那样进行编译期内联),那么可以使用 @JvmField 注解:
kotlin
@JvmField
val CONFIG_ID = 999
这会强制编译器将后备字段(backing field)声明为 public,并移除对应的 Getter 方法。 这样,外部调用方将通过 GETSTATIC 或 GETFIELD 指令直接访问字段,而不是调用 Getter 方法。 这种方式能够带来一定的性能优化,同时又避免了 const val 在多模块项目中由于编译期内联而可能引发的二进制兼容性问题。
需要避免的常见错误(Common Mistakes to Avoid)
❌ 在模块间暴露易变配置时使用 const val :如果某个配置值会在不同版本之间发生变化,将其声明为 const val 可能导致依赖模块出现问题,除非这些模块全部重新编译。
❌ 尝试将 const val 用于自定义对象 :你不能将自定义类实例或可变集合赋值给 const val。const val 严格只支持基本数据类型(Primitive Types)和字符串(String)。
❌ 认为 val 就意味着完全不可变 :val 只保证引用(Reference)本身不可变,但它所指向的对象内部状态仍然可能发生变化:
kotlin
val systemLog = mutableListOf("Init")
systemLog.add("Error") // Fully legal! The reference cannot change, but the object is mutable.