When JNI Meets 'Write Once, Run Anywhere': Navigating Java's Multi-Architecture Reality
Source: Dev.to
- Introduction
- The “Write Once, Run Anywhere” Philosophy
- The Exception: When Architecture Matters
- How Libraries Solve the Multi‑Arch Problem
- Conclusion
- Further Reading
- References
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()orSystem.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
| Situation | Recommended Pattern |
|---|---|
| Small number of platforms and binary size is negligible | Bundle Everything |
| Large number of platforms or binaries are sizable (e.g., OpenCV, TensorFlow) | Platform‑Specific JARs |
| Strict licensing that varies per binary | Platform‑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
- Oracle. Java Platform, Standard Edition Documentation. https://docs.oracle.com/javase/8/docs/
- Xerial. SQLite JDBC Driver. https://github.com/xerial/sqlite-jdbc
- 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:
- Detects OS and architecture from system properties.
- Extracts the correct native library from the JAR to a temporary location.
- Loads the library using
System.load(). - 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)
| Pattern | When 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.