计算机程序的思惟逻辑 (57) - 二进制文件和字节流

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》(马俊昌著),由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买:京东自营连接 html

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

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

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

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每每会提供更为高效的实现。app

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

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);
}
复制代码

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

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会将这些类型的数据转换为其对应的二进制字节,好比:

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

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提供了序列化机制,咱们在后续章节介绍。

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中以二进制字节的方式读写文件,介绍了主要的流。

  • InputStream/OutputStream:是抽象基类,有不少面向流的代码,以它们为参数,好比本节介绍的copy方法。
  • FileInputStream/FileOutputStream:流的源和目的地是文件。
  • ByteArrayInputStream/ByteArrayOutputStream:源和目的地是字节数组,做为输入至关因而适配器,做为输出封装了动态数组,便于使用。
  • DataInputStream/DataOutputStream:装饰类,按基本类型和字符串读写流。
  • BufferedInputStream/BufferedOutputStream:装饰类,提供缓冲,FileInputStream/FileOutputStream通常老是应该用该类装饰。

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

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


未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),深刻浅出,老马和你一块儿探索Java编程及计算机技术的本质。用心原创,保留全部版权。

相关文章
相关标签/搜索