Command Palette

Search for a command to run...

[Java Basics] Đọc và ghi file text trong Java

Mọi chương trình bạn viết từ đầu series đến giờ đều quên sạch mọi thứ ngay khi thoát. File là cách rẻ nhất để sửa điều đó: một cái tên trên disk, một dãy byte, và hai thao tác. Java cho bạn hai bộ công cụ khác nhau để làm việc này — nhóm java.io có từ năm 1996, và nhóm java.nio.file đã thay thế nó trong gần như mọi tình huống hằng ngày — bài này nói về cả hai, vì bạn sẽ đọc code cũ dùng cái thứ nhất và nên viết code mới bằng cái thứ hai.

Có một ý nằm dưới tất cả những thứ đó mà phần lớn tutorial bỏ qua. File chứa byte; String chứa ký tự. Phải có thứ gì đó dịch qua lại giữa hai bên, thứ đó là charset, và trong file không hề có chỗ nào ghi lại charset đã dùng. Sai charset thì text trở về thành rác — im lặng, máy này lỗi máy kia không. Toàn bộ nội dung bên dưới được compile và chạy thật trên OpenJDK 21.0.6 (arm64), kèm một lần chạy trên OpenJDK 17.0.14 ở chỗ mà phiên bản thực sự tạo ra khác biệt.

Ghi một String ra file rồi đọc lại, với charset nằm ở giữa

Bắt đầu từ việc chọn API, vì nó quyết định mọi thứ phía sau.

Hai API cho cùng một việc

java.iojava.nio.file
Đặt tên file bằngString hoặc FilePath
Mở readernew FileReader(name)Files.newBufferedReader(path)
Đọc hết một lầnlặp readLine()Files.readString(path)
Ghi hết một lầnlặp write()Files.writeString(path, text)
File không tồn tạiFileNotFoundExceptionNoSuchFileException
Byte sai charsetthay thế im lặngMalformedInputException
Có từJava 1.0 / 1.1Java 7, phần hay nhất ở Java 8 và 11

Cả hai đều nằm trong JDK, cả hai đều chạy được, và chúng chuyển đổi qua lại được: Path.of("a.txt").toFile() cho bạn một File, còn file.toPath() đi ngược lại. Quy tắc thực dụng là hôm nay bạn nên dùng java.nio.file, còn java.io là thứ bạn vẫn phải nhận ra, vì BufferedReaderBufferedWriter vẫn là câu trả lời đúng khi phải duyệt qua một file quá lớn để nạp hết vào bộ nhớ — và chính Files trả về đúng hai class đó cho bạn.

Ghi text ra file

Chương trình nhỏ nhất đặt được text lên disk:

import java.io.FileWriter;
import java.io.IOException;

public class WriteNotes {
    public static void main(String[] args) throws IOException {
        try (FileWriter writer = new FileWriter("notes.txt")) {
            writer.write("first line" + System.lineSeparator());
            writer.write("second line" + System.lineSeparator());
        }
        System.out.println("wrote notes.txt");
    }
}

Có bốn thứ đang xảy ra ở đây đáng được gọi tên.

new FileWriter("notes.txt") tạo file nếu chưa có và cắt file về 0 byte nếu đã có. Không có hộp thoại xác nhận, không có "bạn chắc chứ" — nội dung cũ biến mất ngay khi constructor trả về.

write nhận một String, một char, hoặc một char[]. Nó không tự thêm ký tự xuống dòng, nên bạn phải tự thêm. System.lineSeparator() trả về đúng thứ mà platform dùng — một ký tự newline trên macOS và Linux, một carriage return cộng một newline trên Windows. Viết thẳng escape \n cũng được và đa số code làm vậy; line separator quan trọng lúc đọc hơn là lúc ghi.

Phần try (...) là một statement try-with-resources, và nó chính là lý do file thực sự có nội dung. Nó có riêng một mục bên dưới.

throws IOException trên main là cách lười, chấp nhận được trong chương trình vứt đi. Code thật thì phải catch, và mục về file không tồn tại cho thấy bạn sẽ nhận được gì.

Ghi đè hay ghi thêm

Constructor một tham số thì cắt file. Constructor hai tham số nhận thêm một boolean nghĩa là append:

try (FileWriter w = new FileWriter("log.txt")) { w.write("run 1" + System.lineSeparator()); }
try (FileWriter w = new FileWriter("log.txt")) { w.write("run 2" + System.lineSeparator()); }
System.out.println("after two overwrites: " + Files.readAllLines(Path.of("log.txt")));

try (FileWriter w = new FileWriter("log.txt", true)) { w.write("run 3" + System.lineSeparator()); }
System.out.println("after one append:     " + Files.readAllLines(Path.of("log.txt")));
after two overwrites: [run 2]
after one append:     [run 2, run 3]

Hai lần ghi mà không có flag chỉ còn lại một dòng. Cái flag đó chính là toàn bộ khác biệt giữa một file log và một file chỉ nhớ được lần chạy cuối cùng.

Bọc thêm BufferedWriter

FileWriter không có method nào để ghi ký tự xuống dòng, và nó đẩy thẳng từng lời gọi xuống hệ điều hành. Bọc nó lại là sửa được cả hai:

try (BufferedWriter out = new BufferedWriter(new FileWriter("notes.txt"))) {
    out.write("first line");
    out.newLine();
    out.write("second line");
    out.newLine();
}

newLine() ghi System.lineSeparator() giùm bạn. Phần buffer mới là phần quan trọng hơn, và mục sau nữa sẽ đo chính xác nó đổi lại được gì.

Đọc file text

Đọc là hình ảnh phản chiếu, với thêm một class trong chồng:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ReadNotes {
    public static void main(String[] args) throws IOException {
        try (BufferedReader reader = new BufferedReader(new FileReader("notes.txt"))) {
            String line;
            int number = 1;
            while ((line = reader.readLine()) != null) {
                System.out.println(number + ": " + line);
                number++;
            }
        }
    }
}
1: first line
2: second line

readLine() là con ngựa kéo chính. Nó trả về dòng kế tiếp không kèm ký tự kết thúc dòng, và trả về null — chứ không phải chuỗi rỗng — khi hết file. Cái null đó chính là điều kiện của vòng lặp, và cách viết gán-trong-điều-kiện ở trên là cách chuẩn.

Bản thân FileReader không có readLine(). Nó chỉ có read(), trả về từng ký tự một dưới dạng int, và -1 khi hết. Gần như không bao giờ bạn muốn như vậy, nên FileReader hầu như luôn được bọc trong một BufferedReader.

Gom các dòng vào một list chỉ là sửa hai dòng:

List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new FileReader("notes.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        lines.add(line);
    }
}
System.out.println(lines);
System.out.println("lines = " + lines.size());
[first line, second line]
lines = 2

readLine xử lý các trường hợp biên ra sao

Năm file, ghi ra rồi đọc lại để thấy chính xác ranh giới nằm ở đâu:

crlf.txt               13 bytes -> [alpha, beta]
no-trailing.txt        7 bytes -> [one, two]
trailing.txt           8 bytes -> [one, two]
blank-line.txt         5 bytes -> [a, , b]
empty.txt              0 bytes -> []

Đọc kỹ bảng này, vì bốn trên năm dòng đi ngược lại điều người ta hay mặc định:

  • Line ending kiểu Windows chạy bình thường. crlf.txt chứa alphabeta ngăn nhau bằng một carriage return và một newline — 13 byte cho 9 ký tự text — và trở về thành hai dòng sạch. readLine() coi newline, carriage return, hay carriage return kèm newline đều là một terminator và cắt sạch.
  • Thiếu newline cuối file không đổi gì cả. no-trailing.txttrailing.txt khác nhau đúng một byte mà cho ra cùng hai dòng.
  • Dòng trống là một dòng thật. Nó trở về thành chuỗi rỗng, không phải là không có gì.
  • File rỗng cho ra list rỗng, và readLine() trả về null ngay ở lần gọi đầu tiên.

Vì sao buffer lại quan trọng

"Dùng BufferedReader cho nhanh" thì đúng nhưng vô dụng — nó không nói cho bạn biết cái gì đã thay đổi. Câu trả lời trung thực là buffer thay đổi số lần lớp bên dưới bị gọi, nên hãy đếm thay vì bấm giờ. Bọc cái đích thật bằng một subclass tăng biến đếm:

class CountingWriter extends Writer {
    private final Writer target;
    int calls = 0;
    long chars = 0;

    CountingWriter(Writer target) { this.target = target; }

    @Override public void write(char[] cbuf, int off, int len) throws IOException {
        calls++;
        chars += len;
        target.write(cbuf, off, len);
    }
    @Override public void flush() throws IOException { target.flush(); }
    @Override public void close() throws IOException { target.close(); }
}

Writer dồn mọi overload của write xuống write(char[], int, int), nên một biến đếm đó thấy hết. Một CountingReader làm y hệt cho read(char[], int, int). Năm mươi nghìn dòng đi qua từng đường:

FileWriter alone        calls=50000   chars=538890
BufferedWriter wrapping calls=66      chars=538890
same bytes on disk: true (538890 bytes)
read() char by char     calls=538891  chars=538890
BufferedReader.readLine calls=67      chars=538890  lines=50000

Chồng các lớp từ code của bạn xuống disk, kèm số lời gọi đếm được ở mỗi chặng

Năm mươi nghìn lời gọi còn sáu mươi sáu. Hai file giống nhau từng byte ở mức 538.890 byte; thứ duy nhất thay đổi là số lần lớp bên dưới bị làm phiền. Phép tính khớp chính xác: buffer mặc định của BufferedWriter là 8192 ký tự, và 65 lần buffer đầy 8192 cộng một lần cuối 6.410 là 66.

Phía đọc còn tệ hơn khi không có buffer. Gọi read() trong vòng lặp đã hỏi reader bên dưới 538.891 lần — mỗi ký tự một lần, cộng một lần trả về -1. Qua BufferedReader thì cùng 50.000 dòng đó chỉ tốn 67 lời gọi. Chênh nhau 8.000 lần.

Đó là lý do BufferedWriterBufferedReader tồn tại và lý do gần như mọi ví dụ về file đều bọc lại. Nó cũng là lý do một buffered writer không bao giờ được close sẽ làm mất dữ liệu của bạn:

BufferedWriter w = new BufferedWriter(new FileWriter("lost.txt"));
w.write("this line never reaches the disk");
// không close(), không flush()
without close(): exists = true, size = 0
with close():    exists = true, size = 32

