作者簡介:
路路,熱愛技術、樂於分享的技術人,目前主要從事數據庫相關技術的研究。
前言
今天給大家帶來 DBLE 的內存管理模塊源碼解析文章。
本文主要分爲兩部分,一是內存管理模塊結構,二是內存管理模塊源碼解析。
內存管理模塊結構
DBLE 管理的內存的主要目的有以下兩點:
1.網絡讀寫
2.查詢結果集處理
簡單說明一下這兩點:
第一點比較好理解,網絡讀的時候需要從通道中讀取字節流到內存中,以便進一步處理,網絡寫的時候也需要將字節流從內存中寫入到通道中,以便發送信息。
第二點就比較複雜一些了,DBLE 獲取到 MySQL 端的數據後,會經過 DBLE 然後再發送給客戶端,如果是簡單查詢的話,DBLE 端只是起到一箇中轉的作用,會利用少量內存用於將結果轉發給客戶端。如果是複雜查詢的話,DBLE 會先對結果集進行處理,比如連接、分組排序等操作,然後再將結果發送給客戶端,這裏涉及到的內存使用就複雜了,對於複雜查詢 DBLE 會先使用堆內存,如果使用的堆內存超過限制,則會寫內存映射文件,如果寫內存映射文件個數再次達到上限,則會寫硬盤。是不是很複雜?放心,這一塊本文不會介紹,原因我就不說了(太複雜了)……。
我們看一下 DBLE 官方內存結構圖:
個人覺得上圖有一點理解上的疑惑,DirectByteBufferPool
佔用的內存應該位於物理內存中,JVM 中只是有着堆外內存的引用對象而已。
下圖個人覺得可能更好理解。
結合DBLE管理內存的主要兩個作用,網絡讀寫主要用到了堆外內存,即 DirectByteBufferPool
管理的部分。結果集處理根據情況有所不同,本文只討論簡單情況,對於簡單情況來講,會使用到堆外內存或堆內存,下文源碼中會提到具體情況。
內存管理模塊源碼解析
來看下內存管理模塊涉及到的類:
從上圖可以看出內存管理模塊涉及到的類不多,只有兩個:
1.DirectByteBufferPool
類即爲管理堆外內存的內存池類,可以看到它創建了 ByteBuffePage
類,並且與該類有着一對多的關係;
2.ByteBufferPage
類爲 DBLE 抽象出的堆外內存頁的概念,內存頁中又有內存塊的概念,內存塊是內存分配的最小單元。採用此種內存概念主要是爲了減少內存碎片問題。
DBLE 官方對於這兩個類的內部結構以圖的形式表示的很清楚了:
上圖中的參數 bufferPoolPageNumber
、bufferPoolPageSize
和 bufferPoolChunkSize
可在 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 有所幫助。