Java IO基础全解析

程序猿阿嘴   2023-05-29 06:21:35

Java IO 一直以来是大厂面试题中的高频考点,本文将从 Java IO 基础使用说起,以案例 + 源码的方式讲解文件、字节流、字符流、缓冲流、打印流、随机访问流等基础内容,再深入到 Java IO 模型与设计模式,从而构建出对 Java IO 的全面认知。

文章不仅适合完全不了解 Java IO 的新同学,也适合具备一定知识储备的老同学。文中的所有案例代码强烈推荐手写复现一遍,以更好地掌握 Java IO 编程基础。


(资料图)

文章的结尾处给出了更新日志,每次新更新的内容都会写明,便于同学们快速了解更新的内容是否是自己所需要的知识点。

我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。

文件

文件在程序中是以流的形式来操作的。类关系如下:

创建文件

常用构造方法

常用构造方法

描述

File(String pathname)

根据路径名构建

File(File parent, String child)

根据父目录文件 + 子路径构建

File(String parent, String child)

根据父目录路径 + 子路径构建

要想真正地在磁盘中创建文件,需要执行 createNewFile() 方法。

Tips

所有 java.io 中的类的 相对路径 默认都是从 用户工作目录 开始的,使用 System.getProperty(\"user.dir\") 可以获取你的用户工作目录。 在 Windows 系统中的分隔符为 \"\\",在 Linux 系统中分隔符为 \"/\",为了保证系统的可移植性,可以通过常量字符串 java.io.File.separator 获取(参见案例中的使用)。

使用案例

使用 File(String pathname) 创建文件
java复制代码@Testpublic void createFile() {    // 更换成你想要存放的文件路径,默认情况为用户工作目录,可以通过 System.getProperty(\"user.dir\") 显示获取    String userDir = System.getProperty(\"user.dir\");    System.out.println(\"用户工作目录:\" + userDir);    System.out.println(\"当前操作系统的文件分隔符为:\" + File.separator);    String fileName = \"createFile.txt\";    String path = userDir + File.separator + fileName; // 组装路径    File file = new File(path); // 此时只是程序中的一个对象    // File file = new File(fileName); // 默认会创建到用户工作目录中,和上一面的语句创建的文件路径一致。    try {        file.createNewFile(); // 执行该方法才会真正地在磁盘中创建文件        System.out.println(\"文件创建成功\");    } catch (IOException e) {        e.printStackTrace();    }}
使用 File(File parent, String child) 创建文件
java复制代码@Testpublic void createFile2() {    // 更换成你想要存放的文件路径    File parentFile = new File(\"/Users/sunnywinter/projects/interviewcode/\");    String fileName = \"testFile2.txt\";    File file = new File(parentFile, fileName);    try {        file.createNewFile();        System.out.println(\"文件创建成功\");    } catch (IOException e) {        e.printStackTrace();    }}
使用 File(String parent, String child) 创建文件
java复制代码@Testpublic void createFile3() {    // 更换成你想要存放的文件路径    String parentFile = System.getProperty(\"user.dir\");    String fileName = \"createFile3.txt\";    File file = new File(parentFile, fileName);    try {        file.createNewFile();        System.out.println(\"文件创建成功\");    } catch (IOException e) {        e.printStackTrace();    }}

获取文件信息

常用方法

返回值

方法名

描述

String

getName()

获取文件名

String

getAbsolutePath()

获取文件绝对路径

String

getParent()

获取文件父级目录

long

length()

返回文件大小(字节)

boolean

exists()

判断文件是否存在

boolean

isFile()

判断是否是一个文件

boolean

isDirectory()

判断是否是一个目录

使用案例

java复制代码@Testpublic void getFileInfo() {    File file = new File(\"/Users/sunnywinter/projects/interviewcode/testFile.txt\");    System.out.println(\"文件名:\" + file.getName());    System.out.println(\"文件绝对路径:\" + file.getAbsolutePath());    System.out.println(\"文件父级目录:\" + file.getParent());    System.out.println(\"文件大小(字节):\" + file.length());    System.out.println(\"文件是否存在:\" + file.exists());    System.out.println(\"是否是一个文件:\" + file.isFile());    System.out.println(\"是否是一个目录:\" + file.isDirectory());}

目录操作与文件删除

使用方法

返回值

方法名

描述

boolean

mkdir()

创建一级目录

boolean

mkdirs()

创建多级目录

boolean

delete()

删除文件或目录

使用案例

java复制代码@Testpublic void test() {    String parentPath = \"/Users/sunnywinter/projects/interviewcode/\";    String fileName = \"testFile.txt\";    String directoryName = \"a\";    String mulDirectoryName = \"b/c/d\";    // 删除文件    File file = new File(parentPath, fileName);    file.delete();    // 创建一级目录    File directory = new File(parentPath, directoryName);    directory.mkdir();    // 创建多级目录    File mulDirectory = new File(parentPath, mulDirectoryName);    mulDirectory.mkdirs();    // 删除目录    directory.delete();}

IO 流概述

IO,Input/Output,即输入/输出。判断输入输出以计算机内存为中心,如果从内存到外部存储就是输出,从外部存储到内存就是输入。数据传输过程类似于水流,因此称为 IO 流。

在 Java 中,根据操作数据单位的不同,IO 流分为字节流和字符流;根据数据流的流向不同,分为输入流和输出流;根据流的角色不同,分为节点流和处理流。

Java IO 流共涉及 40 多个类,但都是从表中的 4 个抽象基类派生而来,派生的子类名称都是以其父类名作为子类名的后缀。

(抽象基类)

字节流

字符流

输入流

InputStream

Reader

输出流

OutputStream

Writer

列举一些常用的类。

字节流

首先,我们先学习如何将数据写入到文件中。

OutputStream(字节输出流)

OutputStream 用于将内存数据(字节信息)写入到文件中,java.io.OutputStream抽象类是所有字节输出流的父类。

常用方法

返回值

方法名

描述

void

write(int b)

将特定字节写入输出流。

void

write(byte b[])

将数组 b 写入到输出流,等价于 write(b, 0, b.length) 。

void

write(byte[] b, int off, int len)

在 write(byte b[]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。

void

flush()

刷新此输出流并强制写出所有缓冲的输出字节。

void

close()

关闭输出流释放相关的系统资源。

FileOutputStream

FileOutputStream 是最常用的字节输出流子类,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。

类图关系如下:

Tips

java.io.Closeable 接口扩展了 java.lang.AutoCloseable 接口。因此,对任何 Closeable 进行操作时,都可以使用 try-with-resource 语句(声明了一个或多个资源的 try 语句,可以自动关闭流,具体使用方法参见使用案例)。 为什么要有两个接口呢?因为 Closeable 接口的 close 方法只抛出了 IOException,而 AutoCloseable.close 方法可以抛出任何异常。

常用构造函数

append 为 true 时,表明追加写入。

使用案例

需求 1:向 mrpersimmon.txt 文件中写入 Hi,Mrpersimmon!

代码:

java复制代码@Testpublic void testFileOutputStream() {    // FileOutputStream(String name, boolean append) 追加写入    // FileOutputStream(String name) 覆盖写入    try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(\"mrpersimmon.txt\"))) {        String str = \"Hi,Mrpersimmon!\";        // write(byte b[]) : 将字节数组 b 写入到输出流,等价于 write(b, 0, b.length)        bos.write(str.getBytes(\"UTF-8\")); // str.getBytes() 字符串 ->字节数组    } catch (IOException e) {        e.printStackTrace();    }}

Tips

FileOutputStream 在使用中要和 BufferedOutputStream 一起使用,性能更好。 try(...OutputStream) 可以自动关闭输出流,无需 try-finally 手动关闭。

运行结果:

DataOutputStream

DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合 FileOutputStream,构造函数源码如下:

类图关系如下:

使用案例

需求:向 mrpersimmon2.txt 写入 Hi,Mrpersimmon!

代码:

java复制代码@Testpublic void testDataOutputStream() {    try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(\"mrpersimmon2.txt\"))) {        // 输出任意输入类型        dos.writeUTF(\"Hi,Mrpersimmon!\");    } catch (IOException e) {        e.printStackTrace();    }}

运行结果:

ObjectOutputStream

ObjectOutputStream 用于将对象写入到输出流(序列化)。与之相对反地,ObjectInputStream 用于从输入流中读取 Java 对象(反序列化)。

类图关系如下:

序列化与反序列化

什么是序列化和反序列化? 序列化就是在保存数据时,保存数据的值和数据类型;反序列化就是在恢复数据时,恢复数据的值和数据类型。

如何让类支持序列化机制呢? 必须让类实现 Serializable 接口(一个标记接口,没有方法)或者 Externalizable 接口(有方法需要实现)。如果类中有属性不想被序列化,需要使用 transient 修饰。

注意事项

读写顺序要一致; 要求序列化和反序列化的对象,需要实现 Serializable 接口 序列化的类中建议添加 serialVersionUID 以太高版本的兼容性 序列化对象时,默认将所有属性进行了序列化,但除了 static 或 transient 修饰的成员 序列化对象时,要求里面属性的类型也需要实现序列化接口 序列化具备可继承性,即某个类实现了序列化,那么它的所有子类也默认实现了序列化。 基本类型对应的包装类都实现了序列化。

使用案例

需求:创建一个支持序列化的 Blog 类,向 mrpersimmon3.txt 写入一个 Blog 对象。

代码:

Blog 类

java复制代码public class Blog implements Serializable {    private static final long serialVersionUID = -4970674810941727545L;    String name;    String url;    public Blog(String name, String url) {        this.name = name;        this.url = url;    }    @Override    public String toString() {        return \"Blog{\" +                \"name="\" + name + "\"" +                \", url="\" + url + "\"" +                "}";    }}

功能代码

java复制代码@Testpublic void testObjectOutputStream() {    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(\"mrpersimmon3.txt\"))) {        Blog blog = new Blog(\"mrpersimmon\", \"https://www.mrpersimmon.top\");        oos.writeObject(blog);        System.out.println(\"数据写入完成(序列化)\");    } catch (IOException e) {        e.printStackTrace();    }}

运行结果

下面我们来学习如何从文件中读取数据信息。

InputStream(字节输入流)

InputStream 用于从文件读取数据(字节信息)到内存中,java.io.InputStream 抽象类是所有字节输入流的父类。

常用方法

返回值

方法名

描述

JDK 8 ↓

JDK 8 ↓

JDK 8 ↓

int

read()

返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。

int

read(byte b[ ])

从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length ,返回读取的字节数。这个方法等价于 read(b, 0, b.length)。

int

read(byte b[], int off, int len)

在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。

long

skip(long n)

忽略输入流中的 n 个字节 ,返回实际忽略的字节数。

int

available()

返回输入流中可以读取的字节数。

void

close()

关闭输入流释放相关的系统资源。

JDK 9 ↓

JDK 9 ↓

JDK 9 ↓

byte[]

readAllBytes()

读取输入流中的所有字节,返回字节数组。

byte[]

readNBytes(byte[] b, int off, int len)

阻塞直到读取 len 个字节。

long

transferTo(OutputStream out)

将所有字节从一个输入流传递到一个输出流。

FileInputStream

FileInputStream 是一个比较常用的字节输入流子类,可 直接指定文件路径 ,可以 直接读取单字节数据 ,也可以 读取至字节数组中 。

类图关系如下:

使用案例

需求:读取 mrpersimmon.txt 文件,并将文件内容显示到控制台中。

方法 1:使用 read() 单个字节读取,效率较低。

代码:

java复制代码@Testpublic void testFileInputStream() {    // try() 会自动关闭输入流,FileInputStream 与 BufferedInputStream 配合使用    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\"mrpersimmon.txt\"))) {        // int available() 返回输入流中可以读取的字节数。        System.out.println(\"文件中可读取的字节数量:\" + bufferedInputStream.available());        // long skip(long n) 忽略输入流中的 n 个字节 ,返回实际忽略的字节数。        long skipCounts = bufferedInputStream.skip(3); // 忽略 3 个字节        System.out.println(\"忽略的字节数量:\" + skipCounts);        // read() 返回输入流中下一个字节的数据。        System.out.print(\"从文件中读取的字节内容:\");        int content;        // 返回值为 -1 时,表示读取完毕        while ((content = bufferedInputStream.read()) != -1) {            System.out.print((char) content); // 将读出的 int 类型数据强转成 char 类型        }    } catch (IOException e) {        e.printStackTrace();    }}

Tips

FileInputStream 在使用中要和 BufferedInputStream 一起使用,性能更好。 try(...InputStream) 可以自动关闭输入流,无需 try-finally 手动关闭。

运行结果:

复制代码文件中可读取的字节数量:15忽略的字节数量:3从文件中读取的字节内容:Mrpersimmon!

方法 2:使用 read(byte[] b) 读取文件,提高效率。

代码:

java复制代码@Testpublic void testFileInputStream2() {    // try() 会自动关闭输入流,FileInputStream 与 BufferedInputStream 配合使用    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(\"mrpersimmon.txt\"))) {        // int available() 返回输入流中可以读取的字节数。        int bufSize = bufferedInputStream.available();        System.out.println(\"文件中可读取的字节数量:\" + bufSize);        byte[] buf = new byte[8]; // 一次读取 8 字节        // long skip(long n) 忽略输入流中的 n 个字节 ,返回实际忽略的字节数。        long skipCounts = bufferedInputStream.skip(3); // 忽略 3 个字节        System.out.println(\"忽略的字节数量:\" + skipCounts);        // read(byte b[]) 从输入流中读取一些字节存储到数组 b 中。        // 如果数组 b 的长度为零,则不读取。        // 如果没有可用字节读取,返回 -1。        // 如果有可用字节读取,则最多读取的字节数最多等于 b.length,返回读取的字节数。        // 这个方法等价于 read(b, 0, b.length)。        System.out.print(\"从文件中读取的字节内容:\");        int readLen;        // 返回值为 -1 时,表示读取完毕        while ((readLen = bufferedInputStream.read(buf)) != -1) {            System.out.print(new String(buf, 0, readLen)); // 将字符数组 buf 转换成字符串        }    } catch (IOException e) {        e.printStackTrace();    }}

运行结果:

复制代码文件中可读取的字节数量:15忽略的字节数量:3从文件中读取的字节内容:Mrpersimmon!

DataInputStream

DataInputStream 用于读取指定类型数据,不能单独使用,必须结合 InputStream 的一个实现类使用,构造函数源码如下:

类图关系如下:

使用案例

需求:读取 mrpersimmon2.txt 文件,并将文件内容显示到控制台中。

代码

java复制代码@Testpublic void testDataInputStream() throws IOException {    // 必须将一个 InputStream 的实现类作为构造参数才能使用    try(DataInputStream dis = new DataInputStream(new FileInputStream(\"mrpersimmon2.txt\"))) {        // 可以读取任意具体的类型数据        System.out.println(dis.readUTF()); // 读取已使用 modified UTF-8 格式编码的字符串。    } catch (IOException e) {        e.printStackTrace();    }}

运行结果

复制代码Hi,Mrpersimmon!

ObjectInputStream

ObjectInputStream 用于从输入流中读取 Java 对象(反序列化)。

类图关系如下:

使用案例

需求:读取 mrpersimmon3.txt 中的 Blog 对象。

代码

java复制代码@Testpublic void testObjectInputStream() {    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(\"mrpersimmon3.txt\"))) {        System.out.println(ois.readObject());        System.out.println(\"数据读取完毕(反序列化完成)\");    } catch (IOException e) {        e.printStackTrace();    } catch (ClassNotFoundException e) {        throw new RuntimeException(e);    }}

运行结果

ini复制代码Blog{name="mrpersimmon", url="https://www.mrpersimmon.top"}数据读取完毕(反序列化完成)

综合案例

需求

完成图片的拷贝。

代码

java复制代码@Testpublic void testCopyPic() {    String srcPicPath = \"data.png\";    String destPicPath = \"data2.png\";    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcPicPath));         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destPicPath))) {        byte[] buf = new byte[1024];        int readLen = 0;        while ((readLen = bis.read(buf)) != -1) {            // bis 输入流从源图片文件读取数据后,写入到 bos 输出流的目标文件地址            bos.write(buf, 0, readLen);        }    } catch (IOException e) {        e.printStackTrace();    }}

字符流

字符流与字节流的对比

为什么 I/O 流操作要分为字节流操作和字符流操作呢?

不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。因此,字节流是必要的。而字符流是由 Java 虚拟机将字节转换得到的,相比较于字节流更加耗时。 字节流在不知道编码类型的情况下很容易出现乱码问题,因此我们需要字符流来读取文本文件。

何时使用字节流,何时使用字符流?

如果是音频文件、图片等媒体文件使用用字节流会有更好的性能优势;

如果涉及到字符的话(如,文本文件等)使用字符流比较好。

常用字符编码所占字节数?

字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。

utf8,英文占 1 字节,中文占 3 字节;

unicode:任何字符都占 2 个字节;

gbk:英文占 1 字节,中文占 2 字节。

Writer(字符输出流)

Writer用于将内存数据(字符信息)写入到文件,java.io.Writer抽象类是所有字符输出流的父类。

常用方法

返回值

方法名

描述

void

write(int c)

写入单个字符。

void

write(char[] cbuf)

写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)。

void

write(char[] cbuf, int off, int len)

在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

void

write(String str)

写入字符串,等价于 write(str, 0, str.length()) 。

void

write(String str, int off, int len)

在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

Writer

append(CharSequence csq)

将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。

Writer

append(char c)

将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象.

void

flush()

刷新此输出流并强制写出所有缓冲的输出字符。

void

close()

关闭输出流释放相关的系统资源。

FileWriter

OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。

类图如下:

使用案例

代码

java复制代码@Testpublic void testFileWriter() {    String filePath = \"mrpersimmon-1.txt\";    try (BufferedWriter bw = new BufferedWriter(new FileWriter(filePath))) {        bw.write(\"Hi,Mrpersimmon!\");        bw.write(\"
\"); // 添加换行符        bw.write(\"欢迎你来到柿子博客\".toCharArray(), 0, 3); // toCharArray 可以将字符串转换成字符数组        bw.write(\"
\");        bw.write(\"欢迎你来到柿子博客\", 3, 6);    } catch (IOException e) {        e.printStackTrace();    }}

Tips

FileWriter 要和 BufferedWriter 一起使用,性能更好; 一定要关闭输出流或者 flush ,否则无法写入到文件中。

运行结果

Reader(字符输入流)

Reader用于从文件读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。

常用方法

返回值

方法名

描述

int

read()

从输入流读取一个字符。

int

read(char[] cbuf)

从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length) 。

int

read(char[] cbuf, int off, int len)

在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

long

skip(long n)

忽略输入流中的 n 个字符,返回实际忽略的字符数。

void

close()

关闭输入流并释放相关的系统资源。

FileReader

InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件。

类图关系如下:

使用案例

需求 :读取 mrpersimmon-1.txt 中的信息

代码

java复制代码@Testpublic void testFileReader1() {    try (BufferedReader br = new BufferedReader(new FileReader(\"mrpersimmon-1.txt\"))) {        char[] cbuf = new char[8];        int readLen = 0;        while ((readLen = br.read(cbuf)) != -1) {            System.out.print(new String(cbuf, 0, readLen));        }    } catch (IOException e) {        e.printStackTrace();    }}

运行结果

复制代码Hi,Mrpersimmon!欢迎你来到柿子博客

字节/字符缓冲流

IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节/字符,从而避免频繁的 IO 操作,提高流的传输效率。

字节缓冲流这里采用了 装饰器模式 来增强 InputStream 和OutputStream子类对象的功能。字符缓冲流同理。

常见的使用方式已在上面的使用案例中给出,使用方式如下:

java复制代码BufferedReader br = new BufferedReader(new FileReader(\"mrpersimmon-1.txt\"))

字节流和字节缓冲流性能对比

字节流和字节缓冲流的性能差别主要体现在调用 write(int b) 和 read() 这两种一次只写入/读取一个节点的方式时。由于 字节缓冲流内部有缓冲区(字节数组) ,因此, 字节缓冲流会先将读取到的字节存放在缓存区 ,大幅减少 IO 次数,提高读取效率。

测试对比 1(单字节处理)

分别使用字节流和字节缓冲流的方式复制一个 207 MB 的 PDF 文件,查看耗时对比。

代码

1. 使用字节流复制 PDF 文件

java复制代码@Testpublic void copyFileByStream() {    System.out.println(\"使用字节流复制 PDF 文件测试开始\");    long startTime = System.currentTimeMillis(); // 记录开始时间    String srcPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf\";    String destPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes-stream.pdf\";    try (FileInputStream fis = new FileInputStream(srcPath);         FileOutputStream fos = new FileOutputStream(destPath)) {        int content = 0;        while ((content = fis.read()) != -1) {            fos.write(content);        }    } catch (IOException e) {        e.printStackTrace();    }    long endTime = System.currentTimeMillis(); // 记录结束时间    System.out.println(\"使用字节流复制 PDF 文件总耗时 \" + (endTime - startTime) + \" 毫秒\");}

2. 使用缓冲字节流复制 PDF 文件

java复制代码@Testpublic void copyFileByBufferStream() {    System.out.println(\"使用缓冲字节流复制 PDF 文件测试开始\");    long startTime = System.currentTimeMillis(); // 记录开始时间    // 文件大小 207 MB    String srcPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf\";    String destPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes-buffer-stream.pdf\";    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcPath));         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destPath))) {        int content = 0;        while ((content = bis.read()) != -1) {            bos.write(content);        }    } catch (IOException e) {        e.printStackTrace();    }    long endTime = System.currentTimeMillis(); // 记录结束时间    System.out.println(\"使用缓冲字节流复制 PDF 文件总耗时 \" + (endTime - startTime) + \" 毫秒\");}

结果对比

yaml复制代码使用字节流复制 PDF 文件总耗时 1052141 毫秒使用缓冲字节流复制 PDF 文件总耗时 6521 毫秒

可以看到,两者耗时差别绝大,相较于字节流,使用缓冲字节流节省约 161 倍的耗时。

测试对比 2(字节数组处理)

分别使用字节流+字节数组、字节缓冲流+字节数组的方式复制一个 207 MB 的 PDF 文件,查看耗时对比。

代码

1. 使用字节流 + 字节数组复制 PDF 文件

java复制代码@Testpublic void copyFileByByteArrStream() {    System.out.println(\"使用字节流+字节数组复制 PDF 文件测试开始\");    long startTime = System.currentTimeMillis(); // 记录开始时间    // 文件大小 207 MB    String srcPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf\";    String destPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes-arr-stream.pdf\";    try (FileInputStream fis = new FileInputStream(srcPath);         FileOutputStream fos = new FileOutputStream(destPath)) {        int readLen = 0;        byte[] b = new byte[8 * 1024];        while ((readLen = fis.read(b)) != -1) {            fos.write(b, 0, readLen);        }    } catch (IOException e) {        e.printStackTrace();    }    long endTime = System.currentTimeMillis(); // 记录结束时间    System.out.println(\"使用字节流 + 字节数组复制 PDF 文件总耗时 \" + (endTime - startTime) + \" 毫秒\");}

2. 使用缓冲字节流 + 字节数组复制 PDF 文件

java复制代码@Testpublic void copyFileByByteArrBufferStream() {    System.out.println(\"使用缓冲字节流+字节数组复制 PDF 文件测试开始\");    long startTime = System.currentTimeMillis(); // 记录开始时间    // 文件大小 207 MB    String srcPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf\";    String destPath = \"/Users/sunnywinter/Downloads/深入剖析Kubernetes-arr-buf-stream.pdf\";    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcPath));         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destPath))) {        int readLen = 0;        byte[] b = new byte[8 * 1024];        while ((readLen = bis.read(b)) != -1) {            bos.write(b, 0, readLen);        }    } catch (IOException e) {        e.printStackTrace();    }    long endTime = System.currentTimeMillis(); // 记录结束时间    System.out.println(\"使用缓冲字节流 + 字节数组复制 PDF 文件总耗时\" + (endTime - startTime) + \"毫秒\");}

结果对比

复制代码使用字节流 + 字节数组复制 PDF 文件总耗时 478 毫秒使用缓冲字节流 + 字节数组复制 PDF 文件总耗时 391 毫秒

可以看到,两者差距不是特别大,但是 缓冲字节流仍具有优势 。

结论

在日常使用时,应当使用缓冲流,以获取更好的性能优势。

字符缓冲流也是同理,限于篇幅,不再提供测试案例,感兴趣的同学可以自行测试。

源码分析

BufferedInputStream

BufferedInputStream 内部维护了一个缓冲区,这个缓冲区是一个字节数组。下面是源码中的一部分内容:

java复制代码publicclass BufferedInputStream extends FilterInputStream {    // 缓冲区默认大小private static int DEFAULT_BUFFER_SIZE = 8192;    // 内部缓冲区字节数组    protected volatile byte buf[];    // 构造函数,使用默认的缓冲区大小    public BufferedInputStream(InputStream in) {        this(in, DEFAULT_BUFFER_SIZE);    }    // 构造函数,使用自定义的缓冲区大小    public BufferedInputStream(InputStream in, int size) {        super(in);        if (size <= 0) {            throw new IllegalArgumentException(\"Buffer size <= 0\");        }        buf = new byte[size];    }}

BufferedOutputStream

下面是源码中的一部分内容:

java复制代码publicclass BufferedOutputStream extends FilterOutputStream {    // 内部缓冲区字节数组    protected byte buf[];    // 构造函数,默认缓冲区大小为 8192     public BufferedOutputStream(OutputStream out) {        this(out, 8192);    }    // 构造函数,使用自定义的缓冲区大小    public BufferedOutputStream(OutputStream out, int size) {        super(out);        if (size <= 0) {            throw new IllegalArgumentException(\"Buffer size <= 0\");        }        buf = new byte[size];    }}

BufferedReader

和 BufferedInputStream 一样,在内部维护了一个缓冲区,不同的是,这里是字符缓冲区。下面是源码中的一部分内容:

java复制代码public class BufferedReader extends Reader {    // 内部缓冲区字符数组private char cb[];    // 默认缓冲区大小    private static int defaultCharBufferSize = 8192;    // 构造函数,使用自定义的缓冲区大小    public BufferedReader(Reader in, int sz) {        super(in);        if (sz <= 0)            throw new IllegalArgumentException(\"Buffer size <= 0\");        this.in = in;        cb = new char[sz];        nextChar = nChars = 0;    }    // 构造函数,使用默认缓冲区大小    public BufferedReader(Reader in) {        this(in, defaultCharBufferSize);    }}

BufferedWriter

下面是源码中的一部分内容:

java复制代码public class BufferedWriter extends Writer {// 内部缓冲区字符数组    private char cb[];    // 默认缓冲区大小    private static int defaultCharBufferSize = 8192;    // 构造函数,使用默认缓冲区大小    public BufferedWriter(Writer out) {        this(out, defaultCharBufferSize);    }    // 构造函数,使用自定义的缓冲区大小    public BufferedWriter(Writer out, int sz) {        super(out);        if (sz <= 0)            throw new IllegalArgumentException(\"Buffer size <= 0\");        this.out = out;        cb = new char[sz];        nChars = sz;        nextChar = 0;        lineSeparator = java.security.AccessController.doPrivileged(            new sun.security.action.GetPropertyAction(\"line.separator\"));    }}

打印流

打印流只有输出流(内存 ->文件),没有输入流。

PrintStream(字节打印流)

我们经常使用的 System.out 就是用于获取一个 PrintStream 对象,System.out.print 方法实际调用的是 PrintStream 对象的 write 方法。

默认情况下,PrintStream 输出数据的位置是标准输出,即显示器。

类图关系如下:

源码

下面是 PrintStream 的部分源码:

java复制代码public class PrintStream extends FilterOutputStream    implements Appendable, Closeable{    // 调用的 write 方法    public void print(String s) {        if (s == null) {            s = \"null\";        }        write(s);    }        private void write(String s) {        try {            synchronized (this) {                ensureOpen();                textOut.write(s);                textOut.flushBuffer();                charOut.flushBuffer();                if (autoFlush && (s.indexOf("
") >= 0))                    out.flush();            }        }        catch (InterruptedIOException x) {            Thread.currentThread().interrupt();        }        catch (IOException x) {            trouble = true;        }    }}

PrintWriter(字符打印流)

包装了 FileWriter ,提供了更方便的方法来完成输出。

类图关系如下

这里我就给出一个案例来说明字符打印流要如何使用。

需求:将 mrpersimmon-1.txt 中内容打印到 mrpersimmon-copy.txt 文件中。

代码

java复制代码@Testpublic void testPrintWriter() {    try (BufferedReader br = new BufferedReader(new FileReader(\"mrpersimmon-1.txt\"));         PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter(\"mrpersimmon-copy.txt\")))) {        String line = null;        while ((line = br.readLine()) != null) { // 一次读取一行内容,为空时代表读取结束            pw.println(line); // 换行并打印到指定文件中        }    } catch (IOException e) {        e.printStackTrace();    }}

运行结果

随机访问流

在本小结,主要介绍 支持随意跳转到文件任意位置读写 的 RandomAccessFile 类。

类关系如下:

构造函数

构造函数的源码如下:

java复制代码// String name: 指定名称的文件public RandomAccessFile(String name, String mode)    throws FileNotFoundException{    this(name != null ? new File(name) : null, mode);}// String file: 指定文件public RandomAccessFile(File file, String mode)        throws FileNotFoundException{    // 省略...}

我们重点介绍输入参数 mode(读写模式)。根据源码中的注释,读写模式共四种:

r: 只读模式; rw: 读写模式; rws: 相较于 rw,还需要将对「文件内容」或「元数据」的每次更新同步写入底层存储设备; rwd: 相较于 rw,还要求对「文件内容」的每次更新都同步写入底层存储设备。

什么是「文件内容」?什么是「元数据」?

「文件内容」指的是文件中实际保存的数据,「元数据」则是用来描述文件属性比如文件的大小信息、创建和修改时间。

rwd 相较于 rws 来说,可以减少执行 IO 操作次数。

文件指针

RandomAccessFile 中有一个文件指针用于表示 下一个将要被写入或者读取的字节所处的位置 。

我们可以通过 seek(long pos) 设置文件指针的偏移量 (据文件开头 pos 个字节处)。源码如下:

java复制代码public void seek(long pos) throws IOException {    if (pos < 0) {        throw new IOException(\"Negative seek offset\");    } else {        seek0(pos);    }}private native void seek0(long pos) throws IOException;

如果想要 获取文件指针当前位置 的话,可以使用 getFilePointer() 方法。源码如下:

java复制代码public native long getFilePointer() throws IOException;

常见方法

返回值

常用方法

描述

long

getFilePointer()

获取文件指针当前位置

void

set(long pos)

设置文件指针的偏移量

long

length()

返回文件的长度

int

read()

读取一个字节

int

read(byte[] b)

从该文件读取最多 b.length字节的数据到字节数组。

int

read(byte[] b, int off, int len)

从该文件读取最多 len个字节的数据到字节数组。

String

readLine()

读取下一行文本。

String

readUTF()

从该文件读取字符串。

void

write(byte[] b)

从指定的字节数组写入 b.length个字节到该文件,从当前文件指针开始。

void

write(byte[] b, int off, int len)

从指定的字节数组写入 len个字节,从偏移量 off开始写入此文件。

void

write(int b)

将指定的字节写入此文件。

void

writeUTF(String str)

以机器无关的方式使用 UTF-8 编码将字符串写入文件。

int

skipBytes(int n)

尝试跳过 n 字节的输入,丢弃跳过的字节。

使用案例

java复制代码@Testpublic void testRandomAccessFile() {    try(RandomAccessFile raf = new RandomAccessFile(new File(\"mrpersimmon.txt\"), \"rw\")) {        // readLine() 读取一行文本        System.out.println(\"起始文件内容:\" + raf.readLine());        raf.seek(0); // 设置文件指针偏移量为 0,回到起始位置        // read() 读取一个字节        // getFilePointer 获取文件指针当前位置        System.out.println(\"读取前的偏移量:\" + raf.getFilePointer() + \",当前读取的字符:\" + (char) raf.read() + \",读取后的偏移量:\" + raf.getFilePointer());        raf.seek(6); // 设置文件指针偏移量为 6        System.out.println(\"读取前的偏移量:\" + raf.getFilePointer() + \",当前读取的字符:\" + (char) raf.read() + \",读取后的偏移量:\" + raf.getFilePointer());        raf.write(new byte[]{"h", "i"});        System.out.println(\"写入后的偏移量:\" + raf.getFilePointer() + \",当前读取的字符:\" + (char) raf.read() + \",读取后的偏移量:\" + raf.getFilePointer());        raf.seek(0); // 设置文件指针偏移量为 0,回到起始位置        System.out.println(\"当前文件的内容为:\" + raf.readLine());        raf.seek(0); // 设置文件指针偏移量为 0,回到起始位置        raf.write(new byte[]{"A", "B", "C"}); // 覆盖数据        raf.seek(0);        System.out.println(\"覆盖后的文件内容为:\" + raf.readLine());    } catch (IOException e) {        e.printStackTrace();    }}

运行结果

css复制代码起始文件内容:abcdefg读取前的偏移量:0,当前读取的字符:a,读取后的偏移量:1读取前的偏移量:6,当前读取的字符:g,读取后的偏移量:7写入后的偏移量:9,当前读取的字符:,读取后的偏移量:9当前文件的内容为:abcdefghi覆盖后的文件内容为:ABCdefghi

应用场景

RandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。

何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。

该部分内容我们会在后续实战部分中,手写一个断点续传下载器进行详细讲解。

综合应用案例

案例 1:格式化读取写入文本

需求说明

1. 给定一个 Employee 类。

java复制代码public class Employee {    private String name; // 姓名    private double salary; // 薪水    private LocalDate hireDay; // 雇佣日期    public Employee(String n, double s, int year, int month, int day)    {        name = n;        salary = s;        hireDay = LocalDate.of(year, month, day);    }    public String getName()    {        return name;    }    public double getSalary()    {        return salary;    }    public LocalDate getHireDay()    {        return hireDay;    }    // 加薪    public void raiseSalary(double byPercent)    {        double raise = salary * byPercent / 100;        salary += raise;    }    @Override    public String toString()    {        return getClass().getName()                + \"[name=\" + name + \",salary=\" + salary + \",hireDay=\" + hireDay + \"]\";    }}

2. 我们需要按照指定格式写入到 Employee.dat 文件中。

第一行数字是写入的记录数量。

指定格式:姓名|薪水|入职时间

复制代码3Carl Cracker|75000.0|1987-12-15Harry Hacker|50000.0|1989-10-01Tony Tester|40000.0|1990-03-15

3. 从 Employee.dat 文件中读取数据打印到控制台中。

代码

java复制代码public class Main {   @Test    public void test() throws IOException {        Employee[] staff = new Employee[3];        staff[0] = new Employee(\"Carl Cracker\", 75000, 1987, 12, 15);        staff[1] = new Employee(\"Harry Hacker\", 50000, 1989, 10, 1);        staff[2] = new Employee(\"Tony Tester\", 40000, 1990, 3, 15);        // 按照指定格式写入到 `Employee.dat` 文件中。        try (PrintWriter out = new PrintWriter(\"Employee.dat\", String.valueOf(StandardCharsets.UTF_8))) {            out.println(staff.length);            for (Employee e : staff) {                out.println(e.getName() + \"|\" + e.getSalary() + \"|\" + e.getHireDay());            }        }        // 从 `Employee.dat` 文件中读取数据打印到控制台中。        try (Scanner in = new Scanner(new FileInputStream(\"Employee.dat\"), String.valueOf(StandardCharsets.UTF_8))) {            int n = in.nextInt();            in.nextLine();            Employee[] employees = new Employee[n];            for (int i = 0; i < n; i++) {                employees[i] = readEmployee(in);            }            for (Employee e : employees) {                System.out.println(e);            }        }    }    public Employee readEmployee(Scanner in) {        String line = in.nextLine();        // split 方法的参数是一个描述分隔符的正则表达式。        // 由于 \"|\" 在正则表达式中有特殊含义,所以需要 \"\" 来转义,而 \"\" 还需要一个 \"\" 来转义。        // 所以,表达式为 \"\|\"。        String[] tokens = line.split(\"\|\");        String name = tokens[0];        double salary = Double.parseDouble(tokens[1]);        LocalDate hireDate = LocalDate.parse(tokens[2]);        int year = hireDate.getYear();        int month = hireDate.getMonthValue();        int day = hireDate.getDayOfMonth();        return new Employee(name, salary, year, month, day);    } }

运行结果

工作目录中出现 Employee.dat 文件,有如下内容: 打印到控制台的内容如下:
css复制代码io.Employee[name=Carl Cracker,salary=75000.0,hireDay=1987-12-15]io.Employee[name=Harry Hacker,salary=50000.0,hireDay=1989-10-01]io.Employee[name=Tony Tester,salary=40000.0,hireDay=1990-03-15]

总结

在这一讲中,我们讲解了如何判断输入、输出流,字节流和字符流的区别和使用场景,缓冲流和普通流的对比实验,什么是打印流,最后介绍了随机访问流。

在下一讲中,我们去看看设计模式是如何在 Java IO 中应用的。

参考资料

JDK 8 API; JDK 9 API; Java核心技术·卷 II(原书第11版)高级特性; JavaGuide;

更新日志

时间

更新内容

2023.5.15

发布文章

2023.5.16

新增: 获取用户工作目录,获取不同操作系统的文件分隔符,try-with-resource 自动关闭流,java.io.Closeable 接口扩展 java.lang.AutoCloseable 接口的原因,综合应用那里。 修改: 修改创建文件的使用案例,完善内容格式。

2023.5.18

新增:Java IO 的设计模式(壹) —— 装饰器模式,在「总结」提供了 url 跳转。

2023.5.23

修改:图片更新。

作者:柿子先生 链接:https://juejin.cn/post/7233337405377593405

最新供应

更多