Sitemap

Java NIO

18 min readNov 17, 2025

--

For years, Java’s traditional I/O (Input/Output) streams, often referred to as Java IO or Blocking I/O (BIO), served us well. However, with the rise of modern, highly concurrent, and network-intensive applications, the traditional model began to show its limits. Enter Java NIO (New Input/Output), a significant architectural shift introduced in JDK 1.4, which revolutionized how Java handles data operations.

Java NIO (New Input/Output) is an alternative to the traditional java.io package. It was designed to enhance performance and scalability, particularly for applications that require high-speed I/O, such as servers, file systems, and network applications. Unlike the stream-oriented java.io, NIO is buffer-oriented and supports non-blocking I/O.

Core Components of Java NIO

Buffers

Buffers are a fundamental component of the Java New Input/Output (NIO) API, acting as fixed-sized containers for data. They are central to how data is transferred between I/O entities (like files or sockets) and Java programs. In NIO, all I/O operations happen via Channels, and data is always read from a Channel into a Buffer, and written from a Buffer into a Channel.

Conceptually, a Buffer An object is a wrapper around a block of memory (an array of primitive data elements) that provides methods for reading and writing data, while also maintaining the data's state. The base class for all buffers is java.nio.Buffer. Java NIO provides a concrete subclass for each non-boolean primitive data type

public abstract class ByteBuffer
extends Buffer
implements Comparable<ByteBuffer>

Note that the buffers can be further subcategorised as:

a) Heap buffers: They are essentially a wrapper around an byte[]array. They are located on the Java heap and hence subject to JavaGC. Typically, they are slower for I/O because data must be copied from the heap buffer to a direct buffer before native I/O operations can occur.

b) Direct Buffers: Memory is allocated directly by the operating system, and they do not have a backing Java byte[]array. They are located outside the Java heap in native memory. The Java garbage collector does not manage them, and they are only deallocated when their corresponding Java object is garbage collected and a special cleanup mechanism is triggered. Faster for I/O because they can be used directly by native I/O operations without an extra copy. Given a direct byte buffer, the Java virtual machine will make a best effort to perform native I/O operations directly upon it. That is, it will attempt to avoid copying the buffer’s content to (or from) an intermediate buffer before (or after) each invocation of one of the underlying operating system’s native I/O operations.

c) Mapped buffers: A special type of direct buffer that maps a region of a file directly into memory, which is also off-heap. MappedByteBuffer is created using a FileChannel.map()method. It allows you to work with a file as if it were in memory, without having to read the entire file into a heap buffer first.

Every buffer has three key attributes that manage its state, which can be visualised as pointers or indices within the memory block. A fourth attribute, the Mark, can also be set.
* Capacity: The maximum number of elements the buffer can hold. This value is set upon creation and never changes. (obviously capacity ≥ 0)
* Limit: The index of the first element that should not be read or written. It is the boundary for data access. ( limit ≤ capacity)
* Position: The index of the next element to be read or written. It’s like a cursor. ( position ≤ limit ≤ capacity)
* Mark: A remembered position to which the buffer’s position can be reset via the reset() method. (mark ≤ position if defined)

The essential invariant that always holds is: 0 ≤ mark ≤ position ≤ limit ≤ capacity

Useful API:

public final int capacity()

public final int position()
public final Buffer position(int newPosition)

public final int limit()
public final Buffer limit(int newLimit) // If the position is larger than the new limit then it is set to the new limit. If the mark is defined and larger than the new limit then it is discarded.

public final Buffer mark()
public final Buffer reset() // Resets this buffer's position to the previously-marked position

// Clears this buffer. The position is set to zero, the limit is set to the capacity, and the mark is discarded.
public final Buffer clear()

// The limit is set to the current position and then the position is set to zero. If the mark is defined then it is discarded.
// performs two actions:
// The current position becomes the new limit. This ensures you only read the data that was actually written
// The position is reset to 0. This ensures reading starts from the beginning of the written data
public final Buffer flip()

// Rewinds this buffer. The position is set to zero and the mark is discarded.
public final Buffer rewind()

public final int remaining()
public final boolean hasRemaining()

public abstract boolean isReadOnly()

public abstract boolean hasArray() //Tells whether or not this buffer is backed by an accessible array.
public abstract Object array()

public abstract boolean isDirect()

The most commonly used Buffer implementation is ByteBuffer — Channels can only read/write ByteBuffers

A byte buffer is either direct or non-direct. Given a direct byte buffer, the Java virtual machine will make a best effort to perform native I/O operations directly upon it. That is, it will attempt to avoid copying the buffer’s content to (or from) an intermediate buffer before (or after) each invocation of one of the underlying operating system’s native I/O operations.

A direct byte buffer may be created by invoking the allocateDirect factory method of this class. The buffers returned by this method typically have somewhat higher allocation and deallocation costs than non-direct buffers

ByteBuffer API:

// Allocates a new byte buffer.
static ByteBuffer allocate(int capacity)

// Allocates a new direct byte buffer.
static ByteBuffer allocateDirect(int capacity)

// Returns the byte array that backs this buffer - Modifications to this buffer's content will cause the returned array's content to be modified, and vice versa
public final byte[] array()

// Tells whether or not this byte buffer is direct.
public abstract boolean isDirect()

// Retrieves this buffer's byte order.
// Two options: BIG_ENDIAN/LITTLE_ENDIAN - The order of a newly-created byte buffer is always BIG_ENDIAN
public final ByteOrder order()

public abstract char getChar()
public abstract ByteBuffer putChar(char value)
public abstract char getChar(int index)
public abstract ByteBuffer putChar(int index, char value)

// Creates a view of this byte buffer as a char buffer.
// The content of the new buffer will start at this buffer's current position. Changes to this buffer's content will be visible in the new buffer, and vice versa
// The new buffer will be direct if, and only if, this buffer is direct, and it will be read-only if, and only if, this buffer is read-only.
public abstract CharBuffer asCharBuffer()

public abstract short getShort()
public abstract ByteBuffer putShort(short value)
public abstract short getShort(int index)
public abstract ByteBuffer putShort(int index, short value)

public abstract ShortBuffer asShortBuffer()

public abstract int getInt()
public abstract ByteBuffer putInt(int value)
public abstract int getInt(int index)
public abstract ByteBuffer putInt(int index, int value)

public abstract IntBuffer asIntBuffer()

public abstract long getLong()
public abstract ByteBuffer putLong(long value)
public abstract long getLong(int index)
public abstract ByteBuffer putLong(int index, long value)

public abstract LongBuffer asLongBuffer()

public abstract float getFloat()
public abstract ByteBuffer putFloat(float value)
public abstract float getFloat(int index)
public abstract ByteBuffer putFloat(int index, float value)

public abstract FloatBuffer asFloatBuffer()

public abstract double getDouble()
public abstract ByteBuffer putDouble(double value)
public abstract double getDouble(int index)
public abstract ByteBuffer putDouble(int index, double value)

public abstract DoubleBuffer asDoubleBuffer()

//The bytes between the buffer's current position and its limit, if any, are copied to the beginning of the buffer. That is, the byte at index p = position() is copied to index zero, the byte at index p + 1 is copied to index one, and so forth until the byte at index limit() - 1 is copied to index n = limit() - 1 - p. The buffer's position is then set to n+1 and its limit is set to its capacity. The mark, if defined, is discarded.
// The buffer's position is set to the number of bytes copied, rather than to zero, so that an invocation of this method can be followed immediately by an invocation of another relative put method.
// Use this if you only read some of the data and need to keep the unread portion.
public abstract ByteBuffer compact()

Java NIO Buffer class hierarchy

The rest of the Buffer classes are pretty much self-explanatory — let's look at MappedByteBuffer in greater detail. The MappedByteBuffer class represents a memory-mapped region of a file. It's a specialised type of ByteBuffer that allows a portion (or all) of a file to be mapped directly into the process's virtual memory space. This technique, known as memory-mapped I/O, essentially tricks your Java program into thinking the file is entirely loaded into memory. You can then read from or write to the file by simply accessing this memory region, just like working with a large array, and the operating system handles all the complex file-to-memory synchronization. When dealing with large files and high-performance I/O in Java, traditional file streams can quickly become a bottleneck. That’s where the MappedByteBuffer from the Java NIO (New Input/Output) package steps in, offering a revolutionary, super-efficient approach to file access.