File đã được tạo. Các ký tự vẫn nằm trong buffer, và buffer bị vứt đi khi chương trình thoát. Close writer — hoặc để try-with-resources làm — thì đúng chuỗi đó rơi xuống disk thành 32 byte.

try-with-resources

Mỗi reader và writer đều giữ một file descriptor của hệ điều hành, mà hệ điều hành thì chỉ có một số lượng hữu hạn. Resource bạn quên close sẽ bị rò rỉ cho tới khi process kết thúc. Trước Java 7, cách duy nhất đảm bảo được việc close là một block finally:

static String firstLine(String path) throws IOException {
    BufferedReader reader = null;
    try {
        reader = new BufferedReader(new FileReader(path));
        return reader.readLine();
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException ignored) {
                // nuốt luôn, vì close() cũng có thể throw
            }
        }
    }
}

Đếm thử phần thủ tục: khởi tạo null bên ngoài block, kiểm tra null bên trong finally, và thêm một try/catch nữa quanh close() vì bản thân việc đóng cũng có thể lỗi — và nếu lỗi thì nó sẽ đè mất lỗi thật. Hai resource là lồng nguyên hình dạng đó hai lần. Ba resource là ba lần.

Try-with-resources nén tất cả vào phần header của try:

static String firstLine(String path) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        return reader.readLine();
    }
}

Bất cứ thứ gì implement AutoCloseable đều đặt được vào đó, ngăn nhau bằng dấu chấm phẩy. Compiler sinh phần close.

Resource được đóng theo thứ tự ngược lại

Thứ tự này không phải chi tiết cài đặt — nó nằm trong đặc tả, và nó là thứ tự duy nhất có thể đúng, vì resource khai báo sau có thể phụ thuộc vào resource khai báo trước. Ba resource tự báo danh:

class Noisy implements Closeable {
    private final String name;
    Noisy(String name) { this.name = name; System.out.println("open  " + name); }
    void use() { System.out.println("use   " + name); }
    @Override public void close() { System.out.println("close " + name); }
}

try (Noisy a = new Noisy("a");
     Noisy b = new Noisy("b");
     Noisy c = new Noisy("c")) {
    a.use(); b.use(); c.use();
}
open  a
open  b
open  c
use   a
use   b
use   c
close c
close b
close a

Ba resource mở xuôi và đóng ngược, đặt cạnh khối finally lồng nhau mà nó thay thế

Đó chính xác là lý do new BufferedReader(new FileReader(path)) chỉ cần object ngoài cùng trong header: close BufferedReader là close luôn thứ nó bọc, đúng thứ tự, mà bạn không phải viết gì thêm.

Giờ thử throw từ trong body:

open  a
open  b
close b
close a
caught boom

Cả hai resource đều được đóng trước khi exception rời khỏi block. Đây chính là lời hứa: việc close xảy ra trên đường bình thường, trên đường exception, và cả khi return từ bên trong block.

Khi chính close bị lỗi

Đây là trường hợp mà bản finally xử lý rất tệ. Một resource mà body throw close() cũng throw:

try (Exploding e = new Exploding()) {
    e.use();                                   // throw IllegalStateException
} catch (Exception e) {
    System.out.println("caught      " + e);
    for (Throwable s : e.getSuppressed()) {
        System.out.println("suppressed  " + s);
    }
}
caught      java.lang.IllegalStateException: body failed
suppressed  java.io.IOException: close failed

Lỗi từ body thắng, vì nó mới là thứ mô tả chuyện thực sự sai. Lỗi từ close() không bị vứt đi và cũng không được phép che mất cái đầu tiên — nó được gắn kèm vào đó, lấy ra bằng getSuppressed(), và in ra thành dòng Suppressed: trong stack trace. Bản finally viết tay ở trên buộc phải chọn một cái và ném cái còn lại đi.

Thêm một tiện ích nữa, từ Java 9: nếu bạn đã có resource trong một biến effectively-final, bạn gọi thẳng tên nó được.

BufferedReader r = new BufferedReader(new FileReader("notes.txt"));
try (r) {
    System.out.println(r.readLine());
}

Sau block thì resource đã đóng nhưng biến vẫn còn trong scope, nên đụng vào nó là lỗi:

java.io.IOException: Stream closed

API hiện đại: Path và Files

java.nio.file cho bạn một type đàng hoàng để chỉ vị trí, và một class đầy các thao tác một dòng. Đây là thứ nên viết hôm nay.

Path file = Path.of("data", "students.txt");
Files.createDirectories(file.getParent());

Files.writeString(file, "An,8.5\nBinh,6.0\nChi,9.25\n", StandardCharsets.UTF_8);
Files.writeString(file, "Dung,7.75\n", StandardCharsets.UTF_8, StandardOpenOption.APPEND);

String whole = Files.readString(file, StandardCharsets.UTF_8);
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
size on disk = 35
readString   = An,8.5\nBinh,6.0\nChi,9.25\nDung,7.75\n
readAllLines = [An,8.5, Binh,6.0, Chi,9.25, Dung,7.75]
line count   = 4

(Các newline trong readString được in ra dưới dạng escape hai ký tự để cả giá trị nằm gọn trên một dòng.)

