enum class Direction { NORTH, SOUTH, WEST, EAST } 编译出 1698 字节的 Direction.class;同样四个常量,javac --release 8 生成的 DirectionJ.class 是 976 字节。
多出来的约 74% 并非装饰。它对应 Kotlin 编译器为枚举补上的几样东西:$VALUES 数组、私有的 $values()、$ENTRIES 缓存和 getEntries(),以及一份 @Metadata。
本文用一个最小枚举,把这些产物逐个拆开;带参数、带匿名子类、带 when 的例子随后补上。
材料与版本
| Kotlin | Java(对照) | |
|---|---|---|
| 源码 | Direction.kt(1 行) | DirectionJ.java(1 行) |
| 编译器 | kotlinc 2.4.21(默认 jvmTarget=1.8) | javac --release 8 |
| 产物 | Direction.class,1698 字节 | DirectionJ.class,976 字节 |
| class 版本 | major 52(Java 8) | major 52(Java 8) |
两边 class 版本一致,差异只来自编译器。kotlinc 和 kotlin-stdlib.jar 都来自 Kotlin v2.4.21 的编译器发行包,运行时 classpath 里要带上 lib/kotlin-stdlib.jar。
Kotlin 版 ⬇️
enum class Direction { NORTH, SOUTH, WEST, EAST }
Java 对照版 ⬇️
public enum DirectionJ { NORTH, SOUTH, WEST, EAST }
编译并查看 ⬇️
kotlinc Direction.kt
javap -p -c -v Direction.class
javac --release 8 DirectionJ.java
javap -p -c -v DirectionJ.class
javap 要加 -p,否则看不到 private 和 synthetic 成员;-c 看指令,-v 看常量池、flags 和注解。
下文出现的 #n 是本文编译产物里的常量池下标;换一次编译,编号可能变化,以你自己 javap 的结果为准。
一、枚举首先是一个 java.lang.Enum 子类
javap 的第一行 ⬇️
public final class Direction extends java.lang.Enum<Direction>
flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM
Java 对照版一模一样 ⬇️
public final class DirectionJ extends java.lang.Enum<DirectionJ>
flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM
JVM 没有为枚举发明新的 class 文件类型。它只是在普通类上多认一个 ACC_ENUM 标志;Class.isEnum() 会检查这个标志(以及父类是否为 java.lang.Enum)。所以:
Direction首先是个类,继承java.lang.Enum<Direction>。- 类是
final的:枚举常量集合在编译期固定,不能通过继承扩展。 name/ordinal不是Direction的字段,而是java.lang.Enum里的private final字段,通过同样在父类里的name()/ordinal()方法读取。
四个常量本身是静态字段 ⬇️
public static final Direction NORTH;
flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM
public static final Direction SOUTH;
public static final Direction WEST;
public static final Direction EAST;
Direction.NORTH 在字节码里就是一次 getstatic,没有方法调用。
Direction 一共有 6 个字段:四个常量、$VALUES、$ENTRIES。Java 版只有 5 个:少了 $ENTRIES。
另外,枚举常量是值,不是类型。Direction.NORTH 的类型是 Direction,不能写 fun f(d: Direction.NORTH)。这是编译期规则;字节码里它只是一个 Direction 类型的静态字段。
二、常量是 <clinit> 里 new 出来的单例
枚举类的静态初始化块负责把四个常量造出来 ⬇️
static {};
Code:
0: new #2 // class Direction
3: dup
4: ldc #58 // String NORTH
6: iconst_0
7: invokespecial #59 // Method "<init>":(Ljava/lang/String;I)V
10: putstatic #47 // Field NORTH:LDirection;
13: new #2 // class Direction
...
49: putstatic #56 // Field EAST:LDirection;
52: invokestatic #64 // Method $values:()[LDirection;
55: putstatic #22 // Field $VALUES:[LDirection;
...
每个常量都是 new + dup + 常量名 + 序号 + 构造器 + putstatic。名字和序号是编译器塞进去的,源码里看不到。
构造器长这样 ⬇️
private Direction();
descriptor: (Ljava/lang/String;I)V
flags: (0x0002) ACC_PRIVATE
Code:
0: aload_0
1: aload_1
2: iload_2
3: invokespecial #10 // Method java/lang/Enum."<init>":(Ljava/lang/String;I)V
6: return
Signature: #8 // ()V
注意 javap 把它打印成无参的 Direction(),但 descriptor 明明是 (String,int)V。原因是 Kotlin 给构造器写了一个 Signature: ()V,把编译器注入的两个参数从 Kotlin 签名里藏掉了。对 Kotlin 源码来说,这就是一个私有的无参构造器;对 JVM 来说,它要接收 name 和 ordinal。
这也解释了为什么不能写 Direction():构造器是 private,而且只在 <clinit> 里被调用。枚举常量因此是类初始化时一次性创建的单例,第一次访问 Direction.NORTH 会触发整个类初始化,四个常量一起建好。
三、$VALUES、$values() 和 values()
字段列表里还有两个 Kotlin 和 Java 都会生成的成员 ⬇️
private static final Direction[] $VALUES;
flags: (0x101a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC
$VALUES 保存常量数组;$values() 负责构造它;公开的 values() 负责返回一份拷贝 ⬇️
private static final Direction[] $values();
Code:
0: iconst_4
1: anewarray #2 // class Direction
4: astore_0
5: aload_0
6: iconst_0
7: getstatic #47 // Field NORTH:LDirection;
10: aastore
...
30: areturn
public static Direction[] values();
Code:
0: getstatic #22 // Field $VALUES:[LDirection;
3: invokevirtual #28 // Method java/lang/Object.clone:()Ljava/lang/Object;
6: checkcast #29 // class "[LDirection;"
9: areturn
values() 每次都 clone,所以调用方拿到的是新数组,改它不会影响枚举内部。运行时验证 ⬇️
println(Direction.values() === Direction.values()) // false
这部分 Java 也有,Kotlin 原样保留,主要用于 Java 互操作和反射;Kotlin 自己推荐的入口是 entries。
四、entries 是 Kotlin 额外加的一层
Kotlin 的 class 里多了一个字段和一个方法 ⬇️
private static final kotlin.enums.EnumEntries $ENTRIES;
flags: (0x101a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC
public static kotlin.enums.EnumEntries<Direction> getEntries();
Code:
0: getstatic #43 // Field $ENTRIES:Lkotlin/enums/EnumEntries;
3: areturn
<clinit> 的结尾把它初始化 ⬇️
52: invokestatic #64 // Method $values:()[LDirection;
55: putstatic #22 // Field $VALUES:[LDirection;
58: getstatic #22 // Field $VALUES:[LDirection;
61: checkcast #66 // class "[Ljava/lang/Enum;"
64: invokestatic #72 // Method kotlin/enums/EnumEntriesKt.enumEntries:([Ljava/lang/Enum;)Lkotlin/enums/EnumEntries;
67: putstatic #43 // Field $ENTRIES:Lkotlin/enums/EnumEntries;
70: return
entries 不是一个字段。写 Direction.entries 时,编译器生成的是 getEntries() 调用 ⬇️
0: invokestatic Direction.getEntries:()Lkotlin/enums/EnumEntries;
3: checkcast java/util/List
EnumEntries 是一个继承 java.util.List 的接口,实现类是 kotlin.enums.EnumEntriesList。它继承 AbstractList,内部只有一个 T[] entries 字段,构造时直接把 $VALUES 的引用存进去,不再复制。
所以 entries 的读操作都很直接 ⬇️
getSize() → entries.length
get(i) → entries[i]
contains(e) / indexOf(e) → 用 e.ordinal() 当下标,再比较引用
contains / indexOf 不是线性扫描,而是把 ordinal 当数组下标做一次身份比较;枚举常量是单例,引用相等就等价于常量相等。这也是 EnumEntriesList 可以直接共享 $VALUES 的原因。
运行时行为 ⬇️
Direction.entries === Direction.entries → true
Direction.values() === Direction.values() → false
Direction.entries.javaClass.name → kotlin.enums.EnumEntriesList
Direction.entries → [NORTH, SOUTH, WEST, EAST]
Direction.entries[1] → SOUTH
Direction.entries.indexOf(EAST) → 3
entries 是只读视图。用 Java 的 List.add 去改它,会得到 ⬇️
java.lang.UnsupportedOperationException: Operation is not supported for read-only collection
和 values() 每次复制一份数组相比,entries 只在类初始化时创建一个共享列表。常量多、调用频繁时,这能省掉一批临时数组。
因为 entries 就是 List,first、firstOrNull、sorted 这些集合 API 可以直接用。文档里的 fromName / fromPosition / fromRgb 这类查找函数,也只是写在 companion object 里的普通方法;编译后它们进入 Color$Companion 类,底层仍然是 entries.find 或 entries.getOrNull,没有额外的枚举机制。
valueOf 和泛型辅助函数
按名字查找常量,用的是另一个生成的静态方法 ⬇️
public static Direction valueOf(java.lang.String);
Code:
0: ldc #2 // class Direction
2: aload_0
3: invokestatic #34 // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
6: checkcast #2 // class Direction
9: areturn
它把活转给 java.lang.Enum.valueOf(Class, String),内部用 Class 的枚举常量目录按名字查。名字对不上就抛 IllegalArgumentException ⬇️
No enum constant Direction.PINK
文档里的 enumEntries<T>()、enumValues<T>() 和 enumValueOf<T>() 都是 inline + reified 的语法糖。类型实参已知时,调用点会被直接替换成对应的静态方法 ⬇️
enumEntries<Direction>() → invokestatic Direction.getEntries()
enumValues<Direction>() → invokestatic Direction.values()
enumValueOf<Direction>(s) → invokestatic Direction.valueOf(String)
前者返回共享列表,后两者每次调用都会新建数组或走一次名字查找。
Direction.entries.getOrNull(index) 则会展开成 getEntries() → List → CollectionsKt.getOrNull,越界返回 null,不会抛异常。
顺带一个运行时依赖:$ENTRIES 的初始化调用了 kotlin.enums.EnumEntriesKt,所以 Kotlin 枚举在类初始化时必须能加载 kotlin-stdlib。只访问一个常量也一样 ⬇️
Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/enums/EnumEntriesKt
at ColorPlain.<clinit>(ColorPlain.kt:4)
这一点和 Hello World 里无参 main() 不同:那个程序不带 stdlib 也能跑,枚举不行。
五、name、ordinal 和比较都来自父类
先定义一个三常量的枚举,后面几节都用它 ⬇️
enum class Color { RED, GREEN, BLUE }
fun ordinalOf(c: Color): Int = c.ordinal
fun nameOf(c: Color): String = c.name
Color 的字段里没有 name 和 ordinal。它们存在 java.lang.Enum 里,访问时走父类方法 ⬇️
ordinalOf:
6: aload_0
7: invokevirtual #21 // Method Color.ordinal:()I
nameOf:
6: aload_0
7: invokevirtual #28 // Method Color.name:()Ljava/lang/String;
片段省略了方法开头的参数空检查和结尾的返回指令。
java.lang.Enum 里 name()、ordinal()、equals()、hashCode()、compareTo() 都是 final,枚举类不能覆盖它们。compareTo 按 ordinal 比较,也就是声明顺序 ⬇️
println(Color.RED < Color.BLUE) // true
println(Color.entries.sorted()) // 声明顺序,不是字典序
因此不能通过重写 compareTo 改变排序;需要别的顺序时,加一个显式属性再 sortedBy,这也是文档给出的建议。
六、带参数的常量:多一个字段和一个构造参数
给每个常量关联一个值,源码只多了一个主构造参数 ⬇️
enum class ColorPlain(val hex: String) {
RED("#FF0000"),
GREEN("#00FF00"),
BLUE("#0000FF")
}
字节码里多出一个字段,构造器多一个参数 ⬇️
private final java.lang.String hex;
private ColorPlain(java.lang.String);
descriptor: (Ljava/lang/String;ILjava/lang/String;)V
Code:
0: aload_0
1: aload_1
2: iload_2
3: invokespecial #11 // Method java/lang/Enum."<init>":(Ljava/lang/String;I)V
6: aload_0
7: aload_3
8: putfield #15 // Field hex:Ljava/lang/String;
11: return
Signature: #8 // (Ljava/lang/String;)V
public final java.lang.String getHex();
构造器仍然是 private,String name 和 int ordinal 仍在最前面,hex 排在后面;Kotlin 签名仍然是 (String)V。<clinit> 里每个常量多压一个字符串 ⬇️
4: ldc #62 // String RED
6: iconst_0
7: ldc #64 // String #FF0000
9: invokespecial #66 // Method "<init>":(Ljava/lang/String;ILjava/lang/String;)V
12: putstatic #53 // Field RED:LColorPlain;
val hex 不是 public 字段,而是一个 private 字段加一个 getHex()。这一点和普通 Kotlin 类一致。
和 Java 对照,结构几乎一样,差别只在 Kotlin 多出来的那层。Java 版 ⬇️
public enum ColorJ {
RED("#FF0000"),
GREEN("#00FF00"),
BLUE("#0000FF");
private final String hex;
ColorJ(String hex) {
this.hex = hex;
}
public String getHex() {
return hex;
}
}
两边的成员数量对比如下 ⬇️
Kotlin ColorPlain | Java ColorJ | |
|---|---|---|
| 字段 | 6(3 常量 + hex + $VALUES + $ENTRIES) | 5(3 常量 + hex + $VALUES) |
| 方法 | 7(含 getEntries()) | 6 |
| class 属性 | 3(多 @Metadata) | 2 |
| 大小 | 1979 字节 | 1165 字节 |
构造器形状、$VALUES、$values()、values()、valueOf() 两边是同一套设计;Kotlin 加的只有 entries 和元数据。Java 编译器也用 Signature 把 (String,int,String) 在源码层显示成 (String),这部分两边是对称的。
枚举类里共用的函数(比如 describe())就是普通的 final 实例方法,所有常量共用一份字节码;operator fun not() 也只是方法名带 not 的普通方法,调用点变成 invokevirtual。只有每个常量各自 override 时,才会走到下一节的匿名子类。
七、每个常量自己的实现,会变成匿名子类
文档里的 ProtocolState ⬇️
enum class ProtocolState {
WAITING {
override fun signal() = TALKING
},
TALKING {
override fun signal() = WAITING
};
abstract fun signal(): ProtocolState
}
主类变成 abstract ⬇️
public abstract class ProtocolState extends java.lang.Enum<ProtocolState>
flags: (0x4421) ACC_PUBLIC, ACC_SUPER, ACC_ABSTRACT, ACC_ENUM
因为 signal() 是抽象方法,ProtocolState 自己不能是 final。两个常量各自生成一个类 ⬇️
ProtocolState.class 2019 字节
ProtocolState$WAITING.class 936 字节
ProtocolState$TALKING.class 936 字节
子类的头 ⬇️
final class ProtocolState$WAITING extends ProtocolState
flags: (0x0030) ACC_FINAL, ACC_SUPER
fields: 0, methods: 2
它是包级可见的 final 类,没有自己的字段,只有一个构造器和一个 signal() ⬇️
ProtocolState$WAITING();
descriptor: (Ljava/lang/String;I)V
Code:
0: aload_0
1: aload_1
2: iload_2
3: aconst_null
4: invokespecial #10 // Method ProtocolState."<init>":(Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
7: return
public ProtocolState signal();
Code:
0: getstatic #23 // Field ProtocolState.TALKING:LProtocolState;
3: areturn
注意构造器最后多压了一个 null,调用的是父类一个带 DefaultConstructorMarker 的构造器 ⬇️
public ProtocolState(java.lang.String, int, kotlin.jvm.internal.DefaultConstructorMarker);
flags: (0x1001) ACC_PUBLIC, ACC_SYNTHETIC
Code:
0: aload_0
1: aload_1
2: iload_2
3: invokespecial #54 // Method "<init>":(Ljava/lang/String;I)V
6: return
原因很直接:父类构造器是 private,子类不能直接调。编译器就生成一个 public synthetic 的桥构造器,多一个没人会用的 marker 参数,让 ProtocolState$WAITING 能绕过访问控制。Kotlin 源码看不到这个构造器,反射能看到。
<clinit> 里创建常量的方式也变了 ⬇️
0: new #59 // class ProtocolState$WAITING
3: dup
4: ldc #60 // String WAITING
6: iconst_0
7: invokespecial #61 // Method ProtocolState$WAITING."<init>":(Ljava/lang/String;I)V
10: putstatic #49 // Field WAITING:LProtocolState;
字段类型仍然是 ProtocolState,不是 ProtocolState$WAITING。所以匿名类体里额外声明的成员在外部访问不到——文档里 waitingMessage 报错就是这个原因。
只有需要“每个常量各自实现”的抽象方法或接口方法,才会生成这些子类;如果整个枚举共用一份实现(比如在枚举类体里统一实现一个接口),就不会有 $WAITING 这样的类。
八、when 枚举:tableswitch 和一张映射表
对枚举做穷尽 when,源码不用写 else ⬇️
fun describe(color: Color): String = when (color) {
Color.RED -> "warm"
Color.GREEN -> "natural"
Color.BLUE -> "cool"
}
字节码先查一张映射表,再 tableswitch ⬇️
6: aload_0
7: getstatic #21 // Field UseSiteKt$WhenMappings.$EnumSwitchMapping$0:[I
10: swap
11: invokevirtual #27 // Method Color.ordinal:()I
14: iaload
15: tableswitch { // 1 to 3
1: 40
2: 45
3: 50
default: 55
}
...
55: new #35 // class kotlin/NoWhenBranchMatchedException
58: dup
59: invokespecial #39 // Method kotlin/NoWhenBranchMatchedException."<init>":()V
62: athrow
这张表不在枚举类里,而在使用方生成的一个合成类里 ⬇️
public final class UseSiteKt$WhenMappings
flags: (0x1031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_SYNTHETIC
public static final int[] $EnumSwitchMapping$0;
flags: (0x1019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC
它的 <clinit> 按 values() 的长度建数组,把每个常量的 ordinal 映射成从 1 开始的分支号 ⬇️
0: invokestatic #14 // Method Color.values:()[LColor;
3: arraylength
4: newarray int
...
9: getstatic #18 // Field Color.RED:LColor;
12: invokevirtual #22 // Method Color.ordinal:()I
15: iconst_1
16: iastore
...
22: getstatic #25 // Field Color.GREEN:LColor;
26: invokevirtual #22 // Method Color.ordinal:()I
29: iconst_2
30: iastore
...
RED → 1、GREEN → 2、BLUE → 3;没填到的位置保持 0,落到 tableswitch 的 default,抛 NoWhenBranchMatchedException。
为什么不直接用 ordinal 做 tableswitch?因为分支编号和 ordinal 是两套东西:一个分支可以合并多个常量,编译器也需要一张稳定的“常量 → 分支”表。更重要的是二进制兼容:如果枚举在另一个模块里新增了常量,调用方没重新编译,映射数组仍会按运行时的 values().length 建成,新常量对应的槽位是 0,于是走 default 抛异常,而不是静默落进某个错误分支。穷尽性首先是编译期保证,运行时这张表是兜底。
这里又出现了一条 swap,和 Hello World 里那条是同一个原因:iaload 需要“数组引用在下面、下标在上面”,而编译器先压了数组、再算 ordinal(),顺序反了,就用 swap 换回来 ⬇️
aload_0 → [Color]
getstatic $EnumSwitchMapping$0 → [Color, int[]]
swap → [int[], Color]
invokevirtual ordinal → [int[], int]
iaload → [int]
九、@Metadata
和 Hello World 的文件门面类一样,枚举类也带 @kotlin.Metadata,但 k 不同 ⬇️
kotlin.Metadata(
mv=[2,4,0]
k=1
d1=[...]
d2=["LDirection;","","<init>","(Ljava/lang/String;I)V","NORTH","SOUTH","WEST","EAST"]
)
Hello World 的 HelloKt 是 k=2(FILE_FACADE),枚举是真正的类,所以 k=1(CLASS)。d2 的字符串表里能看到构造器签名和四个常量名;entries / getEntries 不在里面——它们是编译器按“这是个枚举”合成的成员,不是普通声明。
这些元数据只给工具链用:IDE 的补全、跳转、重构,kotlin-reflect,以及跨模块编译。JVM 执行时不需要它。
一张对照表
| 维度 | Java enum | Kotlin enum |
|---|---|---|
| 类头 | extends java.lang.Enum<T>,ACC_ENUM | 相同 |
| 类标志 | public final | 相同 |
| 常量字段 | public static final + ACC_ENUM | 相同 |
name / ordinal | 继承自 java.lang.Enum | 相同 |
| 构造器 | private (String, int, ...) | 相同 |
$VALUES / $values() / values() / valueOf() | 有 | 有 |
entries / getEntries() | 无 | 有 |
| 常量匿名子类 | 有(常量类体) | 有 |
@Metadata | 无 | 有 |
| 4 常量、无属性 | 976 字节 | 1698 字节 |
| 3 常量 + 一个属性 | 1165 字节 | 1979 字节 |
小结
- 枚举不是新的 class 文件类型。 它是继承
java.lang.Enum<T>的final类,只在访问标志上多了ACC_ENUM。 - 常量是
<clinit>里new出来的单例。name和ordinal由编译器注入构造器,构造器是private,外部无法再创建常量。 $VALUES和values()是 Java 时代就有的设计。 Kotlin 原样保留:values()每次返回一份clone。entries是 Kotlin 加的共享只读列表。 它由$ENTRIES缓存、getEntries()返回;EnumEntriesList直接包住$VALUES,contains/indexOf用ordinal做 O(1) 查找。- 每个带自己实现的常量会生成一个包级
final子类。 父类构造器是私有的,编译器为此补一个带DefaultConstructorMarker的public synthetic构造器。 when枚举编译成tableswitch+ 一张合成映射表。 默认分支仍会抛NoWhenBranchMatchedException,用来兜住枚举在运行时新增常量的情况。@Metadata只服务工具链。 JVM 不需要它;但$ENTRIES的初始化需要kotlin-stdlib,所以 Kotlin 枚举比 Hello World 更依赖标准库。
一个只有四个常量的枚举,把 Kotlin 对 JVM 枚举模型的复用和扩展都展示出来了:能复用的(java.lang.Enum、$VALUES、values()、valueOf())直接复用,需要补的(entries、匿名子类、when 映射、元数据)在字节码里各占一块。
参考资料
- Kotlin 官方文档:Enum classes
- Kotlin API 文档:
- Java API 文档:
java.lang.Enum - Java Virtual Machine Specification:
- 4.1. The
ClassFileStructure(ACC_ENUM在 4.1 节) - 6.5. Instructions(
tableswitch在 6.5 节)
- 4.1. The