Chương 11 · Tệp tin & Serialization

Xử lý tệp tin và Serialization

Java có hai API xử lý tệp tin: java.io (từ 1996) và java.nio.file (từ Java 7). Cả hai vẫn tồn tại, nên tài liệu trên mạng lẫn lộn cả hai và người mới rất dễ học nhầm cái cũ. Chương này nói rõ nên dùng cái nào, khi nào. Nửa sau là serialization — cơ chế biến object thành luồng byte, mạnh mẽ nhưng cũng là nguồn của một trong những lỗ hổng bảo mật nghiêm trọng nhất lịch sử Java.

Độ dài: ~1600 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-10
🎯 Mục tiêu chương
  • Phân biệt java.io.File (cũ) và java.nio.file.Path (nên dùng).
  • Làm chủ Files — lớp tiện ích thay thế gần hết java.io.
  • Hiểu cây phân cấp stream: byte vs ký tự, và vai trò của lớp bọc.
  • Biết vì sao Buffered quan trọng — và đo được chênh lệch.
  • Luôn dùng try-with-resources cho mọi tài nguyên I/O.
  • Đọc/ghi văn bản đúng encoding — tránh lỗi tiếng Việt.
  • Đọc/ghi nhị phân và xử lý tệp lớn hơn RAM.
  • Nắm Serialization: Serializable, transient, serialVersionUID.
  • Hiểu rủi ro bảo mật của deserialization và vì sao nên tránh.
  • Dùng JSON làm định dạng lưu trữ hiện đại thay serialization.

1. File (cũ) vs Path (nên dùng)

Việc cần làmjava.io.File (1996)java.nio.file (Java 7) ⭐
Tạo đối tượng đường dẫnnew File("a/b.txt")Path.of("a", "b.txt")
Kiểm tra tồn tạif.exists()Files.exists(p)
Xoáf.delete() → booleanFiles.delete(p) → ném IOException ⭐
Tạo thư mụcf.mkdirs()Files.createDirectories(p)
Đọc cả tệpCần BufferedReader + vòng lặpFiles.readString(p)
Sao chép / di chuyển❌ Không cóFiles.copy / Files.move
Liên kết tượng trưng❌ Không hỗ trợ✅ Đầy đủ
Thuộc tính tệpRất hạn chếFiles.readAttributes — quyền, chủ sở hữu, thời gian
Báo lỗiTrả false — không biết vì saoNém IOException có lý do cụ thể
Duyệt cây thư mụcĐệ quy thủ côngFiles.walk trả Stream
Dùng java.nio.file. java.io.File chỉ giữ lại vì tương thích ngược. Điểm khác biệt quan trọng nhất: khi thao tác thất bại, File trả về false mà không nói lý do — bạn không biết là tệp không tồn tại, thiếu quyền, hay đang bị khoá. Files ném exception cụ thể.
PathCoBan.java
import java.nio.file.*;

// --- Tạo Path ---
Path p1 = Path.of("du_lieu/bao_cao.csv");
Path p2 = Path.of("du_lieu", "2026", "bao_cao.csv");   // ⭐ đa nền tảng
Path p3 = Paths.get("a/b");                             // cách cũ, tương đương

// --- Thông tin đường dẫn (thuần tính toán, KHÔNG chạm đĩa) ---
p2.getFileName();       // bao_cao.csv
p2.getParent();         // du_lieu/2026
p2.getRoot();           // null (tương đối) hoặc / (tuyệt đối)
p2.getNameCount();      // 3
p2.getName(0);         // du_lieu
p2.toAbsolutePath();    // đường dẫn tuyệt đối
p2.normalize();         // bỏ . và ..
p2.resolve("con.txt");  // nối thêm
p1.relativize(p2);      // đường dẫn từ p1 tới p2

// ⭐ Mẫu vàng: định vị tệp cạnh mã nguồn / thư mục người dùng
Path home = Path.of(System.getProperty("user.home"));
Path hienTai = Path.of("").toAbsolutePath();   // thư mục làm việc
Path cauHinh = home.resolve(".myapp").resolve("config.json");

// --- Thao tác trên đĩa (đều ném IOException) ---
Files.exists(p1);
Files.isRegularFile(p1);
Files.isDirectory(p1);
Files.size(p1);                       // byte
Files.createDirectories(p1.getParent());   // tạo cả cây, không lỗi nếu đã có
Files.createFile(p1);
Files.delete(p1);                     // ném NoSuchFileException nếu không có
Files.deleteIfExists(p1);             // trả boolean, không ném
Files.copy(nguon, dich, StandardCopyOption.REPLACE_EXISTING);
Files.move(nguon, dich, StandardCopyOption.ATOMIC_MOVE);

2. Files — đọc ghi trong một dòng

FilesTienIch.java
import java.nio.charset.StandardCharsets;

Path p = Path.of("data.txt");

// --- ĐỌC (chỉ dùng cho tệp NHỎ — nạp hết vào RAM) ---
String noiDung = Files.readString(p);                       // Java 11+, UTF-8 mặc định
String nd2 = Files.readString(p, StandardCharsets.UTF_8);   // tường minh
List<String> dong = Files.readAllLines(p);
byte[] byteS = Files.readAllBytes(p);

// --- GHI ---
Files.writeString(p, "Xin chào");                          // Java 11+, GHI ĐÈ
Files.writeString(p, "thêm\n", StandardOpenOption.APPEND);   // nối vào cuối
Files.write(p, List.of("dòng 1", "dòng 2"));               // ghi nhiều dòng
Files.write(p, byteS);

// --- ⭐ ĐỌC LƯỜI: tệp LỚN hơn RAM ---
try (Stream<String> s = Files.lines(p)) {   // ⚠️ PHẢI đóng!
    long soLoi = s.filter(l -> l.contains("ERROR")).count();
}
// Chạy được với tệp 100 GB — mỗi lúc chỉ giữ một dòng trong bộ nhớ

// --- Duyệt thư mục ---
try (Stream<Path> s = Files.list(thuMuc)) {          // một cấp
    s.forEach(System.out::println);
}
try (Stream<Path> s = Files.walk(thuMuc)) {          // ĐỆ QUY
    s.filter(Files::isRegularFile)
     .filter(f -> f.toString().endsWith(".java"))
     .forEach(System.out::println);
}
try (Stream<Path> s = Files.find(thuMuc, 5,
        (path, attr) -> attr.size() > 1_000_000)) {          // tệp lớn hơn 1 MB
    s.forEach(System.out::println);
}

// --- Thư mục tạm ---
Path tam = Files.createTempFile("tien_to", ".tmp");
Path tmDir = Files.createTempDirectory("app");
⚠️ Files.lines, walk, list phải đóng

Chúng trả về Stream gắn với một tài nguyên hệ thống đang mở (file handle). Không đóng thì rò rỉ, và sau vài nghìn lần sẽ gặp Too many open files. Đây là ngoại lệ hiếm hoi mà Stream cần try-with-resources — hầu hết stream khác thì không.

DongStream.java
// ❌ Rò rỉ file handle
Files.lines(p).filter(...).forEach(...);

// ✅ try-with-resources
try (Stream<String> s = Files.lines(p)) {
    s.filter(...).forEach(...);
}

3. Cây phân cấp stream I/O

┌── LUỒNG BYTE (nhị phân) ────────────────────────────┐ │ │ │ InputStream OutputStream │ │ ├─ FileInputStream ├─ FileOutputStream │ │ ├─ ByteArrayInputStream ├─ ByteArrayOutput… │ │ ├─ BufferedInputStream ⭐ ├─ BufferedOutput… ⭐ │ │ ├─ DataInputStream ├─ DataOutputStream │ │ └─ ObjectInputStream └─ ObjectOutputStream │ └──────────────────────────────────────────────────────┘ ↕ cầu nối InputStreamReader / OutputStreamWriter ┌── LUỒNG KÝ TỰ (văn bản) ────────────────────────────┐ │ │ │ Reader Writer │ │ ├─ FileReader ├─ FileWriter │ │ ├─ StringReader ├─ StringWriter │ │ ├─ BufferedReader ⭐ ├─ BufferedWriter ⭐ │ │ └─ InputStreamReader └─ PrintWriter │ └──────────────────────────────────────────────────────┘
Luồng byteLuồng ký tự
Đơn vịbyte (8 bit)char (16 bit, Unicode)
Lớp gốcInputStream/OutputStreamReader/Writer
Xử lý encoding❌ Không — dữ liệu thô✅ Có
Dùng choẢnh, video, tệp nén, dữ liệu tuần tự hoáVăn bản: txt, csv, json, xml
Đọc tệp .txt bằng luồng byte⚠️ Được, nhưng phải tự giải mã✅ Đúng cách

Mẫu Decorator — vì sao phải bọc nhiều lớp

Java I/O được thiết kế theo mẫu Decorator: mỗi lớp bọc thêm một khả năng vào lớp bên trong. Điều này giải thích cú pháp lồng nhau trông rườm rà nhưng rất linh hoạt.

Decorator.java
new BufferedReader(                 // ③ thêm bộ đệm + readLine()
    new InputStreamReader(          // ② byte → ký tự (chỉ định encoding)
        new FileInputStream("a.txt"),  // ① nguồn byte thô
        StandardCharsets.UTF_8))

// Nhờ decorator, ba lớp này ghép được với BẤT KỲ nguồn nào:
new BufferedReader(new InputStreamReader(socket.getInputStream()));
new BufferedReader(new InputStreamReader(System.in));
new BufferedReader(new StringReader(chuoi));

// ✅ Nhưng với TỆP, NIO.2 gọn hơn nhiều:
Files.newBufferedReader(Path.of("a.txt"));   // UTF-8 mặc định
⚠️ Tránh FileReader và FileWriter
FileReaderBay.java
// ❌ Dùng bảng mã MẶC ĐỊNH CỦA HỆ THỐNG — khác nhau giữa các máy!
new FileReader("a.txt");
// Trên máy bạn (macOS, UTF-8): chạy tốt
// Trên máy đồng nghiệp (Windows, cp1258): tiếng Việt thành ký tự rác

// ✅ Luôn chỉ định encoding tường minh
new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8);
new FileReader("a.txt", StandardCharsets.UTF_8);   // Java 11+ mới có

// ✅✅ Hoặc dùng NIO.2 — mặc định đã là UTF-8
Files.readString(Path.of("a.txt"));

Từ Java 18 (JEP 400), UTF-8 trở thành mặc định cho toàn bộ API tệp — nhưng code chạy trên Java 8/11/17 vẫn phải khai báo tường minh. Đây là nguyên nhân số một của lỗi tiếng Việt trong ứng dụng Java.

4. Buffered — vì sao quan trọng

Buffered.java
// ❌ KHÔNG buffer: mỗi read() là MỘT lời gọi hệ thống
try (FileInputStream in = new FileInputStream("lon.txt")) {
    int c;
    while ((c = in.read()) != -1) { ... }
}
// Tệp 10 MB → 10 TRIỆU lời gọi hệ thống → mất hàng chục giây

