Java I/O 系统

I/O 流

Java 7 引入了一种简单明了的方式来读写文件和操作目录。大多情况下,文件章节所介绍的那些库和技术就足够用了。如果有一些特殊需求和较底层的操作,就需要了解本章。

对语言设计来说,设计优秀的输入/输出(input/output, I/O)系统是具有挑战性的,这不仅涉及有很多不同的来源和去处(文件、控制台、网络连接等等)而且还涉及需要以很多种方式(顺序读取、随机访问、缓冲、字符、按行读取、按字读取等等)和这些东西打交道。

Java 库的设计者通过创建大量的类来解决这个问题。事实上 Java I/O 系统中如此多的类足以让人望而生畏。随者面向字符、基于 Unicode 的 I/O 类加人原本面向字节的库,Java 1.0 后的 I/O 库也发生了巨大的变化。为了提升性能和功能,还引人了 nio 类(new I/O,Java 1.4 引人该特性多年后所使用的名称)。

因此,要想充分理解 Java I/O 系统以便正确运用它,需要学习相当多的类。理解 I/O 库的演进过程同样有用,因为如果缺乏历史视角,会搞不清楚一些类什么时候该用。

编程语言的 I/O 库常常会使用流的抽象,将任意数据源或数据接收端表达为一个具有生成或接收数据片段能力的对象。

一定要理解,Java 8 函数式流相关的类和 I/O 流之间并无关联。

I/O 流隐藏了实际的 I/O 设备中数据情况的下列细节。

  1. 字节流用于处理原始的二进制数据。

  2. 字符流用于处理字符数据,它会自动处理和本地字符集间的相互转换。

  3. 缓冲区流提升了性能。它通过减少调用本地 API 的次数,优化了输人和输出。

Java 库将 I/O 相关的类分成了输人和输出两类。在 Java 1.0 中,库的设计者们选择让所有和输入相关的类都继承自 InputStream,而所有和输出相关的类都继承自 OutputStream。从 InputStream 类或 Reader 类派生出的所有类都含有根方法 read(),用于从字节数组中读取单个字节或字节数组。同样,从 Outputstream 类或 Writer 类派生出的所有类都含有根方法 write(),用于写人单个字节或字节数组。不过通常不会使用这些方法,它们的存在只是为了供其他类使用一这些其他类提供了更有用的接口。

你很少会通过单个类创建流对象,而一般会将多个类分层放在一起来提供所需的功能(这便是装饰器设计模式)。生成一个流需要创建多个对象,这正是 Java 1.0 的 I/O 库容易让人迷惑的主要原因。

下面对这些类做一个总体的概述,另外还需要通过 JDK 文档理清所有细节,例如某个类详尽的方法列表。

各种 InputStream 类型

Inputstream 用于表示那些从不同源生成输入的类。可能的源有以下几种。

  1. 字节数组

  2. 字符串对象

  3. 文件

  4. “管道”(pipe)

  5. 其他流组成的序列,这样就可以将这些流合并成单个流

  6. 其他源,如外部网络连接

以上这些源各自都有一个关联的 InputStream 子类,如表 7-1 所示。另外,FilterInputStream 也是一种 InputStream 类型,它为“装饰器”类(用于为输人流增加属性或有用的接口)提供了基类。

表 7-1

功能
构造器参数
使用方法

ByteArrayInputStream

使内存中的缓冲区可以充当 InputStream

用于提取出字节的缓冲区

作为一种数据源:通过将其连接到 FilterInputStream 对象来提供有用的接口

StringBufferInputStream

将字符串转换为 InputStream

一个字符串,底层实现实际上用的是 StringBuffer

作为一种数据源:通过将其连接到 FilterInputStream 对象来提供有用的接口

FileInputStream

用于从一个文件中读取信息

一个用于表示文件名或 File 对象,还有 FileDescriptor 对象的字符串

作为一种数据源:通过将其连接到 FilterInputStream 对象来提供有用的接口

PipedInputStream

用于生成写入到对应的 PipedOutputStream 中的数据。它实现了“管道传输”的概念

PipedOutputStream

作为一种多线程形式的数据源:通过将其连接到 FilterInputStream 对象来提供有用的接口

SequenceInputStream

将两个以上的 InputStream 转换为一个 InputStream

两个 InputStream 对象,或一个作为 InputStream 对象容器的 Enumeration

作为一种数据源:通过将其连接到 FilterInputStream 对象来提供有用的接口

FilterInputStream

作为装饰器接口的抽象类,装饰器用来为其他 InputStream 类提供有用的功能。参见表 7-3

参见表 7-3

参见表 7-3

各种 OutputStream 类型

这一系列的类用于决定输出的去向:字节数组(不能是字符串——但一般能用字节数组创建字符串)、文件,还是“管道”。

另外,FilterOutputStream 为“装饰器”类(用于为输出流增加属性或有用的接口)提供了基类,如表7-2所示。

表 7-2

功能
构造器参数
使用方法

ByteArrayOutputStream

在内存中创建一块缓冲区,所有发送到流中的数据都放在该缓冲区

缓冲区初始大小,为可选参数

用于指定数据的目的地:通过将其连接到 FilterOutputStream 对象来提供有用的接口

FileOutputStream

用于向文件发送信息

用于表示文件名或 File 对象,还有 FileDescriptor 对象的字符串

用于指定数据的目的地:通过将其连接到 FilterOutputStream 对象来提供有用的接口

PipedOutputStream

向其中写入的任何信息都将自动作为对应的 PipedInputStream 的输入,实现了“管道传输”的概念

PipedInputStream

用于为多线程指定数据的目的地:通过将其连接到 FilterOutputStream 对象来提供有用的接口

FilterOutputStream

作为装饰器接口的抽象类,装饰器用来为其他 OutputStream 类提供有用的功能。参见表 7-4

参见表 7-4

参见表 7-4

添加属性和有用的接口

装饰器在泛型章节介绍过。Java I/O 需要将各种模块以不同方式组合使用,这正是使用装饰器模式的原因所在。而之所以存在 filter(过滤器)类,是因为 filter 抽象类是所有装饰器的基类。装饰器必须和其所装饰的对象实现同一个接口,但它也可以扩展接口,不过这种情况只发生在个别 filter 类中。

