首页 / Java 入门教程 / 数据流与对象序列化

Java 入门教程

数据流与对象序列化

本教程共 100 篇 · 第 89 篇 · 更新于 2026-08-05 · 约 5 分钟阅读

JavaJava 入门教程DataInputStream序列化SerializableserialVersionUID

89. 数据流与对象序列化

本节目标:学会用数据流读写基本类型,理解对象序列化的原理、坑点和安全边界。

为什么需要数据流

前面几节读写的要么是字节,要么是字符。但程序里的数据大多是有类型的:年龄是 int,价格是 double,是否会员是 boolean

如果只用字节流,你得自己把 int 拆成 4 个字节写出去,读的时候再拼回来。这活儿又枯燥又容易出错。

DataOutputStreamDataInputStream 就是来干这件事的。它们属于处理流,必须包在别的流外面用。

数据流的读写方法

方法名很好记,writeread 后面跟类型名:

写出读入说明
writeInt(int)readInt()固定 4 字节
writeLong(long)readLong()固定 8 字节
writeDouble(double)readDouble()固定 8 字节
writeBoolean(boolean)readBoolean()固定 1 字节
writeUTF(String)readUTF()变长,前 2 字节存长度

注意这些方法写出的是二进制,不是文本。用记事本打开生成的文件,看到的是一堆乱码,这很正常。

import java.io.*;

public class DataStreamDemo {
    public static void main(String[] args) throws IOException {
        // 写出:商品名、单价、库存
        try (DataOutputStream out = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream("goods.dat")))) {
            out.writeUTF("机械键盘");
            out.writeDouble(399.00);
            out.writeInt(25);

            out.writeUTF("显示器");
            out.writeDouble(1299.50);
            out.writeInt(8);
        }

        // 读入:顺序必须完全一致
        try (DataInputStream in = new DataInputStream(
                new BufferedInputStream(new FileInputStream("goods.dat")))) {
            while (true) {
                String name = in.readUTF();
                double price = in.readDouble();
                int stock = in.readInt();
                System.out.printf("%s 单价 %.2f 库存 %d%n", name, price, stock);
            }
        } catch (EOFException e) {
            System.out.println("读取完毕");
        }
    }
}
```text

输出:

```text
机械键盘 单价 399.00 库存 25
显示器 单价 1299.50 库存 8
读取完毕

顺序和 EOFException

上面这段代码有两个必须记住的点。

第一,写出的顺序就是读入的顺序。数据流写出去的是纯粹的二进制,文件里没有任何标记说明哪几个字节是什么类型。你先写 int 后写 double,读的时候就必须先 readInt()readDouble()。顺序错一位,后面全乱。

第二,结束判断靠异常readInt() 这类方法没法用 -1 表示结束——因为 -1 本身就是一个合法的 int 值。所以数据流选择在流末尾抛出 EOFException

这是 Java I/O 里少见的「用异常表示正常结束」的设计。虽然不太优雅,但确实没有更好的办法。

Warning

catch (EOFException e) 只能表示读到了末尾,别把它和真正的读取错误混在一起处理。更别写成 catch (IOException e),那样会把磁盘故障也当成正常结束。

精度问题

有个坑要提前说:floatdouble 是二进制浮点数,无法精确表示大多数十进制小数。

写入 0.1 再读出来,值确实还是 0.1(因为二进制表示原样往返),但如果拿它做累加,误差就会显现。

涉及金额计算,正确做法是用 BigDecimal。而 BigDecimal 不是基本类型,数据流写不了,得用接下来的对象流。

对象序列化是什么

数据流只能处理基本类型。可实际开发中,我们操作的是对象。

把一个对象在内存中的状态转成字节序列,叫序列化;把字节序列还原成对象,叫反序列化

有了这个能力,对象就能存进文件、写入数据库、通过网络发给另一台机器。

Java 内置了一套序列化机制,用起来只要三步。

第一步:实现 Serializable

要让一个类可以被序列化,它必须实现 java.io.Serializable 接口。