// ✅ CÓ buffer: đọc 8 KB mỗi lần vào bộ nhớ, phục vụ read() từ đó
try (BufferedInputStream in =
        new BufferedInputStream(new FileInputStream("lon.txt"))) {
    int c;
    while ((c = in.read()) != -1) { ... }
}
// Tệp 10 MB → khoảng 1.250 lời gọi hệ thống → dưới một giây
// Nhanh hơn khoảng 50-100 lần chỉ nhờ một lớp bọc

// BufferedReader còn thêm readLine() — thứ Reader trần không có
try (BufferedReader br = Files.newBufferedReader(Path.of("a.txt"))) {
    String dong;
    while ((dong = br.readLine()) != null) {   // null = hết tệp
        System.out.println(dong);
    }
}

// ⚠️ Ghi có buffer: dữ liệu nằm trong bộ nhớ tới khi flush hoặc close
try (BufferedWriter bw = Files.newBufferedWriter(Path.of("ra.txt"))) {
    bw.write("Xin chào");
    bw.newLine();             // ký tự xuống dòng của hệ điều hành
    bw.flush();               // ép ghi xuống đĩa ngay
}   // try-with-resources tự flush + close ✅

// 💥 Quên đóng → dữ liệu KHÔNG BAO GIỜ xuống đĩa, tệp rỗng!

Vì sao buffer nhanh hơn tới trăm lần

Mỗi lần đọc từ đĩa là một lời gọi hệ thống (system call): CPU chuyển từ chế độ người dùng sang chế độ nhân, hệ điều hành xử lý, rồi chuyển ngược lại. Chi phí chuyển ngữ cảnh này lớn hơn việc đọc một byte hàng nghìn lần.

Buffer đọc một khối lớn (mặc định 8 KB) vào mảng trong bộ nhớ, rồi phục vụ các lời gọi read() tiếp theo từ mảng đó — không cần chạm hệ điều hành. Đây là ví dụ kinh điển của việc đánh đổi bộ nhớ lấy tốc độ.

Lưu ý: Files.readString(), Files.lines(), Files.newBufferedReader() đều đã có buffer sẵn — bạn không cần bọc thêm.

5. Đọc ghi văn bản — bảng chọn cách

Tình huốngCách dùngBộ nhớ
Tệp nhỏ (< vài MB), cần cả nội dungFiles.readString(p)Bằng cỡ tệp
Tệp nhỏ, xử lý theo dòngFiles.readAllLines(p)Bằng cỡ tệp + overhead
Tệp lớn, xử lý theo dòngFiles.lines(p) trong try-with-resources ⭐Một dòng
Tệp lớn, cần kiểm soát chi tiếtFiles.newBufferedReader + readLine()Một dòng
Ghi cả nội dungFiles.writeString(p, s)—
Ghi dần từng dòngFiles.newBufferedWriter—
Nối vào cuốiStandardOpenOption.APPEND—
XuLyVanBan.java
// --- Đọc CSV thành object (chưa dùng thư viện) ---
record SinhVien(String ma, String ten, double diem) { }

static List<SinhVien> docCsv(Path p) throws IOException {
    try (Stream<String> dong = Files.lines(p)) {
        return dong
            .skip(1)                              // bỏ dòng tiêu đề
            .filter(l -> !l.isBlank())
            .map(l -> l.split(",", -1))            // -1: giữ ô rỗng cuối
            .filter(c -> c.length >= 3)
            .map(c -> new SinhVien(c[0].trim(), c[1].trim(),
                                    Double.parseDouble(c[2].trim())))
            .toList();
    }
}

// --- Ghi CSV ---
static void ghiCsv(Path p, List<SinhVien> ds) throws IOException {
    try (BufferedWriter w = Files.newBufferedWriter(p)) {
        w.write("ma,ten,diem");
        w.newLine();
        for (SinhVien sv : ds) {
            w.write("%s,%s,%.2f".formatted(sv.ma(), sv.ten(), sv.diem()));
            w.newLine();
        }
    }
}

// --- ⭐ GHI AN TOÀN: tệp tạm rồi thay thế nguyên tử ---
static void ghiAnToan(Path dich, String noiDung) throws IOException {
    Path tam = dich.resolveSibling(dich.getFileName() + ".tmp");
    Files.writeString(tam, noiDung);
    Files.move(tam, dich,
        StandardCopyOption.REPLACE_EXISTING,
        StandardCopyOption.ATOMIC_MOVE);
}
// Chương trình sập giữa chừng → tệp gốc VẪN NGUYÊN VẸN,
// chỉ còn lại một tệp .tmp dở dang vô hại

6. Tệp nhị phân

NhiPhan.java
// --- Sao chép tệp lớn theo khối ---
static void saoChep(Path nguon, Path dich) throws IOException {
    try (InputStream  in  = new BufferedInputStream(Files.newInputStream(nguon));
         OutputStream out = new BufferedOutputStream(Files.newOutputStream(dich))) {
        byte[] dem = new byte[8192];
        int n;
        while ((n = in.read(dem)) != -1) {
            out.write(dem, 0, n);     // ⚠️ PHẢI ghi đúng n byte, không phải cả mảng
        }
    }
}

// ✅ Hoặc để JDK lo (nhanh hơn, dùng tối ưu của hệ điều hành)
Files.copy(nguon, dich, StandardCopyOption.REPLACE_EXISTING);
in.transferTo(out);                      // Java 9+

// --- Đọc/ghi kiểu dữ liệu có cấu trúc ---
try (DataOutputStream out = new DataOutputStream(
        new BufferedOutputStream(Files.newOutputStream(p)))) {
    out.writeInt(42);
    out.writeDouble(3.14);
    out.writeUTF("Xin chào");       // độ dài + dữ liệu UTF-8
    out.writeBoolean(true);
}

