《Java 编程的逻辑》笔记——第13章 文件基本技术(一)

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

13.1 文件概述

本节主要介绍文件有关的一些基本概念和常识,Java 中处理文件的基本思路和类结构,以及接来下章节的安排思路。

13.1.1 基本概念和常识

下面,我们先介绍一些基本概念和常识,包括二进制思维、文件类型、文本文件的编码、文件系统和文件读写等。

13.1.1.1 二进制思维

为了透彻理解文件,我们首先要有一个二进制思维。所有文件,不论是可执行文件、图片文件、视频文件、Word 文件、压缩文件、txt 文件,都没什么可神秘的,它们都是以 0 和 1 的二进制形式保存的。我们所看到的图片、视频、文本,都是应用程序对这些二进制的解析结果。

作为程序员,我们应该有一个编辑器,能查看文件的二进制形式,比如 UltraEdit,它支持以十六进制进行查看和编辑。比如说,一个文本文件,看到的内容为:

hello, 123, 老马

打开十六进制编辑,看到的内容如图 13-1 所示。

在这里插入图片描述

左边的部分就是其对应的十六进制,“hello” 对应的十六进制是 “68 65 6C 6C 6F”,对应 ASCII 码编号 “104 101 108 108 111”,“马” 对应的十六进制是 “E9 A9 AC”,这是 “马” 的 UTF-8 编码。

13.1.1.2 文件类型

虽然所有数据都是以二进制形式保存的,但为了方便处理数据,高级语言引入了数据类型的概念,文件处理也类似,所有文件都是以二进制形式保存的,但为了便于理解和处理文件,文件也有文件类型的概念。

文件类型通常以后缀名的形式体现,比如,PDF 文件类型的后缀是 .pdf,图片文件的一种常见后缀是 .jpg,压缩文件的一种常见后缀是 .zip。每种文件类型都有一定的格式,代表着文件含义和二进制之间的映射关系。比如一个 Word 文件,其中有文本、图片、表格,文本可能有颜色、字体、字号等,doc 文件类型就定义了这些内容和二进制表示之间的映射关系。有的文件类型的格式是公开的,有的可能是私有的,我们也可以定义自己私有的文件格式。

对于一种文件类型,往往有一种或多种应用程序可以解读它,进行查看和编辑,一个应用程序往往可以解读一种或多种文件类型。

在操作系统中,一种后缀名往往关联一个应用程序,比如 .doc 后缀关联 Word 应用。用户通过双击试图打开某后缀名的文件时,操作系统查找关联的应用程序,启动该程序,传递该文件路径给它,程序再打开该文件。

需要说明的是,给文件加正确的后缀名是一种惯例,但并不是强制的,如果后缀名和文件类型不匹配,应用程序试图打开该文件时可能会报错。另外,一个文件可以选择使用多种应用程序进行解读,在操作系统中,一般通过右键单击文件,选择打开方式即可。

文件类型可以粗略分为两类,一类是文本文件,另一类是二进制文件。文本文件的例子有普通的 .txt 文件,程序源代码文件 .java,HTML 文件 .html 等,二进制文件的例子有压缩文件 .zip,pdf 文件,mp3 文件,excel 文件等。

基本上,文本文件里的每个二进制字节都是某个可打印字符的一部分,都可以用最基本的文本编辑器进行查看和编辑,如 Windows 上的 notepad,Linux 上的 vi。

二进制文件中,每个字节就不一定表示字符,可能表示颜色、可能表示字体、可能表示声音大小等,如果用基本的文本编辑器打开,一般都是满屏的乱码,需要专门的应用程序进行查看和编辑。

13.1.1.3 文本文件的编码

对于文本文件,我们还必须注意文件的编码方式。文本文件中包含的基本都是可打印字符,但字符到二进制的映射,即编码,却有多种方式,如 GB18030,UTF-8,我们在第 2 章详细介绍过各种编码,这里就不赘述了。

对于一个给定的文本文件,它采用的是什么编码方式呢?一般而言,我们是不知道的。那应用程序用什么编码方式进行解读呢?一般使用某种默认的编码方式,可能是应用程序默认的,也可能是操作系统默认的,当然也可能采用一些比较智能的算法自动推断编码方式。

对于 UTF-8 编码的文件,我们需要特别说明一下,有一种方式,可以标记该文件是 UTF-8 编码的,那就是在文件最开头,加入三个特殊字节 (0xEF 0xBB 0xBF),这三个特殊字节被称为 BOM 头,BOM 是 Byte Order Mark (即字节序标记) 的缩写。比如,对前面的 hello.txt 文件,带 BOM 头的 UTF-8 编码的十六进制形式如图 13-2 所示。

在这里插入图片描述

图 13-1 和图 13-2 都是 UTF-8 编码,看到的字符内容也一样,但二进制内容不一样,一个带 BOM 头,一个不带 BOM 头。

