Serialization — cơ chế, serialVersionUID và transient
Java native serialization hoạt động thế nào: marker interface + reflection, serialVersionUID làm version check, transient cho secret/resource, custom writeObject/readObject để encrypt và validate.
TL;DR: Java native serialization ghi object graph xuống byte stream qua ObjectOutputStream: class chỉ cần implement Serializable — marker interface không method, JVM dùng reflection đọc field. serialVersionUID là version check khi deserialize — không khai báo explicit thì JVM tự hash từ structure class, đổi javac/thêm method là UID đổi → InvalidClassException silent khi deploy. transient loại field khỏi serialize (secret, resource, cache); custom writeObject/readObject (private, JVM gọi qua reflection) cho encrypt/validate/rebuild. Record (Java 14+) deserialize qua canonical constructor — an toàn hơn class thường vốn bị bypass constructor.
Bạn cần lưu một object graph xuống disk để lần khởi động sau load lại, hoặc gửi object qua network giữa 2 JVM (RMI, distributed cache). Java có sẵn câu trả lời từ phiên bản 1.1: chỉ cần implements Serializable — không viết thêm dòng code nào — JVM tự ghi được cả object graph, kể cả reference lồng nhau, xuống byte stream.
"Không viết gì mà chạy được" luôn có giá của nó. Một interface rỗng làm sao ghi được object? Vì sao recompile xong file .ser cũ bỗng không load được? Vì sao field Connection làm cả class không serialize nổi? Bài này mổ cơ chế bên dưới: marker interface + reflection, serialVersionUID, transient, và custom writeObject/readObject.
Còn mặt tối của serialization — vì sao nó thành lỗ hổng security số 1 của Java năm 2015 và industry chuyển sang JSON/Protobuf — để dành trọn cho bài kế tiếp: Deserialization — lỗ hổng RCE và alternatives.
1. Java native serialization — cơ chế cơ bản
Ví dụ đơn giản
import java.io.*;
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name;
int age;
User(String n, int a) { name = n; age = a; }
}
// Serialize
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("user.ser"))) {
out.writeObject(new User("Alice", 30));
}
// Deserialize
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream("user.ser"))) {
User u = (User) in.readObject();
System.out.println(u.name); // Alice
}
3 yêu cầu:
- Class implement
Serializable— marker interface (không có method), signal "an toàn serialize". - Field không
transient— sẽ được serialize. Fieldtransientbỏ qua. serialVersionUID— version identifier.
Serializable chỉ là marker
public interface Serializable {
// Khong co method
}
Không có method nào — JVM dùng reflection để đọc field, serialize. Class không implement Serializable → throw NotSerializableException khi writeObject.
Marker interface là pattern có kể từ Java 1.1 — khác modern-style annotation (@Serializable như Kotlin). Lý do lịch sử: Java 1.1 không có annotation (thêm Java 5), dùng interface rỗng.
Field nào được serialize
- Field không
transient→ serialize. - Field không
static→ serialize (static thuộc class, không instance). - Field
transient→ bỏ qua, deserialize trả default (null/0/false).
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name; // serialize
int age; // serialize
transient String password; // KHONG serialize
transient Connection conn; // KHONG serialize
static int COUNT; // KHONG serialize (static)
}
Deserialize sau, password và conn = null (default cho reference). Nếu cần value, override readObject (mục 4) để rebuild.
2. serialVersionUID — version của class
class User implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
Mỗi class Serializable nên có field serialVersionUID — static final long. Vai trò: version identifier.
Deserialize check
Khi deserialize user.ser:
- JVM đọc UID từ byte stream.
- So với UID class hiện tại.
- Match → deserialize tiếp.
- Khác → throw
InvalidClassException.
Cơ chế: "file serialize version cũ không load được bằng class version mới khác UID".
Nếu không khai báo
JVM tính UID tự động bằng hash từ structure class: signature field, method, interface. Algorithm có trong spec nhưng không hoàn toàn deterministic giữa javac version, JDK version, OS.
Hậu quả:
- Recompile với javac khác → UID đổi.
- Thêm 1 method (kể cả private) → UID đổi.
- Reorder method → UID đổi (có thể).
File .ser serialized với version A không deserialize được bằng version B. Bug silent — phát hiện khi deploy.
Không khai báo → fragile với rebuild. IDE có warning: "The serializable class does not declare a static final serialVersionUID field". Nên bật strict mode và fix. Ngoại lệ duy nhất: record — xem mục 5, record có cơ chế serialization riêng và spec nới lỏng yêu cầu match UID.
Khi tăng UID?
Tăng UID (1L → 2L) khi cố ý break compatibility — file cũ không load được với class mới. Ví dụ đổi schema field không tương thích, rename class.
Giữ UID khi thay đổi backward-compatible — thêm field mới (default), thêm method. Deserialize file cũ vẫn OK, field mới = default.
3. transient — bỏ field khỏi serialize
class Session implements Serializable {
private static final long serialVersionUID = 1L;
String userId;
transient String authToken; // Secret - khong ghi disk
transient Socket connection; // Resource runtime
transient Instant lastAccessed; // Compute lai duoc
}
Deserialize → authToken null, connection null, lastAccessed null.
Dùng cho:
Secret / sensitive data
Password, API token, session token — không persist disk (security risk nếu file leak ra ngoài).
Resource runtime
Socket, Connection, InputStream, Thread — không có ý nghĩa khi deserialize ở máy khác hoặc lần khởi động khác. Phải rebuild.
Cache / computed field
Field tính từ field khác — rebuild trong readObject khi cần:
class PriceCache implements Serializable {
private static final long serialVersionUID = 1L;
private final BigDecimal amount;
private final String currency;
private transient String displayPrice; // "$100.00" - compute tu amount + currency
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.displayPrice = formatPrice(amount, currency); // rebuild
}
}
Large intermediate
Buffer, temporary structure — không cần persist, tạo lại khi cần.
4. Custom writeObject / readObject
Override cơ chế mặc định để control format:
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name;
transient String passwordHash;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // Serialize field binh thuong
out.writeUTF(encrypt(passwordHash)); // Custom: encrypt password truoc khi ghi
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // Deserialize field binh thuong
this.passwordHash = decrypt(in.readUTF()); // Custom: decrypt
}
}
Method private nhưng JVM vẫn gọi được — dùng reflection bypass access check. Thiết kế hơi kỳ lạ, di sản từ Java 1.1.
Use case:
- Encrypt sensitive field before write.
- Validate invariant in readObject (phòng data corruption).
- Rebuild transient field after deserialize.
- Change storage format mà không đổi field (backward-compatible migration).
Thêm readObjectNoData() — gọi khi deserialize object của subclass từ stream cũ không có data parent class. Hiếm dùng.
5. Record serialization (Java 14+)
record Point(int x, int y) implements Serializable {
}
Record có cơ chế serialization riêng, không đi qua đường reflection set-field như class thường. Khi deserialize:
- JVM đọc các component từ stream, rồi gọi canonical constructor — validate input qua compact constructor nếu có.
record Age(int value) implements Serializable {
public Age {
if (value < 0) throw new IllegalArgumentException();
}
}
Deserialize file với value = -1 → canonical constructor chạy → throw → reject. An toàn hơn class thường — class thường bypass constructor khi deserialize, compact constructor của record không bị bypass.
Về serialVersionUID: spec serialization nới lỏng yêu cầu match UID cho record — record không khai báo UID có default 0L, và mismatch UID không chặn deserialize record. Vì vậy lời khuyên "luôn khai báo UID" ở mục 2 áp cho class thường; với record, khai báo hay không chủ yếu là chuyện convention nhất quán trong codebase.
Record vẫn có thể là gadget trong chain nếu implement readObject custom — nhưng mặc định an toàn hơn (chi tiết về gadget chain ở bài kế tiếp).
6. Pitfall tổng hợp
❌ Nhầm 1: Không khai báo serialVersionUID (class thường).
class User implements Serializable { String name; } // UID tu compute
✅ private static final long serialVersionUID = 1L;
❌ Nhầm 2: Serialize resource.
class Session implements Serializable {
private Socket socket; // Fail hoac corrupt khi deserialize
}
✅ Mark transient, rebuild trong readObject:
private transient Socket socket;
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.socket = createSocket(); // rebuild
}
❌ Nhầm 3: Quên transient cho secret.
class Account implements Serializable {
String username;
String password; // Ghi plaintext xuong disk!
}
✅ transient String password; — hoặc tốt hơn, đừng giữ secret trong object cần persist.
❌ Nhầm 4: Dùng .ser làm config persistent.
// Format coupled voi Java version, class version
✅ YAML / JSON — stable, editable, version-independent.
7. 📚 Deep Dive Oracle
Spec / reference chính thức:
- Serializable interface — API + serialVersionUID rule.
- Java Object Serialization Specification — format binary chi tiết; chương "Serialization of Records" mô tả cơ chế riêng + nới lỏng UID cho record.
- ObjectOutputStream / ObjectInputStream — javadoc writeObject/readObject contract.
Ghi chú: Spec serialization là tài liệu hiếm hoi mô tả chính xác từng byte trong file .ser (magic number ACED, stream version, class descriptor). Đọc chương 6 "Object Serialization Stream Protocol" nếu cần debug file serialize bằng hex editor — kỹ năng hữu ích khi gặp InvalidClassException khó hiểu trong production.
8. Tóm tắt
Serializablemarker interface, không method — JVM dùng reflection serialize field quaObjectOutputStream.- Luôn khai báo
serialVersionUIDcho class thường — không khai báo = fragile với rebuild (UID tự hash từ structure class). - Tăng UID khi cố ý break compatibility; giữ UID khi thay đổi backward-compatible.
transientbỏ field khỏi serialize — dùng cho secret, resource, cache, computed.- Custom
writeObject/readObject(private method, JVM gọi qua reflection) override mặc định — dùng cho encrypt, validate, rebuild. - Record (Java 14+) serialize qua canonical constructor — an toàn hơn class thường (compact constructor validate, không bypass).
- Record có cơ chế serialization riêng — spec nới lỏng yêu cầu match
serialVersionUID. - Mặt security của deserialization (gadget chain, RCE, alternatives) — bài kế tiếp.
9. Tự kiểm tra
- Q1Vì sao
serialVersionUIDphải khai báo explicit? - Q2Vì sao
readObject/writeObjectkhai báoprivatemà JVM vẫn gọi được? Thiết kế này nói gì về cơ chế serialization? - Q3Khi nào dùng transient?
- Q4Đoạn sau có vấn đề gì?
class Order implements Serializable { int id; String name; Connection conn; } - Q5Record (Java 14+) có an toàn hơn class thường khi deserialize không? Vì sao?
Bài tiếp theo: Deserialization — lỗ hổng RCE và alternatives JSON/Protobuf
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên