Java NIO 內存映射文件
@author ixenos
文件操作的四大方法
前提:內存的訪問速度比磁盤高幾個數量級,但是基本的IO操作是直接調用native方法獲得驅動和磁盤交互的,IO速度限制在磁盤速度上
由此,就有了緩存的思想,將磁盤內容預先緩存在內存上,這樣當供大於求的時候IO速度基本就是以內存的訪問速度為主,例如BufferedInput/OutputStream等
而我們知道大多數OS都可以利用虛擬內存實現將一個文件或者文件的一部分映射到內存中,然后,這個文件就可以當作是內存數組一樣地訪問,我們可以把它看成一種“永久的緩存”
內存映射文件:內存映射文件允許我們創建和修改那些因為太大而不能放入內存的文件,此時就可以假定整個文件都放在內存中,而且可以完全把它當成非常大的數組來訪問(隨機訪問)
以下是四大文件操作對比:
本圖使用思維導圖軟件XMind制作
在Core Java II中進行了這么一個實驗:在同一台機器上,對JDK的jre/lib目錄中的37MB的rt.jar文件分別用以上四種操作來計算CRC32校驗和,記錄下了如下時間
方法 | 時間 |
普通輸入流 | 110s |
帶緩沖的輸入流 | 9.9s |
隨機訪問文件 | 162s |
內存映射文件 | 7.2s |
這個小實驗也驗證了內存映射文件這個方法的可行性,由於具有隨機訪問的功能(映射在內存數組),所以常用來替代RandomAccessFile。
當然,對於中等尺寸文件的順序讀入則沒有必要使用內存映射以避免占用本就有限的I/O資源,這時應當使用帶緩沖的輸入流。
內存映射文件
java.nio包使得內存映射變得十分簡單
1、首先,從文件中獲得一個通道(channel)。通道是用於磁盤文件的一種抽象,它使我們可以訪問諸如內存映射、文件加鎖機制(下文緩沖區數據結構部分將提到)、文件間快速數據傳遞等操作系統特性。
1 FileChannel channel = FileChannel.open(path, options);
還能通過在一個打開的 File 對象(RandomAccessFile、FileInputStream 或 FileOutputStream)上調用 getChannel() 方法獲取。調用 getChannel() 方法會返回一個連接到相同文件的 FileChannel 對象且該 FileChannel 對象具有與 File 對象相同的訪問權限
2、然后,通過調用FileChannel類的map方法進行內存映射,map方法從這個通道中獲得一個MappedByteBuffer對象(ByteBuffer的子類)。
你可以指定想要映射的文件區域與映射模式,支持的模式有3種:
- FileChannel.MapMode.READ_ONLY:產生只讀緩沖區,對緩沖區的寫入操作將導致ReadOnlyBufferException;
- FileChannel.MapMode.READ_WRITE:產生可寫緩沖區,任何修改將在某個時刻寫回到文件中,而這某個時刻是依賴OS的,其他映射同一個文件的程序可能不能立即看到這些修改,多個程序同時進行文件映射的確切行為是依賴於系統的,但是它是線程安全的
- FileChannel.MapMode.PRIVATE:產生可寫緩沖區,但任何修改是緩沖區私有的,不會回到文件中。。。
1 import java.io.*; 2 import java.nio.*; 3 import java.nio.channels.*; 4 import java.nio.file.*; 5 import java.util.zip.*; 6 7 public class MemoryMapTest 8 { 9 10 public static long checksumMappedFile(Path filename) throws IOException 11 { 12 //直接通過傳入的Path打開文件通道 13 try (FileChannel channel = FileChannel.open(filename)) 14 { 15 CRC32 crc = new CRC32(); 16 int length = (int) channel.size(); 17 //通過通道的map方法映射內存 18 MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, length); 19 20 for (int p = 0; p < length; p++) 21 { 22 int c = buffer.get(p); 23 crc.update(c); 24 } 25 return crc.getValue(); 26 } 27 } 28 29 public static void main(String[] args) throws IOException 30 { 31 System.out.println("Mapped File:"); 32 start = System.currentTimeMillis(); 33 crcValue = checksumMappedFile(filename); 34 end = System.currentTimeMillis(); 35 System.out.println(Long.toHexString(crcValue)); 36 System.out.println((end - start) + " milliseconds"); 37 } 38 }
3、一旦有了緩沖區,就可以使用ByteBuffer類和Buffer超類的方法來讀寫數據
緩沖區支持順序和隨機數據訪問:
順序:有一個可以通過get和put操作來移動的位置
1 while(buffer.hasRemaining()){ 2 byte b = buffer.get(); //get當前位置 3 ... 4 }
隨機:可以按內存數組索引訪問
1 for(int i=0; i<buffer.limit(); i++){ 2 byte b = buffer.get(i); //這個get能指定索引 3 ... 4 }
可以用下面的方法來讀寫數據到一個字節數組(destination array):
get(byte[] bytes) /get(byte[] bytes, int offset, int length)
The method transfers bytes from this buffer into the given destination array.
還有下列getXxx方法:getInt, getLong, getShort, getChar, getFloat, getDouble 用來讀入在文件中存儲為二進制值的基本類型值
關於二進制數據排序機制不同的讀取問題:
我們知道,Java對二進制數據使用高位在前的排序機制(比如 0XA就是 0000 1010,高位在前低位在后),
但是,如果需要低位在前的排序方式(0101 0000)處理二進制數字的文件,需調用:
buffer.order(ByteOrder.LITTLE_ENDIAN);
要查詢緩沖區內當前的字節順序,可以調用:
ByteOrder b = buffer.order();
要向緩沖區寫數字,使用對應的putXxx方法,在恰當的時機,以及當通道關閉時,會將這些修改寫回到文件中的哦。
緩沖區數據結構
在使用內存映射時,我們既可以創建單一的緩沖區橫跨整個文件或者感興趣的文件區域,也可以使用更多的緩沖區來讀寫大小適度的信息塊。
這一小節,就來講講緩沖區Buffer對象上的基本操作。
緩沖區是具有相同基本類型的數值構成的數組(數組在內存中創建),Buffer類是一個抽象類,有以下具體的子類:ByteBuffer,CharBuffer,DoubleBuffer,IntBuffer,LongBuffer和ShortBuffer。(注意StringBuffer跟這些人沒關系,而且String本質是引用類型)
實踐中,最常用的是ByteBuffer和CharBuffer。
每個緩沖區都具有:
1、一個恆定的容量;
2、一個讀寫位置,下一個值將在此進行讀寫;
3、一個界限,超過他無法讀寫;
4、一個可選的標記,用於重復一個讀入或寫出操作;
0≤標記≤位置≤界限≤容量
1、寫:一開始時位置為0,界限等於容量,當我們不斷調用put添值到緩沖區中,直至耗盡所有數據或者寫出的數據集量達到容量大小時,就該進行讀入操作了;
2、讀:這時調用 flip 方法將界限設置到當前位置(相當於trim),並把位置復位到0(為了讀操作),現在在remaining方法返回(界限 — 位置)正數時,不斷調用get;
3、復位:將緩沖區中所有值讀入后,調用clear(位置復位到0,界限復位到容量)使緩沖區為下一次寫循環做准備;
4、復讀:想復讀緩沖區,可調用rewind或mark/reset方法;
緩沖區的獲得:
A、內存映射時使用的是MappedByteBuffer,這是ByteBuffer的子類,由FileChannel的map()方法調用
B、餓漢要獲取緩沖區,可調用ByteBuffer.allocate或ByteBuffer.wrap這樣的靜態方法,然后用來自某個通道的數據填充緩沖區,或者將緩沖區的內容寫出通道中:
1 ByteBuffer buffer = ByteBuffer.allocate(RECORD_SIZE); 2 3 //填充緩沖區 4 channel1.read(buffer); 5 //將Channel位置指定到newpos,作為覆蓋文件內容的起點 6 channel1.position(newpos); 7 //將Buffer界限設置到當前位置,准備寫出,注意區別Buffer和Channel的position,兩者是不同的概念 8 buffer.flip(); 9 //將緩沖區數據寫出通道中 10 channel.write(buffer);
這些方法和RandomAccessFile類的方法類似,但性能更高,因此常用以代替隨機訪問文件。
文件加鎖機制
線程安全:我們知道多線程並發修改共享數據會產生安全問題——競爭條件,為了保證對數據的原子性操作——同步存取,我們有了synchronized關鍵字添加隱式鎖以及ReentranLock添加顯式鎖。但是多進程的同步存取又該怎么實現呢?
進程安全:OS有個文件加鎖機制,由於通道是對磁盤的一種抽象,FileChannel因此也實現了文件鎖,可以調用其lock或tryLock方法進行鎖定。
文件鎖示例:鎖定一個文件
1 FileChannel channel = FileChannel.open(path); 2 3 //調用lock,阻塞 4 FileLock lock = channel.lock(); 5 6 //調用tryLock,立即響應 7 FileLock lock = channel.tryLock();
1、第一個調用 lock() 會阻塞直至可獲得鎖,而第二個調用 tryLock() 將立即返回,要么獲得鎖,要么在鎖不可獲得的情況家返回null;
2、這個文件將保持鎖定狀態,直至這個通道關閉,或者在鎖上調用了release方法;
3、還可以鎖定文件的一部分:FileLock lock(long start, long size, boolean shared) 或 FileLock tryLock(long start, long size, boolean shared)
a)如果shared標志位false,則鎖定文件的目的是讀寫,而如果為true,則這是一個共享鎖,允許多個進程從文件中讀入,並阻止任何進程獲得獨占的鎖。調用FIleLock的isShared可查詢當前持有的文件鎖類型。
b)如果鎖了文件的尾部,但文件長度隨后增長超過了鎖定部分,那么超過的任然是不鎖定的,此時需要使用 Long.MAX_VALUE 來表示尺寸。
4、要確保在操作完成時釋放鎖,可用 try-with-resources 語句(FileLock實現了AutoCloseable接口)
1 try(FileLock lock = channel.lock()){ 2 ... 3 }
手動釋放鎖可調用FileLock對象的close()方法
注意點:
1、文件加鎖機制是依賴於操作系統的
2、意外的建議鎖:在某些系統中文件鎖僅僅是建議性的,可能出現一個應用未能得到鎖,它仍舊可以向被另一個進程並發鎖定的文件執行寫操作;
3、意外的原子性:在某些系統中,不能在鎖定一個文件的同時將其映射到內存中,原子性;
4、意外的全釋放:在某些系統中,關閉一個通道會釋放由JVM持有的底層文件上的所有鎖,因此避免在同一個鎖定文件上使用多個通道,不然其他通道的鎖也可能被釋放!
5、不可重入鎖:文件鎖是由整個JVM持有的,兩個由同一VM啟動的程序不可能獲得在同一個文件上的鎖,如果嘗試對VM上已加鎖的文件再加鎖,將拋出OverlappingFileLockException;
(注意:多線程的ReentranLock是可重入的!簡稱可重入鎖,而文件鎖是不可重入鎖)
6、在網絡文件系統上鎖定文件是高度依賴於系統的,盡量避免使用文件鎖。