Lời gọiTrả vềTừDùng khi
Files.readString(path, cs)một String11file nhỏ và bạn muốn lấy hết
Files.readAllLines(path, cs)List<String>7file nhỏ và bạn muốn từng dòng
Files.lines(path, cs)Stream<String>8file lớn — các dòng được đọc lazy
Files.writeString(path, s, cs, opts)ghi một String11bạn đã có sẵn toàn bộ text
Files.write(path, lines, cs, opts)ghi một collection7bạn có một List<String>
Files.newBufferedReader(path, cs)BufferedReader7bạn muốn duyệt và tự điều khiển vòng lặp
Files.newBufferedWriter(path, cs, opts)BufferedWriter7bạn muốn ghi dần và dùng newLine()

Files.lines là cái cần cẩn thận: nó trả về một Stream đang giữ file mở, nên nó phải nằm trong try-with-resources như mọi resource khác.

try (Stream<String> s = Files.lines(file, StandardCharsets.UTF_8)) {
    double avg = s.map(l -> l.split(",")[1])
                  .mapToDouble(Double::parseDouble)
                  .average()
                  .orElse(0);
    System.out.println("average = " + avg);
}
average = 7.875

Chọn giữa readAllLineslines là chuyện bộ nhớ, không phải chuyện phong cách. readAllLines dựng xong cả list rồi mới trả về; với một file log một gigabyte thì đó là lượng heap bạn không có. lines đọc từng dòng một và để phần còn lại nằm yên trên disk.

Phần còn lại của Files giải quyết những câu hỏi mà nếu không có nó bạn phải viết năm dòng: Files.exists, Files.notExists, Files.isRegularFile, Files.isReadable, Files.size, Files.createDirectories, Files.copy, Files.move, và Files.deleteIfExists — trả về true ở lần đầu và false ở lần thứ hai.

Đường dẫn tương đối và working directory

Path.of("data/notes.txt") là một đường dẫn tương đối. Nó không được phân giải lúc bạn tạo ra — nó được phân giải so với working directory của process vào đúng lúc thực sự dùng. Thư mục đó được chốt lại khi JVM khởi động, và nó là thư mục bạn gõ lệnh java, không phải thư mục chứa file .class.

System.out.println("user.dir       = " + System.getProperty("user.dir"));
Path rel = Path.of("data/notes.txt");
System.out.println("isAbsolute     = " + rel.isAbsolute());
System.out.println("toAbsolutePath = " + rel.toAbsolutePath());
System.out.println("getFileName    = " + rel.getFileName());
System.out.println("getParent      = " + rel.getParent());
System.out.println("resolve        = " + Path.of("/var/app").resolve("conf/app.properties"));
System.out.println("normalize      = " + Path.of("data/../data/./notes.txt").normalize());
user.dir       = /private/tmp/claude-501/fileio35
isAbsolute     = false
toAbsolutePath = /private/tmp/claude-501/fileio35/data/notes.txt
getFileName    = notes.txt
getParent      = data
resolve        = /var/app/conf/app.properties
normalize      = data/notes.txt

Chạy đúng chương trình đó từ một thư mục con thì chỉ một thứ đổi:

user.dir       = /private/tmp/claude-501/fileio35/sub
toAbsolutePath = /private/tmp/claude-501/fileio35/sub/data/notes.txt

Đây là lý do phổ biến nhất khiến file của người mới "biến mất". Chương trình ghi đúng, ghi vào một thư mục khác, vì nó được khởi chạy từ chỗ khác — IDE thường lấy thư mục gốc của project còn terminal thì lấy đúng chỗ bạn đang đứng. In Path.of(name).toAbsolutePath() một lần là hết bí ẩn.

⚠️ System.setProperty("user.dir", "/tmp") không dời được working directory. Default filesystem đã chốt giá trị gốc lúc khởi động, nên sau khi set property, Path.of("x.txt").toAbsolutePath() trong lần chạy trên vẫn phân giải theo thư mục cũ. Không có cách nào được hỗ trợ để đổi working directory của một JVM đang chạy; hãy dựng một Path tuyệt đối thay vì cố.

Đường dẫn tuyệt đối thì bền theo hướng ngược lại: Path.of("/etc/hosts") nghĩa như nhau với mọi người chạy, và không có gì trong cách khởi chạy dời được nó. Cách dung hòa thường thấy là lấy một thư mục gốc từ config rồi resolve các tên tương đối vào đó.

Khi file không tồn tại

Đọc một file không có là lỗi bạn sẽ gặp nhiều nhất, và hai API báo nó theo hai kiểu khác nhau. Cả hai thông báo, nguyên văn:

new FileReader(missing)         java.io.FileNotFoundException: nope.txt (No such file or directory)
Files.readString(missing)       java.nio.file.NoSuchFileException: nope.txt
Files.readAllLines(missing)     java.nio.file.NoSuchFileException: nope.txt
Files.lines(missing)            java.nio.file.NoSuchFileException: nope.txt
new FileWriter(missing dir)     java.io.FileNotFoundException: no/such/dir/out.txt (No such file or directory)
Files.writeString(missing dir)  java.nio.file.NoSuchFileException: no/such/dir/out.txt
new FileReader(directory)       java.io.FileNotFoundException: adir (Is a directory)
Files.readString(directory)     java.io.IOException: Is a directory

Để ý hình dạng từng thông báo. FileNotFoundException mang theo đường dẫn lời giải thích của hệ điều hành trong ngoặc, nên cùng một exception type đó phủ cả "không có", "là thư mục" và, hóa ra, cả "không đủ quyền". NoSuchFileException chỉ mang theo đường dẫn — gọi getMessage() trên nó chỉ trả về tên file chứ không gì khác, điều làm nhiều người ngạc nhiên khi in e.getMessage() và nhận về mỗi chữ config.txt mà không rõ chuyện gì. Hãy in chính exception đó, hoặc e.toString(), khi đó tên class sẽ cung cấp phần ý nghĩa.

Stack trace đầy đủ, để bạn nhận mặt:

Exception in thread "main" java.io.FileNotFoundException: config.txt (No such file or directory)
	at java.base/java.io.FileInputStream.open0(Native Method)
	at java.base/java.io.FileInputStream.open(FileInputStream.java:213)
	at java.base/java.io.FileInputStream.<init>(FileInputStream.java:152)
	at java.base/java.io.FileInputStream.<init>(FileInputStream.java:106)
	at java.base/java.io.FileReader.<init>(FileReader.java:60)
	at Trace.main(Trace.java:4)
Exception in thread "main" java.nio.file.NoSuchFileException: config.txt
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:261)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:380)
	at java.base/java.nio.file.Files.newByteChannel(Files.java:432)
	at java.base/java.nio.file.Files.readAllBytes(Files.java:3281)
	at java.base/java.nio.file.Files.readString(Files.java:3359)
	at java.base/java.nio.file.Files.readString(Files.java:3318)
	at Trace2.main(Trace2.java:4)

Cả hai class đều kế thừa IOException, nên một catch (IOException e) phủ được bất kỳ API nào bạn dùng:

java.io.FileNotFoundException           -> java.io.IOException
java.nio.file.NoSuchFileException       -> java.nio.file.FileSystemException -> java.io.IOException
java.nio.file.AccessDeniedException     -> java.nio.file.FileSystemException -> java.io.IOException
java.nio.charset.MalformedInputException -> java.nio.charset.CharacterCodingException -> java.io.IOException

Lỗi phân quyền cho thấy vấn đề đặt tên ở mức tệ nhất. Ghi vào một thư mục đã bị gỡ quyền ghi:

NIO : java.nio.file.AccessDeniedException: data/x.txt
io  : java.io.FileNotFoundException: data/y.txt (Permission denied)

Một FileNotFoundException mà thông báo lại nói rằng file được tìm thấy và bị từ chối. AccessDeniedException nói đúng chuyện gì xảy ra ngay trong cái tên — thêm một lý do nữa để ưu tiên API mới.

Hai thói quen làm cho tất cả những thứ này dễ thở. Kiểm tra trước khi file thiếu là chuyện bình thường — Files.notExists(path) rồi trả về list rỗng, thay vì catch exception để cài đặt một giá trị mặc định. Catch và báo cáo khi nó không bình thường — và đưa đường dẫn tuyệt đối vào thông báo, vì "không đọc được config.txt" mà không có thư mục thì chưa phải một báo cáo lỗi.

Character encoding

Đây là phần vỡ trên production và không bao giờ vỡ trên máy bạn. File là byte. String là ký tự. Charset là ánh xạ giữa hai bên, và trong một file text thuần không có gì ghi lại charset đã dùng — không header, không magic number, không gì cả. Bên đọc phải được nói cho biết, nếu không thì nó đoán.

Một chuỗi, encode một lần và decode hai kiểu, kèm giá trị byte thật

Lấy chuỗi năm ký tự Tiếng. Encode bằng UTF-8 thì nó là bảy byte, vì ế (U+1EBF) cần tới ba byte:

chars = 5 : Tiếng
U+0054 U+0069 U+1EBF U+006E U+0067
bytes = 7 : 54 69 E1 BA BF 6E 67

Decode đúng bảy byte đó bằng ISO-8859-1 — một charset mà mỗi byte đúng là một ký tự — thì bạn nhận về bảy ký tự thay vì năm, mỗi ký tự đều hợp lệ và gộp lại thì vô nghĩa. Đó là mojibake, và nó không phải là hỏng dữ liệu: mọi byte đều còn nguyên. Chỉ có luật đọc chúng là đổi.

JDK 21 thực sự làm gì

Java 18 ra mắt JEP 400, biến UTF-8 thành charset mặc định của các API chuẩn trên mọi platform. Trước đó, FileReaderFileWriter dùng mặc định của platform, tức là lấy theo locale của hệ điều hành. Đây là những gì JVM đang chạy báo về:

System.out.println("java.version             = " + System.getProperty("java.version"));
System.out.println("Charset.defaultCharset() = " + Charset.defaultCharset());
System.out.println("file.encoding            = " + System.getProperty("file.encoding"));
System.out.println("native.encoding          = " + System.getProperty("native.encoding"));

Path p = Path.of("vn.txt");
Files.writeString(p, "Xin chào, Tiếng Việt", StandardCharsets.UTF_8);
System.out.println("chars written            = " + "Xin chào, Tiếng Việt".length());
System.out.println("bytes on disk            = " + Files.size(p));

try (BufferedReader r = new BufferedReader(new FileReader("vn.txt"))) {
    System.out.println("FileReader (no charset)  = " + r.readLine());
}
System.out.println("Files.readString(UTF_8)  = " + Files.readString(p, StandardCharsets.UTF_8));
java.version             = 21.0.6
Charset.defaultCharset() = UTF-8
file.encoding            = UTF-8
native.encoding          = UTF-8
chars written            = 20
bytes on disk            = 25
FileReader (no charset)  = Xin chào, Tiếng Việt
Files.readString(UTF_8)  = Xin chào, Tiếng Việt

