nandi/libjavapublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/libjava.git
git clone ssh://git@rickub.com/nandi/libjava.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add a Clojure interop example 0f4b377 · on main · nandithebull · 2h ago
README.md · 141 lines · 5.2 KBmarkdown
Blame HistoryOpen raw

libjava

Embed a JVM in a Nim process and call Java from it. Thin JNI bindings plus a
layer that makes the common cases pleasant.

import libjava

let jvm = startJVM(classPath = ["build/classes"], options = ["-Xmx256m"])
defer: jvm.destroy()
let env = jvm.env

let math = env.findClass("java/lang/Math")
echo env.callStatic[:JDouble](math, "sqrt", "(D)D", 2.0)

Build

The JDK is located at compile time: -d:javaHome=/path/to/jdk, else
JAVA_HOME, else by following javac on PATH. The include flags and
-ljvm (with an rpath, so no LD_LIBRARY_PATH needed) are added for you.

nimble test
nimble demo

There is also a justfile wrapping these. just build produces a release
binary of the example at out/demo; just check, just docs, just clean
and just jdk (prints the JDK that will be linked) round it out. just -l
lists them; the nimble tasks stay the source of truth.

Pass -d:libjavaNoLink if your process already has the JVM symbols — when
this code is itself a shared library loaded by a JVM.

What's here

Module
libjava/config compile-time JDK discovery, C/link flags
libjava/jni raw JNI bindings — the two function tables, JNI_CreateJavaVM
libjava/core strings, exceptions, typed calls, signatures
libjava/vm JVM lifecycle, per-thread JNIEnv
Example
examples/demo.nim Java: statics, objects, fields, exceptions, collections
examples/clojure_demo.nim Clojure via clojure.java.api.Clojure

Other JVM languages

Anything that compiles to JVM bytecode is reachable without a line of
language-specific support — it's just jars on the classpath.
examples/clojure_demo.nim calls Clojure through its documented Java API,
which is two members wide:

# clojure.java.api.Clojure/var -> a clojure.lang.IFn; then IFn/invoke
let greet = env.callStatic[:JObjectRef](clojure, "var",
  "(Ljava/lang/Object;Ljava/lang/Object;)Lclojure/lang/IFn;", "demo.core", "greet")
discard env.call[:JObjectRef](greet, ifn, "invoke",
  "(Ljava/lang/Object;)Ljava/lang/Object;", arg)

Requiring a namespace, reading a keyword and walking a Clojure map are all that
same pair pointed at clojure.core. Run it with just clojure, which needs the
clojure CLI on PATH; deps.edn pins the dependency and just clojure-cp
resolves it into build/clojure-classpath. Note that IFn/invoke takes
Object, so primitives must be boxed — java.lang.Long.valueOf in the example.

Calling

Method signatures are JVM descriptors. javap -s SomeClass prints the exact
string for anything you're unsure about; signature(["int", "String"], "void")
builds simple ones in code.

# static
env.callStatic[:string](cls, "shout", "(Ljava/lang/String;)Ljava/lang/String;", "hi")
env.callStatic[:void](cls, "run", "()V")

# construct + instance call
let obj = env.newObject(cls, "(Ljava/lang/String;)V", "world")
echo env.call[:string](obj, cls, "greet", "(I)Ljava/lang/String;", 2)

# omit the class and it resolves against the object's runtime class
echo env.call[:string](obj, "greet", "(I)Ljava/lang/String;", 2)

The type parameter picks the JNI call variant, so it has to agree with the
descriptor's return type. string is sugar for an object call plus a
conversion. Nim's int maps to Java int; use asLong(x) for long, and
asByte / asShort / asChar / asFloat for the other narrow types.

Three things JNI will bite you with

Exceptions don't unwind C. A Java throw just sets a flag on the thread, and
nearly every subsequent JNI call is undefined behaviour while it's set. Every
call here ends in checkException, which clears the throwable and raises
JavaError with the class name, message, and Java stack trace attached. If you
drop to the raw layer, call env.checkException() yourself.

Local references accumulate. They're only freed when the native frame
returns, so a loop that creates them will exhaust the reference table long
before that. Wrap the body:

for i in 0 ..< 100_000:
  env.withLocalFrame(8):
    let s = env.newJString("item " & $i)
    discard env.toNimString(s)

To keep a reference past the frame, promote it: env.newGlobalRef(obj), and
deleteGlobalRef when done.

JNIEnv is per-thread. Only the JavaVM handle may cross threads. A
thread the JVM didn't start must attach before its first JNI call and detach
before it exits, or the VM won't shut down:

proc worker(vm: JavaVM) {.thread.} =
  let env = envOf(vm)        # attaches on first use
  defer: detachOf(vm)
  ...
createThread(t, worker, jvm.vm)

Strings

newJString / toNimString go through UTF-16, not GetStringUTFChars. JNI's
"UTF-8" is modified UTF-8: supplementary characters become two 3-byte
surrogates and NUL becomes 0xC0 0x80, neither of which is valid UTF-8. Going
through UTF-16 is the only way emoji and other non-BMP text survive the trip.

One VM per process

The JNI spec has never supported creating a second VM, and a destroyed one
can't be restarted. startJVM called twice returns the existing VM. If a JVM
already exists — because your library was loaded by one — use
attachExistingJVM(), or adoptJVM(vm) with the handle from JNI_OnLoad.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# libjava

Embed a JVM in a Nim process and call Java from it. Thin JNI bindings plus a
layer that makes the common cases pleasant.

