分佈式 | DBLE 內存管理模塊源碼解讀

作者簡介:
路路,熱愛技術、樂於分享的技術人,目前主要從事數據庫相關技術的研究。

前言

今天給大家帶來 DBLE 的內存管理模塊源碼解析文章。
本文主要分爲兩部分,一是內存管理模塊結構,二是內存管理模塊源碼解析。

內存管理模塊結構

DBLE 管理的內存的主要目的有以下兩點:
1.網絡讀寫
2.查詢結果集處理

簡單說明一下這兩點:
第一點比較好理解,網絡讀的時候需要從通道中讀取字節流到內存中,以便進一步處理,網絡寫的時候也需要將字節流從內存中寫入到通道中,以便發送信息。
第二點就比較複雜一些了,DBLE 獲取到 MySQL 端的數據後,會經過 DBLE 然後再發送給客戶端,如果是簡單查詢的話,DBLE 端只是起到一箇中轉的作用,會利用少量內存用於將結果轉發給客戶端。如果是複雜查詢的話,DBLE 會先對結果集進行處理,比如連接、分組排序等操作,然後再將結果發送給客戶端,這裏涉及到的內存使用就複雜了,對於複雜查詢 DBLE 會先使用堆內存,如果使用的堆內存超過限制,則會寫內存映射文件,如果寫內存映射文件個數再次達到上限,則會寫硬盤。是不是很複雜?放心,這一塊本文不會介紹,原因我就不說了(太複雜了)……。

我們看一下 DBLE 官方內存結構圖:

內存結構概覽

個人覺得上圖有一點理解上的疑惑,DirectByteBufferPool 佔用的內存應該位於物理內存中,JVM 中只是有着堆外內存的引用對象而已。

下圖個人覺得可能更好理解。

內存結構概覽

結合DBLE管理內存的主要兩個作用,網絡讀寫主要用到了堆外內存,即 DirectByteBufferPool 管理的部分。結果集處理根據情況有所不同,本文只討論簡單情況,對於簡單情況來講,會使用到堆外內存或堆內存,下文源碼中會提到具體情況。

內存管理模塊源碼解析

來看下內存管理模塊涉及到的類:

內存管理相關類圖

從上圖可以看出內存管理模塊涉及到的類不多,只有兩個:

1.DirectByteBufferPool 類即爲管理堆外內存的內存池類,可以看到它創建了 ByteBuffePage 類,並且與該類有着一對多的關係;
2.ByteBufferPage 類爲 DBLE 抽象出的堆外內存頁的概念,內存頁中又有內存塊的概念,內存塊是內存分配的最小單元。採用此種內存概念主要是爲了減少內存碎片問題。

DBLE 官方對於這兩個類的內部結構以圖的形式表示的很清楚了:

BufferPool結構

ByteBufferPage結構

上圖中的參數 bufferPoolPageNumberbufferPoolPageSizebufferPoolChunkSize 可在 Server.xml 中配置 ,bufferPoolPageSize 默認爲 2M, bufferPoolPageNumber 默認爲 Java 虛擬機的可用的處理器數量 * 20,bufferPoolChunkSize 的參數默認爲 4k,該參數最好設爲 bufferPoolPageSize 的約數,否則最後一個會造成浪費。

在看源碼前,先看下內存分配的邏輯:

1.如果不指定分配大小,則默認分配一個最小單元(最小單元由 bufferPoolChunkSize 決定);
2.如果指定分配大小,則分配放得下分配大小的最小單元的整數倍(向上取整) ,舉個例子,如果需要 10k 的大小,則在 bufferPoolChunkSize 參數默認爲 4k 的情況下,會分配 3 個內存 chunk;
3.分配邏輯爲:
(1)遍歷緩衝池從 N+1 頁到 bufferPoolPageNumber -1 頁(上次分配過的記爲第 N 頁),然後對單頁加鎖在每個頁中從頭尋找未被使用的連續 M 個最小單元 ;
(2)如果沒找到,再從第 0 頁找到第 N 頁;
(3)成功分配內存後更新上次分配頁,標記內存頁中分配的單元;
(4)如果找不到可存放的單頁(比如大於 bufferPoolPageSize ),直接分配 On-Heap 內存。

