《疯狂java讲义》学习(36):文件处理基础

版权声明:本文为博主原创文章,如若转载请注明出处 https://blog.csdn.net/tonydz0523/article/details/86767910

文件基础技术

我们再日常计算机操作中,接触和处理最多的,除了上网,大概就是各种各样的文件了,从本章开始,我们就来探讨文件处理。

文件概述

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

基本概念和常识

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

1.二进制思维

为了透彻理解文件,我们首先要有一个二进制思维。所有文件,不论是可执行文件、图片文件、视频文件、Word文件、压缩文件、txt文件,都没什么可神秘的,他们都是以0和1的二进制形式保存的。我们所看到的图片、视频、文本,都是应用程序对这些二进制的解析结果。
作为程序员,我们应该有一个编辑器,能查看文件的二进制形式,比如UltraEdit,它支持以十六进制进行查看:
在这里插入图片描述
对泛泛之素进行十六进制编辑
左边就是十六进制,“泛”对应的十六进制是“B7 BA”。

2.文件类型

虽然所有数据都是以二进制形式保存的,但为了方便处理数据,高级语言引入了数据类型的概念。文件处理也类似,所有文件都是以二进制形式保存,但为了便于理解和处理文件,文件也有文件类型的概念。
文件类型通常以扩展的形式体现,比如,PDF文件类型的扩展名是.pdf,图片文件的一种常见扩展名是.jpg,压缩文件常用扩展名是.zip。每种文件类型都有一定的格式,代表着文件含义和二进制之间的映射关系。比如一个Word文件,其中有文本、图片、表格,文本可能有颜色、字体、字号等,doc文件类型就定义了这些内容和二进制表示之间的映射关系。有的文件类型的格式是公开的,有的可能是是有的,我们也可以定义自己是有的文件格式。
需要说明的是,给文件加正确的扩展名是一种惯例,但并不是强制的,如果扩展名和文件类型不匹配,应用程序试图打开该文件时可能会报错。另外,一个文件可以选择使用多种应用程序进行解读,在操作系统中,一般通过右键单击文件,选择打开方式即可。
文件类型可以粗略分为两类:一类是文本文件;另一类是二进制文件。文本文件的例子有普通的文本文件(.txt),程序源代码文件(.java)、HTML文件(.html)等;二进制文件的例子有压缩文件(.zip)、PDF文件(.pdf)、MP3文件(.mp3)、Excel文件(.xlsx)等。
基本上,文本文件里的每个二进制字节都是某个可打印字符的一部分,都可以用最基本的文本编辑器进行查看和编辑,如Windows上的notepad、Linux上的vi。二进制文件中,每个字节就不一定表示字符,可能表示颜色、字体、声音大小等,如果用基本的文本编辑器打开,一般都是满屏的乱码,需要专门的应用程序进行查看和编辑。

3.文本文件的编码

对于文本文件,我们还必须注意文件的编码方式。文本文件中包含的基本都是可打印字符,但字符到二进制的映射(即编码)却有多种方法,如GB18030、UTF-8。
对于一个给定的文本文件,它采用的是什么编码方式呢?一般而言,我们是不知道的。那应用程序用什么编码方式进行解读呢?一般使用某种默认的编码方式,可能是应用程序默认的,也可能是操作系统默认的,当然也可能采用一些比较只能的算法自动推断编码方式。
对于UTF-8编码文件,我们需要特别说明。有一种方式,可以标记该文件是UTF-8编码的,那就是在文件最开头加入三个特殊字节(0xEF 0xBB 0xBF),这三个特殊字节被称为BOM头,BOM是Byte Order Mark(即字节序标记)的缩写。但是不是所有程序都支持带BOM头的UTF-8编码文件,比如PHP就不支持BOM,如果PHP源代码文件带BOM头,PHP运行就会出错。
另外,我们需要说明下文本文件的换行符。在Windows系统中,换行符一般是两个字符"\r\n",即ASCII码的13(’\r’)和10(’\n’),在Linux系统中,换行符一般是一个字符"\n"。

4.文件系统

