深入理解DirectByteBuffer


介紹

    最近在工作中使用到了DirectBuffer來進行臨時數據的存放,由於使用的是堆外內存,省去了數據到內核的拷貝,因此效率比用ByteBuffer要高不少。之前看過許多介紹DirectBuffer的文章,在這里從源碼的角度上來看一下DirectBuffer的原理。

用戶態和內核態

     Intel的 X86架構下,為了實現外部應用程序與操作系統運行時的隔離,分為了Ring0-Ring3四種級別的運行模式。Linux/Unix只使用了Ring0和Ring3兩個級別。Ring0被稱為用戶態,Ring3被稱為內核態。普通的應用程序只能運行在Ring3,並且不能訪問Ring0的地址空間。操作系統運行在Ring0,並提供系統調用供用戶態的程序使用。如果用戶態的程序的某一個操作需要內核態來協助完成(例如讀取磁盤上的某一段數據),那么用戶態的程序就會通過系統調用來調用內核態的接口,請求操作系統來完成某種操作。
    下圖是用戶態調用內核態的示意圖:

DirectBuffer的創建

    使用下面一行代碼就可以創建一個1024字節的DirectBuffer:

1
ByteBuffer.allocateDirect(1024);

 

    該方法調用的是new DirectByteBuffer(int cap)。DirectByteBuffer的構造函數是包級私有的,因此外部是調用不到的。
下面我們來看一下這行代碼背后的邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned(); //是否頁對齊
int ps = Bits.pageSize(); //獲取pageSize大小
long size = Math.max(1L, (long) cap + (pa ? ps : 0)); //如果是頁對齊的話,那么就加上一頁的大小
Bits.reserveMemory(size, cap); //對分配的直接內存做一個記錄

long base = 0;
try {
base = unsafe.allocateMemory(size); //實際分配內存
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0); //初始化內存
//計算地址
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
//生成Cleaner
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

 

    DirectBuffer的構造函數主要做以下三個事情:
1、根據頁對齊和pageSize來確定本次的要分配內存實際大小
2、實際分配內存,並且記錄分配的內存大小
3、聲明一個Cleaner對象用於清理該DirectBuffer內存

需要注意的是DirectBuffer的創建是比較耗時的,所以在一些高性能的中間件或者應用下一般會做一個對象池,用於重復利用DirectBuffer。

DirectBuffer的使用

    查看DirectBuffer類的方法聲明,對於DirectBuffer的使用主要有兩類方法,putXXX和getXXX。
putXXX方法(以putInt為例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public ByteBuffer putInt(int x) {
putInt(ix(nextPutIndex((1 << 2))), x);
return this;
}

private ByteBuffer putInt(long a, int x) {
if (unaligned) {
int y = (x);
unsafe.putInt(a, (nativeByteOrder ? y : Bits.swap(y)));
} else {
Bits.putInt(a, x, bigEndian);
}
return this;
}

 

    putInt方法會根據是否是內存對齊分別調用unsafe.putInt或者Bits.putInt來把數據放到直接內存中。Bits.putInt實際上會根據是大端或者是小端來區分如何把數據放到直接內存中,放的方式同樣是調用unsage.putInt。

getXXX方法(以getInt為例):

1
2
3
4
5
6
7
8
9
10
public int getInt() {
return getInt(ix(nextGetIndex((1 << 2))));
}
private int getInt(long a) {
if (unaligned) {
int x = unsafe.getInt(a);
return (nativeByteOrder ? x : Bits.swap(x));
}
return Bits.getInt(a, bigEndian);
}

 

    首先判斷是否是頁對齊,如果不是頁對齊,那么直接通過unsafe.getInt來獲取數據;如果是頁對齊,那么通過Bits.getInt方法來獲取數據。Bits.getInt同樣是根據大端還是小端,調用unsafe.getInt來獲取數據。

DirectBuffer內存回收

    DirectBuffer內存回收主要有兩種方式,一種是通過System.gc來回收,另一種是通過構造函數里創建的Cleaner對象來回收。

System.gc回收

    在DirectBuffer的構造函數中,用到了Bit.reserveMemory這個方法,該方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static void reserveMemory(long size, int cap) {
······
if (tryReserveMemory(size, cap)) {
return;
}
······
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}

System.gc();
// a retry loop with exponential back-off delays
// (this gives VM some time to do it's job)
boolean interrupted = false;
try {
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
if (!jlra.tryHandlePendingReference()) {
try {
Thread.sleep(sleepTime);
sleepTime <<= 1;
sleeps++;
} catch (InterruptedException e) {
interrupted = true;
}
}
}
// no luck
throw new OutOfMemoryError("Direct buffer memory");
} finally {
if (interrupted) {
// don't swallow interrupts
Thread.currentThread().interrupt();
}
}
}

 

    reserveMemory方法首先嘗試分配內存,如果分配成功的話,那么就直接退出。如果分配失敗那么就通過調用tryHandlePendingReference來嘗試清理堆外內存(最終調用的是Cleaner的clean方法,其實就是unsafe.freeMemory然后釋放內存),清理完內存之后再嘗試分配內存。如果還是失敗,調用System.gc()來觸發一次FullGC進行回收(前提是沒有加-XX:-+DisableExplicitGC參數)。GC完之后再進行內存分配,失敗的話就會進行sleep,然后再進行嘗試。每次sleep的時間是逐步增加的,規律是1, 2, 4, 8, 16, 32, 64, 128, 256 (total 511 ms ~ 0.5 s)。如果最終還沒有可分配的內存,那么就會拋出OOM異常。
    為什么是通過調用tryHandlePendingReference來回收內存呢?答案是JVM在判斷內存不可達之后會把需要GC的不可達對象放在一個PendingList中,然后應用程序就可以看到這些對象。通過調用tryHandlePendingReference來訪問這些不可達對象。如果不可達對象是Cleaner類型,也就是說關聯了堆外的DirectBuffer,那么該DirectBuffer就可以被回收了,通過調用Cleaner的clean方法來回收這部分堆外內存。
這個邏輯就是進行堆外內存分配時觸發的回收內存邏輯,也就是說在分配的時候如果遇到堆外內存不足,可能會觸發FullGC,然后嘗試進行分配。這也是為什么在一些用到堆外內存的應用中不建議加上-XX:-+DisableExplicitGC參數

Cleaner對象回收

    另個觸發堆外內存回收的時機是通過Cleaner對象的clean方法進行回收。在每次新建一個DirectBuffer對象的時候,會同時創建一個Cleaner對象,同一個進程創建的所有的DirectBuffer對象跟Cleaner對象的個數是一樣的,並且所有的Cleaner對象會組成一個鏈表,前后相連。

1
2
3
4
5
public static Cleaner create(Object ob, Runnable thunk) {
if (thunk == null)
return null;
return add(new Cleaner(ob, thunk));
}

 

Cleaner對象的clean方法執行時機是JVM在判斷該Cleaner對象關聯的DirectBuffer已經不被任何對象引用了(也就是經過可達性分析判定為不可達的時候)。此時Cleaner對象會被JVM掛到PendingList上。然后有一個固定的線程掃描這個List,如果遇到Cleaner對象,那么就執行clean方法。

    DirectBuffer在一些高性能的中間件上使用還是相當廣泛的。正確的使用可以提升程序的性能,降低GC的頻率。

----------------------------------------------------------------------------------------------

歡迎關注我的微信公眾號:yunxi-talk,分享Java干貨,進階Java程序員必備。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM