When JNI Meets 'Write Once, Run Anywhere': Navigating Java's Multi-Architecture Reality

Published: (December 26, 2025 at 09:59 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

Introduction

One of Java’s most celebrated promises is “Write Once, Run Anywhere” (WORA).
The idea is simple and powerful: write your code once, compile it, and run it on any platform—Windows, Linux, macOS, x86, ARM—without modification. This promise has made Java the language of choice for enterprise applications, Android development, and countless other use cases.

For pure‑Java code, this promise holds beautifully. But there’s a caveat.

When you venture into the world of JNI (Java Native Interface)—the bridge between Java and native code written in C, C++, or Rust—the cross‑platform simplicity disappears. Suddenly we have to worry about platform‑specific binaries and are greeted with cryptic UnsatisfiedLinkError messages.

In this article we’ll explore:

  • How Java is normally platform‑independent
  • When and why that independence breaks down with JNI
  • How modern libraries cleverly solve the multi‑architecture challenge
  • A peek under the hood of a production multi‑architecture library

The “Write Once, Run Anywhere” Philosophy

Java’s WORA promise isn’t just marketing—it’s a fundamental architectural decision that revolutionized software development in the mid‑1990s.

The Problem It Solved

Before Java, cross‑platform software required painful compromises:

  • Separate codebases for each platform (Windows, macOS, Unix)
  • Abstraction layers like POSIX, but still needed separate compilation for each platform
  • Interpreted languages such as Perl or Python, which were slower and had a limited ecosystem

Java’s Brilliant Solution

Java introduced an intermediate layer: bytecode. Instead of compiling directly to machine code, Java compiles to a universal, platform‑neutral instruction set that can run on any machine with a JVM.

Source Code (.java) → Bytecode (.class) → JVM (x86/ARM/…) → Machine Code
   [Developer]          [Compiler]          [Runtime]          [CPU]

When you compile Java code with javac, it creates .class files containing bytecode—universal instructions that aren’t specific to x86, ARM, or any particular CPU architecture. These bytecode instructions (e.g., aload_0, getstatic, invokevirtual) are understood by the JVM, not directly by the hardware.

The magic happens at runtime: each platform ships its own JVM implementation (compiled for x86‑linux, aarch64‑darwin, etc.) that reads this universal bytecode and translates it to native machine code via Just‑In‑Time (JIT) compilation. The same .jar (a zipped folder of .class files) can run on Linux x86_64, macOS ARM64, or Windows x86_64 without any modification.

This architecture provides true portability, since the same .jar runs everywhere.

The Exception: When Architecture Matters

Now comes the interesting part: when does this beautiful abstraction break down?

Enter JNI: The Bridge to Native Code

JNI (Java Native Interface) allows Java code to call functions written in C, C++, Rust, or any language that can compile to native machine code.

Why would we want to do this?

  • Performance – Critical operations (e.g., cryptography) are often faster in native code.
  • Hardware Access – Low‑level hardware operations that Java can’t perform.
  • Legacy Integration – Re‑using existing C/C++ libraries.
  • System APIs – Accessing OS‑specific features not exposed in Java.

Note: Using JNI is not always beneficial. JNI calls incur overhead, so invoking them frequently for lightweight operations can increase latency. Use JNI only when the native implementation provides a substantial speed advantage.

The Problem: Native Libraries Are Platform‑and‑Architecture‑Specific

When you compile native code, you must produce a binary for each target platform (Windows, macOS, Linux, …) and each target architecture (x86‑64, ARM64, armv7, …).

We can explore this with a simple test using the SQLite JDBC driver:

# Download the SQLite JDBC jar from Maven
mvn dependency:get -Dartifact=org.xerial:sqlite-jdbc:3.44.1.0

# Extract its contents
jar xf ~/.m2/repository/org/xerial/sqlite-jdbc/3.44.1.0/sqlite-jdbc-3.44.1.0.jar

# Inspect the native libraries
file native/Mac/x86_64/libsqlitejdbc.dylib
# → Mach-O 64-bit dynamically linked shared library x86_64

file native/Mac/aarch64/libsqlitejdbc.dylib
# → Mach-O 64-bit dynamically linked shared library arm64

file native/Windows/x86_64/sqlitejdbc.dll
# → PE32+ executable (DLL) (console) x86-64 ...

file native/Windows/x86/sqlitejdbc.dll
# → PE32 executable (DLL) (console) Intel 80386 ...

file native/Windows/armv7/sqlitejdbc.dll
# → PE32 executable (DLL) (console) ARMv7 ...

Each file is compiled for a specific OS + CPU combination; a single JAR cannot contain a “one‑size‑fits‑all” native library.

How Libraries Solve the Multi‑Arch Problem

Modern Java libraries adopt patterns that let a single Maven/Gradle artifact work across many platforms.

The “Bundle Everything” Pattern

  • What it is: Package all native binaries for every supported platform inside one JAR.
  • How it works: At runtime the library detects the current OS/CPU, extracts the matching native library to a temporary location, and loads it via System.load() or System.loadLibrary().
  • Pros: Simple dependency management for the end‑user (just one artifact).
  • Cons: Larger JAR size; extraction overhead; potential licensing issues if some native binaries have restrictive licenses.

The “Platform/Architecture‑Specific JAR” Pattern

  • What it is: Publish separate JARs for each platform/architecture (e.g., my-lib-linux-x86_64.jar, my-lib-windows-arm64.jar).
  • How it works: The main “core” JAR contains only pure‑Java code and declares optional dependencies on the platform‑specific JARs. Build tools (Maven, Gradle, sbt) resolve the correct variant based on the target classifier.
  • Pros: Smaller artifacts; no runtime extraction; clearer licensing per‑platform.
  • Cons: Slightly more complex dependency declarations; users must ensure the correct classifier is pulled in.

When to Use Each Pattern

SituationRecommended Pattern
Small number of platforms and binary size is negligibleBundle Everything
Large number of platforms or binaries are sizable (e.g., OpenCV, TensorFlow)Platform‑Specific JARs
Strict licensing that varies per binaryPlatform‑Specific JARs
Need for fast startup (no extraction)Platform‑Specific JARs
Simplified distribution (single artifact for end‑users)Bundle Everything

Conclusion

Java’s “Write Once, Run Anywhere” promise holds true for pure‑Java code, thanks to the JVM’s bytecode abstraction. The moment you step into native territory with JNI, you re‑introduce platform‑specific concerns. Modern libraries mitigate this pain by either bundling all native binaries into a single artifact or publishing distinct platform‑specific artifacts. Understanding these patterns lets you choose the right approach for your project and keep the developer experience smooth.

Further Reading

  • The Java™ Native Interface: Programmer’s Guide and Specification – Sun Microsystems
  • JEP 391: macOS/AArch64 Port – OpenJDK
  • Class Loading and Native Libraries – Oracle Documentation

References

  1. Oracle. Java Platform, Standard Edition Documentation. https://docs.oracle.com/javase/8/docs/
  2. Xerial. SQLite JDBC Driver. https://github.com/xerial/sqlite-jdbc
  3. OpenJDK. JEP 391: macOS/AArch64 Port. https://openjdk.org/jeps/391

UI) ARMv7 Thumb, for MS Windows

