Java NIO 檔案通道 FileChannel 用法

Robothy發表於2021-01-13

FileChannel 提供了一種通過通道來訪問檔案的方式,它可以通過帶引數 position(int) 方法定位到檔案的任意位置開始進行操作,還能夠將檔案對映到直接記憶體,提高大檔案的訪問效率。本文將介紹其詳細用法和原理。

1. 通道獲取

FileChannel 可以通過 FileInputStream, FileOutputStream, RandomAccessFile 的物件中的 getChannel() 方法來獲取,也可以同通過靜態方法 FileChannel.open(Path, OpenOption ...) 來開啟。

1.1 從 FileInputStream / FileOutputStream 中獲取

從 FileInputStream 物件中獲取的通道是以讀的方式開啟檔案,從 FileOutpuStream 物件中獲取的通道是以寫的方式開啟檔案。

FileOutputStream ous = new FileOutputStream(new File("a.txt"));
FileChannel out = ous.getChannel(); // 獲取一個只讀通道
FileInputStream ins = new FileInputStream(new File("a.txt"));
FileChannel in = ins.getChannel();  // 獲取一個只寫通道

1.2 從 RandomAccessFile 中獲取

從 RandomAccessFaile 中獲取的通道取決於 RandomAccessFaile 物件是以什麼方式建立的,"r", "w", "rw" 分別對應著讀模式,寫模式,以及讀寫模式。

RandomAccessFile file = new RandomAccessFile("a.txt", "rw");
FileChannel channel = file.getChannel(); // 獲取一個可讀寫檔案通道

1.3 通過 FileChannel.open() 開啟

通過靜態靜態方法 FileChannel.open() 開啟的通道可以指定開啟模式,模式通過 StandardOpenOption 列舉型別指定。

FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ); // 以只讀的方式開啟一個檔案 a.txt 的通道

2. 讀取資料

讀取資料的 read(ByteBuffer buf) 方法返回的值表示讀取到的位元組數,如果讀到了檔案末尾,返回值為 -1。讀取資料時,position 會往後移動。

2.1 將資料讀取到單個緩衝區

和一般通道的操作一樣,資料也是需要讀取到1個緩衝區中,然後從緩衝區取出資料。在呼叫 read 方法讀取資料的時候,可以傳入引數 position 和 length 來指定開始讀取的位置和長度。

FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ);
ByteBuffer buf = ByteBuffer.allocate(5);
while(channel.read(buf)!=-1){
    buf.flip();
    System.out.print(new String(buf.array()));
    buf.clear();
}
channel.close();

2.2 讀取到多個緩衝區

檔案通道 FileChannel 實現了 ScatteringByteChannel 介面,可以將檔案通道中的內容同時讀取到多個 ByteBuffer 當中,這在處理包含若干長度固定資料塊的檔案時很有用。

ScatteringByteChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ);
ByteBuffer key = ByteBuffer.allocate(5), value=ByteBuffer.allocate(10);
ByteBuffer[] buffers = new ByteBuffer[]{key, value};
while(channel.read(buffers)!=-1){
    key.flip();
    value.flip();
    System.out.println(new String(key.array()));
    System.out.println(new String(value.array()));
    key.clear();
    value.clear();
}
channel.close();

3. 寫入資料

3.1 從單個緩衝區寫入

單個緩衝區操作也非常簡單,它返回往通道中寫入的位元組數。

FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE);
ByteBuffer buf = ByteBuffer.allocate(5);
byte[] data = "Hello, Java NIO.".getBytes();
for (int i = 0; i < data.length; ) {
    buf.put(data, i, Math.min(data.length - i, buf.limit() - buf.position()));
    buf.flip();
    i += channel.write(buf);
    buf.compact();
}
channel.force(false);
channel.close();

3.2 從多個緩衝區寫入

FileChannel 實現了 GatherringByteChannel 介面,與 ScatteringByteChannel 相呼應。可以一次性將多個緩衝區的資料寫入到通道中。