需要注意的是,带 BOM 头的 UTF-8 编码文件不是所有应用程序都支持的,比如 PHP 就不支持 BOM,如果你的 PHP 源代码文件带 BOM 头的,PHP 运行就会出错,碰到这种问题时,前面介绍的二进制思维就特别重要,不要只看文件的显示,还要看文件背后的二进制。

另外,我们需要说明下文本文件的换行符,在 Windows 系统中,换行符一般是两个字符 “\r\n”,即 ASCII 码的13(’\r’) 和 10(’\n’),在 Linux 系统中,换行符一般是一个字符 “\n”。

13.1.1.4 文件系统

文件一般是放在硬盘上的,一个机器上可能有多个硬盘,但各种操作系统都会隐藏物理硬盘概念,提供一个逻辑上的统一结构。比如,在 Windows 中,可以有多个逻辑盘,C,D,E 等,每个盘可以被格式化为一种不同的文件系统,常见的文件系统有 FAT32 和 NTFS。在 Linux 中,只有一个逻辑的根目录,用斜线 / 表示,Linux 支持多种不同的文件系统,如 Ext2/Ext3/Ext4 等。不同的文件系统有不同的文件组织方式、结构和特点,不过,一般编程时,语言和类库为我们提供了统一的 API,我们并不需要关心其细节。

在逻辑上,Windows 中就是有多个根目录,Linux 就是有一个根目录,每个根目录下就是一颗子目录和文件构成的树。每个文件都有文件路径的概念,路径有两种形式,一种是绝对路径,另一种是相对路径

所谓绝对路径就是从根目录开始到当前文件的完整路径,在 Windows 中,目录之间用反斜线分隔,如 “C:\code\hello.java”,在 Linux 中,目录之间用斜线分隔,如 “/Users/laoma/Desktop/code/hello.java”。在 Java 中,java.io.File 类定义了一个静态变量 File.separator,表示路径分隔符,编程时应使用该变量而避免硬编码

所谓相对路径是相对于当前目录而言的,在命令行终端上,通过 cd 命令进入到的目录就是当前目录,在 Java 中,通过 System.getProperty(“user.dir”) 可以得到运行 Java 程序的当前目录,相对路径不以根目录开头,比如在 Windows 上,当前目录为 “D:\laoma”,相对路径为 “code\hello.java”,则完整路径为 “D:\laoma\code\hello.java”。

每个文件除了有具体内容,还有元数据信息,如文件名、创建时间、修改时间、文件大小等。文件还有一个是否隐藏的性质,在 Linux 系统中,如果文件名以 . 开头,则为隐藏文件,在 Windows 系统中,隐藏是文件的一个属性,可以进行设置。

大部分文件系统,每个文件和目录还有访问权限的概念,对所有者、用户组可以有不同的权限,权限具体包括读、写、执行。

文件名有大小写是否敏感的概念,在 Windows 系统中,一般是大小写不敏感的,而 Linux 则一般是大小写敏感的,也就是说,同一个目录下,“abc.txt” 和 “ABC.txt” 在 Windows 中被视为同一个文件,而 Linux 视为不同的文件。

操作系统中有一个临时文件的概念,临时文件位于一个特定目录,比如 Windows 7,一般位于 “C:\Users\用户名\AppData\Local\Temp”,Linux 系统,位于 “/tmp”,操作系统会有一定的策略自动清理不用的临时文件。临时文件一般不是用户手工创建的,而是应用程序产生的,用于临时目的。

13.1.1.5 文件读写

文件是放在硬盘上的,程序处理文件需要将文件读入内存,修改后,需要写回硬盘。操作系统提供了对文件读写的基本 API,不同操作系统的接口和实现是不一样的,不过,有一些共同的概念,Java 封装了操作系统的功能,提供了统一的 API。

一个基本常识是,硬盘的访问延时,相比内存,是很慢的。操作系统和硬盘一般是按块批量传输,而不是按字节,以摊销延时开销,块大小一般至少为 512 字节,即使应用程序只需要文件的一个字节,操作系统也会至少将一个块读进来。一般而言,应尽量减少接触硬盘,接触一次,就一次多做一些事情,对于网络请求,和其他输入输出设备,原则都是类似的。

另一个基本常识是,一般读写文件需要两次数据拷贝,比如读文件,需要先从硬盘拷贝到操作系统内核,再从内核拷贝到应用程序分配的内存中,操作系统运行所在的环境和应用程序是不一样的,操作系统所在的环境是内核态,应用程序是用户态,应用程序调用操作系统的功能,需要两次环境的切换,先从用户态切到内核态,再从内核态切到用户态,问题是,这种用户态/内核态的切换是有开销的,应尽量减少这种切换

