OpenLDK: Bringing the Java World into Common Lisp
OpenLDK: Bringing the Java World into Common Lisp (atgreen.github.io)
要約
Common LispでJavaライブラリを利用するためのプロジェクト「OpenLDK」を紹介する記事です。OpenLDKはJavaバイトコードをCommon Lispに変換し、SBCL(Steel Bank Common Lisp)でネイティブコードにコンパイルするJITコンパイラおよびランタイムです。これにより、JavaオブジェクトはLispアプリケーションと同じプロセスとヒープ空間で扱え、JavaライブラリをLispからシームレスに利用できるようになります。さらに、JavaクラスがCLOSクラスになったり、KotlinやClojureなどのJVM言語との相互運用性が向上したりする、興味深い可能性についても触れています。
全文翻訳
OpenLDK: Javaの世界をCommon Lispに持ち込む
何年も前に、私はgcj、つまりGNU Javaプログラミング言語コンパイラという、別のJavaコンパイラとランタイムに取り組んでいました。最近では、ほとんどのタスクで私の好みのプログラミング言語はCommon Lispですが、Javaランタイムにはまだ興味があります。
2024年末頃、SBCLからJavaライブラリを使いたかったのですが、アプリケーションに適した統合アプローチが見つかりませんでした。そこで、LispでシンプルなJVMバイトコードプロセッサを構築して、プロセス内で直接コードを実行できるようにするというアイデアを探求することになりました。
コーディングエージェントがこのような作業に役立つようになる前にコーディングを開始し、最初の動作バージョンを手作業で作成しました。
OpenLDKは、Common Lispで書かれたJava用のJITコンパイラおよびランタイムです。Javaバイトコードを読み込み、Lispに変換し、SBCLにそのLispをネイティブマシンコードにコンパイルさせます。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))
signed-field操作は、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 ファイル全体をそのまま示したものです。セットアップの後、1つの式が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)
$ の前にネストされたブロックを置くと、その結果が囲む言語に渡されます。この式は、1つのLispプロセス内で7つの言語実装を横断し、それぞれの構文とライブラリを使用します。これは非常にクールだと思います。
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 はクラスファイルを生成し、アプリケーションクラスローダーを通じてインストールしますが、そのメソッド実装は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のインポートが提供されると、これがベンチマークのエントリポイントになります。最初の呼び出しは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 CPUs
- 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, extracted 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 cache | 758.8 ms | 713.4 ms | 828.8 ms | 181 MB |
| HotSpot + Spring AOT + AOT cache | 595.1 ms | 568.5 ms | 625.6 ms | 157 MB |
| GraalVM native image | 53.5 ms | 50.9 ms | 63.1 ms | 41 MB |
| OpenLDK warm image | 1261.7 ms | 1237.8 ms | 1283.2 ms | 450 MB |
| OpenLDK snapshot image | 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をターゲットとしています。
ポリグロット例を試すには、ビルド要件 に従い、OpenLDKチェックアウトから次のコマンドを実行してください。
ocicl install
make sbcl-polyglot
./sbcl-polyglot --load examples/one-form.lisp
7つの言語を経て:LXI (sixty-one)。