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

寻非 2020年03月19日 883次浏览

本系列均取自《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,  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()将 prices 序列化成字节数组后保存,然后通常在序列化数组的长度和字节会好一些。不过这个代码示例要清楚一点,且简单 writeObject() 中调用 defaultWriteObject() 进行序列化。(事实上,如果可以定制序列化,将 zippedPrices 数组变成 transient 直接一些也更好。)在反序列化时,操作反过来执行。

如果目标是序列化成字节流(就像原先的示例代码一样),这就是个糟糕的提议。这并不令人惊奇,因为压缩字节所需的时间大大超过了写入本地字节数组的时间。

场景序列化时间(秒)反序列化时间(秒)数据大小(字节)
无压缩12.18.041170
压缩/解压缩26.812.75849
仅压缩26.80.4945849

表中最有趣的是最后一行。在该轮测试中,数据在发送前进行了压缩,但 readObject() 并没有调用 unzipPrices(),而是依据需要,在客户端首次调用 getPrice() 时才调用该方法。 readObject() 不再调用 unzipPrices() 后,就只有几个 BigDecimal 对象需要反序列化,速度非常快。

在这个例子中,很可能会出现客户端永远不需要实际的股票价格的情况:客户端可能只需要调用 getHighPrice() 和类似的方法获取合计数据。如果所有方法都是只在需要时获取数据,那么延迟解压价格数据信息就能节省大量时间。如果对象可能需要持久化,延迟解压也会有用(比如,备份 HTTP 会话状态,以防应用服务器失败)。延迟解压既节约 CPU 时间(因为跳过了解压),也节约内存(因为压缩后的数据需要的内存空间更小)。

所以,即便应用在高速局域网络中运行——尤其当目标是节约内存而不是时间时——对序列化数据进行压缩并延迟解压也仍然很有用。

如果序列化是为了在网络中传输,那任何数据压缩都会有益处。下表同样是对 10 000 个股票对象进行序列化,不过这次它将数据传向了另一个进程。这个进程可以是在同一个机器上,也可以在通过宽带连接访问的其他机器上。

对象同一机器(秒)宽带广域网(秒)
无压缩30.1150.1
压缩 / 解压缩41.354.3
只压缩28.044.1

同一机器上的两个进程之间的网络通信是最快的——虽然通信数据会发送到操作系统层,但压根不用通过网络。即便在这种情况下,压缩数据和延迟解压的性能仍然是最快的(至少在这个测试中是如此——但小数据量还是会有所衰退)。可以预料的是,一旦网络速度比较慢,传输数据又有数量级上的差别,总的耗费时间就会有巨大的差别。

追踪对象复制

本节先介绍一个示例,如何不对对象引用进行序列化,以避免在反序列化时处理对象引用。然而,writeObject() 中最有力的优化是不重复输出对象引用。在 StockPriceHistoryImpl 中,这意味着不重复输出 prices map。因为示例采用标准 JDK 中的 map,JDK 的类已经对数据的序列化进行了优化,所以我们不用担心。不过,了解这些类如何进行优化、理解哪些可能的优化都是有益处的。

StockPriceHistoryImpl 中的关键结构是 TreeMap。下图是一个简化版本的 map。JVM 默认先序列化 Node A 的原生数据字段,然后递归调用 Node B 的 writeObject()(接着是 Node C)。Node B 也会序列化它自己的原生数据字段,然后递归序列化它上级 Node 的字段。

简单的 TreeMap 结构图

但是请注意——Node B 上级节点 Node A 已经被序列化,怎么办?对象序列化的代码很智能:它会意识到这一点,并且不会再次序列化 Node A 的数据。相反,它只会在先前序列化的数据中添加一个对象引用。

追踪上一级对象从而递归所有对象,会对序列化的性能有少许影响。但正如 Point 数组的例子所示,这是无法避免的:必须追踪上一级序列化的对象以便正确恢复对象引用。不过,可以通过压缩对象引用来进行智能优化,从而在对象反序列化时易于重建。

不同的集类处理这个问题的方式有所不同。比如 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 并没有特别的优点:最终影响性能的是数据量的大小。

Java EE网络API

我们前面介绍过的几种数据交换技术——XML 解析、JSON 处理和对象序列化——可以在不同的应用中使用,但主要是在 JAX-WS、JAX- RS 和 IIOP/RMI 这三个 Java EE 网络 API 中使用。

这些 API 的协议差别很大,特性也有很大的不同。你在决定为何使用它们以及何时使用它们时,考虑的首要因素就是它们的特性。关于它们有许多争论,比如 JAX-RS 是否比 JAX-WS 快,不过这些争论都假设有这样一个通用型应用,这个应用可以用两种框架编写。如果确定需要安全特性,那就应该选择 JAX-WS,而不管它与 JAX-RS 相比性能如何。如果应用必须和已有的输出 IIOP 接口的服务器通信,那么选择也就显而易见了。

