10.5 对象序列化
不同系统间的数据交换可以使用 XML、JSON 和其他基于文本的格式。Java 进程间交换数据,通常就是发送序列化后的对象状态。尽管序列化在 Java 中随处可见,但 Java EE 中还有两点需要重点考虑。
Java EE 服务器间的 EJB 调用——远程 EJB 调用——通过序列化交换数据。
HTTP 会话状态通过对象序列化的方式来保存,这让 HTTP 会话可以高可用。
JDK 提供了默认的序列化对象机制,以实现 Serializable 或 Externalizable 接口。实际上,默认序列化的性能还有提升的空间,但此时进行过早的优化的确不太明智。特定的序列化和反序列化代码需要很多时间编写,而且也比默认的序列化代码更难维护。编写正确的序列化代码会有一些棘手,试图优化代码也会增加出错的风险。
10.5.1 transient字段
一般来说,序列化的数据越少,改进性能所需的代价就越少。将字段标为 transient,默认就不会序列化了。类可以提供特定的 writeObject() 和 readObject() 以处理这些数据。如果不需要这些数据,简单地将它标记为 transient 就足够了。
10.5.2 覆盖默认的序列化
writeObject() 和 readObject() 可以全面控制数据的序列化。正所谓“权力越多,责任越大”:序列化很容易出错。
为了了解序列化优化的困难性,以一个表示位置的简单对象 Point 为例:
public class Point implements Serializable {private int x;private int y;...}
在我的机器上,100 000 个这样的对象可以在 133 毫秒内序列化,在 741 毫秒内反序列化。但即便像这么简单的对象,性能——即便非常困难——也能改善。
public class Point implements Serializable {private transient int x;private transient int y;....private void writeObject(ObjectOutputStream oos) throws IOException {oos.defaultWriteObject();oos.writeInt(x);oos.writeInt(y);}private void readObject(ObjectInputStream ois)throws IOException, ClassNotFoundException {ois.defaultReadObject();x = ois.readInt();y = ois.readInt();}}
在我机器上序列化 100 000 个这样的对象仍然要花费 132 毫秒,但反序列化只需要 468 毫秒——改善了 30%。如果简单对象的反序列化占用了相当大一部分程序运行的时间,像这样优化就比较有意义。然而请当心,这会使得代码难以维护,因为字段被添加、移除了,等等。
到目前为止,代码更为复杂了,但功能上依然正确(且更快)。注意,将此技术应用到一般场景时务必要谨慎:
public class TripHistory implements Serializable {private transient Point[] airportsVisited;....// 注意,这段代码不正确!private void writeObject(ObjectOutputStream oos) throws IOException {oos.defaultWriteObject();oos.writeInt(airportsVisited.length);for (int i = 0; i < airportsVisited.length; i++) {oos.writeInt(airportsVisited[i].getX());oos.writeInt(airportsVisited[i].getY());}}private void readObject(ObjectInputStream ois)throws IOException, ClassNotFoundException {ois.defaultReadObject();int length = ois.readInt();airportsVisited = new Point[length];for (int i = 0; i < length; i++) {airportsVisited[i] = new Point(ois.readInt(), ois.readInt();}}}
此处的字段 airportsVisited 是表示我出发或到达的所有机场的数组,按照我离开或到达它们的顺序排列。有些机场,像 JFK,在数组中出现得比较频繁,SYD(目前)只出现过一次。
由于序列化对象引用的代价比较昂贵,所以上述代码要比默认的数组序列化机制快:在我的机器上,100 000 个 Point 对象的数组序列化用时 4.7 秒,反序列化用时 6.9 秒。上述“优化”使得序列化只用了 2 秒,反序列化只用了 1.7 秒。
然而这段代码是不正确的。指定 JFK 位置的数组引用都指向相同的对象。这意味着,如果发现数据不正确而更改单个 JFK,那数组中的所有引用都会受到影响(因为它们引用的是相同的对象)。
用上述代码反序列化数组时,这些 JFK 引用就会变为独立的、不同的对象。当某个对象更改时,就只有它发生改变,结果它的数据就不同于其他那些表示 JFK 的对象了。
这条原则非常重要,应该铭记于心,因为序列化的调优常常就是如何对对象的引用进行特殊处理。做对了,序列化的性能可以获得极大提升;做错了,就会引入不易察觉的 bug。
鉴于此,我们来考察一下 StockPriceHistory 的序列化,看看如何优化序列化。以下是这个类的字段:
public class StockPriceHistoryImpl implements StockPriceHistory {private String symbol;protected SortedMap<Date, StockPrice> prices = new TreeMap<>();protected Date firstDate;protected Date lastDate;protected boolean needsCalc = true;protected BigDecimal highPrice;protected BigDecimal lowPrice;protected BigDecimal averagePrice;protected BigDecimal stdDev;private Map<BigDecimal, ArrayList<Date>> histogram;....public StockPriceHistoryImpl(String s, Date firstDate, Date lastDate) {prices = ....}}
当以给定标志 s 构造 StockPriceHistoryImpl 对象时,会创建和存储 SortedMap 类型的变量 prices,键值为 start 和 end 之间的所有股票价格的时间。构造函数也设置保存了 firstDate 和 lastDate。除此之外,构造函数没有设置任何其他字段,它们都是延迟初始化。当调用这些字段的 getter 方法时,getter 会检查 needsCalc 是否为真。如果为真,就会立即计算这些字段的值。
计算包括创建 histogram,它记录了该股票特定的收盘价出现在哪些天。histogram 包含的 BigDecimal 和 Date 对象的数据与 prices 中的相同,只是看待数据的方式不同。
所有的延迟加载字段都可以由 prices 数组计算得来,所以它们都可以标记为 transient,并且在序列化和反序列化时不需要为它们做额外的工作。这个例子比较简单,因为代码已经完成了字段的延迟初始化,因此在接收数据时,可以一直延迟初始化。即便字段要即刻初始化,也仍然可以将可计算字段标记为 transient,而在 readObject() 方法中重新计算它们的值。
注意,上述做法也维护了 prices 和 histogram 对象之间的关系:重新计算 histogram 时,会将已存在的对象塞到新的 map 中。
这种做法在绝大多数情况下都能收到优化效果,但有时也会降低性能。表 10-11 就是这种情况,该表显示了 histogram 对象有无 transient 字段时进行序列化和反序列化所花费的时间,以及序列化数据的大小。
表10-11:序列化和反序列化对象(关于transient字段)所用的时间
| 对象 | 序列化时间(秒) | 反序列化时间(秒) | 数据大小(字节) |
|---|---|---|---|
没有 transient 字段
| 12.8 | 11.9 | 46 969 |
histogram 为 transient
| 11.5 | 0.1 | 40 910 |
目前来看,这个例子中的对象序列化和反序列化节约了大约 15% 的时间。但这个测试实际上没有在接收时重建 histogram 对象:对象只有在接收数据的代码首次对其进行访问时才会创建。
有些时候并不需要 histogram 对象:客户端可能只关心特定日子里的股价,而不是整个 histogram。还有一些不常见的情况,比如如果总是需要 histogram,且测试中计算所有的 histogram 用时超过了 3.1 秒,那么延迟初始化字段就确实会导致性能下降。
在这个例子中,计算 histogram 并不属于这种情况——这是一种非常快的操作。一般来说,重新计算数据片段的代价很少会高于序列化和反序列化数据。但在代码优化时仍然需要考虑。
这个测试实际上并不向系统外传播数据,只是在预先分配的字节数组中写数据和读数据,所以它只是衡量了序列化和反序列化所用的时间。另外,histogram 字段标为 transient 也减少了 13% 的数据大小。通过网络传送数据时,这就变得非常重要了。
10.5.3 压缩序列化数据
上述两种方法引出了改善序列化代码性能的第 3 种方法:数据序列化之后再进行压缩,使得它可以更快地在慢速网络上传输。StockPriceHistoryCompress 在序列化时对 prices 进行了压缩:
public class StockPriceHistoryCompressimplements StockPriceHistory, Serializable {private byte[] zippedPrices;private transient SortedMap<Date, StockPrice> prices;private void writeObject(ObjectOutputStream out)throws IOException {if (zippedPrices == null) {makeZippedPrices();}out.defaultWriteObject();}private void readObject(ObjectInputStream in)throws IOException, ClassNotFoundException {in.defaultReadObject();unzipPrices();}protected void makeZippedPrices() throws IOException {ByteArrayOutputStream baos = new ByteArrayOutputStream();GZIPOutputStream zip = new GZIPOutputStream(baos);ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(zip));oos.writeObject(prices);oos.close();zip.close();zippedPrices = baos.toByteArray();}protected void unzipPrices()throws IOException, ClassNotFoundException {ByteArrayInputStream bais = new ByteArrayInputStream(zippedPrices);GZIPInputStream zip = new GZIPInputStream(bais);ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(zip));prices = (SortedMap<Date, StockPrice>) ois.readObject();ois.close();zip.close();}}
makeZippedPrices()2 将 prices 序列化成字节数组后保存,然后通常在序列化数组的长度和字节会好一些。不过这个代码示例要清楚一点,且简单 writeObject() 中调用 defaultWriteObject() 进行序列化。(事实上,如果可以定制序列化,将 zippedPrices 数组变成 transient 直接一些也更好。)在反序列化时,操作反过来执行。
2原文是“zipPrices”,代码中并没有此方法。——译者注
如果目标是序列化成字节流(就像原先的示例代码一样),这就是个糟糕的提议。这并不令人惊奇,因为压缩字节所需的时间大大超过了写入本地字节数组的时间。参见表 10-12。
表10-12:10 000个对象序列化和反序列化、带压缩和不带压缩时所用时间的对比
| 场景 | 序列化时间(秒) | 反序列化时间(秒) | 数据大小(字节) |
|---|---|---|---|
| 无压缩 | 12.1 | 8.0 | 41170 |
| 压缩/解压缩 | 26.8 | 12.7 | 5849 |
| 仅压缩 | 26.8 | 0.494 | 5849 |
表中最有趣的是最后一行。在该轮测试中,数据在发送前进行了压缩,但 readObject() 并没有调用 unzipPrices(),而是依据需要,在客户端首次调用 getPrice() 时才调用该方法。 readObject() 不再调用 unzipPrices() 后,就只有几个 BigDecimal 对象需要反序列化,速度非常快。
在这个例子中,很可能会出现客户端永远不需要实际的股票价格的情况:客户端可能只需要调用 getHighPrice() 和类似的方法获取合计数据。如果所有方法都是只在需要时获取数据,那么延迟解压价格数据信息就能节省大量时间。如果对象可能需要持久化,延迟解压也会有用(比如,备份 HTTP 会话状态,以防应用服务器失败)。延迟解压既节约 CPU 时间(因为跳过了解压),也节约内存(因为压缩后的数据需要的内存空间更小)。
所以,即便应用在高速局域网络中运行——尤其当目标是节约内存而不是时间时——对序列化数据进行压缩并延迟解压也仍然很有用。
如果序列化是为了在网络中传输,那任何数据压缩都会有益处。表 10-13 同样是对 10 000 个股票对象进行序列化,不过这次它将数据传向了另一个进程。这个进程可以是在同一个机器上,也可以在通过宽带连接访问的其他机器上。
表10-13:10 000个对象的网络传输时间对比
| 对象 | 同一机器(秒) | 宽带广域网(秒) |
|---|---|---|
| 无压缩 | 30.1 | 150.1 |
| 压缩 / 解压缩 | 41.3 | 54.3 |
| 只压缩 | 28.0 | 44.1 |
同一机器上的两个进程之间的网络通信是最快的——虽然通信数据会发送到操作系统层,但压根不用通过网络。即便在这种情况下,压缩数据和延迟解压的性能仍然是最快的(至少在这个测试中是如此——但小数据量还是会有所衰退)。可以预料的是,一旦网络速度比较慢,传输数据又有数量级上的差别,总的耗费时间就会有巨大的差别。
10.5.4 追踪对象复制
本节先介绍一个示例,如何不对对象引用进行序列化,以避免在反序列化时处理对象引用。然而,writeObject() 中最有力的优化是不重复输出对象引用。在 StockPriceHistoryImpl 中,这意味着不重复输出 prices map。因为示例采用标准 JDK 中的 map,JDK 的类已经对数据的序列化进行了优化,所以我们不用担心。不过,了解这些类如何进行优化、理解哪些可能的优化都是有益处的。
StockPriceHistoryImpl 中的关键结构是 TreeMap。图 10-2 是一个简化版本的 map。JVM 默认先序列化 Node A 的原生数据字段,然后递归调用 Node B 的 writeObject()(接着是 Node C)。Node B 也会序列化它自己的原生数据字段,然后递归序列化它上级 Node 的字段。
但是请注意——Node B 上级节点 Node A 已经被序列化,怎么办?对象序列化的代码很智能:它会意识到这一点,并且不会再次序列化 Node A 的数据。相反,它只会在先前序列化的数据中添加一个对象引用。
追踪上一级对象从而递归所有对象,会对序列化的性能有少许影响。但正如 Point 数组的例子所示,这是无法避免的:必须追踪上一级序列化的对象以便正确恢复对象引用。不过,可以通过压缩对象引用来进行智能优化,从而在对象反序列化时易于重建。

