How to Build a Shared Library in C# and Call it From Java Code

Peter Lenjo
13 min readJun 2, 2023

--

Hello from C#!

A library is a collection of pre-compiled code that provides specific functionality. This allows code reuse across various applications without having to rewrite the code. It is common for libraries to be written in low-level languages like C, C++, D and Rust and used from other programming languages. Some easily recognizable examples of this are:

  • The GIMP Toolkit (GTK) — a popular toolkit for creating graphical user interfaces, that is written in C.
  • SQLite — a lightweight file-based database engine (also the most widely deployed and used database) that provides a relational database management system in the form of a C library.
  • libcurl — a client-side library for making network requests using various protocols including HTTP, FTP, SMTP, POP3, TELNET and more.

Libraries can either be statically or dynamically linked. When an application is linked to a static library, all the necessary code is copied from the library into the application into the library at compile time. This means that the resulting executable contains the entire library’s code, making it self-contained and independent of the library’s presence on the target system. These have the .lib extension on Windows and the .a extension on Linux and MacOS.

Dynamic — or “shared” — libraries, on the other hand, are compiled libraries that contain code and data that can be used by multiple applications simultaneously. Unlike static libraries, shared libraries are not copied into the application at compile-time. Instead, when the application is executed, the operating system locates and loads the required shared libraries into memory, resolving the references and enabling the application to access the library’s functionality. They are also known as shared objects (.so) on Linux, dynamically linked libraries (.dll) on Windows, and dynamic libraries (.dylib) on Mac OS.

Shared libraries offer numerous advantages, making them a preferred choice in many scenarios. They promote code modularity and extensibility, allowing developers to separate functionality into reusable components and dynamically load additional features at runtime. This facilitates easier maintenance and updates, as changes to the library code can be applied independently without recompiling and redistributing the entire application. Dynamic libraries also enable memory sharing among applications, reducing overall memory usage. Furthermore, this enables developers to leverage a wide range of existing functionality with ease. They can tap into a rich ecosystem of pre-built libraries, well-documented information, and community support, which simplifies development and accelerates the implementation of complex features.

As a developer, you may be familiar with the concept of buidling and using library, package, gem or crate in the same programming language. In this article, I will build a C# class library, compile it into a C shared library and then load it into a Java application. To follow along, you will need these tools (links to all of the code from this article will also be at the end of the article):

Building the Shared Library in C#

First we need to create a new C# class library. This can be done from your IDE of choice or from the command line, as follows.

dotnet new create classlib --output LibSample
  • dotnet new create is a command provided by the .NET Command Line Interface that scaffolds out a new project from a template. Templates exist for various services from console, web and cloud services. You may use dotnet new list to list all available templates, or dotnet new --help to view other available commands.
  • classlib is the short name of the template we are using to create this project. This scaffolds out a new C# class library, but the language can be switched to F# by passing in the --language option like so:
    dotnet new create classlib --language F#.
  • --output LibSample is an option that specifies the directory this project should be created in. In this case, the dotnet CLI will create a “LibSample” directory in the current folder and add these files to that directory.

The generated project directory contains two files and one folder:

Initial LibSample project structure
  • Class1.cs is a C# source file that is added to your project by default. You may delete this or rename/refactor it to whatever you would like. I will call mine LibSample.cs.
  • LibSample.csproj is your project file. This is an XML file tells the Microsoft Build Engine (MSBuild) how to build your project. This is where you would add your build configuration and/or dependencies.
  • The obj directory serves as a temporary storage location for intermediate build artifacts. When you build a .NET project, the source code is compiled into object files, which are binary representations of the compiled code. These object files contain machine-readable instructions that can be further processed to generate the final executable or library.

.NET and Java projects typically make use of Just-in-Time (JIT) compilation, which means that the C# or Java code will first be compiled into an intermediate representation — CLR Intermediate Language (CIL) for .NET and Java bytecode for Java. This is then run on a virtual machine — the Common Language Runtime (CLR) for .NET and Java Virtual Machine (JVM) for Java —which compiles it down to binary instructions that the target system understands at runtime. This has two distinct advantages. First, compilers only have to worry about compiling the source code to the intermediate representation, and the virtual machine can take care of compiling for different target operating systems and architectures. This makes it easier to achieve the “write-once-run-anywhere” ability that both languages share. Secondly, the virtual machine has access to a form of the source code and can optimize pieces of the code that are called often (“hot paths”) at runtime. This technique is known as profile-guided optimization (PGO) and can offer significant performance boosts for long-running programs like web servers. Just-in-Time compilation, however, also has its downsides, including increased memory usage required to run the virtual machine which must also be present on the target machine.

Both languages also support Ahead-of-Time (AOT) compilation directly into the target machine’s instructions via NativeAOT for .NET and GraalVM for Java. Building a shared library that your operating system and other programming languages can understand without requiring the NET runtime requires that the library is compiled directly into the target system’s instructions. We therefore need tell MSBuild to compile this project using NativeAOT. To do that we need to go into the LibSample.csproj file and add the <PublishAot> property, like so:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>libsample</AssemblyName>
<PublishAot>true</PublishAot>
</PropertyGroup>
</Project>

I have also added the <AssemblyName> property, which simply tells MSBuild what to call the the compiled library, so I will get a libsample.so on Linux, libsample.dll on Windows and libsample.dylib on MacOS.

The next thing to do is to add the code that you would like to call from your external code/applications to your shared library. For the purpose of this article, I have added just the methods below:

using System.Runtime.InteropServices;

namespace LibSample;

public static class LibSample
{
const string GREETING = "Hello there";

[UnmanagedCallersOnly(EntryPoint = "add")]
public static int Add(int a, int b) => a + b;

[UnmanagedCallersOnly(EntryPoint = "subtract")]
public static int Subtract(int a, int b) => a - b;

[UnmanagedCallersOnly(EntryPoint = "multiply")]
public static int Multiply(int a, int b) => a * b;

// UnmanagedCallersOnly methods only accept primitive arguments.
// The primitive arguments have to be marshalled manually if necessary.
[UnmanagedCallersOnly(EntryPoint = "greet")]
public static IntPtr Greet(IntPtr namePointer)
{
// Parse string from the passed pointer
// Default to "friend" if a null pointer is passed.
string name = Marshal.PtrToStringAnsi(namePointer) ?? "friend";

// Concatenate strings
string greeting = $"{GREETING}, {name}";

// Assign pointer of the concatenated string to sumPointer
IntPtr sumPointer = Marshal.StringToHGlobalAnsi(greeting);

// Return pointer
return sumPointer;
}
}

The only different thing from a regular C# class library here is the [UnmanagedCallersOnly] attribute. This tells .NET that the method annotated with this attribute is meant to be exported and called from native code. Note that this attribute can only be added to static methods. It is also possible in C# to make the whole class static to ensure that it can never be instantiated and that all of its methods must also be static

There are a few more limitations to be aware of while building native shared libraries in C#, such as:

  • Exported methods must not be called from “managed code” (What is “managed code”?).
  • Function parameters and arguments must be of types that do not require special handing when passed from managed to unmanaged code. These are called blittable types in .NET.
  • These methods must not have generic type parameters or be contained within a generic class.

Finally, you need to compile your code into a shared library. Using the .NET CLI that can be done as follows:

dotnet publish /p:NativeLib=Shared --runtime linux-x64 --configuration release

To build a static library instead, just replace the NativeLibrary property with `Static`. like so:

dotnet publish /p:NativeLib=Static --runtime linux-x64 --configuration release
  • The --runtime option tells MSBuild what target runtime to build the library for, in my case linux-x64.
  • The --configuration option tells MSBuild what configuration to use to build the project. I theis case we’re using the “Release” configuration — as opposed to “Debug” — which tells MSBuild not to include debug information in the build.
Publishing the shared library

After running this command, a new bin directory will have appeared in your project directory. This is where MSBuild puts the binary results of compilation. Your shared library should be available in the bin/release/.net7.0/<runtime>/native directory. That’s it!. We’ve successfully built a shared library in C#.

(Optional) Call the Shared Library from C Code

To test this, we can try calling the library from a simple C program. This step requires GCC or an IDE with a C Compiler. I have created a simple C program to call the library called LoadLibrary.c which looks like this:

// On unix, make sure to compile using -ldl and -pthread flags.
// See https://github.com/dotnet/samples/blob/main/core/nativeaot/NativeLibrary

