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,记得使用一个 BufferedInputStream 或 BufferedOutputStream 来包装底层的文件流。对于使用字符(字符串)数据的文件 I/O,记得使用一个 BufferedReader 或 BufferedWriter 来包装底层的流。
在探讨文件 I/O 时,这一性能问题很好理解,不过它几乎存在于所有类型的 I/O 中。从 Socket 返回的流(通过 getInputStream() 或 getOutputStream())是以同样的方式运作的,在 Socket 上每次读写一个字节的 I/O 操作相当慢。所以同样要记得正确地使用一个缓冲过滤器流来包装一下。
在使用 ByteArrayInputStream 和 ByteArrayOutputStream 类时,微妙的问题更多。首先,这些类基本上就是大的内存缓冲区。在很多情况下,用缓冲管理器流包装它们,意味着数据会被复制两次:一次是缓冲在过滤器流中,一次是缓冲在 ByteArrayInputStream 中(输出流的情况相反)。除非还设计了其他流,否则这种情况下应该避免缓冲式 I/O。
当涉及其他过滤器流时是否要用缓冲,这个问题就更复杂了。有种常见的情况是使用这些流来序列化或反序列化数据。比如,第 10 章就探讨了显式地管理类的数据序列化时的各种性能得失。在那一章中,有一个简化版的 writeObject() 方法,如下所示:
private void writeObject(ObjectOutputStream out) throws IOException {if (prices == null) {makePrices();}out.defaultWriteObject();}protected void makePrices() throws IOException {ByteArrayOutputStream baos = new ByteArrayOutputStream();ObjectOutputStream oos = new ObjectOutputStream(baos);oos.writeObject(prices);oos.close();}
在这种情况下,如果将 baos 流包装在一个 BufferedOutputStream 中,因为多了一次数据复制,所以会有性能损失。
在这个例子中,将 prices 数组中的数据压缩一下,效率会更高,而代码就变成了下面这样:
private void writeObject(ObjectOutputStream out) throws IOException {if (prices == null) {makeZippedPrices();}out.defaultWriteObject();}protected void makeZippedPrices() throws IOException {ByteArrayOutputStream baos = new ByteArrayOutputStream();GZIPOutputStream zip = new GZIPOutputStream(baos);BufferedOutputStream bos = new BufferedOutputStream(zip);ObjectOutputStream oos = new ObjectOutputStream(bos);oos.writeObject(prices);oos.close();zip.close();}
现在,缓冲数据流就是必要的了,因为 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 倍。
快速小结
1. 围绕缓冲式 I/O 有一些很常见的问题,这是由简单输入输出流类的默认实现引发的。
2. 文件和 Socket 的 I/O 必须正确地缓冲,对于像压缩和字符串编解码等内部操作,也是如此。
快速小结