How to Build a Shared Library in Java and Call it From .NET Code

Peter Lenjo
11 min readJun 22, 2023

--

Wanna see me do it again?

In my last article, I explored how to build a shared library in C# and call it from Java code. If you haven’t read that one, I highly recommend that you do before starting on this one. This article will cover building a native shared library in Java, compile that to a native shared library and then load it into a .NET application written in C#. To follow along, you will need (I will also link to all the code at the end of the post):

GraalVM is a runtime that compiles Java code into native binaries for improved performance. It uses a just-in-time compiler called Graal to transform Java bytecode into optimized machine code. By eliminating the need for a Java Virtual Machine, GraalVM reduces startup time and enables faster application launch. It also supports multiple programming languages and offers cloud-native optimizations, enhancing container density and resource utilization. Overall, GraalVM empowers developers to build high-performing applications that leverage the strengths of different languages.

There is an excellent guide here that covers installing GraalVM alongside your existing Java installation(s) (if any) and troubleshooting common problems with the GraalVM Updater — gu. Once GraalVM is properly set up, you should see ouptut resembling below for java -version :

GraalVM JDK version

You should then be able to install native-image by running:

gu install native-image

The Java Library

In this article I will use a library I’ve previously built but feel free to create one of your own, or use this example from the GraalVM wiki. The library I will be using — libmonday — is a tiny Java library that uses regular expressions to parse M-PESA transaction confirmation messages to determine:

  • Whether the message is an M-PESA message.
  • The type of transaction confirmation message.
  • The transaction details contained in the message.

The library exposes three public methods:

// test whether a message is an M-PESA message.
public static boolean test(String message);

// detect the kind of transaction contained in the message.
public static Optional<TransactionType> detect(String message);

// parse the transaction details from the M-PESA message.
public static Optional<Transaction> parse(String message);

As with Native AOT, methods to be exported to native callers must follow a few guidelines. Firstly, they must be static, to avoid having to instantiate a class before the methods can be used. Secondly — similarly to the [UnmanagedCallersOnly] attribute in C#, exported Java methods must have the @CEntryPoint annotation, and have Thread or IsolateThread as the first paraameter. Another notable restriction in Native Image is that Java code cannot throw exceptions to native callers — this will just cause the calling program to print the exception and terminate. Lastly, similarly to Native AOT we cannot use reference types as parameters and return types.

No object types are permitted for parameters or return types; only primitive Java values, word values, and enum values are allowed.
- Native Image API Reference

To adhere to these restrictions, I have added this Java class that “wraps” this functionality with simpler types:

package io.github.sixpeteunder.libmonday.interop;

import io.github.sixpeteunder.libmonday.Transaction;
import org.graalvm.nativeimage.IsolateThread;
import org.graalvm.nativeimage.c.function.CEntryPoint;
import org.graalvm.nativeimage.c.type.CCharPointer;
import org.graalvm.nativeimage.c.type.CTypeConversion;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Optional;
import java.util.Properties;


/**
* Wrapper class to do any marshalling/unmarshalling required for native interop.
*/
public class NativeTransaction {

@CEntryPoint(name = "test")
public static boolean test(IsolateThread thread, CCharPointer message) {
return Transaction.test(CTypeConversion.toJavaString(message));
}

@CEntryPoint(name = "detect")
public static CCharPointer detect(IsolateThread thread, CCharPointer message) {
String type = Transaction.detect(CTypeConversion.toJavaString(message))
.flatMap(t -> Optional.of(t.toString()))
.orElse(null);

try (final CTypeConversion.CCharPointerHolder holder = CTypeConversion.toCString(type)) {
return holder.get();
}
}

@CEntryPoint(name = "parse")
public static CCharPointer parse(IsolateThread thread, CCharPointer message) {
String data = Transaction.parse(CTypeConversion.toJavaString(message))
.map(NativeWrapper::pack)
.orElse("");

try (final CTypeConversion.CCharPointerHolder holder = CTypeConversion.toCString(data)) {
return holder.get();
}
}

private static String pack(Transaction transaction) {
Properties properties = new Properties();

properties.setProperty("transactionType", String.valueOf(transaction.getTransactionType()));
properties.setProperty("reference", transaction.getReference());
properties.setProperty("amount", String.valueOf(transaction.getAmount()));
properties.setProperty("counterparty", transaction.getCounterparty());
properties.setProperty("dateTime", transaction.getDateTime());
properties.setProperty("newBalance", String.valueOf(transaction.getNewBalance()));
properties.setProperty("transactionCost", String.valueOf(transaction.getTransactionCost()));
properties.setProperty("remainingLimit", String.valueOf(transaction.getRemainingLimit()));

ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
properties.store(output, null);
} catch (IOException e) {
return "";
}

return output.toString();
}
}