// Set this value accordingly to your workspace settings.
#if defined(_WIN32)
#define PathToLibrary "bin\\Debug\\net7.0\\win-x64\\native\\libsample.dll"
#elif defined(__APPLE__)
#define PathToLibrary "./bin/Debug/net7.0/osx-x64/native/libsample.dylib"
#else
#define PathToLibrary "./bin/release/net7.0/linux-x64/native/libsample.so"
#endif

#ifdef _WIN32
#include "windows.h"
#define symLoad GetProcAddress
#else
#include "dlfcn.h"
#include <unistd.h>
#define symLoad dlsym
#endif

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

#ifndef F_OK
#define F_OK 0
#endif

int callAdd(int a, int b);
int callSubtract(int a, int b);
int callMultiply(int a, int b);
char *callGreet(char *name);

typedef int (*arithmeticFunction)(int, int);

int main()
{
// Check if the library file exists
if (access(PathToLibrary, F_OK) == -1)
{
puts("Couldn't find library at the specified path");
return 0;
}

int a = 8, b = 2;

// Sum
int sum = callAdd(a, b);
printf("The sum is %d.\n", sum);

// Subtract
int difference = callSubtract(a, b);
printf("The difference is %d.\n", difference);

// Multiply
int product = callMultiply(a, b);
printf("The product is %d.\n", product);

// Greet
char *result = callGreet("General Kenobi");
printf("%s.\n", result);

// Greet with null pointer
char *nullResult = callGreet(NULL);
printf("%s.\n", nullResult);
}

// Common method to load arithmetic functions since they have similar signatures.
arithmeticFunction loadFunction(char *functionName)
{
#ifdef _WIN32
HINSTANCE handle = LoadLibraryA(path);
#else
void *handle = dlopen(PathToLibrary, RTLD_LAZY);
#endif

// CoreRT/NativeAOT libraries do not support unloading
// See https://github.com/dotnet/corert/issues/7887
return (arithmeticFunction)symLoad(handle, functionName);
}

// Call the add function defined in the C# shared library
int callAdd(int a, int b)
{
arithmeticFunction add = loadFunction("add");
return add(a, b);
}

// Calls the subtract functon defined in the C# shared library
int callSubtract(int a, int b)
{
arithmeticFunction subtract = loadFunction("subtract");
return subtract(a, b);
}

// Calls the multiply functon defined in the C# shared library
int callMultiply(int a, int b)
{
arithmeticFunction multiply = loadFunction("multiply");
return multiply(a, b);
}

// Call the greet function defined in the C# shared library
char *callGreet(char *name)
{
// Library loading
#ifdef _WIN32
HINSTANCE handle = LoadLibraryA(PathToLibrary);
#else
void *handle = dlopen(PathToLibrary, RTLD_LAZY);
#endif

// Declare a typedef
typedef char *(*greetFunction)(char *);

// Import Symbol named funcName
greetFunction greet = (greetFunction)symLoad(handle, "greet");

// The C# function will return a pointer
return greet(name);
}

Compile this with GCC and run it. Remember that the library code was written in C# and we are now trying to access it from a C program. On the command line, compile the LoadLibrary.c program and run the resulting LoadLibrary executable like so:

gcc -o LoadLibrary LoadLibrary.c
./LoadLibrary

Alternatively, run the program using your IDE of choice. You can do this in Visual Studio Code using this C/C++ extension. You should see output from each of the methods that were called, like so:

LoadSampleLib program output.

Calling the Shared Library from Java

The very first thing we would need to do would be to create a Java project in your favourite IDE, like so:

IntelliJ IDEA Java project setup

Note that for the purpose of this article, you must use at least JDK 20. It is not mandatory to create a Git repository. The choice of build system also does not matter for this project. Feel free to pick the one you feel most comfortable with. This will generate a new project directory with a few default files, like so:

  • The .idea folder is used by IntelliJ IDEA to store your workspace configuration. It stores settings such as open files, data source connections and more.
  • The payphone.iml file is used by IntelliJ for keeping module configuration. Modules allow you to combine several technologies and frameworks in one application. See the IntelliJ IDEA documentation for more information on IntelliJ IDEA modules.
  • The src folder is where all of your source code will reside. There is already a Main.java class that we will adapt to call into out shared library.

