Skip to content

Java IO流 是很重要的一个部分,虽然实际应用中我们更多是使用commons.io.FileUtils这样的工具包,但了解java io流体系对于我们处理一些特殊情形或碰到问题时还是很有必要的。 本文对Java IO流体系进行了全面系统的介绍。 内容包括Java IO流体系基础;java.io和nio(new io)相关API;并通过实例代码演示如何访问文件和目录,如何读取和写入数据等。

基础

IO是指Input/Output,即输入和输出。以内存为中心:

  • Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
  • Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。

❓为什么要把数据读到内存才能处理这些数据?因为 代码是在内存中运行的,数据也必须读到内存,最终的表示方式无非是 byte数组字符串等数据类型等,都必须存放在内存里。 从Java代码来看:

  • input 输入实际上就是从外部,例如,硬盘上的某个文件,把内容读到内存,并且以Java提供的某种数据类型表示,例如,byte[],String,这样,后续代码才能处理这些数据。
  • output 输出实际上就是把Java表示的数据格式,例如,byte[],String等输出到外部文件或网络。

image.png

java I/O 历史

Java的IO包含着庞大的类库,经历了从一开始相对复杂的API经过大量改进的过程。让我们一起回顾Java IO历史中几个重要的里程碑:

  1. Java 1.0 I/O库诞生,分为输入和输出两类,面向字节。输入相关的类都继承自InputStream,输出相关的类都继承自OutputStream,且整体使用了装饰器的设计模式;
  2. Java 1.1 对I/O库进行了重大的修改,不但增强了面向字节的类库功能,还新增了面向字符的Reader和Writer,以解决国际化的问题,延续了装饰器的设计模式;
  3. **Java1.4 引入了java.nio(newI/O),**使用通道(channel),缓冲区(buffer),选择器(Selector)等措施 极大的提升了性能;
  4. **Java 1.7/1.8 **对难用的文件I/O的操作体验进行了巨大的改进,且新增了Asynchronous IO(AlO),这时nio也有了一个别名,称之为non-blocking I/O;

image.png

理解 Java IO 库的设计:装饰器模式

**装饰器模式(Decorator Pattern)**是一种结构型设计模式,它允许你通过将对象放入一个特殊的封装对象中来为其添加新的行为或职责。这一模式使得你可以在不改变原始类的基础上,通过组合的方式动态地扩展对象的功能

java io 类库设计是装饰器模式的典型使用案例,IO类库设计使用InputStreamOutputStream这两个抽象基类作为整个IO类库的基础,同时使用装饰器模式将核心功能与附加功能分开。以inputStream为例,核心功能如FileInputStrem 等直接继承自 InputStrem,针对缓冲等附加功能提供FilterInputStrem这个抽象类,作为装饰器接口,IO 类库体系如下: Java IO流的继承体系结构.jpg

操作文件

java.io.file

file代表的是文件路径,既能表示一个文件,也能表示目录。 file的构造方法需要传入一个文件路径,既可以转入绝对路径,也可以传入相对路径。

java
// 🤪注意Windows平台使用"\"作为路径分隔符,在Java中反斜杠"\"被用作转义符,所以需要用两个反斜杠"\\"表示一个路径分隔符"\"。而Linux平台使用/作为路径分隔符:

// 假设当前目录是C:\desktop
File file = new File("c:\\desktop") // 绝对路径
File f1 = new File("sub\\javac"); // fiil对象的绝对路径是C:\desktop\sub\javac
File f3 = new File(".\\sub\\javac"); // 绝对路径是C:\desktop\sub\javac
File f3 = new File("..\\sub\\javac"); // 绝对路径是C:\desktop\javac

💡当构造一个File对象时候,即使传入的文件或目录不存在,代码也不会出错。因为构造一个File对象,并不会发生任何磁盘操作,只有当调用某些方法时,才会进行磁盘操作。

file对象常用的操作包括获取路径、获取目录下的文件和子目录、创建/删除文件等:

  • 获取路径:getPath()/getAbsolutePath()/getCanonicalPath()
  • 获取目录的文件和子目录:list()/listFiles()
  • 实际创建文件/目录/删除文件:createNewFile()/delete()/deleteOnExit()/mkdir()
java
File directory = new File(".\\java\\io"); // 创建表示目录的File对象
File file = new File(".\\java\\io\\io.txt"); // 创建表示文件的File对象

// 获取路径
directory.getPath(); // 相对路径,例如:java\io
directory.getAbsolutePath(); // 绝对路径,D:\code-study\learn-java\learn-java\.\java\io
directory.getCanonicalPath(); // D:\code-study\learn-java\learn-java\java\io

// 获取目录的文件和子目录
directory.listFiles(); // 返回直接子文件和子目录组成的数组
directory.list(); // 返回子文件和子目录的名称组成的数组

java.nio.path

💡java7 以前,用 File 表示文件路径总是给人一种含义不清的感觉,在 java 7 中新添加了Path类,更清晰的表示路径这个含义,同时提供了对路径更加便捷操作。

Path类主要用于对文件路径的拼接处理等操作。 java.nio.paths类是构建Path工厂类Paths.get()方法接受一个或多个字符串,并将它们用默认的路径分隔符连接起来。构建Path可以通过绝对路径和相对路径两种方式,以根路径开始的路径是绝对路径,否则就是相对路径。

java
Path absolutePath = Paths.get("C:\\test.text");
Path relativePath = Paths.get("test.txt");

Path类中常用方法有以下几个:

java
// resolve() 方法组合或解析路径
Path path1 = Paths.get("/Users/johndoe/Documents");
Path resolvedPath = path1.resolve("example.txt");// /Users/johndoe/Documents/example.txt

// getParent() 方法获取父路径
Path path = Paths.get("/Users/johndoe/Documents/example.txt");
Path parentPath = path.getParent();// /Users/johndoe/Documents

// getRoot() 方法获取根路径,并使用 toFile() 方法创建一个 File 对象
Path path2 = Paths.get("/Users/johndoe/Documents/example.txt");
Path rootPath = path2.getRoot();// / ("/" is the root path)

java.nio.files

Files类是一个工具函数类, 提供了众多操作文件的快捷方法: 读写文件:

  • static byte[]readAllBytes(Path path)
  • static List readAllLines(Path path, Charset charset)
  • static InputStream newInputStream(Path path,opentios...)
  • static BufferReader newBufferReader(Path path, Charset charset)

创建文件和目录:

  • static Path createFile(Path path ,FileAttribute attr)
  • static Path createDirectory
  • static Path createTempFiles 创建临时文件,会在程序结束后自行删除

复制移动和删除文件:

  • static Path copy(Path from ,Path to, CopyOption options)
  • static Path move(Path from, Path to, CopyOption options)
  • static void delete()

输入输出流

java IO 类库主要分为字节流和字符流,两者的区别在于:字节流按照 byte为单位读取,而字符流以char为单位读取。

我们知道文件底层都是以二进制的形式存在的,所以字符流是以字节流作为基础,但在上层提供了字符处理的便利性,包括字符编码、解码工作。使得处理文本数据时更加方便和高效。

JAVA IO类库字节流和字符流的常用类的继承层次结构图 如下:

字节流

JAVA IO类库设计使用InputStreamOutputStream这两个抽象基类作为整个IO类库的基础,我们在实际使用中,更多的是使用这两个基类的子类,以便针对不同的数据类型提供更有用的接口。

InputStream

InputStream是一个抽象类,这个抽象类定义的一个最重要的方法就是int read(),签名如下:

java
public abstract int read() throws IOException;

这个方法会读取输入流的下一个字节,并返回字节表示的int值(0~255)。如果已读到末尾,返回-1表示不能继续读取了。 InputStream还有一个重要的方法close(),用于关闭流以释放对应的底层资源。

