博客 / 詳情

返回

Netty網絡編程——NIO與零拷貝

1.什麼是DMA

2.什麼是用户態和內核態

3.普通BIO的拷貝流程分析

4.mmap系統函數

5.sendFile系統函數(零拷貝)

6.java堆外內存如何回收

1.什麼是DMA

DMA(Direct Memory Access直接存儲器訪問),我們先從一張圖來了解一下DMA是一個什麼裝置。

image.png

假設在什麼沒有DMA的情況下,如果CPU想從內存裏讀取數據併發送到網卡中,在讀的過程中,我們可以知道:
1.1)CPU的速度最快
1.2)當CPU在內存中讀取數據的時候,讀取的速度瓶頸在於內存的讀寫速度
1.3)當CPU完成讀取,將數據寫入網卡的時候,寫入的速度瓶頸在於網卡的速度
1.4)CPU在讀寫的時候,是無法做其它事情的。

這個時候我們就可以得出結論:

1.5)cpu的速度取決於這一系列操作環上最慢的那一個。
1.6)cpu利用率極低,大部分時間都在等待IO

此時如果有了DMA,那麼我們的讀寫就會變得和如圖一樣:

image.png

CPU只需要把讀寫任務委託給DMA,協助CPU搬運數據,這些操作都由DMA自動執行,而不需要依賴於CPU的大量中斷負載,此時cpu就可以去做其它的事情了。

2.什麼是用户態和內核態
其實最後我們的服務器程序,都是要在linux上運行的,Linux根據命令的重要程度,也分為不同的權限。Linux操作系統就將權限分成了2個等級,分別就是用户態和內核態

用户態:
用户態的進程能夠訪問的資源就有極大的限制,有些指令操作無關痛癢,隨意執行都沒事,大部分都是屬於用户態的。

內核態:
運行在內核態的進程可以“為所欲為”,可以直接調用操作系統內核函數。

比如:我們調用malloc函數申請動態內存,此時cpu就要從用户態切換到內核態,調用操作系統底層函數來申請空間。

image.png

3.普通BIO的拷貝流程分析

我們來看一下普通IO的拷貝流程

我們來看這一段代碼:
image.png

我們先從服務器上讀取了一個文件,然後通過連接和流傳輸到請求客户端上,我們可以看到大致的請求流程是這樣的:

image.png

當程序或者操作者對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函數對底層進行了一個優化:

image.png

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拷貝到內存中,再發送出去的

image.png
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()的源碼:
image.png
我們可以看出,java使用unsafe類來分配了一塊堆外內存

那麼堆外內存是如何回收的呢?我們來看這樣一行代碼:

image.png

cleaner就是用來回收堆外內存的,但是它是如何工作的呢?我們仔細研究一下cleaner這個類,它是一個鏈表結構:

image.png

通過create(Object,Runnable)方法創建cleaner對象,調用自身的add方法,將其加入鏈表中。
image.png

clean有個重要的clean方法, 它首先將對象從自身鏈表中刪除:
image.png
然後執行this.thunk的run方法,thunk就是由創建的時候傳入的Runnable函數:
image.png
可以看出,run方法是一個釋放堆外內存的函數。

邏輯我們已經梳理完,但是JVM如何釋放其佔用的堆外內存呢如何跟Cleaner關聯起來

首先,Cleaner繼承了PhantomReference(虛引用),關於強軟弱虛引用,在前面的博客已經贅述過:深入理解JVM(八)——強軟弱虛引用

簡單地再介紹一下虛引用,當GC某個對象的時候,如果此對象上有虛引用,會將其加入PhantomReference加入到ReferenceQueue隊列。

Cleaner繼承PhantomReference,而PhantomReference又繼承Reference,Reference初始化的時候,會運行一個靜態代碼塊:
image.png

我們可以看出,ReferenceHandler作為一個優先級比較高的守護線程被啓動了。

在看他的處理邏輯之前,我們先了解一下對象的四種狀態;

  • Active:激活。創建ref對象時就是激活狀態
  • Pending:等待入引用隊列。所對應的引用被GC,就要入隊。
  • Enqueued:入隊狀態。

    • 如果指定了refQueue消費pending移動到enqueued狀態。refQueue.poll時進入失效狀態
    • 如果沒有指定refQueue,直接到失效狀態。
  • Inactive:失效

接下來我們可以看業務邏輯了:
image.png

這是一個死循環,我們再往裏點:

    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)這個線程一直在後台啓動,如果有引用,就會喚醒該線程。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.