1. Tham chiếu vòng lặp là gì?
Tham chiếu vòng lặp là tình huống khi một đối tượng (hoặc một collection) trực tiếp hoặc gián tiếp chứa tham chiếu tới chính nó. Trong các collection, điều này xảy ra thường xuyên hơn bạn tưởng, đặc biệt khi bạn xây dựng cấu trúc dữ liệu phức tạp hoặc làm việc với đồ thị.
Ví dụ thực tế
- Hai đối tượng tham chiếu lẫn nhau:
Ví dụ, bạn có lớp User có tham chiếu tới Profile, và Profile lại tham chiếu ngược về User. - Collection chứa chính nó:
Ví dụ đơn giản và “hài hước” nhất:
List<Object> list = new ArrayList<>();
list.add(list); // Ô kìa! list chứa chính nó
- Đồ thị đối tượng:
Các đối tượng liên kết với nhau, ví dụ các nút của cây, nơi mỗi nút có thể có tham chiếu tới cha và con.
Trực quan hóa
graph LR A[User] -- profile --> B[Profile] B -- user --> A
Hoặc với collection:
graph TD L[List] -- add(self) --> L
Tại sao điều này có thể gây vấn đề?
Nếu bộ tuần tự không biết theo dõi vòng lặp, nó có thể “đi vào vô tận”, cố gắng tuần tự hóa các đối tượng lồng nhau lặp đi lặp lại cho tới khi tràn stack (StackOverflowError). Tin tốt: cơ chế tuần tự hóa chuẩn của Java biết các trường hợp này và xử lý được!
2. Cơ chế tuần tự hóa chuẩn của Java xử lý vòng lặp như thế nào?
Khi bạn tuần tự hóa một đối tượng qua ObjectOutputStream, Java tự động theo dõi những đối tượng nào đã được tuần tự hóa trong luồng đó. Nếu gặp lại cùng một đối tượng, nó không tuần tự hóa lần nữa mà ghi một tham chiếu đặc biệt tới đối tượng đã được ghi. Điều này cho phép tuần tự hóa đúng đắn ngay cả các cấu trúc rất phức tạp có vòng lặp.
Ví dụ: một collection chứa chính nó
Hãy thử tuần tự hóa một collection chứa chính nó. Đây không phải trò đùa — đoạn mã này biên dịch và còn chạy được nữa:
import java.io.*;
import java.util.*;
public class CyclicListDemo {
public static void main(String[] args) throws Exception {
List<Object> list = new ArrayList<>();
list.add("Hello, cyclic world!");
list.add(list); // Thêm chính nó
// Tuần tự hóa
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic_list.ser"))) {
out.writeObject(list);
}
// Giải tuần tự hóa
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic_list.ser"))) {
List<?> deserialized = (List<?>) in.readObject();
System.out.println(deserialized.get(0)); // "Hello, cyclic world!"
System.out.println(deserialized.get(1) == deserialized); // true!
}
}
}
Kết quả:
— Phần tử đầu tiên là một chuỗi bình thường.
— Phần tử thứ hai là... chính collection đó! Kiểm tra deserialized.get(1) == deserialized trả về true.
Java không bị lặp vô hạn hay sập, mà khôi phục đúng cấu trúc tham chiếu.
Bên trong hoạt động thế nào?
ObjectOutputStream duy trì một “sổ đăng ký” nội bộ của các đối tượng đã được tuần tự hóa. Nếu một đối tượng đã được ghi trước đó, vào luồng sẽ ghi một tham chiếu (handle) đặc biệt thay vì nội dung của nó. Khi giải tuần tự hóa, ObjectInputStream khôi phục lại các liên kết y hệt.
3. Vấn đề và hạn chế
- Vô tình tuần tự hóa một đồ thị khổng lồ.
Nếu cấu trúc dữ liệu của bạn rất lớn và chứa nhiều liên kết chéo, việc tuần tự hóa có thể tốn nhiều thời gian và tạo ra một file rất lớn. - Thay đổi cấu trúc lớp.
Nếu bạn đã tuần tự hóa đối tượng rồi sau đó thay đổi lớp của nó (ví dụ thêm hoặc xóa trường), khi giải tuần tự hóa có thể phát sinh InvalidClassException. Đặc biệt rủi ro nếu các trường thay đổi tham gia vào vòng lặp. - Vấn đề với tuần tự hóa tùy chỉnh.
Nếu bạn tự hiện thực các phương thức writeObject và readObject, bạn phải tự xử lý đúng các vòng lặp. Nếu quên gọi các phương thức mặc định (defaultWriteObject/defaultReadObject), bộ tuần tự sẽ không thể theo dõi vòng lặp. - Tuần tự hóa sang các định dạng khác (ví dụ JSON).
Cơ chế tuần tự hóa chuẩn của Java (ObjectOutputStream) xử lý được vòng lặp, nhưng nếu bạn tuần tự hóa đối tượng sang JSON (ví dụ qua Jackson hoặc Gson), các vòng lặp có thể dẫn tới StackOverflowError hoặc ngoại lệ. Mặc định các thư viện này không xử lý vòng lặp — cần cấu hình rõ ràng.
4. Xử lý tham chiếu vòng lặp
Trong tuần tự hóa chuẩn của Java
Mọi thứ “out of the box”! Bạn không cần làm gì đặc biệt — Java tự phát hiện các vòng lặp và giữ nguyên cấu trúc tham chiếu.
Thủ công: tuần tự hóa sang định dạng khác
- Dùng định danh (ID) thay vì tham chiếu.
Thay vì lưu tham chiếu tới các đối tượng khác, hãy lưu định danh duy nhất của chúng. Sau khi giải tuần tự hóa, khôi phục liên kết dựa trên các id này. - Annotation hay cấu hình chuyên biệt.
Trong Jackson có thể dùng annotation @JsonIdentityInfo hoặc cặp @JsonBackReference/@JsonManagedReference để kiểm soát tuần tự hóa vòng lặp. - Loại bỏ vòng lặp trước khi tuần tự hóa.
Tạm thời đặt null các trường tạo vòng lặp, loại trừ chúng bằng transient hoặc bằng annotation.
Ví dụ: tuần tự hóa đồ thị có vòng lặp
Xem ví dụ với cấu trúc phức tạp hơn — đồ thị người dùng, nơi mỗi người dùng có thể là bạn của người khác.
import java.io.*;
import java.util.*;
class User implements Serializable {
String name;
List<User> friends = new ArrayList<>();
User(String name) { this.name = name; }
public String toString() {
return name + " (" + friends.size() + " friends)";
}
}
public class CyclicGraphDemo {
public static void main(String[] args) throws Exception {
User alice = new User("Alice");
User bob = new User("Bob");
User charlie = new User("Charlie");
// Tạo quan hệ bạn bè có chu trình
alice.friends.add(bob);
bob.friends.add(charlie);
charlie.friends.add(alice); // chu trình!
// Tuần tự hóa
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
out.writeObject(alice);
}
// Giải tuần tự hóa
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("users.ser"))) {
User restoredAlice = (User) in.readObject();
System.out.println(restoredAlice);
System.out.println(restoredAlice.friends.get(0));
System.out.println(restoredAlice.friends.get(0).friends.get(0));
System.out.println(restoredAlice.friends.get(0).friends.get(0).friends.get(0) == restoredAlice); // true!
}
}
}
Kết quả:
— Cấu trúc có chu trình được khôi phục: sau ba bước đi qua danh sách bạn bè, ta quay lại Alice.
— Java không bị rối và không lặp vô hạn.
5. Các lỗi thường gặp khi làm việc với tham chiếu vòng lặp
Lỗi số 1: Tuần tự hóa JSON mà không hỗ trợ chu trình. Nếu bạn quyết định tuần tự hóa một đối tượng có vòng lặp bằng Jackson hoặc Gson mà không cấu hình, rất có thể bạn sẽ gặp StackOverflowError. Ví dụ, nếu bạn có lớp Node, nơi mỗi nút tham chiếu tới cha và các con, việc tuần tự hóa cây như vậy sang JSON sẽ dẫn tới độ lồng vô hạn.
Lỗi số 2: Thay đổi cấu trúc lớp. Nếu sau khi tuần tự hóa bạn thay đổi cấu trúc lớp (ví dụ thêm trường), khi giải tuần tự hóa file cũ có thể phát sinh lỗi không tương thích. Điều này đặc biệt nghiêm trọng với các đồ thị phức tạp có vòng lặp.
Lỗi số 3: Tự viết tuần tự hóa mà không xử lý chu trình. Nếu bạn hiện thực writeObject/readObject thủ công và không gọi defaultWriteObject, Java sẽ không thể theo dõi vòng lặp, và quá trình tuần tự hóa có thể bị lặp vô hạn hoặc cấu trúc tham chiếu sẽ hỏng khi giải tuần tự hóa.
Lỗi số 4: Vô tình để collection chứa chính nó. Đôi khi lập trình viên mới vô tình thêm collection vào chính nó (ví dụ khi sao chép phần tử) mà không nhận ra mình đã tạo một vòng lặp. Kết quả là tuần tự hóa vẫn hoạt động, nhưng logic chương trình có thể trở nên kỳ lạ và khó đoán.
GO TO FULL VERSION