A comparison of JNI, JNA and C interop in Kotlin

I'm considering a language switch for some of my programs that access native Linux libraries towards Kotlin. The biggest question for me is: How I can tackle this challenge? ... big enough for a little research and an article that might help you as well to pick your favorite.

Background

As some of the lcarsde tools stopped being installable on the newer Ubuntu 24.04 versions, I need to come up with a solution. The solution will likely be to combine the tools with the window manager. That means, they will need to be rewritten in Kotlin, but it also means they can share some code, if I modularise cleverly. Now, while the window manager will stay a native application, the following question opens up for the tools: Is it better to have them as native applications or running on the Java Virtual Machine (JVM)? The decision for either depends on two things: how to access native libraries and which graphical user interface (GUI) framework to use? Today, we will take a look on the first one.

The plan

In this article, I’m going to look at and compare Java Native Interface (JNI), Java Native Access (JNA) and Kotlin/Native C interop. These are options that we can use to connect our Kotlin program to native (C/C++) libraries. While most of the things written here may work for multiple platforms, I focus on Linux. This is not a deep-dive into the different platform features, because they are described in detail in other documentation and articles. I rather want to compare the principles of the three options to give you and me a foundation for decision-making.

The example

For evaluation purposes, I will implement an adapter for POSIX message queues for each of the options. POSIX message queues are one way to support interprocess communication. I’ve chosen this example, because I use it for communication between some of my applications and it covers a few aspects that are not part of standard tutorials. The example implementations will contain a bare minimum for opening a queue, sending and receiving messages and closing the queue again. To use the message queue library, we will need the header file mqueue.h and the library librt.so. Don’t use the examples for your real live applications without at least adding some error handling!

Btw., there is a Java library available for POSIX IPC.

JNI

To quote the Oracle JNI specification (Sept. 2024):

The JNI is a native programming interface. It allows Java code that runs inside a Java Virtual Machine (VM) to interoperate with applications and libraries written in other programming languages, such as C, C++, and assembly.

When using JNI, we start by creating a Java class where each method that calls native code is marked with the keyword native and has no implementation. Then we can use the command javac -h [/target/path] [MyClassName].java to generate a header file from the class. The file name will be [package_name_MyClassName].h. It contains the definitions of the functions that need to be implemented in C, C++ or … . You will find that the function names correspond to the package-class-method name combination. The connection to the library can now be made in p.ex. C++ by implementing the functions. Then your C/C++ code needs to be compiled and linked to create the adapter library. Don’t forget to add the path to this library to the java.library.path property when running your program, so the JVM knows where to find the native implementation. You can check out this Baeldung article on JNI for details. Make sure that the compiled library has a name of the form libABC.so, because this is the standard way and JNI expects it like that when loading the library. That is, when you load the library using System.loadLibrary("ABC"), it will look for libABC.so.

Now wait a moment, aren’t we writing Kotlin instead of Java? Well … the issue is that we can not generate the *.h file from Kotlin. However, we have a couple of options:

  • We can simply write the Java file and after generating the header file we use IntelliJ’s Convert Java file to Kotlin file option to convert the file to Kotlin. Be aware that any changes to the class name, the method names or signatures or to the package name will require corresponding adjustments in the header file and its implementation.
  • Or write the class with JNI code in Kotlin (use the keyword external instead of native) and write the native header file manually (if you dare 😅). Later changes need to be applied to the header and implementation files as in the previous case.
  • Or don’t care that you have a Java file in the midst of your Kotlin application for each class that uses JNI. In this case you can simply regenerate the header file in case of changes.

How does it compare?

Pros

I like that there is no extra library required to use JNI. It’s part of Java. All the Java / Kotlin code looks like Java / Kotlin code, because the work of connecting the two worlds is mostly done on the native side. This also gives you a certain flexibility on where to put your functionality, in the Java / Kotlin part or in the C/C++/… part. As per different internet sources, p.ex. this stackoverflow comment, JNI implementations run faster than JNA. So if your application makes a lot of calls towards native parts, using JNI might be beneficial.

Cons