Hai mươi ký tự, hai mươi lăm byte, và một vòng ghi-đọc sạch sẽ qua một FileReader không hề được truyền charset nào. native.encoding, thêm vào từ Java 17, là property riêng vẫn báo về locale của hệ điều hành — JDK giữ nó lại để code nào thật sự cần giá trị của platform thì hỏi được.

Khác biệt giữa hai property chỉ lộ ra khi locale không phải UTF-8. Chạy đúng class đó dưới LANG=C:

[JDK 21.0.6, LANG=C]
Charset.defaultCharset() = UTF-8
native.encoding          = US-ASCII

native.encoding tụt xuống theo locale thành US-ASCII; Charset.defaultCharset() thì không nhúc nhích. Đó là JEP 400 đang làm việc.

Cùng chương trình đó trên Java 17

Giờ mới tới lý do bài này khăng khăng bắt truyền charset. Vẫn class đó, compile bằng --release 17 và chạy trên OpenJDK 17.0.14, ghi Xin chào bằng một FileWriter trần:

[JDK 21.0.6, LANG=C]
java.version = 21.0.6   defaultCharset = UTF-8   native.encoding = US-ASCII
bytes written = 9 : 58 69 6E 20 63 68 C3 A0 6F
read back     = Xin chào

[JDK 17.0.14, LANG=C]
java.version = 17.0.14   defaultCharset = US-ASCII   native.encoding = US-ASCII
bytes written = 8 : 58 69 6E 20 63 68 3F 6F
read back     = Xin ch?o

Tám byte thay vì chín. Byte 3F là dấu chấm hỏi: chữ à không bị méo trên đường về, nó bị hủy trên đường đi ra. US-ASCII không có cách biểu diễn nào cho nó, nên encoder thay bằng ? và ký tự gốc không thể khôi phục từ file đó nữa. Không exception nào được ném. Chương trình không in ra gì bất thường. Dữ liệu chỉ đơn giản là mất.

Thêm charset vào — new FileWriter(file, StandardCharsets.UTF_8), có từ Java 11 — thì cũng lần chạy JDK 17 đó ghi ra chín byte đúng và đọc lại được Xin chào. Truyền charset rõ ràng là thói quen sống sót qua mọi phiên bản JDK, kể cả những phiên bản bạn không tự chọn để deploy.

Tạo lỗi mojibake có chủ đích

Ghi bằng UTF-8, rồi cố tình đọc bằng ISO-8859-1:

Path p = Path.of("thanks.txt");
Files.writeString(p, "Cảm ơn bạn", StandardCharsets.UTF_8);

try (InputStream in = new FileInputStream("thanks.txt");
     BufferedReader reader = new BufferedReader(
             new InputStreamReader(in, StandardCharsets.ISO_8859_1))) {
    System.out.println("read as ISO-8859-1 = " + reader.readLine());
}
System.out.println("read as UTF-8      = " + Files.readString(p, StandardCharsets.UTF_8));
written as UTF-8   = Cảm ơn bạn  (15 bytes)
read as ISO-8859-1 = Cảm ơn bạn
read as UTF-8      = Cảm ơn bạn

Cảm Æ¡n bạn là output thật, không phải minh họa. Mười ký tự thành mười lăm — mỗi byte một ký tự — và cái mẫu á, º, £, Æ chính là dấu vân tay của UTF-8 bị đọc bằng charset một byte; nhìn quen một lần là sau này bạn chẩn đoán được ngay. Với các chữ tiếng Việt khác thì còn tệ hơn: chữ trong Việt encode ra E1 BB 87, mà byte 87 trong ISO-8859-1 là một C1 control character vô hình, nên một phần thiệt hại thậm chí không hiện lên màn hình.

Báo lỗi to thay vì im lặng

Hai API bất đồng về việc phải làm gì với những byte không hợp lệ trong charset bạn yêu cầu. Ghi Xin chào bằng ISO-8859-1, ở đó à là byte đơn E0 chứ không phải một chuỗi UTF-8 hợp lệ đứng một mình, rồi đọc file đó lại bằng UTF-8:

Files.readString   java.nio.charset.MalformedInputException: Input length = 3
Files.readAllLines java.nio.charset.MalformedInputException: Input length = 1
FileReader         Xin ch�o
code points        U+0058 U+0069 U+006E U+0020 U+0063 U+0068 U+FFFD U+006F

Files.readString từ chối. FileReader chấp nhận và lặng lẽ thay bằng U+FFFD, replacement character, nghĩa là chỗ hỏng đó đi tiếp xuống database của bạn thay vì dừng lại ngay lúc đọc. Nếu bạn muốn biết sớm thì dùng các method của Files; còn nếu buộc phải dùng Reader, hãy cấu hình một CharsetDecoder với CodingErrorAction.REPORT.

Còn một cái bẫy cuối đáng tách riêng: console có encoding của riêng nó. Trên JDK 21 dưới LANG=C, một chương trình đọc file hoàn toàn đúng vẫn in ra Xin ch?o, vì stdout.encoding là US-ASCII — file thì ổn còn terminal thì không. Kiểm tra byte trên disk bằng hexdump -C trước khi đổ lỗi cho file.