try (DataInputStream in = new DataInputStream(
        new BufferedInputStream(Files.newInputStream(p)))) {
    int a     = in.readInt();      // ⚠️ PHẢI đọc ĐÚNG THỨ TỰ đã ghi
    double b  = in.readDouble();
    String c  = in.readUTF();
    boolean d = in.readBoolean();
}
// Sai thứ tự → dữ liệu rác, không có lỗi nào báo ra ⚠️

// --- Truy cập ngẫu nhiên: nhảy tới vị trí bất kỳ ---
try (RandomAccessFile raf = new RandomAccessFile("data.bin", "rw")) {
    raf.seek(100);              // nhảy tới byte thứ 100
    int x = raf.readInt();
    raf.seek(200);
    raf.writeInt(42);
    System.out.println(raf.length());
}

7. Serialization

Serialization là quá trình chuyển trạng thái của object thành một luồng byte, để lưu vào tệp hoặc gửi qua mạng. Deserialization là quá trình ngược lại.

Object
SinhVien
(ma, ten, diem)
Serialization
ObjectOutputStream
↓ luồng byte
Tệp / mạng
sinhvien.ser
AC ED 00 05 …
Chiều ngược lại dùng ObjectInputStream.readObject() — trả về Object, phải ép kiểu về class ban đầu.
Serialization.java
import java.io.*;

// ① Class PHẢI cài đặt Serializable — interface đánh dấu, không có method nào
public class SinhVien implements Serializable {

    // ② serialVersionUID — BẮT BUỘC khai báo, xem callout bên dưới
    @Serial
    private static final long serialVersionUID = 1L;

    private String ma;
    private String ten;
    private double diem;

    // ③ transient — KHÔNG serialize trường này
    private transient String matKhau;      // dữ liệu nhạy cảm
    private transient Connection ketNoi;    // không serialize được

    // ④ static KHÔNG BAO GIỜ được serialize (thuộc class, không thuộc instance)
    private static int soLuong;

    public SinhVien(String ma, String ten, double diem) { ... }
}


// --- GHI (serialization) ---
SinhVien sv = new SinhVien("SV001", "Việt Anh", 8.5);
try (ObjectOutputStream out = new ObjectOutputStream(
        new BufferedOutputStream(Files.newOutputStream(Path.of("sv.ser"))))) {
    out.writeObject(sv);
}

// --- ĐỌC (deserialization) ---
try (ObjectInputStream in = new ObjectInputStream(
        new BufferedInputStream(Files.newInputStream(Path.of("sv.ser"))))) {
    SinhVien doc = (SinhVien) in.readObject();   // ⚠️ phải ép kiểu
    System.out.println(doc);
} catch (ClassNotFoundException e) {
    // class không có trên classpath khi đọc
}
// ⚠️ Constructor KHÔNG được gọi khi deserialize!
//    JVM tạo object trực tiếp và điền từng trường.
//    matKhau và ketNoi sẽ là null; soLuong giữ giá trị hiện tại của class.
⚠️ serialVersionUID — luôn khai báo tường minh
VersionUid.java
// Không khai báo → JVM tự tính từ TÊN CLASS, TRƯỜNG, METHOD, INTERFACE...
// Thêm một trường mới → UID đổi → mọi tệp .ser cũ KHÔNG ĐỌC ĐƯỢC NỮA:

// java.io.InvalidClassException: SinhVien; local class incompatible:
//   stream classdesc serialVersionUID = 8765432109876543210,
//   local class serialVersionUID = 1234567890123456789

// ✅ Khai báo tường minh → kiểm soát được khi nào phá vỡ tương thích
@Serial
private static final long serialVersionUID = 1L;

// Giữ nguyên số này khi:  thêm/xoá trường, thêm method
// Tăng số này khi:        đổi kiểu trường, đổi ý nghĩa dữ liệu

@Serial (Java 14+) khiến trình biên dịch kiểm tra rằng bạn khai báo đúng chữ ký — tương tự vai trò của @Override. Nếu gõ sai tên hoặc sai kiểu, bạn nhận lỗi ngay thay vì phát hiện khi dữ liệu không đọc được.

Điều gì được serializeCóGhi chú
Trường instance thường✅Kể cả private
Trường transient❌Đọc lại nhận giá trị mặc định (null/0)
Trường static❌Thuộc class, không thuộc instance
Object tham chiếu tới✅ Đệ quyNhưng nó cũng phải Serializable
Tham chiếu vòng✅Java tự phát hiện, không lặp vô hạn
Constructor❌ Không được gọiMọi kiểm tra hợp lệ trong constructor bị bỏ qua!
Lớp cha không Serializable⚠️Trường lớp cha không lưu; constructor rỗng của lớp cha được gọi

8. Rủi ro bảo mật của deserialization

⚠️ Đây không phải rủi ro lý thuyết

Deserialization dữ liệu không tin cậy là một trong những lỗ hổng nghiêm trọng nhất lịch sử Java. Nó cho phép thực thi mã tuỳ ý — kẻ tấn công chỉ cần gửi được một mảng byte vào ứng dụng của bạn.

RuiRo.java
// ❌ CỰC KỲ NGUY HIỂM
ObjectInputStream in = new ObjectInputStream(socket.getInputStream());
Object o = in.readObject();     // 💥 mã của kẻ tấn công có thể chạy NGAY TẠI ĐÂY

// Vì sao? readObject() phải TẠO object trước khi bạn kịp kiểm tra kiểu.
// Nếu classpath có một class nào đó thực thi code trong readObject(),
// readResolve(), hoặc thậm chí trong finalize() — kẻ tấn công lợi dụng được.
// Chuỗi các class như vậy gọi là "gadget chain", và chúng tồn tại trong
// nhiều thư viện phổ biến (Commons Collections, Spring, Groovy...).