Key Mechanism: The OS is Your I/O Butler

  • Creation: A MappedByteBuffer is created using the map() method of a FileChannel.
  • Direct Access: When you access a location in the buffer, the operating system’s virtual memory manager automatically loads the corresponding page of the file data from the disk into physical memory. This process is called a page fault.
  • Zero-Copy: The major advantage is that data is transferred directly between the disk and the memory-mapped area, bypassing the intermediate buffer copies that are typically involved in standard stream I/O.
  • Outside the Heap: Like other direct buffers, the memory for a MappedByteBuffer is allocated off-heap (outside the JVM's managed heap), which significantly reduces the pressure on the Garbage Collector (GC)

Here’s an example code using a MappedByteBuffer to write data to a file:

final int FILE_SIZE = 1024 * 1024; // 1MB

try (RandomAccessFile file = new RandomAccessFile("large_file.dat", "rw");
FileChannel channel = file.getChannel()) {

// 1. Map the entire file region for read/write
MappedByteBuffer buffer = channel.map( FileChannel.MapMode.READ_WRITE,
0,
FILE_SIZE);

// 2. Write data (treated as writing directly to memory)
for (int i = 0; i < FILE_SIZE; i++) {
buffer.put((byte) 'A');
}

// 3. Force changes to be written to disk (Crucial for persistence!)
buffer.force();
} // FileChannel and RandomAccessFile are closed automatically

Charsets

In today’s globalized world, dealing with text data means dealing with many different languages, characters, and encoding schemes. This is where Charsets come into play. In Java, particularly within the New Input/Output (NIO) framework, Charsets are the essential bridge that translates between the character sequences your Java code understands (which are internally stored as 16-bit Unicode) and the byte sequences used for storage or transmission (like files or network streams).

A Charset defines two fundamental, inverse operations:

  1. Encoding: Translating a sequence of characters (Java String, char[], or CharBuffer) into a sequence of bytes (ByteBuffer). This is what happens when you write text to a file.
  2. Decoding: Translating a sequence of bytes (ByteBuffer) into a sequence of characters (CharBuffer). This is what happens when you read text from a file.

The entire Charset mechanism is contained primarily in the java.nio.charset package, which provides four crucial classes:

a) Charset (The Factory) : The abstract base class representing a specific character set. It acts as a factory for creating the encoders and decoders.

  • Getting a Charset: You don’t usually instantiate Charset directly. You use the static method Charset.forName() with the encoding name.
Charset utf8 = Charset.forName("UTF-8");
Charset iso = Charset.forName("ISO-8859-1");
  • Standard Charsets: Java guarantees support for six standard charsets, available as constants in the StandardCharsets utility class:
    US-ASCII
    ISO-8859–1 (Latin-1)
    UTF-8 (The most common and recommended choice)
    UTF-16BE (Big-Endian)
    UTF-16LE (Little-Endian)
    UTF-16 (Uses a Byte Order Mark (BOM) to indicate endianness)

b) CharsetEncoder (From Text to Bytes): This class handles the encoding process.

  • Creation: Obtained via Charset.newEncoder().
  • Method: The key method is encoder.encode(CharBuffer in), which returns a ByteBuffer.
  • Crucial Use: When writing a CharBuffer to a FileChannel.
String text = "Hello, World! €";
Charset charset = StandardCharsets.UTF_8;

// The 'charset.encode()' method is a convenience shortcut
// for creating and running a CharsetEncoder.
ByteBuffer byteBuffer = charset.encode(text);

// The byteBuffer now holds the UTF-8 representation of the string.
// You can now write this buffer to a FileChannel.

c) CharsetDecoder (From Bytes to Text): This class handles the decoding process.

  • Creation: Obtained via Charset.newDecoder().
  • Method: The key method is decoder.decode(ByteBuffer in), which returns a CharBuffer.
  • Crucial Use: When reading a ByteBuffer from a FileChannel to turn it into readable text.
// Assume byteBuffer contains bytes read from a file...
ByteBuffer byteBuffer = ...

Charset charset = Charset.forName("ISO-8859-1");

// The 'charset.decode()' method is a convenience shortcut
// for creating and running a CharsetDecoder.
CharBuffer charBuffer = charset.decode(byteBuffer);

String result = charBuffer.toString();
// The result string now contains the decoded text.

d) CoderResult (Handling Errors): Encoding and decoding aren’t always perfect. A CoderResult is returned by the primary encoding/decoding methods to indicate the outcome, such as success, lack of space, or a conversion error.

Get Kaustubh Saha’s stories in your inbox

Join Medium for free to get updates from this writer.

