1.什麼是DMA
2.什麼是用户態和內核態
3.普通BIO的拷貝流程分析
4.mmap系統函數
5.sendFile系統函數(零拷貝)
6.java堆外內存如何回收
1.什麼是DMA
DMA(Direct Memory Access直接存儲器訪問),我們先從一張圖來了解一下DMA是一個什麼裝置。
假設在什麼沒有DMA的情況下,如果CPU想從內存裏讀取數據併發送到網卡中,在讀的過程中,我們可以知道:
1.1)CPU的速度最快。
1.2)當CPU在內存中讀取數據的時候,讀取的速度瓶頸在於內存的讀寫速度。
1.3)當CPU完成讀取,將數據寫入網卡的時候,寫入的速度瓶頸在於網卡的速度。
1.4)CPU在讀寫的時候,是無法做其它事情的。
這個時候我們就可以得出結論:
1.5)cpu的速度取決於這一系列操作環上最慢的那一個。
1.6)cpu利用率極低,大部分時間都在等待IO。
此時如果有了DMA,那麼我們的讀寫就會變得和如圖一樣:
CPU只需要把讀寫任務委託給DMA,協助CPU搬運數據,這些操作都由DMA自動執行,而不需要依賴於CPU的大量中斷負載,此時cpu就可以去做其它的事情了。
2.什麼是用户態和內核態
其實最後我們的服務器程序,都是要在linux上運行的,Linux根據命令的重要程度,也分為不同的權限。Linux操作系統就將權限分成了2個等級,分別就是用户態和內核態。
用户態:
用户態的進程能夠訪問的資源就有極大的限制,有些指令操作無關痛癢,隨意執行都沒事,大部分都是屬於用户態的。
內核態:
運行在內核態的進程可以“為所欲為”,可以直接調用操作系統內核函數。
比如:我們調用malloc函數申請動態內存,此時cpu就要從用户態切換到內核態,調用操作系統底層函數來申請空間。
3.普通BIO的拷貝流程分析
我們來看一下普通IO的拷貝流程:
我們來看這一段代碼:
我們先從服務器上讀取了一個文件,然後通過連接和流傳輸到請求客户端上,我們可以看到大致的請求流程是這樣的:
當程序或者操作者對CPU發出指令,這些指令和數據暫存在內存裏,在CPU空閒時傳送給CPU,CPU處理後把結果輸出到輸出設備上
3.1)用户態程序接到請求,要從磁盤上讀取文件,切換到內核態,這裏是第1次用户態內核態切換。
3.2)當要讀取的文件通過DMA複製到內核緩衝區的時候,我們還要把這些數據傳送給CPU,CPU之後再把這些數據送到輸出設備上,這裏是第1次cpu拷貝。
3.3)當內核態程序數據讀取完畢,切換回用户態,這裏是第2次內核態用户態切換。
3.4)當程序創建一個緩衝區,並將數據寫入socket緩衝區,這裏是第3次用户態內核態切換。
3.5)此時cpu要把數據拷貝到socket緩衝區,這裏是第2次cpu拷貝。
3.6)完成所有操作之後,應用程序從內核態切換回用户態,繼續執行後續操作(程序到此為止)。這裏是第4次用户態內核態切換。
此時我們可以看出,傳統的IO拷貝流程,經歷了4次用户態和內核態的切換,進行了2次cpu複製,性能耗費巨大,我們有沒有更節省資源的做法呢?
4.mmap系統函數
linux的底層內核函數mmap函數對底層進行了一個優化:
4.1)用户態程序接到請求,要從磁盤上讀取文件,切換到內核態,這裏是第1次用户態內核態切換。
4.2)當要讀取的文件通過DMA複製到內核緩衝區完成,此時內核緩衝區,用户數據緩衝區共享一塊物理內存空間,這裏就無需cpu拷貝到用户空間中。
4.3)此時讀取文件完畢,用户切換回用户態,這是第2次用户態內核態切換。
4.4)申請一塊緩衝區,需要調用內核函數,這是第3次用户態內核態切換。
4.5)內核態通過cpu複製,將共享空間的數據內容拷貝到socket緩衝區中,這是第1次cpu拷貝。
4.6)完成所有操作之後,應用程序從內核態切換回用户態,繼續執行後續操作(程序到此為止)。這裏是第4次用户態內核態切換。
我們可以看出,mmap函數少了一次cpu複製,對於空間的利用率提高了,不過還是需要4次用户態和內核態的切換。
5.sendFile系統函數(零拷貝)
零拷貝:指的是沒有cpu拷貝,數據還是需要通過DMA拷貝到內存中,再發送出去的。
4.1)用户態程序接到請求,要從磁盤上讀取文件,切換到內核態,這裏是第1次用户態內核態切換。
4.2)當數據通過DMA複製進入內核緩衝區並且完成,我們還是通過cpu複製把數據複製到socket緩衝區,不過這裏的cpu複製只複製很少量的內容,可以幾乎忽略不計。
4.3)此時數據通過DMA複製發送給目的地。
4.4)程序切換回用户態,這是第2次用户態內核態切換。
我們發現,sendFile系統函數,只需要兩次用户態到內核態的切換,而且一次cpu複製都不需要,大大節約了資源。
6.java堆外內存如何回收
介紹了零拷貝技術,其實Netty底層是使用堆外內存來實現零拷貝技術的,api:ByteBuffer.allocateDirect(),這條命令直接在堆外內存開闢了一塊空間,我們都知道GC是收集堆內存垃圾的,那堆外內存又是如何收集的呢?
堆外內存的優勢:
堆外內存的優勢在於IO上,java在使用socket發送數據的時候,如果使用堆外內存,就可以直接使用堆外內存往socket上發送數據,就節省了先把堆外數據拷貝到堆內數據的開銷。
我們先來看看ByteBuffer.allocateDirect()的源碼:
我們可以看出,java使用unsafe類來分配了一塊堆外內存
那麼堆外內存是如何回收的呢?我們來看這樣一行代碼:
cleaner就是用來回收堆外內存的,但是它是如何工作的呢?我們仔細研究一下cleaner這個類,它是一個鏈表結構:
通過create(Object,Runnable)方法創建cleaner對象,調用自身的add方法,將其加入鏈表中。
clean有個重要的clean方法, 它首先將對象從自身鏈表中刪除:
然後執行this.thunk的run方法,thunk就是由創建的時候傳入的Runnable函數:
可以看出,run方法是一個釋放堆外內存的函數。
邏輯我們已經梳理完,但是JVM如何釋放其佔用的堆外內存呢?如何跟Cleaner關聯起來?
首先,Cleaner繼承了PhantomReference(虛引用),關於強軟弱虛引用,在前面的博客已經贅述過:深入理解JVM(八)——強軟弱虛引用
簡單地再介紹一下虛引用,當GC某個對象的時候,如果此對象上有虛引用,會將其加入PhantomReference加入到ReferenceQueue隊列。
Cleaner繼承PhantomReference,而PhantomReference又繼承Reference,Reference初始化的時候,會運行一個靜態代碼塊:
我們可以看出,ReferenceHandler作為一個優先級比較高的守護線程被啓動了。
在看他的處理邏輯之前,我們先了解一下對象的四種狀態;
- Active:激活。創建ref對象時就是激活狀態
- Pending:等待入引用隊列。所對應的引用被GC,就要入隊。
-
Enqueued:入隊狀態。
- 如果指定了refQueue消費pending移動到enqueued狀態。refQueue.poll時進入失效狀態
- 如果沒有指定refQueue,直接到失效狀態。
- Inactive:失效
接下來我們可以看業務邏輯了:
這是一個死循環,我們再往裏點:
static boolean tryHandlePending(boolean waitForNotify) {
Reference<Object> r;
Cleaner c;
try {
//可能有多線程對一個引用隊列操作,所以要加鎖
synchronized (lock) {
//如果當前對象是 等待入引用隊列 的狀態
if (pending != null) {
r = pending;
// 'instanceof' might throw OutOfMemoryError sometimes
// so do this before un-linking 'r' from the 'pending' chain...
//轉化為clean對象
c = r instanceof Cleaner ? (Cleaner) r : null;
// unlink 'r' from 'pending' chain
//解除引用
pending = r.discovered;
r.discovered = null;
} else {
//如果沒有,等待喚醒
// The waiting on the lock may cause an OutOfMemoryError
// because it may try to allocate exception objects.
if (waitForNotify) {
lock.wait();
}
// retry if waited
return waitForNotify;
}
}
} catch (OutOfMemoryError x) {
// Give other threads CPU time so they hopefully drop some live references
// and GC reclaims some space.
// Also prevent CPU intensive spinning in case 'r instanceof Cleaner' above
// persistently throws OOME for some time...
Thread.yield();
// retry
return true;
} catch (InterruptedException x) {
// retry
return true;
}
// Fast path for cleaners
//清除內存
if (c != null) {
c.clean();
return true;
}
ReferenceQueue<? super Object> q = r.queue;
if (q != ReferenceQueue.NULL) q.enqueue(r);
return true;
}
我們可以得出:
1)當對象狀態是Pending的時候,就會進入if,將這個對象轉化為clean對象,並將這個引用置空
2)進行clean的垃圾收集
3)這個線程一直在後台啓動,如果有引用,就會喚醒該線程。