1. Khái niệm về mã hóa ký tự (encoding)
Hãy bắt đầu với câu hỏi quan trọng nhất: mã hóa (encoding) là gì?
Hãy tưởng tượng bạn đến một hội nghị quốc tế. Mỗi người nói một ngôn ngữ khác nhau nhưng ai cũng muốn hiểu nhau. Cần có một phiên dịch biết rằng từ "hello" trong tiếng Anh tương ứng với "privet" trong tiếng Nga và "hola" trong tiếng Tây Ban Nha. Trong thế giới máy tính, mã hóa chính là “phiên dịch viên” đó.
Mã hóa là cách biểu diễn ký tự dưới dạng byte
Máy tính rất “thẳng thắn”: nó chỉ hiểu 0 và 1, tức bit và byte. Còn con người lại muốn thấy chữ cái, chữ số, emoji và thậm chí — ôi trời! — cả chữ tượng hình tiếng Trung. Để máy tính “ghi” được ký tự, ta phải thỏa thuận mỗi ký tự tương ứng với dãy byte nào.
Mã hóa (encoding) là tập quy tắc để chuyển các ký tự (chữ, số, dấu câu, emoji, v.v.) thành byte để lưu trữ và truyền đi, và ngược lại: biến byte thành ký tự để hiển thị.
Ví dụ: ký tự ‘A’ dạng Cyrillic trong các bảng mã khác nhau
- Trong UTF-8, ký tự U+0410 (chữ ‘A’ Cyrillic) được mã hóa bởi hai byte: 0xD0 0x90.
- Trong Windows-1251, cùng ký tự đó là một byte: 0xC0.
- Còn chữ cái Latin 'A' trong hầu hết các bảng mã phổ biến là 0x41.
Nếu đọc tệp bằng sai mã hóa, các ký tự sẽ biến thành “mojibake” (ký tự lạ hoặc dấu hỏi).
2. Tại sao cần mã hóa
Vì sao không thể cứ lưu chữ như nó vốn có?
Vì máy tính chỉ hiểu số (0 và 1). Con số nào tương ứng với chữ nào — đó chính là cốt lõi của mã hóa.
Ví dụ: “Privet” trên đĩa
Khi bạn ghi vào tệp từ "Privet" (tiếng Nga, viết bằng chữ Cyrillic), đối với máy tính đó chỉ là một dãy byte. Cách diễn giải dãy byte này phụ thuộc vào mã hóa.
- Nếu tệp được ghi bằng UTF-8, thì các ký tự Cyrillic thường dùng hai byte, v.v.
- Nếu bằng Windows-1251, thì mỗi ký tự dùng một byte, nhưng giá trị byte khác nhau.
Mã hóa cần ở đâu?
- Khi ghi văn bản ra tệp: để sau này đọc lại byte cho đúng.
- Khi đọc văn bản từ tệp: để biến byte trở lại thành chữ.
- Khi gửi văn bản qua mạng (ví dụ, HTTP, e‑mail).
- Khi làm việc với cơ sở dữ liệu: cũng cần biết văn bản lưu bằng mã hóa nào.
Nếu không chỉ định mã hóa...
Giống như mở một văn bản bằng ngôn ngữ lạ và cố đọc nó. Cơ hội hiểu sẽ cao hơn nếu bạn biết đó là ngôn ngữ nào. Còn nếu không — tốt nhất là bạn chẳng hiểu gì, tệ nhất là toàn “bùa chú”.
3. Rắc rối khi không dùng đúng mã hóa
“Mojibake” và mất dữ liệu
Câu than phiền phổ biến của lập trình viên mới (và không chỉ mới): “Vì sao thay vì "Privet" tôi lại thấy "Привет" hoặc toàn dấu hỏi?”
Điều này xảy ra khi tệp được ghi bằng một mã hóa nhưng đọc bằng mã hóa khác. Ví dụ, tệp được tạo trên Windows cũ bằng Windows-1251, còn bạn mở nó trên Linux, nơi mặc định là UTF-8. Hoặc ngược lại.
Ví dụ
- Ghi tệp bằng Windows-1251: byte cho ký tự U+041F (chữ ‘P’ Cyrillic) là 0xCF.
- Mở bằng UTF-8: chương trình kỳ vọng chữ Cyrillic là hai byte, nhưng lại nhận một. Mọi thứ hỏng bét.
Mất dữ liệu
Nếu khi ghi mà ký tự không được mã hóa đích hỗ trợ (ví dụ, bạn cố lưu emoji trong ASCII), nó sẽ biến mất hoặc bị thay bằng dấu hỏi. Bất cứ thứ gì “không lọt” vào bảng mã sẽ bị mất.
Vấn đề khi trao đổi tệp
Các tệp ghi bằng một mã hóa có thể hiển thị sai trên máy khác nếu ở đó mã hóa mặc định khác. Thường gặp khi trao đổi tệp giữa Windows và Linux hoặc khi mở các tệp cũ.
4. Mã hóa trong Java: biểu diễn bên trong và bên ngoài
Bên trong JVM: luôn là Unicode (UTF-16)
Trong Java, chuỗi (String) bên trong chương trình luôn được lưu bằng Unicode (cụ thể là UTF-16). Điều đó có nghĩa bạn có thể yên tâm gán chuỗi ở bất kỳ ngôn ngữ nào trên thế giới, và Java đều “tiêu hóa” được.
String hello = "Xin chào, thế giới! 😀";
Trong bộ nhớ JVM, văn bản này được lưu như một tập các số 16 bit (char), trong đó mỗi ký tự có mã riêng trong bảng Unicode.
Sự thật thú vị
Trong Java, kiểu ký tự char có kích thước 16 bit (2 byte). Nhưng một số ký tự (ví dụ, chữ tượng hình hiếm hoặc emoji) cần đến hai char — gọi là “cặp thay thế” (surrogate pair).
Khi nhập/xuất: mã hóa rất quan trọng!
Khi bạn đọc hoặc ghi chuỗi ra thế giới bên ngoài (tệp, mạng), Java phải chuyển biểu diễn bên trong (UTF-16) thành dãy byte. Lúc này mã hóa là điều cần thiết.
- Nếu bạn không chỉ định rõ mã hóa, Java dùng mặc định của hệ thống (trên Windows tiếng Nga có thể là Windows-1251, trên Linux là UTF-8).
- Điều này nguy hiểm: trên máy khác kết quả có thể khác.
Ví dụ: đọc và ghi tệp mà không chỉ định mã hóa
// Thực hành tệ! Không chỉ định mã hóa.
FileReader reader = new FileReader("data.txt");
FileWriter writer = new FileWriter("data.txt");
Trong trường hợp này Java dùng mã hóa hệ thống. Nếu tệp được ghi trên hệ khác — bạn sẽ nhận “mojibake”.
Thực hành tốt: luôn chỉ định mã hóa
// Tốt! Mã hóa được chỉ định rõ ràng.
BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream("data.txt"), StandardCharsets.UTF_8));
5. Minh họa ngắn: điều gì xảy ra khi làm việc với mã hóa
Sơ đồ: hành trình của chuỗi từ tệp vào chương trình và ngược lại
[Tệp trên đĩa (byte, mã hóa X)]
|
V
[Java đọc byte và dùng mã hóa X để biến thành String (UTF-16)]
|
V
[Bạn làm việc với chuỗi trong chương trình]
|
V
[Java ghi String thành byte, sử dụng mã hóa Y]
|
V
[Tệp trên đĩa (byte, mã hóa Y)]
Nếu X và Y trùng nhau — mọi thứ ổn. Nếu khác — có thể phát sinh vấn đề.
6. Lược sử mã hóa (cho người ham tìm hiểu)
ASCII
ASCII là một trong những bảng mã lâu đời: một byte cho một ký tự, chỉ có bảng chữ cái tiếng Anh, chữ số và các dấu cơ bản. Những bảng chữ cái khác — chịu.
Windows-1251, ISO-8859-1 và các “lão làng” khác
Đó là các bảng mã một byte dành cho các bộ chữ khác nhau: Cyrillic, Latin, Hy Lạp, v.v. Mỗi nơi dùng một loại và sự hỗn loạn bắt đầu.
Unicode và họ UTF
- Unicode — bảng mã toàn cầu cho ký tự trên thế giới.
- UTF-8, UTF-16, UTF-32 — các cách khác nhau để biểu diễn ký tự Unicode thành byte.
- UTF-8 đã trở thành tiêu chuẩn cho Web, tệp và trao đổi liên hệ thống.
7. Thực hành: mã hóa ảnh hưởng thế nào đến làm việc với tệp
Hãy xem một ví dụ nhỏ về ghi và đọc chuỗi với các mã hóa khác nhau.
Ví dụ: ghi và đọc bằng các mã hóa khác nhau
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public class EncodingDemo {
public static void main(String[] args) throws IOException {
String text = "Xin chào, thế giới! 😀";
// Ghi tệp bằng UTF-8
try (Writer writer = new OutputStreamWriter(
new FileOutputStream("utf8.txt"), StandardCharsets.UTF_8)) {
writer.write(text);
}
// Bây giờ thử đọc nó với mã hóa sai
try (Reader reader = new InputStreamReader(
new FileInputStream("utf8.txt"), Charset.forName("Windows-1251"))) {
int c;
while ((c = reader.read()) != -1) {
System.out.print((char) c);
}
}
// Màn hình sẽ là “mojibake”!
}
}
Kết luận: Nếu mã hóa không trùng khớp — văn bản sẽ bị méo.
8. Mã hóa và tích hợp với các hệ thống khác
Trong dự án thực tế, tệp thường được trao đổi giữa nhiều chương trình viết bằng các ngôn ngữ khác nhau và chạy trên các hệ điều hành khác nhau. Mỗi bên có thể kỳ vọng một mã hóa riêng. Nếu không thỏa thuận trước — bạn sẽ nhận “mojibake” và những bug khó lần ra. Trường hợp điển hình: cơ sở dữ liệu lưu văn bản bằng UTF-8, còn chương trình lại đọc tệp nguồn như Windows-1251 và nạp vào DB — ký tự bị méo là điều chắc chắn.
9. Lỗi thường gặp khi làm việc với mã hóa
Lỗi số 1: Không chỉ định mã hóa khi đọc/ghi tệp.
Kết quả là chương trình “chạy được trên máy tôi”, còn trên máy đồng nghiệp — toàn “mojibake”.
Lỗi số 2: Dùng các constructor lỗi thời (FileReader, FileWriter).
Chúng luôn dùng mã hóa hệ thống — chiếc bẫy điển hình cho người mới.
Lỗi số 3: Sai mã hóa của tệp nguồn.
Nếu tệp được ghi bằng một mã hóa, còn đọc bằng mã hóa khác, một phần ký tự sẽ bị méo hoặc bị thay bằng dấu hỏi.
Lỗi số 4: Mất ký tự khi chuyển giữa các mã hóa.
Nếu mã hóa đích không hỗ trợ mọi ký tự (ví dụ, ASCII thay vì UTF-8), một phần văn bản sẽ biến mất.
GO TO FULL VERSION