1. 程式人生 > >JAVA NIO之檔案通道

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 例項。

1
2
3
4
5
6
7
8
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 讀寫操作

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 獲取管道
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();

上面的程式碼輸出結果如下:

-w572

 2.3 資料轉移操作

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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);
}

-w521

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

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

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

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

講完了背景知識,咱們再來看看 FileChannel 是怎樣呼叫 sendfile64 這個函式的。相關程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 封裝的記憶體對映方法的呼叫棧。下面來簡單的示例演示一下記憶體對映的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 從標準輸入獲取資料
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();

上面的程式碼從標準輸入中獲取資料,然後將資料通過記憶體對映快取寫入到檔案中。程式碼執行結果如下:

-w332

接下來在用 C 程式碼演示上面程式碼的功能,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#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。上面的程式碼執行結果如下:

-w332

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

 2.5 其他操作

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

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

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

 3.總結

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

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

 參考

from:http://www.tianxiaobo.com/2018/03/24/JAVA-NIO%E4%B9%8B%E6%96%87%E4%BB%B6%E9%80%9A%E9%81%93/