The biggest issue for me with JNI is that you have to write C/C++/… code, part of that being IntelliJ IDEA not supporting C/C++ coding. This might change with JetBrains Fleet, but at the moment of writing this article, I use VS code in parallel. Compiling and linking your code and making sure that the JVM application finds the generated library is required as well. The next important one for me is that we talk about writing Kotlin, but as far as I know, the header file can only be generated from Java. So you either adjust/create the header file manually or you leave the JNI interface file in Java. I’d love IDE support on that one.

Example implementation

I separated the code into the JNI interface class and another wrapper class for ease of use. I hope that this helps you understand what is necessary for JNI. I’d probably combine it in a real life scenario.

Here is the Java version of the message queue JNI class:

public class JNIMQ {
    static {
        System.loadLibrary("JniMqNative");
    }

    public native int mqOpen(String name, int mode, boolean isManaging);

    public native int mqSend(int mqDes, String message);

    public native String mqReceive(int mqDes);

    public native int mqClose(int mqDes);

    public native int mqUnlink(String name);
}

Here is the same interface in Kotlin:

class JNIMQ {
    external fun mqOpen(name: String?, mode: Int, isManaging: Boolean): Int

    external fun mqSend(mqDes: Int, message: String?): Int

    external fun mqReceive(mqDes: Int): String

    external fun mqClose(mqDes: Int): Int

    external fun mqUnlink(name: String?): Int

    companion object {
        init {
            System.loadLibrary("JniMqNative")
        }
    }
}

The following shows the additional wrapper:

class MQ(private val name: String, private val mode: Mode, private val isManaging: Boolean = false) {
    enum class Mode(val flag: Int) {
        READ(0),
        WRITE(1),
        READ_WRITE(2)
    }

    private val mq = JNIMQ()
    private val mqDescriptor = mq.mqOpen(name, mode.flag, isManaging)

    fun send(message: String?) {
        check(mode != Mode.READ) { "Can not use MQ send in read mode" }
        mq.mqSend(mqDescriptor, message)
    }

    fun receive(): String {
        check(mode != Mode.WRITE) { "Can not use MQ receive in write mode" }
        return mq.mqReceive(mqDescriptor)
    }

    fun close() {
        if (mq.mqClose(mqDescriptor) == -1) {
            return
        }
        if (isManaging) {
            mq.mqUnlink(name)
        }
    }
}

The generated interface looks like this:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class de_atennert_example_JNIMQ */

#ifndef _Included_de_atennert_example_JNIMQ
#define _Included_de_atennert_example_JNIMQ
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     de_atennert_example_JNIMQ
 * Method:    mqOpen
 * Signature: (Ljava/lang/String;IZ)I
 */
JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqOpen
  (JNIEnv *, jobject, jstring, jint, jboolean);

/*
 * Class:     de_atennert_example_JNIMQ
 * Method:    mqSend
 * Signature: (ILjava/lang/String;)I
 */
JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqSend
  (JNIEnv *, jobject, jint, jstring);

/*
 * Class:     de_atennert_example_JNIMQ
 * Method:    mqReceive
 * Signature: (I)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_de_atennert_example_JNIMQ_mqReceive
  (JNIEnv *, jobject, jint);

/*
 * Class:     de_atennert_example_JNIMQ
 * Method:    mqClose
 * Signature: (I)I
 */
JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqClose
  (JNIEnv *, jobject, jint);

/*
 * Class:     de_atennert_example_JNIMQ
 * Method:    mqUnlink
 * Signature: (Ljava/lang/String;)I
 */
JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqUnlink
  (JNIEnv *, jobject, jstring);

#ifdef __cplusplus
}
#endif
#endif

My implementation of this interface is this:

#include "de_atennert_example_JNIMQ.h"
#include <mqueue.h>

#define MAX_MESSAGE_COUNT 10
#define MAX_MESSAGE_SIZE 1024
#define MESSAGE_BUFFER_SIZE MAX_MESSAGE_SIZE + 10

JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqOpen
  (JNIEnv * env, jobject obj, jstring name, jint mode, jboolean isManaging) {
    const char* namePointer = env->GetStringUTFChars(name, NULL);

    if (isManaging) {
      const int oFlags = mode | O_NONBLOCK | O_CREAT;
      const int permissions = 0660;

      struct mq_attr attr;
      attr.mq_flags = O_NONBLOCK;
      attr.mq_maxmsg = MAX_MESSAGE_COUNT;
      attr.mq_msgsize = MAX_MESSAGE_SIZE;
      attr.mq_curmsgs = 0;

      return mq_open(namePointer, oFlags, permissions, &attr);
    } else {
      return mq_open(namePointer, mode);
    }
}

JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqSend
  (JNIEnv * env, jobject obj, jint mqDes, jstring message) {
    const char* msgPointer = env->GetStringUTFChars(message, NULL);
    const size_t msgSize = env->GetStringUTFLength(message);

    return mq_send(mqDes, msgPointer, msgSize, 0);
}

JNIEXPORT jstring JNICALL Java_de_atennert_example_JNIMQ_mqReceive
  (JNIEnv * env, jobject obj, jint mqDes) {
    char buffer [MESSAGE_BUFFER_SIZE];

    ssize_t msgSize = mq_receive(mqDes, buffer, MESSAGE_BUFFER_SIZE, NULL);

    return env->NewStringUTF(buffer);
}

JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqClose
  (JNIEnv * env, jobject obj, jint mqDes) {
    mq_close(mqDes);
}

JNIEXPORT jint JNICALL Java_de_atennert_example_JNIMQ_mqUnlink
  (JNIEnv * env, jobject obj, jstring name) {
    const char* nameCharPointer = env->GetStringUTFChars(name, NULL);

    mq_unlink(nameCharPointer);
}

JNA

JNA was created to simplify the process of using native libraries. It is not necessary to generate a header file and write C/C++ code. Instead, the binding is done automatically by JNA. How does that look like?

First, we need to add the JNA Platform dependency. This contains the framework for connecting to the libraries and the types that we need to extend in order for JNA to find the definitions. At minimum, we extend the Library interface, through which the connection is made. It is required that the methods that are declared in the interface have the same name used in the library. So if we want to call mq_open from the librt library, our method in Kotlin is named mq_open and has the matching arguments. Be aware of special types like unsigned types or size_t. Some of these also have ready to use corresponding types in JNA, others don’t. If you use Kotlin and need to map structs or your special types, make sure that the types fit after compiling. Use annotations like @JvmField or @JvmOverloads to do so. See the code below for examples. Finally, the library must be loaded. Here we use Native.load like Native.load("ABC", MyInterface::class.java). The naming restrictions for libraries in Linux are the same as mentioned in JNI. For a detailed introduction into JNA, check out the Baeldung article on JNA.

How does it compare?

Pros

Let’s start with the most obvious: no Java, no C/C++, it’s enough to write Kotlin. It’s easy to write the interface, when you know the library that you work with, that is the names of functions and structures, as well as the types that are used. The binding to the library is created automatically at runtime by JNA. This includes that there is no overhead for tracking changes between Kotlin / Java and C/C++ and for compiling and linking.

Cons

The biggest issue for me is that there is no IDE support for types and names. I had to research all the names and types to get the interface definition correctly. You may also need to deal with pointers that are not automatically typed. As stated in the JNI part, it’s supposedly a bit slower, which might make an impact if you move lots of data around. A last minor point is that everything around the interface definition might look a bit ugly, because you need to follow the exact naming of the library interface, which may not follow Kotlin naming standards.

Example implementation

We start again with the message queue interface. We see that all the definitions are now done in Kotlin and there is no need for binding code in the way it was necessary for JNI.

class Uint32_t @JvmOverloads constructor(private val value: Int = 0) :
    IntegerType(4, value.toLong(), true) {
    override fun toByte() = value.toByte()

    override fun toShort() = value.toShort()
}

@Structure.FieldOrder("mq_flags", "mq_maxmsg", "mq_msgsize", "mq_curmsgs")
class MqAttributes : Structure() {
    @JvmField
    var mq_flags: Long = 0
    @JvmField
    var mq_maxmsg: Long = 0
    @JvmField
    var mq_msgsize: Long = 0
    @JvmField
    var mq_curmsgs: Long = 0
}

interface JNAMQ : Library {
    fun mq_open(__name: String?, __oflag: Int, permission: Uint32_t, attributes: MqAttributes): Int
    fun mq_open(__name: String?, __oflag: Int): Int

