用 Common Lisp 写一个 JVM:Java 类变成 CLOS 类,Spring Boot 存进镜像 84 毫秒启动

Anthony Green 用 Common Lisp 写了 OpenLDK,把 Java 字节码翻成 Lisp 再交给 SBCL 编译。Java 类成了 CLOS 类,七门 JVM 语言能嵌进同一个 Lisp 表达式,初始化好的 Spring Boot 应用存进镜像后 84 毫秒启动。

中文
复制

多年前我写过一个 Java 编译器和运行时,叫 gcj,全称 GNU Compiler for the Java Programming Language。如今大多数任务我都首选 Common Lisp,但对 Java 运行时依然有兴趣。

2024 年底,我想在 SBCL 里用一个 Java 库,却找不到适合这个应用的集成方案。于是我动了念头:用 Lisp 写一个简单的 JVM 字节码处理器,直接在进程内执行这些代码。

那时 coding agent 还干不了这类活,第一版能跑的代码是我手写的。

OpenLDK 是一个用 Common Lisp 写的 Java JIT 编译器和运行时。它读取 Java 字节码,翻译成 Lisp,再交给 SBCL 编译成本地机器码。Java 对象和 Lisp 应用处在同一个进程、同一个堆里。

我的目标是让 Java 库在 Common Lisp 里用起来像原生的一样。如果只是通用地执行 Java,HotSpot 会快得多。

不过最近几天 OpenLDK 进步很大。我试了 JVM 上的其他语言,也试了运行 Spring Boot 应用。

OpenLDK 带来了一些奇妙得离谱的可能性。Java 类变成 CLOS 类。Lisp 可以围绕 Kotlin 写的方法添加方法。Clojure 函数可以变成传给 funcall 的东西。Java 自己的编译器跑在 SBCL 里。一个初始化完成的 Spring Boot 应用可以存进 Lisp image,再次启动只要约 84 毫秒。

你甚至可以把 Emacs 和 SLY 连到一个跑在 OpenLDK 上的 Spring Boot 应用,查看它的对象,在它运行期间 monkey-patch 它的行为。

下面讲讲它怎么工作、能做什么,以及那个启动数字到底意味着什么。

编译流水线从普通的 class 文件开始。OpenLDK 面向 JDK 25,使用 OpenJDK 的运行时库,从 JDK 的 JMOD 文件或 lib/modules 容器中读取。

类加载时,OpenLDK 创建它的 CLOS 表示,并为它的方法装上 stub。首次调用某个方法会触发编译:字节码先变成中间表示,再变成 Lisp,最后经 SBCL 变成机器码。编译好的定义会替换掉原来的 stub。

这种惰性很关键。加载一个库,并不意味着要把库里每个方法都编译出来。大型库中的很大一部分,某个具体应用可能永远用不到。

看一个 Java 方法:

public static int add(int x, int y) {
    return x + y;
}

去掉生成的控制流脚手架和异常处理机制,翻译后的方法体大致是:

(sb-c::mask-signed-field 32 (+ x y))

带符号字段的运算保留了 Java 的 32 位整数溢出行为。有了带类型的操作数,SBCL 可以把它编译成一条机器加法指令。OpenLDK 负责处理 Java 的语义,同时借用 SBCL 已有的编译器。

这种翻译还让程序能被 Lisp 直接访问。Java 字段变成槽(slot)。实例方法变成 CLOS 泛型函数的方法,按各自的 JVM 签名区分,因此重载方法不会混淆。静态方法变成普通的 Lisp 函数。Java 的异常层级对应一套 Lisp condition 层级。

编译后的方法保存在持久化的代码缓存中。只要所依赖的类没有变化,后续运行就能直接加载缓存的代码;缓存条目按 OpenLDK 构建版本隔离。不同的 image 之间也能复用已编译的 JDK 方法。

最有意思的结果出现在应用开始直接使用这些表示的时候。

外部语言的源码可以直接写在 Lisp 源码里。启动相应的语言并开启 OpenLDK 的源码语法后,一个 Kotlin 表达式长这样:

(let ((n 4))
  #@kt{ (1..$n).map { it * it } })
;; => (1 4 9 16)

$n 把 Lisp 值作为对象传入 Kotlin 代码。词法变量、集合和回调都能跨过边界,不需要序列化成源码字符串。

同一套机制支持 Scala、Groovy、Clojure、Kawa、JScheme 和 ABCL。每个代码块保留各自语言的语法。Clojure 还有一种 #[ ... ] 写法。

Clojure 函数可以作为可调用的 foreign-function 对象返回。外围的 Lisp 用普通的 funcall 调用它,参数和返回值的转换由互操作层处理。反方向同样可行:Lisp 函数可以变成其他语言的回调。

源块也可以嵌套。下面是完整的 examples/one-form.lisp 文件,一字未改。配置好之后,一个表达式就能让值依次穿过全部七种语言:

;;;; One form, seven languages
;;;;
;;;; A single Lisp expression in which every JVM language OpenLDK runs does
;;;; one thing it is known for, written in its own syntax, and hands its
;;;; result to the next.  Read it from the inside out:
;;;;
;;;;   Clojure  takes ten primes from an infinite lazy sequence
;;;;            (2 3 5 7 11 13 17 19 23 29)
;;;;   Groovy   squares them with the spread-dot operator and sums: 2397
;;;;   Kawa     adds one with Java's Long.sum, by colon notation, and
;;;;            divides exactly by the first of 6, 5, 4, 3, 2 that doesn't
;;;;            divide it evenly, escaping the search with call/cc: 1199/3,
;;;;            returned as its numerator and denominator
;;;;   JScheme  sums each one's digits in a named let: (20 3)
;;;;   Scala    matches the pair against a list pattern with a guard: 60
;;;;   Kotlin   pours it into a local data class, copies it, and sweetens it
;;;;            with when and the Elvis operator: 61
;;;;   ABCL     writes it with FORMAT, in Roman numerals and in English
;;;;
;;;; Each block is source in its language, as the language writes it.
;;;; $#@lang{ ... } (or $#[ ... ] for Clojure) splices the value of the
;;;; block inside into the language around it, passed as an object.
;;;;
;;;;   ./sbcl-polyglot --load examples/one-form.lisp
;;;;
;;;; prints: LXI (sixty-one)

(require :asdf)
(push (uiop:getcwd) asdf:*central-registry*)
(asdf:load-system "openldk/polyglot")
(polyglot:startup :verbose nil)
(setf *readtable* (polyglot:enable-syntax (copy-readtable)))

(format t "~&~A~%"
  #@abcl{                                ; ABCL: FORMAT's Roman numerals and English
    (let ((n $#@kt{                      // Kotlin: a local data class, copy, when, ?:
               data class Cup(val tea: Int, val sugar: Int? = null)
               val cup = Cup($#@sc{     // Scala: a list pattern with a guard
                               $#@jscheme{   ; JScheme: digit sums in a named let
                                 (map (lambda (n)
                                        (let loop ((n n) (sum 0))
                                          (if (< n 1)
                                              sum
                                              (loop (quotient n 10) (+ sum (remainder n 10))))))
                                      $#@kawa{   ; Kawa: Java by colon notation; call/cc; exact division
                                        (let* ((m (java.lang.Long:sum
                                                   $#@gv{   // Groovy: the spread-dot operator
                                                     $#[ (->> (iterate inc 2)   ; Clojure: lazy and infinite
                                                              (filter (fn [n] (not-any? #(zero? (rem n %))
                                                                                        (range 2 n))))
                                                              (take 10)) ]*.power(2).sum()
                                                   }
                                                   1))
                                               (d (call-with-current-continuation
                                                    (lambda (found)
                                                      (for-each (lambda (d)
                                                                  (if (not (= 0 (remainder m d))) (found d)))
                                                                '(6 5 4 3 2))
                                                      1)))
                                               (r (/ m d)))
                                          (list (numerator r) (denominator r)))
                                      })
                               } match {
                                 case List(a: Int, b: Int) if a > b => a * b
                                 case List(a: Int, b: Int) => a + b
                               }
                             }).copy(sugar = null)
               when { cup.tea > 40 -> cup.tea + (cup.sugar ?: 1); else -> cup.tea }
             }))
      (format nil "~@R (~R)" n n))
  })

结果:

LXI (sixty-one)

嵌套块前面的 $ 会把它的结果交给外层语言。这个表达式跨越七种语言的实现,用的是它们各自的语法和库,全程都在同一个 Lisp 进程里。我觉得这相当酷。