为了提升文件操作的效率,应用程序经常使用一种常见的策略,即使用缓冲区。读文件时,即使目前只需要少量内容,但预知还会接着读取,就一次读取比较多的内容,放到读缓冲区,下次读取时,缓冲区有,就直接从缓冲区读,减少访问操作系统和硬盘。写文件时,先写到写缓冲区,写缓冲区满了之后,再一次性的调用操作系统写到硬盘。不过,需要注意的是,在写结束的时候,要记住将缓冲区的剩余内容同步到硬盘。操作系统自身也会使用缓冲区,不过,应用程序更了解读写模式,恰当使用往往可以有更高的效率。

操作系统操作文件一般有打开和关闭的概念,打开文件会在操作系统内核建立一个有关该文件的内存结构,这个结构一般通过一个整数索引来引用,这个索引一般称为文件描述符,这个结构是消耗内存的,操作系统能同时打开的文件一般也是有限的,在不用文件的时候,应该记住关闭文件,关闭文件一般会同步缓冲区内容到硬盘,并释放占据的内存结构。

操作系统一般支持一种称之为内存映射文件的高效的随机读写大文件的方法,将文件直接映射到内存,操作内存就是操作文件,在内存映射文件中,只有访问到的数据才会被实际拷贝到内存,且数据只会拷贝一次,被操作系统以及多个应用程序共享。

13.1.2 Java 文件概述

13.1.2.1 流

在 Java 中(很多其他语言也类似),文件一般不是单独处理的,而是视为输入输出(IO - Input/Output)设备的一种。Java 使用基本统一的概念处理所有的 IO,包括键盘、显示终端、网络等。

这个统一的概念是,流有输入流输出流,输入流就是可以从中获取数据,输入流的实际提供者可以是键盘、文件、网络等,输出流就是可以向其中写入数据,输出流的实际目的地可以是显示终端、文件、网络等。

Java IO 的基本类大多位于包 java.io 中,类 InputStream 表示输入流,OutputStream 表示输出流,而 FileInputStream 表示文件输入流,FileOutputStream 表示文件输出流。

有了流的概念,就有了很多面向流的代码,比如对流做加密、压缩、计算信息摘要、计算检验和等,这些代码接受的参数和返回结果都是抽象的流,它们构成了一个协作体系,这类似于之前介绍的接口概念、面向接口的编程、以及容器类协作体系。一些实际上不是IO的数据源和目的地也转换为了流,以方便参与这种协作,比如字节数组,也包装为了流 ByteArrayInputStream 和 ByteArrayOutputStream。

13.1.2.2 装饰器设计模式

基本的流按字节读写,没有缓冲区,这不方便使用,Java 解决这个问题的方法是使用装饰器设计模式,引入了很多装饰类,对基本的流增加功能,以方便使用,一般一个类只关注一个方面,实际使用时,经常会需要多个装饰类。

Java 中有很多装饰类,有两个基类,过滤器输入流 FilterInputStream 和过滤器输出流 FilterOutputStream,所谓过滤,就类似于自来水管道,流入的是水,流出的也是水,功能不变,或者只是增加功能,它有很多子类,这里列举一些:

  • 对流起缓冲装饰的子类是 BufferedInputStream 和 BufferedOutputStream。
  • 可以按八种基本类型和字符串对流进行读写的子类是 DataInputStream 和 DataOutputStream。
  • 可以对流进行压缩和解压缩的子类有 GZIPInputStream, ZipInputStream, GZIPOutputStream, ZipOutputStream。
  • 可以将基本类型、对象输出为其字符串表示的子类有 PrintStream。

众多的装饰类,使得整个类结构变的比较复杂,完成基本的操作也需要比较多的代码,但优点是非常灵活,在解决某些问题时也很优雅。

13.1.2.3 Reader/Writer

以 InputStream/OutputStream 为基类的流基本都是以二进制形式处理数据的,不能够方便的处理文本文件,没有编码的概念,能够方便的按字符处理文本数据的基类是 Reader 和 Writer,它也有很多子类:

  • 读写文件的子类是 FileReader 和 FileWriter。
  • 起缓冲装饰的子类是 BufferedReader 和 BufferedWriter。
  • 将字符数组包装为 Reader/Writer 的子类是 CharArrayReader 和 CharArrayWriter。
  • 将字符串包装为 Reader/Writer 的子类是 StringReader 和 StringWriter。
  • 将 InputStream/OutputStream 转换为 Reader/Writer 的子类是 InputStreamReader OutputStreamWriter。
  • 将基本类型、对象输出为其字符串表示的子类 PrintWriter。

13.1.2.4 随机读写文件

大部分情况下,使用流或 Reader/Writer 读写文件内容,但 Java 提供了一个独立的可以随机读写文件的类 RandomAccessFile,适用于大小已知的记录组成的文件,我们日常应用开发中用的会比较少,但在一些系统程序中用到的会比较多。

13.1.2.5 File

上面介绍的都是操作数据本身,而关于文件路径、文件元数据、文件目录、临时文件、访问权限管理等,Java 使用 File 这个类来表示。

