【Java】 I/O - I/O 基本操作

Posted by 西维蜀黍 on 2019-03-03, Last Modified on 2021-09-21

I/O 简介

I/O就是输入和输出,核心是I/O流,流用于读写设备上的数据,包括硬盘文件、键盘、网络…。

I/O 的分类

根据数据的走向

根据数据的走向分为:输入流(input stream)、输出流(output stream)

根据处理的数据类型

根据处理的数据类型分为:字节流(byte stream)、字符流(character stream)

根据数据来源或者说是操作对象

数据来源或者说是操作对象角度看,IO 类可以分为:

  • 文件(file):FileInputStream、FileOutputStream、FileReader、FileWriter
  • 数组(array):
    • 字节数组(byte[]):ByteArrayInputStream、ByteArrayOutputStream
    • 字符数组(char[]):CharArrayReader、CharArrayWriter
  • 管道操作:PipedInputStream、PipedOutputStream、PipedReader、PipedWriter
  • 基本数据类型:DataInputStream、DataOutputStream
  • 缓冲操作:BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter
  • 打印:PrintStream、PrintWriter
  • 对象序列化反序列化:ObjectInputStream、ObjectOutputStream
  • 转换:InputStreamReader、OutputStreWriter

字节流(byte stream)和字符流(character stream)的区别

字节流每次读取 n 个字节,字符流每次读取 n 个字符。

一个字符根据编码的不同,用于表示同一字符的字节数也不同。如 UTF-8 编码是 3 个字节,中文编码是 2 个字节。

字节流能处理所有类型数据,对应的类以 Stream 名称结尾。

字节流用来处理二进制文件(图片、MP3、视频文件),字符流用来处理文本文件(可以看做是特殊的二进制文件,使用了某种编码,人可以阅读)。

字符流只能处理文本数据,对应的类以 ReaderWriter 名称结尾。

I/O 类和相关方法

在 Java 中,若 I/O 类按字节流和字符流的划分,可以得到下图:

I/O 类虽然很多,但最基本的是 4 个抽象类:InputStream、OutputStream、Reader、Writer。最基本的方法也就是一个读 read() 方法、一个写 write() 方法。方法具体的实现还是要看继承这 4 个抽象类的子类,毕竟我们平时使用的也是子类对象。这些类中的一些方法都是(Native)本地方法、所以并没有 Java 源代码,

注意这里的读取和写入,其实就是获取(输入)数据和输出数据。

InputStream 类

InputStream 类是表示字节输入流的所有类的超类。

子类

  • FileInputStream: 从文件系统中的某个文件中获得输入字节。哪些文件可用取决于主机环境。FileInputStream 用于读取诸如图像数据之类的原始字节流。要读取字符流,请考虑使用 FileReader。
  • BufferedInputStream:该类实现缓冲的输入流。
  • ByteArrayInputStream: 输入源或输出目标是字节数组的流。

InpurStrem 的基本方法

1. read()
public abstract int read() throws IOException;
  • 作用:read从流中读取下一个字节
  • 返回值:int,读取的一个字节,取值0~255,即十六进制0x00~0xFF;当读到流结尾的时候,返回值为-1
  • 工作方式:如果流中没有数据,其阻塞,直到数据到来、流关闭、异常出现
2. read(byte b[])
public int read(byte b[]) throws IOException;
  • 输入:byte[] b,读入的字节将放到数组b中
  • 输出:int,实际读入的字节个数(可以小于数组b的长度)。若刚开始读取已到流结尾,则返回-1
  • 工作方式:将读到字节存到数组b中,一次最多读入的字节个数为数组b的长度;如果流中没有数据,其阻塞,直到数据到来、流关闭、异常出现
3. read(byte b[], int off, int len)
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);
}
4.close()
public void close() throws IOException
  • 工作方式:流读取结束后,关闭,以释放相关资源。
  • 注意:close一般应该放在finally语句内。

OutputStream 类

OutputStream 抽象类是表示输出字节流的所有类的超类。

子类

  • FileOutputStream:文件输出流是用于将数据写入 File 或 FileDescriptor 的输出流。
  • BufferedOutputStream:该类实现缓冲的输出流。
  • ByteArrayOutputSteam:输入源或输出目标是字节数组的流

OutputSteam的基本方法

1.write(int b)
public abstract void write(int b) throws IOException;
  • 输入:int b, 表示一个字节,只用int的低8位
  • 工作方式:向流中写入字节b
