深入解析Java序列化:从使用到原理

在此之前,对于 Java 中的序列化,我一直停留在使用层面 —— 把需要序列化在网络上传输的类实现Serializable接口就可以了

但对于这块知识点,随着工作年限的提升,我觉得必须要好好研究下它了,不能似懂非懂的只知道使用。本文将针对以下几个知识点深入讲解Serializable接口:

  1. 为什么需要序列化实现的类都要添加Serializable接口?
  2. 为什么我自己的项目需要序列化的类没有添加Serializable接口也能成功序列化?
  3. 实现序列化时都建议手动定义一个serialVersionUID,这个属性的作用是什么?

1. Serializable 介绍

Java 序列化是 JDK 1.1 时引入的一组开创性的特性,用于将 Java 对象转换为字节数组,便于存储或传输。此后,仍然可以将字节数组转换回 Java 对象原有的状态

序列化的思想是“冻结”对象状态,然后写到磁盘或者在网络中传输;反序列化 的思想是“解冻”对象状态,重新获得可用的 Java 对象

序列化:Java 对象转换成可在网络中传输或可写入磁盘的一个字节序列

反序列化:将一个字节序列转换为 Java 对象,便于在程序中使用

序列化有一条规则,就是要序列化的对象必须实现Serializbale接口,否则就会报 NotSerializableException 异常

我们先来看看Serializable接口的定义:

public interface Serializable {
}

我们发现,只是定义了接口,但内部没有任何代码。那它是怎么能够保证实现了它的类对象能够被正常的序列化和反序列化呢?

为了能够知道 Java 底层对于序列化、反序列化的实现,我们先定义一个类(Woman)

/**
 * @Author: ZhangGongMing
 * @CreateTime: 2025/5/13 13:57
 * @Description:
 * @Version: 1.0
 */
@Data
public class Woman {

    private String name;

    private String age;
}

接着创建一个测试类,通过ObjectOutputStream将“18 岁的王二”写入到文件当中,实际上就是一种序列化的过程;再通过ObjectInputStream将“18 岁的王二”从文件中读出来,实际上就是一种反序列化的过程

/**
 * @Author: ZhangGongMing
 * @CreateTime: 2025/5/13 14:12
 * @Description:
 * @Version: 1.0
 */
public class Main {
    public static void main(String[] args) {
        Woman woman = new Woman();
        woman.setAge("18");
        woman.setName("张三");
        System.out.println(woman);

        // 序列化过程:将对象写入到文件
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("text"))) {
            oos.writeObject(woman);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 反序列化过程:从文件中读出对象
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("text"))) {
            Woman wm = (Woman) ois.readObject();
            System.out.println(wm);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

我们运行发现,第一个打印能成功执行,但序列化时报错,原因在于我们的Woman类没有实现 Serializable接口。所以在运行的时候会抛出异常。堆栈信息如下:

java.io.NotSerializableException: com.zhang.model.Woman
	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184)
	at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348)
	at com.zhang.Main.main(Main.java:22)

顺着堆栈信息,我们看一下 ObjectOutputStream 的 writeObject0 方法。其部分源码如下:

// 判断对象是否为字符串类型,如果是,则调用 writeString 方法进行序列化
if (obj instanceof String) {
    writeString((String) obj, unshared);
}
// 判断对象是否为数组类型,如果是,则调用 writeArray 方法进行序列化
else if (cl.isArray()) {
    writeArray(obj, desc, unshared);
}
// 判断对象是否为枚举类型,如果是,则调用 writeEnum 方法进行序列化
else if (obj instanceof Enum) {
    writeEnum((Enum) obj, desc, unshared);
}
// 判断对象是否为可序列化类型,如果是,则调用 writeOrdinaryObject 方法进行序列化
else if (obj instanceof Serializable) {
    writeOrdinaryObject(obj, desc, unshared);
} else {
    // 如果对象不能被序列化,则抛出 NotSerializableException 异常
    if (extendedDebugInfo) {
        throw new NotSerializableException(
            cl.getName() + "\n" + debugInfoStack.toString());
    } else {
        throw new NotSerializableException(cl.getName());
    }
}

也就是说,ObjectOutputStream 在序列化过程中,会先判断被序列化的对象是哪一种类型,字符串?数组?枚举?还是 Serializable。如果都不是则抛出 NotSerializableException 异常

加入Woman类实现了Serializable接口,就可以成功序列化和反序列化了

/**
 * @Author: ZhangGongMing
 * @CreateTime: 2025/5/13 13:57
 * @Description:
 * @Version: 1.0
 */
@Data
public class Woman implements Serializable {

    private String name;

    private String age;
}