文件一般是放在硬盘上的,一个机器上可能有多个硬盘,但各种操作系统都会隐藏物理硬盘概念,提供一个逻辑上的同一结构。在Windows中,可以有多个逻辑盘,每个盘可以被格式化为一种不同的文件系统,常见的文件系统有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,表示路径分隔符,编程时应使用该变量而避免硬编码。
每个文件除了有具体内容,还有元数据信息,如文件名、创建时间、修改时间、文件大小等。文件还有一个是否隐藏的性质。在Linux系统中,如果文件名以.开头,则为隐藏文件;在Windows系统中,隐藏是文件的一个属性,可以进行设置。
大部分文件系统的文件和目录具有访问权限的概念,对所有者、用户组可以有不同的权限,具体权限包括读、写、执行。
文件名有大小写是否敏感的概念。在Windows系统中,一般是大小写不敏感的,而Linux则一般是大小写敏感的。也就是说,同一个目录下,abc.txt和ABC.txt在Windows中被视为同一个文件,而在Linux中则被视为不同的文件。
操作系统中有一个临时文件的概念。临时文件位于一个特定目录,比如Windows 7中,临时文件一般位于“C:\Users\用户名\AppData\Local\Temp”; Linux系统中,临时文件位于/tmp。操作系统会有一定的策略自动清理不用的临时文件。临时文件一般不是用户手工创建的,而是应用程序产生的,用于临时目的。

5.文件读写

文件在硬盘上,程序处理文件需要将文件读入内存,修改后再写回硬盘,操作系统提供了对文件读写的基本API,不同操作系统的接口和实现时不一样的,不过,有一些共同的概念。Java封装了操作系统的功能,提供了统一的API。
一个基本常识是:硬盘的访问延时,相比内存,是很慢的。操作系统的硬盘一般是按块批量传输,而不是按字节,已摊销延时开销,块大小一般至少为512字节,即使应用程序只需要问价你的一个字节,操作系统也会至少将一个快读进来。一般而言,应尽量减少接触硬盘,接触一次就一次多做一些事情。对于网络请求和其他输入输出设备,原则都是类似的。
另一个基本常识是:一般读写问价你需要两次数据复制,比如读文件,需要先从硬盘复制到操作系统内核,在从内核复制到应用程序分配的内存中。操作系统运行所在的环境和应用程序是不一样的,操作系统坐在的环境时内核态,应用程序是用户态,应用程序调用操作系统的功能,需要两次环境的切换,先从用户态切换到内核态,再从内核态切换到用户态,这种用户态/内核态的切换时有开销的,应尽量减少这种切换。
为了提升文本操作的效率,应用程序经常使用一种常见策略,即使用缓冲区。读文件时,即使目前只需要少量内容,但预支还会接着兑取,就一次读取比较多的内容,放到读取缓冲区,下次读取时,如果缓冲区有,就直接从缓冲区,减少访问操作系统和硬盘。写文件时,先写到缓冲区,写缓冲区满了之后,再一次性调用操作系统写到硬盘。不过,需要注意的是,在写结束的时候,要记住将缓冲区的剩余内容同步到硬盘。操作系统自身也会使用缓冲区,不过,应用程序更了解读写模式,恰当使用往往可以有更高的效率。
操作系统操作文件一般有打开和关闭的概念。打开文件会在操作系统内核建立一个有关文件的内存结构,这个结构一般通过一个整数索引来引用,操作系统能同时打开的文件一般也是有限的,在不同文件的时候,应该记住关闭文件。关闭文件一般会同步缓冲区内容到硬盘,并释放占据的内存结构。
操作系统一般支持一种称为内存映射文件的高效的随机读写大文件的方法,将文件直接映射到内存,操作内存就是操作文件。在内存映射文件中,只有访问到的数据才会实际复制到内存,且数据只会复制一次,被操作系统以及多个程序共享。

Java文件概述

在Java中处理文件有一些基本概念和类,包括流、装饰器设计模式、Reader/Writer、随机读写文件、File、NIO、序列化和反序列化,下面分别介绍。

1.流