CLOS 集成还能走得更远。Lisp 可以添加一个方法,让它在 Java 的 ArrayList.clear() 之前运行:

(openldk.interop:define-java-method :before
    ("java.util.ArrayList" "clear()V") (list)
  (format t "~&clearing ~D elements~%"
          (openldk.interop:java-call "size()I" list)))

签名里的 V 表示该方法返回 Java 的 void;size()I 返回整数。这个方法会在原始实现清空列表之前打印元素个数。

从编译后的 Java、Kotlin、Scala 或 Groovy 代码发起的调用同样会经过它。

Java 方法也参与 CLOS 的方法组合。应用需要的地方,Lisp 可以提供 :before、:after 和 :around 行为。

Lisp 还能定义一个类,让 Java 代码把它当作普通的 Java 类。OpenLDK 的 define-java-class 会生成 class 文件,并通过应用类加载器装载它,而它的方法实现是 Lisp 代码。

这些类可以实现 Java 接口。它们的方法可以读取 Lisp 状态、调用其他语言组件,而 Java 反射看到的仍是一个普通的 Java 类。

错误处理也参与同一套集成。从外部调用中逃逸出来的 NumberFormatException 会变成带类型的 Lisp 条件 foreign-number-format-error,它同时也是一个 parse-error。其他映射覆盖算术、文件、类型和存储错误。

外部调用接口提供 use-value 和 retry 两种 restart。Lisp handler 可以提供一个替代值,或者重试失败的外部调用。

回调内部抛出的 Lisp 错误可以反方向传播,变成对应的 Java 异常。如果它又逃回 Lisp,原来的 Lisp 条件会被恢复。两边都可以使用各自的错误处理机制。

集合的处理方式类似。Java 列表可以作为 Lisp sequence 暴露出来,访问元素时进行转换。Lisp sequence 或哈希表则可以通过带后备的 list 或 map 视图呈现给 Java。

构建系统也参与其中。ASDF 系统可以包含 Maven JAR 组件,以及受支持语言的源码组件。Kotlin、Scala 和 Groovy 文件在 ASDF 的编译操作中一并编译,生成的类可供后续组件使用。Maven 组件把库放进共享的 classpath。目前依赖必须显式列出。

内联的 Kotlin 和 Scala 代码块也能随所在的 Lisp 文件一起编译,前提是拼接处的类型是静态的,且对应的语言编译器正在运行。它们的 class 字节存入 FASL。加载该 FASL 时不需要重新编译这些代码块。互操作文档介绍了这些接口以及各语言的类型转换。

这些功能建立在若干已经跑起来的大型程序之上。

Java 的 javac 编译器运行在 OpenLDK 上,构建过程会产出一个预热过的 javacl 可执行文件。Clojure 支持 protocol、record、atom 和 future。Kawa 和 JScheme 运行 Scheme。Scala 的编译器和 REPL 可用,Kotlin 能把源码编译成可执行的 class 文件,Groovy 既能运行动态脚本,也能运行静态编译的类。

ABCL 贡献了一个格外有趣的循环:Common Lisp 编译成 Java 字节码,再翻译回 Common Lisp,然后由 SBCL 编译。它的 REPL、编译器、CLOS、condition 系统以及 Java 互操作都在这个环境里运行。

此外还有 Spring Boot 和 Quarkus 应用。Spring PetClinic 跑在嵌入式 Tomcat、Hibernate 和 H2 上。这与下文启动测量所用的小型命令行应用是两回事。

这么多编译是有代价的。冷启动一个大型语言实现可能要花上几分钟,因为 OpenLDK 在运行的同时还要编译这个实现本身。保存可执行镜像能带来显著改善。

热镜像在构建期间运行应用,走一遍选定的路径,并保存编译好的代码。预热没有走到的路径,仍然会在首次使用时编译。

应用状态是另一个独立的选择。默认情况下,dump-warm-image 会重置应用类的静态状态,让初始化可以重新发生。使用 :reset-classes nil 时,则保留预热留下的状态。一个按复用该状态设计的应用,启动时大部分初始化工作已经完成。

Spring Boot 快照就是这么工作的。