No changes are required for the test method because boolean values are support by GraalVM without any changes. For the detect method, we will get the transaction type as a String, and then get a C character pointer from it. For the detect method, which returns an object, the workaround is to pack the transaction details into a String, which is also converted into a char pointer.

The next step is to compile this into a Java Archive (.jar) which can be run on the Java virtual machine. There is any number of ways to do this. In my case, my project is using the Gradle Build Tool, so I need to run

./gradlew clean build

If, at this point, you encounter errors like below, where the GraalVM types canot be found, confirm that you are using the GraalVM Java distribution on your terminal or in your IDE.

error: package org.graalvm.nativeimage.c.constant does not exist :(

This will have generated a .jar of your library, in my case in the build/libs directory like so:

Houston, we have liftoff.

Next, we will use Native Image to compile this into a native shared library. To do this,

native-image --classpath libmonday-1.0-SNAPSHOT.jar:. --shared -H:Name=libmonday
  • The --classpath option here tells Native Image where to look for class files. It accepts a colon-separated list of directories and ZIP or JAR archives.
  • The --shared option tells GraalVM to build a native shared library.
  • The -H:Name argument allows you to set the name of your library.

When this completes, you will notice that a few files have been added to your build directory:

Native Image build output

Interestingly, unlike .NET’s NativeAOT, Native Image auto-generates a pair of header files each for your shared library and GraalVM’s isolates (What are GraalVM isolates?). The first pair, libmonday.h and graal_isolate.h, while the second pair, with the _dynamic suffix, allows for dynamic linking at runtime.

Using the Shared Library from Native C Code

To ascertain that the Java code is now callable as a native shared library, we will write a small C program to test it out. My C program looks like below:

#include <stdlib.h>
#include <stdio.h>

#include <libmonday.h>

int main(int argc, char **argv) {
graal_isolate_t *isolate = NULL;
graal_isolatethread_t *thread = NULL;

if (graal_create_isolate(NULL, &isolate, &thread) != 0) {
fprintf(stderr, "graal_create_isolate error\n");
return 1;
}

char* messages[] = {
"XXXYYYZZZ1 confirmed.You bought Ksh100.00 of airtime on 27/4/21 at 11:05 AM.New M-PESA balance is Ksh5,350.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 299,900.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"Hello World",
"XXXYYYZZZ2 confirmed.You bought Ksh30.00 of airtime on 25/4/21 at 3:45 PM.New M-PESA balance is Ksh5,450.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 299,940.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"XXXYYYZZZ3 Confirmed. Ksh500.00 sent to KPLC PREPAID for account 123456789 on 23/4/21 at 9:57 PM New M-PESA balance is Ksh5,941.22. transactions.Transaction cost, Ksh23.00. Amount you can transact within the day is 296,283.00.",
"XXXYYYZZZ4 Confirmed. Ksh607.00 paid to SUPERMARKET FULANI HAPO. on 23/4/21 at 3:26 PM.New M-PESA balance is Ksh6,494.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 296,813.00.You can now access M-PESA via *334#",
"XXXYYYZZZ5 Confirmed. Ksh1,030.00 sent to MSEE FULANI HAPO +254722222222 on 23/4/21 at 2:38 PM. New M-PESA balance is Ksh7. transactions.Transaction cost, Ksh22.00. Amount you can transact within the day is 297,420.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"Another message",
"XXXYYYZZZ6 Confirmed.on 23/4/21 at 12:31 PMWithdraw Ksh1,500.00 from 010000 - Shop fulani hapo New M-PESA balance is Ksh8. transactions.Transaction cost, Ksh28.00. Amount you can transact within the day is 298,500.00.",
"XXXYYYZZZ9 Confirmed. Ksh80.00 sent to MSEE WA CHIPO 0704444444 on 12/4/21 at 1:57 PM. New M-PESA balance is Ksh 210. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 296,665.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"XXXYYYZZZ10 Confirmed. Ksh700.00 paid to GEL NAIL SHOP. on 17/3/21 at 6:54 PM.New M-PESA balance is Ksh3,646.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 299,300.00.You can now access M-PESA via *334#",
"XXXYYYZZZ11 Confirmed. On 25/7/20 at 3:29 PM Give Ksh2,000.00 cash to Agent fulani New M-PESA balance is Ksh2,100.22",
};

for (int i = 0; i <= 10; ++i) {
char* message = messages[i];
int isTransaction = test(thread, message);

printf("Is transaction? %s\n", isTransaction ? "Yes" : "No");

if (isTransaction) {
printf("Transaction type: %s\n", detect(thread, message));
printf("Transaction data: %s\n", parse(thread, message));
} else {
printf("Message: %s\n", message);
}

printf("\n");
}

if (graal_detach_thread(thread) != 0) {
fprintf(stderr, "graal_detach_thread error\n");
return 1;
}

return 0;
}

As you can see, we simply set up a GraalVM isolate, run an array of strings through the library methods then print the output to stdout. We can then compile the C code with the Clang compiler and run the resulting executable as follows:

clang -I. -L. -lmonday monday.c -o monday
LD_LIBRARY_PATH=. ./monday

Here, we are telling clang to compile monday.c into an output (-o) executable called monday , including the current directory( .) in the include ( -I ) and library (-L) search paths. We are also linking (-l) against a library called libmonday. We are then adding the current path to the LD_LIBRARY_PATH environment variable, to tell the executable where to find our libmonday.so shared object. This should output the transaction details for messages that are valid transaction confirmation messages, and just the message for those that are not, like so:

monday.c console output

Calling Into The Shared Library from .NET Code

I would not like to manage the GraalVM isolates in my C# code, so, while you weren’t looking, I built a second library — libmonday_wrapper — in C that exposes the same three methods, while hiding the GraalVM threading implementation details. That is as below. It is also here on GitHub Gist. This is what I will import into my C# application.

libmonday_wrapper on GitHub Gist

The first step here is to create a new C# console application. You can do this with the .NET command line interface as below:

dotnet new create console --output Monday

This will create a minimal C# console application that we can then add our code to call into the C library. Some of this may look familiar if you read the previous post on calling C# code from Java.

using System.Runtime.InteropServices;

List<string> messages = new() {
"XXXYYYZZZ1 confirmed.You bought Ksh100.00 of airtime on 27/4/21 at 11:05 AM.New M-PESA balance is Ksh5,350.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 299,900.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"XXXYYYZZZ2 confirmed.You bought Ksh30.00 of airtime on 25/4/21 at 3:45 PM.New M-PESA balance is Ksh5,450.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 299,940.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"XXXYYYZZZ3 Confirmed. Ksh500.00 sent to KPLC PREPAID for account 123456789 on 23/4/21 at 9:57 PM New M-PESA balance is Ksh5,941.22. transactions.Transaction cost, Ksh23.00. Amount you can transact within the day is 296,283.00.",
"XXXYYYZZZ4 unconfirmed...",
"XXXYYYZZZ4 Confirmed. Ksh607.00 paid to SUPERMARKET FULANI HAPO. on 23/4/21 at 3:26 PM.New M-PESA balance is Ksh6,494.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 296,813.00.You can now access M-PESA via *334#",
"XXXYYYZZZ5 Confirmed. Ksh1,030.00 sent to MSEE FULANI HAPO +254722222222 on 23/4/21 at 2:38 PM. New M-PESA balance is Ksh7. transactions.Transaction cost, Ksh22.00. Amount you can transact within the day is 297,420.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"C# is fun!",
"XXXYYYZZZ6 Confirmed.on 23/4/21 at 12:31 PMWithdraw Ksh1,500.00 from 010000 - Shop fulani hapo New M-PESA balance is Ksh8. transactions.Transaction cost, Ksh28.00. Amount you can transact within the day is 298,500.00.",
"XXXYYYZZZ9 Confirmed. Ksh80.00 sent to MSEE WA CHIPO 0704444444 on 12/4/21 at 1:57 PM. New M-PESA balance is Ksh 210. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 296,665.00. SAFARICOM ONLY CALLS YOU FROM 0722000000. To reverse, forward this message to 456.",
"XXXYYYZZZ10 Confirmed. Ksh700.00 paid to GEL NAIL SHOP. on 17/3/21 at 6:54 PM.New M-PESA balance is Ksh3,646.22. transactions.Transaction cost, Ksh0.00. Amount you can transact within the day is 299,300.00.You can now access M-PESA via *334#",
"XXXYYYZZZ11 Confirmed. On 25/7/20 at 3:29 PM Give Ksh2,000.00 cash to Agent fulani New M-PESA balance is Ksh2,100.22",
};

var query = from message in messages let result = Process(message) where result.IsTransaction select result.TransactionData;
query.ToList().ForEach(Console.WriteLine);

static (bool IsTransaction, string? TransactionType, string? TransactionData) Process(string message)
{
IntPtr messagePointer = Marshal.StringToHGlobalAnsi(message);

bool isTransaction = test_fn(messagePointer) > 0;

IntPtr transactionTypePointer = detect_fn(messagePointer);
string? transactionType = Marshal.PtrToStringAnsi(transactionTypePointer);

IntPtr transactionDataPointer = parse_fn(messagePointer);
string? transactionData = Marshal.PtrToStringAnsi(transactionDataPointer);

return (isTransaction, transactionType, transactionData);
}

[DllImport("libmonday_wrapper")]
static extern int test_fn(IntPtr message);


[DllImport("libmonday_wrapper")]
static extern IntPtr detect_fn(IntPtr message);


[DllImport("libmonday_wrapper")]
static extern IntPtr parse_fn(IntPtr message);

This is a simple C# program that uses LINQ to pass the List of messages through the methods in the shared library and prints out the TransactionData, but only if the message is a transaction notification.

The most important part of the code above is the [DllImport] attribute. We use this to define the signature of the imported methods, and the library from which to import them. If we don’t specify a suffix to the library name, .NET will automatically look for the appropriate library based on the operating system — .so on Linux, .dll on Windows and .dylib on MacOS. .NET will also automatically search your project root and other library paths on your system for the correct library. Additionally, you will notice that we are marshalling the strings to and from C character pointersm since we cannot directly pass strings to native code.

The program can now be run from your favourite IDE, or using the .NET CLI, like so:

LD_LIBRARY_PATH=. dotnet run

First, we set the LD_LIBRARY_PATH variable, to tell the dynamic link loader where to find your shared libraries (What is LD_LIBRARY_PATH?), and then call dotnet run. You should see output similar to the transaction data from the C program.

It works!

In this article, we’ve built a library in Java and compiled that into a Java archive. We then compiled that .jar file into a C shared library using GraalVM and wrote a wrapper library to hide the GraalVM threading details from calling code. Finally, we loaded and ran that library in a C# application. As promised, all of the code from this article is available in the Monday repository on my GitHub page. That’s it for this article, see you next time.

Hey there 👋. I’m Peter. I’m a software engineer that enjoys messing around with technologies that let different systems talk to each other, lTike APIs, GraphQL, gRPC, websockets, webassembly and interop in its various forms.

--

--