13.1.2.6 Java NIO

以上介绍的类基本都位于包 java.io 下,Java 还有一个关于 IO 操作的包 java.nio,nio 表示 New IO,这个包下同样包括大量的类。

NIO 代表一种不同的看待 IO 的方式,它有缓冲区通道的概念,利用缓冲区和通道往往可以达成和流类似的目的,不过,它们更接近操作系统的概念,某些操作的性能也更高。比如,拷贝文件到网络,通道可以利用操作系统和硬件提供的 DMA 机制(Direct Memory Access,直接内存存取) ,不用 CPU 和应用程序参与,直接将数据从硬盘拷贝到网卡。

除了看待方式不同,NIO 还支持一些比较底层的功能,如内存映射文件、文件加锁、自定义文件系统、非阻塞式 IO、异步 IO 等。

不过,这些功能要么是比较底层,普通应用程序用到的比较少,要么主要适用于网络 IO 操作,我们大多不会介绍,只会介绍内存映射文件。

13.1.2.7 序列化和反序列化

简单来说,序列化就是将内存中的 Java 对象持久保存到一个流中,反序列化就是从流中恢复 Java 对象到内存。序列化/反序列化主要有两个用处,一个是对象状态持久化,另一个是网络远程调用,用于传递和返回对象

Java 主要通过接口 Serializable 和类 ObjectInputStream/ObjectOutputStream 提供对序列化的支持,基本的使用是比较简单的,但也有一些复杂的地方。

不过,Java 的默认序列化有一些缺点,比如,序列化后的形式比较大、浪费空间,序列化/反序列化的性能也比较低,更重要的问题是,它是 Java 特有的技术,不能与其他语言交互。

XML 是前几年最为流行的描述结构性数据的语言和格式,Java 对象也可以序列化为 XML 格式,XML 容易阅读和编辑,且可以方便的与其他语言进行交互。

XML 强调格式化但比较"笨重",JSON 是近几年来逐渐流行的轻量级的数据交换格式,在很多场合替代了 XML,也非常容易阅读和编辑,Java 对象也可以序列化为 JSON 格式,且与其他语言进行交互。

XML 和 JSON 都是文本格式,人容易阅读,但占用的空间相对大一些,在只用于网络远程调用的情况下,有很多流行的、跨语言的、精简且高效的对象序列化机制,如 ProtoBuf, Thrift, MessagePack 等。MessagePack 是二进制形式的 JSON,更小更快。

13.1.3 小结

本节介绍了关于文件的一些基本概念和常识,Java中处理文件的基本思路和类结构,最后我们总结了接下来的章节安排思路。

文件看上去应该很简单,但实际却包含很多内容,让我们耐住性子,下一节,先从二进制开始吧。

13.2 二进制文件和字节流

本节我们介绍在 Java 中如何以二进制字节的方式来处理文件,上节我们提到 Java 中有流的概念,以二进制方式读写的主要流有

  • InputStream/OutputStream: 这是基类,它们是抽象类。
  • FileInputStream/FileOutputStream: 输入源和输出目标是文件的流。
  • ByteArrayInputStream/ByteArrayOutputStream: 输入源和输出目标是字节数组的流。
  • DataInputStream/DataOutputStream: 装饰类,按基本类型和字符串而非只是字节读写流。
  • BufferedInputStream/BufferedOutputStream: 装饰类,对输入输出流提供缓冲功能。

下面,我们就来介绍这些类的功能、用法、原理和使用场景,最后,我们总结一些简单的实用方法。

13.2.1 InputStream/OutputStream

13.2.1.1 InputStream 的基本方法

InputStream 是抽象类,主要方法是:

public abstract int read() throws IOException;

read 从流中读取下一个字节,返回类型为 int,但取值在 0 到 255 之间,当读到流结尾的时候,返回值为 -1,如果流中没有数据,read 方法会阻塞直到数据到来、流关闭、或异常出现。异常出现时,read 方法抛出异常,类型为 IOException,这是一个受检异常,调用者必须进行处理。read 是一个抽象方法,具体子类必须实现,FileInputStream 会调用本地方法,所谓本地方法,一般不是用 Java 写的,大多使用 C 语言实现,具体实现往往与虚拟机和操作系统有关。

InputStream 还有如下方法,可以一次读取多个字节:

public int read(byte b[]) throws IOException

读入的字节放入参数数组 b 中,第一个字节存入 b[0],第二个存入 b[1],以此类推,一次最多读入的字节个数为数组 b 的长度,但实际读入的个数可能小于数组长度,返回值为实际读入的字节个数。如果刚开始读取时已到流结尾,则返回 -1,否则,只要数组长度大于 0,该方法都会尽力至少读取一个字节,如果流中一个字节都没有,它会阻塞,异常出现时也是抛出 IOException。该方法不是抽象方法,InputStream 有一个默认实现,主要就是循环调用读一个字节的 read 方法,但子类如 FileInputStream 往往会提供更为高效的实现。

