使用记录简化序列化

TL;DR:了解如何利用 Java 记录的设计来改进 Java 序列化。


Close-up of 2 colorfull vynil records
照片由 Manuel Sardo 提供

记录类

记录类增强了 Java 的能力 以更少的仪式来建模“纯数据”聚合。记录类声明一些不可变状态,并承诺一个与该状态匹配的 API。这意味着记录类放弃了类通常享有的自由 - 将其 API 与其内部表示分离的能力 - 但作为回报,记录类变得更加简洁。记录类是 Java 14 和 15 中的 预览功能,现在已在 Java 16 中最终确定。

以下是在 JDK 的 jshell 工具中声明的记录类

jshell> record Point (int x, int y) { }
|  created record Point

Point 的状态包含两个组件,xy。这些组件是不可变的,只能通过访问器方法 x()y() 访问,这些方法在编译期间自动添加到 Point 类中。编译期间还添加了用于初始化组件的规范构造函数。对于 Point 记录类,它等效于以下内容

public Point(int x, int y) {
  this.x = x;
  this.y = y;
}

与添加到普通类的无参数 默认构造函数 不同,记录类的规范构造函数与状态具有相同的签名。(如果对象需要可变状态,或者在创建对象时未知的状态,那么记录类不是正确选择;应该声明一个普通类。)

以下是 Point 的实例化和使用方式:(我们说 pPoint 的实例,是“一个记录”)

jshell> Point p = new Point(5, 10)
p ==> Point[x=5, y=10]

jshell> System.out.println("value of x: " + p.x())
value of x: 5

总而言之,记录类的元素形成了开发人员可以依赖的简洁协议:对状态的简洁描述、用于初始化状态的规范构造函数以及对状态的受控访问。这种设计有很多好处,其中包括序列化。

序列化

序列化是将对象转换为可以存储在磁盘上或通过网络传输的格式(“序列化”、“编组”)的过程,并且可以从该格式中恢复对象(“反序列化”、“解编组”)。它提供了提取对象状态并将其转换为持久格式的机制,以及从该格式重建具有等效状态的对象的方法。鉴于它们作为纯数据载体的性质,记录非常适合这种用例。

序列化是一个强大的概念,许多框架都实现了它,其中之一是 JDK 中的 Java 对象序列化(以下简称“Java 序列化”)。在 Java 序列化中,任何实现 java.io.Serializable 接口的类都是可序列化的 - 令人惊讶地简单!该接口没有成员,仅用于将类标记为可序列化。序列化时,所有非瞬态字段的状态都会被提取(即使是私有字段)并写入序列化字节流。反序列化时,在用从序列化字节流中读取的状态填充字段之前,会调用超类的无参数构造函数来创建对象。序列化字节流的格式(“序列化形式”)由 Java 序列化选择,除非实现特殊方法 writeObjectreadObject 来指定自定义格式。

Java 序列化存在缺陷并非新闻,Brian Goetz 的 迈向更好的序列化 提供了问题空间的摘要。问题的核心是 Java 序列化不是作为 Java 对象模型的一部分设计的。这意味着 Java 序列化使用反射等后门技术与对象交互,而不是依赖对象类提供的 API。例如,可以创建新的反序列化对象而无需调用其构造函数之一,并且从序列化字节流中读取的数据不会针对构造函数不变性进行验证。

记录序列化

在 Java 序列化中,记录类与普通类一样,通过实现 java.io.Serializable 来使其可序列化

jshell> record Point (int x, int y) implements Serializable { }
|  created record Point

但是,在幕后,Java 序列化对记录(即记录类的实例)的处理方式与对普通类的实例的处理方式截然不同(这 篇文章 提供了很好的比较)。该设计旨在尽可能保持简单,并基于两个属性

  1. 记录的序列化仅基于其状态组件,以及
  2. 记录的反序列化仅使用规范构造函数。

不允许对记录的序列化过程进行任何自定义。这种方法的简单性得益于,并且是记录施加的语义约束的逻辑延续。作为不可变数据载体,记录只能具有一个状态(其组件的值),因此无需允许对序列化形式进行自定义。同样,在反序列化方面,创建记录的唯一方法是通过其记录类的规范构造函数,其参数是已知的,因为它们与状态描述相同。

回到我们的示例记录类 Point,使用 Java 序列化对 Point 对象进行序列化如下

jshell> var out = new ObjectOutputStream(new FileOutputStream("serial.data"));
out ==> java.io.ObjectOutputStream@5f184fc6