```nim
import libjava

let jvm = startJVM(classPath = ["build/classes"], options = ["-Xmx256m"])
defer: jvm.destroy()
let env = jvm.env

let math = env.findClass("java/lang/Math")
echo env.callStatic[:JDouble](math, "sqrt", "(D)D", 2.0)
```

## Build

The JDK is located at compile time: `-d:javaHome=/path/to/jdk`, else
`JAVA_HOME`, else by following `javac` on `PATH`. The include flags and
`-ljvm` (with an rpath, so no `LD_LIBRARY_PATH` needed) are added for you.

```bash
nimble test
nimble demo
```

There is also a `justfile` wrapping these. `just build` produces a release
binary of the example at `out/demo`; `just check`, `just docs`, `just clean`
and `just jdk` (prints the JDK that will be linked) round it out. `just -l`
lists them; the nimble tasks stay the source of truth.

Pass `-d:libjavaNoLink` if your process already has the JVM symbols — when
this code is itself a shared library loaded *by* a JVM.

## What's here

| Module | |
|---|---|
| `libjava/config` | compile-time JDK discovery, C/link flags |
| `libjava/jni` | raw JNI bindings — the two function tables, `JNI_CreateJavaVM` |
| `libjava/core` | strings, exceptions, typed calls, signatures |
| `libjava/vm` | JVM lifecycle, per-thread `JNIEnv` |

| Example | |
|---|---|
| `examples/demo.nim` | Java: statics, objects, fields, exceptions, collections |
| `examples/clojure_demo.nim` | Clojure via `clojure.java.api.Clojure` |

## Other JVM languages

Anything that compiles to JVM bytecode is reachable without a line of
language-specific support — it's just jars on the classpath.
`examples/clojure_demo.nim` calls Clojure through its documented Java API,
which is two members wide:

```nim
# clojure.java.api.Clojure/var -> a clojure.lang.IFn; then IFn/invoke
let greet = env.callStatic[:JObjectRef](clojure, "var",
  "(Ljava/lang/Object;Ljava/lang/Object;)Lclojure/lang/IFn;", "demo.core", "greet")
discard env.call[:JObjectRef](greet, ifn, "invoke",
  "(Ljava/lang/Object;)Ljava/lang/Object;", arg)
```

Requiring a namespace, reading a keyword and walking a Clojure map are all that
same pair pointed at `clojure.core`. Run it with `just clojure`, which needs the
`clojure` CLI on `PATH`; `deps.edn` pins the dependency and `just clojure-cp`
resolves it into `build/clojure-classpath`. Note that `IFn/invoke` takes
`Object`, so primitives must be boxed — `java.lang.Long.valueOf` in the example.

## Calling

Method signatures are JVM descriptors. `javap -s SomeClass` prints the exact
string for anything you're unsure about; `signature(["int", "String"], "void")`
builds simple ones in code.

```nim
# static
env.callStatic[:string](cls, "shout", "(Ljava/lang/String;)Ljava/lang/String;", "hi")
env.callStatic[:void](cls, "run", "()V")

# construct + instance call
let obj = env.newObject(cls, "(Ljava/lang/String;)V", "world")
echo env.call[:string](obj, cls, "greet", "(I)Ljava/lang/String;", 2)

# omit the class and it resolves against the object's runtime class
echo env.call[:string](obj, "greet", "(I)Ljava/lang/String;", 2)
```

The type parameter picks the JNI call variant, so it has to agree with the
descriptor's return type. `string` is sugar for an object call plus a
conversion. Nim's `int` maps to Java `int`; use `asLong(x)` for `long`, and
`asByte` / `asShort` / `asChar` / `asFloat` for the other narrow types.

## Three things JNI will bite you with

**Exceptions don't unwind C.** A Java throw just sets a flag on the thread, and
nearly every subsequent JNI call is undefined behaviour while it's set. Every
call here ends in `checkException`, which clears the throwable and raises
`JavaError` with the class name, message, and Java stack trace attached. If you
drop to the raw layer, call `env.checkException()` yourself.

**Local references accumulate.** They're only freed when the native frame
returns, so a loop that creates them will exhaust the reference table long
before that. Wrap the body:

```nim
for i in 0 ..< 100_000:
  env.withLocalFrame(8):
    let s = env.newJString("item " & $i)
    discard env.toNimString(s)
```

To keep a reference past the frame, promote it: `env.newGlobalRef(obj)`, and
`deleteGlobalRef` when done.

**`JNIEnv` is per-thread.** Only the `JavaVM` handle may cross threads. A
thread the JVM didn't start must attach before its first JNI call and detach
before it exits, or the VM won't shut down:

```nim
proc worker(vm: JavaVM) {.thread.} =
  let env = envOf(vm)        # attaches on first use
  defer: detachOf(vm)
  ...
createThread(t, worker, jvm.vm)
```

## Strings

`newJString` / `toNimString` go through UTF-16, not `GetStringUTFChars`. JNI's
"UTF-8" is *modified* UTF-8: supplementary characters become two 3-byte
surrogates and NUL becomes `0xC0 0x80`, neither of which is valid UTF-8. Going
through UTF-16 is the only way emoji and other non-BMP text survive the trip.

## One VM per process

The JNI spec has never supported creating a second VM, and a destroyed one
can't be restarted. `startJVM` called twice returns the existing VM. If a JVM
already exists — because your library was loaded by one — use
`attachExistingJVM()`, or `adoptJVM(vm)` with the handle from `JNI_OnLoad`.