Java NIO: Sự Khác Biệt Giữa IO và NIO

1. Giới thiệu về NIO

NIO (New I/O) là thư viện được giới thiệu trong JDK 1.4. Mặc dù có cùng mục đích với IO truyền thống, nhưng cách triển khai hoàn toàn khác biệt. NIO sử dụng cơ chế buffer (bộ đệm) làm trung tâm, giúp tăng hiệu suất đáng kể so với IO thông thường. Java cung cấp hai bộ NIO: một cho xử lý file và standard I/O, một cho lập trình mạng.

2. So sánh IO và NIO

Bảng dưới đây tổng hợp các khác biệt chính:

IO Truyền thốngNIO
Hướng luồng (Stream-oriented)Hướng buffer (Buffer-oriented)
Chế độ chặn (Blocking)Chế độ không chặn (Non-blocking)
Không hỗ trợSelector

2.1. Stream-oriented vs Buffer-oriented

Java IO hoạt động theo mô hình hướng luồng. Dữ liệu được đọc từng byte một hoặc theo khối nhỏ từ stream. Các byte không được lưu trữ ở bất kỳ đâu, và bạn không thể di chuyển tới lui trong stream. Nếu cần thao tác lại dữ liệu, bạn phải đọc lại từ đầu.

Java NIO sử dụng buffer theo hướng tiếp cận khác. Dữ liệu được đọc vào buffer trước khi xử lý, cho phép di chuyển tới lui trong buffer khi cần. Tuy nhiên, bạn cần kiểm tra xem buffer có đủ dữ liệu cần xử lý hay không, và đảm bảo không ghi đè dữ liệu chưa xử lý khi có dữ liệu mới vào.

2.2. Blocking vs Non-blocking IO

Các stream trong Java IO hoạt động theo cơ chế chặn (blocking). Khi gọi read() hoặc write(), luồng thực thi bị chặn cho đến khi dữ liệu được đọc/ghi hoàn tất. Trong thời gian này, luồng không thể làm gì khác.

Java NIO hỗ trợ chế độ không chặn. Một luồng có thể yêu cầu đọc dữ liệu từ channel và nhận ngay lập tức dữ liệu hiện có. Nếu chưa có dữ liệu, phương thức trả về ngay mà không block luồng, cho phép luồng tiếp tục xử lý công việc khác.

Tương tự với operation ghi, một luồng có thể yêu cầu ghi dữ liệu mà không cần chờ hoàn tất, sau đó tiếp tục làm việc khác. Điều này cho phép một luồng duy nhất quản lý nhiều kết nối I/O cùng lúc.

2.3. Selectors

Java NIO cung cấp Selector - một component cho phép một luồng đơn giám sát nhiều kênh input. Bạn có thể đăng ký nhiều channel với một selector, sau đó dùng một luồng duy nhất để "chọn" các channel đã sẵn sàng để đọc hoặc ghi. Cơ chế này đơn giản hóa việc quản lý nhiều kết nối đồng thời.

3. Ảnh hưởng đến thiết kế ứng dụng

Việc lựa chọn IO hay NIO ảnh hưởng đến nhiều khía cạnh thiết kế:

  • Gọi API (IO vs NIO)
  • Xử lý dữ liệu
  • Số lượng luồng sử dụng

3.1. Gọi API

API của NIO khác biệt rõ rệt so với IO. Thay vì đọc trực tiếp từ InputStream theo từng byte, dữ liệu phải được đọc vào buffer trước, sau đó mới xử lý.

3.2. Xử lý dữ liệu

Với thiết kế sử dụng IO truyền thống, dữ liệu được đọc tuần tự từ InputStream hoặc Reader. Ví dụ xử lý text theo dòng:

InputStream input = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input));

String ten = reader.readLine();
String tuoi = reader.readLine();
String email = reader.readLine();
String dienthoai = reader.readLine();

Đặc điểm quan trọng: khi readLine() trả về, bạn chắc chắn dòng đã được đọc hoàn chỉnh. Phương thức sẽ block cho đến khi có đủ dữ liệu dòng. Điều này giúp đơn giản hóa việc xử lý, vì bạn biết chính xác dữ liệu nhận được tại mỗi bước.

Với thiết kế NIO, cách tiếp cận hoàn toàn khác:

ByteBuffer buffer = ByteBuffer.allocate(64);
int soByteDaDoc = channelDoc.read(buffer);

Sau khi gọi read(), bạn không biết buffer có chứa đủ dữ liệu cần xử lý hay không. Giả sử sau lần đọc đầu tiên, buffer chỉ chứa một nửa dòng như "Ho ten:", liệu bạn có thể xử lý được không? Câu trả lời là không - phải đợi đến khi đủ dữ liệu.

Vấn đề đặt ra là: làm sao biết buffer đã sẵn sàng để xử lý? Giải pháp là kiểm tra dữ liệu trong buffer nhiều lần:

ByteBuffer buffer = ByteBuffer.allocate(64);
int bytesRead = channel.read(buffer);

while (!coDuLieuXuLy(buffer)) {
    bytesRead = channel.read(buffer);
}

Phương thức coDuLieuXuLy() cần theo dõi số lượng dữ liệu đã đọc và xác định buffer đã đủ để xử lý hay chưa. Nếu buffer đã đầy, có thể xử lý. Nếu chưa đầu nhưng vẫn cần xử lý một phần, bạn cần xử lý từng phần dữ liệu có sẵn.

3.3. Số lượng luồng

Với IO truyền thống, mỗi kết nối thường cần một luồng riêng để xử lý. Mô hình này đơn giản nhưng tốn kém tài nguyên khi có nhiều kết nối.

Với NIO, một luồng duy nhất có thể quản lý nhiều kết nối đồng thời thông qua Selector. Điều này đặc biệt hữu ích cho:

  • Chat server với hàng nghìn kết nối đồng thời, mỗi kết nối gửi dữ liệu nhỏ
  • P2P network với nhiều kết nối outbound

Tuy nhiên, trade-off là việc parsing dữ liệu phức tạp hơn so với đọc từ blocking stream.

Ngược lại, nếu ứng dụng có ít kết nối nhưng mỗi kết nối yêu cầu băng thông cao với dữ liệu lớn, mô hình IO truyền thống với mỗi kết nối một luồng vẫn là lựa chọn phù hợp.

Đăng vào ngày 1 tháng 6 lúc 17:56