- 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ếtjava.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
Bufferedquan 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àm | java.io.File (1996) | java.nio.file (Java 7) ⭐ |
|---|---|---|
| Tạo đối tượng đường dẫn | new File("a/b.txt") | Path.of("a", "b.txt") |
| Kiểm tra tồn tại | f.exists() | Files.exists(p) |
| Xoá | f.delete() → boolean | Files.delete(p) → ném IOException ⭐ |
| Tạo thư mục | f.mkdirs() | Files.createDirectories(p) |
| Đọc cả tệp | Cần BufferedReader + vòng lặp | Files.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ệp | Rất hạn chế | Files.readAttributes — quyền, chủ sở hữu, thời gian |
| Báo lỗi | Trả false — không biết vì sao | Ném IOException có lý do cụ thể |
| Duyệt cây thư mục | Đệ quy thủ công | Files.walk trả Stream |
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ể.
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
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.
// ❌ 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 | Luồng ký tự | |
|---|---|---|
| Đơn vị | byte (8 bit) | char (16 bit, Unicode) |
| Lớp gốc | InputStream/OutputStream | Reader/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.
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
FileReader và FileWriter// ❌ 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
// ❌ 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ống | Cách dùng | Bộ nhớ |
|---|---|---|
| Tệp nhỏ (< vài MB), cần cả nội dung | Files.readString(p) | Bằng cỡ tệp |
| Tệp nhỏ, xử lý theo dòng | Files.readAllLines(p) | Bằng cỡ tệp + overhead |
| Tệp lớn, xử lý theo dòng | Files.lines(p) trong try-with-resources ⭐ | Một dòng |
| Tệp lớn, cần kiểm soát chi tiết | Files.newBufferedReader + readLine() | Một dòng |
| Ghi cả nội dung | Files.writeString(p, s) | — |
| Ghi dần từng dòng | Files.newBufferedWriter | — |
| Nối vào cuối | StandardOpenOption.APPEND | — |
// --- Đọ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
// --- 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.
SinhVien(ma, ten, diem)
ObjectOutputStream↓ luồng byte
sinhvien.serAC ED 00 05 …ObjectInputStream.readObject() — trả về Object, phải ép
kiểu về class ban đầu.
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// 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 serialize | Có | 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 | ✅ Đệ quy | Như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ọi | Mọ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
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.
// ❌ 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.
| Định dạng | Ưu điểm | Nhược điểm | Dùng khi |
|---|---|---|---|
| Java serialization | Không cần cấu hình gì | ⚠️ Bảo mật, chỉ Java đọc được, dễ vỡ khi đổi class | Chỉ trong nội bộ JVM đáng tin |
| JSON ⭐ | Người đọc được, mọi ngôn ngữ hỗ trợ, an toàn | Dài, chậm hơn nhị phân | Mặc định — API, cấu hình, lưu trữ |
| Protocol Buffers | Nhỏ, nhanh, có lược đồ, tương thích tốt | Cần định nghĩa .proto | Giao tiếp giữa các dịch vụ, hiệu năng cao |
| Định dạng tự viết | Kiểm soát hoàn toàn | Tố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
<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>
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ống | Cách viết |
|---|---|
| Tạo đường dẫn | Path.of("a", "b.txt") — không nối chuỗi bằng / |
| Đọc tệp nhỏ | Files.readString(p) |
| Đọc tệp lớn | try (var s = Files.lines(p)) { … } |
| Ghi tệp | Files.writeString(p, s) |
| Nối vào cuối | Files.writeString(p, s, StandardOpenOption.APPEND) |
| Ghi an toàn | Ghi .tmp rồi Files.move(…, ATOMIC_MOVE) |
| Tạo cây thư mục | Files.createDirectories(p) |
| Duyệt đệ quy | try (var s = Files.walk(dir)) { … } |
| Sao chép tệp | Files.copy(a, b, REPLACE_EXISTING) |
| Encoding | Luôn UTF-8; tránh FileReader/FileWriter không tham số |
| Mọi tài nguyên I/O | try-with-resources, không ngoại lệ |
| Tệp nhị phân lớn | BufferedInputStream + đệm 8 KB, hoặc transferTo |
| Lưu object | JSON (Jackson) — không dùng Java serialization |
| Nếu buộc dùng serialization | Khai báo @Serial serialVersionUID; transient cho dữ liệu nhạy cảm |
| Deserialize dữ liệu ngoài | Khô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:
FileInputStream.read()— từng byte, không buffer.BufferedInputStream.read()— từng byte, có buffer.FileInputStream.read(byte[8192])— theo khối, không buffer.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ữ:
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:
- Serialize một object có trường
transientvà một trườngstatic. Sau khi đọc lại, giá trị của chúng là gì? - 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ì? - 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?
- 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
serialVersionUIDcố định và lặp lại — kết quả khác thế nào? - Đặt một câu lệnh
System.out.printlntrong 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? - Mở tệp
.serbằ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
lastModifiedTimevà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;
--applymới thực thi. - Ghi
manifest.jsonlư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
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
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:
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/movevới tuỳ chọn nguyên tử.walk/find/linestrả 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().
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
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.
Vì sao BufferedReader nhanh hơn FileReader tới hàng chục lần?
Xem đá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.
Vì sao tránh new FileReader("a.txt")?
Xem đá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:
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.
transient làm gì? Trường static có được serialize không?
Xem đá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
.serbằ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.
serialVersionUID để làm gì? Không khai báo thì sao?
Xem đá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.
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:
@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.
Vì sao Java serialization bị coi là nguy hiểm?
Xem đá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
.serbằ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.
Vì sao Files.lines() phải đặt trong try-with-resources?
Xem đá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.
// ❌ 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+Filesthay 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
FileReaderkhô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) —
recordhoạ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) —
Connectioncũ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.Pathtương tự NIO.2,with open()≈ try-with-resources, vàpicklecó đúng vấn đề bảo mật như Java serialization.