Write JNI libraries in Kotlin on both sides. The native part is ordinary Kotlin/Native, the JVM and Android part is
ordinary Kotlin. No C, no handwritten JNI signatures, no cinterop.def for the boundary — and the compiler checks
that the two sides match, on every build, for every platform you ship.
Kotlin/Native JVM / Android
@JniActual fun hello(): JString ←———→ @JniExpect external fun hello(): String
@JniActualmarks a native implementation,@JniExpectthe JVMexternaldeclaration that it implements.- The producer plugin builds the native library. The consumer plugin validates the JVM side against it, packages the binaries per platform, and rebuilds everything when the native code changes.
- A mismatch between the two sides is a compile error, not an
UnsatisfiedLinkErrorin production.
Reach for it when part of your app has to be native — an image or audio codec, a crypto or compression library, a physics kernel, a port of existing C code, one hot loop — and you would rather not own a C toolchain and a pile of unchecked JNI glue to get there.
Everything this project supports is shown end to end in samples/basic. Use this README as a map and
that sample as the full reference.
- Why
- What you write instead
- What you get
- Quick start
- Single-module projects
- Configuration
- Annotations
- Fast and Critical natives
- Libraries
- Samples
JNI is a low-level interface, and low-level interfaces do not scale to a real codebase. Three things go wrong, every time.
1. The signature is a string that nobody checks.
JNIEXPORT jstring JNICALL Java_org_sample_Hello_hello(JNIEnv *env, jobject self) {
return (*env)->NewStringUTF(env, "Hello");
}The class name, the function name and the descriptor ()Ljava/lang/String; are three places to keep in sync and
zero places the compiler looks at. Rename a Kotlin function and the C file still compiles, the jar still builds, and
the failure arrives as UnsatisfiedLinkError: No implementation found for ... on a user's machine — for one OS, or
one ABI, or one device.
2. The native side is C, so you write your program twice.
JNIEnv*, jobject, jstring, UTF-8 versus UTF-16, local reference frames, ReleaseStringUTFChars.
The algorithm is fine; everything around it is bookkeeping in a language your JVM side cannot see.
3. The artifacts are hand-assembled.
One binary per OS and per ABI, each copied into the right place inside the jar or the APK, plus the ABI list, plus the startup code that works out which binary this machine needs and unpacks it.
This project removes all three.
The native side, in Kotlin/Native:
@JniActuals(className = "org.sample.Hello")
object Hello {
context(env: JniEnv)
fun hello(): JString? = "Hello from Kotlin/Native".toJString()
}The JVM side, in Kotlin:
@JniExpect
external fun hello(): StringThat is the whole contract. The JNI symbol name and the descriptor are derived from the Kotlin declarations on both sides, checked against each other while the consumer compiles, and packaged into the artifact automatically.
| Hand-written JNI | Here | |
|---|---|---|
| Native code | C / C++ | Kotlin/Native |
| Symbol names, descriptors | written by hand | derived from the Kotlin declarations |
| Contract mismatch | UnsatisfiedLinkError at runtime |
compile error |
| Per-OS / per-ABI binaries | copied by hand | packaged per target by the plugin |
| Rebuild after a native change | your build script | automatic, and the JVM side is revalidated |
| Runtime loading | System.load(...) per module |
your @LoadMethod, called from the class initializer by plugin |
The contract is checked, per platform. Bindings are matched by full JVM class name and function name, and the
signature is derived from the Kotlin types — overloads, extensions, infix, nested classes, nullable parameters,
Array<Float> → java.lang.Float[] all work. When platforms differ, say so per target
(@JniExpect("linuxX64")), and every target is validated against its own set of bindings.
The native side is Kotlin. Your native code is Kotlin/Native: collections, data classes, exceptions,
coroutines — and jni-binding wraps the JNI API into null-safe Kotlin around it: String.toJString(),
JString.toKString(), typed arrays, allocation scopes requested as extra context parameters, field and method IDs,
DSL. You still use cinterop when you call third-party C APIs — that is ordinary Kotlin/Native work — but the JNI
boundary itself is two annotations.
You can start from either side. Write the JVM API first and run ./gradlew :app:generateJniActuals: the stubs for
the missing @JniActuals are generated into the producer.
Performance when you need it. @CriticalNative binds a function to JNI's critical path — the fastest way to call
native code on Android/ART. See Critical natives for what it costs on desktop JVMs.
Two modules: a native producer and a JVM consumer.
// native/build.gradle.kts
plugins {
kotlin("multiplatform")
id("io.github.mimimishkin.jni-binding-producer") version "2.0.0"
}
kotlin {
jvmToolchain(17)
mingwX64().binaries.sharedLib("native") { // or linuxX64() / macosArm64() / ...
linkJvm() // desktop only; Android needs no linkJvm()
}
}
jniLibraries {
expectedJdkVersion = 17
}// native/src/nativeMain/kotlin/Hello.kt
@JniActual(className = "org.sample.HelloKt")
context(env: JniEnv, autofreeScope: AutofreeScope)
fun hello(): JString? = "Hello from Kotlin/Native".toJString()context(env: JniEnv) (and an allocation scope such as AutofreeScope) are optional — add them only when the
body needs them.
// app/build.gradle.kts
plugins {
kotlin("jvm")
id("io.github.mimimishkin.jni-binding-consumer") version "2.0.0"
}
kotlin {
jvmToolchain(17)
target { // or jvm { ... } in a multiplatform project
compilations.named("main") {
jniLibraries.create("native") { // same name as sharedLib("native")
mingwX64 {
fromProducer(project(":native")) // sync natives
copyToResources() // puts the binary into jar resources
}
}
}
}
}// app/src/main/kotlin/org/sample/Hello.kt
@JniExpect
external fun hello(): String
@LoadMethod
private fun load(os: String, arch: String) {
// `os` receives the OS family ("windows", "linux", "macos", "android"),
// `arch` the normalized architecture ("x86_64", "aarch64", ...).
// After copyToResources() the binary is at /natives/$os-$arch/...
// Copy it out of the jar and System.load(...). Full example: samples/basic.
}That is the whole contract: @JniExpect on the JVM side is implemented by @JniActual on the native side.
Tip: declare @JniExpect first, then run ./gradlew :app:generateJniActuals to stub the missing @JniActuals
into the producer.
Most projects keep the producer and the consumer apart, because they are separate artifacts with separate
lifecycles. A small project — a library, a sample, a prototype — has one module with a native target and a JVM
target instead. io.github.mimimishkin.jni-binding applies both parts in one go:
// build.gradle.kts
plugins {
kotlin("multiplatform")
id("io.github.mimimishkin.jni-binding") version "2.0.0" // producer + consumer
}
kotlin {
jvmToolchain(17)
jvm {
compilations.named("main") {
jniLibraries.create("native") {
mingwX64 {
fromProducer(project) // this very module is the producer
copyToResources()
}
}
}
}
mingwX64().binaries.sharedLib("native") {
linkJvm()
}
}
jniLibraries {
expectedJdkVersion = 17
}The Kotlin code is unchanged. The only difference to watch for is the source sets: everything goes into nativeMain /
jvmMain of one module rather than into two modules.
A top-level function in Hello.kt, package org.sample, binds to the JVM file facade org.sample.HelloKt.
Group several functions with @JniActuals so you write the class name once:
@JniActuals(className = "org.sample.HelloKt")
object Hello {
context(env: JniEnv, autofreeScope: AutofreeScope)
fun hello(): JString? = "Hello".toJString()
}jniLibraries {
expectedJdkVersion = 17 // JDK this library is built for (ignored on Android)
exportMethod = JniExportMethod.RegisterNatives // preferred; default is ExposeFunctions
allowSeveralHooks = false // several @JniOnLoad / @JniOnUnload?
}On desktop, call linkJvm() inside sharedLib { ... }. On Android, do not — linking is automatic:
androidNativeArm64().binaries.sharedLib("native")Need AWT? Call linkJAwt() after linkJvm() (and linkX11IfLinux() on Linux). For a non-host desktop target,
pass a target JDK with linkJvm(downloadCompatibleJdk()) — see samples/basic.
jniLibraries.create("native") {
mingwX64 {
fromProducer(project("native")) // build here
copyToResources("natives/$os-$arch") // copy lib into jar resources / Android assets
}
androidArm32 {
fromPrebuiltBinding(prebuiltDir) // or take a binary built elsewhere
copyToJniLibs() // Android only: copy to jniLibs/<abi>/ for System.loadLibrary
}
allowExtraActuals = false
allowAbsentBindings = false
}Targets: mingwX64(), linuxX64(), linuxArm64(), macosArm64(), androidX86(), androidX64(),
androidArm32(), androidArm64().
A build can only link the host OS, so the usual pattern is: build the host from the producer, take other
platforms from a prebuilt folder (as in samples/basic).
Both plugins add their artifacts to the default source sets. With a custom source set hierarchy you may need to add them by hand:
// producer
implementation("io.github.mimimishkin:jni-binding:2.0.0")
implementation("io.github.mimimishkin:jni-binding-annotations:2.0.0")
// consumer
implementation("io.github.mimimishkin:jni-binding-annotations:2.0.0")How external methods find their native implementations:
RegisterNatives(recommended) — register on library load; one load serves every class.ExposeFunctions(default) — exportJava_...symbols by name; each class that declares natives must load the library. Required for critical natives on the JDK — see Critical natives.
JVM
@JniExpect/@JniExpects— thisexternalmember (or whole class) is implemented via JNI. Optional target filter:@JniExpect("mingwX64").@LoadMethod— function that loads the native library (runs from the object's / companion's initializer).@CriticalNative— critical native (for static non-synchronized functions; primitives and primitive arrays only).
Native
@JniActual/@JniActuals— native implementation of an@JniExpect.@JniOnLoad/@JniOnUnload— library load / unload hooks.@WithJvmType/@WithJvmSignature— override the derived JVM type or full signature when needed.@CriticalNative— critical native (noJniEnv/JObject; primitives and primitive arrays only).
See samples/basic for every supported shape.
They are optimizations for faster JNI function calls.
- A
@FastNativeis android only runtime built-in optimization for native methods to speed up JNI transitions. Ignored on desktop. - A
@CriticalNativeis called without aJniEnvand without a class/object reference. Only primitives / primitive arrays (On JDK only - each array arrives as a(length, pointer)pair),staticand non-synchronizedon the JVM side. Also, the method must never call back into the JVM.
// native
@CriticalNative
@JniActual
fun sum(size: Int, values: CArrayPointer<IntVar>): Long // (length, pointer)
// JVM
@CriticalNative
@JniExpect
external fun sum(values: IntArray): Long // int[]Desktop (HotSpot). Undocumented and unsupported: deprecated in JDK 16, removed in JDK 22.
The JavaCritical_ path needs JDK 21 or older, -XX:+CriticalJNINatives option, and a JIT-compiled call site
(128 calls on my machine); until then — and on newer JDKs — the ordinary Java_ facade runs.
Works only with ExposeFunctions export method. Prefer Project Panama for new code.
Android (ART). First-class support — see
CriticalNative and
JNI tips.
Both RegisterNatives and ExposeFunctions work (minSdk 26+).
See basic and android-basic samples for reference.
| Artifact | Role |
|---|---|
jni-binding |
Idiomatic JNI API for Kotlin/Native . |
jawt-binding |
JAWT / AWT native interface; link with linkJAwt(). |
jni-binding-raw |
Raw cinterop of the JDK headers (jni package), if you need it. |
samples/basic— full feature tour and multi-platform producer / prebuilt setup.samples/android-basic— Android app across the JNI boundary: calling the Java API from native, exceptions, memory and threads, critical natives.samples/windows-registry— Windows Registry wrapper (mingwX64only): a typedRegistryAPI over the win32Reg*functions, ~300 lines of Kotlin per side, no C and no JNI signatures anywhere.