    fun mq_send(__mqdes: Int, __msg_ptr: String, __msg_len: SIZE_T, __msg_prio: Uint32_t): Int

    fun mq_receive(__mqdes: Int, __msg_ptr: PointerByReference, __msg_len: SIZE_T, __msg_prio: Uint32_t?): SSIZE_T

    fun mq_close(__mqdes: Int): Int

    fun mq_unlink(__name: String): Int

    companion object {
        val INSTANCE: JNAMQ = Native.load("rt", JNAMQ::class.java)

        const val QUEUE_PERMISSIONS = 432 // 0660

        const val O_NONBLOCK = 0x800
        const val O_CREAT = 0x40
    }
}

Again, I wrote an additional wrapper to make things a bit easier. This wrapper contains the details about interacting with the library, which I did in C/C++ in JNI.

class MQ(private val name: String, private val mode: Mode, private val isManaging: Boolean = false) {
    enum class Mode(var flag: Int) {
        READ(0),
        WRITE(1),
        READ_WRITE(2)
    }

    private val mq = JNAMQ.INSTANCE
    private val mqDescriptor: Int

    init {
        if (isManaging) {
            val oFlags = mode.flag or JNAMQ.O_NONBLOCK or JNAMQ.O_CREAT
            val attributes = MqAttributes()
            attributes.mq_flags = JNAMQ.O_NONBLOCK.toLong()
            attributes.mq_maxmsg = MAX_MESSAGE_COUNT
            attributes.mq_msgsize = MAX_MESSAGE_SIZE
            attributes.mq_curmsgs = 0

            mqDescriptor = mq.mq_open(name, oFlags, Uint32_t(JNAMQ.QUEUE_PERMISSIONS), attributes)
        } else {
            mqDescriptor = mq.mq_open(name, mode.flag)
        }
    }

    fun send(message: String) {
        check(mode != Mode.READ) { "Can not use MQ send in read mode" }
        mq.mq_send(mqDescriptor, message, SIZE_T(message.length.toLong()), Uint32_t(0))
    }

    fun receive(): String {
        check(mode != Mode.WRITE) { "Can not use MQ receive in write mode" }
        val pref = PointerByReference()
        val msgSize = mq.mq_receive(mqDescriptor, pref, SIZE_T(MESSAGE_BUFFER_SIZE), null)
        return pref.pointer.getByteArray(0, msgSize.toInt()).decodeToString()
    }

    fun close() {
        if (mq.mq_close(mqDescriptor) == -1) {
            return
        }
        if (isManaging) {
            mq.mq_unlink(name)
        }
    }

    private companion object {
        const val MAX_MESSAGE_SIZE = 1024L
        const val MAX_MESSAGE_COUNT = 10L
        const val MESSAGE_BUFFER_SIZE = MAX_MESSAGE_SIZE + 10
    }
}

C interop

The main difference between C interop and the other two options is that JNI and JNA work on the JVM, while C interop does not. It is available when you use Kotlin to create a native application. This has two important implications. The first is that Java libraries and frameworks are not available. The second is that there is no binding layer between native libraries and your compiled code at runtime like it would be with the native library and the JVM. It works all on the same platform. What is required, is a mapping that Kotlin uses to provide access to those libraries. However, this mapping is generated by Kotlin C interop tooling.

To use C interop, we need to create a Kotlin / Native application by utilizing Kotlin Multiplatform Mobile (KMP). As long as we stay with common libraries the binding is already available, which is the case for message queues. Otherwise, we can create a definitions file that contains references to the library header file that we need and the compiler and linker options. These are defined like for C/C++ compilers. This definition file is then used to generate the bindings, either on command line or by executing corresponding build targets in the IDE. One thing to note on C interop is that it is still experimental, so we need to add @ExperimentalForeignApi annotations. Find the details for using C interop in the Kotlin documentation for interoperability with C.

As soon as the bindings are created, we can access the library functions. We don’t need to write any kind of binding manually.

How does it compare?

Pros

I like how easy it is to handle native libraries with C interop. Kotlin tooling creates the binding and we have the fully typed library interface ready to use. There are no additional frameworks necessary and the IDE support of IntelliJ IDEA ist the best of the three options, IMO. The compiled program doesn’t need a JVM to run.

