Tổng hợp kiến thức cốt lõi về Java NIO
Trước khi học NIO, bạn cần nắm kiến thức lý thuyết cơ bản về các mô hình I/O của máy tính. Nếu chưa rõ, bạn có thể tham khảo bài viết tôi đã viết: Giải thích chi tiết mô hình Java IO.
Giới thiệu về NIO
Trong mô hình Java I/O truyền thống (BIO), các thao tác I/O được thực hiện theo kiểu blocking. Nói cách khác, khi một thread thực hiện thao tác I/O, nó sẽ bị blocking cho đến khi thao tác hoàn tất. Mô hình blocking này có thể gây bottleneck hiệu năng khi xử lý nhiều kết nối đồng thời, vì cần tạo một thread cho mỗi kết nối, trong khi việc tạo và context switch giữa các thread đều tốn chi phí.
Để giải quyết vấn đề này, Java 1.4 đã giới thiệu một mô hình I/O mới — NIO (New IO, còn được gọi là Non-blocking IO). NIO khắc phục hạn chế của synchronous blocking I/O: cung cấp I/O non-blocking, hướng buffer và dựa trên channel trong code Java tiêu chuẩn, có thể dùng một số lượng nhỏ thread để xử lý nhiều kết nối, từ đó cải thiện đáng kể hiệu suất I/O và concurrency.
Hình dưới đây là sơ đồ so sánh đơn giản cách BIO, NIO và AIO xử lý request của client (về AIO, bạn có thể xem bài viết tôi đã viết: Giải thích chi tiết mô hình Java IO, đây không phải trọng tâm nên chỉ cần hiểu sơ qua).

⚠️ Cần lưu ý: sử dụng NIO không nhất thiết có nghĩa là hiệu năng cao. Ưu thế hiệu năng của nó chủ yếu thể hiện trong môi trường network có concurrency và latency cao. Khi số lượng kết nối ít, mức độ concurrency thấp hoặc tốc độ truyền dữ liệu trên network nhanh, hiệu năng của NIO chưa chắc tốt hơn BIO truyền thống.
Các thành phần cốt lõi của NIO
NIO chủ yếu gồm ba thành phần cốt lõi sau:
- Buffer: NIO thực hiện đọc và ghi dữ liệu thông qua buffer. Khi đọc, dữ liệu từ Channel được nạp vào Buffer; khi ghi, dữ liệu trong Buffer được ghi vào Channel.
- Channel: Channel biểu thị một kết nối mở tới các thực thể như file, socket. Tùy interface cụ thể, nó có thể hỗ trợ đọc, ghi hoặc đồng thời hỗ trợ cả hai.
- Selector: Cho phép một thread giám sát các event sẵn sàng của nhiều channel có thể chọn (
SelectableChannel), qua đó thực hiện I/O multiplexing. Selector chịu trách nhiệm báo cáo event đã sẵn sàng hay chưa, không chịu trách nhiệm phân bổ thread xử lý.
Mối quan hệ giữa ba thành phần được thể hiện trong hình dưới đây (chưa hiểu cũng không sao, phần sau sẽ giải thích chi tiết):

Dưới đây là phần giới thiệu chi tiết về ba thành phần này.
Buffer
Trong BIO truyền thống, việc đọc và ghi dữ liệu hướng theo stream, được chia thành byte stream và character stream.
Trong thư viện NIO của Java 1.4, mọi dữ liệu đều được xử lý bằng buffer. Đây là điểm khác biệt quan trọng giữa thư viện mới và BIO trước đó, tương tự buffered stream trong BIO. Khi đọc dữ liệu bằng NIO, dữ liệu được đọc trực tiếp vào buffer. Khi ghi dữ liệu, dữ liệu được ghi vào buffer. Khi đọc và ghi dữ liệu bằng NIO, mọi thao tác đều được thực hiện thông qua buffer.
Các lớp con của Buffer được thể hiện trong hình dưới đây. Trong đó, loại được sử dụng phổ biến nhất là ByteBuffer, có thể dùng để lưu trữ và thao tác với dữ liệu byte.

