Đây là bài mở đầu Phần 5 của khóa học: input, output và xử lý dữ liệu. Phần trước để lại một chương trình chạy nhiều thread cùng lúc; phần này nói về chuyện đưa dữ liệu vào và lấy dữ liệu ra khỏi chương trình đó. Điểm xuất phát là package đã có từ Java 1.0 và đến giờ mọi API I/O khác trong hệ sinh thái vẫn mượn hình dáng của nó: java.io.
Bạn đã biết cách ghi một dòng text ra file rồi đọc lại, và biết rằng BufferedReader nhanh hơn một reader không được bọc. Bài này là tầng nằm dưới chỗ đó. Vì sao có đúng bốn abstract stream class chứ không phải một. Decorator chain thật ra là gì, và cái gì thay đổi khi bạn dựng nó sai thứ tự. Buffer làm gì, đo bằng số lần gọi method chứ không phải mili giây. Rồi tới phần hoàn toàn mới: biến một object graph đang sống thành byte rồi dựng lại, trong đống byte đó có gì, và vì sao chính những người viết ra cơ chế đó bây giờ khuyên bạn đừng dùng nó.
![]()
Toàn bộ code bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64). Mọi hex dump đều lấy từ một file được ghi ra thật, và mọi exception đều được trích nguyên văn từ một lần chạy thật. Cả bài cố tình không có một con số thời gian nào: buffer là một thay đổi về cấu trúc, và số lần gọi method mô tả nó chính xác, trong khi một con số mili giây chỉ mô tả cái máy đã đo ra nó.
Bốn abstract root, và vì sao đúng bốn
java.io export khoảng sáu mươi type. Bốn trong số đó là abstract class mà mọi class còn lại đều kế thừa, và khi bạn gọi tên được hai trục sinh ra chúng thì năm mươi sáu class kia thôi trông giống một danh sách phải học thuộc.
Hai trục là chiều và đơn vị. Chiều là vào hoặc ra. Đơn vị là byte hoặc ký tự. Hai trục, mỗi trục hai giá trị, cho ra bốn root, và không có root thứ năm vì không có chiều thứ ba và cũng không có đơn vị thứ ba.
| Root | Chiều | Đơn vị | Method mà mọi overload đều đổ về | Hết dữ liệu |
|---|---|---|---|---|
InputStream | vào | byte | read(byte[], int, int) | trả -1 |
OutputStream | ra | byte | write(byte[], int, int) | không có |
Reader | vào | char | read(char[], int, int) | trả -1 |
Writer | ra | char | write(char[], int, int) | không có |
Cột áp chót mới là chỗ đáng giá thực tế. Vì mọi overload tiện lợi cuối cùng đều gọi xuống method ba tham số nhận array, nên chỉ cần extend một stream và override đúng method đó là bạn thấy được từng byte đi qua — đó chính là mẹo mà phần đo buffer bên dưới dựa vào.
Trục đơn vị mới là trục kéo theo hậu quả thật. File, socket và pipe đều chứa byte. String chứa ký tự. Phải có cái gì đó chuyển đổi, và trong java.io cái đó là một cặp class rất cụ thể:
| Cầu nối | Chuyển | Argument của constructor |
|---|---|---|
InputStreamReader | byte thành char | một InputStream cộng một Charset |
OutputStreamWriter | char thành byte | một OutputStream cộng một Charset |
Hai class này là chỗ duy nhất trong cả chain có xuất hiện charset. Không phải class file, cũng không phải buffer. Hỏi từng object xem nó đang dùng encoding gì thì chỉ cầu nối mới trả lời được:
try (InputStreamReader isr = new InputStreamReader(
new FileInputStream("vn.txt"), StandardCharsets.ISO_8859_1)) {
System.out.println("isr.getEncoding() = " + isr.getEncoding());
}
try (InputStreamReader isr = new InputStreamReader(new FileInputStream("vn.txt"))) {
System.out.println("no charset given = " + isr.getEncoding());
}
try (OutputStreamWriter osw = new OutputStreamWriter(
new ByteArrayOutputStream(), StandardCharsets.UTF_16BE)) {
System.out.println("osw.getEncoding() = " + osw.getEncoding());
}isr.getEncoding() = ISO8859_1
no charset given = UTF8
osw.getEncoding() = UnicodeBigUnmarkedgetEncoding() trả về tên charset lịch sử của JDK chứ không phải tên chuẩn, nên UTF-16BE quay lại thành UnicodeBigUnmarked. Chuyện đó chỉ là hình thức. Điều đáng chú ý là lời gọi thứ hai vẫn có charset: khi bạn bỏ trống, cầu nối lặng lẽ lấy giá trị default, và default đó là thuộc tính của JVM chứ không phải của file.
Hậu quả của việc tách đơn vị lộ ra ngay khi text không còn là ASCII. Hai file, cùng một chương trình, đếm xem mỗi root nhìn thấy gì:
vn.txt bytes=21 chars=21
vn2.txt bytes=26 chars=21vn2.txt chứa đúng hai mươi mốt ký tự đó nhưng có dấu tiếng Việt. Hai mươi sáu byte, hai mươi mốt ký tự. InputStream báo con số thứ nhất, Reader báo con số thứ hai, và không bên nào sai — chúng đếm hai thứ khác nhau. FileReader và FileWriter đúng nghĩa đen chỉ là subclass nối sẵn cầu nối giùm bạn: từ Java 11 chúng nhận luôn một Charset, và new FileReader(f, UTF_8).getEncoding() trả về UTF8.
Decorator chain
Một stream class trong java.io làm đúng một việc, và bạn có được một object hữu dụng bằng cách chồng nhiều class lên nhau. Dòng code kinh điển để đọc một file text sâu tới ba constructor:
BufferedReader in = new BufferedReader(
new InputStreamReader(
new FileInputStream(file), StandardCharsets.UTF_8));Đó là ba object, không phải một. Mỗi object giữ reference tới object kế tiếp và thêm đúng một khả năng lên trên nó.