Ghép lại thành một chương trình

Tất cả những thứ trên, viết theo đúng hình dạng bạn sẽ viết thật:

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class StudentFile {
    private static final Path FILE = Path.of("data", "students.csv");

    static void save(List<String> rows) throws IOException {
        Files.createDirectories(FILE.getParent());
        try (BufferedWriter out = Files.newBufferedWriter(FILE, StandardCharsets.UTF_8)) {
            for (String row : rows) {
                out.write(row);
                out.newLine();
            }
        }
    }

    static List<String> load() throws IOException {
        if (Files.notExists(FILE)) {
            return new ArrayList<>();
        }
        return Files.readAllLines(FILE, StandardCharsets.UTF_8);
    }

    public static void main(String[] args) {
        try {
            save(List.of("An,8.5", "Bình,6.0", "Chi,9.25"));
            List<String> rows = load();
            System.out.println("loaded " + rows.size() + " rows from " + FILE.toAbsolutePath());
            double total = 0;
            for (String row : rows) {
                String[] parts = row.split(",");
                System.out.printf("%-8s %s%n", parts[0], parts[1]);
                total += Double.parseDouble(parts[1]);
            }
            System.out.printf("average  %.2f%n", total / rows.size());
        } catch (IOException e) {
            System.out.println("could not use " + FILE.toAbsolutePath() + ": " + e);
        }
    }
}
loaded 3 rows from /private/tmp/claude-501/fileio35/demo/data/students.csv
An       8.5
Bình     6.0
Chi      9.25
average  7.92

Sáu quyết định trong ba mươi dòng, tất cả đều lấy từ các mục ở trên: một hằng Path để vị trí chỉ được viết một lần, createDirectories để lần chạy đầu tiên trên một bản checkout sạch vẫn chạy được, charset rõ ràng ở cả hai đầu, newBufferedWriter cho phần ghi có buffer và newLine() cho line separator, notExists trả về list rỗng vì file chưa có ở lần chạy đầu là bình thường chứ không phải bất thường, và một catch (IOException) in ra đường dẫn tuyệt đối.

Gỡ quyền ghi của thư mục rồi xóa file đi, cái catch làm đúng việc của nó:

could not use /private/tmp/claude-501/fileio35/demo/data/students.csv: java.nio.file.AccessDeniedException: data/students.csv

Để ý là thông báo của chính exception nói data/students.csv — đường dẫn tương đối — còn dòng bạn tự viết mới nói nó thật sự nằm ở đâu. Đó là toàn bộ lý lẽ cho việc nhét toAbsolutePath() vào thông báo lỗi của bạn.

Những lỗi thường gặp

Quên close writer. File được tạo, file rỗng, và không có lỗi nào. Đo được ở trên: 0 byte khi không close(), 32 byte khi có. Dùng try-with-resources thì chuyện này không xảy ra được.

Tưởng đường dẫn tương đối là tương đối so với file source. Nó tương đối so với working directory của process, tức là chỗ bạn gõ java. Cùng chương trình chạy từ thư mục con đã ghi ra một chỗ khác trong lần chạy ở trên. In toAbsolutePath() mỗi khi file "biến mất".

Đọc file bằng FileReader.read() trong vòng lặp. 538.891 lời gọi để làm việc mà BufferedReader làm trong 67. Bọc nó lại.

Dựa vào charset mặc định. Nó là UTF-8 từ Java 18 trở đi, và là locale của platform trước đó — cùng một file source ghi ra chín byte trên JDK 21 và tám byte trên JDK 17 dưới LANG=C, hủy mất một ký tự. Truyền StandardCharsets.UTF_8 rõ ràng ở mọi lần đọc và mọi lần ghi.

Coi NoSuchFileExceptionFileNotFoundException là hai chuyện không liên quan. Cả hai đều kế thừa IOException nên một catch xử được cả hai. Nhưng NoSuchFileException.getMessage() chỉ trả về đường dẫn, nên log mỗi e.getMessage() là mất luôn phần lý do.

Dùng Files.readAllLines cho file lớn. Nó dựng đủ mọi dòng rồi mới trả về. Hãy dùng Files.lines và giữ nó trong try-with-resources, vì stream đó đang giữ file mở.

Catch IOException rồi in "lỗi file". Bản thân exception đã biết đường dẫn và nguyên nhân. In nó ra, thêm đường dẫn tuyệt đối, và để nó tự nói chuyện gì đã xảy ra.

Trông chờ \n cuối file còn lại thành một dòng. File có và không có newline cuối cho ra cùng một list ở trên. Nếu bạn cần biết file có kết thúc gọn ghẽ không thì phải đọc byte.

FAQ

Nên dùng FileReader hay Files.readString?

Files.readString cho file nhỏ mà bạn muốn lấy nguyên khối, và Files.newBufferedReader khi bạn muốn duyệt vòng lặp. Chỉ dùng new FileReader(...) trong code vốn đã dùng java.io. Các method của Files nhận charset ngay trong cùng lời gọi, báo file thiếu bằng NoSuchFileException thay vì một FileNotFoundException mà cái tên còn phủ cả lỗi phân quyền, và ném exception khi gặp byte không decode được thay vì lặng lẽ thay bằng U+FFFD.

Trên Java 21 còn cần truyền charset không?