Bạn có thể hiểu Buffer là một chuỗi dữ liệu cùng kiểu, tuyến tính và có kích thước giới hạn. Một số Buffer được hỗ trợ bởi array, nhưng các implementation như direct buffer không nhất thiết có underlying array có thể truy cập.
Để hiểu rõ hơn về buffer, hãy xem nhanh bốn biến thành viên được định nghĩa trong class Buffer:
public abstract class Buffer {
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
}Ý nghĩa cụ thể của bốn biến thành viên này như sau:
- Capacity (
capacity): lượng dữ liệu tối đa màBuffercó thể lưu trữ, được thiết lập khi tạoBuffervà không thể thay đổi; - Limit (
limit): ranh giới dữ liệu có thể đọc/ghi trongBuffer. Ở write mode,limitbiểu thị lượng dữ liệu nhiều nhất có thể ghi, thường bằngcapacity(có thể thiết lập bằng methodlimit(int newLimit)); ở read mode,limitbằng kích thước dữ liệu thực tế đã ghi vào Buffer. - Position (
position): vị trí (index) của dữ liệu tiếp theo có thể đọc/ghi. Khi chuyển từ write mode sang read mode (flip),positionsẽ được đặt về 0 để có thể bắt đầu đọc/ghi từ đầu. - Mark (
mark):Buffercho phép đưa position trực tiếp về mark, đây là một thuộc tính tùy chọn;
Khi mark đã được định nghĩa, các biến trên thỏa mãn quan hệ sau: 0 <= mark <= position <= limit <= capacity. Khi mark chưa được định nghĩa, gọi reset() sẽ ném ra InvalidMarkException.
Ngoài ra, Buffer có read mode và write mode, lần lượt dùng để đọc dữ liệu từ Buffer hoặc ghi dữ liệu vào Buffer. Sau khi được tạo, Buffer mặc định ở write mode; gọi flip() có thể chuyển sang read mode. Nếu muốn chuyển lại write mode, có thể gọi method clear() hoặc compact().


Đối tượng Buffer không thể được tạo bằng cách gọi constructor thông qua new, chỉ có thể khởi tạo Buffer bằng static method.
Ở đây dùng ByteBuffer làm ví dụ:
// Phân bổ heap memory
public static ByteBuffer allocate(int capacity);
// Phân bổ direct memory
public static ByteBuffer allocateDirect(int capacity);Hai method cốt lõi nhất của Buffer:
get: đọc dữ liệu trong bufferput: ghi dữ liệu vào buffer
Ngoài hai method trên, các method quan trọng khác:
flip: chuyển buffer từ write mode sang read mode, đặt giá trịlimitthành giá trịpositionhiện tại và đặt giá trịpositionvề 0.clear: xóa buffer, chuyển buffer từ read mode sang write mode, đặt giá trịpositionvề 0 và đặt giá trịlimitthành giá trịcapacity.- …
Quá trình thay đổi trạng thái của Buffer:
import java.nio.*;
public class CharBufferDemo {
public static void main(String[] args) {
// Phân bổ một CharBuffer có capacity bằng 8
CharBuffer buffer = CharBuffer.allocate(8);
System.out.println("Trạng thái ban đầu:");
printState(buffer);
// Ghi 3 ký tự vào buffer
buffer.put('a').put('b').put('c');
System.out.println("Trạng thái sau khi ghi 3 ký tự:");
printState(buffer);
// Gọi flip(), chuẩn bị đọc dữ liệu trong buffer, đặt position về 0 và limit về 3
buffer.flip();
System.out.println("Trạng thái sau khi gọi flip():");
printState(buffer);
// Đọc ký tự
while (buffer.hasRemaining()) {
System.out.print(buffer.get());
}
// Gọi clear(), xóa buffer, đặt position về 0 và limit về capacity
buffer.clear();
System.out.println("Trạng thái sau khi gọi clear():");
printState(buffer);
}
// In ra capacity, limit và position của buffer
private static void printState(CharBuffer buffer) {
System.out.print("capacity: " + buffer.capacity());
System.out.print(", limit: " + buffer.limit());
System.out.print(", position: " + buffer.position());
System.out.println("\n");
}
}Kết quả:
Trạng thái ban đầu:
capacity: 8, limit: 8, position: 0
Trạng thái sau khi ghi 3 ký tự:
capacity: 8, limit: 8, position: 3
Đang chuẩn bị đọc dữ liệu trong buffer!
Trạng thái sau khi gọi flip():
capacity: 8, limit: 3, position: 0
Dữ liệu đã đọc: abc
Trạng thái sau khi gọi clear():
capacity: 8, limit: 8, position: 0Để giúp dễ hình dung, tôi đã vẽ một hình ảnh thể hiện sự thay đổi của capacity, limit và position ở mỗi giai đoạn.

Channel
Channel thiết lập kết nối với data source như file, network socket. Chúng ta có thể dùng nó để đọc và ghi dữ liệu, giống như mở một đường ống nước để dữ liệu tự do chảy trong Channel.
Stream trong BIO là một chiều, được chia thành các loại InputStream (input stream) và OutputStream (output stream), dữ liệu chỉ truyền theo một hướng. Các loại channel khác nhau có thể dùng để đọc, ghi hoặc đồng thời đọc và ghi. Ví dụ, khả năng đọc và ghi của FileChannel phụ thuộc vào cách mở, còn SocketChannel hỗ trợ cả hai.
Channel tương tác với Buffer đã giới thiệu ở trên: khi đọc, dữ liệu trong Channel được nạp vào Buffer; khi ghi, dữ liệu trong Buffer được ghi vào Channel.

Ngoài ra, một số Channel (ví dụ SocketChannel) hỗ trợ đồng thời đọc và ghi, có thể phản ánh trực tiếp hơn khả năng giao tiếp hai chiều của operating system bên dưới.
Các lớp con của Channel được thể hiện trong hình dưới đây.

Trong đó, các loại Channel được sử dụng phổ biến nhất gồm:
FileChannel: Channel truy cập file;SocketChannel,ServerSocketChannel: Channel giao tiếp qua TCP;DatagramChannel: Channel giao tiếp qua UDP;

Hai method cốt lõi nhất của Channel:
read: đọc dữ liệu và ghi vào Buffer.write: ghi dữ liệu trong Buffer vào Channel.
Ở đây dùng FileChannel để minh họa cách đọc dữ liệu từ file.
RandomAccessFile reader = new RandomAccessFile("/Users/guide/Documents/test_read.in", "r");
FileChannel channel = reader.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);Selector
Selector là một thành phần quan trọng trong NIO, cho phép một thread xử lý nhiều Channel. Selector dựa trên event-driven I/O multiplexing model. Nguyên lý hoạt động chủ yếu là: đăng ký các event trên channel thông qua Selector, sau đó Selector liên tục polling các Channel đã đăng ký. Khi event xảy ra, ví dụ một TCP connection mới được tiếp nhận trên một Channel hoặc có event đọc/ghi, Channel đó sẽ ở trạng thái ready và được Selector lấy ra. Selector đưa các Channel liên quan vào ready set. Thông qua SelectionKey có thể lấy tập hợp Channel ready, sau đó thực hiện các thao tác I/O tương ứng trên những Channel ready đó.

