計算機程式的思維邏輯 (61) – 記憶體對映檔案及其應用 – 實現一個簡單的訊息佇列

swiftma發表於2019-02-25

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (61) – 記憶體對映檔案及其應用 – 實現一個簡單的訊息佇列

本節介紹記憶體對映檔案,記憶體對映檔案不是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開始,模擬迴圈佇列。
  • 為了區分佇列滿和空的狀態,始終留一個位置不儲存資料,當佇列頭和尾一樣的時候表示佇列為空,當佇列尾的下一個位置是佇列頭的時候表示佇列滿。

基本設計如下圖所示:

計算機程式的思維邏輯 (61) – 記憶體對映檔案及其應用 – 實現一個簡單的訊息佇列

為簡化起見,我們暫不考慮由於併發訪問等引起的一致性問題。

實現訊息佇列

下面來看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);
    }
}
複製程式碼

基本邏輯是:

  1. 如果訊息太長或佇列滿,丟擲異常。
  2. 找到佇列尾,定位到佇列尾,寫訊息長度,寫實際資料。
  3. 更新佇列尾指標,如果已到檔案尾,再從頭開始。

出隊

程式碼為:

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;
}
複製程式碼

基本邏輯是:

  1. 如果佇列為空,返回null。
  2. 找到佇列頭,定位到佇列頭,讀訊息長度,讀實際資料。
  3. 更新佇列頭指標,如果已到檔案尾,再從頭開始。
  4. 最後返回實際資料

小結

本節介紹了記憶體對映檔案的基本概念及在Java中的的用法,在日常普通的檔案讀寫中,我們用到的比較少,但在一些系統程式中,它卻是經常被用到的一把利器,可以高效的讀寫大檔案,且能實現不同程式間的共享和通訊。

利用記憶體對映檔案,我們設計和實現了一個簡單的訊息佇列,訊息可以持久化,可以實現跨程式的生產者/消費者通訊,我們演示了這個訊息佇列的功能、用法、設計和實現程式碼。

前面幾節,我們多次提到過序列化的概念,它到底是什麼呢?


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (61) – 記憶體對映檔案及其應用 – 實現一個簡單的訊息佇列

相關文章