Java性能调优17——Java EE 性能调优(下)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

对象序列化

不同系统间的数据交换可以使用 XML、JSON 和其他基于文本的格式。Java 进程间交换数据,通常就是发送序列化后的对象状态。尽管序列化在 Java 中随处可见,但 Java EE 中还有两点需要重点考虑。

Java EE 服务器间的 EJB 调用——远程 EJB 调用——通过序列化交换数据。

HTTP 会话状态通过对象序列化的方式来保存,这让 HTTP 会话可以高可用。

JDK 提供了默认的序列化对象机制,以实现 Serializable 或 Externalizable 接口。实际上,默认序列化的性能还有提升的空间,但此时进行过早的优化的确不太明智。特定的序列化和反序列化代码需要很多时间编写,而且也比默认的序列化代码更难维护。编写正确的序列化代码会有一些棘手,试图优化代码也会增加出错的风险。

transient字段

一般来说,序列化的数据越少,改进性能所需的代价就越少。将字段标为 transient,默认就不会序列化了。类可以提供特定的 writeObject() 和 readObject() 以处理这些数据。如果不需要这些数据,简单地将它标记为 transient 就足够了。

覆盖默认的序列化

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 中。

这种做法在绝大多数情况下都能收到优化效果,但有时也会降低性能。下表就是这种情况,该表显示了 histogram 对象有无 transient 字段时进行序列化和反序列化所花费的时间,以及序列化数据的大小。

对象序列化时间(秒)反序列化时间(秒)数据大小(字节)
没有 transient 字段12.811.946 969
histogram 为 transient11.50.140 910

目前来看,这个例子中的对象序列化和反序列化节约了大约 15% 的时间。但这个测试实际上没有在接收时重建 histogram 对象:对象只有在接收数据的代码首次对其进行访问时才会创建。

有些时候并不需要 histogram 对象:客户端可能只关心特定日子里的股价,而不是整个 histogram。还有一些不常见的情况,比如如果总是需要 histogram,且测试中计算所有的 histogram 用时超过了 3.1 秒,那么延迟初始化字段就确实会导致性能下降。

在这个例子中,计算 histogram 并不属于这种情况——这是一种非常快的操作。一般来说,重新计算数据片段的代价很少会高于序列化和反序列化数据。但在代码优化时仍然需要考虑。

这个测试实际上并不向系统外传播数据,只是在预先分配的字节数组中写数据和读数据,所以它只是衡量了序列化和反序列化所用的时间。另外,histogram 字段标为 transient 也减少了 13% 的数据大小。通过网络传送数据时,这就变得非常重要了。

压缩序列化数据

上述两种方法引出了改善序列化代码性能的第 3 种方法:数据序列化之后再进行压缩,使得它可以更快地在慢速网络上传输。StockPriceHistoryCompress 在序列化时对 prices 进行了压缩:

public class StockPriceHistoryCompress
        implements 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,  Class