这个接口特殊——里面一个方法都没有。它只是一个标记,告诉 JVM「这个类允许被序列化」。

import java.io.Serializable;

public class Student implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private int age;
    private transient String password; // 不参与序列化

    public Student(String name, int age, String password) {
        this.name = name;
        this.age = age;
        this.password = password;
    }

    @Override
    public String toString() {
        return "Student{name=" + name + ", age=" + age + ", password=" + password + "}";
    }
}
```bash

两个细节:

`serialVersionUID` 是版本号。反序列化时,JVM 会比对文件里记录的版本号和当前类的版本号,不一致就抛 `InvalidClassException`。手动声明一个固定值,可以避免类改动后旧数据读不出来。

`transient` 修饰的字段不会被写出去。密码、临时缓存、数据库连接这类不该持久化的内容,都应该加上它。

## 第二步:写出对象

```java
import java.io.*;
import java.util.List;

public class SerializeDemo {
    public static void main(String[] args) throws Exception {
        List<Student> list = List.of(
                new Student("张三", 20, "123456"),
                new Student("李四", 22, "abcdef"));

        try (ObjectOutputStream out = new ObjectOutputStream(
                new FileOutputStream("students.ser"))) {
            out.writeObject(list); // 集合本身也可序列化
        }
        System.out.println("已写出");
    }
}

writeObject() 接收 Object,也就是说什么对象都能传。但如果对象没实现 Serializable,运行时会抛 NotSerializableException

第三步:读回对象

import java.io.*;
import java.util.List;

public class DeserializeDemo {
    @SuppressWarnings("unchecked")
    public static void main(String[] args) throws Exception {
        try (ObjectInputStream in = new ObjectInputStream(
                new FileInputStream("students.ser"))) {
            List<Student> list = (List<Student>) in.readObject();
            list.forEach(System.out::println);
        }
    }
}
```text

输出:

```text
Student{name=张三, age=20, password=null}
Student{name=李四, age=22, password=null}

看到 password 变成 null 了吗?这就是 transient 生效的证据。

readObject() 的返回类型是 Object,需要强制转型,所以会抛 ClassNotFoundException——万一 classpath 里没有那个类,就还原不出来。

对象图会被整体保存

序列化不只保存一个对象,而是保存整张对象图

如果 Student 里有一个 Address 字段,那 Address 也会被一起写出去,因此 Address 也必须实现 Serializable。它引用的其他对象同理,一层层递归下去。

Java 还会自动处理循环引用。同一个对象在图里出现多次,只会真正写一份,其余位置写引用编号。读回来之后,这些位置指向的仍是同一个实例。

Tip

这个特性说明序列化的开销可能远超预期。一个看似简单的对象,如果间接引用了一大堆数据,写出的文件会大得吓人。

序列化的安全风险

必须严肃提醒一句:永远不要反序列化来源不可信的数据

readObject() 在还原对象的过程中会执行类中的某些代码。攻击者可以精心构造一段字节流,让反序列化过程触发任意命令执行。历史上多个知名框架的高危漏洞都出在这里。

因此现代 Java 项目中,跨系统传输数据基本不用 Java 原生序列化,而是改用 JSON、Protobuf 这类跨语言的文本或二进制格式。它们的解析过程不会执行目标类的代码,安全得多。

Warning

Java 原生序列化适合的场景很窄:同一个程序内部的临时存储、可信环境下的进程通信。对外接口一律走 JSON。

本节小结

  • 数据流按类型读写基本值,writeXxx()readXxx() 顺序必须严格对应。
  • 数据流用 EOFException 表示读到末尾,不能用 -1
  • 金额计算用 BigDecimal,数据流不支持,得走对象流。
  • 序列化要求类实现 Serializable 标记接口,字段用 transient 可排除。
  • 显式声明 serialVersionUID,避免类改动后旧数据失效。
  • 序列化保存整张对象图,引用到的类都必须可序列化。
  • 不可信数据绝不反序列化,对外传输优先选 JSON。