Cons

On the negative side, you are required to build a native application. All the usual Java libraries and frameworks are not available. Like in JNA, the code looks a bit strange, because the style guidelines of Kotlin and the used library usually don’t match. Worse, your code probably requires extensive pointer and memory handling wherever it accesses native libraries. It is also still experimental, so bigger changes might happen.

Example implementation

Having no kind of manual written intermediary binding, I only created the following wrapper that hides the internals of using the message queue.

@ExperimentalForeignApi
class CInteropMQ(private val name: String, private val mode: Mode, private val isManaging: Boolean = false) {
    enum class Mode (val flag: Int) {
        READ(O_RDONLY),
        WRITE(O_WRONLY),
        READ_WRITE(O_RDWR)
    }

    private val mqDes: mqd_t

    init {
        if (isManaging) {
            val oFlags = mode.flag or O_NONBLOCK or O_CREAT
            val attributes = nativeHeap.alloc<mq_attr>()
            attributes.mq_flags = O_NONBLOCK.convert()
            attributes.mq_maxmsg = MAX_MESSAGE_COUNT
            attributes.mq_msgsize = MAX_MESSAGE_SIZE.convert()
            attributes.mq_curmsgs = 0

            mqDes = mq_open(name, oFlags, QUEUE_PERMISSIONS, attributes)
        } else {
            mqDes = mq_open(name, mode.flag)
        }
    }

    @ExperimentalNativeApi
    fun send(message: String) {
        check(mode != Mode.READ) { "Can not use MQ send in read mode" }

        mq_send(mqDes, message, message.length.convert(), 0.convert())
    }

    @ExperimentalNativeApi
    fun receive(): String {
        check(mode != Mode.WRITE) { "Can not use MQ receive in write mode" }

        val msgBuffer = ByteArray(MESSAGE_BUFFER_SIZE)
        var msgSize: Long = -1
        msgBuffer.usePinned {
            msgSize = mq_receive(mqDes, it.addressOf(0), MESSAGE_BUFFER_SIZE.convert(), null)
        }
        if (msgSize > 0) {
            return msgBuffer.decodeToString(0, msgSize.convert())
        }
        return ""
    }

    fun close() {
        if (mq_close(mqDes) == -1) {
            return
        }

        if (isManaging) {
            mq_unlink(name)
        }
    }

    companion object {
        private val QUEUE_PERMISSIONS: mode_t = 432.convert() // 0660
        private const val MAX_MESSAGE_SIZE = 1024
        private const val MAX_MESSAGE_COUNT = 10L
        private const val MESSAGE_BUFFER_SIZE = MAX_MESSAGE_SIZE + 10
    }
}

Summary

We had a look at connecting to native libraries from Kotlin with JNI, JNA and C interop. Using the example of the POSIX message queue, we compared the different implementations, as well as their pros and cons.

When deciding for one of the three options, in my opinion, the key decision points are the following:

  1. Platform - Is the application supposed to run in the JVM or not? This also has implications on frameworks that are available to us?
    • JVM: JNI or JNA
    • Native: C interop
  2. Ease of use - Is the resulting code hard to understand? Do I have or need knowledge on languages like C/C++ or understanding of pointers?
    • JNI requires writing C/C++ code. Headers are generated.
    • JNA may require basic, untyped pointer and special type handling. Developer needs to make sure that names and types are correct.
    • C interop may require extensive pointer handling, but has good tooling and type safety.
  3. Efficiency - If you are on the JVM and JNA becomes a bottleneck, you may prefer JNI.
  4. IDE and language support - From my point of view, C interop has the best IDE integration with full type support. JNA can be completely written in Kotlin. JNI requires Java for the header generation, and there is no support for C/C++ in IntelliJ. There might be better tooling for JNI in other IDEs.

I hope this article helps you in your decision-making. As for my project, I will use JNA. The reason is that I can write everything in Kotlin, efficiency is probably not going to be an issue and the few libraries that I need to use are stable enough to not require adjustments at any time soon. Besides that, it is likely beneficial for me to create a JVM application for some of the available frameworks.