天天看點

JAVA NIO之檔案通道

1.簡介

通道是 Java NIO 的核心内容之一,在使用上,通道需和緩存類(ByteBuffer)配合完成讀寫等操作。與傳統的流式 IO 中資料單向流動不同,通道中的資料可以雙向流動。通道既可以讀,也可以寫。這裡我們舉個例子說明一下,我們可以把通道看做水管,把緩存看做水塔,把檔案看做水庫,把水看做資料。當從磁盤中将檔案資料讀取到緩存中時,就是從水庫向水塔裡抽水。當然,從磁盤裡讀取資料并不會将讀取的部分從磁盤裡删除,但從水庫裡抽水,則水庫裡的水量在無補充的情況下确實變少了。當然,這隻是一個小問題,大家不要扣這個細節哈,繼續往下說。當水塔中存儲了水之後,我們可以用這些水燒飯,澆花等,這就相當于處理緩存的資料。過了一段時間後,水塔需要進行清洗。這個時候需要把水塔裡的水放回水庫中,這就相當于向磁盤中寫入資料。通過這裡例子,大家應該知道通道是什麼了,以及有什麼用。既然知道了,那麼我們繼續往下看。

Java NIO 出現在 JDK 1.4 中,由于 NIO 效率高于傳統的 IO,是以 Sun 公司從底層對傳統 IO 的實作進行了修改。修改的方式就是在保證相容性的情況下,使用 NIO 重構 IO 的方法實作,無形中提高了傳統 IO 的效率。

2.基本操作

通道類型分為兩種,一種是面向檔案的,另一種是面向網絡的。具體的類聲明如下:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

正如上清單,NIO 通道涵蓋了檔案 IO,TCP 和 UDP 網絡 IO 等通道類型。本文我們先來說說檔案通道。

2.1 建立通道

FileChannel 是一個用于連接配接檔案的通道,通過該通道,既可以從檔案中讀取,也可以向檔案中寫入資料。與SocketChannel 不同,FileChannel 無法設定為非阻塞模式,這意味着它隻能運作在阻塞模式下。在使用FileChannel 之前,需要先打開它。由于 FileChannel 是一個抽象類,是以不能通過直接建立而來。必須通過像 InputStream、OutputStream 或 RandomAccessFile 等執行個體擷取一個 FileChannel 執行個體。

FileInputStream fis = new FileInputStream(FILE_PATH);     FileChannel channel = fis.getChannel();     FileOutputStream fos = new FileOutputStream(FILE_PATH);     FileChannel channel = fis.getChannel();     RandomAccessFile raf = new RandomAccessFile(FILE_PATH , "rw");     FileChannel channel = raf.getChannel();           

2.2 讀寫操作

讀寫操作比較簡單,這裡直接上代碼了。下面的代碼會先向檔案中寫入資料,然後再将寫入的資料讀出來并列印。代碼如下:

// 擷取管道     RandomAccessFile raf = new RandomAccessFile(FILE_PATH, "rw");     FileChannel rafChannel = raf.getChannel();     // 準備資料     String data = "新資料,時間: " + System.currentTimeMillis();     System.out.println("原資料:\n" + "   " + data);     ByteBuffer buffer = ByteBuffer.allocate(128);     buffer.clear();     buffer.put(data.getBytes());     buffer.flip();     // 寫入資料     rafChannel.write(buffer);     rafChannel.close();     raf.close();     // 重新打開管道     raf = new RandomAccessFile(FILE_PATH, "rw");     rafChannel = raf.getChannel();     // 讀取剛剛寫入的資料     buffer.clear();     rafChannel.read(buffer);     // 列印讀取出的資料     buffer.flip();     byte[] bytes = new byte[buffer.limit()];     buffer.get(bytes);     System.out.println("讀取到的資料:\n" + "   " + new String(bytes));     rafChannel.close();     raf.close();           

上面的代碼輸出結果如下:

JAVA NIO之檔案通道

2.3 資料轉移操作

我們有時需要将一個檔案中的内容複制到另一個檔案中去,最容易想到的做法是利用傳統的 IO 将源檔案中的内容讀取到記憶體中,然後再往目标檔案中寫入。現在,有了 NIO,我們可以利用更友善快捷的方式去完成複制操作。FileChannel 提供了一對資料轉移方法 - transferFrom/transferTo,通過使用這兩個方法,即可簡化檔案複制操作。

public static void main(String[] args) throws IOException {         RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");         FileChannel fromChannel = fromFile.getChannel();         RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");         FileChannel toChannel = toFile.getChannel();         long position = 0;         long count = fromChannel.size();         // 将 fromFile 檔案找那個的資料轉移到 toFile 中去         System.out.println("before transfer: " + readChannel(toChannel));         fromChannel.transferTo(position, count, toChannel);         System.out.println("after transfer : " + readChannel(toChannel));         fromChannel.close();         fromFile.close();         toChannel.close();         toFile.close();     }     private static String readChannel(FileChannel channel) throws IOException {         ByteBuffer buffer = ByteBuffer.allocate(32);         buffer.clear();         // 将 channel 讀取位置設為 0,也就是檔案開始位置         channel.position(0);         channel.read(buffer);         // 再次将檔案位置歸零         channel.position(0);         buffer.flip();         byte[] bytes = new byte[buffer.limit()];         buffer.get(bytes);         return new String(bytes);     }           
JAVA NIO之檔案通道