在计算机中,类似文件、网络端口这些资源,都是由操作系统统一管理的。应用程序在运行的过程中,如果打开了一个文件进行读写,完成后要及时地关闭,以便让操作系统把资源释放掉,否则,应用程序占用的资源会越来越多,不但白白占用内存,还会影响其他应用程序的运行。

java
public void readFile() throws IOException {
    // 创建一个FileInputStream对象:
    InputStream input = new FileInputStream("src/readme.txt");
    for (;;) {
        int n = input.read(); // 反复调用read()方法,直到返回-1
        if (n == -1) {
            break;
        }
        System.out.println(n); // 打印byte的值
    }
    input.close(); // 关闭流
}

除了直接使用close()方法关闭流这种方式,更推荐使用try(resource)语法:

java
public void readFile() throws IOException {
    try (InputStream input = new FileInputStream("src/readme.txt")) {
        int n;
        while ((n = input.read()) != -1) {
            System.out.println(n);
        }
    } // 编译器在此自动为我们写入finally并调用close()
}

缓冲read()方法一次只能读取一个字节,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream提供了两个重载方法来支持读取多个字节:

  • int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数
  • int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数

利用上述方法一次读取多个字节时,需要先定义一个byte[]数组作为缓冲区,read()方法会尽可能多地读取字节到缓冲区, 但不会超过缓冲区的大小。read()方法的返回值不再是字节的int值,而是返回实际读取了多少个字节。如果返回-1,表示没有更多的数据了。 利用缓冲区一次读取多个字节的代码如下:

java
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
    // 定义1000个字节大小的缓冲区:
    byte[] buffer = new byte[1000];
    int n;
    while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
        System.out.println("read " + n + " bytes.");
    }
}
}

inputStream 的常用实现类FileInputStream主要用来操作文件输入流:

public static void main(String[] args) throws IOException {
        String s;
        try (InputStream input = new FileInputStream("C:\\test\\README.txt")) {
            int n;
            StringBuilder sb = new StringBuilder();
            while ((n = input.read()) != -1) {
                sb.append((char) n);
            }
            s = sb.toString();
        }
        System.out.println(s);
    }

outputStream

OutputStream类与InputStream类基本对应,常见的字节输出流如下:

OutputStream是字节输出流的基类,最核心的方法是void write(int b),这个方法会写入一个字节到输出流。要注意的是,虽然传入的是int参数,但只会写入一个字节,即只写入int最低8位表示字节的部分(相当于b & 0xff)。 我们以FileOutputStream这个类为例,演示如何将文件写入输出流中:

java
public void writeFile() throws IOException {
    OutputStream output = new FileOutputStream("out/readme.txt");
    output.write(72); // H
    output.write(101); // e
    output.write(108); // l
    output.write(108); // l
    output.write(111); // o
    output.close();
}

一次写入一个字节太麻烦,更常见的方式是用OutputStream提供的重载方法void write(byte[])来一次性写入多个字节:

java
public void writeFile() throws IOException {
OutputStream output = new FileOutputStream("out/readme.txt");
output.write("Hello".getBytes("UTF-8")); // Hello
output.close();
}

InputStream类似,OutputStream也提供了close()方法关闭输出流,以便释放系统资源。


👶 要特别注意:OutputStream还提供了一个flush()方法,它的目的是将缓冲区的内容真正输出到目的地。

为什么要有flush()❓,因为向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]数组),等到缓冲区写满了,再一次性写入文件或者网络。

通常情况下,我们不需要调用这个flush()方法,因为缓冲区写满了OutputStream会自动调用它,并且,在调用close()方法关闭OutputStream之前,也会自动调用flush()方法。 但是,在某些情况下,我们必须手动调用flush()方法,比如说一个聊天软件,当用户输入一句话后,如果此时缓冲区的内容未满,接收方就不会接受到消息,必须调用flush()方法。


FileOutputStreamoutputStream的常用实现类,用于写文件的输出流。

3.2 字符流