Có. Từ Java 18 trở đi mặc định là UTF-8 ở mọi nơi, nên bỏ qua nó thì tình cờ vẫn chạy đúng — cho tới khi code được compile cho hoặc chạy trên Java 17, nơi mặc định chạy theo locale của platform. Cái giá đo được của chuyện đó là một ký tự bị âm thầm thay bằng ? ngay lúc ghi, không khôi phục được. Nó chỉ là thêm một tham số và nó dẹp được cả một họ bug.

Buffer to bao nhiêu, có nên đổi không?

BufferedReaderBufferedWriter mặc định 8192 ký tự, nên 538.890 ký tự cần 66 lần flush. Cả hai constructor đều nhận kích thước tùy ý, nhưng đổi nó gần như không bao giờ là nút thắt — cái thắng nằm giữa "không buffer" và "có buffer", chứ không phải giữa 8 KB và 64 KB. Cứ để yên trừ khi profiler bảo bạn khác.

flush và close khác nhau chỗ nào?

flush() đẩy những gì đang nằm trong buffer xuống lớp dưới và writer vẫn dùng tiếp được. close() flush rồi giải phóng file descriptor, sau đó mọi lời gọi tiếp theo đều ném java.io.IOException: Stream closed. Try-with-resources gọi close() giùm, nên hiếm khi bạn phải tự gọi cái nào; flush() có chỗ đứng khi một process chạy dài phải cho thấy kết quả từng phần trước lúc kết thúc.

Vì sao file của tôi lại nằm ở chỗ khác?

Vì đường dẫn tương đối được phân giải theo working directory của JVM, mà IDE và terminal của bạn thường không đồng ý với nhau về chỗ đó. In Path.of(name).toAbsolutePath() để biết nó thật sự đi đâu. Set property user.dir không dời được nó — default filesystem đã chốt giá trị từ lúc khởi động.

Làm sao đọc file do máy Windows ghi?

Cứ đọc bình thường. readLine()Files.readAllLines đều coi carriage return, newline, hay cả cặp là một terminator và cắt bỏ, nên file CRLF 13 byte ở trên trở về thành hai dòng sạch. Câu hỏi thật sự là encoding: file lưu từ một editor trên Windows có thể đang ở windows-1252 hoặc UTF-16 chứ không phải UTF-8, khi đó hãy truyền đúng charset đó, hoặc convert file một lần cho xong.

Kết luận

File text là byte; String là ký tự; charset là ánh xạ giữa hai bên, và nó không được lưu ở đâu trong file cả. FileWriter cắt trắng file trừ khi bạn truyền true để append, readLine() cắt bỏ mọi line terminator nó gặp và trả về null khi hết, và cả hai class đều không dùng được mấy nếu để trần: buffer biến 50.000 lời gọi ghi thành 66 và 538.891 lời gọi đọc thành 67, trên output giống nhau từng byte. Try-with-resources đóng mọi resource theo thứ tự khai báo ngược lại, trên cả đường bình thường lẫn đường exception, và gắn lỗi từ close() vào exception thật qua getSuppressed() thay vì làm mất một trong hai. Code mới thì dùng PathFilesreadString, writeString, readAllLines, lines, newBufferedReader — vốn báo file thiếu bằng NoSuchFileException, lỗi phân quyền bằng AccessDeniedException, và byte không decode được bằng MalformedInputException thay vì lặng lẽ thay bằng U+FFFD. Và hãy truyền charset rõ ràng mọi lúc: đúng đoạn code ghi ra chín byte chuẩn trên JDK 21 đã ghi tám byte và hủy mất một ký tự trên JDK 17 dưới locale không phải UTF-8.

Bài tiếp theo đem tất cả những thứ này vào một chương trình thật: một trình quản lý sinh viên chạy trên console, giữ dữ liệu trong bộ nhớ, in menu, thêm và tìm và xóa, rồi lưu mọi thứ xuống file để lần chạy sau dữ liệu vẫn còn nguyên.

Bài viết liên quan

[Java Basics] Mảng nhiều chiều trong Java

Mảng nhiều chiều trong Java thực chất là array của array chứ không phải hình chữ nhật - cách tạo và duyệt grid, jagged array, deepToString, deepEquals, shallow vs deep copy, mảng 3 chiều và ví dụ matrix, mọi output đều compile và chạy thật trên JDK 21.

[Java Basics] Từ khóa this, static và final trong Java

Ba từ khóa this, static và final trong Java trên JDK 21 — this trỏ vào đâu, vì sao static field dùng chung cho mọi object, thứ tự static initialiser, constant inlining soi bằng javap, và vì sao final không hề làm object immutable.

[Java Basics] Field, method và constructor trong Java

Field, instance method và constructor trong Java: giá trị default của field, field initialiser, overloading constructor và chaining bằng this(...), thứ tự khởi tạo chứng minh bằng lệnh in, cùng mọi error message thật của javac từ việc viết void trước constructor tới việc đặt this(...) không phải đầu tiên.

[Java Basics] Vòng lặp lồng nhau, break và continue trong Java

Vòng lặp lồng nhau trong Java và hai keyword cắt ngang vòng lặp: số lần thân vòng lặp trong chạy, break chỉ thoát vòng lặp gần nhất, continue bỏ qua update trong while, labeled break và continue, cùng bẫy switch nằm trong loop.