通過上面的代碼,我們可以明顯感受到,利用 transferTo 減少了編碼量。那麼為什麼利用 transferTo 可以減少編碼量呢?在解答這個問題前,先來說說程式讀取資料和寫入檔案的過程。

我們現在所使用的 PC 作業系統,将記憶體分為了核心空間和使用者空間。作業系統的核心和一些硬體的驅動程式就是運作在核心空間内,而使用者空間就是我們自己寫的程式所能運作的記憶體區域。這裡,當我們調用 read 從磁盤中讀取資料時,核心會首先将資料讀取到核心空間中,然後再将資料從核心空間複制到使用者空間内。也就是說,我們需要通過核心進行資料中轉。同樣,寫入資料也是如此。系統先從使用者空間将資料拷貝到核心空間中,然後再由核心空間向磁盤寫入。相關示意圖如下:

JAVA NIO之檔案通道

與上面的資料流向不同,FileChannel 的 transferTo 方法底層基于 sendfile64(Linux 平台下)系統調用實作。sendfile64 會直接在核心空間内進行資料拷貝,免去了核心往使用者空間拷貝,使用者空間再往核心空間拷貝這兩步操作,是以提高了效率。其示意圖如下:

JAVA NIO之檔案通道

通過上面的講解,大家應該知道了 transferTo 和 transferFrom 的效率會高于傳統的 read 和 write 在效率上的差別。差別的原因在于免去了核心空間和使用者空間的互相拷貝,雖然記憶體間拷貝的速度比較快,但涉及到大量的資料拷貝時,互相拷貝的帶來的消耗是不應該被忽略的。

講完了背景知識,咱們再來看看 FileChannel 是怎樣調用 sendfile64 這個函數的。相關代碼如下:

public long transferTo(long position, long count,                                WritableByteChannel target)             throws IOException     {         // 省略一些代碼         int icount = (int)Math.min(count, Integer.MAX_VALUE);         if ((sz - position) < icount)             icount = (int)(sz - position);         long n;         // Attempt a direct transfer, if the kernel supports it         if ((n = transferToDirectly(position, icount, target)) >= 0)             return n;         // Attempt a mapped transfer, but only to trusted channel types         if ((n = transferToTrustedChannel(position, icount, target)) >= 0)             return n;         // Slow path for untrusted targets         return transferToArbitraryChannel(position, icount, target);     }     private long transferToDirectly(long position, int icount,                                     WritableByteChannel target)         throws IOException     {         // 省略一些代碼         long n = -1;         int ti = -1;         try {             begin();             ti = threads.add();             if (!isOpen())                 return -1;             do {                 n = transferTo0(thisFDVal, position, icount, targetFDVal);             } while ((n == IOStatus.INTERRUPTED) && isOpen());             // 省略一些代碼             return IOStatus.normalize(n);         } finally {             threads.remove(ti);             end (n > -1);         }     }           

從上面代碼(transferToDirectly 方法可以在 openjdk/jdk/src/share/classes/sun/nio/ch/FileChannelImpl.java 中找到)中可以看得出 transferTo 的調用路徑,先是調用 transferToDirectly,然後 transferToDirectly 再調用 transferTo0。transferTo0 是 native 類型的方法,我們再去看看 transferTo0 是怎樣實作的,其代碼在

openjdk/jdk/src/solaris/native/sun/nio/ch/FileChannelImpl.c

中。

JNIEXPORT jlong JNICALL     Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,                                                 jint srcFD,                                                 jlong position, jlong count,                                                 jint dstFD)     {     #if defined(__linux__)         off64_t offset = (off64_t)position;         jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);         if (n < 0) {             if (errno == EAGAIN)                 return IOS_UNAVAILABLE;             if ((errno == EINVAL) && ((ssize_t)count >= 0))                 return IOS_UNSUPPORTED_CASE;             if (errno == EINTR) {                 return IOS_INTERRUPTED;             }             JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");             return IOS_THROWN;         }         return n;     // 其他平台的代碼省略     #endif     }           

如上所示,transferTo0 最終調用了 sendfile64 函數,關于 sendfile64 這個系統調用的詳細說明,請參考 man-page,這裡就不展開說明了。

2.4 記憶體映射

記憶體映射這個概念源自作業系統,是指将一個檔案映射到某一段虛拟記憶體(實體記憶體可能不連續)上去。我們通過對這段虛拟記憶體的讀寫即可達到對檔案的讀寫的效果,進而可以簡化對檔案的操作。當然,這隻是記憶體映射的一個優點。記憶體映射還有其他的一些優點,比如兩個程序映射同一個檔案,可以實作程序間通信。再比如,C 程式運作時需要 C 标準庫支援,作業系統将 C 标準庫放到了記憶體中,普通的 C 程式隻需要将 C 标準庫映射到自己的程序空間内就行了,進而可以降低記憶體占用。以上簡單介紹了記憶體映射的概念及作用,關于這方面的知識,建議大家去看《深入了解計算機系統》關于記憶體映射的章節,講的很好。

Unix/Linux 作業系統記憶體映射的系統調用

mmap

,Java 在這個系統調用的基礎上,封裝了 Java 的記憶體映射方法。這裡我就不一步一步往下追蹤了,大家有興趣可以自己追蹤一下 Java 封裝的記憶體映射方法的調用棧。下面來簡單的示例示範一下記憶體映射的用法:

// 從标準輸入擷取資料     Scanner sc = new Scanner(System.in);     System.out.println("請輸入:");     String str = sc.nextLine();     byte[] bytes = str.getBytes();     RandomAccessFile raf = new RandomAccessFile("map.txt", "rw");     FileChannel channel = raf.getChannel();     // 擷取記憶體映射緩沖區,并向緩沖區寫入資料     MappedByteBuffer mappedBuffer = channel.map(MapMode.READ_WRITE, 0, bytes.length);     mappedBuffer.put(bytes);     raf.close();     raf.close();     // 再次打開剛剛的檔案,讀取其中的内容     raf = new RandomAccessFile("map.txt", "rw");     channel = raf.getChannel();     System.out.println("\n檔案内容:")     System.out.println(readChannel(channel));     raf.close();     raf.close();           

上面的代碼從标準輸入中擷取資料,然後将資料通過記憶體映射緩存寫入到檔案中。代碼運作結果如下:

JAVA NIO之檔案通道

接下來在用 C 代碼示範上面代碼的功能,如下:

#include <stdio.h>     #include <fcntl.h>     #include <sys/mman.h>     #include <memory.h>     #include <unistd.h>     int main() {         int dstfd;         void *dst;         char buf[64], out[64];         int len;         printf("Please input:\n");         scanf("%s", buf);         len = strlen(buf);         // 打開檔案         dstfd = open("dst.txt", O_RDWR | O_CREAT | O_TRUNC, S_IRWXU);         lseek(dstfd, len - 1, SEEK_SET);         write(dstfd, "", 1);         // 将檔案映射到記憶體中         dst = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, dstfd, 0);         // 将輸入的資料拷貝到映射記憶體中         memcpy(dst, buf, len);         munmap(dst, len);         close(dstfd);         // 重新打開檔案,并輸出檔案内容         dstfd = open("dst.txt", O_RDONLY);         dst = mmap(NULL, len, PROT_READ, MAP_SHARED, dstfd, 0);         bzero(out, 64);         memcpy(out, dst, len);         printf("\nfile content:\n%s\n", out);         munmap(dst, len);         close(dstfd);         return 0;     }           

關于 mmap 函數的參數說明,這裡就不細說了,大家可以參考 man-page。上面的代碼運作結果如下:

JAVA NIO之檔案通道

關于記憶體映射就說到了,更深入的分析需要涉及到很多作業系統層面的東西。我對這些東西了解的也不多,是以就不繼續分析了,慚愧慚愧。

2.5 其他操作

FileChannel 還有一些其他的方法,這裡通過一個表格來列舉這些方法,就不一一展開說明了。如下:

方法名 用途
position 傳回或修改通道讀寫位置
size 擷取通道所關聯檔案的大小
truncate 截斷通道所關聯的檔案
force 強制将通道中的新資料重新整理到檔案中
close 關閉通道
lock 對通道檔案進行加鎖

以上所列舉的方法用起來比較簡單,大家自己寫代碼驗證一下吧,這裡就不貼代碼了。

3.總結

以上章節對 NIO 檔案通道的用法和部分方法的實作進行了簡單分析。從上面的分析可以看出,NIO FileChannel 在實作上,實際上是對底層作業系統的一些 API 進行了再次封裝,也就是一層皮。有了這層封裝後,對上就屏蔽了底層 API 的細節,以降低使用難度。Java 為了提高開發效率,屏蔽了作業系統層面的細節。雖然 Java 可以屏蔽這些細節,但作為開發人員,我覺得我們不能也去屏蔽這些細節(雖然不了解這些細節也能寫代碼),有時間還是應該多了解了解這些底層的東西。畢竟要想往更高的層次發展,這些底層的知識必不可少。說到這裡,感覺很慚愧,我的技術基礎也很薄弱。大學期間沒有意識到專業基礎課的重要性,學了很多東西,但忽略了基礎。好在工作不久後看了很多牛人的部落格,也意識到了自己的不足。現在靜下心來打基礎,算是亡羊補牢吧。

好了,關于檔案通道的内容這裡就說到這,謝謝大家的閱讀。

參考

  • 《Java 程式設計思想》
  • 《深入了解計算機系統》
  •   Java NIO Channel

本文在知識共享許可協定 4.0 下釋出,轉載需在明顯位置處注明出處

作者:coolblog

本文同步釋出在我的個人部落格:http://www.coolblog.xyz/?r=cb

JAVA NIO之檔案通道

本作品采用知識共享署名-非商業性使用-禁止演繹 4.0 國際許可協定進行許可。