基准测试应用用到了自动配置、绑定到 record 的配置属性、依赖注入,以及自动配置的 Jackson mapper。一个 CommandLineRunner 生成 JSON,再把值读回来。普通启动还会打印 Spring 的 banner,并通过 Logback 输出日志。

它的快照入口点会保留已初始化的上下文:

public class Snapshot {
    static ConfigurableApplicationContext ctx;

    public static void main(String[] args) throws Exception {
        if (ctx == null) {
            ctx = SpringApplication.run(BootApp.class, args);
        } else {
            for (CommandLineRunner runner :
                    ctx.getBeansOfType(CommandLineRunner.class).values()) {
                runner.run(args);
            }
        }
    }
}

在提供 Spring 相关 import 的前提下,这就是基准测试的入口点。第一次调用启动 Spring。之后的调用复用上下文,执行其中的 runner。

把 OpenLDK 加载进 SBCL,并将 CLASSPATH 设为应用类和各个必需的 JAR 之后,就可以用下面的命令构建镜像:

(openldk::dump-warm-image
 "boot-snapshot" "demo.boot.Snapshot"
 :classpath (uiop:getenv "CLASSPATH")
 :warmups (list nil nil)
 :reset-classes nil)

第一次预热完成 Spring 初始化;第二次走复用路径。保存镜像会把这份已初始化的对象图一并保留下来。

这与 CRaC(Coordinated Restore at Checkpoint)的思路相近:应用只初始化一次,保存其状态,后续启动直接复用这份成果。CRaC 恢复的是做过检查点的 JVM 进程。OpenLDK 用的是 SBCL 的镜像保存机制,再次调用 main 时 Spring 上下文已经存在。它没有实现 CRaC API,也不保留运行中的线程,所以按这种方式设计应用时,线程池和其他进程内资源需要额外留意。

以下是我最近一次运行的结果:

  • 主机:Intel(R) Core(TM) Ultra 7 255H,16 个 CPU
  • JDK:openjdk version “25.0.4.1” 2026-08-18
  • GraalVM:native-image 25.0.4.1.1 2026-08-18
  • OpenLDK:39f33c0
  • 运行前负载:3.75 5.29 3.83
  • 每个变体计时运行 20 次
变体中位数最小值最大值峰值 RSS
HotSpot,fat jar2138.3 ms2078.9 ms2219.0 ms266 MB
HotSpot,解压后的 jar1791.3 ms1755.0 ms1880.1 ms225 MB
HotSpot + Spring AOT1481.4 ms1420.5 ms1575.8 ms194 MB
HotSpot + JDK AOT cache758.8 ms713.4 ms828.8 ms181 MB
HotSpot + Spring AOT + AOT cache595.1 ms568.5 ms625.6 ms157 MB
GraalVM native image53.5 ms50.9 ms63.1 ms41 MB
OpenLDK 预热镜像1261.7 ms1237.8 ms1283.2 ms450 MB
OpenLDK 快照镜像83.9 ms77.5 ms88.6 ms175 MB

这些计时覆盖一个小型命令行应用从进程启动到退出的全过程;峰值 RSS 单独测量。基准脚本会检查各变体下应用输出是否一致。测的是启动加一个短任务,不是稳态吞吐量,也不是 Web 服务器就绪情况。

GraalVM 在这里更快、更省内存:53.5 ms、41 MB,OpenLDK 则是 83.9 ms、175 MB。GraalVM 在启动时仍要执行 Spring 初始化;OpenLDK 复用已保存的上下文。

标准 GraalVM Native Image 采用封闭世界假设:所有类必须在构建时已知,动态访问要靠分析或元数据覆盖。OpenLDK 保留了自己的类加载器和 JIT 编译器,因此启动后仍能编译新类和此前未用过的方法。这些路径在首次使用时付出编译开销。

保存的镜像在运行时还需要类路径文件和 JDK。部署限制见镜像文档。

OpenLDK 仍有兼容性缺口,包括尚未实现的字节码校验。开发以 Linux 上的 SBCL 为目标。

想试多语言示例,先满足构建要求,然后在 OpenLDK 检出目录中运行以下命令:

ocicl install
make sbcl-polyglot
./sbcl-polyglot --load examples/one-form.lisp

七种语言之后:LXI (sixty-one)。

来源: REPL Yell!← 返回首页