InputSream和OutputStream是面向字节流的,为了兼容Unicode与面向字符的I/O功能,java 1.1 中增加了面向字符流的ReaderWriter类。 对于处理文本数据,特别是需要考虑字符编码的情况下,使用字符流更为方便和安全,因为字符流会自动处理字符编码和解码,帮助我们避免产生乱码等问题。所以,一般情况下推荐使用字符流来读取文本数据。 **Reader****Writer**类的继承与层次结构与字符流基本类似

3.2.1 Reader

  • Reader是一个抽象基类,不能实例化,主要可以用于接口化编程,它定义了以下几个方法:
    • read() :读取单个字符,返回结果是一个int,需要转成char;到达流的末尾时,返回-1
    • read(char[] cbuf): 读取cbuf字符数组长度个字符并填充进cbuf字符数组,返回结果是读取的字符数,到达流的末尾时,返回-1
    • close() :关闭流,释放占用的系统资源。
  • InputStreamReader 是一个转换流,可以把InputStream中的字节数据流根据字符编码方式转成字符数据流。

❓问题:字节流和字符流都可以用于读取文本数据,他们之间的主要区别是什么?

  • 字节流不会自动处理字符编码和解码,字符流自动处理字符编码和解码,读取和写入过程中会根据指定的字符集进行转换;默认情况下使用系统默认字符集。
  • 同时字符流还针对文本数据提供了一些如 readline 等 好用的接口。

我们以一段读取中文文本的代码来说明字节流和字符流的区别:

java
// 字节流
@Test
public void test3() {
    try (FileInputStream fis = new FileInputStream("readme.txt")) {
        int data;
        while ((data = fis.read()) != -1) {
            System.out.print((char) data); // 输出中文乱码:Hello,你好!
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

// 字符流
@Test
public void test4(){
    try (FileReader reader = new FileReader("readme.txt");
         BufferedReader bufferedReader = new BufferedReader(reader)) {
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            System.out.println(line);  // 输出正常: hello,你好
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

FileReader 继承自InputStreamReader类,可以把FileInputStream中的字节数据转成根据字符编码方式转成字符数据流。FileReader类的源码如下图: BufferedReader 类也是一个常用类,是继承自 FilterReader 下的一个装饰器类,主要用于对增加缓冲功能。它除了可以使用基类定义的函数,它自己还实现了以下函数:

  • read(char[] cbuf, int offset, int length) :从offset位置开始,读取length个字符到cbuf中,返回结果是实际读取的字符数,到达流的末尾时,返回-1
  • readLine() :读取一个文本行,以行结束符作为末尾,返回结果是读取的字符串。如果已到达流末尾,则返回 null

BufferedReader使用如下:

java
@Test
public void test4() {
    try (FileReader reader = new FileReader("readme.txt");
         BufferedReader bufferedReader = new BufferedReader(reader)
        ) {
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            System.out.println(line);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

3.2.2 Writer

Writer 类是字符输出流的基类,Writer、OutputStreamWriter、FileWriter、BufferedWriter

  • Writer类定义了以下几个函数
    • write(char[] cbuf) :往输出流写入一个字符数组。
    • write(int c) :往输出流写入一个字符。
    • write(String str) :往输出流写入一串字符串。
    • write(String str, int off, int len) :往输出流写入字符串的一部分。
    • close() :关闭流,释放资源。 【这个还是抽象的,写出来是说明有这个关闭功能】
    • flush():刷新输出流,把数据马上写到输出流中。 【这个还是抽象的,写出来是说明有这个关闭功能】
  • OutputStreamWriter可以使我们直接往流中写字符串数据,它里面会帮我们根据字符编码方式来把字符数据转成字节数据再写给输出流,它相当于一个中介\桥梁。
  • FileWriterOutputStreamWriter功能类似,我们可以直接往流中写字符串数据,FileWriter内部会根据字符编码方式来把字符数据转成字节数据再写给输出流。
  • BufferedWriterFileWriter还高级一点,它利用了缓冲区来提高写的效率。它还多出了一个函数:
    • newLine() :写入一个换行符。

3.3 处理流( filter 装饰器模式)

为了使得java IO类库能够实现多种不同功能的组合,Java 设计者了采用装饰器模式,FilterInputStream类就是一个装饰器类的基类。FilterInputStream的部分子类包括:

  • BufferedInputStream:用于提供输入流的缓冲功能,通过缓存数据来提高读取性能。
  • DataInputStream:在原始输入流的基础上添加了方法,方便读取不同数据类型的数据(例如,int、double、boolean等)。
  • CheckedInputStream:用于计算校验和(checksum)来验证输入流的数据完整性。
  • CipherInputStream:用于在读取流的过程中解密数据。
  • InflaterInputStream:用于解压缩数据,配合 Deflater 来压缩数据。
  • ObjectInputStream:用于读取 Java 对象的数据流,支持反序列化。
  • DigestInputStream:用于计算数据的哈希值(digest),例如MD5、SHA等。

我们来看下如何使用装饰器类给 IO 流添加功能:

java
// 构造一个InputStream
InputStream file = new FileInputStream("test.gz");
// 增加缓冲功能
InputStream buffered = new BufferedInputStream(file);
// 增加处理gzip压缩功能
InputStream gzip = new GZIPInputStream(buffered);

上述这种写法是装饰器模式的典型写法,也可以把他们写在一起:

java
InputStream gzip = new GZIPInputStream(
    new BufferedInputStream(
        new FileInputStream("test.gz")
    )
);

💡注意,虽然BufferedInputStream是作为抽象类的子类,但在任何文件读取操作中,都推荐使用BufferedInputStream增加缓冲功能,

序列化

序列化是指把一个Java对象变成二进制内容,本质上就是一个byte[]数组。 为什么要把Java对象序列化呢?因为序列化后可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程,这样,就相当于把Java对象存储到文件或者通过网络传输出去了。 有序列化,就有反序列化,即把一个二进制内容(也就是byte[]数组)变回Java对象。 我们来看看如何把一个Java对象序列化;一个Java对象要能序列化,首先必须实现一个特殊的java.io.Serializable接口,它的定义如下:

java
public interface Serializable {
}

Serializable接口没有定义任何方法,它是一个空接口。我们把这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。


序列化 把一个Java对象变为byte[]数组,需要使用ObjectOutputStream。它负责把一个Java对象写入一个字节流:

java
 public static void main(String[] args) throws IOException {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        try (ObjectOutputStream output = new ObjectOutputStream(buffer)) {
            // 写入int:
            output.writeInt(12345);
            // 写入String:
            output.writeUTF("Hello");
            // 写入Object:
            output.writeObject(Double.valueOf(123.456));
        }
        System.out.println(Arrays.toString(buffer.toByteArray()));
    }

反序列化ObjectOutputStream相反,ObjectInputStream负责从一个字节流读取Java对象:

java
public static void main(String[] args) throws IOException {
    try (ObjectInputStream input = new ObjectInputStream(...)) {
        int n = input.readInt();
        String s = input.readUTF();
        Double d = (Double) input.readObject();
    }
}

关于serialVersionUID:我们使用 IDEA 开发时,有时会提示我们生成serialVersionUID静态变量,这个变量的作用是用于标识Java类的序列化“版本”,在序列化和反序列化过程中,serialVersionUID 主要用于验证类的版本一致性,确保序列化的对象可以在不同版本的程序中正确地被反序列化。 在实际开发中,建议根据需要在类中显式声明 serialVersionUID,特别是对于需要进行版本控制的类。这样可以提高系统的稳定性和可维护性,避免由于类结构变更导致的反序列化问题。声明 serialVersionUID 是一种良好的实践,尤其是在构建需要持久化存储或远程传输的对象时。

java
public class Person implements Serializable {
    private static final long serialVersionUID = 2709425275741743919L;
}

要特别注意反序列化的 一个 重要特点: 反序列化时,是由JVM直接构造出Java对象,不调用构造方法,所以构造方法内部的代码,在反序列化时根本不可能执行。

java io流的典型使用方式(重要)

一次性读取文件

FileInputStream为例演示如何读取一个文件:

java
public void readFile() throws IOException {
    // 创建一个FileInputStream对象,注意创建完成后只是创建了一个流,并不会直接读取数据
    InputStream input = new FileInputStream("src/readme.txt");
    InputStream buffered = new BufferedInputStream(input);
    for (;;) {
        int n = buffered.read(); // 反复调用read()方法,直到返回-1
        if (n == -1) {
            break;
        }
        System.out.println(n); // 打印byte的值
    }
    buffered.close(); // 关闭流
}

缓冲读取文件

采用缓冲可以有效提高效率,降低磁盘与内存之间IO交换次数。 利用缓冲区一次读取多个字节的代码如下:

java
public void readFile() throws IOException {
   InputStream input = new FileInputStream("src/readme.txt")
    // 定义1000个字节大小的缓冲区:
    byte[] buffer = new byte[1000];
    int n;
    while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
        System.out.println("read " + n + " bytes.");
    }
}

注意: 实际上BufferedInputStream缓冲流实现原理跟上述代码也是类似的。

逐行输出文本文件内容

java
public static void readFileContent(String filePath) throws IOException {

    FileReader fileReader = new FileReader(filePath);
    BufferedReader bufferedReader = new BufferedReader(fileReader);

    String line;
    while ((line = bufferedReader.readLine()) != null) {
        System.out.println(line);
    }
    // 装饰者模式使得 BufferedReader 组合了一个 Reader 对象
    // 在调用 BufferedReader 的 close() 方法时会去调用 Reader 的 close() 方法
    // 因此只要一个 close() 调用即可
    bufferedReader.close();
}

操作 zip

ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容

java
try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) {
    ZipEntry entry = null;
    while ((entry = zip.getNextEntry()) != null) {
        String name = entry.getName();
        if (!entry.isDirectory()) {
            int n;
            while ((n = zip.read()) != -1) {
                ...
            }
        }
    }
}

ZipOutputStream是一种FilterOutputStream,它可以直接写入内容到zip包。我们要先创建一个ZipOutputStream,通常是包装一个FileOutputStream,然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包。

java
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) {
    File[] files = ...
    for (File file : files) {
    zip.putNextEntry(new ZipEntry(file.getName()));
    zip.write(Files.readAllBytes(file.toPath()));
    zip.closeEntry();
}
}

IO 工具库

IO 操作是开发中很常见的操作,尽管 jdk 提供了强大的 io 类库,但本身较底层,实际开发中,我们经常会使用一些开源的工具类库,覆盖我们日常的使用 场景。 Apache Commons IO 库中的 IOUtils 和 FileUtils 类提供了许多常用的函数,用于处理文件和流操作。下面列举了一些这两个类中常用的函数和方法:

Apache Commons IO

IOUtils 类中的常用函数

  1. toByteArray(InputStream input):读取输入流中的所有字节,并将其转换为字节数组。
  2. toString(InputStream input, String encoding):读取输入流中的内容,并转换为字符串,可以指定字符编码。
  3. copy(InputStream input, OutputStream output):将输入流中的内容复制到输出流中。
  4. closeQuietly(Closeable... closeables):安静地关闭一个或多个 Closeable 对象,捕获可能抛出的异常。

FileUtils 类中的常用函数

  1. copyFile(File srcFile, File destFile):复制源文件到目标文件。
  2. moveFile(File srcFile, File destFile):移动源文件到目标文件。
  3. deleteQuietly(File file):安静地删除指定的文件或目录,不会抛出异常。
  4. readLines(File file, Charset encoding):读取文件内容并将其按行存储在列表中。
  5. listFiles(File directory, String[] extensions, boolean recursive):列出目录中具有指定扩展名的文件,可以指定是否递归查找。
  6. sizeOfDirectory(File directory):计算指定目录的大小,包括所有子目录和文件。

参考:廖雪峰Java教程缓存字节流BufferedInputStream使用及原理解析Java:字节流和字符流(输入流和输出流)