1. Giới thiệu về NIO Channels
Trong Java IO cổ điển (java.io), mọi thứ được xây dựng theo nguyên tắc “một luồng — một tệp hoặc một tài nguyên”. Ngay khi bắt đầu đọc hoặc ghi, luồng sẽ bị chặn và chờ thao tác hoàn tất. Với các trường hợp đơn giản điều này thuận tiện, nhưng trong hệ thống tải cao cách tiếp cận đó trở thành điểm nghẽn: nếu có hàng nghìn kết nối, thì hàng nghìn luồng sẽ bận chờ đợi.
Trong NIO (New IO), cách tiếp cận khác. Ở đây I/O có thể không chặn và luồng không bắt buộc phải rảnh rỗi chờ. Khi một dữ liệu vẫn đang truyền, luồng có thể chuyển sang tác vụ khác. Điều này cho phép phục vụ số lượng kết nối cực lớn chỉ với vài luồng.
Sự khác biệt cũng thấy ở chi tiết. Trong IO “cũ”, công việc xoay quanh các luồng, chúng đọc và ghi byte hoặc ký tự nhưng luôn bị chặn trong thời gian thao tác. Trong NIO, các khái niệm then chốt là kênh (Channels) và bộ đệm (Buffers). Chúng cho phép hiện thực I/O không chặn (quan trọng cho máy chủ), cũng như áp dụng kỹ thuật zero-copy, khi dữ liệu được truyền trực tiếp, tránh các lần sao chép thừa vào bộ đệm của JVM.
So sánh: luồng (Streams) vs kênh (Channels)
Luồng (InputStream/OutputStream):
- Đọc/ghi từng byte hoặc theo mảng.
- Không có cách kiểm soát trực tiếp vị trí trong tệp.
- Không hiệu quả với các tệp rất lớn.
Kênh (Channel):
- Đọc/ghi dữ liệu thông qua bộ đệm (Buffer).
- Có thể quản lý vị trí (bao gồm truy cập ngẫu nhiên).
- Hỗ trợ bất đồng bộ và chế độ không chặn.
- Cho phép dùng zero-copy để sao chép siêu nhanh.
2. FileChannel và SeekableByteChannel
Đọc và ghi dữ liệu bằng bộ đệm
FileChannel là kênh chính để làm việc với tệp. Có thể lấy nó từ FileInputStream, FileOutputStream hoặc qua NIO.2 — Files.newByteChannel (trả về SeekableByteChannel).
Ví dụ: đọc tệp qua FileChannel và 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); // Bộ đệm 1 KB
int bytesRead = channel.read(buffer); // đọc vào bộ đệm
while (bytesRead != -1) {
buffer.flip(); // chuyển bộ đệm sang chế độ đọc
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // xóa bộ đệm cho lần đọc tiếp theo
bytesRead = channel.read(buffer);
}
}
}
}
Ghi vào tệp:
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: mở kênh qua 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);
}
Định vị (position()) và thay đổi kích thước (truncate())
- position() — cho phép lấy hoặc đặt vị trí hiện tại trong tệp (tương tự “con trỏ”).
- truncate(long size) — cắt ngắn tệp tới kích thước chỉ định.
channel.position(100); // di chuyển tới byte thứ 100
channel.truncate(1024); // cắt ngắn tệp còn 1 KB
Truy cập trực tiếp và truy cập theo vị trí đối với tệp
- Truy cập trực tiếp: có thể đọc/ghi vào bất kỳ vị trí nào trong tệp, không chỉ tuần tự.
- Truy cập theo vị trí: có thể đọc/ghi dữ liệu tại vị trí cụ thể mà không thay đổi vị trí hiện tại của kênh.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128); // đọc 4 byte từ vị trí 128 mà không thay đổi channel.position()
3. ByteBuffer: cách nó hoạt động
Các tham số chính: capacity, limit, position, mark
- capacity — kích thước tối đa của bộ đệm (đặt khi tạo).
- limit — giới hạn đến đâu có thể đọc/ghi (mặc định bằng capacity).
- position — vị trí hiện tại (nơi ghi/từ đâu đọc).
- mark — “đánh dấu” có thể đặt và quay lại sau đó.
Vòng đời của bộ đệm:
- Ghi dữ liệu vào bộ đệm (ví dụ, đọc từ kênh bằng read()).
- flip() — chuyển bộ đệm sang chế độ đọc (position = 0, limit = position hiện tại).
- Đọc dữ liệu từ bộ đệm (get()).
- clear() — xóa bộ đệm để lần ghi tiếp theo (position = 0, limit = capacity).
Ví dụ:
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip(); // bây giờ có thể đọc
byte value = buffer.get(); // 42
buffer.clear(); // sẵn sàng cho lần ghi mới
Tạo bộ đệm: allocate() vs allocateDirect()
ByteBuffer có hai cách chính để tạo bộ đệm và sự khác biệt giữa chúng thể hiện rõ trong thực tế. Phương thức allocate() đặt bộ đệm trong heap của JVM: tạo nhanh và phù hợp với hầu hết tác vụ, nhưng với I/O gốc có thể có thêm các lần sao chép giữa heap và bộ nhớ của hệ điều hành.
Phương thức allocateDirect() cấp phát bộ nhớ ngoài heap của JVM (trong “native memory”). Loại bộ đệm này tốn kém hơn khi tạo và khó quản lý hơn, nhưng khi đọc/ghi các tệp lớn hoặc trong các thao tác mạng thì thường nhanh hơn do tránh sao chép thừa.
Ý tưởng đơn giản: nếu hiệu năng trên khối lượng lớn quan trọng — hãy dùng bộ đệm “trực tiếp”. Với thao tác nhỏ và thường xuyên, chi phí tạo có thể vượt lợi ích.
ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);
4. Các thao tác hiệu năng cao: transferTo() và transferFrom()
Các phương thức transferTo() và transferFrom()
Lớp FileChannel có hai phương thức cho phép làm việc theo nguyên tắc “zero-copy” — transferTo() và transferFrom(). Ý tưởng là dữ liệu có thể chuyển trực tiếp giữa các kênh tệp hoặc, chẳng hạn, giữa tệp và mạng. JVM hầu như không tham gia: thao tác do hệ điều hành thực hiện, còn các bộ đệm trong Java không bị đụng tới.
Kết quả là việc sao chép tệp lớn nhanh hơn đáng kể: ít sao chép hơn, ít chuyển giữa user space và kernel space hơn, tải CPU thấp hơn.
Ví dụ: sao chép tệp bằng zero-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("Số byte đã sao chép: " + transferred);
}
}
}
Khi nào zero-copy thực sự hoạt động?
- Khi sao chép giữa các tệp trên cùng một đĩa.
- Khi gửi tệp qua mạng (ví dụ qua SocketChannel).
- Khi hệ điều hành hỗ trợ zero-copy (Linux, macOS, Windows đều hỗ trợ).
Ưu điểm:
- Tối thiểu hóa sao chép: dữ liệu không đi qua các bộ đệm JVM.
- Tốc độ cao: ít chuyển ngữ cảnh hơn và tải CPU thấp hơn.
- Ít bộ nhớ hơn: không cần bộ đệm người dùng lớn.
Ví dụ: sao chép tệp “chỉ một dòng”
Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// Bên trong có thể dùng zero-copy nếu có thể
5. Các lỗi điển hình
Lỗi số 1: quên flip() trước khi đọc từ bộ đệm. Sau khi ghi vào bộ đệm, hãy chắc chắn gọi flip(), nếu không việc đọc sẽ không hoạt động như mong đợi: position/limit sẽ vẫn ở “chế độ ghi”.
Lỗi số 2: sử dụng allocateDirect() cho các thao tác nhỏ. Bộ đệm trực tiếp phù hợp với khối lượng lớn, nhưng với yêu cầu nhỏ thì chi phí tạo của chúng là không đáng. Mặc định hãy ưu tiên allocate().
Lỗi số 3: không đóng kênh. Luôn dùng try-with-resources cho kênh và luồng để tránh rò rỉ descriptor.
Lỗi số 4: nhầm lẫn position/limit/capacity. Trước khi đọc/ghi hãy đảm bảo bộ đệm đang ở chế độ nào: sau khi ghi cần flip(), sau khi đọc để ghi mới — dùng clear() hoặc compact().
Lỗi số 5: kỳ vọng rằng zero-copy “luôn hoạt động”. Trên một số cấu hình (thiết bị khác nhau/hệ thống tệp khác nhau/cờ đặc biệt), zero-copy có thể không khả dụng — khi đó sẽ diễn ra sao chép thông thường và hiệu năng sẽ khác.
GO TO FULL VERSION