同一个 Hello World,Kotlin 和 Java 在字节码层面差在哪

同一个 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 官方的编译器发行包 ⬇️

  1. 打开 Kotlin v2.4.21 的 Release 页面
  2. 下载页面里的 kotlin-compiler-2.4.21.zip(约 86 MB)
  3. 解压,得到一个 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 字节

小结

  1. 顶层函数没有类,编译器按文件名造一个。 Hello.kt → HelloKt,可以用 @file:JvmName 改名。
  2. JVM 只认 main(String[])。 你写 fun main(),编译器补一个合成桥;写带参数的 fun main(args: Array<String>) 就不补。
  3. 门面类没有构造函数。 它只放静态方法,不需要实例。
  4. Kotlin 的 println 走的是 println(Object)。 因为它是 Any? 参数的 inline 函数;顺带还生成了一条 swap 来调整操作数栈。
  5. @Metadata 是给工具链看的,不是给 JVM 看的。 它让 IDE 和 kotlin-reflect 能读懂 Kotlin 语义,去掉它程序照样跑。

一个 Hello World 的 class 文件,把「Kotlin 如何映射到 JVM」的四五个设计决策都摊开了。下次看 Kotlin 编译产物的体积,大概能猜到多出来的部分花在哪。

参考资料

相关推荐
IT_陈寒3 小时前
Redis内存暴涨,没想到是AOF重写捣的鬼
前端·人工智能·后端
ikoala3 小时前
让 Codex 自己查账:一个省 token 的小技巧
前端·后端·ai编程
wo_jack7 小时前
C++ 入门笔记(十六):第16课 vector 模拟实现十问十答 —— 从三指针到迭代器失效
c++·后端·面试·stl·vector
量化分析码农8 小时前
【Python量化波动率实战 02】波动率会传染:EWMA 指数加权给近期行情更大话语权
后端
GISer_Jing9 小时前
如何学习Agent
后端·langchain
JWASX10 小时前
【Go】map 源码解析 - group
后端·golang·map·源码分析
量化分析码农11 小时前
【Python量化波动率实战 01】波动率是怎么算的:年化、滚动与区间估计一次讲清
后端
特立独行的猫A11 小时前
AI 时代,我为什么推荐用 cj-tauri 开发桌面应用
java·后端·python
金銀銅鐵11 小时前
从一个类名到一张类图:用反射自动生成 PlantUML
java·后端·uml