A key advantage of the NIO Charset framework is its robust error handling through the CodingErrorAction enum. There are two main errors:
a) Malformed Input: A sequence of input bytes/characters is illegal for the charset (e.g., encountering a random byte in the middle of a valid UTF-8 sequence).
b) Unmappable Character: A character cannot be represented in the target charset (e.g., trying to encode the Euro symbol (€) using the US-ASCII charset)

You can configure the decoder/encoder to handle these errors using one of three actions:
IGNORESilently discard the erroneous input.
REPLACEReplace the erroneous input with a replacement character (usually '?' or the Unicode Replacement Character U+FFFD).
REPORTThrow a checked exception (MalformedInputException or UnmappableCharacterException). (Default Behavior)

Charset utf8 = StandardCharsets.UTF_8;
CharsetDecoder decoder = utf8.newDecoder();

// Configure the decoder to replace bad bytes instead of throwing an error
decoder.onMalformedInput(CodingErrorAction.REPLACE);
decoder.onUnmappableCharacter(CodingErrorAction.REPLACE);

// Now, decode will proceed without crashing, substituting bad characters.

The single most important takeaway is this: Almost always use UTF-8 as your default encoding scheme. It is fully backward compatible with ASCII. It’s generally efficient for Western languages and doesn’t waste space like UTF-16 might. It can represent every character in the Unicode standard, meaning you won’t encounter unmappable character errors.

Channels

Java NIO (New Input/Output) revolutionised how Java applications handle I/O operations, moving from stream-based blocking I/O to a more flexible, buffer-oriented, and non-blocking approach. At the heart of this transformation are channels.

Think of channels as conduits through which data flows. Unlike traditional streams, which operate on a byte-by-byte basis, channels interact directly with byte buffers, enabling more efficient data transfer, particularly for large volumes of data.

In essence, a channel is an open connection to an I/O device, such as a file, a network socket, or a hardware device. It’s the primary way to interact with data in NIO. Channels can be thought of as a more abstract and generalized form of file descriptors or network sockets.

Key Characteristics of Channels

  1. Bidirectional: Most channels are bidirectional, meaning they can be used for both reading and writing data. This simplifies code compared to needing separate input and output streams.
  2. Buffer-Centric: Channels constantly interact with ByteBuffer objects. Data is read from a channel into a buffer, and written from a buffer into a channel. This buffer-centric approach is a cornerstone of NIO's efficiency.
  3. Non-blocking I/O (Optional): Although not all channels support non-blocking I/O, many do. Non-blocking I/O allows a thread to attempt to read or write data without waiting for the operation to complete. This is crucial for building scalable server applications.

Channel API:

// Tells whether or not this channel is open.
boolean isOpen()

// Closes this channel.
// After a channel is closed, any further attempt to invoke I/O operations upon it will cause a ClosedChannelException to be thrown.
// If this channel is already closed then invoking this method has no effect.
void close()

Java NIO provides several channel implementations, each designed for specific I/O tasks:

  • FileChannel: For reading from, writing to, and manipulating files. It offers powerful features like memory mapping and file locking.
  • SocketChannel: For client-side TCP network connections.
  • ServerSocketChannel: For server-side TCP network connections, listening for incoming connections.
  • DatagramChannel: For UDP network connections.

FileChannel is probably the most commonly used channel implementation

FileChannel API:

// Opens or creates a file, returning a file channel to access the file.
// enum StandardOpenOption: APPEND/CREATE/CREATE_NEW/DELETE_ON_CLOSE
public static FileChannel open(Path path,
Set<? extends OpenOption> options,
FileAttribute<?>... attrs)
throws IOException

// Reads a sequence of bytes from this channel into the given buffer.
// Returns the number of bytes read, possibly zero, or -1 if the channel has reached end-of-stream
public abstract int read(ByteBuffer dst) throws IOException
public abstract long read(ByteBuffer[] dsts, int offset, int length) throws IOException

// Reads a sequence of bytes from this channel into the given buffers.
// Bytes are read starting at this channel's current file position, and then the file position is updated with the number of bytes actually read
public final long read(ByteBuffer[] dsts) throws IOException

public abstract int write(ByteBuffer src) throws IOException
public abstract long write(ByteBuffer[] srcs, int offset, int length) throws IOException

// Writes a sequence of bytes to this channel from the given buffers.
// Bytes are written starting at this channel's current file position unless the channel is in append mode, in which case the position is first advanced to the end of the file. The file is grown, if necessary, to accommodate the written bytes, and then the file position is updated with the number of bytes actually written.
public final long write(ByteBuffer[] srcs) throws IOException