2.write(byte b[])

批量写入的方法:

public void write(byte b[]) throws IOException;
public void write(byte b[], int off, int len) throws IOException
  • 工作方式:向流中写入字节数组b[],off、len指定开始位置和长度
3.flush()
public void flush() throws IOException
  • 工作方式:flush将缓冲而未实际写的数据进行实际写入

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

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

  1. close()
public void close() throws IOException
  • 工作方式:close一般会首先调用flush,然后再释放流占用的系统资源。同InputStream一样,close一般应该放在finally语句内。

再来看 Reader 和 Writer 类中的方法,你会发现和上面两个抽象基类中的方法很像。

Reader 类

Reader 类用于读取字符流的抽象类,它的子类包括:

  • BufferedReader:从字符输入流中读取文本,缓冲各个字符,从而实现字符、数组和行的高效读取。 可以指定缓冲区的大小,或者可使用默认的大小。大多数情况下,默认值就足够大了。
  • InputStreamReader:是字节流通向字符流的桥梁:它使用指定的 charset 读取字节并将其解码为字符。它使用的字符集可以由名称指定或显式给定,或者可以接受平台默认的字符集。
  • FileReader: 用来读取字符文件的便捷类。此类的构造方法假定默认字符编码和默认字节缓冲区大小都是适当的。要自己指定这些值,可以先在 FileInputStream 上构造一个InputStreamReader。
方法 方法介绍
public int read(java.nio.CharBuffer target) 读取字节到字符缓存中
public int read() 读取单个字符
public int read(char cbuf[]) 读取字符到指定的 char 数组中
abstract public int read(char cbuf[], int off, int len) 从 off 位置读取 len 长度的字符到 char 数组中

Writer 类

Writer 类是写入字符流的抽象类,其子类包括:

  • BufferedWriter: 将文本写入字符输出流,缓冲各个字符,从而提供单个字符、数组和字符串的高效写入。
  • OutputStreamWriter :是字符流通向字节流的桥梁:可使用指定的 charset 将要写入流中的字符编码成字节。它使用的字符集可以由名称指定或显式给定,否则将接受平台默认的字符集。
  • FileWriter: 用来写入字符文件的便捷类。此类的构造方法假定默认字符编码和默认字节缓冲区大小都是可接受的。要自己指定这些值,可以先在 FileOutputStream 上构造一个 OutputStreamWriter。
方法 方法介绍
public void write(int c) 写入一个字符
public void write(char cbuf[]) 写入一个字符数组
abstract public void write(char cbuf[], int off, int len) 从字符数组的 off 位置写入 len 数量的字符
public void write(String str) 写入一个字符串
public void write(String str, int off, int len) 从字符串的 off 位置写入 len 数量的字符
public Writer append(CharSequence csq) 追加吸入一个字符序列
abstract public void flush() 强制刷新,将缓冲中的数据写入
abstract public void close() 关闭输出流,流被关闭后就不能再输出数据了

规律总结

1 明确源和目的。

  • 数据源:就是需要读取,可以使用两个体系:InputStream、Reader;
  • 数据汇:就是需要写入,可以使用两个体系:OutputStream、Writer;

2 操作的数据是否是纯文本数据?

  • 如果是:数据源:Reader 数据汇:Writer
  • 如果不是:数据源:InputStream 数据汇:OutputStream

3 虽然确定了一个体系,但是该体系中有太多的对象,到底用哪个呢?明确操作的数据设备。

  • 数据源对应的设备:硬盘(File),内存(数组),键盘(System.in)
  • 数据汇对应的设备:硬盘(File),内存(数组),控制台(System.out)。

使用 Demo

读取控制台中的输入

import java.io.*;

public class Main {
    public static void main(String[] args) throws IOException {
        test02();
        test03();
    }
    
    public static void test02() throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
        System.out.println("请输入一个字符,按 q 键结束");
        char c;
        do {
            c = (char) bufferedReader.read();
            if (c != '\n')
                System.out.println("你输入的字符为" + c);
        } while (c != 'q');
    }

    public static void test03() throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
        System.out.println("请输入一行字符");
        String str = bufferedReader.readLine();
        System.out.println("你输入的字符为" + str);
    }
}
输出
请输入一个字符,按 q 键结束
abc
你输入的字符为a
你输入的字符为b
你输入的字符为c
q
你输入的字符为q

