1. Overview
Handling input and output are common tasks for Java programmers. In this tutorial, we'll look at the original java.io (IO) libraries and the newer java.nio (NIO) libraries and how they differ when communicating across a network.
2. Key Features
Let's start by looking at the key features of both packages.
2.1. IO – java.io
The java.io package was introduced in Java 1.0, with Reader introduced in Java 1.1. It provides:
- InputStream and OutputStream – that provide data one byte at a time
- Reader and Writer – convenience wrappers for the streams
- blocking mode – to wait for a complete message
2.2. NIO – java.nio
The java.nio package was introduced in Java 1.4 and updated in Java 1.7 (NIO.2) with enhanced file operations and an ASynchronousSocketChannel. It provides:
- Buffer – to read chunks of data at a time
- CharsetDecoder – for mapping raw bytes to/from readable characters
- Channel – for communicating with the outside world
- Selector – to enable multiplexing on a SelectableChannel and provide access to any Channels that are ready for I/O
- non-blocking mode – to read whatever is ready
Now let's take a look at how we use each of these packages when we send data to a server or read its response.
3. Configure Our Test Server
Here we'll be using WireMock to simulate another server so that we can run our tests independently.
We'll configure it to listen for our requests and to send us responses just like a real web server would. We'll also use a dynamic port so that we don't conflict with any services on our local machine.
Let's add the Maven dependency for WireMock with test scope:
<dependency> <groupId>com.github.tomakehurst</groupId> <artifactId>wiremock-jre8</artifactId> <version>2.26.3</version> <scope>test</scope> </dependency>
In a test class, let's define a JUnit @Rule to start WireMock up on a free port. We'll then configure it to return us an HTTP 200 response when we ask for a predefined resource, with the message body as some text in JSON format:
@Rule public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort()); private String REQUESTED_RESOURCE = "/test.json"; @Before public void setup() { stubFor(get(urlEqualTo(REQUESTED_RESOURCE)) .willReturn(aResponse() .withStatus(200) .withBody("{ \"response\" : \"It worked!\" }"))); }
Now that we have our mock server set up, we are ready to run some tests.
4. Blocking IO – java.io
Let's look at how the original blocking IO model works by reading some data from a website. We'll use a java.net.Socket to gain access to one of the operating system's ports.
4.1. Send a Request
In this example, we will create a GET request to retrieve our resources. First, let's create a Socket to access the port that our WireMock server is listening on:
Socket socket = new Socket("localhost", wireMockRule.port())
For normal HTTP or HTTPS communication, the port would be 80 or 443. However, in this case, we use wireMockRule.port() to access the dynamic port we set up earlier.
Now let's open an OutputStream on the socket, wrapped in an OutputStreamWriter and pass it to a PrintWriter to write our message. And let's make sure we flush the buffer so that our request is sent:
OutputStream clientOutput = socket.getOutputStream(); PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientOutput)); writer.print("GET " + TEST_JSON + " HTTP/1.0\r\n\r\n"); writer.flush();
4.2. Wait for the Response
Let's open an InputStream on the socket to access the response, read the stream with a BufferedReader, and store it in a StringBuilder:
InputStream serverInput = socket.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput)); StringBuilder ourStore = new StringBuilder();
Let's use reader.readLine() to block, waiting for a complete line, then append the line to our store. We'll keep reading until we get a null, which indicates the end of the stream:
for (String line; (line = reader.readLine()) != null;) { ourStore.append(line); ourStore.append(System.lineSeparator()); }
5. Non-Blocking IO – java.nio
Now, let's look at how the nio package's non-blocking IO model works with the same example.
This time, we'll create a java.nio.channel.SocketChannel to access the port on our server instead of a java.net.Socket, and pass it an InetSocketAddress.
5.1. Send a Request
First, let's open our SocketChannel:
InetSocketAddress address = new InetSocketAddress("localhost", wireMockRule.port()); SocketChannel socketChannel = SocketChannel.open(address);
And now, let's get a standard UTF-8 Charset to encode and write our message:
Charset charset = StandardCharsets.UTF_8; socket.write(charset.encode(CharBuffer.wrap("GET " + REQUESTED_RESOURCE + " HTTP/1.0\r\n\r\n")));
5.2. Read the Response
After we send the request, we can read the response in non-blocking mode, using raw buffers.
Since we'll be processing text, we'll need a ByteBuffer for the raw bytes and a CharBuffer for the converted characters (aided by a CharsetDecoder):
ByteBuffer byteBuffer = ByteBuffer.allocate(8192); CharsetDecoder charsetDecoder = charset.newDecoder(); CharBuffer charBuffer = CharBuffer.allocate(8192);
Our CharBuffer will have space left over if the data is sent in a multi-byte character set.
Note that if we need especially fast performance, we can create a MappedByteBuffer in native memory using ByteBuffer.allocateDirect(). However, in our case, using allocate() from the standard heap is fast enough.
When dealing with buffers, we need to know how big the buffer is (the capacity), where we are in the buffer (the current position), and how far we can go (the limit).
So, let's read from our SocketChannel, passing it our ByteBuffer to store our data. Our read from the SocketChannel will finish with our ByteBuffer‘s current position set to the next byte to write to (just after the last byte written), but with its limit unchanged:
socketChannel.read(byteBuffer)
Our SocketChannel.read() returns the number of bytes read that could be written into our buffer. This will be -1 if the socket was disconnected.
When our buffer doesn't have any space left because we haven't processed all its data yet, then SocketChannel.read() will return zero bytes read but our buffer.position() will still be greater than zero.
To make sure that we start reading from the right place in the buffer, we'll use Buffer.flip() to set our ByteBuffer‘s current position to zero and its limit to the last byte that was written by the SocketChannel. We'll then save the buffer contents using our storeBufferContents method, which we'll look at later. Lastly, we'll use buffer.compact() to compact the buffer and set the current position ready for our next read from the SocketChannel.
Since our data may arrive in parts, let's wrap our buffer-reading code in a loop with termination conditions to check if our socket is still connected or if we've been disconnected but still have data left in our buffer:
while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) { byteBuffer.flip(); storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore); byteBuffer.compact(); }
And let's not forget to close() our socket (unless we opened it in a try-with-resources block):
socketChannel.close();
5.3. Storing Data From Our Buffer
The response from the server will contain headers, which may make the amount of data exceed the size of our buffer. So, we'll use a StringBuilder to build our complete message as it arrives.
To store our message, we first decode the raw bytes into characters in our CharBuffer. Then we'll flip the pointers so that we can read our character data, and append it to our expandable StringBuilder. Lastly, we'll clear the CharBuffer ready for the next write/read cycle.
So now, let's implement our complete storeBufferContents() method passing in our buffers, CharsetDecoder, and StringBuilder:
void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder charsetDecoder, StringBuilder ourStore) { charsetDecoder.decode(byteBuffer, charBuffer, true); charBuffer.flip(); ourStore.append(charBuffer); charBuffer.clear(); }
6. Conclusion
In this article, we've seen how the original java.io model blocks, waits for a request and uses Streams to manipulate the data it receives. In contrast, the java.nio libraries allow for non-blocking communication using Buffers and Channels and can provide direct memory access for faster performance. However, with this speed comes the additional complexity of handling buffers.
As usual, the code for this article is available over on GitHub.