批量读取还有一个更为通用的重载方法:

public int read(byte b[], int off, int len) throws IOException

读入的第一个字节放入 b[off],最多读取 len 个字节,read(byte b[]) 就是调用了该方法:

public int read(byte b[]) throws IOException {
    
    
    return read(b, 0, b.length);
}

流读取结束后,应该关闭,以释放相关资源,关闭方法为:

public void close() throws IOException

不管 read 方法是否抛出了异常,都应该调用 close 方法,所以 close 通常应该放在 finally 语句内。close 自己可能也会抛出 IOException,但通常可以捕获并忽略

13.2.1.2 InputStream 的高级方法

InputStream 还定义了如下方法:

public long skip(long n) throws IOException
public int available() throws IOException
public synchronized void mark(int readlimit)
public boolean markSupported()
public synchronized void reset() throws IOException

skip 跳过输入流中 n 个字节,因为输入流中剩余的字节个数可能不到 n,所以返回值为实际略过的字节个数。InputStream 的默认实现就是尽力读取 n 个字节并扔掉,子类往往会提供更为高效的实现,FileInputStream 会调用本地方法。在处理数据时,对于不感兴趣的部分,skip 往往比读取然后扔掉的效率要高。

available 返回下一次不需要阻塞就能读取到的大概字节个数。InputStream 的默认实现是返回 0,子类会根据具体情况返回适当的值,FileInputStream 会调用本地方法。在文件读写中,这个方法一般没什么用,但在从网络读取数据时,可以根据该方法的返回值在网络有足够数据时才读,以避免阻塞。

一般的流读取都是一次性的,且只能往前读,不能往后读,但有时可能希望能够先看一下后面的内容,根据情况,再重新读取。比如,处理一个未知的二进制文件,我们不确定它的类型,但可能可以通过流的前几十个字节判断出来,判读出来后,再重置到流开头,交给相应类型的代码进行处理。

InputStream 定义了三个方法,mark/reset/markSupported,用于支持从读过的流中重复读取。怎么重复读取呢?先使用 mark 方法将当前位置标记下来,在读取了一些字节,希望重新从标记位置读时,调用 reset 方法。

能够重复读取不代表能够回到任意的标记位置,mark 方法有一个参数 readLimit,表示在设置了标记后,能够继续往后读的最多字节数,如果超过了,标记会无效。为什么会这样呢?因为之所以能够重读,是因为流能够将从标记位置开始的字节保存起来,而保存消耗的内存不能无限大,流只保证不会小于 readLimit。

不是所有流都支持 mark/reset 的,是否支持可以通过 markSupported 的返回值进行判断。InpuStream 的默认实现是不支持,FileInputStream 也不直接支持,但 BufferedInputStream 和 ByteArrayInputStream 可以。

13.2.1.3 OutputStream

OutputStream 的基本方法是:

public abstract void write(int b) throws IOException;

向流中写入一个字节,参数类型虽然是 int,但其实只会用到最低的 8 位。这个方法是抽象方法,具体子类必须实现,FileInputStream 会调用本地方法。

OutputStream 还有两个批量写入的方法

public void write(byte b[]) throws IOException
public void write(byte b[], int off, int len) throws IOException

在第二个方法中,第一个写入的字节是 b[off],写入个数为 len,最后一个是 b[off+len-1],第一个方法等同于调用:write(b, 0, b.length);。OutputStream 的默认实现是循环调用单字节的 write 方法,子类往往有更为高效的实现,FileOutpuStream 会调用对应的批量写本地方法。

OutputStream 还有两个方法:

public void flush() throws IOException
public void close() throws IOException

flush 将缓冲而未实际写的数据进行实际写入,比如,在 BufferedOutputStream 中,调用 flush 会将其缓冲区的内容写到其装饰的流中,并调用该流的 flush 方法。基类 OutputStream 没有缓冲,flush 代码为空。

需要说明的是文件输出流 FileOutputStream,你可能会认为,调用flush会强制确保数据保存到硬盘上,但实际上不是这样,FileOutputStream 没有缓冲,没有重写 flush,调用 flush 没有任何效果,数据只是传递给了操作系统,但操作系统什么时候保存到硬盘上,这是不一定的。要确保数据保存到了硬盘上,可以调用 FileOutputStream 中的特有方法。

close 一般会首先调用 flush,然后再释放流占用的系统资源。同 InputStream 一样,close 一般应该放在 finally 语句内

13.2.2 FileInputStream/FileOutputStream

13.2.2.1 FileOutputStream

FileOutputStream 的主要构造方法有:

public FileOutputStream(File file) throws FileNotFoundException
public FileOutputStream(File file, boolean append) throws FileNotFoundException
public FileOutputStream(String name) throws FileNotFoundException
public FileOutputStream(String name, boolean append) throws FileNotFoundException

