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