1. Giới thiệu
Hãy nhớ lại cách FileReader và FileWriter hoạt động. Mỗi lần bạn gọi phương thức read() hoặc write() của chúng, sẽ có một lần truy cập hệ thống tệp — tức là máy tính thật sự truy cập đĩa để đọc hoặc ghi một ký tự. Nếu tệp lớn và bạn đọc hoặc ghi từng ký tự một, điều đó biến thành một “cuộc marathon” với hàng nghìn, thậm chí hàng triệu lần truy cập đĩa. Mà như đã biết, đĩa — không phải người bạn nhanh nhất của lập trình viên.
Hãy hình dung thế này: bạn cần rót nước từ xô vào chai, nhưng lại dùng muỗng cà phê. Về mặt kỹ thuật — vẫn làm được, nhưng cực kỳ lâu. Hợp lý hơn là dùng gáo hoặc cốc. Làm việc với tệp cũng vậy: đọc hay ghi từng ký tự chẳng khác nào múc nước bằng muỗng.
Nó diễn ra như sau:
// Đọc tệp từng ký tự (bằng "muỗng cà phê"!)
try (FileReader reader = new FileReader("big.txt")) {
int c;
while ((c = reader.read()) != -1) {
// xử lý ký tự (ví dụ, chỉ đếm)
}
}
Nếu tệp lớn, bạn sẽ nhận thấy chương trình chạy rất chậm.
Buffering: là gì và để làm gì
Bộ đệm (buffer) là một vùng nhớ đặc biệt (thường là mảng), nơi dữ liệu được nạp hoặc ghi không phải từng ký tự, mà theo các khối lớn (ví dụ, 8 KB hoặc hơn). Nói ngắn gọn, buffer là một phần bộ nhớ — kiểu như “xô trung gian” — nơi dữ liệu được đọc hoặc ghi theo từng lô lớn, không phải từng ký tự.
Cách hoạt động như sau: khi đọc, chương trình truy cập đĩa một lần, nạp vào bộ đệm cả một khối dữ liệu rồi phát dần cho bạn từng ký tự từ bộ nhớ. Khi khối kết thúc, khối tiếp theo được nạp. Khi ghi — logic tương tự: dữ liệu trước hết được gom vào bộ đệm, rồi mới được gửi một khối lớn xuống đĩa (hoặc ngay lập tức nếu bạn gọi flush()).
Vì sao nhanh hơn? Bởi vì đĩa vốn chậm, nhất là khi bị “gõ” lặt vặt liên tục. Nếu truy cập ít lần hơn nhưng mỗi lần lấy nhiều hơn, mọi thứ sẽ nhanh hơn rõ rệt. Nói ngắn gọn: bộ đệm giúp giảm số lần truy cập đĩa và tăng tốc chương trình.
2. BufferedReader và BufferedWriter: cú pháp và ví dụ
Trong Java, có hai lớp dùng để đệm khi đọc và ghi tệp văn bản:
- BufferedReader — để đọc tệp văn bản.
- BufferedWriter — để ghi tệp văn bản.
Chúng hoạt động phía trên các Reader/Writer thông thường (ví dụ, FileReader/FileWriter), bổ sung khả năng đệm.
Đọc tệp theo dòng với BufferedReader
Kịch bản phổ biến nhất — đọc tệp theo từng dòng. Phương thức readLine() trả về chuỗi cho đến ký tự xuống dòng ("\n" hoặc "\r\n").
import java.io.*;
public class BufferedReaderExample {
public static void main(String[] args) {
try (BufferedReader reader = new BufferedReader(new FileReader("input.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line); // In ra dòng ra màn hình
}
} catch (IOException e) {
System.out.println("Lỗi khi đọc tệp: " + e.getMessage());
}
}
}
Điều gì đang xảy ra ở đây?
Chúng ta tạo FileReader, vốn đọc tệp từng ký tự, rồi bọc nó bằng BufferedReader. Bộ đọc có đệm sẽ lấy dữ liệu theo các khối lớn, đưa vào bộ nhớ và trả ra cho chúng ta theo từng dòng qua readLine(). Kết quả là bạn chỉ viết vòng lặp while, nhận các dòng liên tiếp và không phải bận tâm kích thước tệp: việc đọc vẫn nhanh và tiết kiệm.
Ghi tệp với BufferedWriter
Ghi các dòng vào tệp cũng có thể hiệu quả:
import java.io.*;
public class BufferedWriterExample {
public static void main(String[] args) {
try (BufferedWriter writer = new BufferedWriter(new FileWriter("output.txt"))) {
writer.write("Hello, world!");
writer.newLine(); // Xuống dòng (phụ thuộc vào OS)
writer.write("Đây là dòng thứ hai.");
} catch (IOException e) {
System.out.println("Lỗi khi ghi tệp: " + e.getMessage());
}
}
}
Điều gì đang xảy ra ở đây?
Đầu tiên chúng ta tạo FileWriter, rồi bọc nó bằng BufferedWriter. Khi bạn gọi write() hoặc newLine(), dữ liệu không chạy ngay xuống đĩa. Chúng được gom vào bộ đệm — một lớp nhớ trung gian. Chỉ khi bộ đệm đầy, hoặc bạn đóng luồng (hoặc gọi flush()), toàn bộ văn bản tích lũy mới được ghi một lần xuống tệp. Cách tiếp cận này tăng tốc ghi và giảm số lần truy cập đĩa.
Điều đó trông như thế nào trong một ứng dụng đơn giản?
Giả sử chúng ta viết một chương trình “nhật ký” đơn giản, lưu các mục vào tệp và hiển thị chúng ra màn hình.
import java.io.*;
import java.util.Scanner;
public class DiaryApp {
public static void main(String[] args) {
String fileName = "diary.txt";
Scanner scanner = new Scanner(System.in);
// Ghi mục mới
System.out.print("Nhập một mục nhật ký mới: ");
String entry = scanner.nextLine();
try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName, true))) {
writer.write(entry);
writer.newLine();
System.out.println("Đã lưu mục!");
} catch (IOException e) {
System.out.println("Lỗi khi ghi: " + e.getMessage());
}
// Đọc tất cả các mục
System.out.println("\nNhật ký của bạn:");
try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("Lỗi khi đọc: " + e.getMessage());
}
}
}
Chúng ta lấy tên tệp "diary.txt" và đề nghị người dùng nhập một mục mới. Để lưu, dùng FileWriter ở chế độ append (truyền true), vì vậy các ghi chú cũ không bị xóa — mỗi dòng mới sẽ được thêm vào cuối tệp. Lớp bọc BufferedWriter giúp việc ghi nhanh và tiết kiệm: dữ liệu được gom trong bộ nhớ rồi ghi một lần xuống đĩa.
Sau đó chúng ta mở cùng tệp đó để đọc qua BufferedReader. Nó lấy nội dung theo các khối lớn và trả ra từng dòng cho bạn. Trong vòng lặp, chương trình chỉ việc in ra màn hình, và bạn sẽ thấy toàn bộ “nhật ký” từ đầu đến cuối.
3. Ưu điểm của BufferedReader và BufferedWriter
Tăng tốc đáng kể
Khi bạn đọc hoặc ghi tệp bằng các luồng có đệm, chương trình sẽ tạo ít lần truy cập đĩa hơn hàng chục, thậm chí hàng trăm lần. Điều này đặc biệt rõ trên các tệp lớn.
Các phương thức tiện lợi
- BufferedReader.readLine() — cho phép đọc tệp theo từng dòng, rất tiện cho xử lý tệp văn bản (ví dụ, log, CSV, cấu hình).
- BufferedWriter.newLine() — thêm ký tự xuống dòng, đặt đúng ký tự tùy theo hệ điều hành.
Dễ sử dụng
- Các lớp dễ dàng kết hợp với các luồng khác (ví dụ, có thể bọc InputStreamReader bên trong BufferedReader để đọc các tệp với mã hóa khác nhau).
- Tự động đóng mọi tài nguyên khi dùng try-with-resources.
Linh hoạt
- Có thể đặt kích thước bộ đệm rõ ràng, nếu mặc định (thường 8 KB) là quá ít hoặc quá nhiều:
BufferedReader reader = new BufferedReader(new FileReader("big.txt"), 16384); // bộ đệm 16 KB
4. Khi nào sử dụng BufferedReader và BufferedWriter
Nên dùng khi:
- Làm việc với tệp văn bản (log, CSV, dữ liệu văn bản lớn).
- Cần đọc hoặc ghi tệp theo từng dòng.
- Để tăng tốc khi làm việc với tệp lớn, khi tốc độ quan trọng.
- Khi cần xử lý luồng dữ liệu từ mạng hoặc nguồn khác hỗ trợ Reader/Writer.
Không nên dùng khi:
- Đối với tệp nhị phân (ví dụ, ảnh, archive, video) — hãy dùng InputStream/OutputStream.
- Nếu tệp rất nhỏ và được đọc/ghi toàn bộ một lần — lợi ích từ bộ đệm sẽ tối thiểu (nhưng cũng không gây hại).
5. Những lưu ý hữu ích
Kết hợp với các mã hóa khác nhau
Nếu bạn cần đọc/ghi tệp với mã hóa xác định (ví dụ, "UTF-8", "Windows-1251"), hãy dùng InputStreamReader/OutputStreamWriter kết hợp với các luồng có đệm:
BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream("input.txt"), "UTF-8")
);
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream("output.txt"), "UTF-8")
);
Xả bộ đệm một cách rõ ràng
Đôi khi quan trọng là dữ liệu chắc chắn đã được ghi xuống đĩa (ví dụ, khi ghi log hoặc hóa đơn). Khi đó hãy gọi writer.flush(). Thông thường việc này không cần, vì đóng luồng sẽ tự động xả bộ đệm.
Kích thước bộ đệm
Mặc định bộ đệm vào khoảng 8 KB. Bạn có thể đặt kích thước khác nếu chắc chắn nó sẽ mang lại hiệu năng tốt hơn (ví dụ, khi xử lý các tệp cực lớn).
So sánh: FileReader/FileWriter vs BufferedReader/BufferedWriter
| Lớp | Tốc độ trên tệp lớn | Thuận tiện đọc theo dòng | Thuận tiện ghi theo dòng | Linh hoạt về mã hóa |
|---|---|---|---|---|
| FileReader/FileWriter | Chậm | Không (chỉ từng ký tự) | Không (chỉ từng ký tự) | Chỉ theo mặc định |
| BufferedReader/Writer | Nhanh | Có (readLine()) | Có (newLine()) | Có (qua InputStreamReader/OutputStreamWriter) |
6. Các lỗi thường gặp khi làm việc với BufferedReader và BufferedWriter
Lỗi số 1: Quên đóng luồng. Nếu không dùng try-with-resources hoặc không gọi close(), tệp có thể bị khóa và dữ liệu có thể chưa được ghi xuống đĩa. Hãy luôn dùng try-with-resources!
Lỗi số 2: Nhầm lẫn giữa tệp văn bản và tệp nhị phân. Cố mở tệp nhị phân (".jpg", ".zip") bằng BufferedReader sẽ dẫn đến “ký tự rác” và rất có thể gây lỗi. Với tệp nhị phân, hãy dùng InputStream/OutputStream.
Lỗi số 3: Không dùng bộ đệm khi dữ liệu lớn. Nếu đọc hoặc ghi từng ký tự, chương trình sẽ chạy chậm. Hãy luôn dùng bộ đệm cho các tệp lớn.
Lỗi số 4: Không gọi flush() khi cần. Nếu cần dữ liệu xuất hiện ngay trên đĩa (ví dụ, cho việc ghi log), hãy gọi writer.flush(). Nhưng thường thì việc đóng luồng đã xử lý điều này.
Lỗi số 5: Bỏ qua mã hóa. Nếu mở tệp với mã hóa không đúng, văn bản có thể hiển thị sai (chữ cái tiếng Nga biến thành “?”, ký tự lạ, v.v.). Hãy luôn chỉ định rõ mã hóa cần dùng nếu nó khác với mã hóa hệ thống (ví dụ, "UTF-8").
GO TO FULL VERSION