有两类参数,一类是文件路径,可以是 File 对象 file,也可以是文件路径 name,路径可以是绝对路径,也可以是相对路径,如果文件已存在,append 参数指定是追加还是覆盖,true 表示追加,没传 append 参数表示覆盖。new 一个 FileOutputStream 对象会实际打开文件,操作系统会分配相关资源。如果当前用户没有写权限,会抛出异常 SecurityException,它是一种 RuntimeException。如果指定的文件是一个已存在的目录,或者由于其他原因不能打开文件,会抛出异常 FileNotFoundException,它是 IOException 的一个子类。

我们看一段简单的代码,将字符串 “hello, 123, 老马” 写到文件 hello.txt 中:

OutputStream output =  new FileOutputStream("hello.txt");
try{
    
    
    String data = "hello, 123, 老马";
    byte[] bytes = data.getBytes(Charset.forName("UTF-8"));
    output.write(bytes);
}finally{
    
    
    output.close();
}

OutputStream 只能以 byte 或 byte 数组写文件,为了写字符串,我们调用 String 的 getBytes 方法得到它的 UTF-8 编码的字节数组,再调用 write 方法,写的过程放在 try 语句内,在 finally 语句中调用 close 方法。

FileOutputStream 还有两个额外的方法:

public FileChannel getChannel()
public final FileDescriptor getFD()

FileChannel 定义在 java.nio 中,表示文件通道概念,我们不会深入介绍通道,但内存映射文件方法定义在 FileChannel 中,我们会在后续章节介绍。FileDescriptor 表示文件描述符,它与操作系统的一些文件内存结构相连,在大部分情况下,我们不会用到它,不过它有一个方法 sync:

public native void sync() throws SyncFailedException;

这是一个本地方法,它会确保将操作系统缓冲的数据写到硬盘上。注意与 OutputStream 的 flush 方法相区别,flush 只能将应用程序缓冲的数据写到操作系统,sync 则确保数据写到硬盘,不过一般情况下,我们并不需要手工调用它,只要操作系统和硬件设备没问题,数据迟早会写入,但在一定特定情况下,一定需要确保数据写入硬盘,则可以调用该方法。

13.2.2.2 FileInputStream

FileInputStream 的主要构造方法有:

public FileInputStream(String name) throws FileNotFoundException
public FileInputStream(File file) throws FileNotFoundException

参数与 FileOutputStream 类似,可以是文件路径或 File 对象,但必须是一个已存在的文件,不能是目录。new 一个 FileInputStream 对象也会实际打开文件,操作系统会分配相关资源,如果文件不存在,会抛出异常 FileNotFoundException,如果当前用户没有读的权限,会抛出异常 SecurityException。

我们看一段简单的代码,将上面写入的文件 “hello.txt” 读到内存并输出:

InputStream input = new FileInputStream("hello.txt");
try{
    
    
    byte[] buf = new byte[1024];
    int bytesRead = input.read(buf);    
    String data = new String(buf, 0, bytesRead, "UTF-8");
    System.out.println(data);
}finally{
    
    
    input.close();
}

读入到的是 byte 数组,我们使用 String 的带编码参数的构造方法将其转换为了 String。这段代码假定一次 read 调用就读到了所有内容,且假定字节长度不超过 1024。为了确保读到所有内容,可以逐个字节读取直到文件结束:

int b = -1;
int bytesRead = 0;
while((b=input.read())!=-1){
    
    
    buf[bytesRead++] = (byte)b;
}

在没有缓冲的情况下逐个字节读取性能很低,可以使用批量读入且确保读到文件结尾,如下所示:

byte[] buf = new byte[1024];
int off = 0;
int bytesRead = 0;
while((bytesRead=input.read(buf, off, 1024-off ))!=-1){
    
    
    off += bytesRead;
}    
String data = new String(buf, 0, off, "UTF-8");

不过,这还是假定文件内容长度不超过一个固定的大小 1024。如果不确定文件内容的长度,不希望一次性分配过大的 byte 数组,又希望将文件内容全部读入,怎么做呢?可以借助 ByteArrayOutputStream

13.2.3 ByteArrayInputStream/ByteArrayOutputStream

13.2.3.1 ByteArrayOutputStream

ByteArrayOutputStream 的输出目标是一个 byte 数组,这个数组的长度是根据数据内容动态扩展的。它有两个构造方法:

public ByteArrayOutputStream()
public ByteArrayOutputStream(int size) 

第二个构造方法中的 size 指定的就是初始的数组大小,如果没有指定,长度为 32。在调用 write 方法的过程中,如果数组大小不够,会进行扩展,扩展策略同样是指数扩展,每次至少增加一倍。

ByteArrayOutputStream 有如下方法,可以方便的将数据转换为字节数组或字符串