装饰器模式有一个缺点。在编写程序时,装饰器可以带来更大的灵活性(让你可以轻松地对属性进行混合和匹配),但也会增加代码的复杂性。Java I/O 库难用的原因在于,必须创建很多类(“核心”的 I/O 类型,加上所有的装饰器)才能得到想要的单个 I/O 对象。

FilterInputStream 和 FilterOutputStream 这两个类提供了可控制某个特定 InputStream 或 Outputstream 的装饰器接口,而这两个类的命名不是很直观。它们派生自 I/O 库的基类:InputStream 和 OutputStream,这两个基类是装饰器的必要组成部分(这样装饰器才能为所有被装饰的对象提供公共接口)。

  1. 用 FilterlnputStream 从 Inputstream 中读取

各种 FilterlnputStream 类可以完成两件截然不同的事情。其中 DataInputStream 用于读取不同的基本类型数据以及字符串对象。(所有方法名都以“read”开头,如 readByte(), readFloat() 等)再加上其伴生类 DataOutputStream,使得你可以通过流将基本类型数据从一个地方移动到另一个地方。具体是哪些“地方”则由表 7-1 中的那些类所决定。

其它 FilterInputStream 类则在内部修改了 InputStream 的行为方式:是否缓冲,是否保留它所读过的行(允许我们查询行数或设置行数),以及是否允许把单个字符推回输入流等等。最后两个类看起来就像是为了创建编译器提供的,因此我们在一般编程中不会用到它们。

表 7-3 各种 FilterInputStream 类型

功能
构造器参数
使用方法

DataInputStream

与 DataOutputStream 配合使用,以可移植的方式从流中读取基本类型(int, char, long等)

InputStream

包含用于读取基本类型的全部接口

BufferedInputStream

用于防止在每次需要更多数据时都进行物理上的读取。相当于声明“使用缓冲区”

InputStream 以及可选参数:(指定)缓冲区大小

本质上并未提供接口,只是为进程增加缓冲操作而已,需要与接口对象搭配使用

LineNumberInputStream

记录输入流中的行号,可以调用 getLineNumber() 和 setLineNumber(int)

InputStream

只是增加了行号而已,因此可能需要与接口对象搭配使用

PushbackInputStream

包含一个单字节回退缓冲区,用于将最后读取的字符推回输入流

InputStream

通常用于编译器的扫描器,一般不会用到

在实际应用中,不管连接的是什么 I/O 设备,我们基本上都会对输入进行缓冲。所以当初 I/O 类库如果能默认都让输入进行缓冲,同时将无缓冲输入作为一种特殊情况(或者只是简单地提供一个方法调用),这样会更加合理,而不是像现在这样迫使我们基本上每次都得手动添加缓冲。

  1. 用 FilterOutputStream 向 OutputStream 中写入

与 DataInputStream 对应的是 DataOutputStream,它可以将各种基本类型和字符串对象格式化输出到流中,这样任何机器上的任何 DataInputStream 就都可以读取这些信息了。其中所有的方法都以"write"开头,如 writeByte(), writefloat() 等。

PrintStream 最初的目的是以可视化的格式来打印所有的基本数据类型和字符串对象。这和 DataOutputStream 不同,后者的目的是以某种方式将数据元素放入流中,使得 DataInputStream 可以用可移植的方式重建这些元素。

PrintStream 中有两个重要的方法:print()println(),它们会被重载,然后用于打印各种类型。

PrintStream 可能会出现问题,因为它会“吞掉”所有的 IOException(因此必须用 checkError() 显式地检查错误状态,如果出现了错误,便会返回 true)。另外 PrintStream 的国际化并不完善。这些问题在 PrintWriter 中得到了解决,稍后会进行介绍。

BufferedOutputStream 是个修饰语,它会告诉流使用缓冲,这样就不会在每次向流中写人时都发生物理写操作了。在进行输出时,很可能会经常用到它。

表 7-4 各种 FilterOutputStream 类型

功能
构造器参数
使用方法

DataOutputStream

与 DataInputStream 搭配使用,这样就能以可移植的方式向流中写入基本类型(int, char, long, 等等)

OutputStream

包含用于写入基本类型数据的全部接口

PrintStream

用于生成格式化的输出。DataOutputStream 负责处理数据的存储,而 PrintStream 则负责处理数据的显示

OutputStream 以及可选参数:boolean,表示是否在每次换行时清空缓冲区

应该作为 OutputStream 对象的 "final" 包装,可能会经常用到

BufferedOutputStream

用来防止在每次发送数据时都发生物理写操作,相当于声明“使用缓冲区”。可以调用 flush() 来清空缓冲区

OutputStream 以及可选参数:(指定)缓冲区大小

本质上并未提供接口,只是向进程添加缓冲操作。需要与接口对象搭配使用

各种 Reader 和 Writer

Java 1.1 对基础流式 I/O 库逬行了重大的修改。虽然原始的流库某些方面已被弃用(如果使用它们,编译器就会发出警告),但 InputStream 和 OutputStream 类仍然以面向字节的 I/O 的形式提供了有价值的功能,而 Reader 和 Writer 类则提供了兼容 Unicode 并且基于字符的 I/O 能力。此外,还有以下两点需要注意:

  1. Java 1.1 往 InputStream 和 OutputStream 的继承体系中又添加了一些新类,显然整个继承层次结构没被替换。

  2. 有时必须将“字节”继承层次结构中的类和“字符”继承层次结构中的类结合使用。为了达到这个目的,需要用到“适配器(adapter)类”:InputStreamReader 可以把 InputStream 转换为 Reader,而 OutputStreamWriter 可以把 OutputStream 转换为 Writer。

设计 Reader 和 Writer 继承体系主要是为了国际化。老的 I/O 流继承体系仅支持 8 比特的字节流,并且不能很好地处理 16 比特的 Unicode 字符。由于 Unicode 用于字符国际化(Java 本身的 char 也是 16 比特的 Unicode),所以添加 Reader 和 Writer 继承体系就是为了让所有的 I/O 操作都支持 Unicode。另外,新类库的设计使得它的操作比旧类库要快。

  1. 数据的来源和去处

几乎所有原始的 Java I/O 流类都有相应的 Reader 和 Writer 类来提供原生的 Unicode 操作。但是在某些地方,面向字节的 InputStream 和 OutputStream 才是正确的选择。特别是 java.util.zip 类库就是面向字节的。因此,最明智的做法是尽量尝试使用 Reader 和 Writer,一旦代码没法成功编译,你就会发现此时应该使用面向字节的类库了。

下表展示了在两个继承体系中,信息的来源和去处(即数据物理上来自哪里又去向哪里)之间的对应关系:

表 7-5

来源和去处:Java 1.0 中的类
Java 1.1 中对应的类

InputStream

Reader 适配器:InputStreamReader

OutputStream

Writer 适配器:OutputStreamWriter

FileInputStream

FileReader

FileOutputStream

FileWriter

StringBufferInputStream(已弃用)

StringReader

(没有对应的类)

StringWriter

ByteArrayInputStream

CharArrayReader

ByteArrayOutputStream

CharArrayWriter

PipedInputStream

PipedReader

PipedOutputStream

PipedWriter

  1. 改变流的行为

对于 InputStream 和 OutputStream 来说,会基于特定的需求,由 FilterInputStream 和 FilterOutputStream 的“装饰器”子类对流进行适配。Reader 和 Writer 这两套继承层次结构中的类继续沿用了这套思想,但并不完全相同。

在下表中,两列间的对应关系相较于表 7-5 粗略一些。造成这种区别的原因在于类的组织方式不同,虽然 BufferedOutputStream 是 FilterOutputStream 的子类,但 BufferedWriter 不是 FilterWriter 的子类(尽管 Filterwriter 是抽象类,但它没有任何子类,因此把它放在那里似乎只是当作占位符,或者仅仅是为了告诉你有这么一个类而已)。不过这些类的接口却十分相似。

表 7-6

过滤器:Java 1.0 中的类
Java 1.1 中对应的类

FilterInputStream

FilterReader

FilterOutputStream

FilterWriter(没有任何子类的抽象类)

BufferedInputStream

