Java性能权威指南-总结21
- Java EE性能调优
- 对象序列化
- transient字段
- 覆盖默认的序列化
- 压缩序列化数据
Java EE性能调优
对象序列化
不同系统间的数据交换可以使用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 inplements Serializable {
private int x;
private int y;
...
}
在测试的机器上,100000个这样的对象可以在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();
}
}
在测试机器上序列化100000个这样的对象仍然要花费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(目前)只出现过一次。
由于序列化对象引用的代价比较昂贵,所以上述代码要比默认的数组序列化机制快:在测试的机器上,100000个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
字段时进行序列化和反序列化所花费的时间,以及序列化数据的大小。
目前来看,这个例子中的对象序列化和反序列化节约了大约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 bais = new ByteArrayOutputStream();
GZIPOutputStream zip = new GZIPOutputStream(bais);
ObjectoutputStream oos = new ObjectoutputStream(new BufferedoutputStream(zip));
oos.writeObject(prices);
oos.close();
zip.close();
zippedPrices = bais.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直接序列化数组的长度和字节会好一些。不过这个代码示例要清楚一点,且简单一些也更好。)在反序列化时,操作反过来执行。
如果目标是序列化成字节流(就像原先的示例代码一样),这就是个糟糕的提议。这并不令人惊奇,因为压缩字节所需的时间大大超过了写入本地字节数组的时间。参见下表。
10000个对象序列化和反序列化、带压缩和不带压缩时所用时间的对比
表中最有趣的是最后一行。在该轮测试中,数据在发送前进行了压缩,但readobject()
并没有调用unzipPrices()
,而是依据需要,在客户端首次调用getPrice()
时才调用该方法。readobject()
不再调用unzipPrices()
后,就只有几个BigDecimal
对象需要反序列化,速度非常快。
在这个例子中,很可能会出现客户端永远不需要实际的股票价格的情况:客户端可能只需要调用getHighPrice()
和类似的方法获取合计数据。如果所有方法都是只在需要时获取数据,那么延迟解压价格数据信息就能节省大量时间。如果对象可能需要持久化,延迟解压也会有用(比如,备份HTTP会话状态,以防应用服务器失败)。延迟解压既节约CPU时间(因为跳过了解压),也节约内存(因为压缩后的数据需要的内存空间更小)。
所以,即便应用在高速局域网络中运行——尤其当目标是节约内存而不是时间时——对序列化数据进行压缩并延迟解压也仍然很有用。如果序列化是为了在网络中传输,那任何数据压缩都会有益处。 下表同样是对10000个股票对象进行序列化,不过这次它将数据传向了另一个进程。这个进程可以是在同一个机器上,也可以在通过宽带连接访问的其他机器上。
10000个对象的网络传输时间对比
同一机器上的两个进程之间的网络通信是最快的——虽然通信数据会发送到操作系统层,但压根不用通过网络。即便在这种情况下,压缩数据和延迟解压的性能仍然是最快的(至少在这个测试中是如此——但小数据量还是会有所衰退)。可以预料的是,一旦网络速度比较慢,传输数据又有数量级上的差别,总的耗费时间就会有巨大的差别。