Một multiplexer như Selector có thể đồng thời giám sát nhiều Channel. Cơ chế triển khai bên dưới của Selector do SelectorProvider của platform quyết định, ví dụ trên Linux có thể dùng epoll, còn các platform khác sẽ dùng implementation tương ứng. Số lượng connection có thể tiếp nhận vẫn bị giới hạn bởi các tài nguyên như file descriptor, memory và cấu hình hệ thống.
Selector có thể lắng nghe bốn loại event sau:
SelectionKey.OP_ACCEPT: biểu thị event chấp nhận kết nối của channel, thường dùng choServerSocketChannel.SelectionKey.OP_CONNECT: biểu thị event channel hoàn tất connection, thường dùng choSocketChannel.SelectionKey.OP_READ: biểu thị event channel đã sẵn sàng đọc, nghĩa là có dữ liệu có thể đọc.SelectionKey.OP_WRITE: biểu thị event channel đã sẵn sàng ghi, nghĩa là có thể ghi dữ liệu.
Selector là abstract class, có thể tạo instance Selector bằng cách gọi static method open() của class này. Selector có thể đồng thời giám sát hoạt động I/O của nhiều SelectableChannel, là core của non-blocking I/O.
Một instance Selector có ba tập hợp SelectionKey:
- Tập hợp toàn bộ
SelectionKey: biểu thị cácChannelđã đăng ký trên Selector đó, có thể trả về tập hợp này thông qua methodkeys(). - Tập hợp
SelectionKeyđược chọn: biểu thị toàn bộ Channel có thể lấy được thông qua methodselect()và cần xử lýIO, có thể trả về tập hợp này thông quaselectedKeys(). - Tập hợp
SelectionKeyđã hủy: biểu thị toàn bộChannelđã bị hủy quan hệ đăng ký. Khi thực thi methodselect()lần tiếp theo,SelectionKeytương ứng với cácChannelnày sẽ bị xóa hoàn toàn. Chương trình thường không cần truy cập trực tiếp tập hợp này và cũng không có method để truy cập.
Minh họa đơn giản cách duyệt và xử lý tập hợp SelectionKey được chọn:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key != null) {
if (key.isAcceptable()) {
// ServerSocketChannel đã tiếp nhận connection mới
} else if (key.isConnectable()) {
// Một connection mới đã được thiết lập
} else if (key.isReadable()) {
// Channel có dữ liệu sẵn sàng để đọc
} else if (key.isWritable()) {
// Channel đã sẵn sàng ghi dữ liệu
}
}
keyIterator.remove();
}Selector còn cung cấp một loạt method liên quan đến select():
int select(): giám sát tất cảChannelđã đăng ký. Khi một trong số chúng có thao tácI/Ocần xử lý, method này trả về và thêmSelectionKeytương ứng vào tập hợp cácSelectionKeyđược chọn. Giá trị trả về là số lượng key mà ready set được cập nhật trong thao tác này.int select(long timeout): thao tácselect()có thể thiết lập thời gian timeout.int selectNow(): thực hiện thao tácselect()trả về ngay lập tức. So với methodselect()không có tham số, method này không blocking thread.Selector wakeup(): khiến một methodselect()đang chờ lập tức trả về.- …
Ví dụ đơn giản sử dụng Selector để thực hiện đọc/ghi network:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NioSelectorExample {
public static void main(String[] args) {
try {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
// Đăng ký ServerSocketChannel với Selector và lắng nghe event OP_ACCEPT
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int readyChannels = selector.select();
if (readyChannels == 0) {
continue;
}
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// Xử lý event kết nối
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
// Đăng ký channel của client với Selector và lắng nghe event OP_READ
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// Xử lý event đọc
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = client.read(buffer);
if (bytesRead > 0) {
buffer.flip();
System.out.println("Dữ liệu nhận được: " +new String(buffer.array(), 0, bytesRead));
// Lưu dữ liệu chờ gửi và lắng nghe event OP_WRITE
key.attach(ByteBuffer.wrap("Hello, Client!".getBytes()));
key.interestOps(SelectionKey.OP_WRITE);
} else if (bytesRead < 0) {
// Client đã ngắt connection
client.close();
}
} else if (key.isWritable()) {
// Xử lý event ghi
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
client.write(buffer);
// Non-blocking write có thể chỉ ghi một phần dữ liệu; chuyển lại sang OP_READ sau khi ghi xong toàn bộ
if (!buffer.hasRemaining()) {
key.attach(null);
key.interestOps(SelectionKey.OP_READ);
}
}
keyIterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}Trong ví dụ, chúng ta tạo một server đơn giản lắng nghe port 8080, sử dụng Selector để xử lý event kết nối, đọc và ghi. Khi nhận dữ liệu từ client, server đọc dữ liệu và in ra console, sau đó phản hồi "Hello, Client!" cho client.
NIO zero-copy
Zero-copy là một phương pháp phổ biến để cải thiện hiệu năng thao tác I/O. Các dự án open-source hàng đầu như ActiveMQ, Kafka, RocketMQ, QMQ và Netty đều sử dụng zero-copy.
Zero-copy nghĩa là khi máy tính thực hiện thao tác I/O, CPU không cần copy dữ liệu từ vùng lưu trữ này sang vùng lưu trữ khác, qua đó giảm context switch và thời gian CPU dùng để copy. Nói cách khác, zero-copy chủ yếu giải quyết vấn đề hệ điều hành liên tục copy dữ liệu khi xử lý thao tác I/O. Các kỹ thuật triển khai zero-copy phổ biến gồm: mmap+write, sendfile và sendfile + DMA gather copy.
Hình dưới đây thể hiện sơ đồ so sánh các kỹ thuật zero-copy:
| CPU copy | DMA copy | System call | Context switch | |
|---|---|---|---|---|
| Phương pháp truyền thống | 2 | 2 | read+write | 4 |
| mmap+write | 1 | 2 | mmap+write | 4 |
| sendfile | 1 | 2 | sendfile | 2 |
| sendfile + DMA gather copy | 0 | 2 | sendfile | 2 |
Có thể thấy, dù là I/O theo cách truyền thống hay sau khi áp dụng zero-copy, đều không thể thiếu 2 lần DMA (Direct Memory Access) copy, do cả hai lần copy DMA đều phụ thuộc vào hardware để hoàn thành. Zero-copy chủ yếu giảm CPU copy và context switch.
Java hỗ trợ zero-copy:
MappedByteBufferlà implementation của memory-mapped file do Java NIO cung cấp, có thể map một phần file vào memory. Cơ chế bên dưới phụ thuộc vào hệ điều hành, ví dụ trên Linux thường dựa trênmmap.transferTo()/transferFrom()củaFileChannelcó thể truyền byte trực tiếp giữa các channel. Nhiều hệ điều hành có thể tối ưu kiểu truyền này, ví dụ trên Linux có thể sử dụngsendfile. Cách triển khai cụ thể phụ thuộc vào JDK và hệ điều hành. Về cách sử dụngFileChannel, bạn có thể xem bài viết này: Cách sử dụng Java NIO FileChannel.
Ví dụ code:
private void loadFileIntoMemory(File xmlFile) throws IOException {
FileInputStream fis = new FileInputStream(xmlFile);
// Tạo đối tượng FileChannel
FileChannel fc = fis.getChannel();
// FileChannel.map() map file vào direct memory và trả về đối tượng MappedByteBuffer
MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
xmlFileBuffer = new byte[(int)fc.size()];
mmb.get(xmlFileBuffer);
fis.close();
}Tổng kết
Trong bài viết này, chúng ta chủ yếu giới thiệu những kiến thức cốt lõi về NIO, gồm các thành phần cốt lõi của NIO và zero-copy.
Nếu cần dùng NIO để xây dựng chương trình mạng, không nên dùng trực tiếp NIO gốc vì lập trình phức tạp và tính năng còn hạn chế. Bạn nên dùng các framework lập trình mạng trưởng thành dựa trên NIO như Netty. Netty thực hiện một số tối ưu và mở rộng trên nền tảng NIO, ví dụ hỗ trợ nhiều protocol và SSL/TLS, v.v.
Tham khảo
Phân tích sơ lược Java NIO: https://tech.meituan.com/2016/11/04/nio.html
Người phỏng vấn hỏi: Bạn có biết Java NIO không? https://mp.weixin.qq.com/s/mZobf-U8OSYQfHfYBEB6KA
Java NIO: Buffer, Channel và Selector: https://www.javadoop.com/post/java-nio
Lời cuối
Nếu nội dung hữu ích với bạn, hãy tiện tay tặng JavaGuide một Star miễn phí để ủng hộ: GitHub | Gitee.
JavaGuide đã được duy trì gần bảy năm, tích lũy 6100+ commit, với sự chung tay hoàn thiện của 620+ contributor. Star, phản hồi và PR của bạn đều là động lực để dự án tiếp tục cập nhật.
Nếu bạn đang chuẩn bị phỏng vấn backend / phát triển ứng dụng AI, có thể tham khảo Knowledge Planet của tôi, bao gồm các project thực tế về backend và AI, tối ưu CV, hỏi đáp 1-1 và tài liệu về các trọng điểm thường gặp, đã được duy trì liên tục sáu năm.