public synchronized byte[] toByteArray()
public synchronized String toString()
public synchronized String toString(String charsetName)

toString() 方法使用系统默认编码。

ByteArrayOutputStream 中的数据也可以方便的写到另一个 OutputStream:

public synchronized void writeTo(OutputStream out) throws IOException

ByteArrayOutputStream 还有如下额外方法:

public synchronized int size()
public synchronized void reset()

size 返回当前写入的字节个数。reset 重置字节个数为 0,reset 后,可以重用已分配的数组。

使用 ByteArrayOutputStream,我们可以改进上面的读文件代码,确保将所有文件内容读入:

InputStream input = new FileInputStream("hello.txt");
try{
    
    
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    byte[] buf = new byte[1024];
    int bytesRead = 0;
    while((bytesRead=input.read(buf))!=-1){
    
    
        output.write(buf, 0, bytesRead);
    }    
    String data = output.toString("UTF-8");
    System.out.println(data);
}finally{
    
    
    input.close();
}

读入的数据先写入 ByteArrayOutputStream 中,读完后,再调用其 toString 方法获取完整数据

13.2.3.2 ByteArrayInputStream

ByteArrayInputStream 将 byte 数组包装为一个输入流,是一种适配器模式,它的构造方法有:

public ByteArrayInputStream(byte buf[])
public ByteArrayInputStream(byte buf[], int offset, int length)

第二个构造方法以 buf 中 offset 开始 length 个字节为背后的数据。ByteArrayInputStream 的所有数据都在内存,支持 mark/reset 重复读取。

为什么要将 byte 数组转换为 InputStream 呢?这与容器类中要将数组、单个元素转换为容器接口的原因是类似的,有很多代码是以 InputStream/OutputSteam 为参数构建的,它们构成了一个协作体系,将 byte 数组转换为 InputStream 可以方便的参与这种体系,复用代码。

13.2.4 DataInputStream/DataOutputStream

上面介绍的类都只能以字节为单位读写,如何以其他类型读写呢?比如 int, double。可以使用 DataInputStream/DataOutputStream,它们都是装饰类

13.2.4.1 DataOutputStream

DataOutputStream 是装饰类基类 FilterOutputStream 的子类,FilterOutputStream 是 OutputStream 的子类,它的构造方法是:

public FilterOutputStream(OutputStream out)

它接受一个已有的 OutputStream,基本上将所有操作都代理给了它。

DataOutputStream 实现了 DataOutput 接口,可以以各种基本类型和字符串写入数据,部分方法如下:

void writeBoolean(boolean v) throws IOException;
void writeInt(int v) throws IOException;
void writeDouble(double v) throws IOException;
void writeUTF(String s) throws IOException;

在写入时,DataOutputStream 会将这些类型的数据转换为其对应的二进制字节,比如:

  • writeBoolean: 写入一个字节,如果值为 true,则写入 1,否则 0
  • writeInt: 写入四个字节,最高位字节先写入,最低位最后写入
  • writeUTF: 将字符串的 UTF-8 编码字节写入,这个编码格式与标准的 UTF-8 编码略有不同,不过,我们不用关心这个细节。

与 FilterOutputStream 一样,DataOutputStream 的构造方法也是接受一个已有的 OutputStream:

public DataOutputStream(OutputStream out)

我们来看一个例子,保存一个学生列表到文件中,学生类的定义为:

class Student {
    
    
    String name;
    int age;
    double score;
    
    public Student(String name, int age, double score) {
    
    
         ...
    }
    ...
}    

我们省略了构造方法和 getter/setter 方法,学生列表内容为:

List<Student> students = Arrays.asList(new Student[]{
    
    
        new Student("张三", 18, 80.9d),
        new Student("李四", 17, 67.5d)
});

将该列表内容写到文件 students.dat 中的代码可以为:

public static void writeStudents(List<Student> students) throws IOException{
    
    
    DataOutputStream output = new DataOutputStream(
            new FileOutputStream("students.dat"));
    try{
    
    
        output.writeInt(students.size());
        for(Student s : students){
    
    
            output.writeUTF(s.getName());
            output.writeInt(s.getAge());
            output.writeDouble(s.getScore());
        }
    }finally{
    
    
        output.close();
    }
}

我们先写了列表的长度,然后针对每个学生、每个字段,根据其类型调用了相应的 write 方法。

13.2.4.2 DataInputStream

DataInputStream 是装饰类基类 FilterInputStream 的子类,FilterInputStream 是 InputStream 的子类。

DataInputStream 实现了 DataInput 接口,可以以各种基本类型和字符串读取数据,部分方法如下:

boolean readBoolean() throws IOException;
int readInt() throws IOException;
double readDouble() throws IOException;
String readUTF() throws IOException;

在读取时,DataInputStream 会先按字节读进来,然后转换为对应的类型。

DataInputStream 的构造方法接受一个 InputStream:

public DataInputStream(InputStream in)

