1. Introduction
We’ve previously looked at inter-process communication (IPC) and seen some performance comparisons between different methods. In this article, we’re going to look at how we can implement some of these methods in our Java applications.
2. What Is Inter-Process Communication?
Inter-Process Communication, or IPC for short, is a mechanism by which different processes can communicate. This can range from various processes that form the same application, to different processes running on the same computer, and other processes spread across the internet.
For example, some web browsers run each tab as a different OS process. This is done to keep them isolated from each other but does require a level of IPC between the tab process and the main browser process to keep everything working correctly.
Everything we look at here will be in the form of message passing. Java lacks standard support for shared memory mechanisms, though some third-party libraries can facilitate this. As such, we’ll think about a production process that sends messages to a consumption process.
3. File-Based IPC
The simplest form of IPC that we can achieve in standard Java is simply using files on the local file system. One process can write a file, while the other can read from the same file. Anything that any process does using the file system outside the process boundary can be seen by all other processes on the same computer.
3.1. Shared Files
We can start by having our two processes read and write the same file. Our producing process will write to a file on the file system, and later, our consuming process will read from the same file.
We do need to be careful that writing to the file and reading from the file don’t overlap. On many computers, file system operations aren’t atomic, so if the writing and reading are happening simultaneously, the consuming process may get corrupt messages. However, if we can guarantee this — for example, using filesystem locking — then shared files are a straightforward way to facilitate IPC.
3.2. Shared Directory
A step up from sharing a single, well-known file is to share an entire directory. Our producing application can write a new file into the directory every time it needs to, and our consuming application can detect the presence of a new file and react to it.
Java has the WatchService API in NIO2 that we can use for this. Our consuming process can use it to watch our target directory, and whenever it notifies us that a new file has been created, we can react to it:
WatchService watchService = FileSystems.getDefault().newWatchService();
Path path = Paths.get("pathToDir");
path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE);
WatchKey key;
while ((key = watchService.take()) != null) {
for (WatchEvent<?> event : key.pollEvents()) {
// React to new file.
}
key.reset();
}
Having done this, our producing process needs to create appropriate files in this directory, and the consuming process will detect and process them.
Remember, though, that most filesystem operations aren’t atomic. We must ensure that the file creation event is only triggered when the file is completely written. This is commonly done by writing the file into a temporary directory and then moving it into the target directory when finished.
On most filesystems, a “move file” or “rename file” action is considered atomic as long as it happens within the same filesystem.
3.3. Named Pipes
So far, we’ve used complete files to pass our messages between processes. This requires that the producing process has written the entire file before the consuming process reads it.
Named Pipes are a particular type of file we can use here. Named pipes are entries on the file system but don’t have any storage behind them. Instead, they act as a pipeline between writing and reading processes.
We start by having our consuming process open the named pipe for reading. Because this named pipe is presented as a file on the filesystem, we do this using standard file IO mechanisms:
BufferedReader reader = new BufferedReader(new FileReader(file));
String line;
while ((line = reader.readLine()) != null) {
// Process read line
}
Everything that’s written to this named pipe will then be immediately read by this consuming process. This means that our production process needs to open this file and write it as normal.
Unfortunately, we don’t have a mechanism to create these named pipes in Java. Instead, we need to use standard OS commands to create the file system entry before our program can use it. Exactly how we do this varies by operating system. For example, on Linux, we’d use the mkfifo command:
$ mkfifo /tmp/ipc-namedpipe
And then, we can use /tmp/ipc-namedpipe in our consuming and producing processes.
4. Network-Based IPC
Everything we’ve seen has revolved around the two processes sharing the same filesystem. This means that they need to be running on the same computer. However, in some cases, we wish to have our processes communicate with each other regardless of the computer they’re running on.
We can achieve this by using network-based IPC instead. Essentially, this is just running a network server in one process and a network client in another.
4.1. Simple Sockets
The most obvious example of implementing network-based IPC is to use simple network sockets. We can either use the sockets support in the JDK or rely on libraries such as Netty or Grizzly.
Our consuming process would run a network server that listens on a known address. It can then handle incoming connections and process messages as any network server would:
try (ServerSocket serverSocket = new ServerSocket(1234)) {
Socket clientSocket = serverSocket.accept();
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
// Process read line
}
}
The producing processes can then send network messages to this to facilitate our IPC:
try (Socket clientSocket = new Socket(host, port)) {
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
out.println(msg);
}
Notably, compared to our file-based IPC, we can more easily send messages in both directions.
4.2. JMX
Using network sockets works well enough, but there’s a lot of complexity that we need to manage ourselves. As an alternative, we can also use JMX. This technically still uses network-based IPC, but it abstracts the networking away from us, so we’re working only in terms of the MBeans.
As before, we’d need a server running on our consuming process. However, this server is now our standard MBeanServer from the JVM rather than anything we do ourselves.
We’d first need to define our MBean itself:
public interface IPCTestMBean {
void sendMessage(String message);
}
class IPCTest implements IPCTestMBean {
@Override
public void sendMessage(String message) {
// Process message
}
}
Then, we can provide this to the MBeanServer within the JVM:
ObjectName objectName = new ObjectName("com.baeldung.ipc:type=basic,name=test");
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new IPCTest(), objectName);
At this point, we’ve got our consumer ready.
We can then use a JMXConnectorFactory instance to send messages to this server from our producing system:
JMXServiceURL url = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://localhost:1234/jmxrmi");
try (JMXConnector jmxc = JMXConnectorFactory.connect(url, null)) {
ObjectName objectName = new ObjectName("com.baeldung.ipc:type=basic,name=test");
IPCTestMBean mbeanProxy = JMX.newMBeanProxy(jmxc.getMBeanServerConnection(), objectName, IPCTestMBean.class, true);
mbeanProxy.sendMessage("Hello");
}
Note that for this to work, we need to run our consumer with some additional JVM arguments to expose JMX on a well-known port:
-Dcom.sun.management.jmxremote=true
-Dcom.sun.management.jmxremote.port=1234
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false
We then need to use this within the URL within the client for it to connect to the correct server.
5. Messaging Infrastructure
Everything we’ve seen so far is a relatively simple means of IPC. At a certain point, this stops working as well. For example, it assumes that there is only one process consuming messages — or that the producers know exactly which consumer to talk to.
If we need to go beyond this, we can integrate with dedicated messaging infrastructure using something like JMS, AMPQ, or Kafka.
Obviously, this is on a much larger scale than we’ve been covering here — this would allow an entire suite of producing and consuming systems to pass messages between each other. However, if we need this kind of scale, then these options do exist.
6. Conclusion
We’ve seen several different means of IPC between processes and how we can implement them ourselves. This has covered a range of scales, from sharing an individual file to an enterprise-level scale.
Next time you need to have multiple processes communicating with each other, why not consider some of these options?
As always, all the code from this article can be found over on GitHub.