其内部是怎么实现的序列化和反序列化呢?

以 ObjectOutputStream 为例,他在序列化时会依次调用:

writeObject()writeObject0()writeOrdinaryObject()writeSerialData()invokeWriteObject()defaultWriteFields()

private void defaultWriteFields(Object obj, ObjectStreamClass desc) throws IOException {
    // 获取对象的类,并检查是否可以进行默认的序列化
    Class cl = desc.forClass();
    desc.checkDefaultSerialize();

    // 获取对象的基本类型字段的数量,以及这些字段的值
    int primDataSize = desc.getPrimDataSize();
    desc.getPrimFieldValues(obj, primVals);
    // 将基本类型字段的值写入输出流
    bout.write(primVals, 0, primDataSize, false);

    // 获取对象的非基本类型字段的值
    ObjectStreamField[] fields = desc.getFields(false);
    Object[] objVals = new Object[desc.getNumObjFields()];
    int numPrimFields = fields.length - objVals.length;
    desc.getObjFieldValues(obj, objVals);
    // 循环写入对象的非基本类型字段的值
    for (int i = 0; i < objVals.length; i++) {
        // 调用 writeObject0 方法将对象的非基本类型字段序列化写入输出流
        try {
            writeObject0(objVals[i], fields[numPrimFields + i].isUnshared());
        }
        // 如果在写入过程中出现异常,则将异常包装成 IOException 抛出
        catch (IOException ex) {
            if (abortIOException == null) {
                abortIOException = ex;
            }
        }
    }
}

那怎么反序列化呢?

以 ObjectInputStream 为例,他它在反序列化的时候会依次调用:

readObject()readObject0()readOrdinaryObject()readSerialData()defaultReadFields()

private void defaultReadFields(Object obj, ObjectStreamClass desc) throws IOException {
    // 获取对象的类,并检查对象是否属于该类
    Class cl = desc.forClass();
    if (cl != null && obj != null && !cl.isInstance(obj)) {
        throw new ClassCastException();
    }

    // 获取对象的基本类型字段的数量和值
    int primDataSize = desc.getPrimDataSize();
    if (primVals == null || primVals.length < primDataSize) {
        primVals = new byte[primDataSize];
    }
    // 从输入流中读取基本类型字段的值,并存储在 primVals 数组中
    bin.readFully(primVals, 0, primDataSize, false);
    if (obj != null) {
        // 将 primVals 数组中的基本类型字段的值设置到对象的相应字段中
        desc.setPrimFieldValues(obj, primVals);
    }

    // 获取对象的非基本类型字段的数量和值
    int objHandle = passHandle;
    ObjectStreamField[] fields = desc.getFields(false);
    Object[] objVals = new Object[desc.getNumObjFields()];
    int numPrimFields = fields.length - objVals.length;
    // 循环读取对象的非基本类型字段的值
    for (int i = 0; i < objVals.length; i++) {
        // 调用 readObject0 方法读取对象的非基本类型字段的值
        ObjectStreamField f = fields[numPrimFields + i];
        objVals[i] = readObject0(Object.class, f.isUnshared());
        // 如果该字段是一个引用字段,则将其标记为依赖该对象
        if (f.getField() != null) {
            handles.markDependency(objHandle, passHandle);
        }
    }
    if (obj != null) {
        // 将 objVals 数组中的非基本类型字段的值设置到对象的相应字段中
        desc.setObjFieldValues(obj, objVals);
    }
    passHandle = objHandle;
}

我想看到这里,第一个问题的答案是不是已经有答案了。Serializable接口之所以定义为空,是因为它只是起一个标记作用。告诉 JVM 实现了它的对象是可以被序列化的。而具体的序列化和反序列化操作并不需要它来完成

在使用 Java 自带的序列化方式时,必须要显示的使用 Serializable 接口指定具体需要执行序列化操作的类

但在实际开发中,我们很少使用 JDK 自带的序列化和反序列化。原因如下:

  • 可移植性差:Java 特有的,无法跨语言进行序列化和反序列化
  • 性能差:序列化后的字节体积大,增加了传输/保存成本
  • 安全问题:攻击者可以通过构造恶意数据来实现远程代码执行,从而对系统造成严重的安全威胁。相关阅读:Java 反序列化漏洞之殇

这也解释了为什么我的项目不使用 Serializable 指定类但是能够成功序列化?

因为我没有使用 Java 自身的序列化方式,但是还是建议按照规范在每个需要序列化的类上实现 Serializable 接口。因为你不能保证你们的系统未来不换序列化方式。加入未来换成了 Java 的序列化方式,没有 Serializable 注解会导致程序抛出异常!


2. 注意事项

