Day59中我们通过InputStream/OutputStream,学会了怎么读写原始字节。

Day60中我们通Reader/Writer,学会了怎么读写人类字符。

今天我们看一下另外一个问题,假如我们内存里面有一个Java对象,比如User user = new User("LazySnail", 28);。

我们不想手动把他转成JSON或者CSV,而是想把他原封不动的存到文件里,然后明天程序重启的时候,再从文件里把他复原成一个一模一样的User 对象,该怎么办?

这个过程其实就是Java的序列化和反序列化机制。

序列化就是把一个Java内存对象转换成一个字节序列,这样就可以存到磁盘或者在网络间传输。

反序列化就是把一个字节序列还原,重新构造成一个Java内存对象。

一、Serializable接口

Serializable是一个标记接口。打开他的源码,我们会发现里面一个方法都没有。

这个接口的唯一作用,就是向JVM的序列化算法发出一个信号,这个类我们已经检查过了,同意这个类的对象(包括其所有字段)可以被序列化。

换句话说,我们想要序列化的对象的类必须实现Serializable,不然就会出现NotSerializableException。


package com.lazy.snail.day61;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

/**
 * @ClassName SerializableDemo1
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/17 16:12
 * @Version 1.0
 */
public class SerializableDemo1 {
    public static void main(String[] args) {
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("C:\\Users\\lazysnail\\Desktop\\user.ser"))) {
            oos.writeObject(new User("懒惰蜗牛", 28));
        } catch (IOException e) {
            System.err.println("序列化失败: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

class User {
    private String name;

    private int age;

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

示例代码中,我们尝试将一个User对象序列化存储到本地文件里,运行时出现java.io.NotSerializableException。

我们把User实现Serializable再运行代码,就能成功序列化了。

二、序列化和反序列化

2.1 序列化

在上一节中我们已经演示了怎么将一个Java对象序列化成字节序列。

序列化后存储的文件是user.ser。我们直接用UltraEdit打开看一下。是一堆我们看不懂的乱码。

切换到16进制显示模式。

文件里开头的两个字节AC ED是一个魔数(固定值),就是用来识别文件类型的,表示这是个序列化文件。

我们在输出序列化文件时使用的ObjectOutputStream,其中使用到的一个常量就是0xaced。

文件中后两个字节是流版本(00 05),也就是STREAM_VERSION。

这些都是ObjectOutputStream的构造函数在创建时自动写入的,这是Java序列化格式的标准开头。

序列化还有个特性就是Serializable的传递性。

当我们序列化一个User对象时,JVM的序列化算法会使用反射来检查User类的所有字段,并递归的尝试序列化每一个字段。

那假设User类中有字段的对象没有实现Serializable,会发生什么呢?


package com.lazy.snail.day61;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;

/**
 * @ClassName SerializableDemo2
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/17 16:12
 * @Version 1.0
 */
public class SerializableDemo2 {
    public static void main(String[] args) {
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("C:\\Users\\lazysnail\\Desktop\\stu.ser"))) {
            oos.writeObject(new Student("懒惰蜗牛", 28, new Address("江州", "长安街")));
        } catch (IOException e) {
            System.err.println("序列化失败: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

class Student implements Serializable {
    private String name;

    private int age;

    private Address address;

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

class Address {
    private String city;
    private String street;

    public Address(String city, String street) {
        this.city = city;
        this.street = street;
    }
}

示例代码中Student类实现了Serializable。其中有一个引用类型的属性Address,没有实现Serializable。我们尝试序列化一个Student对象。

结果抛出了java.io.NotSerializableException。

所以Java序列化要求整个对象图中的所有对象都必须可序列化。

那如果我们想不序列化某些字段,比如有些字段包含了敏感信息,我们想在序列化的时候剔除掉,有解决方案吗?

那就需要使用一个关键字transient。


package com.lazy.snail.day61;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;

/**
 * @ClassName SerializableDemo3
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/18 9:55
 * @Version 1.0
 */
public class SerializableDemo3 {
    public static void main(String[] args) {
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("C:\\Users\\lazysnail\\Desktop\\ui1.ser"))) {
            oos.writeObject(new UserInfo("懒惰蜗牛", "123456"));
        } catch (IOException e) {
            System.err.println("序列化失败: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

class UserInfo implements Serializable {
    private String name;
    private transient String password;

    public UserInfo(String name, String password) {
        this.name = name;
        this.password = password;
    }
}

示例代码中,password字段使用了transient进行修饰,那么在序列化过程中就不会将该字段进行序列化。

下图是ui.ser是没有使用transient关键字产生的序列化文件,ui1.ser是加上了transient关键字产生的序列化文件,能够直观的看出后者没有password字段相关的内容。

2.2 反序列化

反序列化就是序列化反向的一个过程,从字节序列构建Java对象。

package com.lazy.snail.day61;

import java.io.FileInputStream;
import java.io.ObjectInputStream;

/**
 * @ClassName SerializableDemo4
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/18 10:09
 * @Version 1.0
 */
public class SerializableDemo4 {
    public static void main(String[] args) {
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("C:\\Users\\lazysnail\\Desktop\\user.ser"))) {
            User user = (User) ois.readObject();
            System.out.println(user);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

上面的代码我们尝试使用ObjectInputStream,将user.ser文件中的数据恢复成Java对象。

但是却抛出了一个异常:

java.io.InvalidClassException: com.lazy.snail.day61.User; local class incompatible: stream classdesc serialVersionUID = -4181543053864524157, local class serialVersionUID = 2346668906149080388

这是一个典型的serialVersionUID不匹配导致的异常。当序列化和反序列化时的serialVersionUID不一致时,Java会抛出InvalidClassException。

我们之前也没有在任何地方看到serialVersionUID这个东西,是哪里冒出来的?

当我们没有显示的指定serialVersionUID时,Java序列化运行的时候会根据类的各个方面自动计算一个默认值。

当反序列化的时候,如果我们的类没有被修改(或者进行了兼容性的修改,比如新增字段等),那就能成功的反序列化。

但是我们的类如果发生了结构性的改变,比如删了字段、改了字段类型等等,自动生成的serialVersionUID就可能改变。就会导致InvalidClassException。

class User implements Serializable {
    private static final long serialVersionUID = 1L;
}

我们可以在类中简单的显示指定序列化版本。

只要我们提供了这个1L,后续再给User类增加新字段(比如email),反序列化旧文件的时候,JVM都会信任我们。会正常填充name和age,新加的email字段会被赋予默认值(null)。

就算显示指定了序列化版本,也不能真正解决版本兼容性问题。

三、反序列化漏洞

前面我们学会了怎么使用序列化,现在我们看看什么时候我们不应该使用他。

当ois.readObject()执行的时候,会读取字节流里的类名,然后JVM会自动findClass()来构造这个对象。

现在有这样一个场景,有个坏小子精心构造了一个恶意的字节流,这个流声称自己是一个合法的Serializable类(比如某个我们依赖的第三方库commons-collections里的类),但在反序列化(构造)这个类的过程中,他的 readObject方法(被黑客重写过的)会执行恶意代码。

// 伪代码
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    // 正常反序列化逻辑...
    in.defaultReadObject();
    
    // 恶意代码!
    Runtime.getRuntime().exec("rm -rf /");
    // 或者建立反向shell连接等
}

我们的服务器在调用ois.readObject()的时候,就可能被远程代码执行。

这些字节流会来自客户端HTTP请求体、网络socket接收的数据、用户上传的文件等等。

所以一般情况下我们不会对任何不可信来源的字节流执行ObjectInputStream.readObject()。

现代的Java应用也基本上没有使用Java原生序列化在网络间传输数据了。

现在一般使用的是JSON、Protocol Buffers、Kryo、Avro等等。

结语

通过本文的学习,我们掌握了Java原生序列化将对象状态转换为持久字节流的核心机制。

我们从Serializable标记接口出发,理解了序列化与反序列化的基本过程,探讨了transient关键字的作用和serialVersionUID的意义和局限。

下一篇预告

Day62 | Java IO之NIO三件套--通道和缓冲区(上)

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