Next, we will use jextract to generate Java bindings to the shared library. The jextract tool is part of Java’s Project Panama, which aims to ease the interaction between Java and foreign (non-Java) APIs. It comprises three main modules:

  • Foreign Function and Memory API — useful for allocating and accessing off-heap memory and calling foreign functions directly from Java code.
  • Vector API — enables advanced developers to express complex data parallel algorithms in Java.
  • Jextract — a tool to mechanically derive Java bindings from a set of native headers.

To generate the Java bindings into the shared library using jextract, the first thing you will need is a C header (.h) file. Create one in the root directory of the java project with the type definitions for the functions in the shared library. For libsample.so, the libsample.h file would look like this:

int add(int a, int b);
int subtract(int a, int b);
int multiply(int a, int b);
char *greet(char *name);

Next, use jextract to derive Java bindings from the header file.

jextract --source --output src --target-package org.example.libsample --header-class-name LibSample --library /path/to/your/libsample.so libsample.h
  • --source tells jextract to generate Java source files rather that compiled class files (.class)
  • --output specifies the directory to place generated files. If this
    option is not specified, then current directory is used.
  • --target-package specifies the target package name for the generated files.
  • --header-class-name specifies the name of the generated header class. If this option is not specified, then header class name is derived from the header file name. For example, class “libfoo_h” for header “libfoo.h”.
  • --library specifies a library by platform-independent name (“libsample”) or by absolute path (“/usr/lib/libsample.so”) that will be loaded by the generated class.
    NOTE: If you use only the name of the library, Java will try to find it on your system. On Linux, for example, Java will by default look in /usr/java/packages/lib, /usr/lib64, /lib64, /lib and /usr/lib.
  • The libsample.h argument points to the header file for which you are generating bindings.

This will generate Java source files in the specified output directory and package that bind to your shared library. Feel free to inspect these files and specifically note how they use the java.lang.foreign and java.lang.invoke APIs to call into the shared library. You then need to add your application’s code that calls into these bindings. This may look something like:

import java.lang.foreign.MemorySegment;
import java.lang.foreign.SegmentAllocator;
import java.lang.foreign.SegmentScope;

import static org.example.libsample.LibSample.*;

public class Main {
public static void main(String[] args) {
int a = 8;
int b = 2;

// Sum
int sum = add(a, b);
System.out.printf("The sum is %d.\n", sum);

// Subtract
int difference = subtract(a, b);
System.out.printf("The difference is %d.\n", difference);

// Multiply
int product = multiply(a, b);
System.out.printf("The product is %d.\n", product);

// Greet
// Passing strings require a little more legwork.
// We allocate the string to a memory segment off-heap and pass the pointer to that to the native code.
SegmentAllocator allocator = SegmentAllocator.nativeAllocator(SegmentScope.auto());
MemorySegment generalKenobi = allocator.allocateUtf8String("General Kenobi");
MemorySegment result = greet(generalKenobi);
String greeted = result.getUtf8String(0);
System.out.printf("%s.\n", greeted);

// Greet with a mull pointer
String nullGreeted = greet(MemorySegment.NULL).getUtf8String(0);
System.out.printf("%s.\n", nullGreeted);
}
}

The only thing to left to do is to run the application, which can be done via the IDE or java command line tools. However, Java’s Foreign Function Invocation and Memory APIs are in preview, and we will need to explicitly tell Java to enable preview APIs for release 20 during the build, using the argument --enable-preview. In IntelliJ IDEA, this can be done by simply adding the argument to the Java Compiler settings and Run Configuration like so:

Enabling Java preview features in compiler settings.
Enabling Java preview features and native access in run configuration.

If you do not have an existing run configuration, simply create a run configuration of type “Application” and select your main class. To show the VM options input, select it under “Modify Options” > “Java”. It is also possible to do this on the command line by running these commands from the src directory:

javac Main.java org/example/libsample/*.java --enable-preview --release 20
java --enable-preview --enable-native-access=ALL-UNNAMED Main

This should output the results of the arithmetic operations as defined in the shared library:

Payphone program output

That’s it. We’ve built a class library in C#, compiled it into a shared library, and loaded it into a Java program. As promised, the code for the C# LibSample project is available in my libsample repository on GitHub and the code for the Java application is available in my payphone repository, also on GitHub. In my next post, I will demonstrate doing the same thing in reverse — building a shared library in Java and Calling it from C# code.

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

--

--