Java Foreign Function and Memory API (java.lang.foreign) is a powerful tool
that you can use to take advantage of various OS-specific optimizations. Often
though, due to high usage of the C/C++ preprocessor and its powerful macros it
can be cumbersome to write and maintain the Java-side code.
Help is available in the form of jextract - a JDK tool that reads C/C++ header files (*.h)
and generates Java code that can be used exactly like the original C/C++ macros, structs,
typedefs, etc.
In this post we leverage jextract to implement one such OS-specific optimization:
short-circuit reads and writes, a zero-copy IPC technique based on shared
memory. Also, we show how jextract-generated code is much easier to read
and maintain.
Sharing memory between processes is perhaps the fastest inter-process communication (IPC) mechanism available on modern operating systems. As the name suggests, it literally allows one process to modify a segment of memory and the changes will be immediately visible within another process. No networking is involved, no TCP/IP stack, it’s just two (or more) processes reading from and writing to exactly the same memory.
Shared memory segments are often used by high-performance systems to achieve data locality - to run algorithms on data that is practically in local memory, without the overhead of transferring it. Data may still need to be loaded from disk (if not cached already), but the extra syscalls and context switches that might be incurred when using sockets are completely avoided. There’s no copying of data either, which saves a huge number of CPU cycles.
Additional information:“Short-circuit” I/O
In large-scale distributed systems the term “short-circuiting” (like short-circuit read or short-circuit write) is often used to describe the technique of using shared memory to achieve zero-copy data locality.
Just as in electrical engineering, the phrase was adopted because it suggests a low-resistance path that allows a massive surge of current (or data) to bypass the normal load.
The DataNode of Apache HDFS is a good example of a system that uses short-circuiting to get zero-copy data locality for Apache Spark or MapReduce jobs. When a job is submitted, the driver (application master, etc) program asks HDFS which DataNodes store copies of the desired input. Based on that information it then tries to launch tasks (workers, etc) on the same DataNodes. Once that happens - the worker requests short-circuiting from the DataNode and starts working directly on the memory-mapped blocks that the DataNode shares with it.
Another typical scenario is when multiple applications, written in different programming
languages need to cooperate. As an example: think of researchers using Python notebooks
to interactively analyze a large database implemented in another, high-performance
programming language. The notebook’s DataFrame can ask the database engine to do most of
the heavy lifting (like in a predicte pushdown manner), the database then prepares
partial results in memory, shares that memory back with the notebook, which in turn
lets the researcher to interactively finalize the computation in the desired way.
There are wonderful articles on the web that explain how memory sharing is implemented in an operating system. In here, we’re not going to cover that, we’re just going to include a little reminder of the steps a C programmer would follow in order to implement short-circuiting on the Linux operating system. After all, the goal of this post is to do the same in Java.
So, we begin by creating a shared memory object and its file descriptor:
Then we allocate some memory (size bytes) in the shared object:
Next, we “map” the newly allocated shared memory within our own address space:
Now we can fill the memory pointed to by shared_memory_ptr with whatever data we want
to share with other processes. Changes these processes make to the same memory will be
immediately visible to us too.
So far so good - we’ve allocated a shared memory segment and we can read and write to it. We
haven’t actually shared it though. To do that we simply pass the shared_memory_fd (the file
descriptor behind the shared memory object) to whichever process we want to.
On the Linux operating system this is done by sending a control message (cmsg) via a Unix domain socket (AF_UNIX):
The client receives the control message and gets the file descriptor of the shared memory object:
shared_memory_fd is now available on the “client-side” and can be mapped locally:
And now finally - both ends of the Unix domain socket have a pointer,
shared_memory_ptr that points to the same physical memory. Changes done on one side
will be immediately visible on the other without invoking syscalls or copying data.
Job done!
Alright, so now lets move to implementing the same in Java, as it is the main topic of this blog post.
Additional information:OS-specific code ahead
Java’s motto is “write once, run anywhere.” What follows is code that only works on Linux. That is, the code is not be portable to other operating systems and breaks Java’s promise of platform independence!
Java traditionally focuses on platform independence and with each new release adds more and more abstractions and APIs so that developers can leverage OS-dependent optimizations in an OS-agnostic way. These new APIs usually take time to get standardized and become mainstream JDK features, hence the need to also have a way of calling underlying OS APIs directly.
A good example of the above is the evolution of the Java-to-native interfaces. It began as
Java Native Interface (JNI) - a bunch of (mostly) C++ APIs that a developer could use
to interact with JVM internals, and a native keyword to tell the JVM that invocations of
a given method should be directed back to C++.
Additional information:JNI in practice
The HDFS DataNode that we mentioned earlier actually uses a JNI library, called ‘libhadoop’ and written in C++, to do its short-circuiting.
JNI later evolved into the Foreign Function & Memory (FFM) API (the java.lang.foreign
package), which offers an OS-agnostic way of interacting with the OS dynamic linker -
loading libraries, looking up symbols, calling functions, working with memory pointers,
and so on.
For the purpose of this post - java.lang.foreign is exactly the package we want to be using in
order to implement our memory sharing and short-circuit I/O. With it we can lookup (that is -
find the memory address of) all C/C++ functions we need to call - shm_open, ftruncate,
mmap, etc and get Java-invokeable method handles (java.lang.invoke.MethodHandle) for
each one of them. We can then allocate C-style strings and other memory pointers, pass them
as arguments when calling syscall method handles (to MethodHandle.invokeExact() for example),
and we can also get back the results of such invocations.
Additional information:java.lang.foreign vs. Java Reflection
You can think of the FFM API (java.lang.foreign) as a reflection API for native code.
Conceptually they’re very similar - both are there to dynamically lookup fields and methods, both give you ways of invoking methods and so on. Reflection does it on compiled Java and FFM - on compiled native (C/C++) code.
So, for example, we can create a shared memory object just as we did in C above:
The code above initially looks simple, but you are probably noticing that quite quickly
it is getting cumbersome to both implement and maintain. First, we need to know the
exact signature of each syscall: correct return types and correct argument types.
Then, there’s all those C-macros that go as arguments - O_CREAT, O_RDWR, permissions
like S_IRUSR, S_IWUSR, memory protection modes like PROT_READ, PROT_WRITE, and so on.
All of these come from the C header files, but they’re not symbols and we cannot look them
up with java.lang.foreign.SymbolLookup. We have to go through quite a lot of header files to
find out what all of these macros evaluate to so we can write the same final values in Java.
Additional information:C preprocessor macros
C preprocessor macros save repetitive work and help avoiding mistakes. Each macro is
just a piece of C/C++ code that gets substituted in place during preprocessing, before
code is sent to the compiler. The compiler never sees those macros therefore they never
get to symbol tables in compiled binaries. This is why we cannot look them up with
java.lang.foreign.SymbolLookup.
Preprocessor macros are essentially C’s way of defining constants:
A C developer may write:
But the preprocessor will turn it into:
And this is the code that the compiler sees. The names of constants like O_CREAT,
O_RDWR, S_IRUSR, S_IWUSR, PROT_READ, PROT_WRITE and so on are forever lost.
For example, on Linux, /usr/include/sys/mman.h defines shm_open:
So from this line we know in Java we should be looking for a function called shm_open that
returns an int and takes a pointer, an int, and a mode_t as arguments. What’s a mode_t
and how do we map it in Java? Well, we have to look at /usr/include/bits/types.h to find out:
/usr/include/bits/types.h then defines __mode_t:
In turn, /usr/include/bits/typesizes.h defines __MODE_T_TYPE:
… and so on.
Same with constants like O_CREAT, O_RDWR, S_IRUSR, S_IWUSR - we
have to look them up in /usr/include/bits/fcntl.h and /usr/include/bits/stat.h
to find out their actual values. Once we put the values (0100 for O_CREAT, 02 for O_RDWR,
0400 for S_IRUSR, and 0200 for S_IWUSR) our Java code quickly becomes unreadable and
unmaintainable.
That’s not an issue in C/C++ because the preprocessor resolves all those macros for us, but in Java we have to do it manually. Unless, of course, we have a tool that, just like in C, reads and process the same header files, finds the definitions that we need, and generates Java code for us.
Enter - a JDK tool that does exactly that. Part of and described as “Native library binding extraction tool”:
jextract is a tool which mechanically generates Java bindings from native library headers. This tool leverages the clang C API in order to parse the headers associated with a given native library, and the generated Java bindings build upon the Foreign Function & Memory API.
It really comes in handy when we want to have maintainable java.lang.foreign code.
Sticking to the example of shm_open above, we can run jextract on the Linux
headers to generate a Java class that captures the necessary details for us:
Running the above will create the java sources for a ForeignSharedMemory
class (and perhaps a few others) in the foreign.shm package. Lets
take a look at what’s inside:
Great! A bunch of static methods for our constants. And then a bit further:
Using the generated static methods above makes our Java code much simpler and understandable:
We can, in fact, use the original Linux documentation for C developers and
follow it almost verbatim in our Java code. For example, the shm_open
documentation describes the function’s signature and how the system behaves
depending on the flags passed:
… and of course what errors might be encountered:
Another tricky bit with native C/C++ code is that members of a struct are
‘placed’ on memory addresses that align with the CPU architecture’s word size.
For example - int members are typically aligned to 4-byte boundaries, long
members on 8-byte boundaries, and so on. In Java, when we need to pass a pointer
to a C struct to native code, or need to access members of a C struct returned
by a native function - we have to follow the same alignment rules. Otherwise, we
might end up reading or writing the wrong memory locations within the C struct.
The java.lang.foreign package takes this into account and provides abstractions
that allow developers to set data in the correct memory layout. But again, just like
with the macros and constants above, the developer has to follow the exact C struct
definitions in the C header files and work out the correct alignments from them.
And this is another scenario where jextract comes to the rescue:
With the above command two extra classes will be generated, one for each of the
msghdr and cmsghdr structs. Correct layouts are defined in these files
(node the paddings):
Additional information:Memory layout and alignment
Note the MemoryLayout.paddingLayout(4) calls - these are the alignment rules that
jextract has inferred from the C header files and added to the generated code.
Helper methods, getters and setters for the members of the struct are also generated. These methods take into account the C-type and the correct offset of each member so we don’t have to:
Now, let’s put together some server-side Java code. Again, following the C examples above:
And a Java client looks something like this:
At this point - both Java processes have the same physical memory mapped into their
address space, under the MemorySegment Object returned by a successful call to mmap().
Both processes can then turn that memory in to a ByteBuffer (by calling MemorySegment.asByteBuffer()),
or into an array (with MemorySegment.toArray()), or use the MemorySegment getters and setters,
perhaps alongside a predefined GroupLayout to access the memory as a struct of some sort, or deserialize
Java POJOs, etc. MemorySegment offers a lot of flexibility and it’s really up to you to decide how best to use
that memory.
One thing to keep in mind is that this memory is now shared. A change made by one process is immediately visible to other processes - we’ve bypassed networking, syscalls, context switches and data copying!
Additional information:Complete code examples available
In another article of this series we’ll plug these shared memory optimizations into and share its buffers and vectors between apps (Java and/or Python). Then, with the help of another native library, we’ll also add some GPU-processing power to the same Apache Arrow vectors.
So, stay put! :)