Java Internals & Concurrency/Serialization — cơ chế, serialVersionUID và transient
23/39
Bài 23 / 39~14 phútI/O & NIOMiễn phí lượt xem

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:

  1. Class implement Serializablemarker interface (không có method), signal "an toàn serialize".
  2. Field không transient — sẽ được serialize. Field transient bỏ qua.
  3. 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, passwordconn = 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 serialVersionUIDstatic 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.

⚠️ Luôn khai báo serialVersionUID explicit

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

📚 Deep Dive Oracle

Spec / reference chính thức:

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

  • Serializable marker interface, không method — JVM dùng reflection serialize field qua ObjectOutputStream.
  • Luôn khai báo serialVersionUID cho 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.
  • transient bỏ 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

Tự kiểm tra
Q1
Vì sao serialVersionUID phải khai báo explicit?

Không khai báo → JVM compute UID từ signature class (field, method, modifier, interface) qua hash algorithm. Algorithm có trong spec nhưng không hoàn toàn deterministic — UID có thể đổi giữa:

  • Các version javac khác nhau.
  • JDK khác nhau (Oracle, OpenJDK, Azul).
  • Thêm/bớt method dù private.
  • Reorder declaration.

Hậu quả: file .ser hoặc RMI message ghi bằng version A không deserialize được bằng version B — throw InvalidClassException. Bug silent, phát hiện khi deploy production.

Khai báo explicit (= 1L, = 2L...) → dev chủ động quyết định version, có migration path khi intentionally break compatibility.

Lưu ý: rule này áp cho class thường. Record có cơ chế riêng — spec nới lỏng yêu cầu match UID cho record, nên thiếu UID trên record không gây fragile như class.

Q2
Vì sao readObject/writeObject khai báo private mà JVM vẫn gọi được? Thiết kế này nói gì về cơ chế serialization?

ObjectInputStream/ObjectOutputStream tìm 2 method này bằng reflection và gọi qua setAccessible(true) — bypass access check của private. Đây không phải lời gọi method bình thường qua interface contract.

private ở đây là chủ ý thiết kế: method chỉ dành cho machinery serialization, không cho code khác gọi trực tiếp, và không bị override bởi subclass (mỗi class trong hierarchy tự khai báo bản riêng, JVM gọi đúng bản của từng class khi serialize hierarchy).

Hệ quả đáng nhớ: serialization nằm ngoài hệ thống access control và constructor bình thường của Java. Deserialize class thường không chạy constructor — JVM cấp phát object raw rồi set field bằng reflection. Đây chính là gốc rễ khiến deserialization có thể tạo object ở state mà constructor không bao giờ cho phép — và là mầm của lỗ hổng security ở bài kế tiếp.

Q3
Khi nào dùng transient?

4 use case chính:

  1. Secret / sensitive: password, API token, session token. Không persist disk — file leak là mất luôn secret.
  2. Resource: Socket, Connection, InputStream, Thread, FileChannel. Không có ý nghĩa khi deserialize ở máy khác / lần khác. Phải rebuild.
  3. Cache / computed field: field tính từ field khác. Vd displayPrice từ amount + currency. Rebuild trong readObject.
  4. Large temporary: buffer, intermediate state — tiết kiệm size serialized.

Deserialize → transient field = default (null cho reference, 0 cho numeric, false cho boolean).

Nếu cần value: override readObject:

private transient Connection conn;

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  this.conn = connect();   // rebuild after deserialize
}
Q4
Đoạn sau có vấn đề gì? class Order implements Serializable { int id; String name; Connection conn; }

3 vấn đề:

  1. Thiếu serialVersionUID — fragile với rebuild, bug silent khi deploy.
  2. Connection không Serializable — throw NotSerializableException khi writeObject (Connection là resource, implementation không implement Serializable).
  3. Semantic sai — Connection là resource runtime, không có ý nghĩa persist. Ngay cả nếu serialize được, deserialize ở máy khác cũng không dùng được connection đó (DB connection bind vào process cụ thể).

Fix:

class Order implements Serializable {
  private static final long serialVersionUID = 1L;
  int id;
  String name;
  transient Connection conn;   // Khong serialize
}

Tốt hơn về mặt design: tách Connection khỏi model domain. Dependency injection — Order là data, Connection là dependency:

class Order implements Serializable {   // Model thuan
  private static final long serialVersionUID = 1L;
  int id;
  String name;
}

class OrderRepository {   // Data access, khong serializable
  private final Connection conn;
  public Order findById(int id) { ... }
}

Separation of concerns — model không biết DB layer (xem lại bài Encapsulation ở khoá Java Foundations).

Q5
Record (Java 14+) có an toàn hơn class thường khi deserialize không? Vì sao?

Có — an toàn hơn ở 2 điểm:

  1. Canonical constructor chạy khi deserialize: class thường khi deserialize bypass constructor — JVM tạo instance "raw" và set field bằng reflection. Record không bypass — luôn đi qua canonical constructor.
  2. Compact constructor validate: compact constructor chạy trước khi field được set, validate input. File .ser với data invalid → throw → reject.
record Age(int value) implements Serializable {

  public Age {
      if (value < 0) throw new IllegalArgumentException();
  }
}

// Deserialize file voi value = -1 -> IllegalArgumentException
// Class thuong: chi field duoc set, ko validation

Tấn công: với class thường, attacker craft byte stream bypass validation trong constructor → tạo object ở state invalid → exploit downstream. Record không cho.

Nhưng: record vẫn có thể là gadget trong chain nếu implement readObject / readObjectNoData custom. Mặc định an toàn hơn, custom có thể phá.

Khuyến nghị: nếu phải dùng native serialization, ưu tiên record. Nhưng vẫn nên chuyển sang JSON/Protobuf cho code production lâu dài (bài kế tiếp).

Bài tiếp theo: Deserialization — lỗ hổng RCE và alternatives JSON/Protobuf

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

Đặt 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