Ai làm việc với tiếng Việt đều từng thấy dòng này:

  ghi UTF-8, đọc ISO-8859-1 : Tiếng Viá»t có dấu

Dữ liệu không hỏng. Nó chỉ đang bị đọc bằng bảng mã khác với lúc ghi.

Bài này về chuyện đó, cộng ba chỗ Java hành xử khác nhau mà nhiều người không để ý.

Java 18 đã đổi mặc định

  Charset.defaultCharset() : UTF-8
  file.encoding            : UTF-8
  native.encoding          : UTF-8

Trước Java 18, Charset.defaultCharset() lấy theo cấu hình vùng của hệ điều hành. Trên Linux tiếng Anh thì UTF-8, trên Windows tiếng Việt thì windows-1258, trên máy chủ Nhật thì Shift_JIS.

Kết quả là loại lỗi kinh điển: mã chạy đúng trên máy lập trình viên, ra ký tự lạ trên máy chủ, và không ai đổi dòng nào.

Từ Java 18, mặc định là UTF-8 trên mọi nền tảng. Thuộc tính native.encoding giữ lại bảng mã của hệ điều hành cho ai thật sự cần.

Nhưng đừng dựa vào mặc định. Luôn khai bảng mã tường minh:

Files.readString(p, StandardCharsets.UTF_8);
Files.newBufferedWriter(p, StandardCharsets.UTF_8);
chuoi.getBytes(StandardCharsets.UTF_8);

Vì mã của bạn có thể phải chạy trên Java 11 ở một dự án khác, và vì đọc mã thấy UTF_8 rõ hơn là phải đoán.

Số byte khác nhau

  UTF-8        24 byte  54 69 e1 ba bf 6e 67 20 ...
  UTF-16       36 byte  fe ff 00 54 00 69 1e bf ...
  ISO-8859-1   17 byte  54 69 3f 6e 67 20 56 69 ...

Cùng chuỗi 17 ký tự "Tiếng Việt có dấu".

UTF-8 dùng 1 byte cho ký tự ASCII, 3 byte cho chữ có dấu tiếng Việt — nên 24 byte.

UTF-16 dùng 2 byte cho mọi ký tự trong mặt phẳng cơ bản, cộng 2 byte BOM ở đầu — nên 36.

ISO-8859-1 không biểu diễn được chữ có dấu, nên nó thay bằng 3f — mã của dấu hỏi. Dữ liệu mất vĩnh viễn: 17 byte, và không cách nào khôi phục.

Đây là điểm quan trọng nhất về bảng mã: ghi sai thì mất dữ liệu, đọc sai thì chỉ hiển thị xấu. Chuỗi Tiếng ở đầu bài vẫn khôi phục được vì byte gốc còn nguyên; còn dấu hỏi thì không.

Files.readString ném lỗi, new String thì không

Đây là chỗ tôi phát hiện khi viết bài, và nó đáng để ý:

  ghi UTF-8, đọc US-ASCII     : MalformedInputException   <- Files.readString NÉM LỖI
  cùng byte, new String(ASCII): 'Ti���ng Vi���t c�� d���u' <- thay ký tự, KHÔNG lỗi

Cùng dữ liệu, cùng bảng mã sai, hai hành vi khác hẳn:

Files.readStringFiles.lines dùng chế độ REPORT — gặp byte không hợp lệ là ném MalformedInputException.

new String(bytes, charset) dùng chế độ REPLACE — thay byte hỏng bằng ký tự thay thế U+FFFD và chạy tiếp.

Cái nào tốt hơn tuỳ tình huống, nhưng bạn phải biết mình đang dùng cái nào. Chế độ im lặng nguy hiểm ở chỗ dữ liệu hỏng lọt vào CSDL rồi bạn mới phát hiện.

Muốn kiểm soát tường minh thì cấu hình decoder:

CharsetDecoder d = StandardCharsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)      // hoặc REPLACE, IGNORE
        .onUnmappableCharacter(CodingErrorAction.REPORT);
  new String(...) : '('   (thay bằng ký tự thay thế)
  decoder REPORT  : MalformedInputException

Với dữ liệu quan trọng, tôi luôn chọn REPORT — thà hỏng sớm còn hơn lưu dữ liệu rác.

NFC và NFD: cùng chữ, khác chuỗi

  NFC length = 1  bytes = 3
  NFD length = 3  bytes = 5
  nfc.equals(nfd) : false   <- trông giống hệt nhau

Chữ "ế" có hai cách biểu diễn trong Unicode:

NFC — một điểm mã duy nhất U+1EBF. Đây là dạng thường gặp.

NFD — ba điểm mã: chữ e, dấu mũ, dấu sắc. Ghép lại hiển thị y hệt.

Chúng in ra giống nhau, nhưng equals trả false, length() khác nhau, và làm khoá HashMap thì không tìm thấy nhau.

Đây là nguồn của một lớp lỗi rất khó chịu với tiếng Việt: macOS mặc định dùng NFD cho tên tệp, còn Linux và Windows dùng NFC. Nên một tệp tên "Tiếng Việt.pdf" tải từ máy Mac có thể "không tồn tại" trên máy chủ Linux dù bạn nhìn thấy nó trong danh sách.

Cách chữa là chuẩn hoá trước khi so sánh hay lưu:

String chuan = Normalizer.normalize(vao, Normalizer.Form.NFC);
  sau khi chuẩn hoá NFC : true

Tôi khuyên: chuẩn hoá NFC ở mọi cửa vào — dữ liệu người dùng nhập, tên tệp tải lên, chuỗi đọc từ API ngoài. Một dòng, và nó chặn hẳn một họ lỗi mà rất khó tái hiện.

BOM: ba byte vô hình

  đọc file có BOM: độ dài 4 (mong đợi 3)
  ký tự đầu      : U+FEFF

Một số công cụ Windows — Notepad, Excel khi xuất CSV — thêm ba byte EF BB BF vào đầu tệp UTF-8. Đó là dấu thứ tự byte, vốn chỉ có ý nghĩa với UTF-16.

Java không tự bỏ nó. Nên chuỗi bạn đọc được có thêm một ký tự vô hình ở đầu, và:

chuoi.startsWith("id") trả false dù nhìn thấy chữ id.

Integer.parseInt ném NumberFormatException với thông báo khó hiểu.

Tên cột đầu tiên trong CSV không khớp với gì cả.

Cách xử lý gọn:

if (!chuoi.isEmpty() && chuoi.charAt(0) == '') {
    chuoi = chuoi.substring(1);
}

Hoặc dùng BOMInputStream của Apache Commons IO nếu đã có sẵn thư viện đó.

Danh sách kiểm tra

Khi gặp ký tự lạ, tôi rà theo thứ tự này:

Ghi bằng bảng mã nào? Nếu ghi sai thì dữ liệu đã mất, sửa chỗ đọc không cứu được.

Đọc bằng bảng mã nào? Ba dòng readString, newBufferedReader, new String đều nhận tham số charset — kiểm cả ba.

Có BOM không? Xem ba byte đầu bằng xxd | head -1.

Đã chuẩn hoá NFC chưa? Nhất là khi dữ liệu đi qua máy Mac.

CSDL và kết nối dùng bảng mã gì? PostgreSQL và MySQL đều có cấu hình riêng, và chuỗi kết nối JDBC cũng có tham số charset. Đây là chỗ thứ năm mà tôi hay quên nhất.

Vài mẹo

String.chars() trả về đơn vị mã UTF-16, không phải ký tự. Với emoji hay chữ ngoài mặt phẳng cơ bản thì dùng codePoints().

String.getBytes() không tham số dùng bảng mã mặc định — luôn khai tường minh.

Trong Docker, đặt -Dfile.encoding=UTF-8 là thừa từ Java 18, nhưng đặt LANG=C.UTF-8 vẫn có ích cho các tiến trình con và cho native.encoding.

Khi ghi CSV cho người dùng Excel mở, đôi khi phải thêm BOM — nếu không Excel đoán sai bảng mã. Đây là một trong số ít trường hợp BOM có ích.

Thử ba mươi giây

Ghi một chuỗi tiếng Việt ra tệp bằng ISO_8859_1, rồi đọc lại bằng chính ISO_8859_1.

Bạn sẽ thấy toàn dấu hỏi — dữ liệu đã mất ngay lúc ghi, và đọc đúng bảng mã cũng không cứu được. Đó là khác biệt giữa "hiển thị xấu" và "mất dữ liệu".

Ngày mai: serialization của Java — vì sao nó là một trong những tính năng bị hối tiếc nhất, và ba lựa chọn thay thế.