同一个 Hello World,Kotlin 和 Java 在字节码层面差在哪
同样是打印一行字,Java 编译出 417 字节的 class,Kotlin 编译出 631 字节。从后者可以看到 Kotlin 编译器所做的事情:文件门面类、合成桥方法、@Metadata 注解,以及一条纯粹为了调整操作数栈而生成的 swap 指令。
本文通过 Hello World 程序来展示把这些差异。
材料与版本
| Java | Kotlin | |
|---|---|---|
| 源码 | Hello.java(5 行) |
Hello.kt(3 行) |
| 编译器 | javac --release 8 |
kotlinc 2.4.21(默认 jvmTarget=1.8) |
| 产物 | Hello.class,417 字节 |
HelloKt.class,631 字节 |
class 文件版本 |
major 52(Java 8) | major 52(Java 8) |
Java 那边特意用 --release 8 编译,让两个文件的 class 版本一致------这样下面所有差异都只来自编译器,而不是目标版本。
先是 Java 版 ⬇️
java
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
然后 Kotlin 版,功能完全一样 ⬇️
kotlin
fun main() {
println("Hello, world!")
}
编译并查看 ⬇️
bash
# Kotlin
kotlinc Hello.kt
javap -p -v HelloKt.class
# Java(--release 8,让 class 文件版本和 Kotlin 编译出来的 class 文件版本一致)
javac --release 8 Hello.java
javap -p -v Hello
Kotlin 的编译产物是 HelloKt.class(还有一个 META-INF/main.kotlin_module,后文会讲)。运行时要让 JVM 找到 Kotlin 标准库,所以在 -cp 里带上 kotlinc/lib/ 下的 stdlib ⬇️
bash
# 假设当前目录下有 kotlinc/(Kotlin 编译器发行包)
java -cp kotlinc/lib/kotlin-stdlib.jar:. HelloKt # Kotlin
java Hello # Java
就这个程序而言,println 已经被内联成 System.out.println,字节码里没有别的标准库调用,所以直接 java HelloKt 也能跑------带上 stdlib 是更通用的写法,换成任何引用了 Kotlin 标准库的程序都适用。
kotlinc 和 stdlib 从哪来
前面用到的 kotlinc 命令和 kotlin-stdlib.jar,都来自 Kotlin 官方的编译器发行包 ⬇️
- 打开 Kotlin v2.4.21 的 Release 页面
- 下载页面里的
kotlin-compiler-2.4.21.zip(约 86 MB) - 解压,得到一个
kotlinc/目录
解压后的结构大致如下 ⬇️
text
kotlinc/
├── bin/ # kotlinc、kotlin、kapt 等可执行脚本
├── lib/ # kotlin-stdlib.jar、kotlin-compiler.jar 等 58 个 jar
├── build.txt # 版本号,这里是 2.4.21
└── license
于是前面那两条命令里的路径就对上了 ⬇️
- 编译用的
kotlinc,就是bin/kotlinc - 运行时
-cp里的kotlinc/lib/kotlin-stdlib.jar,就是lib/下的那个 jar
lib/ 里还有 kotlin-reflect.jar、kotlin-compiler.jar 以及各种 compiler plugin,本文只用到 stdlib。
差异一:特殊的类名
javap 的第一行就不同 ⬇️
text
public class Hello
text
public final class HelloKt
Java 的类名就是源码里写的类名。Kotlin 的顶层函数没有"类"可以依附,编译器就按文件名 造一个类:Hello.kt → HelloKt。这个类叫文件门面类(file facade),只用来放顶层函数编译出来的静态方法。
两个细节:
- Kotlin 的类带
ACC_FINAL(flags: (0x0031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER),Java 的只有ACC_PUBLIC, ACC_SUPER。Kotlin 的类默认不可继承。 - 文件名决定类名,但可以用注解改 ⬇️
kotlin
@file:JvmName("Hello")
fun main() { ... }
加上这一行,产物就变成 Hello.class,Java 那边调用时也就不需要记住 HelloKt 这个后缀了。
差异二:两个 main
Kotlin 的 class 里有两个 main 方法 ⬇️
text
public static final void main();
descriptor: ()V
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
Code:
stack=2, locals=0, args_size=0
0: ldc #8 // String Hello, world!
2: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
5: swap
6: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
9: return
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x1009) ACC_PUBLIC, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #23 // Method main:()V
3: return
第一个是你写的 fun main(),方法体是真正的逻辑;第二个是编译器补的桥方法 ,方法体只有一句 invokestatic main()。
为什么必须补?因为 JVM 启动器只认 public static void main(String[]) 这个签名。你写的是无参的 fun main(),签名对不上,java HelloKt 就找不到入口。于是 kotlinc 生成一个符合 JVM 要求的 main(String[]),把调用转给真正的 main()。
顺带一提:
- 桥方法带
ACC_SYNTHETIC,所以javap里看得到,Method.isSynthetic()也会返回true。 - 如果你写的是
fun main(args: Array<String>),签名本来就匹配,就不会生成桥方法。
顺带一个运行时差异:带参数的写法会被编译器加一句空检查 ⬇️
text
0: aload_0
1: ldc #9 // String args
3: invokestatic #15 // Method kotlin/jvm/internal/Intrinsics.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V
这句依赖 kotlin.jvm.internal.Intrinsics,所以 fun main(args: Array<String>) 运行时必须带 kotlin-stdlib 。而本文这种无参的 fun main() 不会生成这句------HelloKt.class 在 classpath 上没有 kotlin-stdlib 时,java HelloKt 照样能跑起来。
差异三:Kotlin 的类没有构造函数
Java 的 Hello 里除了 main,还有一个默认构造 ⬇️
text
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
Kotlin 的 HelloKt 里没有 <init>------javap 显示 fields: 0, methods: 2,两个方法就是上面那两个 main。
原因很直接:文件门面类只承载静态方法,不需要实例,编译器就不生成构造函数。所以你写不出 new HelloKt(),HelloKt 也从来不会被实例化。
差异四:println 的重载,和一条 swap
同样是 System.out.println("Hello, world!"),两边的字节码不一样。
Java ⬇️
text
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello, world!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
Kotlin ⬇️
text
0: ldc #8 // String Hello, world!
2: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
5: swap
6: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
9: return
两处差异。
第一处:调用的重载不同。 Java 直接选中 println(String);Kotlin 选的是 println(Object)。因为 Kotlin 的 println 定义在 kotlin.io.ConsoleKt 里,签名是 fun println(message: Any?),是个 inline 函数,编译时被内联展开成 System.out.println(message)------参数静态类型是 Any?,自然就落到 println(Object) 这个重载上。
运行结果没有区别:PrintStream.println(Object) 内部会先 String.valueOf(x) 再输出。
第二处:多了一条 swap,以及求值顺序相反。 把操作数栈画出来就清楚了 ⬇️
text
Kotlin:
ldc "Hello, world!" → [String]
getstatic System.out → [String, PrintStream] 栈顶是接收者,顺序反了
swap → [PrintStream, String] 换回来
invokevirtual → 弹出接收者和参数,调用
Java:
getstatic System.out → [PrintStream]
ldc "Hello, world!" → [PrintStream, String] 顺序本来就是对的
invokevirtual → 直接调用
invokevirtual 要求「接收者在参数下面」。kotlinc 生成的顺序是先压参数、再压接收者,于是栈顶变成了 PrintStream;用一条 swap 把两个值换回来,比重新生成一遍取值的指令更省。javac 则是先取接收者、再压参数,所以不需要 swap。
swap 在 javac 的产物里几乎见不到,算是 Kotlin 编译器留给字节码读者的一个小彩蛋。
差异五:@Metadata 注解
Java 的 Hello 只有一个 SourceFile 属性;Kotlin 的 HelloKt 多了一个运行时可见注解 ⬇️
text
RuntimeVisibleAnnotations:
0: #26(...)
kotlin.Metadata(
mv=[2,2,0]
k=2
xi=48
d1=["\u0000\u0006\n\u0000\n\u0002\u0010\u0002\u001a\u0006\u0010\u0000\u001a\u00020\u0001"]
d2=["main",""]
)
这是 Kotlin 的"自描述":
mv=[2,2,0]:元数据版本,跟着 Kotlin 版本变。k=2:Kind.FILE_FACADE,说明这个类是个文件门面。d1:protobuf 编码的元数据,记录了文件里有哪些函数、签名、可见性等。d2:d1引用到的字符串表。这里只有两个值:函数名main和默认包名""。xi:扩展位,一般不用关心。
谁在读它?IDE(补全、跳转、重构)、kotlin-reflect、以及跨模块编译时的 Kotlin 编译器。JVM 执行时完全不需要它 ------没有它,java HelloKt 照样能跑;但没了它,IDE 就把这个类当成普通 Java 类,Kotlin 的语义(默认参数、扩展函数、可空性)就全丢了。
差异六:多了一个 .kotlin_module
Kotlin 编译后会额外生成一个 37 字节的文件 ⬇️
text
META-INF/main.kotlin_module
它记录模块级的信息(这个模块里有哪些包、哪些文件门面类),供跨模块编译时使用。javac 不会生成类似的东西------Java 的跨模块信息由 classpath 上的类文件本身承担。
用 xxd 看,能直接在末尾看到 HelloKt 这个名字。
一张对照表
| 维度 | Java | Kotlin |
|---|---|---|
| 类名 | Hello |
HelloKt(由文件名决定) |
| 类标志 | public |
public final |
| 构造函数 | 默认 <init> |
无 |
| 方法 | main(String[]) |
main() + 合成桥 main(String[]) |
println 重载 |
println(String) |
println(Object) |
| 求值顺序 | 接收者 → 参数 | 参数 → 接收者(多一条 swap) |
| 注解 | 无 | @kotlin.Metadata |
| 附加文件 | 无 | META-INF/main.kotlin_module |
| 体积 | 417 字节 | 631 字节 |
小结
- 顶层函数没有类,编译器按文件名造一个。
Hello.kt→HelloKt,可以用@file:JvmName改名。 - JVM 只认
main(String[])。 你写fun main(),编译器补一个合成桥;写带参数的fun main(args: Array<String>)就不补。 - 门面类没有构造函数。 它只放静态方法,不需要实例。
- Kotlin 的
println走的是println(Object)。 因为它是Any?参数的 inline 函数;顺带还生成了一条swap来调整操作数栈。 @Metadata是给工具链看的,不是给 JVM 看的。 它让 IDE 和kotlin-reflect能读懂 Kotlin 语义,去掉它程序照样跑。
一个 Hello World 的 class 文件,把「Kotlin 如何映射到 JVM」的四五个设计决策都摊开了。下次看 Kotlin 编译产物的体积,大概能猜到多出来的部分花在哪。
参考资料
- Kotlin 官方文档:Hello world
- Java Virtual Machine Specification 中的
- 4.1. The
ClassFileStructure - 6.5. Instructions(
swap在 6.5 节)
- 4.1. The
- Kotlin API 文档:
kotlin.Metadata