在Java中(很多其他语言也类似),文件一般不是单独处理的,而是视为输入输出(Input/Output,IO)设备的一种。Java使用基本统一的概念处理所有的IO,包括键盘、显示终端、网络等。
这个统一的概念是流,流有输入流和输出流之分。输入流就是可以从中获取数据,输入轮流的实际提供者可以是键盘、文件、网络等;输出流就是可以向其中写入数据,输出流的实际目的地可以是显示终端、wenjian、网络等。
Java IO的基本类大多位于包java.io中。类InputStream表示输入流,OutputStream表示输出流,而FileInputStream表示文件输入流,FileOutputStream表示文件输出流。
有了流的概念,就有了很多面向留的代码,比如对流的加密、压缩、计算信息摘要、计算检验和等,这些代码接受的参数和返回结果都是抽象的流,他们构成了一个协作体系,这类似于之前介绍的接口概念、面向接口的编程一节容器协作体系。一些实际上不是IO的数据源和目的地也转换为了流,以方便参与这种协作,比如字节数组ByteArrayInputStream和ByteArrayOutputStream也包装称为流。

2.装饰器设计模式

基本的流按字节读写,没有缓冲区,这不方便使用。Java引入装饰器的设计模式,引入了很多装饰类,对基本的流增加功能,以方便使用。一般一个类只关注一个方面,实际使用时,经常会需要多个装饰类。
Java中有很多装饰类,有两个基类:过滤器输入流FilterInputStream和过滤器输出流FilterOutputStream。过滤类似于自来水管道,流入的是水,流出的也是水,功能不变,或者只是增加功能。它有很多子类,这里列举一些:

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

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

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。

4.随机读写文件

Java提供了一个独立的可以随机读写文件的类RandomAccessFile,适用于大小已知的记录组成的文件。该类在日常应用开发中用的比较少,但在一些系统程序中用的比较多。

5.FIle

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

6.NIO

以上介绍的类接班都位于包java.io下,Java还有一个关于IO操作的包java.nio,nio表示New IO,这个包下同样包含大量的类。
NIO代表一种不同的看待IO的方式,它有缓存区和通道的概念,利用缓冲区的通道往往可以达成和流类似的目的,不过,它们更接近操作系统的概念,某些操作的性能也更高。比如,复制文件到网络,通道可以利用操作系统和硬件体用的DMA机制(Direct Memory Access,直接内存存取),不用CPU和应用程序参与,直接将数据从硬盘复制到网卡。
除了看待方式不同,NIO还支持一些比较底层的功能,如内存映射文件、文件加锁、自定义文件系统、非阻塞式IO、异步IO等。
不过,这些功能要么是比较底层,普通应用程序用到的比较少,要么主要适用于网络IO操作,我们大多不会介绍,只会介绍内存映射文件。

7.序列化和反序列化

简单来说,序列化就是将内存中的Java对象持久保存到一个流中,反序列化就是从流中恢复Java对象到内存。序列化和反序列化主要有两个用处:一是对象状态持久化,二是网络远程调用,用于传递和返回对象。
Java主要通过接口Serializable和类ObjectInputStream/ObjectOutputStream提供对序列化的支持,基本的使用是比较简单的,但也有一些复杂的地方。不过,Java的默认序列化有一些缺点,比如,序列化后的形式比较大、浪费空间,序列化/反序列化的性能也比较低,更重要的问题是,它是Java特有的技术,不能与其他语言交互。
ⅩML是前几年最为流行的描述结构性数据的语言和格式,Java对象也可以序列化为ⅩML格式。ⅩML容易阅读和编辑,且可以方便地与其他语言进行交互。ⅩML强调格式化但比较“笨重”, JSON是近几年来逐渐流行的轻量级的数据交换格式,在很多场合替代了ⅩML,也非常容易阅读和编辑。Java对象也可以序列化为JSON格式,且与其他语言进行交互。
ⅩML和JSON都是文本格式,人容易阅读,但占用的空间相对大一些,在只用于网络远程调用的情况下,有很多流行的、跨语言的、精简且高效的对象序列化机制,如ProtoBuf、Thrift、MessagePack等。其中,MessagePack是二进制形式的JSON,更小更快。

二进制文件和字节流

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

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

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

InputStream/OutputStream

我们分别看下InputStream和OutputStream。

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,但通常可以捕获并忽略。
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的返回值进行判断。InputStream的默认实现是不支持,FileInputStream也不直接支持,但BufferedInputStream和ByteArrayInputStream可以支持。

2.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语句内。

FileInputStream/FileOutputStream

FileInputStream和FileOutputStream的输入源和输出目标是文件,我们分别介绍。

