彻底转变流,第 1 部分
通常,Java I/O 框架用途极其广泛。同一个框架支持文件存取、网络访问、字符转换、压缩和加密等等。不过,有时它不是十分灵活。例如,压缩流允许您将数据写成压缩格式,但 它们不能让您读取压缩格式的数据。同样地,某些第三方模块被构建成写出数据,而没有考虑应用程序需要读取数据的情形。本文是两部分系列文章的第一部分, Java 密码专家和作家 Merlin Hughes 介绍了使应用程序从仅支持将数据写至输出流的源中有效读取数据的框架。
自早期基于浏览器 的 applet 和简单应用程序以来,Java 平台已有了巨大的发展。现在,我们有多个平台和概要及许多新的 API,并且还在制作的差不多有数百种之多。尽管 Java 语言的复杂程度在不断增加,但它对于日常的编程任务而言仍是一个出色的工具。虽然有时您会陷入那些日复一日的编程问题中,但偶尔您也能够回过头去,发现一 个很棒的解决方案来处理您以前曾多次遇到过的问题。
就在前几天,我想要压缩一些通过网络连接读取的数据(我以压缩格式将 TCP 数据中继到一个 UDP 套接字)。记得 Java 平台自版本 1.1 开始就支持压缩,所以我直接求助于 java.util.zip 包,希望能找到一个适合于我的解决方案。然而,我发现一个问题:构造的类都适用于常规情况,即在读取时对数据解压缩而在写入时压缩它们,没有其它变通方法。虽然绕过 I/O 类是可能的,但我希望构建一个基于流的解决方案,而不想偷懒直接使用压缩程序。
不久以前,我在另一种情况下也遇到过完全相同的问题。我有一个 base-64 转码库,与使用压缩包一样,它支持对从流中读取的数据进行译码,并对写入流中的数据进行编码。然而,我需要的是一个在我从流中读取数据的同时可以进行编码的库。
在我着手解决该问题时,我认识到我在另一种情况下也遇到过该问题:当序列化 XML 文档时,通常会循环遍历整个文档,将节点写入流中。然而,我遇到的情况是需要读取序列化格式的文档,以便将子集重新解析成一个新文档。
回过头想一下,我意识到这些孤立事件表示了一个共性的问题:如果有一个递增地将数据写入输出流的数据源,那么我需要一个输入流使我能够读取这些数据,每当需要更多数据时,都能透明地访问数据源。
在本文中,我们将研究对这一问题的三种可能的解决方案,同时决定一个实现最佳解决方案的新框架。然后,我们将针对上面列出的每个问题,检验该框架。我们将扼要地谈及性能方面的问题,而把对此的大量讨论留到下一篇文章中。
首先,让我们简单回顾一下 Java 平台的基本流类,如图 1 所示。 OutputStream 表示对其写入数据的流。通常,该流将直接连接至诸如文件或网络连接之类的设备,或连接至另一个输出流(在这种情况下,它称为 过滤器(filter))。通常,输出流过滤器在转换了写入其中的数据之后,才将转换后产生的数据写入相连的流中。 InputStream 表示可以从中读取数据的流。同样,该流也直接连接至设备或其它流。输入流过滤器从相连的流中读取数据,转换该数据,然后允许从中读取转换后的数据。
图 1. I/O 流基础知识
就我最初的问题看, GZIPOutputStream 类是一个输出流过滤器,它压缩写入其中的数据,然后将该压缩数据写入相连的流。我需要的输入流过滤器应该能从流中读取数据,压缩数据,然后让我读取结果。
Java 平台,版本 1.4 已引入了一个新的 I/O 框架 java.nio 。不过,该框架在很大程度上与提供对操作系统 I/O 资源的有效访问有关;而且,虽然它确实为一些传统的 java.io 类提供了类似功能,并可以表示同时支持输入和输出的双重用途的资源,但它并不能完全替代标准流类,并且不能直接处理我需要解决的问题。
|
在着手寻找解决我问题的工程方案前,我根据标准 Java API 类的精致和有效性,研究了基于这些类的解决方案。
该问题的蛮力解决方案就是简单地从输入源中读取所有数据,然后通过转换程序(即,压缩流、编码流或 XML 序列化器)将它们推进内存缓冲区中。然后,我可以从该内存缓冲区中打开要读取的流,这样我就解决了问题。
首先,我需要一个通用的 I/O 方法。清单 1 中的方法利用一个小缓冲区将 InputStream 中的所有数据复制到 OutputStream 。当到达输入的结尾( read() 函数的返回值小于零)时,该方法就返回,但不关闭这两个流。
清单 1. 通用的 I/O 方法
public static void io (InputStream in, OutputStream out) |
清单 2 显示蛮力解决方案如何使我读取压缩格式的输入流。我打开写入内存缓冲区的 GZIPOutputStream (使用 ByteArrayOutputStream )。接着,将输入流复制到压缩流中,这样将压缩数据填入内存缓冲区中。然后,我返回 ByteArrayInputStream ,它让我从输入流中读取,如图 2 所示。
图 2. 蛮力解决方案
清单 2. 蛮力解决方案
public static InputStream bruteForceCompress (InputStream in) |
这个解决方案有一个明显的缺点,它将整个压 缩文档都存储在内存中。如果文档很大,那么这种方法将不必要地浪费系统资源。使用流的主要特性之一是它们允许您操作比所用系统内存要大的数据:您可以在读 取数据时处理它们,或在写入数据时生成数据,而无需始终将所有数据保存在内存中。
从效率上,让我们对在缓冲区之间复制数据进行更深入研究。
通过 io() 方法,将数据从输入源读入至一个缓冲区中。然后,将数据从缓冲区写入 ByteArrayOutputStream 中的缓冲区(通过我忽略的压缩过程)。然而, ByteArrayOutputStream 类对扩展的内部缓冲区进行操作;每当缓冲区变满时,就会分配一个大小是原来两倍的新缓冲区,接着将现有的数据复制到该缓冲区中。平均下来,这一过程每个字节复制两次。(算术计算很简单:当进入 ByteArrayOutputStream 时,对数据平均复制两次;所有数据至少复制一次;有一半数据至少复制两次;四分之一的数据至少复制三次,依次类推。)然后,将数据从该缓冲区复制到 ByteArrayInputStream 的一个新缓冲区中。现在,应用程序可以读取数据了。总之,这个解决方案将通过四个缓冲区写数据。这对于估计其它技术的效率是一个有用的基准。
|
管道式流 PipedOutputStream 和 PipedInputStream 在 Java 虚拟机的线程之间提供了基于流的连接。一个线程将数据写入 PipedOutputStream 中的同时,另一个线程可以从相关联的 PipedInputStream 中读取该数据。
就这样,这些类提供了一个针对我问题的解决方案。清单 3 显示了使用一个线程通过 GZIPOutputStream 将数据从输入流复制到 PipedOutputStream 的代码。然后,相关联的 PipedInputStream 将提供对来自另一个线程的压缩数据的读取权,如图 3 所示:
图 3. 管道式流解决方案
清单 3. 管道式流解决方案
private static InputStream pipedCompress (final InputStream in) |
理论上,这可能是个好技术:通过使用线程(一个执行压缩,另一个处理产生的数据),应用程序可以从硬件 SMP(对称多处理)或 SMT(对称多线程)中受益。另外,这一解决方案仅涉及两个缓冲区写操作:I/O 循环将数据从输入流读入缓冲区,然后通过压缩流写入 PipedOutputStream 。接着,输出流将数据存储在内部缓冲区中,与 PipedInputStream 共享缓冲区以供应用程序读取。而且,因为数据通过固定缓冲区流动,所以从不需要将它们完全读入内存中。事实上,在任何给定时刻,缓冲区都只存储小部分的工作集。
不 过,实际上,它的性能很糟糕。管道式流需要利用同步,从而引起两个线程之间激烈争夺同步。它们的内部缓冲区太小,无法有效地处理大量数据或隐藏锁争用。其 次,持久共享缓冲区会阻碍许多简单的高速缓存策略共享 SMP 机器上的工作负载。最后,线程的使用使得异常处理极其困难:没有办法将可能出现的任何 IOException 下推到管道中以便阅读器处理。总之,这一解决方案太难处理,根本不实际。
|
|
现在,我们将研究另一种解决该问题的工程方案。这种解决方案提供了一个特地为解决这类问题而设计的框架,该框架提供了对数据的 InputStream 访问,这些数据是从递增地向 OutputStream 写入数据的源中产生的。递增地写入数据这一事实很重要。如果源在单个原子操作中将所有数据都写入 OutputStream ,而且如果不使用线程,则我们基本上又回到了蛮力技术的老路上。不过,如果可以访问源以递增地写入其数据,则我们就实现了在蛮力和管道式流解决方案之间的 良好平衡。该解决方案不仅提供了在任何时候只在内存中保存少量数据的管道式优点,同时也提供了避免线程的蛮力技术的优点。
图 4 演示了完整的解决方案。我们将在本文的剩余部分研究 该解决方案的源代码。
图 4. 工程解决方案
清单 4 提供了一个描述数据源的接口 OutputEngine 。正如我所说的,这些源递增地将数据写入输出流:
清单 4. 输出引擎
package org.merlin.io; |
initialize() 方法向该引擎提供一个流,应该向这个流写入数据。然后,重复调用 execute() 方法将数据写入该流中。当数据写完时,引擎会关闭该流。最后,当引擎应该关闭时,将调用 finish() 。这会发生在引擎关闭其输出流的前后。
输出引擎解决了让我费力处理的问题,它是一个通过输出流过滤器将数据从输入流复制到目标输出流的引擎。这满足了递增性的特性,因为它可以一次读写单个缓冲区。
清单 5 到 10 中的代码实现了这样的一个引擎。通过输入流和输入流工厂来构造它。清单 11 是一个生成过滤后的输出流的工厂;例如,它会返回包装了目标输出流的 GZIPOutputStream 。
清单 5. I/O 流引擎
package org.merlin.io; |
该类的构造器只初始化各种变量和将用于传输数据的缓冲区。
清单 6. 构造器
public IOStreamEngine (InputStream in, OutputStreamFactory factory) { |
在 initialize() 方法中,该引擎调用其工厂来封装与其一起提供的 OutputStream 。该工厂通常将一个过滤器连接至 OutputStream 。
清单 7. initialize() 方法
public void initialize (OutputStream out) throws IOException { |
在 execute() 方法中,引擎从 InputStream 中读取一个缓冲区的数据,然后将它们写入已封装的 OutputStream ;或者,如果输入结束,它会关闭 OutputStream 。
清单 8. execute() 方法
public void execute () throws IOException { |
最后,当关闭引擎时,它就关闭其 InputStream 。
清单 9. 关闭 InputStream
public void finish () throws IOException { |
内部 OutputStreamFactory 接口(下面清单 10 中所示)描述可以返回过滤后的 OutputStream 的类。
清单 10. 内部输出流工厂接口
public static interface OutputStreamFactory { |
清单 11 显示将提供的流封装到 GZIPOutputStream 中的一个示例工厂:
清单 11. GZIP 输出流工厂
public class GZIPOutputStreamFactory |
该 I/O 流引擎及其输出流工厂框架通常足以支持大多数的输出流过滤需要。
最后,我们还需要一小段代码来完成这个解决方案。清单 12 到 16 中的代码提供了读取由输出引擎所写数据的输入流。事实上,这段代码有两个部分:主类是一个从内部缓冲区读取数据的输入流。与此紧密耦合的是一个输出流(如 清单 17 所示),它把输出引擎所写的数据填充到内部读缓冲区。
主输入流类将用其内部输出流来初始化输出引擎。然后,每当它的缓冲区为空时,它会自动执行该引擎来接收更多数据。输出引擎将数据写入其输出流中,这将重新填充输入流的内部缓冲区,以允许需要内部缓冲区数据的应用程序高效地读取数据。
清单 12. 输出引擎输入流
package org.merlin.io; |
该输入流的构造器获取一个输出引擎以从中读取数据和一个可选的缓冲区大小。该流首先初始化其本身,然后初始化输出引擎。
清单 13. 构造器
public OutputEngineInputStream (OutputEngine engine) throws IOException { |
代码的主要读部分是一个相对简单的基于字节数组的输入流,与 ByteArrayInputStream 类非常相似。然而,每当需要数据而该流为空时,它都会调用输出引擎的 execute() 方法来重新填写读缓冲区。然后,将这些新数据返回给调用程序。因而,这个类将对输出引擎所写的数据反复读取,直到它读完为止,此时将设置 eof 标志并且该流将返回已到达文件末尾的信息。
清单 14. 读取数据
private byte[] one = new byte[1]; |
当操作数据的应用程序关闭该流时,它调用输出引擎的 finish() 方法,以便可以释放其正在使用的任何资源。
清单 15. 释放资源
public void close () throws IOException { |
当输出引擎将数据写入其输出流时,调用 writeImpl() 方法。它将这些数据复制到读缓冲区,并更新读限制索引;这将使新数据可自动地用于读方法。
在单次循环中,如果输出引擎写入的数据比缓冲区中可以保存的数据多,则缓冲区的容量会翻倍。然而,这不能频繁发生;缓冲区应该快速扩展到足够的大小,以便进行状态稳定的操作。
清单 16. writeImpl() 方法
private void writeImpl (byte[] data, int offset, int length) { |
下面清单 17 中显示的内部输出流实现表示了一个流将数据写入内部输出流缓冲区。该代码验证参数都是可接受的,并且如果是这样的话,它调用 writeImpl() 方法。
清单 17. 内部输出流实现
private class OutputStreamImpl extends OutputStream { |
最后,当输出引擎关闭其输出流,表明它已写入了所有的数据时,该输出流设置输入流的 eof 标志,表明已经读取了所有的数据。
清单 18. 设置输入流的 eof 标志
public void close () { |
敏感的读者可能注意到我应该将 writeImpl() 方法的主体直接放在输出流实现中:内部类有权访问所有包含类的私有成员。然而,对这些字段的内部类访问比由包含类的直接方法的访问在效率方面稍许差一些。所以,考虑到效率以及为了使类之间的相关性最小化,我使用额外的助手方法。
|
清单 19 演示了这个类框架的使用来解决我最初的问题:在我读取数据时压缩它们。该解决方案归结为创建一个与输入流相关联的 IOStreamEngine 和一个 GZIPOutputStreamFactory ,然后将 OutputEngineInputStream 与这个 GZIPOutputStreamFactory 相连。自动执行流的初始化和连接,然后可以直接从结果流中读取压缩数据。当处理完成且关闭流时,输出引擎自动关闭,并且它关闭初始输入流。
清单 19. 应用工程解决方案
private static InputStream engineCompress (InputStream in) |
虽然为解决这类问题而设计的解决方案应该产生十分清晰的代码,这一点没有什么可惊奇的,但是通常要充分留意以下教训:无论问题大小,应用良好的设计技术都几乎肯定会产生更为清晰、更便于维护的代码。
|
从效率看, IOStreamEngine 将数据读入其内部缓冲区,然后通过压缩过滤器将它们写入 OutputStreamImpl 。这将数据直接写入 OutputEngineInputStream ,以便它们可供读取。总共只执行两次缓冲区复制,这意味着我应该从管道式流解决方案的缓冲区复制效率和蛮力解决方案的无线程效率的结合中获益。
要测试实际的性能,我编写了一个简单的测试工具(请参阅所附 资源中的 test.PerformanceTest ),它使用这三个推荐的解决方案,通过使用一个空过滤器来读取一块哑元数据。在运行 Java 2 SDK,版本 1.4.0 的 800 MHz Linux 机器上,达到了下列性能:
管道式流解决方案
15KB:23ms;15MB:22100ms
蛮力解决方案
15KB:0.35ms;15MB:745ms
工程解决方案
15KB:0.16ms;15MB:73ms
该问题的工程解决方案很明显比基于标准 Java API 的另两个方法都更有效。
顺 便提一下,考虑到如果输出引擎能够遵守这样的约定:在将数据写入其输出流后,它不修改从中写入数据的数组而返回,那么我能提供一个只使用一次缓冲区复制操 作的解决方案。可是,输出引擎很少会遵守这种约定。如果需要,输出引擎只要通过实现适当的标记程序接口,就能宣称它支持这种方式的操作。
|
任何可以用“提供对将数据反复写入 OutputStream 的实体的读访问权”表述的问题,都可以用这一框架解决。在这一节和下一节中,我们将研究这样的问题示例及其有效的解决方案。
首先,考虑要读取 UTF-8 编码格式的字符流的情况: InputStreamReader 类让您将以二进制编码的字符数据作为一系列 Unicode 字符读取;它表示了从字节输入流到字符输入流的关口。 OutputStreamWriter 类让您将一系列二进制编码格式的 Unicode 字符写入输出流;它表示从字符输出流到字节输入流的关口。 String 类的 getBytes() 方法将字符串转换成经编码的字节数组。然而,这些类中没有一个能直接让您读取 UTF-8 编码格式的字符流。
清单 20 到 24 中的代码演示了以与 IOStreamEngine 类极其相似的方式使用 OutputEngine 框架的一种解决方案。我们并不是从输入流读取和通过输出流过滤器进行写操作,而是从字符流读取,并通过所选的字符进行编码的 OutputStreamWriter 进行写操作。
清单 20. 读取编码的字符数据
package org.merlin.io; |
该类的构造器接受要从中读取的字符流、要使用的编码以及可选的缓冲区大小。
清单 21. 构造器
public ReaderWriterEngine (Reader in, String encoding) { |
当该引擎初始化时,它将以所选编码格式写字符的 OutputStreamWriter 连接至提供的输出流。
清单 22. 初始化输出流写程序
public void initialize (OutputStream out) throws IOException { |
当执行该引擎时,它从输入字符流中读取数据,然后将它们写入 OutputStreamWriter ,接着 OutputStreamWriter 将它们以所选的编码格式传递给相连的输出流。至此,该框架使数据可供读取。
清单 23. 读取数据
public void execute () throws IOException { |
当引擎执行完时,它关闭其输入。
清单 24. 关闭输入
public void finish () throws IOException { |
在这种与压缩不同的情况中,Java I/O 包不提供对 OutputStreamWriter 之下的字符编码类的低级别访问。因此,这是在 Java 平台 1.4 之前的发行版上读取编码格式的字符流的唯一有效解决方案。从版本 1.4 开始, java.nio.charset 包确实提供了与流无关的字符编码和译码能力。然而,这个包不能满足我们对基于输入流的解决方案的要求。
|
应用工程解决方案:读取序列化的 DOM 文档
最后,让我们研究该框架的最后一种用法。清单 25 到 29 中的代码提供了一个用来读取序列化格式的 DOM 文档或文档子集的解决方案。这一代码的潜在用途可能是对部分 DOM 文档执行确认性重新解析。
清单 25. 读取序列化的 DOM 文档
package org.merlin.io; |
构造器获取要在上面进行循环的 DOM 节点,或预先构造的节点迭代器(这是 DOM 2 的一部分),以及一个用于序列化格式的编码。
清单 26. 构造器
public DOMSerializerEngine (Node root) { |
初始化期间,该引擎将适当的 OutputStreamWriter 连接至目标输出流。
清单 27. initialize() 方法
public void initialize (OutputStream out) throws IOException { |
在执行阶段,该引擎从节点迭代器中获得下一个节点,然后将其序列化至 OutputStreamWriter 。当获取了所有节点后,引擎关闭它的流。
清单 28. execute() 方法
public void execute () throws IOException { |
当该引擎关闭时,没有要释放的资源。
清单 29. 关闭
public void finish () throws IOException { |
序列化每个节点的其它内部细节不太有趣;这一过程主要涉及根据节点的类型和 XML 1.0 规范写出节点,所以我将在本文中省略这一部分的代码。请参阅附带的 源代码,获取完整的详细信息。
|
我所提供的是一个有用的框架,它利用标准输入流 API 让您能有效读取由只能写入输出流的系统产生的数据。它让我们读取经压缩或编码的数据及序列化文档等。虽然可以使用标准 Java API 实现这一功能,但使用这些类的效率根本不行。应该充分注意到,这种解决方案比最简单的蛮力解决方案更有效(即使在数据不大的情况下)。将数据写入 ByteArrayOutputStream 以便进行后续处理的任何应用程序都可能从这一框架中受益。
字节数组流的拙劣性能和管道式流难以置信的蹩脚性能,实际上都是我下一篇文章的主题。在那篇文章中,我将研究重新实现这些类,并比这些类的原创者更加关注它们的性能。只要 API 约定稍微宽松一点,性能就可能改进一百倍了。
我 讨厌洗碗。不过,正如大多数我自认为是较好(虽然常常还是微不足道)的想法一样,这些类背后的想法都是在我洗碗时冒出来的。我时常发现撇开实际代码,回头 看看并且把问题的范围考虑得更广些,可能会得出一个更好的解决方案,它最终为您提供的方法可能比您找出的容易方法更好。这些解决方案常常会产生更清晰、更 有效而且更可维护的代码。
我真的担心我们有了洗碗机的那一天。
来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/374079/viewspace-130126/,如需转载,请注明出处,否则将追究法律责任。
转载于:http://blog.itpub.net/374079/viewspace-130126/