Common Lisp で JVM を書くと、Java のクラスが CLOS クラスになり Spring Boot が 84 ミリ秒で起動する
Anthony Green が Common Lisp で書いた OpenLDK は、Java バイトコードを Lisp に変換して SBCL にコンパイルさせる。Java クラスは CLOS クラスになり、7 つの JVM 言語を 1 つの式に埋め込め、初期化済みの Spring Boot は 84 ミリ秒で起動する。
日本語
コピー
何年も前、私は gcj という Java 言語用の GNU コンパイラ、つまり Java の代替コンパイラとランタイムに取り組んでいた。今ではほとんどの作業で Common Lisp を好んで使っているが、Java ランタイムには今も関心がある。
2024 年の終わり頃、SBCL から Java ライブラリを使いたかったのだが、アプリケーションに合う連携方法が見つからなかった。そこで、Lisp で簡単な JVM バイトコードプロセッサを作り、コードをプロセス内で直接実行するというアイデアを探ることにした。
コーディングエージェントがこうした作業に使えるようになる前の話で、最初の動作するバージョンは手で書いた。
OpenLDK は Common Lisp で書かれた Java の JIT コンパイラ兼ランタイムだ。Java バイトコードを読み、Lisp に変換し、その 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 イメージに保存し、約 84 ミリ秒で再び起動できる。
OpenLDK 上で動く Spring Boot アプリケーションに Emacs と SLY を接続し、オブジェクトを検査し、動かしたままその挙動をモンキーパッチすることもできる。
ここからは、仕組みと、何ができるのか、そしてあの起動時間が実際に何を意味するのかを見ていく。
コンパイルのパイプラインは、ごく普通のクラスファイルから始まる。OpenLDK は JDK 25 を対象とし、OpenJDK のランタイムライブラリを使う。ライブラリは JDK の JMOD ファイルか、lib/modules コンテナから読み込む。
クラスがロードされると、OpenLDK はその CLOS 表現を作り、メソッドのスタブを設置する。メソッドを初めて呼ぶとコンパイルが走る。バイトコードが中間表現になり、次に Lisp になり、SBCL を通じてネイティブコードになる。コンパイル済みの定義がスタブを置き換える。
この遅延評価は重要だ。ライブラリをロードしても、その中のすべてのメソッドがコンパイルされるわけではない。大規模なライブラリの多くは、特定のアプリケーションから一度も使われないかもしれない。
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 のフィールドはスロットになる。インスタンスメソッドは CLOS ジェネリック関数のメソッドになり、JVM シグネチャで識別されるため、オーバーロードされたメソッドも区別されたまま残る。静的メソッドは通常の Lisp 関数になる。Java の例外階層には、対応する Lisp のコンディション階層がある。
コンパイル済みのメソッドは永続的なコードキャッシュに保持される。依存するクラスが変わっていなければ、次回以降の実行でキャッシュされたコードをロードできる。キャッシュのエントリは OpenLDK のビルドごとに分かれている。別のイメージでも、コンパイル済みの 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 の関数は、呼び出し可能な外部関数オブジェクトとして返ってくる。周囲の Lisp は普通の funcall でそれを呼び出し、引数と戻り値の変換は相互運用レイヤーが担う。逆方向も同じだ。Lisp の関数を他の言語のコールバックにできる。
ソースブロックはネストできる。以下は examples/one-form.lisp ファイルの全文そのままである。セットアップの後、ひとつの式が 7 つの言語すべてを通過して値を渡す。
;;;; 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)
ネストしたブロックの前の $ は、その結果を外側の言語に渡す。この式は 7 つの言語実装をまたぎ、それぞれの構文とライブラリを使いながら、ひとつの Lisp プロセスの中で動いている。これはかなりすごいことだと思う。
CLOS との統合はさらに踏み込む。Java の ArrayList.clear() の前に実行されるメソッドを Lisp から追加できる。
(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 の振る舞いを供給できる。
Java コードからは普通の Java クラスに見えるクラスを、Lisp で定義することもできる。OpenLDK の define-java-class はクラスファイルを生成し、アプリケーションクラスローダー経由でインストールする。メソッドの実装は Lisp コードだ。
こうしたクラスは Java インターフェースを実装できる。メソッドから Lisp の状態を参照し、他の言語のコンポーネントを呼び出せる。Java のリフレクションから見れば、ただの Java クラスである。
エラー処理も同じ統合の一部だ。外部呼び出しから抜けてきた NumberFormatException は、型付きの Lisp コンディション foreign-number-format-error になる。これは parse-error でもある。算術、ファイル、型、ストレージのエラーにも、それぞれ対応するマッピングがある。
外部呼び出しインターフェースには use-value と retry のリスタートがある。Lisp のハンドラは、代替の値を返したり、失敗した外部呼び出しを再試行したりできる。
コールバックの中で発生した Lisp エラーは、逆方向に、対応する Java 例外として伝わる。それが Lisp 側に戻ってくれば、元の Lisp コンディションが復元される。どちらの側も、自前のエラー処理機構を使える。
コレクションにも同様の橋渡しがある。Java のリストは Lisp のシーケンスとして公開でき、要素はアクセス時に変換される。Lisp のシーケンスやハッシュテーブルは、裏付けのあるリストやマップのビューを通じて Java に提示できる。
ビルドシステムもこの仕組みに加わる。ASDF システムには、Maven JAR コンポーネントと、対応言語のソースコンポーネントを含められる。Kotlin、Scala、Groovy のファイルは ASDF のコンパイル操作中にコンパイルされ、そのクラスは後続のコンポーネントから利用できる。Maven コンポーネントはライブラリを共有クラスパスに置く。依存関係は現状、明示的に列挙する必要がある。
インラインの Kotlin ブロックと Scala ブロックは、スプライスが静的型を持ち、その言語のコンパイラが動いていれば、周囲の Lisp ファイルと一緒にコンパイルできる。クラスバイト列は FASL に格納される。その FASL をロードする際に、これらのブロックを再コンパイルする必要はない。これらのインターフェースと言語固有の変換については、相互運用のドキュメントで説明している。
これらの機能は、すでに動いているいくつかの大きなプログラムの上に成り立っている。
Java の javac コンパイラは OpenLDK 上で動作し、ビルドはウォームアップ済みの javacl 実行ファイルを生成する。Clojure はプロトコル、レコード、アトム、フューチャーを備えて動く。Kawa と JScheme は Scheme を実行する。Scala のコンパイラと REPL が動作し、Kotlin はソースを実行可能なクラスファイルにコンパイルし、Groovy は動的スクリプトと静的コンパイル済みクラスを実行する。
ABCL は特に愉快なループを加える。Common Lisp を Java バイトコードにコンパイルし、それを Common Lisp に戻し、SBCL でコンパイルする。その REPL、コンパイラ、CLOS、コンディション、Java 相互運用はこの環境で動く。
Spring Boot と Quarkus のアプリケーションもある。Spring PetClinic は組み込み Tomcat、Hibernate、H2 で動く。これは後述の起動計測に使う小さなコマンドラインアプリケーションとは別物だ。
コンパイルにはそれなりのコストがかかる。大きな言語実装をコールドスタートすると数分かかることがある。OpenLDK が実行しながら実装自体をコンパイルしているからだ。実行可能イメージを保存すると、ここが大きく変わる。
ウォームイメージはビルド中にアプリケーションを動かし、選んだ経路を実行して、コンパイル済みのコードを保存する。ウォームアップで通らなかった経路は、初回利用時にコンパイルされる。
アプリケーションの状態をどうするかは別の選択だ。デフォルトでは dump-warm-image がアプリケーションクラスの静的状態をリセットするので、初期化をやり直せる。:reset-classes nil を指定すると、ウォームアップが残した状態を保持する。その状態を再利用するよう設計されたアプリケーションなら、初期化の多くが済んだ状態で起動できる。
Spring Boot のスナップショットはこう動く。
ベンチマークアプリケーションは、自動構成、レコードにバインドされた構成プロパティ、依存性注入、自動構成された Jackson マッパーを使っている。CommandLineRunner が JSON を生成し、値を読み戻す。通常の起動では Spring のバナーが表示され、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 が起動する。以降の呼び出しはコンテキストを再利用し、そのランナーを実行する。
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 が初期化され、2 回目で再利用の経路を通る。イメージを保存すると、初期化済みのオブジェクトグラフがそのまま残る。
発想としては CRaC(Coordinated Restore at Checkpoint)に近い。アプリケーションを一度初期化して状態を保存し、以降の起動でその作業を再利用する。CRaC がチェックポイント済みの JVM プロセスを復元するのに対し、OpenLDK は SBCL のイメージ保存機構を使い、Spring コンテキストがすでに存在する状態で main を再度呼び出す。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 jar | 2138.3 ms | 2078.9 ms | 2219.0 ms | 266 MB |
| HotSpot、展開済み jar | 1791.3 ms | 1755.0 ms | 1880.1 ms | 225 MB |
| HotSpot + Spring AOT | 1481.4 ms | 1420.5 ms | 1575.8 ms | 194 MB |
| HotSpot + JDK AOT キャッシュ | 758.8 ms | 713.4 ms | 828.8 ms | 181 MB |
| HotSpot + Spring AOT + AOT キャッシュ | 595.1 ms | 568.5 ms | 625.6 ms | 157 MB |
| GraalVM ネイティブイメージ | 53.5 ms | 50.9 ms | 63.1 ms | 41 MB |
| OpenLDK ウォームイメージ | 1261.7 ms | 1237.8 ms | 1283.2 ms | 450 MB |
| OpenLDK スナップショットイメージ | 83.9 ms | 77.5 ms | 88.6 ms | 175 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 を対象としている。
polyglot のサンプルを試すには、ビルド要件に従ったうえで、OpenLDK のチェックアウトで次のコマンドを実行する:
ocicl install
make sbcl-polyglot
./sbcl-polyglot --load examples/one-form.lisp
7 言語を経て: LXI (sixty-one)。