NIO複習(2):channel
- 2020 年 2 月 25 日
- 筆記
上篇學習了NIO的buffer,繼續來學習channel,類圖如下(註:為了不讓圖看起來太複雜,隱藏了一些中間的介面)

Channel派生了很多子介面,其中最常用的有FileChannel(用於文件操作)以及SocketChannel、ServerSocketChannel(用於網路通訊),下面用幾段示例程式碼學習其基本用法:
一、文件寫入
1.1 入門示例
public static void fileWriteReadSimpleDemo() throws IOException { String filePath = "/tmp/yjmyzz.txt"; //文件寫入 String fileContent = "菩提樹下的楊過"; FileOutputStream outputStream = new FileOutputStream(filePath); FileChannel writeChannel = outputStream.getChannel(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); byteBuffer.put(fileContent.getBytes()); byteBuffer.flip();//別忘記了,反轉position,否則此時position已經移到最後1個有效字元處,下一行將讀不到數據 //緩衝區的數據,通過channel寫入文件 writeChannel.write(byteBuffer); writeChannel.close(); //文件讀取 File file = new File(filePath); FileInputStream inputStream = new FileInputStream(file); FileChannel readChannel = inputStream.getChannel(); //註:這裡要重新指定實際大小,否則byteBuffer前面初始化成1024長度,文件內容不足1024位元組, // 後面的空餘部分全是默認0填充,最終轉換成字元串時,填充的0,也會轉換成不可見字元輸出 byteBuffer = ByteBuffer.allocate((int) file.length()); readChannel.read(byteBuffer); System.out.println(new String(byteBuffer.array())); readChannel.close(); }
FileOutputStream類中內嵌了一個FileChannel的實例,通過getChannel()方法可以獲取引用。寫文件緩衝區初始化時,如何設置正確的大小,這個不太好掌握,設置太大浪費記憶體,設置太小又裝不下,正確姿勢可參考下面的示例2
1.2 緩衝區不夠大時循環寫入
public static void writeFileDemo() throws IOException { String fileContent = "菩提樹下的楊過(http://yjmyzz.cnblogs.com/)nn" + "送柴侍御n" + "【作者】王昌齡 【朝代】唐n" + "沅水通波接武岡,送君不覺有離傷。n" + "青山一道同雲雨,明月何曾是兩鄉。n"; //故意設置一個很小的緩衝區,演示緩衝區不夠大的情況 ByteBuffer byteBuffer = ByteBuffer.allocate(5); String filePath = "/tmp/yjmyzz.txt"; FileOutputStream outputStream = new FileOutputStream(filePath); FileChannel writeChannel = outputStream.getChannel(); //將文件內容,按緩衝區大小拆分成一段段寫入 byte[] src = fileContent.getBytes(); int pages = (src.length % byteBuffer.capacity() == 0) ? (src.length / byteBuffer.capacity()) : (src.length / byteBuffer.capacity() + 1); for (int i = 0; i < pages; i++) { int start = i * byteBuffer.capacity(); int end = Math.min(start + byteBuffer.capacity() - 1, src.length - 1); for (int j = start; j <= end; j++) { byteBuffer.put(src[j]); } byteBuffer.flip(); writeChannel.write(byteBuffer); //記得清空 byteBuffer.clear(); } writeChannel.close(); }
注意:文件讀取時,直接通過File對象的length可以提前知道緩衝的大小,能精確指定Buffer大小,不需要類似這麼複雜的循環處理。
二、文件複製
public static void copyFileDemo() throws IOException { String srcFilePath = "/tmp/yjmyzz.txt"; File srcFile = new File(srcFilePath); String targetFilePath = "/tmp/yjmyzz.txt.bak"; FileInputStream inputStream = new FileInputStream(srcFile); FileOutputStream outputStream = new FileOutputStream(targetFilePath); FileChannel inputChannel = inputStream.getChannel(); FileChannel outputChannel = outputStream.getChannel(); //文件複製 ByteBuffer buffer = ByteBuffer.allocate((int) srcFile.length()); inputChannel.read(buffer); buffer.flip(); outputChannel.write(buffer); //也可以用這一行,搞定文件複製(推薦使用) // outputChannel.transferFrom(inputChannel, 0, srcFile.length()); inputChannel.close(); outputChannel.close(); }

三、文件修改
場景:某個文件需要把最後1個漢字,修改成其它字。先寫一段程式碼,生成測試用的文件
public static void writeLargeFile() throws IOException { String content = "12345678-abcdefg-菩提樹下的楊過n"; String filePath = "/tmp/yjmyzz.txt"; FileOutputStream outputStream = new FileOutputStream(filePath); FileChannel writeChannel = outputStream.getChannel(); ByteBuffer buffer = ByteBuffer.allocate(128); buffer.put(content.getBytes()); for (int i = 0; i < 10; i++) { buffer.flip(); writeChannel.write(buffer); } writeChannel.close(); }
運行完後,測試文件中的內容如下:
12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過 12345678-abcdefg-菩提樹下的楊過
3.1 常規方法示例
public static void modify1() throws IOException { String filePath = "/tmp/yjmyzz.txt"; File file = new File(filePath); FileInputStream inputStream = new FileInputStream(file); FileChannel inputChannel = inputStream.getChannel(); ByteBuffer buffer = ByteBuffer.allocate((int) file.length()); byte[] tempBytes = "佛".getBytes(); inputChannel.read(buffer); buffer.flip(); //修改最後1個漢字 for (int i = 0; i < tempBytes.length; i++) { //最後有一個回車符,然後漢字utf-8佔3個位元組,所以這裡要減4,才是最後1個漢字 int j = buffer.limit() - 4 + i; buffer.put(j, tempBytes[i]); } FileOutputStream outputStream = new FileOutputStream(filePath); FileChannel outputChannel = outputStream.getChannel(); outputChannel.write(buffer); inputChannel.close(); outputChannel.close(); }
運行完後,從下面的截圖可以看到,測試最後1個字,從「過」變成了「佛」:

這個方法,對於小文件而言沒什麼問題,但如果文件是一個幾G的巨無霸,會遇到2個問題:

首先是allocate方法,只接受int型參數,對於幾個G的大文件,File.length很有可能超過int範圍,無法分配足夠大的緩衝。其次,就算放得下,幾個G的內容全放到記憶體中,也很可能造成OOM,所以需要其它辦法。
3.2 利用RandomAccessFile及Channel.map修改文件
public static void modify2() throws IOException { String filePath = "/tmp/yjmyzz.txt"; RandomAccessFile file = new RandomAccessFile(filePath, "rw"); FileChannel channel = file.getChannel(); //將最後一個漢字映射到記憶體中 MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, file.length() - 4, 3); byte[] lastWordBytes = "新".getBytes(); //這樣就直接在記憶體中修改了文件,不再需要調用channel.write mappedByteBuffer.put(lastWordBytes); channel.close(); }
這個方法相對就高級多了,RandomAccessFile類是File類的加強版,允許以游標的方式,直接讀取文件的某一部分,另外Channel.map方法,可以直接將文件中的某一部分映射到記憶體,在記憶體中直接修MappedByteBuffer後,文件內容就相應的修改了。

值得一提的是,從上面調試的截圖來看,FileChannel.map方法返回的MappedByteBuffer,真實類型是它下面派生的子類DirectByteBuffer,這是「堆外」記憶體,不在JVM 自動垃圾回收的管轄範圍。
參考文章:
https://docs.oracle.com/en/java/javase/13/docs/api/java.base/java/nio/channels/Channel.html