// ⚠️ Giảm thiểu (Java 9+): lọc theo danh sách trắng
ObjectInputFilter loc = ObjectInputFilter.Config.createFilter(
    "com.vidu.model.*;java.util.*;!*"   // chỉ cho các gói này, cấm phần còn lại
);
in.setObjectInputFilter(loc);

// ✅✅ GIẢI PHÁP THẬT: đừng dùng Java serialization cho dữ liệu bên ngoài.
//     Dùng JSON, Protocol Buffers, hoặc định dạng riêng.
Không bao giờ deserialize dữ liệu bạn không kiểm soát. Chính đội ngũ Java gọi serialization là "sai lầm khủng khiếp" (a horrible mistake) trong bài trình bày về Project Amber. Java 24 đã bắt đầu lộ trình loại bỏ dần cơ chế này.
Định dạngƯu điểmNhược điểmDùng khi
Java serializationKhông cần cấu hình gì⚠️ Bảo mật, chỉ Java đọc được, dễ vỡ khi đổi classChỉ trong nội bộ JVM đáng tin
JSON ⭐Người đọc được, mọi ngôn ngữ hỗ trợ, an toànDài, chậm hơn nhị phânMặc định — API, cấu hình, lưu trữ
Protocol BuffersNhỏ, nhanh, có lược đồ, tương thích tốtCần định nghĩa .protoGiao tiếp giữa các dịch vụ, hiệu năng cao
Định dạng tự viếtKiểm soát hoàn toànTốn công viết và bảo trìYêu cầu rất đặc thù

9. JSON — cách lưu trữ hiện đại

pom.xml — thêm Jackson
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.18.2</version>
</dependency>
<!-- Hỗ trợ java.time (LocalDate...) -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.18.2</version>
</dependency>
Json.java
import com.fasterxml.jackson.databind.*;

record SinhVien(String ma, String ten, double diem, LocalDate ngaySinh) { }
// ⭐ record hoạt động thẳng với Jackson — không cần getter/setter hay annotation

ObjectMapper mapper = new ObjectMapper()
    .registerModule(new JavaTimeModule())               // cho LocalDate
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    .enable(SerializationFeature.INDENT_OUTPUT);         // in đẹp

// --- Object → JSON ---
SinhVien sv = new SinhVien("SV001", "Việt Anh", 8.5, LocalDate.of(2004, 1, 14));
String json = mapper.writeValueAsString(sv);
mapper.writeValue(Path.of("sv.json").toFile(), sv);
// {
//   "ma" : "SV001",
//   "ten" : "Việt Anh",
//   "diem" : 8.5,
//   "ngaySinh" : "2004-01-14"
// }

// --- JSON → Object ---
SinhVien doc = mapper.readValue(json, SinhVien.class);

// --- Danh sách: cần TypeReference vì type erasure (chương 9!) ---
List<SinhVien> ds = mapper.readValue(jsonMang,
    new TypeReference<List<SinhVien>>() {});

// --- Đọc JSON không có class tương ứng ---
JsonNode goc = mapper.readTree(json);
String ten = goc.get("ten").asText();
double d   = goc.path("diem").asDouble(0);      // path() an toàn với khoá thiếu

// --- Bỏ qua trường lạ trong JSON (rất quan trọng khi gọi API) ---
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

10. Cheat sheet chương 11

Tình huốngCách viết
Tạo đường dẫnPath.of("a", "b.txt") — không nối chuỗi bằng /
Đọc tệp nhỏFiles.readString(p)
Đọc tệp lớntry (var s = Files.lines(p)) { … }
Ghi tệpFiles.writeString(p, s)
Nối vào cuốiFiles.writeString(p, s, StandardOpenOption.APPEND)
Ghi an toànGhi .tmp rồi Files.move(…, ATOMIC_MOVE)
Tạo cây thư mụcFiles.createDirectories(p)
Duyệt đệ quytry (var s = Files.walk(dir)) { … }
Sao chép tệpFiles.copy(a, b, REPLACE_EXISTING)
EncodingLuôn UTF-8; tránh FileReader/FileWriter không tham số
Mọi tài nguyên I/Otry-with-resources, không ngoại lệ
Tệp nhị phân lớnBufferedInputStream + đệm 8 KB, hoặc transferTo
Lưu objectJSON (Jackson) — không dùng Java serialization
Nếu buộc dùng serializationKhai báo @Serial serialVersionUID; transient cho dữ liệu nhạy cảm
Deserialize dữ liệu ngoàiKhông bao giờ — lỗ hổng thực thi mã tuỳ ý

Bài tập

Công cụ thống kê thư mục

Viết CLI ThongKeThuMuc nhận đường dẫn và in báo cáo:

  • Tổng số tệp, tổng số thư mục, tổng dung lượng (định dạng dễ đọc: KB/MB/GB).
  • Phân bố theo phần mở rộng: số lượng và tổng dung lượng mỗi loại, sắp giảm dần.
  • 10 tệp lớn nhất.
  • Tệp mới nhất và cũ nhất (theo thời gian sửa đổi).
  • Thư mục sâu nhất và độ sâu của nó.
  • Tìm tệp trùng nội dung (so bằng băm SHA-256).

Yêu cầu: dùng Files.walk + Stream; xử lý AccessDeniedException mà không dừng cả quá trình.

Gợi ý

Files.walk ném IOException ngay giữa Stream nếu gặp thư mục không có quyền. Dùng Files.walkFileTree với SimpleFileVisitor và override visitFileFailed để bỏ qua rồi đi tiếp — đây là lúc API cũ hơn lại phù hợp hơn.
Băm: MessageDigest.getInstance("SHA-256"), đọc theo khối 8 KB thay vì nạp cả tệp. Tối ưu: nhóm theo kích thước trước, chỉ băm những nhóm có nhiều hơn một tệp.

