本节我们介绍在 Java 中如何以二进制字节的方式来处理文件,我们提到 Java 中有流的概念,以二进制方式读写的主要流有:
下面,我们就来介绍这些类的功能、用法、原理和使用场景,最后,我们总结一些简单的实用方法。
InputStream/OutputStream
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 的高级方法
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 可以。
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
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 则确保数据写到硬盘,不过一般情况下,我们并不需要手工调用它,只要操作系统和硬件设备没问题,数据迟早会写入,但在一定特定情况下,一定需要确保数据写入硬盘,则可以调用该方法。
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
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 方法获取完整数据。
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 可以方便的参与这种体系,复用代码。
DataInputStream/DataOutputStream
上面介绍的类都只能以字节为单位读写,如何以其他类型读写呢?比如 int, double。可以使用 DataInputStream/DataOutputStream,它们都是装饰类。
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 会将这些类型的数据转换为其对应的二进制字节,比如:
与 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 students = Arrays.asList(new Student[]{
- new Student("张三", 18, 80.9d),
- new Student("李四", 17, 67.5d)
- });
将该列表内容写到文件 students.dat 中的代码可以为:
- public static void writeStudents(List 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 方法。
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 readStudents() throws IOException{
- DataInputStream input = new DataInputStream(
- new FileInputStream("students.dat"));
- try{
- int size = input.readInt();
- List students = new ArrayList(size);
- for(int i=0; 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 是没有缓冲的,按单个字节读写时性能比较低,虽然可以按字节数组读取以提高性能,但有时必须要按字节读写,比如上面的 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")));
实用方法
可以看出,即使只是按二进制字节读写流,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);
- }
- }
将文件读入字节数组
代码为:
- 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 中以二进制字节的方式读写文件,介绍了主要的流。
最后,我们提供了一些实用方法,以方便常见的操作,在实际开发中,可以考虑使用专门的类库如 Apache Commons IO。
本节介绍的流不适用于处理文本文件,比如,不能按行处理,没有编码的概念,下一节,就让我们来看文本文件和字符流。
----------------
未完待续,查看最新文章,敬请关注微信公众号 "老马说编程"(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索 Java 编程及计算机技术的本质。用心原创,保留所有版权。
来源: http://www.cnblogs.com/swiftma/p/6165599.html