Pushing the JNI Boundaries: Java Meets Assembly
This lesson in assembly and Java will teach you how to use the Java Native Interface to work directly with an assembler.
Join the DZone community and get the full member experience.
Join For FreeThe Java Native Interface (JNI) is used by developers to call native code compiled from other languages. Most online resources, including the Javadocs, showcase examples based on either C or C++. Nevertheless, is it possible to go a level lower and call code compiled from assembly without any intermediary C or C++ layer?
This article will walk you through a simple example. No prior knowledge of the JNI is necessary and only basic understanding of assembly fundamentals will be enough for you to survive this journey. The original source code is available on GitHub alongside a minimal build script.
Example Outline
Consider the following Java class:
public class JNIArraySum {
public static native long computeNativeArraySum(int[] array, int arrayLength);
}
The objective is to write an assembly implementation of this native method declaration that sums all elements within an integer array and returns the result as a long. An equivalent plain Java implementation would resemble the following:
public long computeArraySum(int[] array) {
long sum = 0L;
for (int value : array) sum += value;
return sum;
}
Naming Conventions
When looking for a method within a native library, the JVM follows a well-defined naming convention, described in the documentation. In our case, we need to concatenate the prefix Java
, the class name, and the method name, all separated by underscores: Java_JNIArraySum_computeNativeArraySum
.
Let's slowly warm up and start writing a bit of assembly! This mangled method name has to be visible from other translation units, which can be achieved by declaring a symbol as follows:
global Java_JNIArraySum_computeNativeArraySum
The above uses NASM/Yasm syntax, but other assemblers may rely on different keywords, for instance public
in FASM. On macOS, prepend an additional underscore to match the operating system's calling convention:
global _Java_JNIArraySum_computeNativeArraySum
Arguments Passed by Java
The JVM passes several arguments to the native function when called:
- A pointer to
JNIEnv
, which we will come back to later. - A reference to the calling Java class or object.
- The parameters defined in the Java method's declaration (
int[] array
andint arrayLength
).
Depending on the targeted hardware architecture, these arguments can be held in registers, on the stack or even in other memory structures. In the x84-64 world, the first argument (JNIEnv
) is put in the rdi register, the second one (calling the Java object) is put in rsi, the third one (int[] array
) is put in rdx, and finally, the fourth one (int arrayLength
) is put in rcx.
With this in mind, we can start writing the Java_JNIArraySum_computeNativeArraySum
function:
Java_JNIArraySum_computeNativeArraySum:
push rdx ; save Java array pointer for later use
push rdi ; save JNIEnv pointer for later use
push rcx ; save array length for later use
Nothing fancy happening here: Some of the parameters are simply pushed onto the stack for later use. Don't forget to prefix the function name with an extra underscore if you're on macOS.
Data Type Mappings
Java data types need to be mapped to native data types as these may not have the same representation. The JNI provides a number of different functions to do so, all listed in the docs. The example at hand operates on a Java integer array that can be mapped to a native one using the GetIntArrayElements
function. Once all necessary computations are performed on the native array, the JVM must be informed that those resources are no longer needed. This can be done with the ReleaseIntArrayElements
function, which will free any memory buffers that were used for the native data type mapping.
These JNI functions seem promising, but how does one call them from assembly?
Calling JNI Functions
Remember the JNIEnv
pointer passed in the rdi register? This pointer is itself a pointer to the JNI function table, which contains pointers to the individual interface functions. Lost with all these pointers? Things can be summed up thanks to this diagram:
Let's follow the first arrow using assembly and store the table pointer in the rax register:
mov rax, [rdi] ; get location of JNI function table
The index of each JNI function can be found in the documentation. To map an index to an actual position in memory, multiplying the index by the size of a pointer in the targeted architecture and adding the resulting offset to the address of the table will do the trick. In x84-64, each pointer is 8 bytes (64 bits). Therefore, to call GetIntArrayElements
with index 187:
mov rsi, rdx ; set array parameter for GetIntArrayElements
call [rax + 187 * 8]
The first instruction copies the Java integer array pointer to the register used to store the first argument of function calls, rsi. The second instruction maps this Java integer array to a native array and puts the resulting memory location in the rax register.
Summing Elements
We now have all the data structures required to compute the sum. The following code snippet loops through the array using rax as the current address, rcx as the upper bound and rbx as the accumulator for the sum:
pop rcx ; retrieve array length
lea rcx, [rax + 4 * rcx] ; compute loop end address (after last array element)
mov r8, rax ; copy native array pointer for later use
xor rbx, rbx ; initialise sum accumulator
add_element:
movsx r9, dword [rax] ; get current element
add rbx, r9 ; add to sum
add rax, 4 ; move array pointer to next element
cmp rax, rcx ; has all array been processed?
jne add_element
Note that the number 4 above corresponds to the number of bytes per integer.
The native array can now be released by calling the ReleaseIntArrayElements
JNI function with index 195 :
pop rdi ; retrieve JNIEnv
pop rsi ; retrieve Java array pointer
push rbx ; store sum result
mov rax, [rdi] ; get location of JNI function table
mov rdx, r8 ; set elems parameter for ReleaseIntArrayElements
call [rax + 195 * 8]
To finish the computation, in addition to the final ret
instruction, the result must be stored in rax:
pop rax ; retrieve sum result
ret
Compiling and Running
We're done with all the assembly code! Feel free to pull down the full source if you've missed something. To compile it using NASM and targeting a Linux x86-64 system, a command similar to the following can be run:
nasm -f elf64 -o ArraySum.o ArraySum.asm
To compile with Yasm, invoke yasm
instead of nasm
. On macOS, replace elf64
by macho64
.
The produced object files must then be linked, for instance using good old GCC or Clang:
gcc -shared -z noexecstack -o libArraySum.so ArraySum.o
clang -shared -o libArraySum.so ArraySum.o
Let's add a bit of sugar to the initial Java code in order to wire things together and print the result:
import java.io.File;
public class JNIArraySum {
private static final int[] ARRAY_TO_SUM = { 2, 41, 92, 9, 52, 27, 20, 0, 22, 35, 3, 57, 33, 4, 40, 44, 59, 31, 71, 5 };
public static void main(String[] args) {
File file = new File("libArraySum.so");
System.load(file.getAbsolutePath());
long sum = computeNativeArraySum(ARRAY_TO_SUM, ARRAY_TO_SUM.length);
System.out.println("The result of the sum is: " + sum); // expected result: 647
}
public static native long computeNativeArraySum(int[] array, int arrayLength);
}
Finally to compile the Java code and run it:
javac JNIArraySum.java
java JNIArraySum
Hurray, the program prints the expected result when run!
Conclusive Notes
Hopefully, you will now be able to write Java code that can interoperate with low-level assembly. Obviously, not many people in their right mind would want to sum an array this way, especially without any vectorization, but beyond its educational value, this example can be a basis for further experimentation! These techniques can also easily be extended to match different assembler or operating system requirements. Long live assembly and Java!
Opinions expressed by DZone contributors are their own.
Comments