Đo hiệu năng buffer

Tạo tệp 50 MB rồi đo thời gian đọc bằng bốn cách:

  1. FileInputStream.read() — từng byte, không buffer.
  2. BufferedInputStream.read() — từng byte, có buffer.
  3. FileInputStream.read(byte[8192]) — theo khối, không buffer.
  4. Files.readAllBytes().

Lập bảng kết quả và trả lời: chênh lệch giữa (1) và (2) là bao nhiêu lần? Vì sao (3) đã nhanh dù không có BufferedInputStream? Thử đổi kích thước đệm (512, 4096, 8192, 65536) — có điểm bão hoà không?

Gợi ý

Bạn sẽ thấy (1) chậm hơn (2) khoảng 50-100 lần. Lý do: mỗi read() không buffer là một lời gọi hệ thống — chi phí chuyển ngữ cảnh lớn hơn việc đọc một byte rất nhiều.
(3) nhanh vì bản thân việc đọc theo khối đã giảm số lời gọi hệ thống — Buffered thực chất chỉ làm tự động điều đó cho bạn.
Đệm lớn hơn 64 KB thường không cải thiện thêm, vì lúc đó nút thắt chuyển sang tốc độ đĩa.

Ba cách lưu trữ — so sánh

Cho record SinhVien(String ma, String ten, double diem, LocalDate ngaySinh, List<String> monHoc), cài đặt ba bộ lưu trữ:

API chung
interface KhoLuuTru {
    void luu(List<SinhVien> ds, Path p) throws IOException;
    List<SinhVien> doc(Path p) throws IOException;
}
// Cài đặt: KhoCsv, KhoJson (Jackson), KhoNhiPhan (DataOutputStream)

Với 100.000 bản ghi, so sánh:

  • Kích thước tệp.
  • Thời gian ghi và thời gian đọc.
  • Người có đọc được tệp không?
  • Chuyện gì xảy ra khi thêm một trường mới vào record? Cách nào vẫn đọc được tệp cũ?
Gợi ý

Câu cuối là quan trọng nhất — nó gọi là tương thích lược đồ (schema evolution). JSON thắng: Jackson bỏ qua trường thiếu (nhận null) nếu bạn đặt FAIL_ON_UNKNOWN_PROPERTIES = false. CSV cần logic riêng theo số cột. Nhị phân tự viết thì hỏng hoàn toàn trừ khi bạn tự thêm số phiên bản vào đầu tệp.
Về kích thước: nhị phân nhỏ nhất (~40%), CSV ở giữa, JSON lớn nhất (~200% so với nhị phân) — nhưng JSON đọc được bằng mắt, và đó thường đáng giá.

Thử nghiệm Serialization

Thực hiện các thí nghiệm sau, ghi lại kết quả và giải thích:

  1. Serialize một object có trường transient và một trường static. Sau khi đọc lại, giá trị của chúng là gì?
  2. Serialize object A có tham chiếu tới B. Nếu B không Serializable, chuyện gì xảy ra? Thông điệp lỗi là gì?
  3. Tạo tham chiếu vòng (A trỏ B, B trỏ A) rồi serialize. Có lặp vô hạn không? Vì sao?
  4. Serialize, rồi thêm một trường mới vào class, rồi thử đọc lại tệp cũ. Chép lại thông điệp lỗi. Sau đó khai báo serialVersionUID cố định và lặp lại — kết quả khác thế nào?
  5. Đặt một câu lệnh System.out.println trong constructor rồi deserialize. Nó có in ra không? Ý nghĩa của điều này với việc kiểm tra dữ liệu?
  6. Mở tệp .ser bằng trình soạn thảo hex. Nhận ra được gì? Tìm chuỗi ký tự trong đó.
Gợi ý

(5) là bài học quan trọng nhất: constructor không được gọi khi deserialize. Nghĩa là mọi kiểm tra hợp lệ bạn đặt trong constructor đều bị bỏ qua — kẻ tấn công có thể tạo ra object ở trạng thái mà class của bạn "không thể" đạt tới. Đây là một trong những lý do serialization bị coi là nguy hiểm.
(6): bạn sẽ thấy tên class và giá trị chuỗi hiện rõ dưới dạng văn bản — nên serialization không mã hoá gì cả. Đừng bao giờ lưu mật khẩu mà không đánh dấu transient.

Công cụ đồng bộ thư mục

Viết CLI DongBo <nguon> <dich> [--apply] [--xoa-thua]:

  • Sao chép tệp mới hoặc đã đổi (so lastModifiedTime và size; nghi ngờ thì so băm).
  • Tạo thư mục còn thiếu ở đích.
  • --xoa-thua: xoá tệp ở đích mà nguồn không có.
  • Mặc định chỉ xem trước; --apply mới thực thi.
  • Ghi manifest.json lưu băm mọi tệp để lần sau so nhanh hơn.
  • Báo tiến độ và tổng kết: số tệp sao chép, số byte, thời gian.

Ràng buộc an toàn: kiểm tra dich không nằm bên trong nguon (tránh sao chép đệ quy vô hạn).

Gợi ý

Kiểm tra lồng nhau: dich.toAbsolutePath().normalize().startsWith(nguon.toAbsolutePath().normalize()).
Giữ metadata khi sao chép: Files.copy(a, b, COPY_ATTRIBUTES, REPLACE_EXISTING).
Đây chính là bài toán mà rsync giải quyết — hãy đọc cách nó hoạt động sau khi làm xong để thấy các tối ưu bạn còn thiếu (so sánh theo khối, nén khi truyền).

