文件操作和 IO(二):文件内容操作 => 流对象
目录
1. 流
1.1 什么是流
1.2 字节流/字符流
2. InputStream(字节流 - 读/输入)
2.1 打开文件
2.2 关闭文件
2.2.1 try with resources
2.3 读文件
2.3.1 int read()
2.3.2 int read(byte[] b)
2.3.3 read(byte[] b, int off, int len)
3. OutputStream(字节流 - 写/输出)
3.1 打开/关闭文件
3.2 写文件
3.2.1 追加写
4. Reader(字符流 - 读/输入)
4.1 打开/关闭文件
4.2 读文件
4.2.1 转码
5. Writer(字符流 - 写/输出)
6. 拓展 - 缓冲区
7. 练习一 (扫描文件名称并删除)
7.1 代码
8. 练习二 (文件复制)
8.1 代码
9. 练习三 (根据名称和内容搜索文件)
9.1 代码
引言, 上篇博客所讲的 File 类, 是针对文件进行系统操作.
而本篇博客所讲的"流", 就是针对文件进行内容操作 --- 读写文件.
1. 流
针对文件的内容操作(读写文件), 主要是通过 "流对象" 来实现的.
为了方便大家更好的理解什么是 "流", 这里举个例子.
1.1 什么是流
说起流, 想必大家第一时间就会想到 水流.
如果我们要接100ml的水, 通过水流可以有很多种接法:
- 可以一次接100ml, 一次性接完.
- 可以一次接50ml, 分两次接完.
- 可以一次接1ml, 分一百次接完.
- ......
计算机中的 "流" 和水流也是十分相似的:
如果我们要从文件中读取100字节的数据, 通过 "流" 可以有很多种读数据的方法:
- 可以一次读100字节数据, 一次读完.
- 可以一次读50字节数据, 分两次读完.
- 可以一次读1字节数据, 分一百次读完.
- ......
所以, 在计算机中, 读写数据也是通过 "流(stream)" 来实现的.
流, 是操作系统层面的术语, 各种编程语言操作文件, 都叫做流.
1.2 字节流/字符流
Java 中, 也提供了几十个类来表示 流.
针对那么多的流, 大体上分为以下两个大的类别:
- 字节流. 在读写文件时, 以字节为单位, 是针对二进制文件使用的.
- 字符流. 在读写文件时, 以字符为单位, 是针对文本文件使用的
注意:
字节 != 字符
一个字节对应多少个字符呢?? 答案是不确定, 这取决于字符集(编码方式)
举个例子,
一个汉字占多少个字节, 这取决于字符集(汉字是怎样编码的).
- gbk 字符集中, 一个汉字占2个字节(Windows 10/11 简体中文版默认是 gbk 编码)
- utf8 字符集中, 一个汉字占3个字节. (utf8 本身是变长编码, 1~4 个字节)
- Unicode 字符集中, 一个汉字占2个字节.(Java 的 char 使用的就是 Unicode)
针对 字节流, 有两个代表性的类(其他流对象, 都是直接或间接的继承自这两个类):
- InputStream --- 输入(从文件中读数据)
- OutputStream --- 输出(往文件中写数据)
针对 字符流, 有两个代表性的类(其他流对象, 都是直接或间接的继承自这两个类):
- Reader --- 输入(从文件中读数据)
- Writer --- 输出(往文件中写数据)
啥叫输入, 啥叫输出呢??
输入, 输入, 是在 cpu 的视角下, 数据的流向.
- 输入: 数据从硬盘(文件) => cpu/内存
- 输出: 数据从cpu => 硬盘/内存
可以把自己想象成 cpu, 如果数据迎面向你走来, 就是输入; 如果数据离你而去, 就是输出.
2. InputStream(字节流 - 读/输入)
上文说到, InputStream 可以针对文件进行读(输入)操作.
注意: 像 InputStream 这样的流对象体系, 不仅是针对文件进行操作, 像后续的网络编程也需要 流对象.
2.1 打开文件
InputStream 本身也是一个抽象类, 是不能进行实例化的.
通过 new FileInputStream 来完成向上转型, FileInputStream 的构造方法, 填写要操作文件的路径(绝对/相对).
由于该文件可能是不存在的, 所以可能会抛出 FileNotFoundException 异常(继承自IOException):
(文件没有找到的异常)
并且, 这里的创建对象操作一旦成功, 就相当于 "打开文件" 的操作(类似于 C语言的 fopen)
只有先打开文件, 才能进行后续的读写操作, 而打开操作, 就是根据文件路径, 定位到对应的磁盘空间.
2.2 关闭文件
由于文件也是一种资源, 所以谈到打开文件, 那就不得不提关闭文件~
在对文件完成相关的操作后, 一定一定要关闭文件资源. 如果没有关闭文件的话, 就会造成"文件资源泄露", 类似于 C语言 的"内存资源泄露".
对于我们Java 程序员, 由于内存的释放 JVM 的 GC 已经帮助我们自动完成了, 我们只需申请内存就好, 释放我们不用去管.
但是 文件资源 不等同于 内存资源, 虽然 GC 能够自动管理内存, 但是不能自动管理文件, 文件资源的释放需要我们手动来完成.
而为什么非要释放内存资源呢??
还记得在进程中的 PCB 吗?? 进程的一个关键属性就是: 文件描述符表.
进程每打开一个文件, 就会在文件描述符表中申请一个表项(相当于占了个位置), 而文件描述符表的长度是固定的(无法扩容), 所以, 如果光打开文件而不释放文件, 就会使文件描述符表中的表项消耗殆尽, 后续再打开文件, 就会打开失败, bug 就出现了~
通俗来说, 如果只打开文件而不释放文件, 就是 "占着茅坑不拉屎"~
而 close 方法, 就是用来关闭文件资源的, 并且该方法可能抛出IOException 异常 :
上文说到, 为了避免 文件资源泄露, close 方法是一定要执行的,
为了避免程序中途 return 或者抛异常使程序提前结束, 而导致 close 没有执行到, 我们可以将 close 放到 finally 块中, 从而保证 close 100%被执行.
虽然这样保证了 close 的执行, 但是又出现了一个新的问题 => 丑!!
2.2.1 try with resources
try-finally 虽然保证了 close 的执行, 但是代码变得非常的冗余不美观, 要知道这是一个看脸的世界~
而 try with resources 就可以在保证 close 的执行(文件资源关闭)的前提下, 又能保证代码的简洁美观.
try with resources 是 Java 1.5 引入的语法, 可以保证代码出了 try 的代码块, 就会执行 close.
并且, try 内可以打开多个资源, 中间使用 ; 隔开即可:
到这里, 有同学就想到了之前的 ReentrantLock : "既然 try with resources 这么香, 是不是它也能保证 ReentrantLock 的 unlock 的执行??"
很遗憾, 答案是不能~
因为只有实现了 Closable 接口的类才能够使用 try with resources, Closable 接口约定了类必须有 close 方法, 这样才能让 JVM 自动调用~~
2.3 读文件
打开文件后, 通过 read 方法来读取文件中的内容, read 的使用(传参)有以下三种形式:
2.3.1 int read()
read 不带参数的版本, 调用一次, 读取一个字节的数据, 返回读取到的内容.
当数据全部读取完毕后, 返回 -1.
这里有一个问题, 明明是读取一个字节的内容, 为什么 read 的返回值是一个 int 类型的数据呢?? 为啥不用 byte 类型呢??
- 因为一个字节(8 bit)数据的取值范围为 0 ~ 255 .
- 而一个 byte 的只能表示 -128 ~ 127
- 并且 int 还可以表示 -1, 代表文件读取完毕.
我们将 test.txt 中的内容换成汉字, 继续观察:
发现, 一个汉字是3个字节, 推断出是 utf8 编码. 查看码表, 发现确实是文件中的内容, 故, 读取文件成功.
while (true) {// 一次读取一个字节的内容int data = inputStream.read();if (data == -1) {// 文件读完break;}System.out.println(data);}
2.3.2 int read(byte[] b)
- read 带一个参数的版本, 调用一次, 读取多个字节.
- 其中字节数组作为 "输出型参数" 传入, 会将读取到的内容尽可能的填满到字节数组中(也就是说, 每次最多能读取的字节数是字节数组的长度), 如果填不满, 能填几个是几个. 返回值是读取到的字节数, 当文件全部读取完毕时, 返回 -1
- 如果定义的字节数组足够大(能够装满文件内容), 则会一次读完所有的数据
while (true) {// 自定义数组长度byte[] data = new byte[1024];// 一次读取多个字节// data 作为"输出型"参数, 将读取到的内容尽可能填满字节数组// 如果填不满, 能填几个是几个// 返回值是读取到的字节数int n = inputStream.read(data);if (n == -1) {// 文件读完break;}for (int i = 0; i < n; i++) {System.out.println(data[i]);}}
2.3.3 read(byte[] b, int off, int len)
- 字节数组 byte[] b 仍然作为 "输出型参数" 保存读取到的内容.
- off => offset, 代表偏移量(数组下标), 将读取到的内容放到字节数组的 off 下标处.(带一个参数的版本, 默认将数据放到 0 下标处)
- len 代表, 一次最多读取 len 个字节的内容.
- 返回值仍然为读取到的字节数, 当文件全部读取完毕返回 -1
这个版本的作用就是, 可以将每次读操作读到的数据, 放到一个数组的某一部分.
3. OutputStream(字节流 - 写/输出)
3.1 打开/关闭文件
从 cpu 往文件中写(输出)数据, 同样需要先打开文件(搭配 try with resources 使用, 出代码块自动调用 close 关闭文件资源):
注意 OutputStream 的以下注意事项:
- 当文件不存在时, OutputStream 会自动创建这个文件!!(当文件不存在时, 不会出现报错)
- 每次使用 OutputStream 打开一个文件时, 会清除上次文件中的内容!! (在打开文件的一瞬间, 上次文件中的内容就清空了)
- 若采用 追加写 的方式, 会避免上次文件中的内容被清空.
3.2 写文件
OutputStream 通过 write 方法来向文件中写数据.
write 方法同样提供了三个版本:
- 第一个版本 write(int b), 传入要写数据的内容(ASCII 值):
- 第二个版本 write(byte[] b), 将字节数组中的内容全部写入文件中:
- 第三个版本 write(byte[] b, int off, int len) : 从字符数组的 off 下标处的数据开始, 写入文件中, 一共写入 len 个字节的数据.
3.2.1 追加写
为避免文件内容被清空, 可以采用 追加写 形式进行写操作.
上文的写操作是不带追加写模式的, 即每次进行写操作时, 都会把上次文件中的内容清空.
而追加写模式, 可以不清空上次文件中内容, 而继续往后写本次要写入的数据.
使用追加写, 直接在构造 FileOutputStream 对象时, 第二个参数传入 true 即可(开启追加写模式):
4. Reader(字符流 - 读/输入)
4.1 打开/关闭文件
Reader 的操作和 InputStream 的操作步骤相同, 想要读取一个文件的内容, 首先需要打开文件.
当 Reader 对象被创建好时, 文件就被打开了, 搭配 try with resources 使用, 出了代码块就会自动关闭文件资源:
同样, 当要读取的文件不存在时, 程序会抛出异常.
4.2 读文件
使用 read 进行读操作时, 是一个字符一个字符的读取, read 同样也提供了多种版本, 当读取完毕时返回 -1:
- 第一个版本, read(), 一次读取一个字符, 读取完毕返回 -1:
- 第二个版本(字符数组版本), read(char[] cbuf), 一次读多个字符放到字符数组中, 尽可能将数组填满, 读取完毕返回 -1:
- 第三个版本, read(char[] cbuf, int off, int len), 将读到的字符从字符数组的 off 位置开始放, 最多读 len 个
4.2.1 转码
到这里可以发现, 字符流的 read 和 字节流的 read 使用上是差不多的.
但是, 我们在使用字节流读取汉字时, 一汉字占的是 3 个字节(utf8 编码), 但是在使用字符流读取时, 一次读取的是两个字节的内容, 一个字符(包括汉字)占的是 2 个字节.
为什么两种方式读取的汉字都是相同的, 字符所占的字节数却不同呢?? 这两个哪个是对的, 哪个是错的呢??
答: 两个方式的读取结果, 都是正确的.
- 字节流读取的数据, 是文件中原始的数据, 在硬盘上保存文件的时候, 采用的是 utf8 编码, 一个汉字占的是 3 个字节.
- 而字符流在读取文件的时候, 就会根据文件的内容编码格式, 进行解析. 调用一次 read(), 确实读取到的是 3 个字节的内容('你' 这个汉字)(按照 utf8 进行的编码), 但是在返回的时候, 就对这 3 个字节('你' 这个汉字)进行转码了.
- 是这样进行转码的: 把 3 个字节的内容在 utf8 中查了一下, 发现是 '你' 这个汉字. read 又把 '你' 这个汉字在 unicode 表中查了一下, 得到 unicode 的编码值, 最终把 unicode 的编码值进行了返回, 最终返回的就是两个字节了.
综上, Java 使用字符流进行读取时, 会自动进行转码操作, 从而提升了我们的开发效率(代码写得快~~)
效率的影响因素有两个:
- 程序执行快 => 运行效率
- 代码写得快 => 开发效率(更为重要)
虽然转码有性能开销, 但是这些性能的开销却大大提高了我们的开发效率~~
5. Writer(字符流 - 写/输出)
Writer 是以字符流中进行写操作的类, 以字符的形式向文件中写数据.
其用法也是分为以下几步:
- 打开文件(Writer 对象一旦创建好, 就打开了文件. 文件可以不存在, Writer 会自动创建)
- 写文件(write() 方法, 以字符形式写数据)
- 关闭文件(出了 try 自动调用 close 方法)
可以观察到, Writer 的 write 方法, 不仅可以一次写一个字符, 可以一次写一个字符数组, 还可以一次写一个字符串.
此外, 若想使用 追加写 的形式写数据, 依旧在构造对象时, 第二个参数传入 true 即可:
6. 拓展 - 缓冲区
缓冲区, 通常就是一段内存空间.(可以提高程序的效率)
大家都知道, 硬盘的读写速度是比较慢的, 一次一次的读取是很低效的, 因此我们就希望减少文件的读写次数来提高效率.
因此在进行 IO 操作的时候, 就希望能够使用缓冲区, 把要读/写的内容放到缓冲区中先存一波, 攒一攒, 最后将这些数据一起读/写.
举个例子, 大家嗑瓜子时, 都喜欢把磕完的瓜子皮放到手中, 等瓜子皮在手中满后, 再去扔到垃圾桶中, 这样我们就避免每磕一个瓜子就去扔一个瓜子片, 而手, 就可以认为是一个缓冲区.
在上文中, 流对象的 read/write 方法中的字节数组/字符数组, 其实就是一个缓冲区, 可以提高文件读写时的效率.
综上, 8 个流对象(InputStream, FileInputStream, OutputStream, FileOutputStream, Writer, FileWriter, Reader, FileReader)的介绍已经完毕了, 总结如下:
- 流对象的使用流程: 打开文件 => 读/写文件 => 关闭文件
- 文本文件的读写使用字符流
- 二进制文件的读写使用字节流
7. 练习一 (扫描文件名称并删除)
要求: 扫描指定目录,并找到名称中包含指定字符的所有普通文件(不包含目录),并且后续询问用户是否要删除该文件
7.1 代码
/*** 扫描指定目录,并找到名称中包含指定字符的所有普通文件(不包含目录),并且后续询问用户是否要删除该文件*/
public class Demo11 {public static void main(String[] args) {Scanner scanner = new Scanner(System.in);System.out.println("请输入要搜索文件的路径:");String rootDir = scanner.next();File sourceFile = new File(rootDir);if(!sourceFile.isDirectory()) {System.out.println("输入的不是目录!!");return;}System.out.println("请输入要删除文件的关键字:");String key = scanner.next();searchFile(key, sourceFile);}private static void searchFile(String key, File sourceFile) {File[] files = sourceFile.listFiles();if (files == null) {// 目录为空return;}for (File file : files) {if (file.isFile()) {// 普通文件 => 判断isDestFile(key, file);}else {// 目录 => 递归深搜searchFile(key, file);}}}private static void isDestFile(String key, File file) {String fileName = file.getName();if (fileName.contains(key)) {System.out.println("找到含关键字的文件名, 该文件路径为:" + file.getAbsoluteFile());System.out.println("是否删除(y/s):");Scanner scanner = new Scanner(System.in);String input = scanner.next();if (input.equalsIgnoreCase("y")) {file.delete();System.out.println("文件删除成功!!");}}}
}
8. 练习二 (文件复制)
进行普通文件的复制
8.1 代码
/*** 进行普通文件的复制*/
public class Demo12 {public static void main(String[] args) throws IOException {Scanner scanner = new Scanner(System.in);System.out.println("请输入源文件路径:");String sourcePath = scanner.next();File sourceFile = new File(sourcePath);if (!sourceFile.isFile()) {System.out.println("源文件不是文件或不存在!!");return;}System.out.println("请输入目标文件路径:");String destPath = scanner.next();File destFile = new File(destPath);// 目标文件可以不存在, 但是目标文件下的目录必须存在if (!destFile.getParentFile().isDirectory()) {System.out.println("目标文件所在目录不存在!!");return;}copyFile(sourceFile, destFile);}private static void copyFile(File sourceFile, File destFile) throws IOException {// 进行的是复制操作, 要求两个文件中的内容完全相同// 所以不能使用 追加写try (InputStream inputStream = new FileInputStream(sourceFile);OutputStream outputStream = new FileOutputStream(destFile)) {while (true) {byte[] bytes = new byte[1024];int n = inputStream.read(bytes);if (n == -1) {break;}outputStream.write(bytes, 0, n);}}}
}
9. 练习三 (根据名称和内容搜索文件)
扫描指定目录,并找到名称或者内容中包含指定字符的所有普通文件(不包含目录)
9.1 代码
注意:由于代码是通过深搜来进行查找的, 所以目前方案性能较差,尽量不要在太复杂的目录下或者大文件下实验
/*** 扫描指定目录,并找到名称或者内容中包含指定字符的所有普通文件(不包含目录)*/
public class Demo13 {public static void main(String[] args) throws FileNotFoundException {Scanner scanner = new Scanner(System.in);System.out.println("请输入要搜索的目录:");String rootPath = scanner.next();File rootDir = new File(rootPath);if (!rootDir.isDirectory()) {System.out.println("输入的路径不是目录或不存在!!");return;}System.out.println("请输入要搜索的文件的名称的关键字或文件内容中的关键字:");String key = scanner.next();searchFile(key, rootDir);}private static void searchFile(String key, File rootDir) throws FileNotFoundException {File[] files = rootDir.listFiles();if (files == null) {return;}for (File file : files) {if (file.isFile()) {isDestFile(key, file);} else {searchFile(key, file);}}}private static void isDestFile(String key, File file) throws FileNotFoundException {if (file.getName().contains(key)) {System.out.println("文件名称中包含关键字: " + file.getAbsoluteFile());return;}StringBuilder stringBuilder = new StringBuilder();try (Reader reader = new FileReader(file)) {while (true) {char[] chars = new char[1024];int read = reader.read(chars);if (read == -1) {break;}stringBuilder.append(chars, 0, read);}} catch (IOException e) {throw new RuntimeException(e);}if (stringBuilder.indexOf(key) >= 0) {// 包含关键字System.out.println("文件内容中包含关键字: " + file.getAbsoluteFile());}}
}
END