FileChannel channel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE);
ByteBuffer key = ByteBuffer.allocate(10), value = ByteBuffer.allocate(10);
byte[] data = "017 Robothy".getBytes();
key.put(data, 0, 3);
value.put(data, 4, data.length-4);
ByteBuffer[] buffers = new ByteBuffer[]{key, value};
key.flip();
value.flip();
channel.write(buffers);
channel.force(false); // 將資料刷出到磁碟
channel.close();

3.3 資料刷出

為了減少訪問磁碟的次數,通過檔案通道對檔案進行操作之後可能不會立即刷出到磁碟,此時如果系統崩潰,將導致資料的丟失。為了減少這種風險,在進行了重要資料的操作之後應該呼叫 force() 方法強制將資料刷出到磁碟。

無論是否對檔案進行過修改操作,即使檔案通道是以只讀模式開啟的,只要呼叫了 force(metaData) 方法,就會進行一次 I/O 操作。引數 metaData 指定是否將後設資料(例如:訪問時間)也刷出到磁碟。

channel.force(false); // 將資料刷出到磁碟,但不包括後設資料

4. 檔案鎖

可以通過呼叫 FileChannel 的 lock() 或者 tryLock() 方法來獲得一個檔案鎖,獲取鎖的時候可以指定引數起始位置 position,鎖定大小 size,是否共享 shared。如果沒有指定引數,預設引數為 position = 0, size = Long.MAX_VALUE, shared = false。

位置 position 和大小 size 不需要嚴格與檔案保持一致,position 和 size 均可以超過檔案的大小範圍。例如:檔案大小為 100,可以指定位置為 200, 大小為 50;則當檔案大小擴充套件到 250 時,[200,250) 的部分會被鎖住。

shared 引數指定是排他的還是共享的。要獲取共享鎖,檔案通道必須是可讀的;要獲取排他鎖,檔案通道必須是可寫的。

由於 Java 的檔案鎖直接對映為作業系統的檔案鎖實現,因此獲取檔案鎖時代表的是整個虛擬機器,而非當前執行緒。若作業系統不支援共享的檔案鎖,即使指定了檔案鎖是共享的,也會被轉化為排他鎖。

FileLock lock = channel.lock(0, Long.MAX_VALUE, false);// 排它鎖,此時同一作業系統下的其它程式不能訪問 a.txt
System.out.println("Channel locked in exclusive mode.");
Thread.sleep(30 * 1000L); // 鎖住 30 s
lock.release(); // 釋放鎖

lock = channel.lock(0, Long.MAX_VALUE, true); // 共享鎖,此時檔案可以被其它檔案訪問
System.out.println("Channel locked in shared mode.");
Thread.sleep(30 * 1000L); // 鎖住 30 s
lock.release();

與 lock() 相比,tryLock() 是非阻塞的,無論是否能夠獲取到鎖,它都會立即返回。若 tryLock() 請求鎖定的區域已經被作業系統內的其它的程式鎖住了,則返回 null;而 lock() 會阻塞,直到獲取到了鎖、通道被關閉或者執行緒被中斷為止。

5. 通道轉換

普通的讀寫方式是利用一個 ByteBuffer 緩衝區,作為資料的容器。但如果是兩個通道之間的資料互動,利用緩衝區作為媒介是多餘的。檔案通道允許從一個 ReadableByteChannel 中直接輸入資料,也允許直接往 WritableByteChannel 中寫入資料。實現這兩個操作的分別為 transferFrom(ReadableByteChannel src, position, count) 和 transferTo(position, count, WritableChannel target) 方法。

這進行通道間的資料傳輸時,這兩個方法比使用 ByteBuffer 作為媒介的效率要高;很多作業系統支援檔案系統快取,兩個檔案之間實際可能並沒有發生複製。

transferFrom 或者 transferTo 在呼叫之後並不會改變 position 的位置。

下面示例是一個 spring 原始碼中的一個工具方法。

public static void copy(File source, File target) throws IOException {
    FileInputStream sourceOutStream = new FileInputStream(source);
    FileOutputStream targetOutStream = new FileOutputStream(target);
    FileChannel sourceChannel = sourceOutStream.getChannel();
    FileChannel targetChannel = targetOutStream.getChannel();
    sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);
    sourceChannel.close();
    targetChannel.close();
    sourceOutStream.close();
    targetOutStream.close();
}

