When programming in Java, you usually don’t have to care about how the JVM interacts with the operating system. However, there are cases where a basic understanding of these mechanisms can be useful. In this blog post, I will give a brief overview of how Java native calls work. I’m going to show you how to use that knowledge to track down bugs in your application.
How Do Java Native Calls Work?
Let’s look at an example. The method Files.delete
in the java.nio.file
package is used to delete a file or a directory. If you look at the implementation, you will see that the method delegates the call to an instance of FileSystemProvider
. There are specializations of this class for different file systems and operating systems. On a Windows system, WindowsFileSystemProvider.delete
gets called, which eventually calls WindowsNativeDispatcher.DeleteFile0
. Now, look at the declaration of this method:
private static native void DeleteFile0(long lpFileName) throws WindowsException;
The keyword native
indicates that the method has a native implementation. As you can see, there is no method body that contains the implementation. Instead, a platform-specific library contains the implementation of this method. For every platform on which a Java application is supposed to run, there needs to be platform-specific code that gets called when using Files.delete
. This code is responsible for the actual call into the operating system.
If you have a look at your JAVA_HOME\bin directory, you will find a file called nio.dll. It contains the platform-specific code for the Java NIO functionalities. Using a tool like DLL Export Viewer, you can see that the DLL contains the function Java_sun_nio_fs_WindowsNativeDispatcher_DeleteFile0
.
As you would expect, this is the function that contains the implementation of WindowsNativeDispatcher.DeleteFile0
. Let’s look at the source code to find out what this function does (you can find the source code of the OpenJDK platform here):
JNIEXPORT void JNICALL Java_sun_nio_fs_WindowsNativeDispatcher_DeleteFile0(JNIEnv* env, jclass this, jlong address) { LPCWSTR lpFileName = jlong_to_ptr(address); if (DeleteFileW(lpFileName) == 0) { throwWindowsException(env, GetLastError()); } }
Essentially, what the code does is delegating the call to the function DeleteFileW
. This function is part of the Windows operating system API. It allows applications to call into the operating system and tell it to delete a file (see the Windows API reference on DeleteFileW for details).
If you were using Unix instead of Windows, things would work pretty much the same: UnixFileSystemProvider
calls UnixNativeDispatcher.unlink0
, which calls unlink
, which is the POSIX-way to delete a file. However, the behaviors of DeleteFileW
and unlink
differ in some respects. When the same Java application behaves differently on Windows and Unix, such differences can be the cause.
How to Trace Native Calls at Runtime?
The easiest way to understand how your application interacts with the operating system is by tracing the system calls. Process Monitor is a tool from Microsoft’s Windows Sysinternals collection that allows you to trace and filter all kinds of system calls related to file system, registry, network, and process handling. You can filter events by process name, PID, event type, and other criteria to narrow down the stream of events.
Let’s look at the events when our code calls the java.nio.file.Files.delete
method that we looked at above. Unfortunately, you will not find a call to DeleteFileW
in the list of events directly. Instead, you will find something like this:
The flag FILE_DISPOSITION_DELETE
indicates that this is where the deletion happens. Let’s right-click on the event to open a stack trace:
Looking at the bottom of the stack, we can see that Java_sun_nio_fs_WindowsNativeDispatcher_DeleteFile0
gets called, which in turn calls DeleteFileW
. This is exactly what we would have expected from looking at the code.
A Real-World Example
A while ago, my team and I investigated a performance issue where uploading files was slow. The upload was much slower than the network bandwidth would have allowed. To get an idea of what was happening, we started up Process Monitor and initiated an upload. We could immediately see that lots and lots of WriteFile
events were logged. What struck us was that every call wrote a chunk of only a few kilobytes to the file.
We assumed that writing the file in such small chunks comes with a large overhead and that this was slowing down the upload. It turned out that a library we were relying on to handle the upload was using a very small write buffer of only 8 kilobytes. We found a way to increase the buffer size and this solved the performance problem.
As you can see, understanding how Java applications call into the operating system and how to trace these calls can be a very useful tool. When you’re facing I/O-related performance issues or when your application behaves differently on different platforms, it helps a lot to know about these things.