还是以上面的学生列表为例,我们来看怎么从文件中读进来:

public static List<Student> readStudents() throws IOException{
    
    
    DataInputStream input = new DataInputStream(
            new FileInputStream("students.dat"));
    try{
    
    
        int size = input.readInt();
        List<Student> students = new ArrayList<Student>(size);
        for(int i=0; i<size; i++){
    
    
            Student s = new Student();
            s.setName(input.readUTF());
            s.setAge(input.readInt());
            s.setScore(input.readDouble());
            students.add(s);
        }
        return students;
    }finally{
    
    
        input.close();
    }
}

基本是写的逆过程,代码比较简单,就不赘述了。

使用 DataInputStream/DataOutputStream 读写对象,非常灵活,但比较麻烦,所以 Java 提供了序列化机制,我们在后续章节介绍。

13.2.5 BufferedInputStream/BufferedOutputStream

FileInputStream/FileOutputStream 是没有缓冲的,按单个字节读写时性能比较低,虽然可以按字节数组读取以提高性能,但有时必须要按字节读写,比如上面的 DataInputStream/DataOutputStream,它们包装了文件流,内部会调用文件流的单字节读写方法。怎么解决这个问题呢?方法是将文件流包装到缓冲流中。

BufferedInputStream 内部有个字节数组作为缓冲区,读取时,先从这个缓冲区读,缓冲区读完了再调用包装的流读,它的构造方法有两个:

public BufferedInputStream(InputStream in)
public BufferedInputStream(InputStream in, int size)

size 表示缓冲区大小,如果没有,默认值为 8192。

除了提高性能,BufferedInputStream 也支持 mark/reset,可以重复读取。

与BufferedInputStream 类似,BufferedOutputStream 的构造方法也有两个,默认的缓冲区大小也是 8192,它的 flush 方法会将缓冲区的内容写到包装的流中。

在使用 FileInputStream/FileOutputStream 时,应该几乎总是在它的外面包上对应的缓冲类,如下所示:

InputStream input = new BufferedInputStream(new FileInputStream("hello.txt"));
OutputStream output =  new BufferedOutputStream(new FileOutputStream("hello.txt"));

再比如:

DataOutputStream output = new DataOutputStream(
        new BufferedOutputStream(new FileOutputStream("students.dat")));
DataInputStream input = new DataInputStream(
        new BufferedInputStream(new FileInputStream("students.dat")));    

13.2.6 实用方法

可以看出,即使只是按二进制字节读写流,Java 也包括了很多的类,虽然很灵活,但对于一些简单的需求,却需要写很多代码,实际开发中,经常需要将一些常用功能进行封装,提供更为简单的接口。下面我们提供一些实用方法,以供参考。

13.2.6.1 拷贝

拷贝输入流的内容到输出流,代码为:

public static void copy(InputStream input,
        OutputStream output) throws IOException{
    
    
    byte[] buf = new byte[4096];
    int bytesRead = 0;
    while((bytesRead = input.read(buf))!=-1){
    
    
        output.write(buf, 0, bytesRead);
    }
}    

13.2.6.2 将文件读入字节数组

代码为:

public static byte[] readFileToByteArray(String fileName) throws IOException{
    
    
    InputStream input = new FileInputStream(fileName);
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    try{
    
    
        copy(input, output);
        return output.toByteArray();
    }finally{
    
    
        input.close();
    }
}

这个方法调用了上面的拷贝方法。

13.2.6.3 将字节数组写到文件

public static void writeByteArrayToFile(String fileName,
        byte[] data) throws IOException{
    
    
    OutputStream output = new FileOutputStream(fileName);
    try{
    
    
        output.write(data);
    }finally{
    
    
        output.close();    
    }
}

Apache 有一个类库 Commons IO,里面提供了很多简单易用的方法,实际开发中,可以考虑使用。

13.2.7 小结

本节我们介绍了如何在 Java 中以二进制字节的方式读写文件,介绍了主要的流。

  • InputStream/OutputStream:是抽象基类,有很多面向流的代码,以它们为参数,比如本节介绍的 copy 方法。
  • FileInputStream/FileOutputStream:流的源和目的地是文件。
  • ByteArrayInputStream/ByteArrayOutputStream:源和目的地是字节数组,作为输入相当于是适配器,作为输出封装了动态数组,便于使用。
  • DataInputStream/DataOutputStream:装饰类,按基本类型和字符串读写流。
  • BufferedInputStream/BufferedOutputStream:装饰类,提供缓冲,FileInputStream/FileOutputStream 一般总是应该用该类装饰。

最后,我们提供了一些实用方法,以方便常见的操作,在实际开发中,可以考虑使用专门的类库如 Apache Commons IO。

本节介绍的流不适用于处理文本文件,比如,不能按行处理,没有编码的概念,下一节,就让我们来看文本文件和字符流。

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/108297653