需要注意的是,呼叫這兩個轉換方法之後,某些情況下並不保證資料能夠全部完成傳輸,確切傳輸了多少位元組的資料需要根據返回的值來進行判斷。例如:從一個非阻塞模式下的 SocketChannel 中輸入資料就不能夠一次性將資料全部傳輸過來,或者將檔案通道的資料傳輸給一個非阻塞模式下的 SocketChannel 不能一次性傳輸過去。

下面給出一個示例,客戶端連線到服務端,然後從服務端下載一個叫 video.mp4 檔案,檔案在當前目錄存在。

錯誤示例:

/** 服務端 **/
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 開啟服務通道
serverSocketChannel.bind(new InetSocketAddress(9090)); // 繫結埠號
SocketChannel clientChannel = serverSocketChannel.accept(); // 等待客戶端連線,獲取 SocketChannel
FileChannel fileChannel = FileChannel.open(Paths.get("video.mp4"), StandardOpenOption.READ); // 開啟檔案通道
fileChannel.transferTo(0, fileChannel.size(), clientChannel); // 【可能出錯位置】檔案通道資料輸出轉化到 socket 通道,輸出範圍為整個檔案。檔案太大將導致輸出不完整

/** 客戶端 **/
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9090)); // 打卡 socket 通道並連線到服務端
FileChannel fileChannel = FileChannel.open(Paths.get("video-downloaded.mp4"), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, StandardOpenOption.CREATE); // 開啟檔案通道
fileChannel.transferFrom(socketChannel, 0, Long.MAX_VALUE); // 【非阻塞模式下可能出錯】
fileChannel.force(false); // 確保資料刷出到磁碟

正確的姿勢是:transferTo/transferFrom 的時候應該用一個迴圈檢查實際輸出內容大小是否和期望輸出內容大小一致,特別是通道處於非阻塞模式下,極大概率不能夠一次傳輸完成。

所以服務端正確的轉換方式是:

long transfered = 0;
while (transfered < fileChannel.size()){
    transfered += fileChannel.transferTo(transfered, fileChannel.size(), clientChannel);
}

本例中客戶端使用的是阻塞模式,服務端通道關閉輸出(socketChannel.shutdownOutput())之後 transferFrom 才退出,服務端正常關閉通道的情況下資料傳輸不會出錯,這裡就不處理非正常關閉的情況了。(完整程式碼)。

6. 擷取檔案

FileChannel.truncate(long size) 可以擷取指定的檔案,指定大小之後的內容將被丟棄。size 的值可以超過檔案大小,超過的話不會擷取任何內容,也不會增加任何內容。

FileChannel fileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE);
fileChannel.truncate(1);
System.out.println(fileChannel.size()); // 輸出 1
fileChannel.write(ByteBuffer.wrap("Hello".getBytes()));
System.out.println(fileChannel.size()); // 輸出 5
fileChannel.force(true);
fileChannel.close();

7. 對映檔案到直接記憶體

檔案通道 FileChannel 可以將檔案的指定範圍對映到程式的地址空間中,對映部分使用位元組緩衝區的一個子類 MappedByteBuffer 的物件表示,只要對對映位元組緩衝區進行操作就能夠達到操作檔案的效果。與之相對應的,前面介紹的內容是通過操作檔案通道和堆記憶體中的位元組緩衝區 HeapByteBuffer 來達到操作檔案的目的。

通過 ByteBuffer.allocate() 分配的緩衝區是一個 HeapByteBuffer,存在於 JVM 堆中;而 FileChannle.map() 將檔案對映到直接記憶體,返回的是一個 MappedByteBuffer,存在於堆外的直接記憶體中;這塊記憶體在 MappedByteBuffer 物件本身被回收之前有效。

主存主存JVM程式記憶體HeapByteBufferJVM 堆記憶體a) HeapByteBuffer 在記憶體中的位置b) MappedByteBuffer 在記憶體中的位置JVM 堆記憶體JVM程式記憶體MappedByteBuffer

7.1 記憶體對映原理

