文件IO:实现高效正确的文件读写
背景
本篇将会讲一些文件读写的推荐使用姿势以及编码时的注意事项,便于新手更好地理解如何高效地进行大文件读写,比如利用好缓冲区避免出现OOM,或者及时地释放资源以保证资源被及时地关闭,避免资源泄露。
处理中文时读取到乱码
大家都知道,中文的编码和英文的编码使用的字符集是不一样的,字符集不匹配的时候读取中文很容易出现乱码问题。下面我举个例子,说明一下读取中文时如何解决乱码问题。
1、使用下面代码先创建一个hello.txt文件,编码格式为GBK;文件内容是“你好hi”
Files.deleteIfExists(Paths.get("hello.txt"));
Files.write(Paths.get("hello.txt"), "你好hi".getBytes(Charset.forName("GBK")));
log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello.txt"))));
2、使用下面代码读取这个hello文件中的中文并打印
char[] chars = new char[10];
String content = "";
try (FileReader fileReader = new FileReader("hello.txt")) {
int count;
while ((count = fileReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
3、打印结果。
12:33:42.976 [main] INFO com.example.demo3.commonpitfalls.FileIOTest - bytes:c4e3bac36869
12:33:42.993 [main] INFO com.example.demo3.commonpitfalls.FileIOTest - result:���hi
可以发现"你好hi"没有正确显示,而是出现乱码。
4、分析
出现乱码的原因是我们在对你好hi进行编码的时候,使用的是GBK, 但是读取时使用FileReader,这边想说明的是,FileReader 是以当前机器的默认字符集来读取文件的,
也就是说,默认使用IDEA默认的机器码来解码,默认的字符集是UTF-8。所以,当前机器默认字符集是 UTF-8,自然无法读取 GBK 编码的汉字,因而出现了乱码。
解决这个问题也很简单,就是我们在编码的时候就使用UTF_8, 解码的时候本来默认的就是UTF_8, 这样就不会有乱码问题了。
修复代码如下:
Files.deleteIfExists(Paths.get("hello3.txt"));
Files.write(Paths.get("hello3.txt"), "你好hi".getBytes(Charset.forName("UTF-8")));
log.info("bytes:{}", Hex.encodeHexString(Files.readAllBytes(Paths.get("hello3.txt"))));
char[] chars = new char[10];
String content = "";
try (FileReader fileReader = new FileReader("hello3.txt")) {
int count;
while ((count = fileReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
}
log.info("result:{}", content);
打印结果:
12:41:10.105 [main] INFO com.example.demo3.commonpitfalls.FileIOTest - bytes:e4bda0e5a5bd6869
12:41:10.112 [main] INFO com.example.demo3.commonpitfalls.FileIOTest - result:你好hi
这个时候有人又会问了,如果我就要使用GBK来编码hello.txt文件,如何能解码成功不会乱码呢?当然也是有方法的。可直接使用 FileInputStream 拿文件流,
然后使用 InputStreamReader 读取字符流,并指定字符集为 GBK!
// 使用FileInputStream, InputStreamReader
char[] chars = new char[10];
String content = "";
// 使用try-with-resources来释放资源,语句中打开的资源会在代码块执行完毕后自动关闭,无需手动调用关闭方法,避免了资源泄漏。
// 无需使用finally 手动释放!
try( FileInputStream fileInputStream = new FileInputStream("hello.txt");
InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, Charset.forName("GBK"));) {
int count;
while ((count = inputStreamReader.read(chars)) != -1) {
content += new String(chars, 0, count);
}
log.info("result: {}", content);
} catch (IOException ex){
ex.printStackTrace();
}
5、总结
String text = "你好,世界!";
byte[] gbkBytes = text.getBytes(Charset.forName("GBK"));
String decodedText = new String(gbkBytes, Charset.forName("GBK"));
System.out.println(decodedText);
以上是使用GBK对中文编码解码的一个简单的例子,这个例子是可以正常打印“你好世界!”说到这, 就有人想问问GBK和UTF-8的区别了。
GBK和UTF-8都是字符编码方案,用于在计算机中表示和存储文本数据。对于中文的表示,这二者都可以使用。只不过在使用的时候有一些区别,我们一般会使用UTf-8比较多一点。这是因为:
对字节长度而言,
GBK 是双字节编码,即一个汉字通常占用两个字节,但有些生僻字可能需要三个或四个字节来表示。
UTF-8 是一种变长编码方案,一个字符的长度可以是1到4个字节不等,常见的英文字符只占用一个字节,常见的汉字占用三个字节。
所以,UTF-8可以表示更多的汉字。
从用途上来说,
GBK主要用于中文字符编码,包括简体中文中的常用汉字、符号等。
UTF-8 是一种全球通用的编码方案,能够表示几乎所有的字符,包括世界上所有语言的文字、符号和表情符号。
从存储上来看,GBK一般使用2个字节来存储汉字,但是UTF-8会使用3个字节来保存汉字。所以,使用GBK编码的汉字,用UTF-8来解码,必然不会成功了。
Files 类的readAllLines
使用前文提到的FileInputStream, InputStreamReader, 看起来会比较繁琐。Files 类的readAllLines是一种比较易用的方法,该方法是JDK7推出的,它可以很方便地用一行代码完成整个文件内容的读取。如下所示:
log.info("result: {}", Files.readAllLines(Paths.get("hello.txt"), Charset.forName("UTF-8")));
打开readAllLines()的源码可以发现,读取的字符都会被放在这个List中,List虽然是动态增长的,但是如果内存无法存储这个增长到很大容量的List, 必然会抛出这个OOM。
public static List<String> readAllLines(Path path, Charset cs) throws IOException {
try (BufferedReader reader = newBufferedReader(path, cs)) {
List<String> result = new ArrayList<>();
for (;;) {
String line = reader.readLine();
if (line == null)
break;
result.add(line);
}
return result;
}
}
所以,readAllLines的缺点就是,如果文件非常大的时候,读取超出内存大小的大文件时会出现OOM。
Files类的lines()
上文提到的readAllLine()是一次性读取内容到内存中,其实某些场景,比如下载大文件,我们可以一次只读取一部分数据到内存中,然后再进行数据的处理。
lines()方法就是这样的实现。接下来,我们说说使用 lines 方法时需要注意的一些问题。
与 readAllLines 方法返回 List
// 总共读取2000行
log.info("lines {}", Files.lines(Paths.get("hello3.txt")).limit(2000).collect(java.util.stream.Collectors.joining("\n")));
但是我们可以想一下这两种方式的差异在哪里,一次读取,只需要一次IO即可,但是多次读取,需要多次打开磁盘的文件,多次IO。虽然不会带来OOM, 但是会频繁的IO。
那么,我们每次读取一小部分数据的时候,就不宜读取地太少,而是按需读取一定大小(如2000)的数据,每次读的数据相对比较大的话,那么IO的次数也就比较少了。
另外,这样处理时,虽然不再有OOM,但是其实也有问题,即读取完文件后没有关闭!
我们通常会认为静态方法的调用不涉及资源释放,因为方法调用结束自然代表资源使用完成,由 API 释放资源,但对于 Files 类的一些返回 Stream 的方法并不是这样。这是一个很容易被忽略的严重问题。
以下例子是模拟 Files.lines 方法分批读取大文件。
首先,我们创建一个demo.txt,写入10行数据。
String filename = "demo.txt";
try {
StringBuilder content = new StringBuilder();
IntStream.rangeClosed(1, 10)
.forEach(i -> content.append("Line ").append(i).append(": This is some sample data.\n"));
Files.write(Paths.get(filename), content.toString().getBytes(), CREATE, TRUNCATE_EXISTING);
System.out.println("写入成功!");
} catch (IOException e) {
System.err.println("写入文件时出现异常:" + e.getMessage());
}
然后使用Files.lines 方法读取这个文件 100 万次,每读取一行计数器 +1:
// 读取这个文件 100 万次,每读取一行计数器 +1:
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -> {
try {
Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment());
} catch (IOException e) {
throw new RuntimeException(e);
}
});
log.info("total : {}", longAdder.longValue());
然后就发现,可能会报这样的错误,java.nio.file.FileSystemException: demo.txt: Too many open files。
其实,在JDK 文档中有提到,注意使用 try-with-resources 方式来配合,确保流的 close 方法可以调用释放资源。如果报错无法运行,那么请使用try-with-resources!
这也很容易理解,使用流式处理,如果不显式地告诉程序什么时候用完了流,程序又如何知道呢,它也不能帮我们做主何时关闭文件。
修复方式很简单,必须使用 try 来包裹 Stream !
try (Stream<String> lines = Files.lines(Paths.get("demo.txt"))) {
lines.forEach(line -> longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
}
查看 lines 方法源码可以发现,Stream 的 close 注册了一个回调,来关闭 BufferedReader 进行资源释放:
public static Stream<String> lines(Path path, Charset cs) throws IOException {
BufferedReader br = Files.newBufferedReader(path, cs);
try {
return br.lines().onClose(asUncheckedRunnable(br));
} catch (Error|RuntimeException e) {
try {
br.close();
} catch (IOException ex) {
try {
e.addSuppressed(ex);
} catch (Throwable ignore) {}
}
throw e;
}
}
private static Runnable asUncheckedRunnable(Closeable c) {
return () -> {
try {
c.close();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
注意读写文件要考虑设置缓冲区
从上述命名上可以看出,使用了BufferedReader 进行字符流读取时,用到了缓冲。这里缓冲 Buffer 的意思是,使用一块内存区域作为直接操作的中转。
比如,读取文件操作就是一次性读取一大块数据(比如 8KB)到缓冲区,后续的读取可以直接从缓冲区返回数据,而不是每次都直接对应文件 IO。写操作也是类似。如果每次写几
十字节到文件都对应一次 IO 操作,那么写一个几百兆的大文件可能就需要千万次的 IO 操作,耗时会非常久。
就比如之前说的Files.lines()分批读取数据,读取的数据先放在一个独立buffer中,buffer相当于个一个中转站。和直接读数据加载到内存的区别是,放在buffer中的话有更多的好处。就比如我现在既需要对这部分数据读取再进行其他处理,或者将这部分数据保存在其他文件,这只是举个例子啊。这个时候,我只需要读取一次放入buffer, 后续对数据的其他操作都直接从buffer中拿就好了。
private static void bufferOperationWith100Buffer() throws IOException {
try (FileInputStream fileInputStream = new FileInputStream("src.txt");
FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
byte[] buffer = new byte[100];
int len = 0;
while ((len = fileInputStream.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, len);
}
}
}
上述代码我们使用了一个byte[] 缓冲区,极大提高了数据读取性能。建议进行文件 IO 处理的时候,使用合适的缓冲区!
你可能会说,实现文件读写还要自己 new一个缓冲区出来,太麻烦了,不是有BufferedInputStream 和 BufferedOutputStream 可以实现输入输出流的缓冲处理吗?
是的,它们在内部实现了一个默认 8KB 大小的缓冲区。但是,在使用 BufferedInputStream 和 BufferedOutputStream 时,它们实现了内部缓冲进行逐字节的操作。
接下来,我写一段代码比较下使用下面三种方式读写一个文件的性能:
- 直接使用 BufferedInputStream 和 BufferedOutputStream;
- 额外使用一个 8KB 缓冲,使用 BufferedInputStream 和 BufferedOutputStream;
- 直接使用 FileInputStream 和 FileOutputStream,再使用一个 8KB 的缓冲。
//使用BufferedInputStream和BufferedOutputStream
private static void bufferedStreamByteOperation() throws IOException {
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("src.txt"));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
int i;
while ((i = bufferedInputStream.read()) != -1) {
bufferedOutputStream.write(i);
}
}
}
//额外使用一个8KB缓冲,再使用BufferedInputStream和BufferedOutputStream
private static void bufferedStreamBufferOperation() throws IOException {
try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("src.txt"));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
byte[] buffer = new byte[8192]; // 8KB
int len = 0;
while ((len = bufferedInputStream.read(buffer)) != -1) {
bufferedOutputStream.write(buffer, 0, len);
}
}
}
//直接使用FileInputStream和FileOutputStream,再使用一个8KB的缓冲
private static void largerBufferOperation() throws IOException {
try (FileInputStream fileInputStream = new FileInputStream("src.txt");
FileOutputStream fileOutputStream = new FileOutputStream("dest.txt")) {
byte[] buffer = new byte[8192];
int len = 0;
while ((len = fileInputStream.read(buffer)) != -1) {
fileOutputStream.write(buffer, 0, len);
}
}
}
性能:
--------------------------------------------
ns
%
Task name--------------------------------------------
1424649223 086% bufferedStreamByteOperation
117807808 007% bufferedStreamBufferOperation
112153174 007% largerBufferOperation
可以看到,第一种方式虽然使用了缓冲流,但逐字节的操作因为方法调用次数实在太多还是慢;后面两种方式的性能差不多。虽然第三种方式没有使用缓冲流,但使用了 8KB 大小的缓冲区,和缓冲流默认的缓冲区大小相同。
BufferedInputStream 和 BufferedOutputStream 的意义
在实际代码中每次需要读取的字节数很可能不是固定的,有的时候读取几个字节,有的时候读取几百字节,这个时候
有一个固定大小较大的缓冲,也就是使用 BufferedInputStream 和 BufferedOutputStream 做为后备的稳定的二次缓冲,就非常有意义了。
最后我要补充说明的是,对于类似的文件复制操作,如果希望有更高性能,可以使用
FileChannel 的 transfreTo 方法进行流的复制。在一些操作系统(比如高版本的 Linux 和 UNIX)上可以实现 DMA(直接内存访问),也就是数据从磁盘经过总线直接发送到目标文件,无需经过内存和 CPU 进行数据中转:
private static void fileChannelOperation() throws IOException {
FileChannel in = FileChannel.open(Paths.get("src.txt"), StandardOpenOption
FileChannel out = FileChannel.open(Paths.get("dest.txt"), CREATE, WRITE);
in.transferTo(0, in.size(), out);
}
总结
本文分享了文件读写操作中最重要的几个方面。
第一,如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致的,否则可能产生乱码。
第二,使用 Files 类的一些流式处理操作,注意使用 try-with-resources 包装 Stream,确保底层文件资源可以释放,避免产生 too many open files 的问题。
第三,进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少 IO 次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用 FileChannel 进行流转发。
最后我要强调的是,文件操作因为涉及操作系统和文件系统的实现,JDK 并不能确保所有 IO API 在所有平台的逻辑一致性,代码迁移到新的操作系统或文件系统时,要重新进行功
能测试和性能测试。