BufferedReader(同样包含 readLine()

BufferedOutputStream

BufferedWriter

DataInputStream

使用 DataInputStream(如果必须使用 readLine(),则应该使用 BufferedReader)

PrintStream

PrintWriter

LineNumberInputStream(已弃用)

LineNumberReader

StreamTokenizer

StreamTokenizer(使用以 Reader 作为参数的构造器)

PushbackInputStream

PushbackReader

有一点应当清楚,任何需要使用 readLine() 时,应该用 BufferedReader 中的版本,因为 DataInputStream 中的已经弃用。除此之外,DataInputStream 仍是 I/O 类库的首选成员。

为了更平缓地过渡到使用 PrintWriter,除了接收 Writer 对象的构造器外,它还提供了接收任意 OutputStream 对象的构造器。PrintWriter 的格式化接口实际上与 PrintStream 相同。

Java 5 中増加了 PrintWriter 构造器,以简化对输出执行写操作时的文件创建过程,后面会介绍。

有一种 PrintWriter 构造器还支持执行自动清空的可选操作,如果设置了构造器的该选项标志,便会在每次 println() 后执行该清空操作。

  1. 未发生变化的类

有一些类则从 Java 1.0 中原样保留到了 Java 1.1,这些类是:DataOutputStream,File,RandomAccessFile,SequenceInputStream。

特别是 DataoutputStream,在使用上没有任何变化,因此如果想要以可传输的方式存储和读取数据,就应该使用 InputStream 和 OutputStream 继承层次结构中的类。

单独的 RandomAccessFile

RandomAccessFile 适合用来处理由大小已知的记录组成的文件,由此可以通过 seek() 在各条记录上来回移动,然后读取或者修改记录。文件中各条记录的大小不必相同,只需确定它们的大小以及在文件中的位置即可。

这个类与 InputStream 和 OutputStream 系列毫无关系,只是碰巧实现了 DataInput 和 DataOutput 接口。(DataInputStream 和 DataOutputStream 也实现了这两个接口)它甚至都不使用 InputStream 和 OutputStream 类中已有的任何功能。它是一个完全独立的类,其所有的方法(大多数都是原生方法)都是从头开始编写的。这么设计的原因在于 RandomAccessFile 可以使我们在文件中前后移动,所以其行为和其他的 I/O 类型有着本质上的区别。在任何情况下,它都是独立的,是直接从 Object 派生而来的。

从本质上来讲,RandomAccessFile 的工作方式类似于把 DataIunputStream 和 DataOutputStream 组合起来使用。另外它还有一些额外的方法,比如使用 getFilePointer() 可以得到当前文件指针在文件中的位置,使用 seek() 可以移动文件指针,使用 length() 可以得到文件的长度。

第二个参数(和 C 语言中的 fopen() 相同)用来表示我们是准备对文件进行 “随机读”(r)还是“读写”(rw)。它并不支持只写(write-only)文件,由此可以猜测 RandomAccessFile 如果是从 DataInputStream 继承而来的,可能也运行得不错。

只有 RandomAccessFile 才支持检索相关的方法,并且仅适用于文件。BufferedInputStream 确实可以 mark()(标记)某个位置(其值保存在单个内部变量中),以及 reset()(重置)该位置,但这些功能很有限,并没有多大用处。

从 Java 1.4 开始,RandomAccessFile 的绝大多数功能被 nio 的内存映射文件(memory mapped file)所取代。

I/O 流的典型用法

虽然可以用多种不同方式组合各种 I/O 流的类,但常用的就几种。下面这些示例可以作为 I/O 典型用法的基本参考。(如果确定文件章节中介绍的库无法实现需求)

在这些示例中,异常处理被简化为将异常传递给控制台,这种方式仅适用于小型示例或者工具程序。在实际的代码中,需要考虑更加复杂的错误处理方式。

  1. 缓冲输入文件

如果想要打开一个文件进行字符输入,需要使用由字符串或 File 对象作为文件名参数的 FileReader。为了提高速度,需要对文件逬行缓冲,因此要将产生的引用传递给 BufferedReader 的构造器。BufferedReader 提供了可以生成 Stieam<String>lines() 方法:

Collectors.joining() 在其内部使用了一个 StringBuilder 来累加其运行结果。该文件会通过 try-with-resources 子句自动关闭。

  1. 从内存输入

下面的示例使用从 BufferedInputFile.read() 得到的字符串类型数据创建了 StringReader 然后 read() 会将字符逐个生成,并显示到控制台:

注意 read() 是以 int 形式返回下一个字节,所以必须类型转换为 char 才能正确打印。

  1. 格式化的内存输入

要读取格式化数据,需要使用面向字节(不是面向 char)的 I/O 类 DataInputStream。因此必须使用 InputStream 类(而不是 Reader)。InputStream 类以字节形式读取任何数据(比如一个文件),但这里使用的是字符串。

ByteArrayInputStream 只能接收字节数组(此处由 String.getBytes() 生成)。得到的 BytearrayInputStream 便可以作为 InputStream 类型参数传给 DatalnputStream。

如果用 readByte() 从 DataInputStream 一次一个字节地读取字符,那么任何字节的值都是合法结果,因此返回值不能用来检测输入是否结束。因此需要使用 available() 方法得到剩余可读取字符的数量。下面例子演示了怎么一次一个字节地读取文件:

注意,available() 的工作方式会随着所读取媒介类型的不同而有所不同,该方法的字面意思是“在没有阻塞的情况下所能读取的字节数量”。对于文件来说,这意味着整个文件,但是对于不同类型的流来说,则可能并不是这样,因此该方法要谨慎使用。

你也可以在类似的情况中通过捕获异常来检测输入是否结束。不过用异常来控制程序流程一般被认为是错误的异常使用方式。

  1. 基本的文件输出

FileWriter 对象用于向文件写入数据。实际使用时,通常会用 BufferedWriter 将其包装起来以增加缓冲的功能。(缓冲往往能显著地增加 I/O 操作的性能)本例中,FileWriter 被装饰为 Printwriter 以提供格式化的能力。以这种方式创建的数据文件可以作为普通的文本文件来读取:

try-with-resources 语句会自动 flush 并关闭文件。

Java 5 为 PrintWriter 增加了一个辅助构造器,因此我们无须在每次创建文本文件并向其中写人时都去手动执行装饰工作。下面用该快捷方式对 BasicFileOutput.java 进行了重写:

使用这种方式仍具备了缓冲的功能,只是现在不必自己手动添加缓冲了。但遗憾的是,其它常见的写入任务都没有快捷方式,因此典型的 I/O 流依旧涉及大量冗余的代码。在文件章节中介绍了通过一种不同的方式来极大地简化其他任务。

  1. 存储和恢复数据

PrintWriter 可以将数据格式化为人类可读的格式。如果要输出可供另一个流恢复的数据,可以使用 DataOutputStream 来写人数据,并用 DataInputStream 来恢复数据。这里说的流可以是任何形式,不过下面的示例中用的是文件,并为读写进行了缓冲处理。DataOutputStream 和 DataInputStream 都是面向字节的,因此需要使用 InputStream 和 OutputStream。

如果使用 DataOutputStream 来写人数据,那么 Java 会确保你可以通过 DataInputStream 精确地恢复数据,不论是在什么平台上写人和读取数据。任何在数据的跨平台问题上花费过时间和精力的人都知道,这非常有价值。如果两个平台上都安装了 Java,这就不是问题。(XML 是另一种解决在不同计算平台间传输数据的问题的途径,而且不依赖于这些平台上是否安装了 Java)

要想使用 DataOutputStream 写人字符串,以便可以让 DataInputStream 来恢复,唯一可靠的方法是使用 UTF-8 编码(本例中由 writeUTF()readUTF() 实现)。UTF-8 是一种多字节格式,其编码长度根据实际使用的字符集会有所变化。如果使用的(几乎)全都是 ASCII 字符(只占用7位),使用固定编码就会显得浪费空间或带宽,因此 UTF-8 会将 ASCII 字符编码为1个字节,非 ASCIl 字符则会编码为2个或3个字节。此外,UTF-8 字符串会将字符串的长度保存在头两个字节。但是,writeUTF()readUTF() 使用的是一种适用于 Java 的 UTF-8 变体(JDK 文档中有这些方法的详尽描述),因此如果用一个非 Java 程序读取用 writeUTF() 所写的字符串时,必须编写一些特殊的代码才能正确读取。

有了 writeUTF()readUTF(),便可以在 DataOutputStream 中将字符串和其他类型的数据进行混合,因为我们知道字符串会被妥当地存储为 Unicode,而且可以轻松地用 DataInputStream 来恢复。

writeDouble() 将 double 类型的数字存储在流中,并用相应的 readDouble() 恢复它。(还有其他类似的方法用于其他类型的读写操作)但是为了保证所有的读方法都能够正常工作,我们必须知道流中数据项所在的确切位置,因为同样可以将存储的 double 类型作为简单的字节(或 char 等)序列进行读取。因此你必须要么对文件中的数据使用固定的格式,要么在你要解析的文件中加入额外的信息来确定数据所在的位置。注意,对象序列化或者XML 可能是更容易存储及读取复杂数据结构的方式。

  1. 读写随机访问文件

使用 RandomAccessFile 就类似于组合使用了 DataInputStream 和 DataOutputStream(因为它实现了相同的接口:DataInput 和 DataOutput),另外还可以使用 seek() 方法移动文件指针并修改对应位置的值。

在使用 RandomAccessFile 时,你必须清楚文件的结构,以实现对文件的正确操作。RandomAccessFile 含有专门用于读写基本类型和 UTF-8 字符串的方法:

display() 方法打开了一个文件,并以 double 值的形式显示了其中的七个元素。在 main() 中,首先创建了文件,然后打开并修改了它。因为 double 总是 8 字节长,所以如果要用 seek() 定位到第 5 个(从 0 开始计数) double 值,则要传入的地址值应该为 5 * 8。

如前所述,虽然 RandomAccess 实现了 DataInput 和 DataOutput 接口,但实际上它和 I/O 继承体系中的其它部分是分离的。它不支持装饰器,故而不能将其与 InputStream 及 OutputStream 子类中的任何一个组合起来,所以也没法给它添加缓冲的功能,必须假定 RandomAccessFile 已经妥当地进行了缓冲。

构造器的第二个参数可以让 RandomAccessFile 以“只读”(r)方式或“读写” (rw)方式打开文件。

可以考虑用 nio 的内存映射文件来取代 RandomAccessFile。

小结

Java I/O 流库确实满足了基本的需求:可以对控制台、文件、内存甚至跨网络进行读写操作。通过继承,可以创建新的输人和输出对象类型。甚至还可以通过重新定义 toString() 方法(在向预期参数类型为 String 的方法传人对象时,会自动调用该对象的 toString() 方法。这是 Java 有限的“自动类型转换”)为流所能接收的对象种类添加简单的扩展性。

在 I/O 流类库的文档和设计中,仍留有一些没有解决的问题。例如,在打开某个文件并向其输出时,如果会覆盖其原有的内容,则最好能抛出异常。(有的编程系统只有当该文件不存在时,才允许你将其作为输出文件打开)在 Java 中,应该使用一个 File 对象来判断文件是否存在,因为如果我们用 FileOutputStream 或者 FileWriter 打开,那么文件内容肯定会被覆盖。

I/O 流类库让我们喜忧参半。它确实挺有用的,而且还具有可移植性。但是如果我们没有理解“装饰器”设计模式,那么这种设计就会显得不是很直观。所以,它的学习成本相对较高。而且它的完成度也不高。

一旦你真的理解了装饰器模式,并开始在依赖其灵活性的场景中使用该库,你就可以开始享受这种设计模式的好处,这时那些额外的代码成本便不会再那么困扰你了。不过一定要记得要先确认文件章节中介绍的库和技巧肯定解决不了你的问题。

标准 I/O

标准 I/O 这个术语参考 Unix 中的概念,指程序所使用的单一信息流。(这种思想在大多数操作系统中也有相似形式的实现)程序的所有输入都可以来自于标准输入,其所有输出都可以发送到标准输出,所有错误信息均可以发送到标准错误。标准 I/O 的意义在于程序之间可以很容易地连接起来,一个程序的标准输出可以作为另一个程序的标准输入。这是一个非常强大的工具。

从标准输入中读取

遵循标准 I/O 模型,Java 提供了标准输入流 System.in、标准输出流 System.out 和标准错误流 System.err。其中 System.out 已经被预先包装为 PrintStream 对象。System.err 也同样是一个 PrintStream,但是 System.in 是未经包装的原生 InputStream。这意味将虽然可以随时使用 System.outSystem.err,但是 System.in 则必须在读取前先进行包装。

从输入中读取时一般一次读取一行,要实现该功能,需要将 System.in 包装在 BufferedReader 中,这就需要用 InputStreamReader 将 System.in 转换为 Reader。下面这个示例会回显出你输人的每一行内容:

BufferedReader 中的 lines() 方法会返回 Stream<String>,体现出了流模型的灵活性:可以良好地处理标准输人。peek() 方法会重启 TimedAbort 以保持程序的开启状态(只要输人间断不超过5秒)。

将 System.out 转换为 PrintWriter

System.out 是一个 PrintStream,而 PrintStream 是一个 OutputStream。 PrintWriter 有一个以 OutputStream 作为参数的构造器。因此如果需要,可以使用这个构造器把 System.out 转换成 PrintWriter。

重点是要使用带有两个参数的 PrintWriter 构造器,并将第二个参数设为 true 来开启自动清空的功能,否则可能就看不到输出。

标准 I/O 重定向

Java的 System 类提供了简单的 static 方法调用,对标准输入流、标准输出流和标准错误流进行重定向:

  • setIn(InputStream)

  • setOut(PrintStream)

  • setErr(PrintStream)

如果你突然开始在屏幕上制造大量输出,而且输出滚动的速度快到让你看不清,这时可以重定向输出。而如果要用命令行程序反复测试特定的用户输入序列,这时重定向输人就很有用。下面示例演示了相关方法:

该程序将文件中内容载入到标准输入,并把标准输出和标准错误重定向到另一个文件。程序的开头存放了一个引用,指向原始的 System.out 对象,并在结尾将系统输出恢复到该对象上。

I/O 重定向操纵的是字节流,而不是字符流,因此这里实际使用的是 InputStream 和 OutputStream,而不是 Reader 和 Writer。

进程控制

Java 库提供了一些类,用来从 Java 内部执行操作系统程序,并控制这些程序的输人和输出。

一些常见任务,如运行某个程序,并将产生的输出发送到控制台。本节中有个实用工具可以简化这项任务。

该工具可能会出现两种类型的错误。第一种是导致异常的普通错误,对于这种错误只需要重新抛出 RuntimeException 异常即可。第二种是进程自身的执行过程中产生的错误,需要用一个单独的异常来报告这些错误:

如果要运行某个程序,就需要向 OSExecute.command() 中传入 String command,也就是你要在控制台中运行程序时所输人的那条命令。将这条命令传给 java.lang.ProcessBuilder 构造器(其参数为一个 String 对象序列),然后生成的 ProcessBuilder 对象就启动了:

如果要在该程序执行时捕获标准输出流,就需要调用 getInputStream()。这是因为 InputStream 就是用来从中读取数据的。

这里这些行只是被打印了出来,你也可以从 command() 捕获和返回它们。

该程序的错误被发送到了标准错误流,并通过调用 getErrorStream() 得以捕获。如果出现任何错误,这些错误便会显示出来,并抛出 OSExecuteException 异常,以便调用程序处理这个问题。

下面示例演示了 OSExecute 的用法:

这里用到了 javap 反编译器对程序进行反编译。

新 I/O 系统

Java 1.4 的 java.nio.* 包所引入的“新” I/O 库,目标只有一个:速度。

实际上,“旧” I/O 包已经用 nio 重新实现过了,以利用其带来的速度优势,因此即 使没有显式地用 nio 来写代码,也已经享受到了 nio 带来的好处。速度的提升在文件 I/O 及网络 I/O(比如可用于互联网编程)这两个方面均有体现。

速度的提升来自于使用了更接近操作系统 I/O 执行方式的结构:Channel(通道) 和 Buffer(缓冲区)。可以想象一个煤矿:通道就是连接矿层(数据)的矿井,缓冲区是运送煤矿的小车。通过小车装煤,再从车里取矿。换句话说,我们不能直接和 Channel 交互; 我们需要与 Buffer 交互并将 Buffer 中的数据发送到 Channel 中;Channel 需要从 Buffer 中提取或放入数据。

日常中使用的都已经包含在文件章节了,只有在遇到性能问题(比如在要用到内存映射文件的时候)或要实现自定义的 I/O 库时,才有必要理解 nio。

字节缓冲区 byteBuffer

ByteBuffer(即保存原生字节的缓冲区)是唯一直接和通道通信的类型。查看 java.nio.ByteBuffer 的 JDK 文档,会发现它很简单:只需要告诉它分配多少内存,就可以创建出一块字节缓冲区,并带有一些用于放入和取出数据(以原生字节的形式,或作为基本数据类型)的方法。但是并不能放入或取出对象,即使是基本的字符串类型。这是一种相当底层的操作方式,正因如此才可以在大多数操作系统中使(内存)映射更加高效。

“旧” I/O 库中有三个方法经过了修改,以用于生成 FileChannel:用于读的 FileInputStream,用于写的 FileOutputStream,以及既读又写的 RandomAccessFile。注意,这些都是操纵字节的流,和 nio 的底层性质一致。字符模式的 Reader 和 Writer 类不会生成通道,但是 java.nio.channels.Channels 类提供了工具方法来从通道生成 Reader 和 Writer。

下面练习这三个类型的流,生成可写,可读写,以及可读的通道:

对于这里出现的任何流类,都可以通过 getChannel() 生成一个 FileChannel。通道其实很简单:向其传入一个用于读写的 ByteBuffer,然后锁住文件区域以保证独占式访问。

将字节放入 ByteBuffer 的一种方法是,选择一个 get() 方法,用它将一个(或多个)字节或基本类型的值直接填充进去。不过如你所见,也可以用 wrap() 方法将已有的 byte 数组“包装”到 ByteBuffer 中。如果这么做,底层的数组就不再会被复制,而是充当所生成的 ByteBuffer 的存储。这样产生的 ByteBuffer 是数组“支持”的。

RandomAccessFile 再次打开了 data.txt 文件。注意,可以在文件中来回移动 FileChannel,这里它被移动到了尾部,然后就可以附上(向末尾)追加的写操作了。

如要进行只读的访问,就需要用 static allocate() 方法显式地分配一个 ByteBuffer。nio 的目标就是为了快速地移动大量数据,因此需要特别重视 ByteBuffer 的大小。(这里用的 1 KB对于真实场景应该小了,必须在实际运行的程序中验证,来找到最佳大小)

allocateDirect() 取代 allocate() 甚至可能达到更快速度,它会生成和操作系统结合度更高的“直接”缓冲区。然而,这种分配的开销更大,而且实际效果因操作系统的不同而有所不同,因此,在工作中你必须再次测试程序,以检验直接缓冲区是否能为你带来速度上的优势。

一旦调用了 read() 来让 FileChannel 将字节保存到 ByteBuffer,就必须在缓冲区上调用 flip(),使缓冲区做好被提取字节的准备。(看起来有点笨拙,但记住这是很底层的操作,目的是获得更快的速度)并且如果要用缓冲区来做进一步的 read() 操作,还需要调用 clear() 来让缓冲区为后续的每次 read() 做好准备。下面简单的文件复制程序演示了上述操作:

这里打开了两个 FileChannel,一个用于读,另一个用于写。然后分配了一个 ByteBuffer,如果 FileChannel.read() 返回了-1(毫无疑问,这是继承了 UNIX 和 C 的惯例),就说明输入流读取完了。在每次用 read() 将数据放入缓冲后,flip() 便会准备好缓冲区,以便 write() 提取它的信息。在 write() 运行完后,数据仍然在缓冲区中,需要 clear() 来重置所有内部指针,以便在下一次 read() 中接受数据。

不过上例并不是处理这种操作的理想方法。专用的 transferTo()transferFrom() 方法可以将一个通道直接连接到另一个通道:

该方法不常用到,但了解更好。

转换数据

在 GetChannel.java 中,为了将文件中的信息打印出来,将数据以一次一个 byte 的方式拉取出来,并将每个 byte 转型为 char。这种方法很原始,如果查看 java.nio.CharBuffer 类的内部,便会看到其中有个 toString() 方法,它可以“返回一个包含该缓冲区中字符的 String”。ByteBuffer 可以被看作一个带有 asCharBuffer() 方法的 CharBuffer,那么为什么不用呢?如下方输出语句中的第一行所示,这样是行不通的:

缓冲区中保存着简单的字节,为了将这些字节转换为字符,要么在将字节放入的时候进行编码(这样在读取出来的时候才会有意义),要么在将它们从缓冲区中读取出来的时候进行解码。可以通过 java.nio.charset.Charset 类来完成这些操作,这些类提供了将数据编码为多种不同字符集类型的工具:

因此,回到 BufterToText.java,如果对缓冲区执行 rewind()(回到数据的起始位置),然后使用该平台的默认字符集来 decode() 数据,那么得到的 CharBuffer 便会正确地显示在控制台上。可以通过 System.getProperty("file.encoding") 方法来查看平台默认字符集名称。将该名称传入 Charset.forName(),就能生成可对字符串进行解码的 Charset 对象了。

另一种可选方案是用某个可在读取文件时生成可打印内容的字符集来 encode()。该处使用了 UTF-16BE 来将文木写人到文件,而在读取时,需要做的就是将它转换为 CharBuffer,然后就能生成符合预期的文本内容了。

最后可以看到,如果通过 CharBuffer 来对 ByteBuffer 进行写入,会发生什么情况。注意,这里为 ByteBuffer 分配了 24 字节,而每个 char 需要 2 字节,因此可以容纳 12 个 char,但是“Some text”只有 9 个 char。其余的空内容字节仍然会出现在由 CharBuffer 的 toString() 所展示的内容中,如输出中所见。

获取基本类型

虽然 ByteBuffer 只能保存字节类型,但其中包含的方法可以从所保存的字节生成各种不同类型的基本类型值。下面这个示例演示了如何通过这些方法来插入和提取各种类型的值:

在为 ByteBuffer 分配内存后,会对它的值进行检查,以确认该分配是否自动清零了内容,全部 1024 个值都经过了检查(由缓冲区的上限决定,该上限可通过 limit() 得到,并且全都为零。

将基本类型值插入 ByteBuffer 的最简单的方法是通过 asCharBuffer()asShortBuffer() 等方法在该缓冲区上得到合适的“视图”,然后使用该视图的 put() 方法。该方法针对每种基本数据类型都有相应的实现。唯一看起来较奇怪的是针对 ShortBuffer 的 put() 方法,它需要进行类型转换(该转型会截断并改变生成的值)。其他所有视图缓冲区的 put() 方法都不需要进行转型。

视图缓冲区

“视图缓冲区”(view buffer)相当于透过某个特定基本类型的视角来看底层的 ByteBuffer(因此称为“视图”)。实际存储数据的仍然是 ByteBuffer,它“支撑”着该视图,因此对视图所做的任何修改都会映射为 ByteBuffer 中数据的修改。如前面示例,这种方式非常便于向 ByteBuffer 中插入基本类型。视图还可以用一次一个(ByteBuffer 支持的方式)或批量(以数组形式)的方式从 ByteBuffer 中读取基本类型的值。下面示例是通过 IntBuffer 操纵 ByteBuffer 中的 int:

首先用重载后的 put() 方法保存 int 数组。随后调用 get()put() 方法直接访问了底层 ByteBuFfer 中某个 int 的位置。注意此处是通过绝对地址访问的,也可以直接通过 ByteBuffer 以相同方式访问基本类型。

一旦通过视图缓中区向底层的 ByteBuffer 填入了 int 或其他基本类型后,该 ByteBuffer 就可以直接写人通道中了。可以同样轻松地从通道中读取数据,并使用视图缓冲区将所有内容都转换为指定的基本类型。下面这个示例通过在同一个 ByteBuffer 上建立不同的视图缓冲区,将同一个字节序列分别解析为 short、int、float、long以及 double:

ByteBuffer 是由一个 8 字节的数组“包装”而成的,随后通过各种不同基本类型的视图缓冲区将该数组显示出来。

字节序

不同的机器可以使用不同的字节排序方式保存数据。“大端”(big endian,即高位优先)方式将最高位的字节放在最低的内存地址(即内存起始地址),而“小端”(little endian,即低位优先)方式将最高位的字节放在最高的内存地址(即内存末尾地址)。在存储大于一个字节的值(如 int、float等)时,可能就需要考虑字节序问题了。ByteBuffer 以大端方式存储数据,数据在网络中传输时用的也都是大端序。可以通过向 order() 方法传入参数 ByteOrder.BIG_ENDIAN 或 ByteOrder.LITLE_ENDIAN 来改变 ByteBuffer 中的字节序。

给一个包含两个字节的 ByteBuffer: 00000000 01100001,如果以 short 形式(ByteBuffer.asShortBuffer())读取数据,会得到数字 97。(二进制为 00000000 01100001)改为小端方式后读取,还是这两个字节,却会得到数字 24832。(二进制为 01100001 0000000)

下面示例可以看出,字节的顺序是由字节序的设置决定的:

ByteBuffer 分配了一定内存空间,将 charArray 中的所有字节保存为一块扩展缓冲区,这样就可以调用 array() 方法来显示底层的字节了。array() 方法是“可选”(非必需)的,只能在基于数组的缓冲区上调用,否则便会抛出 UnsupportedOperationException 异常。

charArray 通过 CharBuffer 视图被插入 ByteBuffer 中。在底层字节显示出来后,可以看到默认的顺序和随后的大端序相同,而小端序则交换了字节的顺序。

用缓冲区操纵数据

下图表明了各种 nio 类之间的关系,并演示了如何移动和转换数据。例如,如果要将 byte 数组写人文件,就需要用 ByteBuffer.wrap() 方法将 byte 包装起来,并用 getChannel() 方法在 FileIntputStream 上打开一个通道,然后将 ByteBuffer 中的数据写人 FileChannel 中。

nio 类

ByteBuffer 是唯一可将数据移入或移出通道的方法,我们只能创建独立的基本类型缓冲区,或者通过"as"方法从 ByteBuffer 生成一个该缓冲区。也就是说,无法将基本类型的缓冲区转换为 ByteBuffer,不过由于可以通过视图缓冲区将基本类型数据移人或移出 ByteBuffer,因此这实际上也不成限制。

缓冲区的细节

Buffer 由数据和4个用于高效访问和操纵该数据的索引构成:mark(标记),position(位置),limit(界限)以及 capacity(容量)。下表列出了用于设置、重置及查询以上索引的方法:

方法
作用

capacity()

返回缓冲区的 capacity

clear()

清空缓冲区,将 position 设为 0,将 limit 设为 capacity。可以调用该方法来重置已存在的缓冲区

flip()

将 limit 设为 position,position 设为 0。该方法用于让缓冲区在数据写入完成后,进入准备读取的就绪状态

limit()

返回 limit 的值

limit(int lim)

设置 limit 的值

mark()

将 mark 设为 position

position()

返回 position 的值

position(int pos)

设置 position 的值

remaining()

返回 limit - position 后的值(即“剩余多少元素”)

hasRemaining()

如果在 position 和 limit 之间存在任何元素(即“还有剩余元素”),则返回 true

从缓冲区插入和提取数据的方法通过更新索引来反映所做的更改。下例使用了一种非常简单的算法(交换相邻字符)对 CharBuffer 中的字符进行加密(scramble)和解密(unscramble):

虽然可以通过调用 wrap() 方法并带上作为参数的 char 数组,由此直接生成 CharBuffer,但本例中并未这么做,而是分配了一个底层的 ByteBuffer,CharBuffer 只是作为视图在其上生成。由此凸显出我们的目标永远是操纵 ByteBuffer,因为它才是和通道交互的对象。

在进入 symmetricScramble() 方法时,缓冲区的索引状态为:pos->U, cap->s, lim->s。(这里实际 pos 为 0,cap 和 lim 都是 12)

其中的 while 循环会一直迭代执行,直到 position 等于 limit。也可以调用使用索引参数的绝对 get()put() 方法,但是这些方法不会修改 position 值。

调用 mark() 设置 mark 值,此时索引状态为:pos->U, mar->U, cap->s, lim->s。

连续的两次 get() 调用将前两个字符的值分别保存到变量 c1 和 c2 中。这之后,索引状态变为:pos->i, mar->U, cap->s, lim->s。

为了实现字符交换,需要在位置 0 处写入 c2,位置 1 处写入 c1。可以用绝对 put() 方法实现,也可以通过 reset() 将 position 的值设置为 mark,再两次 put() 调用先后写入 c2 和 c1。

在循环的下一轮迭代中,将 mark 设置为 position 的当前值,此时索引状态为 pos->i, mar->i, cap->s, lim->s。

该过程一直持续到整个缓冲区遍历完成。在 while() 循环的最后,position 指向了缓冲区的末尾。如果将该缓冲区显示出来,则只会显示出位于 position 和 limit 之间的字符(即为空)。因此,如果要显示出缓冲区的完整内容,就必须用 rewind() 方法将 position 设置为缓冲区的起始位置。

内存映射文件

内存映射文件(Memory-mapped file)让你可以创建和修改那些因为太大而无法加载到内存中的文件。有了内存映射文件,便可以假装整个文件都已加载在内存中,可以将它当作一个非常大的数组来访问。这种方法极大地简化了实现文件修改所需的代码:

为了能同时读写,首先要用到 RandomAccessFile,先为该文件生成管道,然后调用 map() 生成 MappedByteBuffer,这是一种特殊类型的直接缓冲区。你需要指定文件的起始位置和映射区域的长度一一这意味着你可以选择只映射大文件中的一块较小区域。

MappedByteBuffer 继承自 ByteBuffer,因此它拥有 ByteBuffer 的全部方法。此处只演示了最简单的 put()get() 的用法,但也可以使用诸如 asCharBuffer() 等方法。

本例创建的文件大小是 128MB,该文件似乎可以一次性全部访问到,这是因为只有其中一部分被加载到了内存中,而其他部分则被交换了出去。通过这种方式,即使是非常巨大的文件(最大可到 2G),也可以轻松地进行修改。注意,底层操作系统的文件映射能力的用途就是将性能提升最大化。

虽然“旧”流式 I/O 的性能在用 nio 重新实现后得到了改进,但是映射文件访问往往可以更大程度地提升速度。下面这段程序对性能做了简单的对比:

Tester 使用了模板方法(Template Method)设计模式,为匿名内部子类中定义的 test() 的各种实现创建了一套测试框架。这些子类中每一个都实现了一种测试,因此这些 test() 方法也算提供了一套实现各种 I/O 操作的原型参考。

虽然映射写操作看起来似乎应该使用 FileOutputStream,但是文件映射中的所有输出都必须使用 RandomAccessFile,正如前面的程序中的读写操作所做的那样。

注意,test() 方法的计时包括了各种 I/O 对象的初始化耗时,因此尽管映射文件的建立过程开销很大,但总的收益相较于流式 I/O 来说还是明显要大很多的。

文件加锁

对文件加锁会对文件的访问操作加上同步处理,这样文件才可以作为共享资源(而不会出现并发问题)。不过争用同一个文件的两个线程可能分别处在不同的 JVM 中,或者可能一个是 Java 线程,而另一个是操作系统中的某个本地线程。由于 Java 的文件加锁直接映射到了本地操作系统的加锁能力,因此文件锁对其他的操作系统进程也是可见的。

下面演示了基本的文件加锁:

通过在 FileChannel 上调用 tryLock()lock(),便可以获得整个文件上的 Filelock。SocketChannel,DatagramChannel 以及 ServerSocketChannel 并不需要上锁,因为它们天然就是单进程实体,你一般不会在两个进程间共享一个网络套接字。

  • tryLock() 是非阻塞操作。它会试图获取锁,但如果不成功(如果某个其他进程已经持有了同一个锁,并且该锁不可共享),便会直接从方法调用中返回。

  • lock() 则会一直阻塞,直到成功获得锁,或者调用 lock() 的线程被中断,抑或是调用 lock() 的通道被关闭。可以通过 FileLock.release() 释放锁。

也可以对文件的一部分上锁:tryLock(long position, long size, boolean shared) 或者 lock(long position, long size, boolean shared)。这样会对 (size - position) 区域上锁,第三个参数指定了锁是否可以共享。

无参数的加锁方法会根据文件大小的变化自动调整,但是固定大小的锁不会随着文件大小的变化而变化。如果获得的是从 position 到 position + size 范围的锁,而文件增大到超出了 position + size 的范围,那么超出 position + size 的范围便不会被锁住。而无参数的加锁方法会锁住整个文件,即使文件后来增大了。

排他锁或共享锁必须由底层操作系统提供支持。如果操作系统不支持共享锁,却请求共享锁,那么就会用排他锁来代替。可以通过 FileLock.isShared() 查询是否为共享锁。

部分映射文件上锁

文件映射一般用于非常大的文件。你可能会只想对这类文件中的某些部分加锁,这样其他的进程就可以修改未加锁的部分。比如,数据库就必须允许很多用户同时访问。

下例中的两个线程,每一个都对文件的不同部分加上了锁:

LockAndModify 线程类设置了缓冲区域,并创建了要修改的 slice()(片段),而 run() 中则获取到了文件通道上的锁(无法获取缓冲区上的锁,只能获得通道上的锁)。对 lock() 的调用和获取对象上的线程锁非常相似一一这样便在文件上得到了一个具备排他访问权限的“临界区”。

如果 JVM 退出,或获得锁的通道关闭,锁也会随即被自动释放,但也可以显式地在 FileLock 对象上调用 release(),如上所示。

最后更新于