public abstract long position() throws IOException

// Sets this channel's file position.
public abstract FileChannel position(long newPosition) throws IOException

// Returns the current size of this channel's file.
public abstract long size() throws IOException

// Truncates this channel's file to the given size.
// If the given size is less than the file's current size then the file is truncated, discarding any bytes beyond the new end of the file
// If the given size is greater than or equal to the file's current size then the file is not modified.
public abstract FileChannel truncate(long size) throws IOException

// Forces any updates to this channel's file to be written to the storage device that contains it.
// If this channel's file resides on a local storage device then when this method returns it is guaranteed that all changes made to the file since this channel was created, or since this method was last invoked, will have been written to that device. This is useful for ensuring that critical information is not lost in the event of a system crash.
// If the file does not reside on a local device then no such guarantee is made.
// The metaData parameter can be used to limit the number of I/O operations that this method is required to perform. Passing false for this parameter indicates that only updates to the file's content need be written to storage; passing true indicates that updates to both the file's content and metadata must be written, which generally requires at least one more I/O operation.
public abstract void force(boolean metaData) throws IOException

// Transfers bytes from this channel's file to the given writable byte channel.
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException

// Transfers bytes into this channel's file from the given readable byte channel.
public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException

// Reads a sequence of bytes from this channel into the given buffer, starting at the given file position.
public abstract int read(ByteBuffer dst, long position) throws IOException

// Writes a sequence of bytes to this channel from the given buffer, starting at the given file position.
public abstract int write(ByteBuffer src, long position) throws IOException

// Maps a region of this channel's file directly into memory.
// modes:
// Read-only: Any attempt to modify the resulting buffer will cause a ReadOnlyBufferException to be thrown. (MapMode.READ_ONLY)
// Read/write: Changes made to the resulting buffer will eventually be propagated to the file; they may or may not be made visible to other programs that have mapped the same file. (MapMode.READ_WRITE)
// Private: Changes made to the resulting buffer will not be propagated to the file and will not be visible to other programs that have mapped the same file; instead, they will cause private copies of the modified portions of the buffer to be created. (MapMode.PRIVATE)
// For a read-only mapping, this channel must have been opened for reading; for a read/write or private mapping, this channel must have been opened for both reading and writing.
public abstract MappedByteBuffer map(FileChannel.MapMode mode, long position, long size) throws IOException

// Acquires a lock on the given region of this channel's file.
// An invocation of this method will block until the region can be locked, this channel is closed, or the invoking thread is interrupted, whichever comes first.
public abstract FileLock lock(long position, long size, boolean shared) throws IOException

// Acquires an exclusive lock on this channel's file.
// equivalent to calling: fc.lock(0L, Long.MAX_VALUE, false)
public final FileLock lock() throws IOException

// Attempts to acquire a lock on the given region of this channel's file.
public abstract FileLock tryLock(long position, long size, boolean shared) throws IOException


public final FileLock tryLock() throws IOException

Let’s illustrate with a simple example of using a FileChannel to read data from a file.

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
public static void main(String[] args) throws Exception {
// 1. Open a FileChannel
RandomAccessFile aFile = new RandomAccessFile("data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

// 2. Create a ByteBuffer
ByteBuffer buf = ByteBuffer.allocate(48); // Allocate a 48-byte buffer

int bytesRead = inChannel.read(buf); // 3. Read data from channel into buffer

while (bytesRead != -1) {
System.out.println("Read " + bytesRead + " bytes");
buf.flip(); // 4. Flip the buffer to prepare for reading from it

while(buf.hasRemaining()){
System.out.print((char) buf.get()); // Read byte by byte from buffer
}

buf.clear(); // 5. Clear the buffer to prepare for writing into it again
bytesRead = inChannel.read(buf);
}
aFile.close();
}
}

Another example of using a FileChannel to write data to a file.

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;