1.FileOutputStream

FileOutputStream有多个构造方法,其中两个如下所示:

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

File类型的参数file和字符串的类型的参数name都表示文件路径,路径可以是绝对路径,也可以是相对路径,如果文件已经存在,append参数指定是追加还是覆盖,true表示追加,false表示覆盖,第二个构造方法没有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的get-Bytes方法得到它的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方法则确保数据写到硬盘,不过一般情况下,我们并不需要手工调用它,只要操作系统和硬件设备没问题,数据迟早会写入。在一定特定情况下,一定需要确保数据写入硬盘,则可以调用该方法。

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。

ByteArrayInputStream/ByteArrayOutputStream

它们的输入源和输出目标是字节数组,我们分别介绍。

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方法获取完整数据。

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/OutputStream为参数构建的,他们构成了一个协作体系,将byte数组转换为InputStream可以方便地参与这种体系,复用代码。

DataInputStream/DataOutputStream

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

1.DataOutputStream

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

public FilterOutputStream(OutputStream out)

它接受一个已有的OutputStream,基本上将所有操作都代理给了它。DataOutputStream实现了DataOutput接口,可以以各种基本类型和字符串写入数据,部分方法如下:

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

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

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

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

public DataOutputStream(OutputStream out)

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

class 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方法。

2.DataInputStream

DataInputStream是装饰类基类FilterInputStream的子类,FilterInputStream是Input-Stream的子类。DataInputStream实现了DataInput接口,可以以各种基本类型和字符串读取数据,部分方法有:

boolean readBoolean() throws IOException;
int readInt() 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提供了序列化机制。

BufferedInputStream/BufferedOutputStream

FileInputStream/FileOutputStream是没有缓冲的,按单个字节读写时性能比较低,虽然可以按字节数组读取以提高性能,但有时必须要按字节读写,怎么解决这个问题呢?方法是将文件流包装到缓冲流中。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")));

实用方法

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

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);
    }
}

实际上,在Java 9中,InputStream类增加了一个方法transferTo,可以实现相同功能,实现是类似的,具体代码为:

public long transferTo(OutputStream out) throws IOException {
    Objects.requireNonNull(out, "out");
    long transferred = 0;
    byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; //buf大小是8192
    int read;
    while((read = this.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) {
        out.write(buffer, 0, read);
        transferred += read;
    }
    return transferred;
}

将文件读入字节数组,这个方法调用了上面的复制方法,具体代码为:

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();
    }
}

将字节数组写到文件,代码为:

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,里面提供了很多简单易用的方法,实际开发中,可以考虑使用。

Java实例练习

显示文件的基本信息

本实例将通过一些File类的相关方法来查看一些文件的基本信息。

1.

新建项目FileInfo,并在其中创建一个FileInfo.java文件。在该类的主方法中使用File类方法获取文件的名字、文件的长度、文件的路径、判断文件是否可读或者可写、是否存在等。核心代码如下所示:

package FileInfo;

import java.io.File;

public class FileInfo {
    public static void main(String[] args) {
        File file = new File("D:\\createFile\\file1.txt");
        // 根据指定的路径创建一个File对象
        System.out.println("文件名为:" + file.getName());// 获取该File对象的名字
        System.out.println("绝对路径为:" + file.getAbsolutePath());
        // 获取该File对象的绝对路径
        System.out.println("路径为:" + file.getPath());// 获取该File对象的相对路径
        System.out.println("所属磁盘目录为:" + file.getParent());
        // 获取该File对象的目录
        if (!file.exists()) {
            file.mkdir();
        }
        // 判断该File对象是不是目录
        System.out.println("此路径名表示的文件是一个目录吗?:" + file.isDirectory());
        System.out.println("它是处于可读状态吗?:" + file.canRead());
        // 判断该File对象是否可读
        System.out.println("它是处于可写状态吗?" + file.canWrite());
        // 判断该File对象是否可写
        System.out.println("该文件长度为:" + file.length() + "字节");
        // 获取该File对象中的字符长度
        System.out.println("此文件最后修改的时间为:" + file.lastModified());
        // 获取该File对象最后修改时间
    }
}

以字节为单位读取文件内容

以字节为单位读取文件,常用于读取二进制文件,如图片、声音、影像等。