Quiz

Q1

java.io.File và java.nio.file.Path khác nhau thế nào? Nên dùng cái nào?

Xem đáp án
✓ Đáp án

Dùng java.nio.file (Java 7+). File chỉ giữ lại vì tương thích ngược.

Khác biệt quan trọng nhất — cách báo lỗi:

BaoLoi.java
if (!f.delete()) { }             // ❌ false — nhưng VÌ SAO? Không biết.
Files.delete(p);                   // ✅ NoSuchFileException / AccessDeniedException /
                                  //    DirectoryNotEmptyException — biết chính xác

Ngoài ra Files có nhiều thứ mà File không có:

  • readString/writeString — đọc ghi trong một dòng.
  • copy/move với tuỳ chọn nguyên tử.
  • walk/find/lines trả về Stream.
  • Hỗ trợ liên kết tượng trưng, quyền POSIX, thuộc tính tệp đầy đủ.
  • Mặc định UTF-8 thay vì bảng mã hệ thống.

Cần chuyển đổi qua lại: file.toPath() và path.toFile().

Q2

Luồng byte và luồng ký tự khác nhau ra sao? Khi nào dùng cái nào?

Xem đáp án
✓ Đáp án

Luồng byte (InputStream/OutputStream) làm việc với byte thô — 8 bit. Không hiểu gì về bảng mã. Dùng cho dữ liệu nhị phân: ảnh, video, tệp nén, tệp .ser.

Luồng ký tự (Reader/Writer) làm việc với char — 16 bit Unicode. Tự động giải mã/mã hoá theo bảng mã. Dùng cho văn bản: .txt, .csv, .json, .xml.

Hai cầu nối: InputStreamReader (byte → ký tự) và OutputStreamWriter (ký tự → byte). Chúng là nơi bạn chỉ định encoding.

Đọc tệp văn bản bằng luồng byte thì vẫn được, nhưng bạn phải tự giải mã — và đó chính là chỗ sinh ra lỗi tiếng Việt. Với NIO.2 thì đơn giản hơn nhiều: Files.readString(p) đã lo hết.

Q3

Vì sao BufferedReader nhanh hơn FileReader tới hàng chục lần?

Xem đáp án
✓ Đáp án

Vì mỗi lần đọc từ đĩa là một lời gọi hệ thống: CPU chuyển từ chế độ người dùng sang chế độ nhân, hệ điều hành xử lý, rồi chuyển ngược. Chi phí chuyển ngữ cảnh này lớn hơn hàng nghìn lần so với việc đọc một byte.

BufferedReader đọc 8 KB một lần vào mảng trong bộ nhớ, rồi phục vụ các lời gọi read() tiếp theo từ mảng đó. Với tệp 10 MB: 10 triệu lời gọi hệ thống giảm còn khoảng 1.250 — nhanh hơn khoảng 50-100 lần.

Nó còn thêm phương thức readLine() mà Reader trần không có.

Điều tương tự áp dụng cho ghi — nhưng có thêm hệ quả nguy hiểm: dữ liệu nằm trong bộ đệm cho tới khi flush() hoặc close(). Quên đóng thì tệp rỗng. Đây là lý do try-with-resources không phải tuỳ chọn.

Lưu ý: Files.readString, Files.lines, Files.newBufferedReader đều đã có buffer sẵn.

Q4

Vì sao tránh new FileReader("a.txt")?

Xem đáp án
✓ Đáp án

Vì nó dùng bảng mã mặc định của hệ thống — thứ khác nhau giữa các máy. Code chạy hoàn hảo trên macOS (UTF-8) nhưng hỏng trên Windows tiếng Việt (cp1258), và ngược lại. Đây là nguyên nhân số một của lỗi tiếng Việt trong ứng dụng Java.

Ba cách sửa, theo thứ tự nên dùng:

Sua.java
Files.readString(Path.of("a.txt"));                     // ✅✅ NIO.2, UTF-8 mặc định
new FileReader("a.txt", StandardCharsets.UTF_8);        // ✅ Java 11+
new InputStreamReader(new FileInputStream("a.txt"),
                      StandardCharsets.UTF_8);              // ✅ mọi phiên bản

Từ Java 18 (JEP 400), UTF-8 là mặc định cho toàn bộ API tệp. Nhưng nếu code phải chạy trên Java 8/11/17 thì vẫn phải khai báo tường minh. Bật cảnh báo bằng java -Dfile.encoding=COMPAT để phát hiện chỗ phụ thuộc bảng mã hệ thống.

Q5

transient làm gì? Trường static có được serialize không?

Xem đáp án
✓ Đáp án

transient đánh dấu một trường không được serialize. Khi deserialize, nó nhận giá trị mặc định: null cho kiểu tham chiếu, 0 cho số, false cho boolean.

Dùng khi:

  • Dữ liệu nhạy cảm — mật khẩu, khoá API. Lưu ý serialization không mã hoá gì cả; mở tệp .ser bằng trình soạn thảo hex là thấy chuỗi rõ ràng.
  • Không serialize được — Connection, Thread, Socket, luồng I/O.
  • Tính lại được — cache, giá trị dẫn xuất; lưu chỉ tốn chỗ.

Trường static không bao giờ được serialize — nó thuộc về class, không thuộc instance. Sau khi deserialize, nó giữ giá trị hiện tại của class trong JVM đang chạy, không phải giá trị lúc serialize. Đây là điểm hay bị nhầm.

Q6

serialVersionUID để làm gì? Không khai báo thì sao?

Xem đáp án
✓ Đáp án