使用 statictransient 修饰的字段是不会被序列化的

我们在 Woman 类中增加两个字段

/**
 * @Author: ZhangGongMing
 * @CreateTime: 2025/5/13 13:57
 * @Description:
 * @Version: 1.0
 */
@Data
public class Woman implements Serializable {

    private String name;

    private String age;

    // 不会参与序列化
    private static String address = "银河系";

    private transient String son = "张三";

    @Override
    public String toString() {
        return "Woman{" + "name=" + name + ",age=" + age + ",address=" + address + ",son=" + son + "}";
    }
}

修改测试类,在序列化之后反序列以前修改属性,观察 static 修饰的变量

/**
 * @Author: ZhangGongMing
 * @CreateTime: 2025/5/13 14:12
 * @Description:
 * @Version: 1.0
 */
public class Main {
    public static void main(String[] args) {
        Woman woman = new Woman();
        woman.setAge("18");
        woman.setName("张三");
        System.out.println(woman);

        // 序列化过程:将对象写入到文件
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("text"))) {
            oos.writeObject(woman);
        } catch (IOException e) {
            e.printStackTrace();
        }

        Woman.address = "中国";

        // 反序列化过程:从文件中读出对象
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("text"))) {
            Woman wm = (Woman) ois.readObject();
            System.out.println(wm);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

输出:

Woman{name=张三,age=18,address=银河系,son=张三}
Woman{name=张三,age=18,address=中国,son=null}

从结果的对比中,我们发现:

  1. 序列化前,address 的属性是银河系,反序列后,address 的属性被修改为中国,而不是序列化前的银河系

为什么呢?因为序列化保存的是对象的状态,而static修饰的字段属于类的状态,因此可以证明序列化并不保存static所修饰的字段

  1. 序列化前,son 的值是“张三”。反序列化,son 却为 null。这是为什么?

transient的中文释义为“临时的”。它可以阻止字段被序列化到文件中。在被反序列化后,transient字段的值被设为初始值,比如int型的初始值为 0,对象型的初始值为null

private static ObjectStreamField[] getDefaultSerialFields(Class cl) {
    // 获取该类中声明的所有字段
    Field[] clFields = cl.getDeclaredFields();
    ArrayList list = new ArrayList<>();
    int mask = Modifier.STATIC | Modifier.TRANSIENT;

    // 遍历所有字段,将非 static 和 transient 的字段添加到 list 中
    for (int i = 0; i < clFields.length; i++) {
        Field field = clFields[i];
        int mods = field.getModifiers();
        if ((mods & mask) == 0) {
            // 根据字段名、字段类型和字段是否可序列化创建一个 ObjectStreamField 对象
            ObjectStreamField osf = new ObjectStreamField(field.getName(), field.getType(), !Serializable.class.isAssignableFrom(cl));
            list.add(osf);
        }
    }

    int size = list.size();
    // 如果 list 为空,则返回一个空的 ObjectStreamField 数组,否则将 list 转换为 ObjectStreamField 数组并返回
    return (size == 0) ? NO_FIELDS :
        list.toArray(new ObjectStreamField[size]);
}

看到Modifier.STATIC | Modifier.TRANSIENT了吧,这两个修饰符标记的字段就没有被放入要序列化的字段列表中


3. serialVersionUID 的作用

我们在实现了 Serializable 接口后,IDEA 往往会提示我们定义一个serialVersionUID。那这个 serialVersionUID到底是什么呢?

serialVersionUID被称为序列化 ID。它是决定 Java 对象能否反序列化成功的重要因子。在反序列化时,JVM 会把字节流中的serialVersionUID与被序列化类中的serialVersionUID进行比较,如果相同则可以进行反序列化,否则就会抛出序列化版本不一致的异常

生成序列化 ID 我们常见的有以下三种方式:

  1. 添加一个默认版本的序列化 ID
private static final long serialVersionUID = 1L。
  1. 添加一个随机生成的不重复的序列化 ID
private static final long serialVersionUID = -2095916884810199532L;
  1. 使用@SuppressWarnings注解
@SuppressWarnings("serial")

比较常见的其实是前两种,第三种可能比较少见:使用@SuppressWarnings("serial")注解时,该注解会为被序列化类自动生成一个随机的序列化 ID

Java 虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,还有一个非常重要的因素就是序列化 ID 是否一致

也就是说,如果没有特殊需求,采用默认的序列化 ID(1L)就可以,这样可以确保代码一致时反序列化成功

这样,开篇提到的第三个问题也得到了很好的解释。总结下其实serialVersionUID只是 JVM 在反序列时进行版本一致性判断的属性

你可能感兴趣的:(实用开发技术,php,开发语言)