本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
本節介紹記憶體對映檔案,記憶體對映檔案不是Java引入的概念,而是作業系統提供的一種功能,大部分作業系統都支援。
我們先來介紹記憶體對映檔案的基本概念,它是什麼,能解決什麼問題,然後我們介紹如何在Java中使用,我們會設計和實現一個簡單的、持久化的、跨程式的訊息佇列來演示記憶體對映檔案的應用。
基本概念
所謂記憶體對映檔案,就是將檔案對映到記憶體,檔案對應於記憶體中的一個位元組陣列,對檔案的操作變為對這個位元組陣列的操作,而位元組陣列的操作直接對映到檔案上。這種對映可以是對映檔案全部區域,也可以是隻對映一部分割槽域。
不過,這種對映是作業系統提供的一種假象,檔案一般不會馬上載入到記憶體,作業系統只是記錄下了這回事,當實際發生讀寫時,才會按需載入。作業系統一般是按頁載入的,頁可以理解為就是一塊,頁的大小與作業系統和硬體相關,典型的配置可能是4K, 8K等,當作業系統發現讀寫區域不在記憶體時,就會載入該區域對應的一個頁到記憶體。
這種按需載入的方式,使得記憶體對映檔案可以方便處理非常大的檔案,記憶體放不下整個檔案也不要緊,作業系統會自動進行處理,將需要的內容讀到記憶體,將修改的內容儲存到硬碟,將不再使用的記憶體釋放。
在應用程式寫的時候,它寫的是記憶體中的位元組陣列,這個內容什麼時候同步到檔案上呢?這個時機是不確定的,由作業系統決定,不過,只要作業系統不崩潰,作業系統會保證同步到檔案上,即使對映這個檔案的應用程式已經退出了。
在一般的檔案讀寫中,會有兩次資料拷貝,一次是從硬碟拷貝到作業系統核心,另一次是從作業系統核心拷貝到使用者態的應用程式。而在記憶體對映檔案中,一般情況下,只有一次拷貝,且記憶體分配在作業系統核心,應用程式訪問的就是作業系統的核心記憶體空間,這顯然要比普通的讀寫效率更高。
記憶體對映檔案的另一個重要特點是,它可以被多個不同的應用程式共享,多個程式可以對映同一個檔案,對映到同一塊記憶體區域,一個程式對記憶體的修改,可以讓其他程式也看到,這使得它特別適合用於不同應用程式之間的通訊。
作業系統自身在載入可執行檔案的時候,一般都利用了記憶體對映檔案,比如:
- 按需載入程式碼,只有當前執行的程式碼在記憶體,其他暫時用不到的程式碼還在硬碟
- 同時啟動多次同一個可執行檔案,檔案程式碼在記憶體也只有一份
- 不同應用程式共享的動態連結庫程式碼在記憶體也只有一份
記憶體對映檔案也有侷限性,比如,它不太適合處理小檔案,它是按頁分配記憶體的,對於小檔案,會浪費空間,另外,對映檔案要消耗一定的作業系統資源,初始化比較慢。
簡單總結下,對於一般的檔案讀寫不需要使用記憶體對映檔案,但如果處理的是大檔案,要求極高的讀寫效率,比如資料庫系統,或者需要在不同程式間進行共享和通訊,那就可以考慮記憶體對映檔案。
理解了記憶體對映檔案的基本概念,接下來,我們看怎麼在Java中使用它。
用法
對映檔案
記憶體對映檔案需要通過FileInputStream/FileOutputStream或RandomAccessFile,它們都有一個方法:
public FileChannel getChannel()
複製程式碼
FileChannel有如下方法:
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException
複製程式碼
map方法將當前檔案對映到記憶體,對映的結果就是一個MappedByteBuffer物件,它代表記憶體中的位元組陣列,待會我們再來詳細看它。map有三個引數,mode表示對映模式,positon表示對映的起始位置,size表示長度。
mode有三個取值:
- MapMode.READ_ONLY:只讀
- MapMode.READ_WRITE:既讀也寫
- MapMode.PRIVATE:私有模式,更改不反映到檔案,也不被其他程式看到
這個模式受限於背後的流或RandomAccessFile,比如,對於FileInputStream,或者RandomAccessFile但開啟模式是"r",那mode就不能設為MapMode.READ_WRITE,否則會丟擲異常。
如果對映的區域超過了現有檔案的範圍,則檔案會自動擴充套件,擴充套件出的區域位元組內容為0。
對映完成後,檔案就可以關閉了,後續對檔案的讀寫可以通過MappedByteBuffer。
看段程式碼,比如以讀寫模式對映檔案"abc.dat",程式碼可以為:
RandomAccessFile file = new RandomAccessFile("abc.dat","rw");
try {
MappedByteBuffer buf = file.getChannel().map(MapMode.READ_WRITE, 0, file.length());
//使用buf...
} catch (IOException e) {
e.printStackTrace();
}finally{
file.close();
}
複製程式碼
MappedByteBuffer
怎麼來使用MappedByteBuffer呢?它是ByteBuffer的子類,而ByteBuffer是Buffer的子類。ByteBuffer和Buffer不只是給記憶體對映檔案提供的,它們是Java NIO中運算元據的一種方式,用於很多地方,方法也比較多,我們只介紹一些主要相關的。
ByteBuffer可以簡單理解為就是封裝了一個位元組陣列,這個位元組陣列的長度是不可變的,在記憶體對映檔案中,這個長度由map方法中的引數size決定。
ByteBuffer有一個基本屬性position,表示當前讀寫位置,這個位置可以改變,相關方法是:
//獲取當前讀寫位置
public final int position()
//修改當前讀寫位置
public final Buffer position(int newPosition)
複製程式碼
ByteBuffer中有很多基於當前位置讀寫資料的方法,如:
//從當前位置獲取一個位元組
public abstract byte get();
//從當前位置拷貝dst.length長度的位元組到dst
public ByteBuffer get(byte[] dst)
//從當前位置讀取一個int
public abstract int getInt();
//從當前位置讀取一個double
public abstract double getDouble();
//將位元組陣列src寫入當前位置
public final ByteBuffer put(byte[] src)
//將long型別的value寫入當前位置
public abstract ByteBuffer putLong(long value);
複製程式碼
這些方法在讀寫後,都會自動增加position。
與這些方法相對應的,還有一組方法,可以在引數中直接指定position,比如:
//從index處讀取一個int
public abstract int getInt(int index);
//從index處讀取一個double
public abstract double getDouble(int index);
//在index處寫入一個double
public abstract ByteBuffer putDouble(int index, double value);
//在index處寫入一個long
public abstract ByteBuffer putLong(int index, long value);
複製程式碼
這些方法在讀寫時,不會改變當前讀寫位置position。
MappedByteBuffer自己還定義了一些方法:
//檢查檔案內容是否真實載入到了記憶體,這個值是一個參考值,不一定精確
public final boolean isLoaded()
//儘量將檔案內容載入到記憶體
public final MappedByteBuffer load()
//將對記憶體的修改強制同步到硬碟上
public final MappedByteBuffer force()
複製程式碼
訊息佇列
瞭解了記憶體對映檔案的用法,接下來,我們來看怎麼用它設計和實現一個簡單的訊息佇列,我們稱之為BasicQueue。
功能
BasicQueue是一個先進先出的迴圈佇列,長度固定,介面主要是出隊和入隊,與之前介紹的容器類的區別是:
- 訊息持久化儲存在檔案中,重啟程式訊息不會丟失
- 可以供不同的程式進行協作,典型場景是,有兩個不同的程式,一個是生產者,另一個是消費者,生成者只將訊息放入佇列,而消費者只從佇列中取訊息,兩個程式通過佇列進行協作,這種協作方式更靈活,相互依賴性小,是一種常見的協作方式。
BasicQueue的構造方法是:
public BasicQueue(String path, String queueName) throws IOException
複製程式碼
path表示佇列所在的目錄,必須已存在,queueName表示佇列名,BasicQueue會使用以queueName開頭的兩個檔案來儲存佇列資訊,一個字尾是.data,儲存實際的訊息,另一個字尾是.meta,儲存後設資料資訊,如果這兩個檔案存在,則會使用已有的佇列,否則會建立新佇列。
BasicQueue主要提供兩個方法,出隊和入隊,如下所示:
//入隊
public void enqueue(byte[] data) throws IOException
//出隊
public byte[] dequeue() throws IOException
複製程式碼
與上節介紹的BasicDB類似,訊息格式也是byte陣列。BasicQueue的佇列長度是有限的,如果滿了,呼叫enqueue會丟擲異常,訊息的最大長度也是有限的,不能超過1020,如果超了,也會丟擲異常。如果佇列為空,dequeue返回null。
用法示例
BasicQueue的典型用法是生產者和消費者之間的協作,我們來看下簡單的示例程式碼。生產者程式向佇列上放訊息,每放一條,就隨機休息一會兒,程式碼為:
public class Producer {
public static void main(String[] args) throws InterruptedException {
try {
BasicQueue queue = new BasicQueue("./", "task");
int i = 0;
Random rnd = new Random();
while (true) {
String msg = new String("task " + (i++));
queue.enqueue(msg.getBytes("UTF-8"));
System.out.println("produce: " + msg);
Thread.sleep(rnd.nextInt(1000));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
複製程式碼
消費者程式從佇列中取訊息,如果佇列為空,也隨機睡一會兒,程式碼為:
public class Consumer {
public static void main(String[] args) throws InterruptedException {
try {
BasicQueue queue = new BasicQueue("./", "task");
Random rnd = new Random();
while (true) {
byte[] bytes = queue.dequeue();
if (bytes == null) {
Thread.sleep(rnd.nextInt(1000));
continue;
}
System.out.println("consume: " + new String(bytes, "UTF-8"));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
複製程式碼
假定這兩個程式的當前目錄一樣,它們會使用同樣的佇列"task"。同時執行這兩個程式,會看到它們的輸出交替出現。
設計
我們採用如下簡單方式來設計BasicQueue:
- 使用兩個檔案來儲存訊息佇列,一個為資料檔案,字尾為.data,一個是後設資料檔案.meta。
- 在.data檔案中使用固定長度儲存每條資訊,長度為1024,前4個位元組為實際長度,後面是實際內容,每條訊息的最大長度不能超過1020。
- 在.meta檔案中儲存佇列頭和尾,指向.data檔案中的位置,初始都是0,入隊增加尾,出隊增加頭,到結尾時,再從0開始,模擬迴圈佇列。
- 為了區分佇列滿和空的狀態,始終留一個位置不儲存資料,當佇列頭和尾一樣的時候表示佇列為空,當佇列尾的下一個位置是佇列頭的時候表示佇列滿。
基本設計如下圖所示:
為簡化起見,我們暫不考慮由於併發訪問等引起的一致性問題。實現訊息佇列
下面來看BasicQueue的具體實現程式碼。
常量定義
BasicQueue中定義瞭如下常量,名稱和含義如下:
// 佇列最多訊息個數,實際個數還會減1
private static final int MAX_MSG_NUM = 1020*1024;
// 訊息體最大長度
private static final int MAX_MSG_BODY_SIZE = 1020;
// 每條訊息佔用的空間
private static final int MSG_SIZE = MAX_MSG_BODY_SIZE + 4;
// 佇列訊息體資料檔案大小
private static final int DATA_FILE_SIZE = MAX_MSG_NUM * MSG_SIZE;
// 佇列後設資料檔案大小 (head + tail)
private static final int META_SIZE = 8;
複製程式碼
內部組成
BasicQueue的內部成員主要就是兩個MappedByteBuffer,分別表示資料和後設資料:
private MappedByteBuffer dataBuf;
private MappedByteBuffer metaBuf;
複製程式碼
構造方法
BasicQueue的構造方法程式碼是:
public BasicQueue(String path, String queueName) throws IOException {
if (path.endsWith(File.separator)) {
path += File.separator;
}
RandomAccessFile dataFile = null;
RandomAccessFile metaFile = null;
try {
dataFile = new RandomAccessFile(path + queueName + ".data", "rw");
metaFile = new RandomAccessFile(path + queueName + ".meta", "rw");
dataBuf = dataFile.getChannel().map(MapMode.READ_WRITE, 0,
DATA_FILE_SIZE);
metaBuf = metaFile.getChannel().map(MapMode.READ_WRITE, 0,
META_SIZE);
} finally {
if (dataFile != null) {
dataFile.close();
}
if (metaFile != null) {
metaFile.close();
}
}
}
複製程式碼
輔助方法
為了方便訪問和修改佇列頭尾指標,我們有如下方法:
private int head() {
return metaBuf.getInt(0);
}
private void head(int newHead) {
metaBuf.putInt(0, newHead);
}
private int tail() {
return metaBuf.getInt(4);
}
private void tail(int newTail) {
metaBuf.putInt(4, newTail);
}
複製程式碼
為了便於判斷佇列是空還是滿,我們有如下方法:
private boolean isEmpty(){
return head() == tail();
}
private boolean isFull(){
return ((tail() + MSG_SIZE) % DATA_FILE_SIZE) == head();
}
複製程式碼
入隊
程式碼為:
public void enqueue(byte[] data) throws IOException {
if (data.length > MAX_MSG_BODY_SIZE) {
throw new IllegalArgumentException("msg size is " + data.length
+ ", while maximum allowed length is " + MAX_MSG_BODY_SIZE);
}
if (isFull()) {
throw new IllegalStateException("queue is full");
}
int tail = tail();
dataBuf.position(tail);
dataBuf.putInt(data.length);
dataBuf.put(data);
if (tail + MSG_SIZE >= DATA_FILE_SIZE) {
tail(0);
} else {
tail(tail + MSG_SIZE);
}
}
複製程式碼
基本邏輯是:
- 如果訊息太長或佇列滿,丟擲異常。
- 找到佇列尾,定位到佇列尾,寫訊息長度,寫實際資料。
- 更新佇列尾指標,如果已到檔案尾,再從頭開始。
出隊
程式碼為:
public byte[] dequeue() throws IOException {
if (isEmpty()) {
return null;
}
int head = head();
dataBuf.position(head);
int length = dataBuf.getInt();
byte[] data = new byte[length];
dataBuf.get(data);
if (head + MSG_SIZE >= DATA_FILE_SIZE) {
head(0);
} else {
head(head + MSG_SIZE);
}
return data;
}
複製程式碼
基本邏輯是:
- 如果佇列為空,返回null。
- 找到佇列頭,定位到佇列頭,讀訊息長度,讀實際資料。
- 更新佇列頭指標,如果已到檔案尾,再從頭開始。
- 最後返回實際資料
小結
本節介紹了記憶體對映檔案的基本概念及在Java中的的用法,在日常普通的檔案讀寫中,我們用到的比較少,但在一些系統程式中,它卻是經常被用到的一把利器,可以高效的讀寫大檔案,且能實現不同程式間的共享和通訊。
利用記憶體對映檔案,我們設計和實現了一個簡單的訊息佇列,訊息可以持久化,可以實現跨程式的生產者/消費者通訊,我們演示了這個訊息佇列的功能、用法、設計和實現程式碼。
前面幾節,我們多次提到過序列化的概念,它到底是什麼呢?
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。