1. Introduction to NIO Channels
In classic Java IO (java.io) everything is built around the “one thread—one file or resource” principle. As soon as a read or write starts, the thread blocks and waits for the operation to complete. For simple cases this is convenient, but in high-load systems this approach becomes a bottleneck: if there are thousands of connections, thousands of threads end up waiting.
NIO (New I/O) takes a different approach. I/O can be non-blocking here, and a thread does not have to idle. While some data is still in flight, it can switch to another task. This makes it possible to serve a huge number of connections with just a few threads.
The difference also shows up in the details. In “old” IO the work revolves around streams that read and write bytes or characters, but always block for the duration of operations. In NIO the key concepts are channels (Channels) and buffers (Buffers). They make it possible to implement non-blocking I/O (important for servers) and to use zero-copy, where data is transferred directly, bypassing extra copies in JVM buffers.
Comparison: streams (Streams) vs channels (Channels)
Streams (InputStream/OutputStream):
- Read/write bytes one by one or in arrays.
- No direct control over the position in a file.
- No efficient handling of very large files.
Channels (Channel):
- Read/write data through buffers (Buffer).
- You can control the position (including random access).
- Support asynchrony and non-blocking mode.
- Enable zero-copy for ultra-fast copying.
2. FileChannel and SeekableByteChannel
Reading and writing data using buffers
FileChannel is the primary channel for working with files. You can obtain it from FileInputStream, FileOutputStream, or via NIO.2—Files.newByteChannel (returns SeekableByteChannel).
Example: reading a file with FileChannel and ByteBuffer
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class FileChannelReadExample {
public static void main(String[] args) throws Exception {
try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // 1 KB buffer
int bytesRead = channel.read(buffer); // read into buffer
while (bytesRead != -1) {
buffer.flip(); // switch buffer to read mode
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // clear buffer for the next read
bytesRead = channel.read(buffer);
}
}
}
}
Writing to a file:
try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
FileChannel channel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
channel.write(buffer);
}
NIO.2: opening a channel via Files.newByteChannel
import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;
Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
ByteBuffer buf = ByteBuffer.allocate(256);
ch.read(buf);
}
Positioning (position()) and resizing (truncate())
- position() — allows you to get or set the current position in the file (like a “cursor”).
- truncate(long size) — trims the file to the specified size.
channel.position(100); // move to the 100th byte
channel.truncate(1024); // truncate file to 1 KB
Direct and positional file access
- Direct access: you can read/write to any place in the file, not only sequentially.
- Positional access: you can read/write data at a specific position without changing the channel’s current position.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128); // read 4 bytes from position 128 without changing channel.position()
3. ByteBuffer: how it works
Core parameters: capacity, limit, position, mark
- capacity — the maximum buffer size (set at creation).
- limit — the boundary up to which you can read/write (by default equals capacity).
- position — the current position (where we write from/where we read to).
- mark — a “bookmark” you can set and later return to.
Buffer lifecycle:
- Write data into the buffer (for example, read from a channel with read()).
- flip() — switch the buffer to read mode (position = 0, limit = current position).
- Read data from the buffer (get()).
- clear() — clear the buffer for the next write (position = 0, limit = capacity).
Example:
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip(); // now we can read
byte value = buffer.get(); // 42
buffer.clear(); // ready for new write
Creating buffers: allocate() vs allocateDirect()
ByteBuffer has two main ways to create a buffer, and the difference shows up in practice. The allocate() method places the buffer on the JVM heap: it is created quickly and suits most tasks, but with native I/O additional copies between the heap and the OS memory may occur.
The allocateDirect() method allocates memory outside the JVM heap (in “native memory”). Such a buffer is more expensive to create and harder to manage, but when reading/writing large files or in networking operations it is often faster thanks to avoiding extra copies.
The idea is simple: if you care about performance for large volumes, use direct buffers. For small and frequent operations, the allocation overhead can outweigh the benefit.
ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);
4. High-performance operations: transferTo() and transferFrom()
Methods transferTo() and transferFrom()
The FileChannel class has two methods that enable “zero-copy” — transferTo() and transferFrom(). The idea is that data can be pumped directly between file channels or, for example, between a file and the network. The JVM barely participates: the operation is performed by the OS, and Java-side buffers are not touched.
As a result, copying large files becomes noticeably faster: fewer copies, fewer user space↔kernel space switches, and lower CPU load.
Example: zero-copy file copy
import java.nio.channels.FileChannel;
import java.nio.file.*;
public class ZeroCopyExample {
public static void main(String[] args) throws Exception {
try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = src.size();
long transferred = src.transferTo(0, size, dst);
System.out.println("Bytes copied: " + transferred);
}
}
}
When does zero-copy actually work?
- When copying between files on the same disk.
- When sending files over the network (for example, via SocketChannel).
- When the OS supports zero-copy (Linux, macOS, Windows do).
Advantages:
- Minimal copying: data does not pass through JVM buffers.
- High speed: fewer switches and lower CPU usage.
- Less memory: large user-space buffers are unnecessary.
Example: one-liner file copy
Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// May use zero-copy under the hood if available
5. Common pitfalls
Pitfall #1: forgot flip() before reading from the buffer. After writing to a buffer, always call flip(), otherwise reading will not work as expected: position/limit will remain in “write mode”.
Pitfall #2: using allocateDirect() for small operations. Direct buffers are great for large volumes, but for small requests their creation cost is unjustifiably high. By default, choose allocate().
Pitfall #3: didn’t close the channel. Always use try-with-resources for channels and streams to avoid descriptor leaks.
Pitfall #4: confusing position/limit/capacity. Before reading/writing, make sure which mode the buffer is in: after writing you need flip(), after reading for a new write you need clear() or compact().
Pitfall #5: expecting zero-copy to “always work”. On some configurations (different devices/different file systems/special flags) zero-copy may be unavailable—then a regular copy occurs and performance will differ.
GO TO FULL VERSION