/**
* Demonstrates how to write data to a file using Java NIO's FileChannel.
*/
public class FileChannelWriteExample {

public static void main(String[] args) {
// The data we want to write to the file
String data = "Hello, Java NIO Channels! This is a writing demonstration.";

try (
// 1. Open a RandomAccessFile in read/write mode ("rw").
// Using try-with-resources ensures the file and channel are closed automatically.
RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
FileChannel channel = file.getChannel()
) {
// 2. Wrap the String data into a ByteBuffer
// We use StandardCharsets.UTF_8 to convert the String to bytes.
ByteBuffer buffer = ByteBuffer.wrap(data.getBytes(StandardCharsets.UTF_8));

// 3. Write the buffer content to the channel
// The write() method takes data from the buffer's current position to its limit.
int bytesWritten = channel.write(buffer);

// 4. Print confirmation
System.out.println("Successfully wrote data to output.txt.");
System.out.println("Total bytes written: " + bytesWritten);

// 5. Ensure any pending writes are forced to the device (optional but good practice)
channel.force(true);

} catch (Exception e) {
System.err.println("An error occurred during file channel write operation: " + e.getMessage());
e.printStackTrace();
}
}
}

Channels are a fundamental component of Java NIO, offering a robust and efficient way to handle I/O operations. By understanding their buffer-centric nature and the different types of channels available, developers can write high-performance and scalable Java applications. Whether you’re dealing with large files or building concurrent network servers, mastering channels is a key step in leveraging the power of Java NIO.

Selectors

In classic (blocking) Java I/O, network programming follows a “thread-per-connection” model.

  • Every time a client connects, the server spawns a new thread to handle it.
  • If that thread tries to read data from the connection, and the client hasn’t sent anything yet, the thread blocks (goes idle) until data arrives.

This approach is simple but quickly becomes resource-intensive. For hundreds or thousands of connections, the operating system spends more time managing threads (context switching) than actually handling data, leading to performance bottlenecks.

A java.nio.channels.Selector acts like an air traffic controller for your I/O operations. Its main job is to monitor multiple SelectableChannel instances (like SocketChannel or ServerSocketChannel) and notify a single thread only when one of those channels is truly "ready" for an I/O event.

Instead of having a thread waiting for each client, you have one thread waiting for the Selector.

To understand Selectors, you need to understand the four key components that work together:

  1. Selector: The central object that monitors channels for readiness events.
  2. SelectableChannel: A Channel that can be monitored by a Selector (e.g., SocketChannel). Must be set to non-blocking mode. This represents the data conduit being monitored
  3. Interest Set (Operations): A set of events a channel is interested in (e.g., accepting a connection, reading data). Determines what the Selector watches for.
    SelectionKey.OP_ACCEPT: A ServerSocketChannel is ready to accept a new connection.
    SelectionKey.OP_CONNECT: A SocketChannel has finished connecting to a server (client-side).
    SelectionKey.OP_READ: A channel has data ready to be read.
    SelectionKey.OP_WRITE: A channel is ready to be written to (it has space in its internal buffers).
  4. SelectionKey: A token returned when a Channel registers with a Selector. It links the Channel, the Selector, and the Interest Set together. Represents the channel’s registration and current status.

Using a Selector involves a repetitive, four-step loop

Step 1: Open and Configure

First, open the Selector and the channel you want to monitor, ensuring the channel is non-blocking.

Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // CRITICAL STEP

Step 2: Register the channel

Register the channel with the Selector, specifying what events you are interested in. A server channel typically registers for OP_ACCEPT.

serverChannel.register(selector, SelectionKey.OP_ACCEPT);

Step 3: Wait for Readiness (The select() call)

The select() method is where the single thread waits. It blocks until one or more registered channels are ready for an I/O operation.

int readyChannels = selector.select();
if (readyChannels == 0) continue; // Loop again if no channels are ready

Step 4: Process the keys

Once select() returns, you get the set of "ready keys" (selectedKeys()). You iterate over these keys to handle the corresponding I/O event.

Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();

while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove(); // IMPORTANT: Must remove key to prevent double processing

if (key.isAcceptable()) {
// Handle new client connection
} else if (key.isReadable()) {
// Read data from the socket
}
}

The Selector pattern is the foundation of high-performance networking in Java. By switching from a resource-heavy thread-per-connection model to an efficient single-thread (or small thread pool) multiplexing model, Selectors enable applications to manage thousands of simultaneous, low-activity connections without being overwhelmed. It introduces complexity with the buffer/channel workflow, but the performance benefits for scalable server applications are undeniable.

--

--

Kaustubh Saha
Kaustubh Saha

Written by Kaustubh Saha

I'm a senior fintech pro at Citi, blending global banking, tech, and strategy. I simplify complexity and share insights through writing and mentoring.