下面我們就開始看看對應的源碼。

內存池初始化的代碼調用在 DbleServer#startup 方法中:

//通過配置的相關參數初始化內存池
bufferPool = new DirectByteBufferPool(bufferPoolPageSize, bufferPoolChunkSize, bufferPoolPageNumber);

看下內存池初始化邏輯,代碼在 DirectByteBufferPool 類的構造方法中:

public DirectByteBufferPool(int pageSize, short chunkSize, short pageCount) {
        //初始化內存總頁數
        allPages = new ByteBufferPage[pageCount];
        //內存頁中的內存塊大小
        this.chunkSize = chunkSize;
        //每個內存頁的大小
        this.pageSize = pageSize;
        //內存頁總數
        this.pageCount = pageCount;
        //記錄上次分配過的頁
        prevAllocatedPage = new AtomicInteger(0);
        //循環初始化內存頁
        for (int i = 0; i < pageCount; i++) {
            allPages[i] = new ByteBufferPage(ByteBuffer.allocateDirect(pageSize), chunkSize);
        }
        //記錄堆外內存的使用大小
        memoryUsage = new ConcurrentHashMap<>();
    }

繼續看下內存頁的初始化邏輯,在 ByteBufferPage 類的構造方法中:

public ByteBufferPage(ByteBuffer buf, int chunkSize) {
    //內存塊大小
    this.chunkSize = chunkSize;
    //計算總內存塊數
    chunkCount = buf.capacity() / chunkSize;
    //非常巧妙的位圖結構,用於標記內存塊的使用情況
    chunkAllocateTrack = new BitSet(chunkCount);
    //內存頁的大小
    this.buf = buf;
}

上述代碼完成了堆外內存的初始化。

下面讓我們看看內存的分配邏輯代碼。

分配內存的操作主要在 DirectByteBufferPool#allocate 方法中:

public ByteBuffer allocate(int size) {
        //計算需要分配的chunk數
        final int theChunkCount = size / chunkSize + (size % chunkSize == 0 ? 0 : 1);
        //本次分配的開始頁數,爲上次分配過的頁數(N)+1
        int selectedPage = prevAllocatedPage.incrementAndGet() % allPages.length;
        //從N+1頁到bufferPoolPageNumber-1頁遍歷分配,allocateBuffer方法下面會進一步分析
        ByteBuffer byteBuf = allocateBuffer(theChunkCount, selectedPage, allPages.length);
        //沒有分配成功,則從0頁到N頁繼續遍歷分配
        if (byteBuf == null) {
            byteBuf = allocateBuffer(theChunkCount, 0, selectedPage);
        }
        final long threadId = Thread.currentThread().getId();
        //分配成功的話,則記錄分配到的內存大小
        if (byteBuf != null) {
            if (memoryUsage.containsKey(threadId)) {
                memoryUsage.put(threadId, memoryUsage.get(threadId) + byteBuf.capacity());
            } else {
                memoryUsage.put(threadId, (long) byteBuf.capacity());
            }
        }
        //如果遍歷完所有頁還沒有分配成功,則直接分配堆上內存
        if (byteBuf == null) {
            //ByteBuffer.allocate方法爲分配JVM堆內存
            return ByteBuffer.allocate(size);
        }
        return byteBuf;
    }

繼續看下 DirectByteBufferPool#allocateBuffer 方法:

private ByteBuffer allocateBuffer(int theChunkCount, int startPage, int endPage) {
        //從指定頁數開始遍歷分配內存,分配成功則標記當前分配的頁數,然後直接返回
        for (int i = startPage; i < endPage; i++) {
            //調用了ByteBufferPage類的allocateChunk方法進行內存塊的分配,該方法下面也會進一步分析
            ByteBuffer buffer = allPages[i].allocateChunk(theChunkCount);
            if (buffer != null) {
                prevAllocatedPage.getAndSet(i);
                return buffer;
            }
        }
        return null;
    }

繼續看一下 ByteBufferPage#allocateChunk 方法,該方法比較長,分配頁中的連續內存塊邏輯就在此方法中:

public ByteBuffer allocateChunk(int theChunkCount) {
    //對頁加狀態鎖,防止併發操作異常
    if (!allocLockStatus.compareAndSet(false, true)) {
        return null;
    }
    int startChunk = -1;
    int continueCount = 0;
    try {
       //下面的邏輯爲在頁中找到符合內存分配大小的連續內存塊
        for (int i = 0; i < chunkCount; i++) {
            if (!chunkAllocateTrack.get(i)) {
                if (startChunk == -1) {
                    startChunk = i;
                    continueCount = 1;
                    if (theChunkCount == 1) {
                        break;
                    }
                } else {
                    if (++continueCount == theChunkCount) {
                        break;
                    }
                }
            } else {
                startChunk = -1;
                continueCount = 0;
            }
        }
        //成功找到後則返回分配的內存塊,並且標記相應的內存塊位置
        if (continueCount == theChunkCount) {
            int offStart = startChunk * chunkSize;
            int offEnd = offStart + theChunkCount * chunkSize;
            buf.limit(offEnd);
            buf.position(offStart);

            ByteBuffer newBuf = buf.slice();
            markChunksUsed(startChunk, theChunkCount);
            return newBuf;
        } else {
            //分配失敗返回null
            return null;
        }
    } finally {
        //解鎖
        allocLockStatus.set(false);
    }
}

到這裏,內存分配的邏輯就大概講完了。

有分配也得有回收,爲了文章的完整性,內存回收也順帶講一下。

內存回收邏輯比較簡單,分兩種情況,如果是分配的堆內存,則直接 clear 等待 GC 回收,如果是堆外內存,則遍歷所有頁,找到對應頁然後加鎖,找到對應塊的位置,標記爲未使用就可以了。

內存回收的主要代碼在 DirectByteBufferPool#recycle 方法中:

public void recycle(ByteBuffer theBuf) {
        //堆內存的回收
        if (!(theBuf instanceof DirectBuffer)) {
            theBuf.clear();
            return;
        }

        final long size = theBuf.capacity();
        //堆外內存的回收
        boolean recycled = false;
        DirectBuffer thisNavBuf = (DirectBuffer) theBuf;
        int chunkCount = theBuf.capacity() / chunkSize;
        DirectBuffer parentBuf = (DirectBuffer) thisNavBuf.attachment();
        int startChunk = (int) ((thisNavBuf.address() - parentBuf.address()) / this.chunkSize);
        //遍歷頁然後將對應塊標記爲未使用即可
        for (ByteBufferPage allPage : allPages) {
            if ((recycled = allPage.recycleBuffer((ByteBuffer) parentBuf, startChunk, chunkCount))) {
                break;
            }
        }
        final long threadId = Thread.currentThread().getId();

        if (memoryUsage.containsKey(threadId)) {
            memoryUsage.put(threadId, memoryUsage.get(threadId) - size);
        }
        if (!recycled) {
            LOGGER.info("warning ,not recycled buffer " + theBuf);
        }
    }

內存的分配與回收到這裏也講完了,還記得開始時候說的內存的主要作用嗎?一個是網絡讀寫時使用,一個是結果集暫存時使用。

網絡讀寫時使用的例子可以參考這裏,NIOSocketWR#asyncRead 方法:

public void asyncRead() throws IOException {
    ByteBuffer theBuffer = con.readBuffer;
    if (theBuffer == null) {
        //這裏分配了從通道讀取數據時候的內存
        theBuffer = con.processor.getBufferPool().allocate(con.processor.getBufferPool().getChunkSize());
        con.readBuffer = theBuffer;
    }
    int got = channel.read(theBuffer);
    con.onReadData(got);
}

暫存結果集使用的例子可以參考這裏,SingleNodeHandler#rowResponse 方法:

//省略無關內容
......
//這裏分配內存,將結果集寫入,然後待發送到客戶端
buffer = session.getSource().writeToBuffer(row, allocBuffer());
......

當然內存分配的代碼調用遠不止上面舉例的兩處,這裏只是和內存管理的作用做個呼應。其他地方大家可以自己看。

總結

DBLE 的內存管理模塊源碼閱讀的內容如上所述,希望能夠對大家更深入的理解 DBLE 有所幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章