1.

新建项目ByteReadFile,并在其中创建一个ByteReadFile.java文件。在该类的主方法中使用FileInputStream()方法去读取指定文件的内容并输出。核心代码如下所示:

package ByteReadFile;

import java.io.*;

public class ByteReadFile {    //以字节为单位读取文件
    public static void readFileByBytes(String fileName){
        File file = new File(fileName);                 // 创建文件
        InputStream in = null;
        try {
            System.out.println("以字节为单位读取文件内容,一次读一个字节:");
            in = new FileInputStream(file);             // 将文件放入文件输入流中
            int tempbyte;
            while ((tempbyte = in.read()) != -1){// 一次读一个字节,循环将内容读出来
                System.out.write(tempbyte);
            }
            in.close();                                 // 关闭文件输入流
        } catch (IOException e) {                       // 捕获异常
            e.printStackTrace();
            return;
        }
        try {
            System.out.println("以字节为单位读取文件内容,一次读多个字节:");
            // 一次读多个字节
            byte[] tempbytes = new byte[100];       // 声明长度为100的字节数组
            int byteread = 0;
            in = new FileInputStream(fileName);     // 将文件放入文件输入流中
            ByteReadFile.showAvailableBytes(in);    // 显示输入流中还剩的字节数
            // 读入多个字节到字节数组中,byteread为一次读入的字节数
            while ((byteread = in.read(tempbytes)) != -1){
                // 一次读多个字节,循环将内容读出来
                System.out.write(tempbytes, 0, byteread);
            }
        } catch (Exception e1) {                    // 捕获异常
            e1.printStackTrace();
        } finally {                                 // 内容总执行
            if (in != null) {
                try {
                    in.close();                   // 确保文件输入流关闭
                } catch (IOException e1) {
                }
            }
        }
    }
    private static void showAvailableBytes(InputStream in){
        // 显示输入流中还剩的字节数
        try {
            System.out.println("当前字节输入流中的字节数为:" + in.available());
        } catch (IOException e) {                       // 捕获异常
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        String fileName = "D:/book.py";
        System.out.println("按字节为单位读取文件:");
        readFileByBytes(fileName);
    }
}

InputStream类是所有字节输入流类的父类。InputStream类中拥有很多字节输入流都需要的方法,可以通过使用InputStream类提供的方法实现从输入流读取字节或者字节数组数据的功能。

以字节为单位写文件

知道了如何以字节为单位读取文件之后,我们来看如何以字节为单位来写文件。本实例就是一个以字节为单位进行写文件的例子。以字节为单位写文件主要用到的是FileOutputStream类,它是写文件最通用的方法,能写入任何类型的文件,特别适合写二进制数据文件。

1.

新建项目BytesWriteFile,并在其中创建一个BytesWriteFile.java文件。在该类的主方法中使用FileOutputStream类方法将一首《静夜思》写入到D盘下的text2.txt文本中。

package BytesWriteFile;

import java.io.*;

public class BytesWriteFile {                            // 操作多种方式写文件的类
    public static void writeFileByBytes(String fileName){   // 以字节为单位写文件
        File file = new File(fileName);                  // 创建一个文件
        OutputStream out= null;
        try {
            out = new FileOutputStream(file);            // 打开文件输出流
            String content ="静夜思 床前明月光,疑是地上霜。"+
                    " 举头望明月, 低头思故乡。";
            byte[] bytes = content.getBytes();           // 读取输出流中的字节
            out.write(bytes);                            // 写入文件
            System.out.println("写文件" + file.getAbsolutePath() + "成功!");
        } catch (IOException e){
            System.out.println("写文件" + file.getAbsolutePath() + "失败!");
            e.printStackTrace();
        } finally {                                      // 总被执行的内容
            if (out != null){
                try {
                    out.close();                       // 关闭输出文件流
                } catch (IOException e1) {
                }
            }
        }
    }
    public static void main(String[] args) {             // Java程序主入口处
        String fileName = "D:/text2.txt";
        System.out.println("以字节为单位写文件:");
        BytesWriteFile.writeFileByBytes(fileName);       // 调用方法写文件
    }
}

猜你喜欢

转载自blog.csdn.net/tonydz0523/article/details/86767910