但对于这几种网络 API 来说,它们需要克服的性能问题类似。本节讨论其中的一些困难以及如何处理这些困难。

调整传输数据的大小

影响这些技术性能的首要因素是数据交换,这是为什么本章花那么多时间讨论它的原因之一。传输的数据量应该尽量小,无论是压缩或去冗,或者其他技术。

另一方面,发起一次网络调用会有很显著的网络开销。设计网络 API 时,应该设计成“粗粒度”的——也就是说,最好一次调用返回大量数据,使得客户端发起的网络调用总数最小。这个原则与减少数据交换量相违背,所以必须做些权衡。

可以在前面股票 RESTful Web 服务平均响应时间的测试中观察到这种平衡。可以将服务设计成只返回一段时期内的基本数据(最高价、最低价、平均价和标准差),也可以设计成在返回这些基本数据的同时再加上这期间每天的日数据。

如果预先知道客户端要如何使用数据,就能很容易且精确地知道会返回哪些数据。但是,实际情况并不总是这样。在这个例子中,比如说客户端请求某只股票 5 年的历史数据,那开始时客户端应用只会向用户展示概要数据。如果用户想深入了解数据,查看单个股票的日数据,那会发生什么?是否所有数据都应该在第一次调用中返回给客户端,使得查看详情时无需再次发起网络调用?是否应该只返回概要数据,而在用户想要了解某年详情时,程序才再次发起调用获得那年的日数据?是否第二次调用应该获取整个 5 年的历史数据,即便此时用户只想看第三年的数据?

为了确定此处该用哪种策略,可以比较一下返回全部数据的时间和多次网络调用的时间。

(1) 客户端请求 1 年的数据。

(2) 客户端请求 1 年的概要数据。

(3) 客户端请求 5 年的数据。

(4) 客户端发起 2 次请求:先请求概要数据,然后进一步请求特定时间的详细数据。

(5) 客户端发起 10 个请求:1 个概要请求,加上 9 个特定时间详细数据的请求。

场景平均响应时间(毫秒)数据量(字节)
1 年数据9030 K
1 年概要数据3060
5 年数据300186 K
2 次概要请求602 次调用;每次 60
10 次概要请求28010 次调用;每次 60

获取一整年完整数据的时间并不比只获取概要数据长太多,所以如果用户只需要获取其中的三个数据片段,那一次性返回整个数据集总是更好的选择。

不过 5 年的概要数据有点不同:数据编组和发送所需的时间要长得多,所以在总时间达到平等前,用户需要发起 11 个获取详情的请求。

本例中的时间包括将 RESTful 服务返回的 JSON 数据编组的时间,这个时间取决于数据有多少年。但是多个客户端可能会请求相同的数据集,这种情况可以重用之前编好组的数据。如果编好组的数据已经计算好,响应时间的拐点就会有很大的不同。

场景平均响应时间(毫秒)
1 年数据50
1 年概要数据30
5 年数据90
2 次概要请求60
10 次概要请求270

由于调用一次概要数据的开销基本不变,所以概要数据的响应时间差别很小。而获取 1 年和 5 年完整数据的时间只是在传输数据上,明显少于之前数据需要计算和编组的情况。一般来说,可以返回给客户端大量可能并不需要的数据,而没有太多性能上的损耗。

网络已今非昔比

虽然我看起来不太像老顽固,但我确实曾经用 300 波特的调制解调器连接过远程网络。(幸运的是,我不必穿过 4 英尺厚的雪步行 5 英里到学校。)对于这种情况,传统上的偏见会认为有许多的网络调用。

如今,我打开浏览器,每次敲入一些字符时,都会调用远程的 Google 服务器,然后它会返回给我一些文字,辅助我进行一些搜索(ajax)。Google 已经算出,完成调用大约需要 200 毫秒或更少,这种延迟时间并不会引起我的注意。

Java EE优化小结

Java EE 应用的性能依赖于好几个因素。其中应用代码的质量永远是最主要的。而且,因为用到了许多外部资源,所以应用服务器的性能瓶颈通常并不在 Java 层。

许多影响应用服务器性能的因素并不只是针对 Java EE——尤其是线程的性能、对象池和网络性能。对应用服务器而言,最重要的影响因素是它传输或处理的数据量——无论是简单的 HTML,还是 XML、序列化对象状态或 JSON 等。本章列出的实践有助于你最大限度地发挥可用资源对应用服务器的作用。