jshell> out.writeObject(new Point(5, 10));
jshell> var in = new ObjectInputStream(new FileInputStream("serial.data"));
in ==> java.io.ObjectInputStream@504bae78

jshell> in.readObject();
$5 ==> Point[x=5, y=10]

在幕后,序列化框架可以在序列化期间使用 Pointx()y() 访问器来提取 p 的组件状态,然后将其写入序列化字节流。在反序列化期间,从 serial.data 中读取字节,并将状态传递给 Point 的规范构造函数以获得新的记录。

总的来说,记录的设计自然地符合序列化的要求。状态和 API 之间的紧密耦合促进了更安全、更易于维护的实现。此外,它允许对记录的反序列化进行一些有趣的效率改进。

优化记录反序列化

对于普通类,Java 序列化严重依赖反射来设置新反序列化对象的私有状态。但是,记录类通过明确指定的公共 API 公开其状态和重建方式,Java 序列化利用了这一点。

记录类的约束性质使我们能够重新评估 Java 序列化的反射策略。如果如上所述,记录类的 API 描述了记录的状态,并且由于该状态是不可变的,因此序列化字节流不再需要成为唯一的事实来源,序列化框架也不再需要成为该事实的唯一解释器。相反,记录类可以控制其序列化形式,该形式可以从组件派生。一旦派生出序列化形式,我们就可以根据该形式提前生成匹配的“实例化器”并将其存储在记录类的 class 文件中。通过这种方式,控制权从 Java 序列化(或任何其他序列化框架)反转到记录类。记录类现在确定自己的序列化形式,它可以优化、存储并根据需要提供。

这可以通过多种方式增强记录反序列化,其中两个有趣的领域是类演化和吞吐量。

更多自由来演化记录

这方面的潜力来自记录反序列化中一个现有的明确指定的功能:对缺失流字段的默认值注入。当序列化字节流中不存在特定记录组件的值时,其默认值将传递给规范构造函数。以下示例使用记录类 Point 的演化版本演示了这一点

jshell> record Point (int x, int y, int z) implements Serializable { }
|  created record Point

在我们对上一个示例中的 Point 记录进行序列化之后,serial.data 文件包含一个 Point 的表示形式,其中只有 xy 的值,而没有 z 的值。出于兼容性原因,我们希望能够在新的 Point 声明的上下文中反序列化该原始序列化对象。由于对缺失字段值的默认值注入,这是可能的,并且反序列化成功完成

jshell> var in = new ObjectInputStream(new FileInputStream("serial.data"));
in ==> java.io.ObjectInputStream@421faab1

jshell> in.readObject();
$3 ==> Point[x=5, y=10, z=0]

此功能可以在记录序列化的上下文中加以利用。如果在反序列化期间注入默认值,那么它们是否需要在序列化形式中表示?在这种情况下,更紧凑的序列化形式仍然可以完全捕获记录对象的状态。

更一般地说,此功能还有助于支持记录类版本控制,并使序列化和反序列化总体上更能适应不同版本中记录状态的变化。与普通类相比,记录类因此更适合存储数据。

处理记录时吞吐量更高

另一个有趣的增强领域是反序列化期间的吞吐量。反序列化期间的对象创建通常需要反射 API 调用,这些调用成本高昂且难以正确执行。这两个问题可以通过使反射调用更高效以及将实例化机制封装在记录类本身中来解决。

为此,我们可以利用方法句柄动态计算的常量的强大功能。Java 7 中引入了 java.lang.invoke 中的方法句柄 API,它提供了一组用于查找、调整、组合和调用方法/设置字段的低级操作。方法句柄是一个类型化的引用,它允许对参数和返回值类型进行转换,如果使用得当,它可能比来自 Java 1.1 的传统反射更快。在我们的例子中,可以将多个方法句柄链接在一起,以根据其记录类的序列化形式定制记录的创建。

此方法处理链可以存储为记录类的 class 文件中的动态计算常量,该常量在第一次调用时延迟计算。动态计算的常量可以被 JVM 动态编译器优化,因此实例化代码只会在记录类的占用空间中增加少量开销。通过这种方式,记录类现在负责其序列化形式和实例化代码,不再依赖其他中介或框架。这种策略进一步提高了性能和代码重用。它还减轻了序列化框架的负担,序列化框架现在可以简单地使用记录类提供的反序列化策略,而无需编写复杂且可能不安全的映射机制。

结论

我们已经看到了序列化如何利用 Java 语言设计对记录施加的语义约束。从这里可以探索许多进一步的潜在优化。很明显,让记录类负责自己的序列化形式,可以让我们在记录序列化方面走得更远。