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)
创建文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Test public void createFile() { 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); try { file.createNewFile(); System.out.println("文件创建成功"); } catch (IOException e) { e.printStackTrace(); } }
|
- 使用
File(File parent, String child)
创建文件
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Test public void createFile2() { File parentFile = new File(System.getProperty("user.dir")); String fileName = "createFile2.txt"; File file = new File(parentFile, fileName); try { file.createNewFile(); System.out.println("文件创建成功"); } catch (IOException e) { e.printStackTrace(); } }
|
- 使用
File(String parent, String child)
创建文件
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Test public 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() |
判断是否是一个目录 |
使用案例
1 2 3 4 5 6 7 8 9 10 11
| @Test public 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() |
删除文件或目录 |
使用案例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Test public 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!
代码:
1 2 3 4 5 6 7 8 9 10 11 12
| @Test public void testFileOutputStream() { try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("mrpersimmon.txt"))) { String str = "Hi,Mrpersimmon!"; bos.write(str.getBytes("UTF-8")); } catch (IOException e) { e.printStackTrace(); } }
|
Tips
FileOutputStream
在使用中要和
BufferedOutputStream
一起使用,性能更好。
- try(...OutputStream) 可以自动关闭输出流,无需 try-finally
手动关闭。
运行结果:
DataOutputStream
DataOutputStream
以二进制格式写入所有的基本 Java 类型数据,不能单独使用,必须结合
FileOutputStream
,构造函数源码如下:
类图关系如下:
使用案例
需求:向 mrpersimmon2.txt 写入 Hi,Mrpersimmon!
代码:
1 2 3 4 5 6 7 8 9
| @Test public 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 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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 + '\'' + '}'; } }
|
功能代码
1 2 3 4 5 6 7 8 9 10
| @Test public 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
用于从文件读取数据(字节信息)到内存中,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
是一个比较常用的字节输入流子类,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。
类图关系如下:
使用案例
需求:读取 mrpersimmon.txt
文件,并将文件内容显示到控制台中。
方法 1:使用 read() 单个字节读取,效率较低。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Test public void testFileInputStream() { try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("mrpersimmon.txt"))) { System.out.println("文件中可读取的字节数量:" + bufferedInputStream.available());
long skipCounts = bufferedInputStream.skip(3); System.out.println("忽略的字节数量:" + skipCounts);
System.out.print("从文件中读取的字节内容:"); int content; while ((content = bufferedInputStream.read()) != -1) { System.out.print((char) content); } } catch (IOException e) { e.printStackTrace(); } }
|
Tips
- FileInputStream 在使用中要和 BufferedInputStream
一起使用,性能更好。
- try(...InputStream) 可以自动关闭输入流,无需 try-finally
手动关闭。
运行结果:
1 2 3
| 文件中可读取的字节数量:15 忽略的字节数量:3 从文件中读取的字节内容:Mrpersimmon!
|
方法 2:使用 read(byte[] b) 读取文件,提高效率。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Test public void testFileInputStream2() { try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("mrpersimmon.txt"))) { int bufSize = bufferedInputStream.available(); System.out.println("文件中可读取的字节数量:" + bufSize); byte[] buf = new byte[8];
long skipCounts = bufferedInputStream.skip(3); System.out.println("忽略的字节数量:" + skipCounts);
System.out.print("从文件中读取的字节内容:"); int readLen; while ((readLen = bufferedInputStream.read(buf)) != -1) { System.out.print(new String(buf, 0, readLen)); } } catch (IOException e) { e.printStackTrace(); } }
|
运行结果:
1 2 3
| 文件中可读取的字节数量:15 忽略的字节数量:3 从文件中读取的字节内容:Mrpersimmon!
|
DataInputStream
以二进制格式读取所有的基本 Java
类型数据,不能单独使用,必须结合 InputStream
的一个实现类使用,构造函数源码如下:
类图关系如下:
使用案例
需求:读取 mrpersimmon2.txt
文件,并将文件内容显示到控制台中。
代码
1 2 3 4 5 6 7 8 9 10
| @Test public void testDataInputStream() throws IOException { try(DataInputStream dis = new DataInputStream(new FileInputStream("mrpersimmon2.txt"))) { System.out.println(dis.readUTF()); } catch (IOException e) { e.printStackTrace(); } }
|
运行结果
ObjectInputStream
用于从输入流中读取 Java
对象(反序列化)。
类图关系如下:
使用案例
需求:读取 mrpersimmon3.txt 中的 Blog 对象。
代码
1 2 3 4 5 6 7 8 9 10 11
| @Test public 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); } }
|
运行结果
1 2
| Blog{name='mrpersimmon', url='https://www.mrpersimmon.top'} 数据读取完毕(反序列化完成)
|
综合案例
需求
完成图片的拷贝。
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Test public 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) { 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
是基于该基础上的封装,可以直接将字符写入到文件。
类图如下:
使用案例
代码
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Test public void testFileWriter() { String filePath = "mrpersimmon-1.txt"; try (BufferedWriter bw = new BufferedWriter(new FileWriter(filePath))) { bw.write("Hi,Mrpersimmon!"); bw.write("\n"); bw.write("欢迎你来到柿子博客".toCharArray(), 0, 3); bw.write("\n"); 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 中的信息
代码
1 2 3 4 5 6 7 8 9 10 11 12
| @Test public 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(); } }
|
运行结果
1 2 3
| Hi,Mrpersimmon! 欢迎你 来到柿子博客
|
字节/字符缓冲流
IO
操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节/字符,从而避免频繁的
IO 操作,提高流的传输效率。
字节缓冲流这里采用了装饰器模式来增强
InputStream
和OutputStream
子类对象的功能。字符缓冲流同理。
常见的使用方式已在上面的使用案例中给出,使用方式如下:
1
| BufferedReader br = new BufferedReader(new FileReader("mrpersimmon-1.txt"))
|
字节流和字节缓冲流性能对比
字节流和字节缓冲流的性能差别主要体现在调用 write(int b)
和 read()
这两种一次只写入/读取一个节点的方式时。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少
IO 次数,提高读取效率。
测试对比 1(单字节处理)
分别使用字节流和字节缓冲流的方式复制一个 207 MB 的 PDF
文件,查看耗时对比。
代码
1. 使用字节流复制 PDF 文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Test public 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 文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Test public void copyFileByBufferStream() { System.out.println("使用缓冲字节流复制 PDF 文件测试开始"); long startTime = System.currentTimeMillis(); 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) + " 毫秒"); }
|
结果对比
1 2 3
| 使用字节流复制 PDF 文件总耗时 1052141 毫秒
使用缓冲字节流复制 PDF 文件总耗时 6521 毫秒
|
可以看到,两者耗时差别绝大,相较于字节流,使用缓冲字节流节省约 161
倍的耗时。
测试内容 2(字节数组处理)
分别使用字节流+字节数组、字节缓冲流+字节数组的方式复制一个 207 MB 的
PDF 文件,查看耗时对比。
代码
1. 使用字节流 + 字节数组复制 PDF 文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Test public void copyFileByByteArrStream() { System.out.println("使用字节流+字节数组复制 PDF 文件测试开始"); long startTime = System.currentTimeMillis(); 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 文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Test public void copyFileByByteArrBufferStream() { System.out.println("使用缓冲字节流+字节数组复制 PDF 文件测试开始"); long startTime = System.currentTimeMillis(); 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) + "毫秒"); }
|
结果对比
1 2 3
| 使用字节流 + 字节数组复制 PDF 文件总耗时 478 毫秒
使用缓冲字节流 + 字节数组复制 PDF 文件总耗时 391 毫秒
|
可以看到,两者差距不是特别大,但是缓冲字节流仍具有优势。
结论
在日常使用时,应当使用缓冲流,以获取更好的性能优势。
字符缓冲流也是同理,限于篇幅,不再提供测试案例,感兴趣的同学可以自行测试。
源码分析
BufferedInputStream
内部维护了一个缓冲区,这个缓冲区是一个字节数组。下面是源码中的一部分内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class 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
下面是源码中的一部分内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class BufferedOutputStream extends FilterOutputStream { protected byte buf[]; 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
一样,在内部维护了一个缓冲区,不同的是,这里是字符缓冲区。下面是源码中的一部分内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 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
的部分源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| public class PrintStream extends FilterOutputStream implements Appendable, Closeable { 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('\n') >= 0)) out.flush(); } } catch (InterruptedIOException x) { Thread.currentThread().interrupt(); } catch (IOException x) { trouble = true; } } }
|
PrintWriter(字符打印流)
包装了 FileWriter
,提供了更方便的方法来完成输出。
类图关系如下
使用案例
这里我就给出一个案例来说明字符打印流要如何使用。
需求:将 mrpersimmon-1.txt 中内容打印到 mrpersimmon-copy.txt
文件中。
代码
1 2 3 4 5 6 7 8 9 10 11 12
| @Test public 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
类。磁盘文件都是随机访问的,但是与网络
Socket 通信的输入输出流却不是。当我们将已有文件作为 RandomAccessFile
打开时,这个文件不会被删除。
类关系如下:
构造函数
构造函数的源码如下:
1 2 3 4 5 6 7 8 9 10 11 12
| public RandomAccessFile(String name, String mode) throws FileNotFoundException { this(name != null ? new File(name) : null, mode); }
public RandomAccessFile(File file, String mode) throws FileNotFoundException { }
|
我们重点介绍输入参数
mode
(读写模式)。根据源码中的注释,读写模式共四种:
r
: 只读模式;
rw
: 读写模式;
rws
: 相较于
rw
,还需要将对「文件内容」或「元数据」的每次更新同步写入底层存储设备;
rwd
: 相较于
rw
,还要求对「文件内容」的每次更新都同步写入底层存储设备。
什么是「文件内容」?什么是「元数据」?
「文件内容」指的是文件中实际保存的数据,「元数据」则是用来描述文件属性比如文件的大小信息、创建和修改时间。
rwd 相较于 rws 来说,可以减少执行 IO 操作次数。
文件指针
RandomAccessFile
中有一个文件指针用于表示下一个将要被写入或者读取的字节所处的位置。
我们可以通过 seek(long pos)
设置文件指针的偏移量(据文件开头 pos
个字节处)。源码如下:
1 2 3 4 5 6 7 8
| 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()
方法。源码如下:
1
| 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 字节的输入,丢弃跳过的字节。 |
使用案例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Test public void testRandomAccessFile() { try(RandomAccessFile raf = new RandomAccessFile(new File("mrpersimmon.txt"), "rw")) { System.out.println("起始文件内容:" + raf.readLine()); raf.seek(0); System.out.println("读取前的偏移量:" + raf.getFilePointer() + ",当前读取的字符:" + (char) raf.read() + ",读取后的偏移量:" + raf.getFilePointer()); raf.seek(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); System.out.println("当前文件的内容为:" + raf.readLine()); raf.seek(0); raf.write(new byte[]{'A', 'B', 'C'}); raf.seek(0); System.out.println("覆盖后的文件内容为:" + raf.readLine()); } catch (IOException e) { e.printStackTrace(); } }
|
运行结果
1 2 3 4 5 6
| 起始文件内容:abcdefg 读取前的偏移量:0,当前读取的字符:a,读取后的偏移量:1 读取前的偏移量:6,当前读取的字符:g,读取后的偏移量:7 写入后的偏移量:9,当前读取的字符:,读取后的偏移量:9 当前文件的内容为:abcdefghi 覆盖后的文件内容为:ABCdefghi
|
应用场景
RandomAccessFile
比较常见的一个应用就是实现大文件的
断点续传 。
何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。
该部分内容我们会在后续实战部分中,手写一个断点续传下载器进行详细讲解。
综合应用案例
案例 1:格式化读取写入文本
需求说明
- 给定一个 Employee 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| 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 + "]"; } }
|
- 我们需要按照指定格式写入到
Employee.dat
文件中。
第一行数字是写入的记录数量。
指定格式:姓名|薪水|入职时间
1 2 3 4
| 3 Carl Cracker|75000.0|1987-12-15 Harry Hacker|50000.0|1989-10-01 Tony Tester|40000.0|1990-03-15
|
- 从
Employee.dat
文件中读取数据打印到控制台中。
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| 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); 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()); } } 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(); 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
文件,有如下内容:
- 打印到控制台的内容如下:
1 2 3
| 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
接口的原因,增加综合应用案例。修改:修改创建文件的使用案例,完善格式。 |