请输入一行字符
11223
你输入的字符为11223

读取二进制文件

我们先看一个可以正常工作,但是性能较低的实现:

import java.io.*;

public class Main {
    public static void main(String[] args) throws IOException {
        InputStream streamReader = null;   //文件输入流
        ByteArrayOutputStream baos = null;
        try {
            streamReader = new FileInputStream(new File("aFile"));
            baos = new ByteArrayOutputStream();
            int value;
            while ((value = streamReader.read()) != -1) {//读取文件字节,并递增指针到下一个字节
                baos.write(value);
            }
            baos.flush();

            byte[] result = baos.toByteArray();
            System.out.println(result);
        } catch (final IOException e) {
            //TODO 自动生成的 catch 块
            e.printStackTrace();
        } finally {
            streamReader.close();
            baos.close();
        }
    }
}
分析

上面的程序存在问题是,在读取二进制文件内容时,每次只读取一个字节。

如果文件十分庞大,这样的操作会导致较慢的读取速度。

Java-二进制文件和字节流 中,作者做了一个实验,即使用不同的 read() 方法来读取同一个文件并计算读取速度:

  1. read(),逐个字节读取 78999ms
  2. read(byte[] b),批量字节读取 54ms
  3. read(byte b[], int off, int len),批量字节读取 49ms

由于 read(byte[] b) 本质上也是调用 read(byte b[], int off, int len),因此它们本质上相同。

结论:尽量不要使用 read() 一个一个字节的读取文件,而要使用将数据读取到缓冲区的方式。

所以,Java 中出现了缓冲区的概念。

Java I/O 默认是不缓冲流的,所谓“缓冲”就是先把从流中得到的一块字节序列暂存在一个被称为 buffer 的内部字节数组里,然后你可以一下子取到这一整块的字节数据,没有缓冲的流只能一个字节一个字节读,效率孰高孰低一目了然。

在上面的例子中,我们可以将 streamReader.read() 改成streamReader.read(byte[] buffer) 。后者方法读取的字节数目等于字节数组的长度,读取的数据被存储在字节数组中,返回读取的字节数。

写入一个新的二进制文件
 public void test04() throws IOException {
        byte[] bytes = {12,21,34,11,21};
        FileOutputStream fileOutputStream = new FileOutputStream(new File("a.txt"));
        // 写入二进制文件,直接打开会出现乱码
        fileOutputStream.write(bytes);
        fileOutputStream.close();
    }
读取二进制文件并写入一个新的二进制文件

我们来看一个引入缓冲区后,读取二进制文件并写入一个新的二进制文件的例子:

import java.io.*;

public class Main {
    public static void main(String[] args) throws Exception {
        String source = "aFile";
        String destination = "bFile";

        int bufferSize = 4096; // 设置缓冲区大小
        byte buffer[] = new byte[bufferSize]; // 缓冲区字节数组

        File sourceFile = new File(source);
        InputStream fis = new FileInputStream(sourceFile);

        OutputStream fos = new FileOutputStream(destination);
        BufferedOutputStream bos = new BufferedOutputStream(fos, bufferSize);

        int readSize = -1; // 记录每次实际读取字节数
        try {
            while ((readSize = fis.read(buffer)) != -1) {
                bos.write(buffer, 0, readSize);
            }
            bos.flush();
        } finally {
            bos.close();
        }

        System.out.println("复制完成");
    }
}

写入到文本文件并读取该文件

import java.io.*;

public class Main {
    public static void main(String[] args) throws Exception {
        String file = "a.txt";
        String charset = "UTF-8";

        // 写字符换转成字节流
        FileOutputStream outputStream = new FileOutputStream(file);
        OutputStreamWriter writer = new OutputStreamWriter(
                outputStream, charset);
        try {
            writer.write("这是要保存的中文字符");
        } finally {
            writer.close();
        }

        // 读取字节转换成字符
        FileInputStream inputStream = new FileInputStream(file);
        InputStreamReader reader = new InputStreamReader(
                inputStream, charset);
        StringBuffer buffer = new StringBuffer();
        char[] buf = new char[64];
        int count = 0;
        try {
            while ((count = reader.read(buf)) != -1) {
                buffer.append(buffer, 0, count);
            }
        } finally {
            reader.close();
        }
    }
}

Reference