12.1 缓冲式I/O

在我于 2000 年加入 Java 性能团队(Java Performance Group)时,我的老板刚出版了一本探讨 Java 性能的书,这是该领域有史以来的第一本书。缓冲式 I/O 是当时最热的话题之一。14 年过去了,我原以为这是老生常谈的话题,准备将其去掉。然而就在我着手编写本书大纲的那周,在两个毫无关联的项目中,我发现了一个问题:非缓冲式 I/O 对性能影响很大。又是几个月之后,在为本书准备例子时,我抓耳挠腮地想,为什么我的“优化”会如此之慢呢?然后我意识到:真蠢,忘记正确地缓冲 I/O 了。

下面就来谈一下缓冲式 I/O 的性能。InputStream.read()OutputStream.write() 方法操作的是一个字符。由于所访问资源不同,这些方法有可能非常慢。而在 fileInputStream 上调用 read() 方法,更是慢得难以形容:每次调用该方法,都要进入内核,去取一个字节的数据。在大多数操作系统上,内核都会缓冲 I/O,因此,很幸运,该场景不会在每次调用 read() 方法时触发一次磁盘读取操作。但是这种缓冲保存在内核中,而非应用中,这就意味着每次读取一个字节时,每个方法调用还是会涉及一次代价高昂的系统调用。

写数据也是如此:使用 write() 方法向 fileOutputStream 发送一个字节,也需要一次系统调用,将该字节存储到内核缓冲区中。最后(当文件关闭或刷新时),内核会把缓冲区中的内容写入磁盘。

对于使用二进制数据的文件 I/O,记得使用一个 BufferedInputStreamBufferedOutputStream 来包装底层的文件流。对于使用字符(字符串)数据的文件 I/O,记得使用一个 BufferedReaderBufferedWriter 来包装底层的流。

在探讨文件 I/O 时,这一性能问题很好理解,不过它几乎存在于所有类型的 I/O 中。从 Socket 返回的流(通过 getInputStream()getOutputStream())是以同样的方式运作的,在 Socket 上每次读写一个字节的 I/O 操作相当慢。所以同样要记得正确地使用一个缓冲过滤器流来包装一下。

在使用 ByteArrayInputStreamByteArrayOutputStream 类时,微妙的问题更多。首先,这些类基本上就是大的内存缓冲区。在很多情况下,用缓冲管理器流包装它们,意味着数据会被复制两次:一次是缓冲在过滤器流中,一次是缓冲在 ByteArrayInputStream 中(输出流的情况相反)。除非还设计了其他流,否则这种情况下应该避免缓冲式 I/O。

当涉及其他过滤器流时是否要用缓冲,这个问题就更复杂了。有种常见的情况是使用这些流来序列化或反序列化数据。比如,第 10 章就探讨了显式地管理类的数据序列化时的各种性能得失。在那一章中,有一个简化版的 writeObject() 方法,如下所示:

  1. private void writeObject(ObjectOutputStream out) throws IOException {
  2. if (prices == null) {
  3. makePrices();
  4. }
  5. out.defaultWriteObject();
  6. }
  7. protected void makePrices() throws IOException {
  8. ByteArrayOutputStream baos = new ByteArrayOutputStream();
  9. ObjectOutputStream oos = new ObjectOutputStream(baos);
  10. oos.writeObject(prices);
  11. oos.close();
  12. }

在这种情况下,如果将 baos 流包装在一个 BufferedOutputStream 中,因为多了一次数据复制,所以会有性能损失。

在这个例子中,将 prices 数组中的数据压缩一下,效率会更高,而代码就变成了下面这样:

  1. private void writeObject(ObjectOutputStream out) throws IOException {
  2. if (prices == null) {
  3. makeZippedPrices();
  4. }
  5. out.defaultWriteObject();
  6. }
  7. protected void makeZippedPrices() throws IOException {
  8. ByteArrayOutputStream baos = new ByteArrayOutputStream();
  9. GZIPOutputStream zip = new GZIPOutputStream(baos);
  10. BufferedOutputStream bos = new BufferedOutputStream(zip);
  11. ObjectOutputStream oos = new ObjectOutputStream(bos);
  12. oos.writeObject(prices);
  13. oos.close();
  14. zip.close();
  15. }

现在,缓冲数据流就是必要的了,因为 GZIPOutputStream 处理一块数据比处理一个字节的数据更高效。在上面这两种情况下,ObjectOutputStream 都会将单字节数据发送给下一个流。如果下一个流就是最终目的地,比如 ByteArrayOutputStream 这种情况,则无需缓冲。而如果中间还有另一个过滤器流(比如这个例子中的 GZIPOutputStream),则缓冲往往是必要的。

到底何时需要在两个不同的流中间插入一个缓冲流,并没有一个统一的规则。这最终取决于所用流的类型,但是在多数情况下,操作一块数据(来自缓冲的流)通常要好于操作一系列单个字节(来自 ObjectOutputStream)。

同样的情况也适用于输入流。举个具体的例子,GZIPInputStream 操作一块字节数据更高效;一般情况下,对于插入在 ObjectInputStream 和原始的字节数据源之间的流,如果配合一块数据,其表现会更好。

注意,这种情况特别适用于编解码器流。当在字节和字符之间转换时,操作尽可能大的一段数据,性能最佳。如果提供给编解码器的是单个的字节或字符,性能会很差。

郑重声明,我在编写压缩这个例子时犯过的错误就是没有缓冲 gzip 流。如表 12-1 中的数据所示,这个错误代价很高。

表12-1:压缩情况下,序列化/反序列化10 000个对象所需时间

操作 序列化时间(秒) 反序列化时间(秒)
无缓冲压缩 / 解压缩 60.3 79.3
带缓存压缩 / 解压缩 26.8 12.7

没有正确地缓冲 I/O,性能差距多达 6 倍。

12.1 缓冲式I/O - 图1 快速小结

1. 围绕缓冲式 I/O 有一些很常见的问题,这是由简单输入输出流类的默认实现引发的。

2. 文件和 Socket 的 I/O 必须正确地缓冲,对于像压缩和字符串编解码等内部操作,也是如此。