文件IO:實現(xiàn)高效正確的文件讀寫
背景
本篇將會講一些文件讀寫的推薦使用姿勢以及編碼時的注意事項,便于新手更好地理解如何高效地進行大文件讀寫,比如利用好緩沖區(qū)避免出現(xiàn)OOM,或者及時地釋放資源以保證資源被及時地關閉,避免資源泄露。
處理中文時讀取到亂碼
大家都知道,中文的編碼和英文的編碼使用的字符集是不一樣的,字符集不匹配的時候讀取中文很容易出現(xiàn)亂碼問題。下面我舉個例子,說明一下讀取中文時如何解決亂碼問題。
1、使用下面代碼先創(chuàng)建一個hello.txt文件,編碼格式為GBK;文件內(nèi)容是“你好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
可以發(fā)現(xiàn)"你好hi"沒有正確顯示,而是出現(xiàn)亂碼。
4、分析
出現(xiàn)亂碼的原因是我們在對你好hi進行編碼的時候,使用的是GBK, 但是讀取時使用FileReader,這邊想說明的是,FileReader 是以當前機器的默認字符集來讀取文件的,
也就是說,默認使用IDEA默認的機器碼來解碼,默認的字符集是UTF-8。所以,當前機器默認字符集是 UTF-8,自然無法讀取 GBK 編碼的漢字,因而出現(xiàn)了亂碼。
解決這個問題也很簡單,就是我們在編碼的時候就使用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來釋放資源,語句中打開的資源會在代碼塊執(zhí)行完畢后自動關閉,無需手動調(diào)用關閉方法,避免了資源泄漏。
// 無需使用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的區(qū)別了。
GBK和UTF-8都是字符編碼方案,用于在計算機中表示和存儲文本數(shù)據(jù)。對于中文的表示,這二者都可以使用。只不過在使用的時候有一些區(qū)別,我們一般會使用UTf-8比較多一點。這是因為:
對字節(jié)長度而言,
GBK 是雙字節(jié)編碼,即一個漢字通常占用兩個字節(jié),但有些生僻字可能需要三個或四個字節(jié)來表示。
UTF-8 是一種變長編碼方案,一個字符的長度可以是1到4個字節(jié)不等,常見的英文字符只占用一個字節(jié),常見的漢字占用三個字節(jié)。
所以,UTF-8可以表示更多的漢字。
從用途上來說,
GBK主要用于中文字符編碼,包括簡體中文中的常用漢字、符號等。
UTF-8 是一種全球通用的編碼方案,能夠表示幾乎所有的字符,包括世界上所有語言的文字、符號和表情符號。
從存儲上來看,GBK一般使用2個字節(jié)來存儲漢字,但是UTF-8會使用3個字節(jié)來保存漢字。所以,使用GBK編碼的漢字,用UTF-8來解碼,必然不會成功了。
Files 類的readAllLines
使用前文提到的FileInputStream, InputStreamReader, 看起來會比較繁瑣。Files 類的readAllLines是一種比較易用的方法,該方法是JDK7推出的,它可以很方便地用一行代碼完成整個文件內(nèi)容的讀取。如下所示:
log.info("result: {}", Files.readAllLines(Paths.get("hello.txt"), Charset.forName("UTF-8")));
打開readAllLines()的源碼可以發(fā)現(xiàn),讀取的字符都會被放在這個List中,List雖然是動態(tài)增長的,但是如果內(nèi)存無法存儲這個增長到很大容量的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的缺點就是,如果文件非常大的時候,讀取超出內(nèi)存大小的大文件時會出現(xiàn)OOM。
Files類的lines()
上文提到的readAllLine()是一次性讀取內(nèi)容到內(nèi)存中,其實某些場景,比如下載大文件,我們可以一次只讀取一部分數(shù)據(jù)到內(nèi)存中,然后再進行數(shù)據(jù)的處理。
lines()方法就是這樣的實現(xiàn)。接下來,我們說說使用 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。
那么,我們每次讀取一小部分數(shù)據(jù)的時候,就不宜讀取地太少,而是按需讀取一定大小(如2000)的數(shù)據(jù),每次讀的數(shù)據(jù)相對比較大的話,那么IO的次數(shù)也就比較少了。
另外,這樣處理時,雖然不再有OOM,但是其實也有問題,即讀取完文件后沒有關閉!
我們通常會認為靜態(tài)方法的調(diào)用不涉及資源釋放,因為方法調(diào)用結束自然代表資源使用完成,由 API 釋放資源,但對于 Files 類的一些返回 Stream 的方法并不是這樣。這是一個很容易被忽略的嚴重問題。
以下例子是模擬 Files.lines 方法分批讀取大文件。
首先,我們創(chuàng)建一個demo.txt,寫入10行數(shù)據(jù)。
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("寫入文件時出現(xiàn)異常:" + e.getMessage());
}
然后使用Files.lines 方法讀取這個文件 100 萬次,每讀取一行計數(shù)器 +1:
// 讀取這個文件 100 萬次,每讀取一行計數(shù)器 +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());
然后就發(fā)現(xiàn),可能會報這樣的錯誤,java.nio.file.FileSystemException: demo.txt: Too many open files。
其實,在JDK 文檔中有提到,注意使用 try-with-resources 方式來配合,確保流的 close 方法可以調(diào)用釋放資源。如果報錯無法運行,那么請使用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 方法源碼可以發(fā)現(xiàn),Stream 的 close 注冊了一個回調(diào),來關閉 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);
}
};
}
注意讀寫文件要考慮設置緩沖區(qū)
從上述命名上可以看出,使用了BufferedReader 進行字符流讀取時,用到了緩沖。這里緩沖 Buffer 的意思是,使用一塊內(nèi)存區(qū)域作為直接操作的中轉。
比如,讀取文件操作就是一次性讀取一大塊數(shù)據(jù)(比如 8KB)到緩沖區(qū),后續(xù)的讀取可以直接從緩沖區(qū)返回數(shù)據(jù),而不是每次都直接對應文件 IO。寫操作也是類似。如果每次寫幾
十字節(jié)到文件都對應一次 IO 操作,那么寫一個幾百兆的大文件可能就需要千萬次的 IO 操作,耗時會非常久。
就比如之前說的Files.lines()分批讀取數(shù)據(jù),讀取的數(shù)據(jù)先放在一個獨立buffer中,buffer相當于個一個中轉站。和直接讀數(shù)據(jù)加載到內(nèi)存的區(qū)別是,放在buffer中的話有更多的好處。就比如我現(xiàn)在既需要對這部分數(shù)據(jù)讀取再進行其他處理,或者將這部分數(shù)據(jù)保存在其他文件,這只是舉個例子啊。這個時候,我只需要讀取一次放入buffer, 后續(xù)對數(shù)據(jù)的其他操作都直接從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[] 緩沖區(qū),極大提高了數(shù)據(jù)讀取性能。建議進行文件 IO 處理的時候,使用合適的緩沖區(qū)!
你可能會說,實現(xiàn)文件讀寫還要自己 new一個緩沖區(qū)出來,太麻煩了,不是有BufferedInputStream 和 BufferedOutputStream 可以實現(xiàn)輸入輸出流的緩沖處理嗎?
是的,它們在內(nèi)部實現(xiàn)了一個默認 8KB 大小的緩沖區(qū)。但是,在使用 BufferedInputStream 和 BufferedOutputStream 時,它們實現(xiàn)了內(nèi)部緩沖進行逐字節(jié)的操作。
接下來,我寫一段代碼比較下使用下面三種方式讀寫一個文件的性能:
- 直接使用 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
可以看到,第一種方式雖然使用了緩沖流,但逐字節(jié)的操作因為方法調(diào)用次數(shù)實在太多還是慢;后面兩種方式的性能差不多。雖然第三種方式?jīng)]有使用緩沖流,但使用了 8KB 大小的緩沖區(qū),和緩沖流默認的緩沖區(qū)大小相同。
BufferedInputStream 和 BufferedOutputStream 的意義
在實際代碼中每次需要讀取的字節(jié)數(shù)很可能不是固定的,有的時候讀取幾個字節(jié),有的時候讀取幾百字節(jié),這個時候
有一個固定大小較大的緩沖,也就是使用 BufferedInputStream 和 BufferedOutputStream 做為后備的穩(wěn)定的二次緩沖,就非常有意義了。
最后我要補充說明的是,對于類似的文件復制操作,如果希望有更高性能,可以使用
FileChannel 的 transfreTo 方法進行流的復制。在一些操作系統(tǒng)(比如高版本的 Linux 和 UNIX)上可以實現(xiàn) DMA(直接內(nèi)存訪問),也就是數(shù)據(jù)從磁盤經(jīng)過總線直接發(fā)送到目標文件,無需經(jīng)過內(nèi)存和 CPU 進行數(shù)據(jù)中轉:
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);
}
總結
本文分享了文件讀寫操作中最重要的幾個方面。
第一,如果需要讀寫字符流,那么需要確保文件中字符的字符集和字符流的字符集是一致的,否則可能產(chǎn)生亂碼。
第二,使用 Files 類的一些流式處理操作,注意使用 try-with-resources 包裝 Stream,確保底層文件資源可以釋放,避免產(chǎn)生 too many open files 的問題。
第三,進行文件字節(jié)流操作的時候,一般情況下不考慮進行逐字節(jié)操作,使用緩沖區(qū)進行批量讀寫減少 IO 次數(shù),性能會好很多。一般可以考慮直接使用緩沖輸入輸出流BufferedXXXStream,追求極限性能的話可以考慮使用 FileChannel 進行流轉發(fā)。
最后我要強調(diào)的是,文件操作因為涉及操作系統(tǒng)和文件系統(tǒng)的實現(xiàn),JDK 并不能確保所有 IO API 在所有平臺的邏輯一致性,代碼遷移到新的操作系統(tǒng)或文件系統(tǒng)時,要重新進行功
能測試和性能測試。

浙公網(wǎng)安備 33010602011771號