图 10-2:简单的 TreeMap 结构图
不同的集类处理这个问题的方式有所不同。比如 TreeMap,它只是遍历树然后序列化键值,丢弃了键之间的所有关系(也就是它们的排列顺序)。在反序列化时,readObject() 会重新排列数据并生成树。虽然排序对象听起来代价很昂贵,但实际并非如此:对 10 000 只股票而言,整个过程要比默认的序列化快 20%,默认机制需要追踪所有的对象引用。
需要序列化的对象减少了,因此 TreeMap 也能从优化中获益。map 中的 Node(在 JDK 中为 Entry)包含两个对象:键和值。由于 map 不会包含两个相同的 Node,所以序列化保留 Node 的对象引用时不用担心。在这种情况下,它不会序列化 Node 对象本身,而是直接序列化键和值。所以最终的 writeObject() 看起来像这样(为便于阅读,代码作了调整):
private void writeObject(ObjectOutputStream oos) throws IOException {....for (Map.Entry<K,V> e : entrySet()) {oos.writeObject(e.getKey());oos.writeObject(e.getValue());}....}
这段代码看起来与 Point 示例中的那段不能正常工作的代码非常像。差别在于该段代码会序列化相同的对象。TreeMap 不会有两个相同的 Node,所以没有必要序列化 Node 引用。TreeMap 可以有相同的值,所以值必须序列化成对象引用。
这就回到了起点:正如我在本节开头所说的,正确优化对象序列化非常困难。但当对象序列化成为应用的主要瓶颈时,恰当地进行优化可以带来很大的益处。
关于
Externalizable接口本节没有讨论另一种优化对象序列化的方法,即实现
Externalizable接口而不是Serializable接口。实际上,这两个接口的差别在于它们如何处理非
transient字段。当writeObject()调用defaultWriteObject()时,Serializable会序列化非transient字段。但Externalizable没有这样的方法。Externalizable类必须显式序列化所有关注的字段,无论transient与否。即便一个对象中的所有字段都是
transient,也最好实现Serializable接口,并调用defaultWriteObject()方法。这使得代码在添加(移除)字段时更容易维护。从性能的角度来看,Externalizable并没有特别的优点:最终影响性能的是数据量的大小。
快速小结
1. 数据的序列化,特别是 Java EE 中的序列化,有可能是很大的性能瓶颈。
2. 将变量标记为
transient可以加快序列化,并减少传输的数据量。这些做法都可以极大地提高性能,除非接收方重建数据需要花费很长时间。3. 其他
writeObject()和readObject()方法的优化也可以显著加快序列化。但请小心,因为这容易出错,而且不留神就会引入不易察觉的 bug。4. 通常在序列化时进行压缩都有益处,即便数据不在慢速网络上传输。
