写下一篇博客,可以使我加深对知识的理解,可以使我感到宁静。多年以后,回顾走过的编程,我曾经来过。
1. 前言
之前工作中接触 I/O 较少,只是用一些工具类处理问题。以为这样就可以了。后来在工作中用到多文件传输,文件合并,感到I/O流知识的欠缺。现在有时间把它们总结一下,写成本文。
2. 正文
2.1 Java I/O 系统的历史
版本 | 变化 | 类举例 |
---|---|---|
1.0 | 添加面向字节的类,File 类 |
File 、InputStream 、OutputStream 、FileInputStream 、FileOutputStream 、ByteArrayInputStream 、ByteArrayOutputStream 、FilterInputStream 、FilterOutputStream 、BufferedInputStream 、BufferedOutputStream 、DataInputStream 、DataOutputStream 、PrintStream 等 |
1.1 | 1,添加面向字符和基于Unicode的类 ;2,添加序列化流;3,添加了转换流 | Reader 、Writer 、FileWriter 、 FileReader 、FilterReader 、FilterWriter 、BufferedReader 、BufferedWriter 、CharArrayReader 、CharArrayWriter 、ObjectInputStream 、ObjectOutputStream 、OutputStreamWriter 、InputStreamReader 等 |
1.4 | 添加了 nio 类,用于改进性能及功能 | Buffer 、ByteBuffer 、FileChannel 等 |
2.2 File
类
我们不应该从 File
这个单词的名字出发,认为 File
类只是指代文件。实际上,它既能代表一个特定文件的名称,又能代表一个目录下的一组文件的名称。
另外,File
类不仅仅只代表存在的文件或目录,也可以用 File
对象来创建新的目录或尚不存在的整个目录路径。
File
类可以查看文件的特性(如:大小、最后修改日期、读/写),检查某个 File
对象代表的是一个文件还是一个目录,并可以删除文件,重命名文件。
2.2.1 遍历目录
介绍完了 File
类,下面我们来一起实现一个需求:获取指定目录下符合要求的所有文件。
首先,要获取到指定目录下的所有文件,这要用到递归的思想,代码如下:
private static List<File> getFileListInDir(File dir) {
List<File> result = new ArrayList<>();
if (dir.isDirectory()) {
File[] files = dir.listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
result.addAll(getFileListInDir(file));
} else {
result.add(file);
}
}
}
}
return result;
}
这里面使用了几个 File
类的方法:
- 使用
isDirectory()
方法来判断是否存在且是一个目录,也就是说这个方法包含exists()
(判断文件或者目录是否存在)的判断; - 使用
listFiles()
方法,获取指定目录下的目录或文件,需要说明的是,获取的是指定目录下直接包含的目录或文件,而不能获取所包含的目录里面的文件。
其次,获取满足要求的文件集合,比如满足后缀名为 .bat
的所有文件。这个需求不难实现,这里已经拿到了目录下的所有文件了。只要遍历总的文件集合,保留后缀名为 .java
的文件即可。这样是可以的,但是 Java 给我们提供了文件过滤器,来更好地实现这一需求。
public class RecurseDirDemo2 {
public static void main(String[] args) {
File dir = new File(".");
List<File> list = getFileListInDir(dir, new SuffixFilenameFilter(".bat"));
for (File file : list) {
System.out.println(file.getAbsolutePath());
}
}
private static List<File> getFileListInDir(File dir, FileFilter fileFilter) {
List<File> result = new ArrayList<>();
if (dir.isDirectory()) {
File[] files = dir.listFiles(fileFilter);
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
result.addAll(getFileListInDir(file, fileFilter));
} else {
result.add(file);
}
}
}
}
return result;
}
/** * 保留指定后缀名的文件 */
private static class SuffixFilenameFilter implements FileFilter {
private final String suffix;
private SuffixFilenameFilter(String suffix) {
this.suffix = suffix;
}
@Override
public boolean accept(File pathname) {
// 是目录,接受
if (pathname.isDirectory()) return true;
// 是文件,以指定后缀名结尾,才接受
return pathname.isFile() && pathname.getName().endsWith(suffix);
}
}
}
这里的变化就是给 listFiles()
方法传入了一个 FileFilter
的子类对象,这样就能实现获取后缀名为 .bat
的所有文件了。这是怎么实现的呢?看一下 listFiles()
方法的源码:
public File[] listFiles(FileFilter filter) {
String ss[] = list();
if (ss == null) return null;
ArrayList<File> files = new ArrayList<>();
for (String s : ss) {
File f = new File(s, this);
if ((filter == null) || filter.accept(f))
files.add(f);
}
return files.toArray(new File[files.size()]);
}
可以看到只有当 FileFilter
对象的 accept()
方法返回 true
时,才会把文件保留下来,放在 files
集合里面。FileFilter
对象实际上是提供了一个策略,允许传递任何实现了 FileFilter
接口的任何子类对象,这样就可以灵活地改变 listFiles()
方法返回的文件结果(是一种代码行为)。
2.2.2 File
一些方法的辨析
createNewFile()
与 createTempFile()
方法
createNewFile()
用于创建一个新的文件,如果指定的文件不存在并创建成功,会返回 true
;如果指定的文件已经存在,则返回 false
;如果指定的文件无法创建(例如文件所在的目录不存在),则会抛出异常。
createTempFile()
:File
类的静态方法,在默认临时文件目录中创建一个空文件,使用给定前缀和后缀生成其名称。在 Windows 电脑上,默认临时文件目录是 C:\Users\willw\AppData\Local\Temp\
;在 Android 系统上,默认临时文件目录是 /data/user/0/com.example.javaiostudy/cache
。
public class CreateFileApiDemo {
public static void main(String[] args) throws IOException {
File file1 = new File("./a.txt");
System.out.println("file1.createNewFile() = " + file1.createNewFile());
try {
File file2 = new File("./dirx/a.txt");
System.out.println("file2.createNewFile() = " + file2.createNewFile());
} catch (IOException e) {
System.out.println(e.toString());
}
File dir = new File("./diry");
if (!dir.exists()) {
boolean mkdir = dir.mkdir();
System.out.println(dir.getAbsolutePath() + "创建结果:" + mkdir);
if (mkdir) {
File file3 = new File("./diry/a.txt");
System.out.println("file3.createNewFile() = " + file3.createNewFile());
}
}
System.out.println(File.createTempFile("temp", ".tmp").getAbsolutePath());
}
}
打印结果:
file1.createNewFile() = true
java.io.IOException: 系统找不到指定的路径。
I:\AndroidProjects\BlogCodes\JavaIOStudy\.\diry创建结果:true
file3.createNewFile() = true
C:\Users\willw\AppData\Local\Temp\temp8631840391869796592.tmp
listFiles()
方法的返回值
listFiles()
方法的返回:返回指定目录下的所有文件,只包括该目录直接包含的文件或者目录。如果指定目录里面有内容,则返回一个有长度的 File
数组;如果指定目录为空,则返回一个空数组;如果指定路径不是一个目录,则返回 null
。
所以,对于 listFiles()
方法,需要注意的一点是返回值可以为 null
。
public class ListFilesApiDemo {
public static void main(String[] args) {
list(new File("."));
list(new File("./emptyDir"));
list(new File("what"));
}
private static void list(File dir) {
File[] files = dir.listFiles();
if (files == null) {
System.out.println(dir.getAbsolutePath() + "不是目录");
} else if (files.length == 0) {
System.out.println(dir.getAbsolutePath() + "是空目录");
} else {
System.out.println(dir.getAbsolutePath() + "里包含文件");
}
}
}
打印结果:
I:\AndroidProjects\BlogCodes\JavaIOStudy\.里包含文件
I:\AndroidProjects\BlogCodes\JavaIOStudy\.\emptyDir是空目录
I:\AndroidProjects\BlogCodes\JavaIOStudy\what不是目录
mkdir()
与 mkdirs()
方法
mkdir()
:用于创建单级目录,创建成功时返回 true
,否则返回 false
;
mkdirs()
:用于创建多级目录,创建成功时返回 true
,否则返回 false
。
public class MkDirMkDirsApiDemo {
public static void main(String[] args) {
System.out.println("new File(\"./dir1\").mkdir() = " + new File("./dir1").mkdir());
System.out.println("new File(\"./dira\").mkdirs() = " + new File("./dira").mkdirs());
System.out.println("new File(\"./dir_1/dir_2\").mkdir() = " + new File("./dir_1/dir_2").mkdir());
System.out.println("new File(\"./dir_a/dir_b\").mkdirs() = " + new File("./dir_a/dir_b").mkdirs());
}
}
打印结果:
new File("./dir1").mkdir() = true
new File("./dira").mkdirs() = true
new File("./dir_1/dir_2").mkdir() = false
new File("./dir_a/dir_b").mkdirs() = true
注意: 在开发中,在调用完创建目录的方法后,一定要查看一下返回值,即确定目录是否创建成功。想当然地认为目录一定会创建成功,可能会带来程序的 bug。
delete()
方法
delete()
方法可以用于删除一个文件或者目录。但是在删除一个目录时,只有该目录是空目录时才有效。当且仅当成功删除文件或目录时,返回 true
;否则返回 false
。
public class DeleteApiDemo {
public static void main(String[] args) throws IOException {
new File("./existfile.txt").createNewFile();
deletePrint(new File("./existfile.txt"));
deletePrint(new File("./notexistfile.txt"));
// mkdir:创建单级目录
boolean mkdir = new File("./existdir").mkdir();
System.out.println("mkdir = "+mkdir);
deletePrint(new File("./existdir"));
deletePrint(new File("./notexistdir"));
// mkdirs:创建多级目录
boolean mkdirs = new File("./existdir1/existdir2").mkdirs();
System.out.println("mkdirs = " + mkdirs);
deletePrint(new File("./existdir1"));
}
private static void deletePrint(File file1) {
System.out.println("删除" + file1.getName() + ",结果:" + file1.delete());
}
}
打印结果:
删除existfile.txt,结果:true
删除notexistfile.txt,结果:false
mkdir = true
删除existdir,结果:true
删除notexistdir,结果:false
mkdirs = true
删除existdir1,结果:false
2.2.3 删除目录
/** * 删除目录 * @param dir 要删除的目录 */
private static void deleteDir(File dir) {
if (dir == null) {
throw new IllegalArgumentException("dir is null");
}
File[] files = dir.listFiles();
if (files == null) {
// dir 不是一个目录,或者发生了I/O错误
throw new RuntimeException("dir is not a directory, or i/o error happens");
}
if (files.length == 0) {
// 是空目录
dir.delete();
return;
}
for (File file : files) {
if (file.isFile()) {
file.delete();
} else {
deleteDir(file);
}
}
dir.delete();
}
2.2.4 删除空目录
public class DeleteEmptyDirsDemo {
public static void main(String[] args) {
deleteEmptyDir(new File("C:\\Users\\39233\\Downloads\\a"));
}
private static void deleteEmptyDir(File dir) {
if (dir == null) {
throw new IllegalArgumentException("dir is null");
}
File[] files = dir.listFiles();
if (files == null) {
// dir 不是一个目录,或者发生了I/O错误
throw new RuntimeException("dir is not a directory, or i/o error happens");
}
if (files.length == 0) {
// 是空目录
dir.delete();
return;
}
for (File file : files) {
if (file.isDirectory()) {
deleteEmptyDir(file);
}
}
// 外层目录变成空目录
File[] listFiles = dir.listFiles();
if (listFiles != null && listFiles.length == 0) {
dir.delete();
}
}
}
2.3 字节流复制文本文件
2.3.1 简单地复制文本文件
这个需求不难,但是在这个简单的需求之上,我们会逐步丰富需求,也逐步地学习I/O相关的类。
代码实现如下:
public class SimpleCopyTextFile {
public static void main(String[] args) throws IOException {
// 1, 创建字节输入流,用于把文件读入内存
FileInputStream fis = new FileInputStream("./file.txt");
// 2, 创建字节输出流,用于把内存的字节写入外部文件中
FileOutputStream fos = new FileOutputStream("./filecopy.txt");
int b;
// 3, 从字节输入流中读取一个字节,如果已经到达文件末尾,则返回 -1
while((b = fis.read()) != -1) {
// 4,将读到的字节写入到字节输出流中
fos.write(b);
}
// 5, 关闭流资源
fos.close();
fis.close();
}
}
file.txt
的内容如下:
Hello world!
你好,世界!
How are you?
你好吗?
Where are you from?
你来自哪里?
运行程序结束后,可以看到生成了filecopy.txt
文件,内容如下:
Hello world!
你好,世界!
How are you?
你好吗?
Where are you from?
你来自哪里?
可以看到,我们确实实现了文本文件的拷贝。
到这里,是不是觉得很简单?
先等等,回答完以下几个小问题,再离开不迟:
-
如果传入
FileInputStream
构造方法的文件不存在,会发生什么?答:会抛出异常
FileNotFoundException
:Exception in thread "main" java.io.FileNotFoundException: .\unexistfile.txt (系统找不到指定的文件。)
-
如果传入
FileInputStream
构造方法的文件是一个目录,会发生什么?答:会抛出异常:
Exception in thread "main" java.io.FileNotFoundException: .\dir (拒绝访问。)
-
如果传入
FileOutputStream
构造方法的文件存在或不存在,会发生什么?答:文件不存在,会创建该文件;文件已存在,会先擦除文件再重新写入。
-
如果传入
FileOutputStream
构造方法的文件要实现续写,该怎么做?答:使用
FileOutputStream(String name, boolean append)
这个构造方法,第二个参数为true
。这样如果文件不存在就会创建它,之后就会向该文件写入;如果文件存在,就会向文件写入。 -
FileOutputStream
的write(int b)
方法,是把一个整型的数据写入到字节输出流里面吗?答:不是的,只是把整型数据的低八位写入到字节输出流里面而已。可以看下面的程序来验证:
// 1, 创建字节输出流,用于把内存的字节写入外部文件中 FileOutputStream fos = new FileOutputStream("./filecopy.txt"); // 2, 将指定的字节写入到字节输出流中 // 0110 0001 -> 97 fos.write(97); // 1 0110 0001 -> 353 fos.write(353); // 3, 关闭流资源 fos.close();
查看
filecopy.txt
中的内容,都是 a。 -
FileInputStream
的close()
方法和FileOutputStream
的close()
方法都只是释放与流相关的系统资源吗?答:不是的,
FileInputStream
的close()
方法还表示关闭此文件输入流;FileOutputStream
的close()
方法还表示关闭此文件输出流。换句话说,在调用了FileInputStream
的close()
方法后,再调用它的read()
方法就会抛出异常:Exception in thread "main" java.io.IOException: Stream Closed at java.io.FileInputStream.read0(Native Method) at java.io.FileInputStream.read(FileInputStream.java:207) at com.example.javaio._02_copytextfile.FileInputStreamReadDemo.main(FileInputStreamReadDemo.java:18)
在调用了
FileOutputStream
的close()
方法后,再调用它的write()
方法就会抛出异常:Exception in thread "main" java.io.IOException: Stream Closed at java.io.FileOutputStream.write(Native Method) at java.io.FileOutputStream.write(FileOutputStream.java:290) at com.example.javaio._02_copytextfile.FileOutputStreamWriteDemo.main(FileOutputStreamWriteDemo.java:19)
另外,在调用 FileOutputStream(String name)
时,因为其他某些原因而无法打开进行读取,会抛出 FileNotFoundException
;在调用 FileOutputStream(String name)
时, 该文件不存在,但无法创建它;抑或因为其他某些原因而无法打开它,则抛出 FileNotFoundException
。这两点,在 Android 开发过程中,操作 sd 卡的读写时,可能会遇到。
2.3.2 自定义字节数组缓冲区复制文本文件
在上节中,我们采用从字节输入流中读取一个字节,然后向字节输出流中写入一个字节的方式完成了复制文本文件。
这是否有优化空间呢?
想起服务员上菜的例子,开始饭店里的顾客少,厨师炒好一盘菜,服务员上一盘菜。
这就是我们在 2.2.3 小节中的做法。现在饭店里的顾客多了,服务员还采用原来的工作方式,显然会被炒鱿鱼了。那么,服务员该怎么办呢?
服务员会使用上菜盘,一个上菜盘上可以放多盘菜。这样就不用在厨房和前厅多次跑来跑去了。
我们这里使用自定义缓冲区来复制文本文件,也是同样的道理。
这里要查以下 api 文档,使用如下两个 api:
FileInputStream
的public int read(byte b[]) throws IOException
方法:从此输入流中将最多b.length
个字节的数据读入一个byte
数组中。FileOutputStream
的public void write(byte b[], int off, int len) throws IOException
方法:将指定byte
数组中从偏移量off
开始的len
个字节写入此文件输出流。
代码实现如下:
public class CustomBufferCopyTextFile {
private static final int BUFFER_SIZE= 1024;
public static void main(String[] args) throws IOException {
// 1, 创建字节输入流,用于把文件读入内存
FileInputStream fis = new FileInputStream("./file.txt");
// 2, 创建字节输出流,用于把内存的字节写入外部文件中
FileOutputStream fos = new FileOutputStream("./filecopy.txt");
// 3, 定义一个字节数组,作为缓冲区
byte[] buffer = new byte[BUFFER_SIZE];
// 记录读入缓冲区的字节总数
int len;
// 4, 从此输入流中将最多 b.length 个字节的数据读入一个 byte 数组中。
// 如果已经到达文件末尾,则返回 -1.
while((len = fis.read(buffer)) != -1) {
// 5,将指定 byte 数组中从偏移量 off 开始的 len 个字节写入此文件输出流。
fos.write(buffer, 0, len);
}
// 6, 关闭流资源
fos.close();
fis.close();
}
}
为了便于理解,我们把代码和上菜盘的例子做一下简单的对应:
byte[] buffer = new byte[BUFFER_SIZE];
就对应于上菜盘;len = fis.read(buffer)
对应服务员用上菜盘去厨房端菜,len
表示上菜盘上实际放的菜的数量;fos.write(buffer, 0, len);
表示把上菜盘上的菜放到顾客的桌子上,只需要从上菜盘上拿取实际放的菜数就可以了。
2.3.3 使用Java提供的缓冲字节流实现文本文件复制
到这里,可能会想:Java 作为一门成熟的语言,有没有实现利用缓冲里提高读写效率的能力呢?
它们就是 BufferedInputStream
和 BufferedOutputStream
。
代码如下:
public class JavaBufferCopyTextFile {
public static void main(String[] args) throws IOException {
// 1, 创建字节输入流,用于把文件读入内存
FileInputStream fis = new FileInputStream("./file.txt");
// 2, 创建字节输入流的缓冲区对象,关联需要被缓冲的字节输入流对象
BufferedInputStream bis = new BufferedInputStream(fis);
// 3, 创建字节输出流,用于把内存的字节写入外部文件中
FileOutputStream fos = new FileOutputStream("./filecopy.txt");
// 4, 创建字节输出流的缓冲区对象,关联需要被缓冲的字节输出流对象
BufferedOutputStream bos = new BufferedOutputStream(fos);
int b;
// 5, 从字节输入流的缓冲区对象中读取一个字节,读取不到时返回-1
while((b = bis.read()) != -1) {
// 6,将指定字节写入到字节输出流的缓冲区对象。
bos.write(b);
bos.flush();
}
// 7, 关闭流资源
bos.close();
bis.close();
}
}
到这里,大家可能会产生疑问:
- 我们代码里还是读一个字节,再写一个字节,那么
BufferedInputStream
和BufferedOutputStream
是如何实现缓冲的? - 为什么要调用
bos.flush()
这行? - 关闭流资源只关闭了
bos
和bis
,难道不需要关闭fos
和fis
吗?
这些疑问有点难,我们一起学习一下吧。
-
我们代码里还是读一个字节,再写一个字节,那么
BufferedInputStream
和BufferedOutputStream
是如何实现缓冲的?
答:BufferedInputStream
内部维护一个字节数组作为缓冲区,当我们使用BufferedInputStream
来读取一个字节时,会先判断缓冲区里面有没有可用的字节,有,则直接取出来;没有,就从原始字节流读取指定长度的字节填充缓冲区,再从缓冲区中取出一个字节。具体可以看 2.3.4 的实现。 -
为什么要调用
bos.flush()
这行?
答:这行的作用是刷新BufferedOutputStream
对象的缓冲区,把缓冲区的字节写到底层输出流中。对应的源码如下:public synchronized void flush() throws IOException { flushBuffer(); out.flush(); } /** Flush the internal buffer */ private void flushBuffer() throws IOException { if (count > 0) { out.write(buf, 0, count); count = 0; } }
-
关闭流资源只关闭了
bos
和bis
,难道不需要关闭fos
和fis
吗?答:只关闭
bos
和bis
就可以了。可以看源码:
ButteredOutputStream
的close()
方法:public void close() throws IOException { // 关闭原始输出流 try (OutputStream ostream = out) { // 在关闭流之前会先刷新缓冲区。 flush(); } }
BufferedInputStream
的close()
方法:public void close() throws IOException { in.close(); }
2.3.4 手写 MyBufferedInputStream 实现文本文件复制
这里手写的 MyBufferedInputStream
是由Java 里面的 BufferedInputStream
简化而来,不包括标记,跳过等方法。代码如下:
public class MyBufferedInputStream extends FilterInputStream {
private static final int DEFAULT_BUFFER_SIZE = 8192;
private byte[] buf;
// 当前已读位置
private int pos;
// 存入缓冲区中的字节数
private int count;
public MyBufferedInputStream(InputStream in) {
this(in, DEFAULT_BUFFER_SIZE);
}
public MyBufferedInputStream(InputStream in, int size) {
super(in);
buf = new byte[size];
}
public int read() throws IOException {
// 当前已读位置大于等于存入缓冲区中的字节数
if (pos >= count) {
// 填充缓冲区数组
fill();
if (pos >= count) {
return -1;
}
}
return getBufIfOpen()[pos++] & 0xff;
}
private void fill() throws IOException {
byte[] buffer = getBufIfOpen();
pos = 0;
count = 0;
// 从原始输入流向缓冲区中写入字节
int n = getInIfOpen().read(buffer, pos, buffer.length);
if (n > 0) {
count = n + pos;
}
}
private byte[] getBufIfOpen() throws IOException {
byte[] buffer = buf;
if (buffer == null)
throw new IOException("Stream closed");
return buffer;
}
private InputStream getInIfOpen() throws IOException {
InputStream input = in;
if (input == null)
throw new IOException("Stream closed");
return input;
}
}
把上面的例子中的
BufferedInputStream bis = new BufferedInputStream(fis);
替换为
MyBufferedInputStream bis = new MyBufferedInputStream(fis);
运行程序,符合预期。
可以看到,MyBufferedInputStream
继承了 FilterInputStream
类,在构造方法中接收原始的输入流对象以及可选的缓冲区字节数组的长度,初始化一个字节数组作为缓冲区。
最重要的逻辑就是 read()
方法:先判断当前读取位置是否大于等于缓冲区字节数组中存放的字节数,如果满足,就调用 fill()
方法填充缓冲区字节数组;返回的字节是从缓冲区字节数组里面来取。换句话说,read()
方法内部是先从原始输入流里面读取一定长度的字节存放在缓冲区字节数组里面,再从缓冲区字节数组里面来获取字节。这样同样可以减少对磁盘等的操作,提高输入流的效率。
2.3.5 自定义字节数组缓冲区+Java的缓冲字节流实现文本文件复制
在 2.3.2 里面我们使用自定义字节数组缓冲区来实现文本文件复制;在 2.3.3 里面我们使用 Java 提供的缓冲字节流来实现文本文件复制。
那把这两种方式结合起来有没有意义呢?
有的,请看下面的代码:
public class JavaBufferCopyTextFile2 {
public static void main(String[] args) throws IOException {
// 1, 创建字节输入流,用于把文件读入内存
FileInputStream fis = new FileInputStream("./file.txt");
System.out.println("fis.available() = " + fis.available()); // 101
// 2, 创建字节输入流的缓冲区对象,关联需要被缓冲的字节输入流对象
BufferedInputStream bis = new BufferedInputStream(fis);
// 3, 创建字节输出流,用于把内存的字节写入外部文件中
FileOutputStream fos = new FileOutputStream("./filecopy.txt");
// 4, 创建字节输出流的缓冲区对象,关联需要被缓冲的字节输出流对象
BufferedOutputStream bos = new BufferedOutputStream(fos);
byte[] b = new byte[1024];
int len;
// 5, 从此缓冲输入流中将最多 b.length 个字节的数据读入一个 byte 数组中。
// 如果已经到达文件末尾,则返回 -1.
while((len = bis.read(b)) != -1) {
// 6,将指定 byte 数组中从偏移量 off 开始的 len 个字节写入缓冲输出流
bos.write(b, 0, len);
bos.flush();
}
// 7, 关闭流资源
bos.close();
bis.close();
}
}
在代码里,自定义缓冲区是减少了对Java提供的缓冲区的操作;而Java提供的缓冲区减少了对磁盘等的操作。这两种缓冲区的具体目的不相同,但都是为了提高I/O效率的。
2.3.6 装饰者设计模式在字节流中的体现
Java I/O 是装饰者设计模式的典型应用。目前我们演示的代码已经体现了装饰者设计模式,类结构图如下:
2.4 字符流复制文本文件
2.4.1 简单地复制文本文件
这里我们要用到 FileReader
和 FileWriter
两个类来实现。
public class SimpleCopyTextFile {
public static void main(String[] args) throws IOException {
// 1, 创建字符输入流,用于把文件读入内存
FileReader fr = new FileReader("file.txt");
// 2,创建字符输出流,用于把内存中的字符写入外部文件中
FileWriter fw = new FileWriter("filecopy.txt");
int ch;
// 3,从字符输入流中读取一个字符,如果已经到达文件末尾,则返回-1
while((ch = fr.read()) != -1) {
// 4,将读到的字符写入到字符输出流中
fw.write(ch);
// 5, 刷新该流的缓冲
fw.flush();
}
// 6,关闭流资源
fw.close();
fr.close();
}
}
从这段代码上看,与 2.3.1 的区别:这里每次是从字符输入流中读取一个字符,然后把读到的字符写入到字符输入流中;2.3.1 中每次是从字节输入流中读取一个字节,然后把读到的字节写入到字节输出流中。
但是,不止这点区别。
我们看一下 FileReader
的源码:
public class FileReader extends InputStreamReader {
public FileReader(String fileName) throws FileNotFoundException {
super(new FileInputStream(fileName));
}
public FileReader(File file) throws FileNotFoundException {
super(new FileInputStream(file));
}
public FileReader(FileDescriptor fd) {
super(new FileInputStream(fd));
}
}
可以看到,FileReader
拿到文件对象后,需要构造一个 FileInputStream
对象传递给父类 InputStreamReader
的构造方法。
也就是说,字符输入流还是要依赖字节输入流来实现的。
那么,字节又是如何转成字符的呢?例如,中文的"你好",要一次读入"你",一次读取"好",接口里面是怎么识别出一部分字节就是对应着"你",而余下的部分字节就是对应着"好"?
解决这个问题,就要看 FileReader
的父类:InputStreamReader
:
public class InputStreamReader extends Reader {
private final StreamDecoder sd;
// 创建一个使用默认字符集的 InputStreamReader。
public InputStreamReader(InputStream in) {
super(in);
try {
sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
} catch (UnsupportedEncodingException e) {
// The default encoding should always be available
throw new Error(e);
}
}
// 创建使用指定字符集的 InputStreamReader。
public InputStreamReader(InputStream in, String charsetName)
throws UnsupportedEncodingException
{
super(in);
if (charsetName == null)
throw new NullPointerException("charsetName");
sd = StreamDecoder.forInputStreamReader(in, this, charsetName);
}
// 创建使用给定字符集的 InputStreamReader。
public InputStreamReader(InputStream in, CharsetDecoder dec) {
super(in);
if (dec == null)
throw new NullPointerException("charset decoder");
sd = StreamDecoder.forInputStreamReader(in, this, dec);
}
public int read() throws IOException {
return sd.read();
}
// 省略了与分析无关的代码
}
看到在 InputStreamReader
的构造方法里面,使用指定的字符集读取字节并将其解码为字符。如果没有指定字符集,就会使用平台默认的字符集。因此,InputStreamReader
被称为字节流通向字符流的桥梁。这里用到了适配器模式。
ps:如果不太了解字符集,可以参考计算机字符编码的前世今生以及字符编码笔记:ASCII,Unicode 和 UTF-8来学习。
那么,FileReader
和 InputStreamReader
的关系是什么?
FileReader
是一个使用了平台默认的字符集来读取字节并将其解码为字符的字符流。使用 InputStreamReader
可以使用指定的字符集来把字节流转为字符流。FileReader
与 InputStreamReader
是特殊与一般的关系,是具体与抽象的关系。
2.4.2 自定义字符数组缓冲区复制文本文件
和 2.3.2 的道理一样,通过自定义字符数组,一次从字符输入流里读取至多指定个数的字符到字符数组缓冲区里,然后再把读取的字符写到字符输出流里面。
public class CustomBufferCopyTextFile {
public static void main(String[] args) throws IOException {
// 1, 创建字符输入流,用于把文件读入内存
FileReader fr = new FileReader("file.txt");
// 2,创建字符输出流,用于把内存中的字符写入外部文件中
FileWriter fw = new FileWriter("filecopy.txt");
// 3,自定义一个字符数组,作为缓冲区
char[] buffer = new char[1024];
// 记录读入缓冲区的字符数
int len;
// 4, 从此输入流中将最多 b.length 个字符的数据读入一个字符数组中。
// 如果已经到达文件末尾,则返回 -1.
while((len = fr.read(buffer)) != -1) {
// 5,将缓冲区中读到的字符写入到字符输出流中
fw.write(buffer, 0, len);
// 6, 刷新该流的缓冲
fw.flush();
}
// 7,关闭流资源
fw.close();
fr.close();
}
}
2.4.3 使用Java提供的缓冲字符流实现文本文件复制
这里用到的是 BufferedReader
和 BufferedWriter
:
public class JavaBufferCopyTextFile {
public static void main(String[] args) throws IOException {
// 1, 创建字符输入流,用于把文件读入内存
FileReader fr = new FileReader("file.txt");
// 2,创建字符输入流的缓冲区对象,关联需要被缓冲的字符输入流
BufferedReader br = new BufferedReader(fr);
// 3,创建字符输出流,用于把内存中的字符写入外部文件中
FileWriter fw = new FileWriter("filecopy.txt");
// 4,创建字符输出流的缓冲区对象,关联需要被缓冲的字符输出流
BufferedWriter bw = new BufferedWriter(fw);
int ch;
// 5,从字符输入流中读取一个字符,如果已经到达文件末尾,则返回-1
while ((ch = br.read()) != -1) {
// 6,将读到的字符写入到字符输出流中
bw.write(ch);
// 7, 刷新该流的缓冲
bw.flush();
}
// 8,关闭流资源
bw.close();
br.close();
}
}
2.4.4 手写 MyBufferedReader 实现文本文件复制
这里手写的 MyBufferedReader
是由Java 里面的 BufferedReader
简化而来,不包括标记,跳过等方法。代码如下:
public class MyBufferedReader extends Reader {
private Reader in;
private char[] cb;
/** * 缓冲区读取到字符的位置 */
private int nextChar;
/** * 读入缓冲区中的字符数 */
private int nChars;
private static int defaultCharBufferSize = 8192;
public MyBufferedReader(Reader in, int sz) {
super(in);
this.in = in;
cb = new char[sz];
}
public MyBufferedReader(Reader in) {
this(in, defaultCharBufferSize);
}
@Override
public int read() throws IOException {
synchronized (lock) {
ensureOpen();
for (; ; ) {
if (nextChar >= nChars) {
// 填充字符数组缓冲区
fill();
if (nextChar >= nChars) {
return -1;
}
}
return cb[nextChar++];
}
}
}
@Override
public void close() throws IOException {
synchronized (lock) {
if (in == null)
return;
try {
in.close();
} finally {
in = null;
cb = null;
}
}
}
private void fill() throws IOException {
int n;
do {
// 这里是调用被包装的字符流的读取方法
n = in.read(cb, 0, cb.length);
} while (n == 0);
if (n > 0) {
nextChar = 0;
nChars = n;
}
}
private void ensureOpen() throws IOException {
if (in == null)
throw new IOException("Stream closed");
}
@Override
public int read(char[] cbuf, int off, int len) throws IOException {
// 注意:这里没有实现这个方法
return -1;
}
}
把上面的例子中的
BufferedReader br = new BufferedReader(fr);
替换为
MyBufferedReader br = new MyBufferedReader(fr);
运行程序,符合预期。
MyBufferedReader
的构造方法会接收一个原始的字符流,和一个可以指定的缓冲区长度。在构造方法内部,会持有原始的字符流以及初始化一个指定长度的字符数组缓冲区。
接着看重要的 read()
方法的实现:
先判断字符数组缓冲区中是否有可读的字符,没有的话,就会调用 fill()
方法(内部会读取原始字符流来填充字符数组缓冲区),然后从字符数组缓冲区中取出一个字符返回。
2.4.5 使用BufferedReader
独有的 readLine()
方法实现文本文件复制
public class JavaBufferReadLineCopyTextFile {
public static void main(String[] args) throws IOException {
// 1, 创建字符输入流,用于把文件读入内存
FileReader fr = new FileReader("file.txt");
// 2,创建字符输入流的缓冲区对象,关联需要被缓冲的字符输入流
BufferedReader br = new BufferedReader(fr);
// 3,创建字符输出流,用于把内存中的字符写入外部文件中
FileWriter fw = new FileWriter("filecopy.txt");
// 4,创建字符输出流的缓冲区对象,关联需要被缓冲的字符输出流
BufferedWriter bw = new BufferedWriter(fw);
String line;
// 5,从字符输入流中读取一行,如果已经到达文件末尾,则返回null
while ((line = br.readLine()) != null) {
// 6,将读到的行写入到字符输出流中
bw.write(line);
bw.newLine();
// 7, 刷新该流的缓冲
bw.flush();
}
// 8,关闭流资源
bw.close();
br.close();
}
}
需要注意的是,readLline()
是不会读取任何行终止符的,所以,在把读到的行写入字符输出流后,需要再调用 newLine()
方法写入一个行分隔符。
readLine()
方法允许我们在读取时对每一行进行操作,比如是否过滤具有某种特征的行。
2.4.6 装饰者设计模式在字符流中的体现
这里不存在抽象装饰类,只有具体的装饰类。可见,设计模式也不是一层不变的。
2.4.7 字符流真的不可以复制图片吗?
到这里,我们可以知道,使用字符流可以更出色地复制文本文件:可以一行一行地进行读写。
现在有一个复制图片的需求,用字节流肯定是可以实现的,因为图片也是由一个一个字节组成的。那么,可以用字符流复制图片吗?
这是要复制的图片:
只是一个 2x2 像素的小点。
我们尝试用字符流来进行复制,代码如下:
private static void copyImageByCharStream() throws IOException {
// 1, 创建字符输入流,用于把文件读入内存
FileReader fr = new FileReader(srcFileName);
System.out.println("fr.getEncoding() = " + fr.getEncoding());
// 2,创建字符输出流,用于把内存中的字符写入外部文件中
FileWriter fw = new FileWriter(dstFileName);
System.out.println("fw.getEncoding() = " + fw.getEncoding());
int ch;
// 3,从字符输入流中读取一个字符,如果已经到达文件末尾,则返回-1
while ((ch = fr.read()) != -1) {
System.out.println(((char) ch));
// 4,将读到的字符写入到字符输出流中
fw.write(ch);
// 5, 刷新该流的缓冲
fw.flush();
}
// 6,关闭流资源
fw.close();
fr.close();
File src = new File(srcFileName);
File dst = new File(dstFileName);
System.out.println(srcFileName + " 图片的大小:" + src.length());
System.out.println(dstFileName + " 图片的大小:" + dst.length());
}
打印如下:
fr.getEncoding() = UTF8
fw.getEncoding() = UTF8
point.webp 图片的大小:44
point_copy.webp 图片的大小:52
复制完成的图片大小跟源图片是不一样的,可见使用字符流复制图片是不行的。
那么,为什么使用 UTF-8 编码表进行编解码的字符流复制图片是不成功的,复制完成后的图片大小跟原图片是不一样的?在哪里出的问题造成的?
字符输入流:使用 UTF-8 码表读取图片中的字节并将其解码为字符。具体来说,将图片中的字节数据和UTF-8 码表中的值进行对比,看能否按照 UTF-8 码表匹配出字符,如果某些字节数据不能匹配出字符,UTF-8 码表就给出未知字符。
字符输出流:使用 UTF-8 码表将要写入流中的字符编码为字节。具体来说,将读取的字符和UTF-8码表中的值进行对比,看能否按照 UTF-8 码表匹配出字节,如果某些字符是未知字符,UTF-8码表就会返回一个未知字符对应的数字。
到这里,可以说字符流不可以复制图片吗?
不可以。
我们看看用其他码表来复制图片是否可以。
private static String srcFileName = "point.webp";
private static String dstFileName = "point_copy.webp";
public static void main(String[] args) throws IOException {
copyImageByCharStream2("iso8859-1");
}
private static void copyImageByCharStream2(String charsetName) throws IOException {
// 1, 创建字符输入流,用于把文件读入内存
InputStreamReader isr = new InputStreamReader(new FileInputStream(srcFileName), charsetName);
System.out.println("isr.getEncoding() = " + isr.getEncoding());
// 2,创建字符输出流,用于把内存中的字符写入外部文件中
OutputStreamWriter osr = new OutputStreamWriter(new FileOutputStream(dstFileName), charsetName);
System.out.println("osr.getEncoding() = " + osr.getEncoding());
int ch;
// 3,从字符输入流中读取一个字符,如果已经到达文件末尾,则返回-1
while ((ch = isr.read()) != -1) {
// 4,将读到的字符写入到字符输出流中
osr.write(ch);
// 5, 刷新该流的缓冲
osr.flush();
}
// 6,关闭流资源
isr.close();
osr.close();
File src = new File(srcFileName);
File dst = new File(dstFileName);
System.out.println(srcFileName + " 图片的大小:" + src.length());
System.out.println(dstFileName + " 图片的大小:" + dst.length());
}
打印结果如下:
isr.getEncoding() = ISO8859_1
osr.getEncoding() = ISO8859_1
point.webp 图片的大小:44
point_copy.webp 图片的大小:44
而且复制后的图片可以正常打开。
到这里,我们知道使用 ISO8859-1 码表的字符流可以复制图片。
这就推翻了字符流不可以复制图片的说法。
为什么使用 ISO8859-1 码表的字符流就可以成功复制图片呢?
这是因为 ISO8859-1 码表是单字节编码。我们还使用编解码的步骤分析。
字符输入流:使用 ISO8859-1 码表读取图片中的字节并将其解码为字节。具体来说,将图片中的字节数据和ISO8859-1 码表中的值进行对比,看能否按照 ISO8859-1 码表匹配出字节,这是可以匹配出来的。
字符输出流:使用 ISO8859-1 码表将要写入流中的字节编码为字节。具体来说,将读取的字节和UTF-8码表中的值进行对比,看能否按照 UTF-8 码表匹配出字节,这是可以匹配出来的。
3. 最后
本文主要参考了《Java编程思想》第18章 Java I/O 系统,以及毕向东老师的 I/O 流视频。
通过简单的文本复制来说明字节流和字符流的使用,以及如何使读写更加高效,介绍了装饰者设计模式在I/O流中的体现。
本文没有覆盖到 nio 以及 okio 的内容,希望大家能够继续查找资料学习。
文章评论