Đọc chain từ trong ra. FileInputStream biết một file descriptor và không biết gì khác — nó sinh ra byte và không hề biết đó có phải text hay không. InputStreamReader nhận đống byte đó và decode thành ký tự bằng charset bạn đưa cho nó. BufferedReader nhận đống ký tự đó, giữ một lô trong array, và thêm readLine().
Pattern này có tên là decorator, và lý do cái tên đáng nhắc là vì nó ghép được. BufferedInputStream sẽ buffer bất cứ thứ gì là InputStream, dù đó là file, socket, một array trong bộ nhớ hay một decorator khác. Không có BufferedFileInputStream và cũng không có BufferedSocketInputStream, vì không cần có.
Mọi stack bạn có khả năng phải viết đều rút ra từ một danh sách ngắn:
| Layer | Root | Thêm |
|---|---|---|
BufferedInputStream / BufferedOutputStream | byte | một array nội bộ, để layer bên dưới được gọi theo từng lô |
BufferedReader / BufferedWriter | char | tương tự, cộng thêm readLine() và newLine() |
InputStreamReader / OutputStreamWriter | cầu nối | decode và encode theo charset |
DataInputStream / DataOutputStream | byte | readInt, writeLong và đồng bọn |
ObjectInputStream / ObjectOutputStream | byte | nguyên cả object graph |
PrintWriter | char | println, printf, và nuốt luôn exception |
GZIPInputStream / GZIPOutputStream | byte | nén, nằm trong java.util.zip |
Đóng object ngoài cùng là đóng cả chain, theo đúng thứ tự, vì close() của mỗi decorator sẽ đóng cái nó bọc. Đó là lý do phần header của try-with-resources chỉ cần nêu tên object ngoài cùng.
Thứ tự bọc là một phần của ý nghĩa
Vì các layer được phân loại theo root của chúng, nên phần lớn thứ tự sai đều bị compiler bắt. Bọc một Reader vào một cầu nối vốn cần InputStream:
Reader wrong = new InputStreamReader(
new BufferedReader(new FileReader(f)), StandardCharsets.UTF_8);BadOrder.java:6: error: no suitable constructor found for InputStreamReader(BufferedReader,Charset)
constructor InputStreamReader.InputStreamReader(InputStream,String) is not applicable
(argument mismatch; BufferedReader cannot be converted to InputStream)
constructor InputStreamReader.InputStreamReader(InputStream,Charset) is not applicable
(argument mismatch; BufferedReader cannot be converted to InputStream)Và buffer byte ở nhầm phía của cầu nối:
BadOrder2.java:5: error: incompatible types: InputStreamReader cannot be converted to InputStreamTrường hợp nguy hiểm là trường hợp compile được. Đặt buffer xuống dưới cầu nối thay vì lên trên và mọi type khớp hoàn hảo:
Reader r = new InputStreamReader(
new BufferedInputStream(new FileInputStream("lines.txt")),
StandardCharsets.UTF_8);
System.out.println(r.readLine());BadOrder3.java:6: error: cannot find symbol
System.out.println(r.readLine());
^
symbol: method readLine()
location: variable r of type ReaderChain đó có buffer, có charset, và vẫn không có readLine(), vì khả năng đó nằm trên cái decorator mà bạn đã không đặt ra ngoài cùng. Object ngoài cùng là object duy nhất mà bạn gọi được API của nó. Mọi thứ bên dưới chỉ chạm tới được qua những method mà layer phía trên chịu để lộ ra.
Buffer thực sự hoạt động thế nào
"Bọc BufferedInputStream vào cho nhanh" thì đúng nhưng chẳng giải thích gì. Cái buffer thay đổi là chuyện cấu trúc: số lần layer bên dưới bị hỏi dữ liệu. Con số đó đếm được, và đếm nó cho biết nhiều hơn là bấm giờ nó.
Mọi InputStream đều dồn các overload của mình về read(byte[], int, int), và FilterInputStream tồn tại đúng để bạn nhét được một bộ đếm vào giữa hai layer:
class CountIn extends FilterInputStream {
int single = 0, array = 0;
long bytes = 0;
CountIn(InputStream in) { super(in); }
@Override public int read() throws IOException {
single++;
int b = in.read();
if (b >= 0) bytes++;
return b;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
array++;
int n = in.read(b, off, len);
if (n > 0) bytes += n;
return n;
}
}Đặt một cái như vậy ngay trên file rồi đọc một file 100.000 byte từng byte một, có và không có buffer ở trên:

plain FileInputStream read()=100001 read(byte[],int,int)=0 bytes=100000
BufferedInputStream 512 read()=0 read(byte[],int,int)=197 bytes=100000
BufferedInputStream 8192 read()=0 read(byte[],int,int)=14 bytes=100000
BufferedInputStream 65536 read()=0 read(byte[],int,int)=3 bytes=100000
read(4096) unbuffered read()=0 read(byte[],int,int)=26 bytes=100000
read(4096) over 8192 read()=0 read(byte[],int,int)=14 bytes=100000
read(32768) over 8192 read()=0 read(byte[],int,int)=5 bytes=100000Có ba điều tách bạch nhìn thấy được trong khối đó.
Buffer biến các lời gọi một byte thành lời gọi theo array. Không buffer, file nhận 100.001 lần đọc một byte — mỗi byte một lần cộng lần cuối trả về -1. Với buffer 8192 byte, file nhận không lần đọc một byte nào và mười bốn lần đọc theo array. Các lời gọi một byte không biến mất; vòng lặp của bạn vẫn gọi đủ 100.000 lần. Chúng dừng lại ở BufferedInputStream, và nó trả lời từ array của mình.
Phép tính là chính xác, không phải xấp xỉ. 100.000 chia 8192 bằng 12,2, nên mười hai lần fill đầy cộng một lần fill dở cộng một lần gọi cuối báo hết file là mười bốn. Với 512 byte: 195 lần đầy, một lần dở, một lần báo hết, thành 197. Với 65536: một lần đầy, một lần dở, một lần báo hết, thành 3. Bạn đoán được con số trước khi chạy.
Một lần đọc vào array đủ lớn sẽ bỏ qua buffer hoàn toàn. Đọc vào array 32768 byte qua một buffer 8192 byte chỉ tốn năm lần gọi chứ không phải mười bốn. BufferedInputStream kiểm tra xem buffer của nó có đang rỗng không, yêu cầu có lớn bằng buffer đó không, và có mark() nào đang treo không; khi cả ba đúng, nó đọc thẳng vào array của caller thay vì chép qua array của mình. Bọc buffer lên một stream mà bạn vốn đã đọc theo lô lớn thì chỉ tốn thêm một lần cấp phát và không được gì.
Phía ghi là hình ảnh phản chiếu, và đó là chỗ buffer lộ rõ nhất:
plain FileOutputStream write(int)=100000 write(byte[],int,int)=0
BufferedOutputStream 8192 write(int)=0 write(byte[],int,int)=13
identical files: trueMười ba chứ không phải mười bốn, vì chiều đi ra không có lời gọi báo hết file. Dòng identical files: true là điểm cần nói thẳng: buffer không đổi gì ở phía byte, nó chỉ đổi số lần gọi đã chở đống byte đó đi.
Buffer size đổi cái gì, và khi nào nên đổi
Các con số đo được, xếp cạnh giá trị default:
| Buffer | Số lần gọi tới file cho 100.000 byte | So với không buffer |
|---|---|---|
| không có | 100.001 | 1x |
| 512 | 197 | ít hơn 508x |
| 8192 (default) | 14 | ít hơn 7.143x |
| 65536 | 3 | ít hơn 33.334x |
Hãy đọc cột đó như một đường lợi ích giảm dần chứ đừng đọc như hướng dẫn tuning. Đi từ không buffer sang có buffer bất kỳ đã xóa 99,8% số lần gọi. Đi từ 8 KB lên 64 KB xóa thêm mười một lần trên mười bốn lần còn lại. Giá trị default 8192 là ổn, và phiên bản đáng quan tâm của quyết định này luôn là "có buffer hay không", không bao giờ là "8 KB hay 64 KB".
Phía reader đã có sẵn một buffer bạn không hề yêu cầu
Cùng bộ đếm đó đặt trên một file text cho ra kết quả mà phần lớn tutorial về java.io nói sai. Một file 208.890 byte gồm 20.000 dòng, đọc theo ba cách, đếm số lần gọi chạm tới file:
InputStreamReader.read() read()=0 read(byte[],int,int)=27 bytes=208890
BufferedReader.readLine() read()=0 read(byte[],int,int)=27 bytes=208890
InputStreamReader over Buffered read()=0 read(byte[],int,int)=27 bytes=208890Hai mươi bảy lần trong cả ba trường hợp, kể cả trường hợp đọc từng ký tự một mà không có BufferedReader nào. InputStreamReader tự giữ một array input 8192 byte bên trong nó, nên bọc một BufferedInputStream xuống dưới InputStreamReader không đổi gì ở phía file — đó là một layer thêm một lần cấp phát và không thêm hành vi nào.
Vậy BufferedReader mua được gì? Dời bộ đếm lên một tầng, đặt giữa cầu nối và buffer, rồi đếm số lần gọi chạm tới decoder:
read() char by char read()=208891 read(char[],int,int)=0 chars=208890
BufferedReader.readLine() read()=0 read(char[],int,int)=27 chars=208890
BufferedReader size 512 read()=0 read(char[],int,int)=409 chars=208890208.891 lần gọi vào decoder rút xuống còn 27. Đó mới là phần thắng thật, và nó nằm ở tầng decode chứ không phải ở đĩa — cộng thêm readLine(), thứ mà Reader hoàn toàn không có. Quy tắc sống sót qua tất cả những cái trên: đặt buffer ở đầu chain, chỗ có pattern gọi của chính bạn, chứ không phải ở đáy.
Ghi primitive: DataOutputStream và DataInputStream
File text biến một con số thành các chữ số. Đôi khi bạn muốn chính con số đó — một layout cố định độ rộng, máy đọc được. DataOutputStream ghi các primitive type của Java theo một dạng nhị phân đã định nghĩa sẵn, và DataInputStream đọc chúng về.
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(Files.newOutputStream(p)))) {
out.writeInt(1000);
out.writeLong(1_700_000_000_000L);
out.writeDouble(9.25);
out.writeBoolean(true);
out.writeChar('A');
out.writeUTF("Hoa");
}File nó tạo ra, dump ra:
size = 28 bytes
0000 00 00 03 E8 00 00 01 8B CF E5 68 00 40 22 80 00 |..........h.@"..|
0010 00 00 00 00 01 00 41 00 03 48 6F 61 |......A..Hoa|Hai mươi tám byte, không dấu phân cách, không tên field, không đệm: 4 cho int, 8 cho long, 8 cho double, 1 cho boolean, 2 cho char, và 5 cho chuỗi. Đọc từ trái sang phải là thấy đủ mọi giá trị.
Layout là big-endian, luôn luôn, trên mọi platform. Byte có trọng số lớn nhất đi trước, ngược với thứ CPU x86 hay ARM giữ trong bộ nhớ, và điều này do specification quy định chứ không do máy:
writeInt(1000) = 00 00 03 E8
writeInt(-2) = FF FF FF FE
writeShort(1000) = 03 E8
writeUTF("Hoa") = 00 03 48 6F 61
writeChars("Hoa") = 00 48 00 6F 00 61
writeBytes("Hoa") = 48 6F 61Thứ tự cố định đó chính là toàn bộ giá trị của class này: một file do JVM trên laptop ARM ghi ra thì đọc y hệt trên server x86, và layout được ghi tài liệu đủ rõ để một chương trình C hay Go parse được.
Ba method ghi chuỗi đáng tách riêng, vì hai trong ba là bẫy. writeUTF ghi độ dài hai byte không dấu rồi tới phần encode UTF-8 sửa đổi, nên nó đi về được qua readUTF và là cái duy nhất bạn nên dùng bình thường. writeChars ghi hai byte cho mỗi ký tự và hoàn toàn không có độ dài, nên bạn phải tự biết đọc bao nhiêu. writeBytes ghi byte thấp của mỗi ký tự rồi vứt byte cao đi — với Hoa thì tình cờ đúng ASCII, còn với Hòa thì nó sẽ lặng lẽ làm hỏng chữ có dấu. Đặt cạnh nhau: writeUTF encode ò thành hai byte C3 B2 và báo độ dài 4, còn writeBytes phát ra đúng một byte F2 — nửa thấp của U+00F2, vốn không phải UTF-8 hợp lệ và không khôi phục lại được:
writeUTF("Hòa") = 00 04 48 C3 B2 61
writeBytes("Hòa") = 48 F2 61Đọc lại các field sai thứ tự
Trong file không có gì nói các field là gì. Chuỗi lời gọi của bên đọc chính là schema, và nếu nó lệch với bên ghi thì không có gì phát hiện ra:
try (DataInputStream in = new DataInputStream(Files.newInputStream(p))) {
System.out.println(" long = " + in.readLong()); // the file has an int here
System.out.println(" int = " + in.readInt()); // and a long here
System.out.println(" double = " + in.readDouble());
}-- read back in the SAME order --
int = 1000
long = 1700000000000
double = 9.25
-- read back with int and long SWAPPED --
long = 4294967296395
int = -807049216
double = 9.25Không exception. Không cảnh báo. readLong nuốt bốn byte của int cộng bốn byte đầu của long rồi báo về một con số trông hoàn toàn hợp lý. Tệ hơn cả đống rác đó là dòng thứ ba: vì một int cộng một long và một long cộng một int đều là mười hai byte, stream đã tự đồng bộ lại một cách tình cờ và double quay về đúng. Một lỗi hỏng dữ liệu tự lành giữa chừng là loại khó nhận ra nhất khi nhìn log.
Lỗi duy nhất bạn thực sự nhận được là chạy quá cuối file, và nó tới mà không kèm message nào:
java.io.EOFExceptionBài học vượt ra ngoài class này. Một file DataOutputStream chỉ đọc được bởi code đã biết sẵn layout của nó, nên nếu layout đổi thì bạn cần một số version làm field đầu tiên và một bên đọc rẽ nhánh theo nó. Đó đúng là bài toán mà object serialization cố giải tự động, và phần kế tiếp nói về cái giá của sự tự động đó.
Bên trong một object đã serialize thật ra có gì
DataOutputStream ghi những giá trị bạn liệt kê. Object serialization ghi cả object — các field của nó, các field của mọi thứ nó tham chiếu tới, và đủ phần mô tả từng class để dựng lại tất cả ở đầu bên kia. API chỉ có hai method:
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(p))) {
out.writeObject(new Point(3, 4));
}
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(p))) {
Point back = (Point) in.readObject();
}và một interface:
public class Point implements Serializable {
int x;
int y;
}Serializable là một marker interface: nó không khai báo method nào cả. Implement nó không phải là lời hứa sẽ làm gì, mà là một sự cho phép — nó báo với ObjectOutputStream rằng class này được đi qua. Đó cũng là dấu hiệu đầu tiên cho thấy ở đây có gì đó bất thường, vì cái cơ chế đọc field của bạn rồi ghi ra không phải code bạn viết và cũng không tìm thấy được bằng cách search trong project.
Kết quả cho object hai field đó là 42 byte, và chúng giải mã ra được hết:
point.ser = 42 bytes
0000 AC ED 00 05 73 72 00 05 50 6F 69 6E 74 86 92 96 |....sr..Point...|
0010 F2 3C 40 61 FE 02 00 02 49 00 01 78 49 00 01 79 |.<@a....I..xI..y|
0020 78 70 00 00 00 03 00 00 00 04 |xp........|
| Byte | Ý nghĩa |
|---|---|
AC ED | STREAM_MAGIC — mọi Java serialization stream đều bắt đầu ở đây |
00 05 | STREAM_VERSION, đã là 5 từ Java 1.2 |
73 | TC_OBJECT — kế tiếp là một object |
72 | TC_CLASSDESC — và đây là phần mô tả class của nó |
00 05 50 6F 69 6E 74 | tên class, có prefix độ dài: 5 ký tự, Point |
86 92 96 F2 3C 40 61 FE | serialVersionUID |
02 | flags: SC_SERIALIZABLE |
00 02 | số field: 2 |
49 00 01 78 | field 0: mã type I nghĩa là int, tên x |
49 00 01 79 | field 1: mã type I nghĩa là int, tên y |
78 | TC_ENDBLOCKDATA — hết phần class descriptor |
70 | TC_NULL — không có superclass descriptor |
00 00 00 03 00 00 00 04 | cuối cùng mới tới giá trị: x = 3, y = 4 |
Hai điều rơi ra ngay từ bảng đó. Thứ nhất là tỉ lệ: tám byte dữ liệu cần tới ba mươi bốn byte mô tả class. Thứ hai là 86 92 96 F2 3C 40 61 FE không hề tùy tiện. Hỏi JDK xem nó cho rằng định danh version của class là gì:
default serialVersionUID of Point = -8749765158890348034đổi ra hex là 0x869296F23C4061FE — đúng tám byte đó. Point chưa bao giờ khai báo version, nên JVM tự tính một cái bằng cách hash tên class, các modifier, interface, field và signature của method. Nhớ kỹ chỗ này; nó là nguyên nhân của lỗi serialization phổ biến nhất trên production, và có hẳn một mục riêng bên dưới.
Riêng magic number thì đáng học thuộc. Bất kỳ file, dòng log hay HTTP body nào bắt đầu bằng AC ED 00 05 — hoặc rO0AB sau khi encode base64 — đều là một Java serialization stream, và nhận ra nó ngay từ cái nhìn đầu tiên thật sự có ích khi bạn đang xem traffic của người khác.
transient và static
Hai modifier thay đổi thứ được ghi ra, vì hai lý do hoàn toàn khác nhau.
public class Account implements Serializable {
private static final long serialVersionUID = 1L;
static int instances = 0;
static String bank = "VCB";
private final String owner;
private long balance;
private transient String password;
private transient int loginCount;
}Ghi một object ra, đổi các static giữa lúc ghi và lúc đọc, rồi đọc lại:
creating:
[constructor ran for Hoa]
before Account{owner=Hoa, balance=2500000, password=s3cret, loginCount=7, bank=VCB}
wrote 81 bytes
deserializing:
after Account{owner=Hoa, balance=2500000, password=null, loginCount=0, bank=TECHCOMBANK}
same object? falsetransient nghĩa là "field này không thuộc trạng thái cần lưu của object". Nó không được ghi, và lúc quay về nó được để ở giá trị default của type — null cho reference, 0 cho int — chứ không phải ở giá trị mà một field initializer hay constructor sẽ gán. Kiểm chứng từ phía bên kia bằng cách nhìn vào file: tám mươi mốt byte đó chứa Account, balance, owner và Hoa, và không hề chứa s3cret ở đâu cả.
Field static bị bỏ qua vì một lý do khác: chúng thuộc về class chứ không thuộc về object, và không có chỗ nào hình dạng object để nhét chúng vào. bank quay về là TECHCOMBANK vì đó là thứ mà static đang giữ trong JVM đọc file tại thời điểm đó. Không có gì được khôi phục cả; field đó đơn giản là chưa từng bị đụng tới.
Hai lý do khác nhau nên chúng dẫn tới hai thói quen khác nhau. Đánh dấu transient cho field là một bí mật, một cache, một socket, một thread, một Logger — bất cứ thứ gì suy ra được hoặc không chia sẻ được. Đừng đánh dấu transient rồi trông chờ constructor sẽ đổ lại giá trị, vì constructor không chạy.
Constructor không hề chạy
Đây là sự thật làm nhiều người bất ngờ nhất, và chứng minh nó rất dễ. Cho một class serializable một class cha không serializable rồi đặt lệnh in vào mọi constructor:
public class Base { // NOT Serializable
protected String tag;
public Base() { tag = "default-from-no-arg-ctor"; System.out.println(" [Base() ran]"); }
public Base(String tag) { this.tag = tag; System.out.println(" [Base(String) ran]"); }
}
public class Child extends Base implements Serializable {
private static final long serialVersionUID = 1L;
int value;
public Child(int value) {
super("set-by-child");
System.out.println(" [Child(int) ran]");
this.value = value;
}
}creating:
[Base(String) ran]
[Child(int) ran]
before Child{value=42, tag=set-by-child}
deserializing:
[Base() ran]
after Child{value=42, tag=default-from-no-arg-ctor}Quy tắc chính xác nằm trong sáu dòng đó. Không constructor nào của bất kỳ class serializable nào trong cây kế thừa được chạy. JVM cấp phát object rồi ghi thẳng các field vào. Thứ có chạy là constructor không tham số của superclass không serializable gần nhất — ở đây là Base() — và đó là lý do tag quay về là default-from-no-arg-ctor chứ không phải set-by-child: tag nằm trong một class không serializable, nên giá trị của nó chưa bao giờ nằm trong stream, và constructor không tham số đã cấp cho nó một giá trị.
Nếu superclass đó không có constructor không tham số truy cập được thì class hoàn toàn không deserialize được — và bạn chỉ biết lúc đọc, không phải lúc ghi:
wrote 39 bytes
read: java.io.InvalidClassException: Child2; no valid constructorBa mươi chín byte đã được ghi thành công. Lỗi nằm ở đầu bên kia đường truyền, có thể trên một máy khác, có thể ở một bản release khác.
Record là ngoại lệ, và là ngoại lệ có chủ ý. Từ Java 16, một record serializable được dựng lại qua canonical constructor của nó, nên phần kiểm tra trong compact constructor vẫn chạy:
record Money(String currency, long amount) implements Serializable {
Money {
if (amount < 0) throw new IllegalArgumentException("negative amount: " + amount);
}
}record:
[canonical constructor ran: VND 100]
bytes = 81
[canonical constructor ran: VND 100]
read back = Money[currency=VND, amount=100]Constructor chạy hai lần: một lần khi record được tạo ra, một lần khi nó được dựng lại từ byte. Với một class thường thì nó chỉ chạy một lần.
serialVersionUID, và một lần mismatch trông ra sao
Point ở trên không có serialVersionUID, nên một giá trị được tính ra từ hình dạng của class. Phép tính đó bao gồm danh sách field, nghĩa là mọi thay đổi trên field đều đổi định danh, nghĩa là mọi stream đã ghi trước đó đều ngừng load được. Ghi ra một Config hai field, thêm field thứ ba, compile lại, đọc file cũ:
--- write with v1, read with v1 ---
wrote uid = -3752027558011437564
local uid = -3752027558011437564
read = Config{host=db.internal, port=5432}
--- write with v1, read with v2 (field added, no explicit uid) ---
local uid = -2625930104420556901
FAILED: java.io.InvalidClassException: Config; local class incompatible: stream classdesc serialVersionUID = -3752027558011437564, local class serialVersionUID = -2625930104420556901Message đó, với cả hai con số trong nó, là lỗi serialization được search nhiều nhất. Cách sửa là tự khai báo định danh để nó thôi bám theo hình dạng class:
private static final long serialVersionUID = 1L;Với dòng đó ở cả hai phiên bản, cùng một thay đổi lại chạy được, và kết quả đáng đọc kỹ:
--- explicit uid 1L on both sides, v4 has two extra fields ---
read = Config{host=db.internal, port=5432, timeout=0, schema=null}
--- and the reverse: v4 wrote, v3 reads (fields removed) ---
read = Config{host=db.internal, port=5432}Trong source, hai field mới được khai báo là int timeoutSeconds = 30 và String schema = "public", và chúng quay về là 0 và null. Field initializer cũng không chạy khi deserialize, y như constructor. Một field mà stream không chứa sẽ nhận giá trị default của type, và đoạn code từng cho rằng timeoutSeconds không bao giờ bằng không giờ đã sai theo kiểu không exception nào báo. Đọc một stream mới hơn bằng class cũ hơn là nửa còn lại, êm hơn: các field dư đơn giản là bị vứt đi.
Khai báo định danh là một lời hứa bạn phải giữ, và nó không phủ hết mọi thay đổi. Giữ nguyên con số rồi đổi type của một field:
--- same uid, but port changed int -> long ---
FAILED: java.io.InvalidClassException: Config; incompatible types for field portCác thay đổi tương thích là: thêm field, bỏ field, thêm một class vào cây kế thừa, đổi thân method, đổi access modifier của field. Các thay đổi không tương thích là: đổi type của field, chuyển field giữa static và không static hoặc giữa transient và không transient, đổi vị trí của class trong cây kế thừa, và chuyển class giữa Serializable và Externalizable. serialVersionUID bảo vệ bạn khỏi lệch version ngoài ý muốn, chứ không bảo vệ khỏi một lần thiết kế lại có chủ ý.
writeObject, readObject và Externalizable
Hành vi mặc định ghi từng field có thể thay thế được, và có hai mức thay thế.
Mức thứ nhất là một cặp method private với signature chính xác, mà ObjectOutputStream tìm bằng reflection. Không có interface nào để implement và không có @Override nào bảo vệ bạn khỏi gõ sai; sai signature là method của bạn lặng lẽ không bao giờ được gọi.
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
private final String user;
private transient Instant createdAt;
private transient char[] token;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // the non-transient fields
out.writeLong(createdAt.toEpochMilli()); // then whatever else we choose
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.createdAt = Instant.ofEpochMilli(in.readLong());
this.token = new char[0]; // a sane value for a field we did not write
}
}before Session{user=hoa, createdAt=2026-09-14T08:30:00Z, token.length=3}
[writeObject ran]
bytes 73
[readObject ran]
after Session{user=hoa, createdAt=2026-09-14T08:30:00Z, token.length=0}Đây là hình dạng chuẩn: gọi phần mặc định trước, rồi tự tay encode những field mà phần mặc định không lo được. readObject cũng là chỗ mà một class quan tâm tới invariant của chính nó sẽ kiểm tra lại, vì constructor sẽ không chạy.
Mức thứ hai là Externalizable, tắt hẳn mọi thứ và giao stream cho bạn:
public class Ext implements Externalizable {
private String name;
private int size;
public Ext() { System.out.println(" [public no-arg ctor ran]"); }
@Override public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(name);
out.writeInt(size);
}
@Override public void readExternal(ObjectInput in) throws IOException {
name = in.readUTF();
size = in.readInt();
}
}Externalizable bytes = 39
0000 AC ED 00 05 73 72 00 03 45 78 74 F9 8F 44 99 59 |....sr..Ext..D.Y|
0010 B3 76 7F 0C 00 00 78 70 77 0C 00 06 72 65 70 6F |.v....xpw...repo|
0020 72 74 00 00 03 E8 78 |rt....x|
deserializing:
[public no-arg ctor ran]
Ext{name=report, size=1000}Đọc phần descriptor: 00 00 nói là không có field nào, vì stream không còn mô tả chúng nữa — byte flags 0C đánh dấu class là externalizable, và mọi thứ sau 78 70 là một khối thô chứa đúng những gì writeExternal chọn ghi. Và để ý dòng cuối khi deserialize: Externalizable bắt buộc phải có constructor public không tham số và có gọi nó, ngược hẳn với quy tắc của Serializable và là lý do hai thứ này không thay thế cho nhau được.
Còn hai hook nữa đáng biết vì chúng sửa một bug thật. Serialization bỏ qua constructor private, nên nó sẵn sàng sản xuất ra bản sao thứ hai của một singleton:
plain singleton : false
with readResolve() : true
enum : trueprivate Object readResolve() chạy sau khi object được dựng xong và cho phép bạn thay thế nó bằng instance chính thức; writeReplace là phiên bản đối xứng ở phía ghi. Một enum không cần cái nào, vì enum constant được serialize theo tên và giải về qua valueOf, một trong nhiều lý do enum là cách an toàn nhất để viết singleton trong Java.
Object graph: shared reference và cycle đều sống sót
Điểm khiến serialization hơn hẳn một cú dump field là nó ghi một graph, không phải một cây. Ba nhân viên trỏ về một phòng ban, hai người trong đó chung một quản lý, và một người tự quản lý chính mình:
Dept eng = new Dept("Engineering");
for (Emp e : List.of(lan, binh, chi)) { e.dept = eng; eng.members.add(e); }
binh.manager = lan;
chi.manager = lan; // shared reference
lan.manager = lan; // self-cyclebefore:
binh.manager == chi.manager : true
lan.dept == eng : true
lan.manager == lan : true
distinct objects reachable : 5
bytes written : 277
after:
b2.manager == c2.manager : true
b2.manager == l2 : true
l2.dept == back : true
l2.manager == l2 : true
back == eng : false
distinct objects reachable : 5Mọi phép == đúng trước đó vẫn đúng sau đó. Người quản lý dùng chung vẫn là một object, các back-reference từ nhân viên về phòng ban vẫn trỏ đúng phòng ban chứa họ, self-cycle không biến thành vòng lặp vô hạn, và số object vẫn là năm. Chỉ dòng cuối là false, và đó chính là điểm mấu chốt: đây là một bản sao sâu, nên không cái gì trùng danh tính với bản gốc, nhưng mọi quan hệ bên trong bản sao đều được giữ nguyên.
ObjectOutputStream làm được chuyện đó bằng một handle table. Lần đầu gặp một object nó ghi nguyên cả object; mọi lần gặp lại sau đó nó chỉ ghi một back-reference về lần trước. Ghi cùng một object nhỏ một nghìn lần thì khác biệt đúng là chỗ đó:
1 Point : 42 bytes
1000 times, shared: 5037 bytes
1000 times, reset : 39004 bytesVà đó dẫn tới cái bẫy. Handle table nghĩa là stream nhớ những gì nó đã ghi — bao gồm cả trạng thái của object tại thời điểm đó:
o.writeObject(p); p.x = 99; o.writeObject(p);
o.reset(); p.x = 55; o.writeObject(p); 1st = Point(1,1)
2nd = Point(1,1) (back-reference: the old state)
3rd = Point(55,1) (after reset(): written afresh)Sửa một object rồi ghi lại trên cùng một stream sẽ gửi đi một back-reference tới phiên bản bạn gửi lần đầu. Bất kỳ ObjectOutputStream sống lâu nào — một socket, một log chỉ ghi thêm — đều dính chuyện này, và reset() là lời giải. Chính cái table đó cũng là một memory leak theo thiết kế: stream giữ strong reference tới mọi object nó từng ghi, nên một connection stream object hàng giờ mà không gọi reset() cuối cùng sẽ ăn hết heap.
Còn một lỗi cuối đáng nhận diện. Một object Serializable tham chiếu tới thứ không serializable sẽ cho bạn một exception lúc runtime nêu tên thủ phạm, và lúc đó thì việc ghi đã làm dở dang:
java.io.NotSerializableException: Plain
java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1200)
java.base/java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1585)
java.base/java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1542)
java.base/java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1451)
java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1194)
java.base/java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:358)Tên class trong message là type của field, không phải tên field, và stack trace không nói gì về việc field nào đang giữ nó. Trên một graph lớn thì đó là một cuộc truy tìm thực sự khó chịu.
Vì sao code mới không nên dùng Java serialization
Mọi thứ ở trên đều chạy được, và cơ chế này hoàn chỉnh đến mức đáng nể. Nó cũng là, theo đánh giá của chính những người đang bảo trì nó, một sai lầm mà Java đã mất hai thập kỷ để khoanh vùng. Đây không phải quan điểm phong cách để bạn chọn theo hay không — nó thay đổi những gì bạn được phép làm với một luồng byte đến từ ngoài process của bạn.
Khi deserialize thì thật ra cái gì chạy
Vấn đề không phải format dài dòng. Vấn đề là ai quyết định chuyện gì xảy ra. Xét một caller chỉ muốn một String:
try (ObjectInputStream in = new ObjectInputStream(source)) {
String s = (String) in.readObject();
}Phép cast đó trông như một lần kiểm tra type. Nó không phải, hoặc đúng hơn là một lần kiểm tra xảy ra quá muộn. readObject phải quyết định tạo instance của class nào trước khi trả về được bất cứ thứ gì, và nó lấy quyết định đó từ stream. Stream nêu tên một class; JVM load class đó; object được cấp phát; các field được đổ vào; và nếu class đó có method readObject thì method đó chạy — tất cả trước khi phép cast được chạm tới:
>> Payload2.readObject ran BEFORE the cast
caught java.lang.ClassCastException: class Payload2 cannot be cast to class java.lang.String (Payload2 is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')Payload2 ở đây là một class vô hại chỉ in ra một dòng. Cơ chế không quan tâm: nó sẽ làm y hệt với bất kỳ class serializable nào có trên classpath của bạn, kể cả class trong thư viện bên thứ ba mà bạn chưa từng gọi, có readObject được viết cho một mục đích hoàn toàn khác. Ghép vài class như vậy lại thành một thứ phá hoại là cả một mảng nghiên cứu, luồng byte tạo ra được gọi là gadget chain, và lý do nó chạy được đúng là chuỗi sự kiện in ra ở trên — kẻ tấn công không cần thêm class nào vào ứng dụng của bạn, chỉ cần chọn trong đám class bạn vốn đã đóng gói sẵn.
Còn một vấn đề thứ hai, âm thầm hơn. Deserialize ghi thẳng vào field, nên nó tạo ra được những object mà không constructor nào cho phép. Lấy một class có kiểm tra trong constructor, serialize một instance hợp lệ, lật tám byte trong file, rồi đọc lại:
Mutable: found the amount at offset 37 of 45
deserialized: Mutable{amount=-1}Mutable từ chối số âm ngay trong constructor và khai báo field là final. Object đi ra từ stream vẫn có amount = -1, không exception và không lần gọi constructor nào. Mọi invariant mà class của bạn áp đặt chỉ áp đặt được lên code có gọi constructor, còn một bộ deserializer thì không gọi.
Phiên bản record của cùng class đó, cũng lật đúng tám byte, hành xử theo cách bạn mong muốn:
Money (record): found the amount at offset 67 of 81
[canonical constructor ran: VND -1]
java.io.InvalidObjectException: negative amount: -1Đó là canonical constructor làm đúng việc của nó, và là một lý lẽ khá tốt cho việc biến các value type serializable thành record. Nhưng nó không phải giải pháp tổng quát: một class thường vẫn cần readObject để tự kiểm tra lại bằng tay.
Bật ObjectInputFilter
Vì gỡ serialization khỏi JDK là bất khả thi, Java thêm vào một cách để nói không. JEP 290 đưa ObjectInputFilter vào Java 9, và JEP 415 thêm filter factory theo ngữ cảnh ở Java 17. Filter được hỏi ý kiến cho từng class mà stream nêu tên, trước khi class đó được load hay tạo instance.
Theo từng stream, dùng pattern:
ObjectInputFilter f = ObjectInputFilter.Config.createFilter("java.base/*;!*");
try (ObjectInputStream in = new ObjectInputStream(source)) {
in.setObjectInputFilter(f);
System.out.println(in.readObject());
}java.io.InvalidClassException: filter status: REJECTEDPattern đọc từ phải sang trái: !* từ chối mọi thứ, còn java.base/* cho phép lại bất cứ gì thuộc module java.base. Từ Java 17 bạn cũng có thể viết danh sách cho phép dưới dạng một predicate, rõ ràng hơn một chuỗi khi danh sách ngắn:
ObjectInputFilter only = ObjectInputFilter.allowFilter(
c -> c == String.class || c == Integer.class, ObjectInputFilter.Status.REJECTED); String -> hello
Payload -> java.io.InvalidClassException: filter status: REJECTEDFilter còn mang theo các giới hạn số, và chúng quan trọng vì một stream thù địch không cần class gadget nào mới hại được bạn — vài trăm byte mô tả một cấu trúc lồng thật sâu là đủ để ăn hết heap khi bung ra. maxdepth, maxrefs, maxbytes và maxarray lo phần đó:
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("maxdepth=2;java.base/*;!*"));java.io.InvalidClassException: filter status: REJECTEDVà filter đặt được cho cả JVM mà không phải sửa dòng code nào, đó là lựa chọn thực tế cho một ứng dụng bạn thừa kế lại:
$ java -Djdk.serialFilter='java.base/*;!*' Cast
caught java.io.InvalidClassException: filter status: REJECTED⚠️ Filter là biện pháp khoanh vùng, không phải bảo đảm an toàn. Nó thu hẹp tập class mà kẻ tấn công chạm tới được; nó không làm cho việc deserialize byte không đáng tin trở nên an toàn. Đừng bao giờ gọi
readObjecttrên dữ liệu không do chính bạn tạo ra, dù đã cài filter nào.
Vậy lập trường thực tế là thế này. Java serialization là công cụ hợp lý cho một luồng byte không bao giờ rời khỏi ranh giới bạn kiểm soát, nơi hai đầu là cùng một bản build — một cú deep copy trong bộ nhớ, một cache do chính process của bạn ghi, một lời gọi RMI trong cùng một lần deploy. Còn với bất cứ thứ gì đi qua ranh giới process, ranh giới version hay ranh giới tin cậy, hãy dùng một format tường minh: một schema do bạn viết, chở dữ liệu chứ không chở code, một bên đọc không phải Java cũng parse được, và báo lỗi to khi hình dạng đổi. JSON là câu trả lời thông thường và một bài khác trong phần này sẽ nói kỹ về nó, cùng với XML.
Kích thước cũng nói lên điều đó từ một hướng khác. Vẫn object hai int đó:
ObjectOutputStream : 42 bytes
DataOutputStream : 8 bytes
hand-written JSON : 13 bytesBa mươi bốn trong bốn mươi hai byte đó là phần mô tả một class mà đầu bên kia gần như chắc chắn đã có sẵn.
Những lỗi thường gặp
Đặt buffer ở đáy chain thay vì ở đầu. Một BufferedInputStream nằm dưới InputStreamReader là gánh nặng vô ích — cầu nối vốn đã giữ sẵn array 8192 byte của nó, và cả ba reader chain ở trên đều chạm tới file đúng 27 lần. Hãy đặt buffer ở chỗ có pattern gọi của chính bạn.
Cho rằng BufferedInputStream luôn buffer. Một lần đọc vào array lớn bằng hoặc hơn buffer sẽ bỏ qua nó hoàn toàn: 5 lần gọi thay vì 14 cho cùng 100.000 byte. Nếu bạn vốn đã đọc theo lô lớn thì lớp bọc chỉ tốn thêm một lần cấp phát.
Ngồi tuning buffer size. Đi từ không có lên 8192 đã xóa 99,986% số lần gọi; đi từ 8192 lên 65536 xóa thêm mười một lần. Quyết định đáng quan tâm là có buffer hay không.
Bỏ quên serialVersionUID. Giá trị tự tính hash hình dạng của class, nên thêm một field là phá vỡ mọi stream từng ghi ra, kèm local class incompatible: stream classdesc serialVersionUID = ... local class serialVersionUID = .... Hãy khai báo nó ngay ngày class trở thành serializable.
Trông chờ field initializer đổ giá trị cho field mới. Chúng không chạy. Một field thêm vào sau khi stream đã ghi sẽ về là 0, false hoặc null, bất kể khai báo viết gì — lần chạy ở trên cho ra timeout=0 từ một field khai báo = 30.
Dựa vào constructor để khôi phục một field transient. Không constructor nào của class serializable chạy. Chỉ constructor không tham số của superclass không serializable gần nhất chạy, và nếu không có thì bạn nhận InvalidClassException: ...; no valid constructor lúc đọc, rất lâu sau khi việc ghi đã thành công.
Ghi lại một object đã sửa trên cùng một stream. Lời gọi writeObject thứ hai gửi đi một back-reference và bên đọc nhận trạng thái đầu tiên. Hãy gọi reset(), nó cũng giải phóng handle table vốn đang ghim mọi object bạn đã ghi.
Tin rằng phép cast bảo vệ được bạn. (String) in.readObject() chạy readObject do stream chọn trước rồi mới cast. Hãy cài ObjectInputFilter, và đừng deserialize byte không do bạn ghi ra.
Đọc các field của DataInputStream khác thứ tự lúc ghi. Không có gì kiểm tra. Bạn nhận về rác trông hợp lý, và nếu độ rộng tình cờ khớp thì stream tự đồng bộ lại và giấu luôn thiệt hại.
Dùng writeBytes cho text. Nó ghi byte thấp của mỗi ký tự rồi vứt phần còn lại. writeUTF mới là cái đi về được nguyên vẹn.
FAQ
Dùng Files.newInputStream rồi thì còn cần BufferedInputStream không?
Có, nếu bạn đọc theo từng mẩu nhỏ. Files.newInputStream trả về một stream không buffer dựa trên channel, nên một vòng lặp đọc từng byte qua nó vẫn chạm tới file mỗi byte một lần y hệt FileInputStream. Nếu bạn đọc vào một array lớn, hoặc bạn đưa stream cho thứ vốn đã buffer sẵn như InputStreamReader hay Files.newBufferedReader, thì không cần.
flush và close trên ObjectOutputStream khác nhau thế nào?
flush() đẩy phần byte đang nằm trong buffer xuống chain và để stream vẫn dùng được; close() flush, không ghi thêm gì, rồi giải phóng resource bên dưới. Không cái nào xóa handle table cả — đó là việc của reset(), một thao tác thứ ba tách biệt và là thứ người ta thực sự cần trên một stream sống lâu.
Có deserialize được object mà không còn class của nó không?
Không. readObject ném ClassNotFoundException khi class mà stream nêu tên không có trên classpath, và đó là một checked exception bạn phải catch cùng với IOException. Đây là một trong những cạnh sắc của format này: đống byte vô nghĩa nếu thiếu đúng những class đã tạo ra nó, ngược hẳn với thứ bạn muốn ở một format lưu trữ.
serialVersionUID có đủ để một class tiến hóa an toàn không?
Nó đủ để chặn exception lệch version, mà đó không phải cùng một chuyện. Với một định danh cố định, bạn thêm và bỏ field được, nhưng giá trị của field bị bỏ sẽ lặng lẽ bị vứt và field thêm vào sẽ về giá trị default của type chứ không về initializer của nó. Đổi type của field vẫn hỏng, kèm incompatible types for field .... Hãy coi một class serializable như một wire format đã công bố và sửa nó với đúng mức cẩn thận đó.
Có nên dùng Externalizable để stream nhỏ lại không?
Hiếm khi. Nó có cho ra stream nhỏ hơn — 39 byte so với 42 cho một object tương đương, và khoảng cách nới rộng theo số field — nhưng bạn phải tự viết và tự bảo trì cả hai nửa của bộ codec, phải cấp một constructor public không tham số, và hoàn toàn không được hỗ trợ gì khi class thay đổi. Nếu kích thước hay layout thật sự quan trọng thì câu trả lời là một format tường minh, chứ không phải một biến thể viết tay của cái format bạn đang muốn rời bỏ.
Làm sao biết một file có phải Java serialization stream không?
Nhìn bốn byte đầu. AC ED 00 05 là STREAM_MAGIC rồi tới STREAM_VERSION, và encode base64 thì nó là tiền tố rO0AB. Nếu bạn thấy chuỗi đó ở đầu một HTTP body, một cookie hay một message trong queue, thì đang có thành phần nào đó deserialize input từ ngoài process, và chuyện đó đáng để nói lại với cả team.
Kết luận
java.io có bốn abstract root vì có hai chiều và hai đơn vị, và InputStreamReader với OutputStreamWriter là hai class duy nhất trong cả package có chỗ chọn charset — không phải class file, không phải buffer. Stream ghép lại theo kiểu decorator, nên object ngoài cùng là object duy nhất mà bạn gọi được API của nó: BufferedInputStream nằm dưới InputStreamReader thì compile được và chẳng mua được gì, còn new BufferedReader(new InputStreamReader(in, UTF_8)) cho bạn readLine() và một encoding tường minh. Buffer là thay đổi về cấu trúc và nó đếm được: 100.001 lần gọi rút còn 14 với buffer 8192 byte, 197 với 512, 3 với 65536, và 5 khi lệnh đọc đi thẳng vào một array 32 KB — mà file ghi ra vẫn giống nhau từng byte. DataOutputStream ghi primitive theo big-endian trong một layout không có tên field, nên chuỗi lời gọi của bên đọc chính là schema và đọc sai thứ tự cho ra rác trông hợp lý chứ không cho ra lỗi.
Object serialization ghi cả một graph, giữ nguyên shared reference và cycle, và tốn 34 byte mô tả class cho 8 byte dữ liệu. Serializable là marker không có method nào; field transient và static bị bỏ qua; không constructor nào của class serializable chạy, chỉ constructor không tham số của superclass không serializable gần nhất chạy; field initializer cũng không chạy, nên một field mới thêm sẽ về 0 hoặc null; và serialVersionUID được tính từ hình dạng class trừ khi bạn tự khai báo, đó là lý do thêm đúng một field đã sinh ra local class incompatible: stream classdesc serialVersionUID = .... Và format này quyết định tạo instance của class nào từ chính đống byte, chạy readObject của class đó trước cả khi phép cast của bạn được tính tới — đó là lý do ObjectInputFilter tồn tại, lý do -Djdk.serialFilter là một lựa chọn deploy có thật, và lý do code mới nên mang theo một format tường minh.
Bài kế tiếp vẫn ở trong mảng I/O và chuyển sang API đã thay thế phần lớn API này: java.nio.file. Đại số trên Path và chuẩn hóa đường dẫn, duyệt cây thư mục bằng Files, đọc và ghi file attribute, theo dõi thay đổi thư mục bằng WatchService, và mô hình channel cùng buffer nằm dưới tất cả, kể cả memory-mapped file.