前面使用堆緩衝區 ByteBuffer 和檔案通道 FileChannel 對檔案的操作使用的是 read()/write() 系統呼叫。讀取資料時資料從 I/O 裝置讀到核心快取,再從核心快取複製到使用者空間快取,這裡是 JVM 的堆記憶體。而對映磁碟檔案是使用 mmap() 系統呼叫,將檔案的指定部分對映到程式地址空間中;資料互動發生在 I/O 裝置於使用者空間之間,不需要經過核心空間。

檔案核心空間使用者空間快取IO裝置快取檔案核心空間使用者空間快取IO裝置快取a) 普通 I/Ob) 記憶體對映 I/O

雖然對映磁碟檔案減少了一次資料複製,但對於大多數作業系統來說,將檔案對映到記憶體這個操作本身開銷較大;如果操作的檔案很小,只有數十KB,對映檔案所獲得的好處將不及其開銷。因此,只有在操作大檔案的時候才將其對映到直接記憶體。

7.2 對映緩衝區用法

檔案通道 FileChanle 通過成員方法 map(MapMode mode, long position, long size) 將檔案對映到應用記憶體。

FileChannel fileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE); // 以讀寫的方式開啟檔案通道
MappedByteBuffer buf = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size()); // 將整個檔案對映到記憶體

mode 表示開啟模式,為列舉值,其值可以為 READ_ONLY, READ_WRITE, PRIVATE。
+ 模式為 READ_ONLY 時,不能對 buf 進行寫操作;
+ 模式為 READ_WRITE 時,通道 fileChannel 必須具有讀寫檔案的許可權;對 buf 進行的寫操作將對檔案生效,但不保證立即同步到 I/O 裝置;
+ 模式為 PRIVATE 時,通道 fileChannle 必須對檔案有讀寫許可權;但是對檔案的修改操作不會傳播到 I/O 裝置,而是會在記憶體複製一份資料。此時對檔案的修改對其它執行緒和程式不可見。

position 指定檔案的開始對映到記憶體的位置;

size 指定對映的大小,值為非負 int 型整數。

呼叫 map() 方法之後,返回的 MappedByteBuffer 就於 fileChannel 脫離了關係,關閉 fileChannel 對 buf 沒有影響。同時,如果要確保對 buf 修改的資料能夠同步到檔案 I/O 裝置中,需要呼叫 MappedByteBuffer 中的無引數的 force() 方法,而呼叫 FileChannel 中的 force(metaData) 方法無效。

此時可以通過操作緩衝區來操作檔案了。不過對映的內容存在於 JVM 程式的堆外記憶體中,這部分記憶體是虛擬記憶體,意味著 buf 中的內容不一定都在實體記憶體中,要讓這些內容載入到實體記憶體,可以呼叫 MappedByteBuffer 中的 load() 方法。另外,還可以呼叫 isLoaded() 來判斷 buf 中的內容是否在實體記憶體中。

FileChannel fileChannel = FileChannel.open(Paths.get("a.txt"), StandardOpenOption.WRITE, StandardOpenOption.READ);
MappedByteBuffer buf = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());
fileChannel.close();    // 關於檔案通道對 buf 沒有影響
System.out.println(buf.capacity()); // 輸出 fileChannel.size()
System.out.println(buf.limit());    // 輸出 fileChannel.size()
System.out.println(buf.position()); // 輸出 0
buf.put((byte)'R'); // 寫入內容
buf.compact();      // 截掉 positoin 之前的內容
buf.force();        // 將資料刷出到 I/O 裝置

8. 小結

1)檔案通道 FileChannel 能夠將資料從 I/O 裝置中讀入(read)到位元組緩衝區中,或者將位元組緩衝區中的資料寫入(write)到 I/O 裝置中。

2)檔案通道能夠轉換到 (transferTo) 一個可寫通道中,也可以從一個可讀通道轉換而來(transferFrom)。這種方式使用於通道之間地資料傳輸,比使用緩衝區更加高效。

3)檔案通道能夠將檔案的部分內容對映(map)到 JVM 堆外記憶體中,這種方式適合處理大檔案,不適合處理小檔案,因為對映過程本身開銷很大。

4)在對檔案進行重要的操作之後,應該將資料刷出刷出(force)到磁碟,避免作業系統崩潰導致的資料丟失。

相關文章