% file native/Windows/aarch64/sqlitejdbc.dll
native/Windows/aarch64/sqlitejdbc.dll: PE32+ executable (DLL) (GUI) Aarch64, for MS Windows

The x86 .so file won’t work on ARM, and vice‑versa. The Linux .so won’t work on Windows. When you try it, you will get the famous

java.lang.UnsatisfiedLinkError

The “Write Once, Run Anywhere” promise is broken—but only when native code is involved.

How Libraries Solve the Multi‑Arch Problem (Extended)

The “Bundle Everything” Pattern (Revisited)

The most common solution: include all native libraries for all platforms in a single JAR. We saw this in the previous section with sqlite-jdbc.

At runtime the Java code:

  1. Detects OS and architecture from system properties.
  2. Extracts the correct native library from the JAR to a temporary location.
  3. Loads the library using System.load().
  4. Cleans up temporary files on JVM exit.

The JAR file is larger (it contains multiple native libraries), but the developer experience is seamless.

The “Platform/Architecture‑Specific JAR” Pattern (Revisited)

An alternative approach: publish separate JARs for each platform, and let the build tool select the right one at build time.

For example, netty‑tcnative provides:

netty-tcnative-2.0.74.Final-javadoc.jar
netty-tcnative-2.0.74.Final-linux-x86_64-fedora.jar
netty-tcnative-2.0.74.Final-linux-x86_64.jar
netty-tcnative-2.0.74.Final-osx-aarch_64.jar
netty-tcnative-2.0.74.Final-osx-x86_64.jar
netty-tcnative-2.0.74.Final-sources.jar
netty-tcnative-2.0.74.Final.jar

One slight hiccup: if we hard‑code the classifier in pom.xml, the build becomes platform‑specific.

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-tcnative</artifactId>
    <version>2.0.74.Final</version>
    <classifier>linux-x86_64</classifier>
</dependency>

Now the pom.xml only works on Linux x86_64. Developers on macOS can’t build, and we can’t deploy to ARM servers.

The Solution: Maven’s os-maven-plugin

The os‑maven‑plugin automatically detects the build platform and selects the correct JAR.

<plugin>
    <groupId>kr.motd.maven</groupId>
    <artifactId>os-maven-plugin</artifactId>
    <version>1.7.1</version>
</plugin>

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-tcnative</artifactId>
    <version>2.0.74.Final</version>
    <classifier>${os.detected.classifier}</classifier>
</dependency>

The plugin sets ${os.detected.classifier} based on the build environment, so the same pom.xml works on every developer’s machine and in every CI/CD pipeline.

When to Use Each Pattern (Extended)

PatternWhen to Choose It
Bundle Everything- Distributing desktop applications to unknown end‑users
- Want a single universal artifact that works everywhere
- Deploy the same JAR to multiple architectures
- Simplicity outweighs JAR size
Platform‑Specific JARs- Building server applications for known platforms
- Using containerization (each container image is already platform‑specific)
- JAR size matters (e.g., AWS Lambda cold‑start times)
- Consistent CI/CD pipeline per architecture

Conclusion (Extended)

Java’s “Write Once, Run Anywhere” promise isn’t a myth; it just carries a practical asterisk when native code enters the picture. With the patterns above, modern Java libraries can maintain cross‑platform compatibility even when they rely on architecture‑specific native code.

Further Reading (Extended)

  • Project Panama – Foreign Function & Memory API, aiming to replace JNI with a safer, simpler API.
  • GraalVM Native Image – Compiles Java to native binaries, changing the paradigm entirely.
  • Improved JIT compilation – Narrowing the performance gap between Java and native code.

If you liked this article, you might enjoy my other post on Simplifying the complicated world of Libraries.

References (Extended)

Back to Blog

Related posts

Read more »

Embedding JVM in Rust

Java ↔ Rust Interop with the jni Crate !Ivan Yurchenkohttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fd...

My TicketDesk System

Intro For my Intro to Programming module, I made a TicketDesk system in Java that can: - Track tickets - Track login information - Provide a role‑based authent...