Nó là số phiên bản của class trong quá trình serialize. Khi deserialize, JVM so sánh UID trong tệp với UID của class hiện tại — khác nhau thì ném InvalidClassException.

Không khai báo: JVM tự tính từ tên class, danh sách trường, method, interface, và cả mức truy cập. Hệ quả là bất kỳ thay đổi nào — thêm một trường, thêm một method, thậm chí đổi thứ tự — cũng làm UID đổi, và mọi dữ liệu cũ trở nên không đọc được.

Loi.java
java.io.InvalidClassException: SinhVien; local class incompatible:
  stream classdesc serialVersionUID = 8765432109876543210,
  local class serialVersionUID = 1234567890123456789

Khai báo tường minh cho bạn quyền kiểm soát:

Uid.java
@Serial
private static final long serialVersionUID = 1L;
  • Giữ nguyên khi thêm/xoá trường hoặc method — dữ liệu cũ vẫn đọc được, trường mới nhận giá trị mặc định.
  • Tăng lên khi đổi kiểu trường hoặc đổi ý nghĩa dữ liệu — cố ý báo rằng dữ liệu cũ không còn dùng được.

@Serial (Java 14+) khiến trình biên dịch kiểm tra chữ ký — như @Override.

Q7

Vì sao Java serialization bị coi là nguy hiểm?

Xem đáp án
✓ Đáp án

Vì readObject() phải tạo object trước khi bạn kịp kiểm tra nó là kiểu gì. Nếu classpath có class nào đó thực thi code trong readObject(), readResolve() hay các hook tương tự, kẻ tấn công có thể xâu chuỗi chúng lại (gọi là gadget chain) để thực thi mã tuỳ ý.

Các gadget chain đã được tìm thấy trong nhiều thư viện rất phổ biến (Commons Collections, Spring, Groovy…), nên chỉ cần chúng có mặt trên classpath là ứng dụng của bạn có nguy cơ — dù bạn không hề dùng tới chúng.

Ba vấn đề khác:

  • Constructor không được gọi — mọi kiểm tra hợp lệ bị bỏ qua, object có thể ở trạng thái "không thể xảy ra".
  • Không mã hoá — mở tệp .ser bằng hex editor là thấy chuỗi rõ ràng.
  • Rất dễ vỡ — đổi class là hỏng dữ liệu cũ.

Giải pháp: đừng deserialize dữ liệu không kiểm soát. Dùng JSON (Jackson) hoặc Protocol Buffers — chúng chỉ tạo object thuộc kiểu bạn chỉ định. Nếu bắt buộc, dùng ObjectInputFilter (Java 9+) với danh sách trắng.

Q8

Vì sao Files.lines() phải đặt trong try-with-resources?

Xem đáp án
✓ Đáp án

Vì nó trả về Stream gắn với một tài nguyên hệ thống đang mở (file handle). Không đóng thì file handle rò rỉ, và sau vài nghìn lần bạn gặp Too many open files — hoặc trên Windows, tệp bị khoá không xoá được.

Dung.java
// ❌ Rò rỉ
Files.lines(p).filter(...).count();

// ✅ Đúng
try (Stream<String> s = Files.lines(p)) {
    return s.filter(...).count();
}

Điều này áp dụng cho Files.lines, Files.walk, Files.list, Files.find, Files.newDirectoryStream.

Đây là ngoại lệ — hầu hết Stream khác (từ collection, từ Stream.of) không cần đóng vì chúng không giữ tài nguyên hệ thống nào. Stream cài đặt AutoCloseable chính là để phục vụ nhóm đặc biệt này.

Đổi lại, Files.lines cho phép xử lý tệp lớn hơn RAM — mỗi lúc chỉ giữ một dòng, trong khi Files.readAllLines nạp toàn bộ.

Tổng kết

Sau chương 11, bạn đã nắm:

  • Path + Files thay thế java.io.File; báo lỗi cụ thể thay vì trả false.
  • Đọc ghi một dòng: readString, writeString, lines, walk.
  • Cây stream I/O: byte vs ký tự, mẫu Decorator, cầu nối InputStreamReader.
  • Buffer giảm lời gọi hệ thống — nhanh hơn tới trăm lần.
  • Encoding: luôn UTF-8 tường minh; tránh FileReader không tham số.
  • try-with-resources cho mọi tài nguyên I/O, kể cả Files.lines.
  • Ghi an toàn qua tệp tạm + di chuyển nguyên tử.
  • Serialization: Serializable, transient, serialVersionUID, và constructor không được gọi.
  • Rủi ro bảo mật — không bao giờ deserialize dữ liệu không kiểm soát.
  • JSON là lựa chọn hiện đại: đọc được, đa ngôn ngữ, an toàn, dễ tiến hoá lược đồ.

Kết nối

  • Chương 6 (Ngoại lệ) — IOException ở khắp nơi; try-with-resources và suppressed exception.
  • Chương 10 (Stream) — Files.lines() trả Stream lười; nhớ đóng.
  • Chương 9 (Generics) — TypeReference<List<T>> của Jackson là mẹo lách type erasure.
  • Chương 5 (OOP) — record hoạt động thẳng với Jackson, không cần getter/setter.
  • Chương 12 (Đa luồng) — I/O là thao tác chặn; virtual thread (Java 21) thay đổi hoàn toàn cách xử lý I/O đồng thời.
  • Chương 13 (JDBC) — Connection cũng là AutoCloseable; cùng mẫu quản lý tài nguyên.
  • Chương 14 (Dự án) — đọc cấu hình, ghi báo cáo, xuất dữ liệu.
  • Python Chương 7 — đối chiếu: Python có pathlib.Path tương tự NIO.2, with open() ≈ try-with-resources, và pickle có đúng vấn đề bảo mật như Java serialization.