国产 无码 综合区,色欲AV无码国产永久播放,无码天堂亚洲国产AV,国产日韩欧美女同一区二区

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

這篇具有很好參考價值的文章主要介紹了MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異。希望對大家有所幫助。如果存在錯誤或未考慮完全的地方,請大家不吝賜教,您也可以點擊"舉報違法"按鈕提交疑問。

本文基于 Linux 內(nèi)核 5.4 版本進行討論

自上篇文章《從 Linux 內(nèi)核角度探秘 JDK MappedByteBuffer》 發(fā)布之后,很多讀者朋友私信我說,文章的信息量太大了,其中很多章節(jié)介紹的內(nèi)容都是大家非常想要了解,并且是頻繁被搜索的內(nèi)容,所以根據(jù)讀者朋友的建議,筆者決定將一些重要的章節(jié)內(nèi)容獨立出來,更好的方便大家檢索。

關(guān)于 MappedByteBuffer 和 FileChannel 的話題,網(wǎng)上有很多,但大部分都在討論 MappedByteBuffer 相較于傳統(tǒng) FileChannel 的優(yōu)勢,但好像很少有人來寫一寫 MappedByteBuffer 的劣勢,所以筆者這里想寫一點不一樣的,來和大家討論討論 MappedByteBuffer 的劣勢有哪些。

但在開始討論這個話題之前,筆者想了想還是不能免俗,仍然需要把 MappedByteBuffer 和 FileChannel 放在一起從頭到尾對比一下,基于這個思路,我們先來重新簡要梳理一下 FileChannel 和 MappedByteBuffer 讀寫文件的流程。

1. FileChannel 讀寫文件過程

在之前的文章《從 Linux 內(nèi)核角度探秘 JDK NIO 文件讀寫本質(zhì)》中,由于當時我們還未介紹 DirectByteBuffer 以及 MappedByteBuffer,所以筆者以 HeapByteBuffer 為例來介紹 FileChannel 讀寫文件的整個源碼實現(xiàn)邏輯。

當我們使用 HeapByteBuffer 傳入 FileChannel 的 read or write 方法對文件進行讀寫時,JDK 會首先創(chuàng)建一個臨時的 DirectByteBuffer,對于 FileChannel#read 來說,JDK 在 native 層會將 read 系統(tǒng)調(diào)用從文件中讀取的內(nèi)容首先存放到這個臨時的 DirectByteBuffer 中,然后在拷貝到 HeapByteBuffer 中返回。

對于 FileChannel#write 來說,JDK 會首先將 HeapByteBuffer 中的待寫入數(shù)據(jù)拷貝到臨時的 DirectByteBuffer 中,然后在 native 層通過 write 系統(tǒng)調(diào)用將 DirectByteBuffer 中的數(shù)據(jù)寫入到文件的 page cache 中。

public class IOUtil {

   static int read(FileDescriptor fd, ByteBuffer dst, long position,
                    NativeDispatcher nd)
        throws IOException
    {
        // 如果我們傳入的 dst 是 DirectBuffer,那么直接進行文件的讀取
        // 將文件內(nèi)容讀取到 dst 中
        if (dst instanceof DirectBuffer)
            return readIntoNativeBuffer(fd, dst, position, nd);
  
        // 如果我們傳入的 dst 是一個 HeapBuffer,那么這里就需要創(chuàng)建一個臨時的 DirectBuffer
        // 在調(diào)用 native 方法底層利用 read  or write 系統(tǒng)調(diào)用進行文件讀寫的時候
        // 傳入的只能是 DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
        try {
            // 底層通過 read 系統(tǒng)調(diào)用將文件內(nèi)容拷貝到臨時 DirectBuffer 中
            int n = readIntoNativeBuffer(fd, bb, position, nd);    
            if (n > 0)
                // 將臨時 DirectBuffer 中的文件內(nèi)容在拷貝到 HeapBuffer 中返回
                dst.put(bb);
            return n;
        }
    }

    static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd) throws IOException
    {
        // 如果傳入的 src 是 DirectBuffer,那么直接將 DirectBuffer 中的內(nèi)容拷貝到文件 page cache 中
        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd);
        // 如果傳入的 src 是 HeapBuffer,那么這里需要首先創(chuàng)建一個臨時的 DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            // 首先將 HeapBuffer 中的待寫入內(nèi)容拷貝到臨時的 DirectBuffer 中
            // 隨后通過 write 系統(tǒng)調(diào)用將臨時 DirectBuffer 中的內(nèi)容寫入到文件 page cache 中
            int n = writeFromNativeBuffer(fd, bb, position, nd);     
            return n;
        } 
    }
}

當時有很多讀者朋友給我留言提問說,為什么必須要在 DirectByteBuffer 中做一次中轉(zhuǎn),直接將 HeapByteBuffer 傳給 native 層不行嗎 ?

答案是肯定不行的,在本文開頭筆者為大家介紹過 JVM 進程的虛擬內(nèi)存空間布局,如下圖所示:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

HeapByteBuffer 和 DirectByteBuffer 從本質(zhì)上來說均是 JVM 進程地址空間內(nèi)的一段虛擬內(nèi)存,對于 Java 程序來說 HeapByteBuffer 被用來特定表示 JVM 堆中的內(nèi)存,而 DirectByteBuffer 就是一個普通的 C++ 程序通過 malloc 系統(tǒng)調(diào)用向操作系統(tǒng)申請的一段 Native Memory 位于 JVM 堆之外。

既然 HeapByteBuffer 是位于 JVM 堆中的內(nèi)存,那么它必然會受到 GC 的管理,當發(fā)生 GC 的時候,如果我們選擇的垃圾回收器采用的是 Mark-Copy 或者 Mark-Compact 算法的時候(Mark-Swap 除外),GC 會來回移動存活的對象,這就導(dǎo)致了存活的 Java 對象比如這里的 HeapByteBuffer 在 GC 之后它背后的內(nèi)存地址可能已經(jīng)發(fā)生了變化。

而 JVM 中的這些 native 方法是處于 safepoint 之下的,執(zhí)行 native 方法的線程由于是處于 safepoint 中,所以在執(zhí)行 native 方法的過程中可能會有 GC 的發(fā)生。

如果我們把一個 HeapByteBuffer 傳遞給 native 層進行文件讀寫的時候不巧發(fā)生了 GC,那么 HeapByteBuffer 背后的內(nèi)存地址就會變化,這樣一來,如果我們在讀取文件的話,內(nèi)核將會把文件內(nèi)容拷貝到另一個內(nèi)存地址中。如果我們在寫入文件的話,內(nèi)核將會把另一個內(nèi)存地址中的內(nèi)存寫入到文件的 page cache 中。

所以我們在通過 native 方法執(zhí)行相關(guān)系統(tǒng)調(diào)用的時候必須要保證傳入的內(nèi)存地址是不會變化的,由于 DirectByteBuffer 背后所依賴的 Native Memory 位于 JVM 堆之外,是不會受到 GC 管理的,因此不管發(fā)不發(fā)生 GC,DirectByteBuffer 所引用的這些 Native Memory 地址是不會發(fā)生變化的。

所以我們在調(diào)用 native 方法進行文件讀寫的時候需要傳入 DirectByteBuffer,如果我們用得是 HeapByteBuffer ,那么就需要一個臨時的 DirectByteBuffer 作為中轉(zhuǎn)。

這時可能有讀者朋友又會問了,我們在使用 HeapByteBuffer 通過 FileChannel#write 對文件進行寫入的時候,首先會將 HeapByteBuffer 中的內(nèi)容拷貝到臨時的 DirectByteBuffer 中,那如果在這個拷貝的過程中發(fā)生了 GC,HeapByteBuffer 背后引用內(nèi)存的地址發(fā)生了變化,那么拷貝到 DirectByteBuffer 中的內(nèi)容仍然是錯的啊。

事實上在這個拷貝的過程中是不會發(fā)生 GC 的,因為 JVM 這里會使用 Unsafe#copyMemory 方法來實現(xiàn) HeapByteBuffer 到 DirectByteBuffer 的拷貝操作,copyMemory 被 JVM 實現(xiàn)為一個 intrinsic 方法,中間是沒有 safepoint 的,執(zhí)行 copyMemory 的線程由于不在 safepoint 中,所以在拷貝的過程中是不會發(fā)生 GC 的。

public final class Unsafe {
  // intrinsic 方法
  public native void copyMemory(Object srcBase, long srcOffset,
                                  Object destBase, long destOffset,
                                  long bytes);  
}

在交代完這個遺留的問題之后,下面我們就以 DirectByteBuffer 為例來重新簡要回顧下傳統(tǒng) FileChannel 對文件的讀寫流程:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

  1. 當 JVM 在 native 層使用 read 系統(tǒng)調(diào)用進行文件讀取的時候,JVM 進程會發(fā)生第一次上下文切換,從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)。

  2. 隨后 JVM 進程進入虛擬文件系統(tǒng)層,在這一層內(nèi)核首先會查看讀取文件對應(yīng)的 page cache 中是否含有請求的文件數(shù)據(jù),如果有,那么直接將文件數(shù)據(jù)拷貝到 DirectByteBuffer 中返回,避免一次磁盤 IO。并根據(jù)內(nèi)核預(yù)讀算法從磁盤中異步預(yù)讀若干文件數(shù)據(jù)到 page cache 中

  3. 如果請求的文件數(shù)據(jù)不在 page cache 中,則會進入具體的文件系統(tǒng)層,在這一層內(nèi)核會啟動磁盤塊設(shè)備驅(qū)動觸發(fā)真正的磁盤 IO。并根據(jù)內(nèi)核預(yù)讀算法同步預(yù)讀若干文件數(shù)據(jù)。請求的文件數(shù)據(jù)和預(yù)讀的文件數(shù)據(jù)將被一起填充到 page cache 中。

  4. 磁盤控制器 DMA 將從磁盤中讀取的數(shù)據(jù)拷貝到頁高速緩存 page cache 中。發(fā)生第一次數(shù)據(jù)拷貝

  5. 由于 page cache 是屬于內(nèi)核空間的,不能被 JVM 進程直接尋址,所以還需要 CPU 將 page cache 中的數(shù)據(jù)拷貝到位于用戶空間的 DirectByteBuffer 中,發(fā)生第二次數(shù)據(jù)拷貝。

  6. 最后 JVM 進程從系統(tǒng)調(diào)用 read 中返回,并從內(nèi)核態(tài)切換回用戶態(tài)。發(fā)生第二次上下文切換

從以上過程我們可以看到,當使用 FileChannel#read 對文件讀取的時候,如果文件數(shù)據(jù)在 page cache 中,涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換是主要的性能開銷點。

下面是通過 FileChannel#write 寫入文件的整個過程:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

  1. 當 JVM 在 native 層使用 write 系統(tǒng)調(diào)用進行文件寫入的時候,JVM 進程會發(fā)生第一次上下文切換,從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)。

  2. 進入內(nèi)核態(tài)之后,JVM 進程在虛擬文件系統(tǒng)層調(diào)用 vfs_write 觸發(fā)對 page cache 寫入的操作。內(nèi)核調(diào)用 iov_iter_copy_from_user_atomic 函數(shù)將 DirectByteBuffer 中的待寫入數(shù)據(jù)拷貝到 page cache 中。發(fā)生第一次拷貝動作( CPU 拷貝)。

  3. 當待寫入數(shù)據(jù)拷貝到 page cache 中時,內(nèi)核會將對應(yīng)的文件頁標記為臟頁,內(nèi)核會根據(jù)一定的閾值判斷是否要對 page cache 中的臟頁進行回寫,如果不需要同步回寫,進程直接返回。這里發(fā)生第二次上下文切換

  4. 臟頁回寫又會根據(jù)臟頁數(shù)量在內(nèi)存中的占比分為:進程同步回寫和內(nèi)核異步回寫。當臟頁太多了,進程自己都看不下去的時候,會同步回寫內(nèi)存中的臟頁,直到回寫完畢才會返回。在回寫的過程中會發(fā)生第二次拷貝(DMA 拷貝)。

從以上過程我們可以看到,當使用 FileChannel#write 對文件寫入的時候,如果不考慮臟頁回寫的情況,單純對于 JVM 這個進程來說涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換仍然是主要的性能開銷點。

2. MappedByteBuffer 讀寫文件過程

下面我們來看下通過 MappedByteBuffer 對文件進行讀寫的過程:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

首先我們需要通過 FileChannel#map 將文件的某個區(qū)域映射到 JVM 進程的虛擬內(nèi)存空間中,從而獲得一段文件映射的虛擬內(nèi)存區(qū)域 MappedByteBuffer。由于底層使用到了 mmap 系統(tǒng)調(diào)用,所以這個過程也涉及到了兩次上下文切換。

如上圖所示,當 MappedByteBuffer 在剛剛映射出來的時候,它只是進程地址空間中的一段虛擬內(nèi)存,其對應(yīng)在進程頁表中的頁表項還是空的,背后還沒有映射物理內(nèi)存。此時映射文件對應(yīng)的 page cache 也是空的,我們要映射的文件內(nèi)容此時還靜靜地躺在磁盤中。

當 JVM 進程開始對 MappedByteBuffer 進行讀寫的時候,就會觸發(fā)缺頁中斷,內(nèi)核會將映射的文件內(nèi)容從磁盤中加載到 page cache 中,然后在進程頁表中建立 MappedByteBuffer 與 page cache 的映射關(guān)系。由于這里涉及到了缺頁中斷的處理,因此也會有兩次上下文切換的開銷。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

后面 JVM 進程對 MappedByteBuffer 的讀寫就相當于是直接讀寫 page cache 了,關(guān)于這一點,很多讀者朋友會有這樣的疑問:page cache 是內(nèi)核態(tài)的部分,為什么我們通過用戶態(tài)的 MappedByteBuffer 就可以直接訪問內(nèi)核態(tài)的東西了?

這里大家不要被內(nèi)核態(tài)這三個字給唬住了,雖然 page cache 是屬于內(nèi)核部分的,但其本質(zhì)上還是一塊普通的物理內(nèi)存,想想我們是怎么訪問內(nèi)存的 ? 不就是先有一段虛擬內(nèi)存,然后在申請一段物理內(nèi)存,最后通過進程頁表將虛擬內(nèi)存和物理內(nèi)存映射起來么,進程在訪問虛擬內(nèi)存的時候,通過頁表找到其映射的物理內(nèi)存地址,然后直接通過物理內(nèi)存地址訪問物理內(nèi)存。

回到我們討論的內(nèi)容中,這段虛擬內(nèi)存不就是 MappedByteBuffer 嗎,物理內(nèi)存就是 page cache 啊,在通過頁表映射起來之后,進程在通過 MappedByteBuffer 訪問 page cache 的過程就和訪問普通內(nèi)存的過程是一模一樣的。

也正因為 MappedByteBuffer 背后映射的物理內(nèi)存是內(nèi)核空間的 page cache,所以它不會消耗任何用戶空間的物理內(nèi)存(JVM 的堆外內(nèi)存),因此也不會受到 -XX:MaxDirectMemorySize 參數(shù)的限制。

3. MappedByteBuffer VS FileChannel

現(xiàn)在我們已經(jīng)清楚了 FileChannel 以及 MappedByteBuffer 進行文件讀寫的整個過程,下面我們就來把兩種文件讀寫方式放在一起來對比一下,但這里有一個對比的前提:

  • 對于 MappedByteBuffer 來說,我們對比的是其在缺頁處理之后,讀寫文件的開銷。

  • 對于 FileChannel 來說,我們對比的是文件數(shù)據(jù)已經(jīng)存在于 page cache 中的情況下讀寫文件的開銷。

因為筆者認為只有基于這個前提來對比兩者的性能差異才有意義。

  • 對于 FileChannel 來說,無論是通過 read 方法對文件的讀取,還是通過 write 方法對文件的寫入,它們都需要兩次上下文切換,以及一次 CPU 拷貝,其中上下文切換是其主要的性能開銷點。

  • 對于 MappedByteBuffer 來說,由于其背后直接映射的就是 page cache,讀寫 MappedByteBuffer 本質(zhì)上就是讀寫 page cache,整個讀寫過程和讀寫普通的內(nèi)存沒有任何區(qū)別,因此沒有上下文切換的開銷,不會切態(tài),更沒有任何拷貝

從上面的對比我們可以看出使用 MappedByteBuffer 來讀寫文件既沒有上下文切換的開銷,也沒有數(shù)據(jù)拷貝的開銷(可忽略),簡直是完爆 FileChannel。

既然 MappedByteBuffer 這么屌,那我們何不干脆在所有文件的讀寫場景中全部使用 MappedByteBuffer,這樣豈不省事 ?JDK 為何還保留了 FileChannel 的 read , write 方法呢 ?讓我們來帶著這個疑問繼續(xù)下面的內(nèi)容~~

4. 通過 Benchmark 從內(nèi)核層面對比兩者的性能差異

到現(xiàn)在為止,筆者已經(jīng)帶著大家完整的剖析了 mmap,read,write 這些系統(tǒng)調(diào)用在內(nèi)核中的源碼實現(xiàn),并基于源碼對 MappedByteBuffer 和 FileChannel 兩者進行了性能開銷上的對比。

雖然祭出了源碼,但畢竟還是 talk is cheap,本小節(jié)我們就來對兩者進行一次 Benchmark,來看一下 MappedByteBuffer 與 FileChannel 對文件讀寫的實際性能表現(xiàn)如何 ? 是否和我們從源碼中分析的結(jié)果一致。

我們從兩個方面來對比 MappedByteBuffer 和 FileChannel 的文件讀寫性能:

  • 文件數(shù)據(jù)完全加載到 page cache 中,并且將 page cache 鎖定在內(nèi)存中,不允許 swap,MappedByteBuffer 不會有缺頁中斷,F(xiàn)ileChannel 不會觸發(fā)磁盤 IO 都是直接對 page cache 進行讀寫。

  • 文件數(shù)據(jù)不在 page cache 中,我們加上了 缺頁中斷,磁盤IO,以及 swap 對文件讀寫的影響。

具體的測試思路是,用 MappedByteBuffer 和 FileChannel 分別以
64B ,128B ,512B ,1K ,2K ,4K ,8K ,32K ,64K ,1M ,32M ,64M ,512M 為單位依次對 1G 大小的文件進行讀寫,從以上兩個方面對比兩者在不同讀寫單位下的性能表現(xiàn)。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

需要提醒大家的是本小節(jié)中得出的讀寫性能具體數(shù)值是沒有參考價值的,因為不同軟硬件環(huán)境下測試得出的具體性能數(shù)值都不一樣,值得參考的是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集大小下的讀寫性能趨勢走向。筆者的軟硬件測試環(huán)境如下:

  • 處理器:2.5 GHz 四核Intel Core i7
  • 內(nèi)存:16 GB 1600 MHz DDR3
  • SSD:APPLE SSD SM0512F
  • 操作系統(tǒng):macOS
  • JVM:OpenJDK 17

測試代碼:https://github.com/huibinliupush/benchmark , 大家也可以在自己的測試環(huán)境中運行一下,然后將跑出的結(jié)果提交到這個倉庫中。這樣方便大家在不同的測試環(huán)境下對比兩者的文件讀寫性能差異 —— 眾人拾柴火焰高。

4.1 文件數(shù)據(jù)在 page cache 中

由于這里我們要測試 MappedByteBuffer 和 FileChannel 直接對 page cache 的讀寫性能,所以筆者讓 MappedByteBuffer ,F(xiàn)ileChannel 只針對同一個文件進行讀寫測試。

在對文件進行讀寫之前,首先通過 mlock 系統(tǒng)調(diào)用將文件數(shù)據(jù)提前加載到 page cache 中并主動觸發(fā)缺頁處理,在進程頁表中建立好 MappedByteBuffer 和 page cache 的映射關(guān)系。最后將 page cache 鎖定在內(nèi)存中不允許 swap。

下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的讀取性能測試:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

運行結(jié)果如下:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的性能差異,筆者根據(jù)上面跑出的性能數(shù)據(jù)繪制成下面這幅柱狀圖,方便大家觀察兩者的性能趨勢走向。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

這里我們可以看出,MappedByteBuffer 在 4K 之前具有明顯的壓倒性優(yōu)勢,在 [8K , 32M] 這個區(qū)間內(nèi),MappedByteBuffer 依然具有優(yōu)勢但已經(jīng)不是十分明顯了,從 64M 開始 FileChannel 實現(xiàn)了一點點反超。

我們可以得到的性能趨勢是,在 [64B, 2K] 這個單次讀取數(shù)據(jù)量級范圍內(nèi),MappedByteBuffer 讀取的性能越來越快,并在 2K 這個數(shù)據(jù)量級下達到了性能最高值,僅消耗了 73 ms。從 4K 開始讀取性能在一點一點的逐漸下降,并在 64M 這個數(shù)據(jù)量級下被 FileChannel 反超。

而 FileChannel 的讀取性能會隨著數(shù)據(jù)量的增大反而越來越好,并在某一個數(shù)據(jù)量級下性能會反超 MappedByteBuffer。FileChannel 的最佳讀取性能點是在 64K 處,消耗了 167ms 。

因此 MappedByteBuffer 適合頻繁讀取小數(shù)據(jù)量的場景,具體多小,需要大家根據(jù)自己的環(huán)境進行測試,本小節(jié)我們得出的數(shù)據(jù)是 4K 以下。

FileChannel 適合大數(shù)據(jù)量的批量讀取場景,具體多大,還是需要大家根據(jù)自己的環(huán)境進行測試,本小節(jié)我們得出的數(shù)據(jù)是 64M 以上。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的寫入性能測試:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

運行結(jié)果如下:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的寫入性能的趨勢走向柱狀圖:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

這里我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優(yōu)勢,它的寫入性能趨勢是在 [64B , 8K] 這個數(shù)據(jù)集方位內(nèi),寫入性能隨著數(shù)據(jù)量的增大而越來越快,直到在 8K 這個數(shù)據(jù)集下達到了最佳寫入性能。

而在 [32K, 32M] 這個數(shù)據(jù)集范圍內(nèi),MappedByteBuffer 仍然具有優(yōu)勢,但已經(jīng)不是十分明顯了,最終在 64M 這個數(shù)據(jù)集下被 FileChannel 反超。

和前面的讀取性能趨勢一樣,F(xiàn)ileChannel 的寫入性能也是隨著數(shù)據(jù)量的增大反而越來越好,最佳的寫入性能是在 64K 處,僅消耗了 160 ms 。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

4.2 文件數(shù)據(jù)不在 page cache 中

在這一小節(jié)中,我們將缺頁中斷和磁盤 IO 的影響加入進來,不添加任何的優(yōu)化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對文件讀寫的性能。

為了避免被 page cache 影響,所以我們需要在每一個測試數(shù)據(jù)集下,單獨分別為 MappedByteBuffer 和 FileChannel 創(chuàng)建各自的測試文件。

下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對文件的讀取性能測試:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

運行結(jié)果:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

從這里我們可以看到,在加入了缺頁中斷和磁盤 IO 的影響之后,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁盤 IO 的影響下在 [64B , 512B] 這個數(shù)據(jù)集范圍內(nèi)比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個數(shù)據(jù)集范圍內(nèi)比之前平均多出了 100 ms 的開銷。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

在 2K 之前, MappedByteBuffer 具有明顯的讀取性能優(yōu)勢,最佳的讀取性能出現(xiàn)在 512B 這個數(shù)據(jù)集下,從 512B 往后,MappedByteBuffer 的讀取性能趨勢總體成下降趨勢,并在 4K 這個地方被 FileChannel 反超。

FileChannel 則是在 [64B, 1M] 這個數(shù)據(jù)集范圍內(nèi),讀取性能會隨著數(shù)據(jù)集的增大而提高,并在 1M 這個地方達到了 FileChannel 的最佳讀取性能,僅消耗了 258 ms,在 [32M , 512M] 這個范圍內(nèi) FileChannel 的讀取性能在逐漸下降,但是比 MappedByteBuffer 的性能高出了一倍。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

讀到這里大家不禁要問了,理論上來講 MappedByteBuffer 應(yīng)該是完爆 FileChannel 才對啊,因為 MappedByteBuffer 沒有系統(tǒng)調(diào)用的開銷,為什么性能在后面反而被 FileChannel 超越了近一倍之多呢 ?

要明白這個問題,我們就需要分別把 MappedByteBuffer 和 FileChannel 在讀寫文件時候所涉及到的性能開銷點一一列舉出來,并對這些性能開銷點進行詳細對比,這樣答案就有了。

首先 MappedByteBuffer 的主要性能開銷是在缺頁中斷,而 FileChannel 的主要開銷是在系統(tǒng)調(diào)用,兩者都會涉及上下文的切換。

FileChannel 在讀寫文件的時候有磁盤IO,有預(yù)讀。同樣 MappedByteBuffer 的缺頁中斷也有磁盤IO 也有預(yù)讀。目前來看他倆一比一打平。

但別忘了 MappedByteBuffer 是需要進程頁表支持的,在實際訪問內(nèi)存的過程中會遇到頁表競爭以及 TLB shootdown 等問題。還有就是 MappedByteBuffer 剛剛被映射出來的時候,其在進程頁表中對應(yīng)的各級頁表以及頁目錄可能都是空的。所以缺頁中斷這里需要做的一件非常重要的事情就是補齊完善 MappedByteBuffer 在進程頁表中對應(yīng)的各級頁目錄表和頁表,并在頁表項中將 page cache 映射起來,最后還要刷新 TLB 等硬件緩存。

想更多了解缺頁中斷細節(jié)的讀者可以看下之前的文章——
《一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults》

而 FileChannel 并不會涉及上面的這些開銷,所以 MappedByteBuffer 的缺頁中斷要比 FileChannel 的系統(tǒng)調(diào)用開銷要大,這一點我們可以在上小節(jié)和本小節(jié)的讀寫性能對比中看得出來。

文件數(shù)據(jù)在 page cache 中與不在 page cache 中,MappedByteBuffer 前后的讀取性能平均差了 500 ms,而 FileChannel 前后卻只平均差了 100 ms。

MappedByteBuffer 的缺頁中斷是平均每 4K 觸發(fā)一次,而 FileChannel 的系統(tǒng)調(diào)用開銷則是每次都會觸發(fā)。當兩者單次按照小數(shù)據(jù)量讀取 1G 文件的時候,MappedByteBuffer 的缺頁中斷較少觸發(fā),而 FileChannel 的系統(tǒng)調(diào)用卻在頻繁觸發(fā),所以在這種情況下,F(xiàn)ileChannel 的系統(tǒng)調(diào)用是主要的性能瓶頸。

這也就解釋了當我們在頻繁讀寫小數(shù)據(jù)量的時候,MappedByteBuffer 的性能具有壓倒性優(yōu)勢。當單次讀寫的數(shù)據(jù)量越來越大的時候,F(xiàn)ileChannel 調(diào)用的次數(shù)就會越來越少,這時候缺頁中斷就會成為 MappedByteBuffer 的性能瓶頸,到某一個點之后,F(xiàn)ileChannel 就會反超 MappedByteBuffer。因此當我們需要高吞吐量讀寫文件的時候 FileChannel 反而是最合適的。

除此之外,內(nèi)核的臟頁回寫也會對 MappedByteBuffer 以及 FileChannel 的文件寫入性能有非常大的影響,無論是我們在用戶態(tài)中調(diào)用 fsync 或者 msync 主動觸發(fā)臟頁回寫還是內(nèi)核通過 pdflush 線程異步臟頁回寫,當我們使用 MappedByteBuffer 或者 FileChannel 寫入 page cache 的時候,如果恰巧遇到文件頁的回寫,那么寫入操作都會有非常大的延遲,這個在 MappedByteBuffer 身上體現(xiàn)的更為明顯。

為什么這么說呢 ? 我們還是到內(nèi)核源碼中去探尋原因,先來看臟頁回寫對 FileChannel 的寫入影響。下面是 FileChannel 文件寫入在內(nèi)核中的核心實現(xiàn):

ssize_t generic_perform_write(struct file *file,
    struct iov_iter *i, loff_t pos)
{
   // 從 page cache 中獲取要寫入的文件頁并準備記錄文件元數(shù)據(jù)日志工作
  status = a_ops->write_begin(file, mapping, pos, bytes, flags,
      &page, &fsdata);
   // 將用戶空間緩沖區(qū) DirectByteBuffer 中的數(shù)據(jù)拷貝到 page cache 中的文件頁中
  copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
  // 將寫入的文件頁標記為臟頁并完成文件元數(shù)據(jù)日志的寫入
  status = a_ops->write_end(file, mapping, pos, bytes, copied,
      page, fsdata);
  // 判斷是否需要同步回寫臟頁
  balance_dirty_pages_ratelimited(mapping);
}

首先內(nèi)核會在 write_begin 函數(shù)中通過 grab_cache_page_write_begin 從文件 page cache 中獲取要寫入的文件頁。

struct page *grab_cache_page_write_begin(struct address_space *mapping,
          pgoff_t index, unsigned flags)
{
  struct page *page;
  // 在 page cache 中查找寫入數(shù)據(jù)的緩存頁
  page = pagecache_get_page(mapping, index, fgp_flags,
      mapping_gfp_mask(mapping));
  if (page)
    wait_for_stable_page(page);
  return page;
}

在這里會調(diào)用一個非常重要的函數(shù) wait_for_stable_page,這個函數(shù)的作用就是判斷當前 page cache 中的這個文件頁是否正在被回寫,如果正在回寫到磁盤,那么當前進程就會阻塞直到臟頁回寫完畢。

/**
 * wait_for_stable_page() - wait for writeback to finish, if necessary.
 * @page:	The page to wait on.
 *
 * This function determines if the given page is related to a backing device
 * that requires page contents to be held stable during writeback.  If so, then
 * it will wait for any pending writeback to complete.
 */
void wait_for_stable_page(struct page *page)
{
	if (bdi_cap_stable_pages_required(inode_to_bdi(page->mapping->host)))
		wait_on_page_writeback(page);
}
EXPORT_SYMBOL_GPL(wait_for_stable_page);

等到臟頁回寫完畢之后,進程才會調(diào)用 iov_iter_copy_from_user_atomic 將待寫入數(shù)據(jù)拷貝到 page cache 中,最后在 write_end 中調(diào)用 mark_buffer_dirty 將寫入的文件頁標記為臟頁。

除了正在回寫的臟頁會阻塞 FileChannel 的寫入過程之外,如果此時系統(tǒng)中的臟頁太多了,超過了 dirty_ratio 或者 dirty_bytes 等內(nèi)核參數(shù)配置的臟頁比例,那么進程就會同步去回寫臟頁,這也對寫入性能有非常大的影響。

我們接著再來看臟頁回寫對 MappedByteBuffer 的寫入影響,在開始分析之前,筆者先問大家一個問題:通過 MappedByteBuffer 寫入 page cache 之后,page cache 中的相應(yīng)文件頁是怎么變臟的 ?

FileChannel 很好理解,因為 FileChannel 走的是系統(tǒng)調(diào)用,會進入到文件系統(tǒng)由內(nèi)核進行處理,如果寫入文件頁恰好正在回寫時,內(nèi)核會調(diào)用 wait_for_stable_page 阻塞當前進程。在將數(shù)據(jù)寫入文件頁之后,內(nèi)核又會調(diào)用 mark_buffer_dirty 將頁面變臟。

MappedByteBuffer 就很難理解了,因為 MappedByteBuffer 不會走系統(tǒng)調(diào)用,直接讀寫的就是 page cache,而 page cache 也只是內(nèi)核在軟件層面上的定義,它的本質(zhì)還是物理內(nèi)存。另外臟頁以及臟頁的回寫都是內(nèi)核在軟件層面上定義的概念和行為。

MappedByteBuffer 直接寫入的是硬件層面的物理內(nèi)存(page cache),硬件哪管你軟件上定義的臟頁以及臟頁回寫啊,沒有內(nèi)核的參與,那么在通過 MappedByteBuffer 寫入文件頁之后,文件頁是如何變臟的呢 ?還有就是 MappedByteBuffer 如何探測到對應(yīng)文件頁正在回寫并阻塞等待呢 ?

既然我們涉及到了軟件的概念和行為,那么一定就會有內(nèi)核的參與,我們回想一下整個 MappedByteBuffer 的生命周期,唯一一次和內(nèi)核打交道的機會就是缺頁中斷,我們看看能不能在缺頁中斷中發(fā)現(xiàn)點什么~

當 MappedByteBuffer 剛剛被 mmap 映射出來的時候它還只是一段普通的虛擬內(nèi)存,背后什么都沒有,其在進程頁表中的各級頁目錄項以及頁表項都還是空的。

當我們立即對 MappedByteBuffer 進行寫入的時候就會發(fā)生缺頁中斷,在缺頁中斷的處理中,內(nèi)核會在進程頁表中補齊與 MappedByteBuffer 映射相關(guān)的各級頁目錄并在頁表項中與 page cache 進行映射。

static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
    // 從 page cache 中讀取文件頁
    ret = __do_fault(vmf);   
    if (vma->vm_ops->page_mkwrite) {
        unlock_page(vmf->page);
        // 將文件頁變?yōu)榭蓪憼顟B(tài),并設(shè)置文件頁為臟頁
        // 如果文件頁正在回寫,那么阻塞等待
        tmp = do_page_mkwrite(vmf);
    }
}

除此之外,內(nèi)核還會調(diào)用 do_page_mkwrite 方法將 MappedByteBuffer 對應(yīng)的頁表項變成可寫狀態(tài),并將與其映射的文件頁立即設(shè)置位臟頁,如果此時文件頁正在回寫,那么 MappedByteBuffer 在缺頁中斷中也會阻塞。

int block_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf,
			 get_block_t get_block)
{
	set_page_dirty(page);
	wait_for_stable_page(page);
}

這里我們可以看到 MappedByteBuffer 在內(nèi)核中是先變臟然后在對 page cache 進行寫入,而 FileChannel 是先寫入 page cache 后在變臟。

從此之后,通過 MappedByteBuffer 對 page cache 的寫入就會變得非常絲滑,那么問題來了,當 page cache 中的臟頁被內(nèi)核異步回寫之后,內(nèi)核會把文件頁中的臟頁標記清除掉,那么這時如果 MappedByteBuffer 對 page cache 寫入,由于不會發(fā)生缺頁中斷,那么 page cache 中的文件頁如何再次變臟呢 ?

內(nèi)核這里的設(shè)計非常巧妙,當內(nèi)核回寫完臟頁之后,會調(diào)用 page_mkclean_one 函數(shù)清除文件頁的臟頁標記,在這里會首先通過 page_vma_mapped_walk 判斷該文件頁是不是被 mmap 映射到進程地址空間的,如果是,那么說明該文件頁是被 MappedByteBuffer 映射的。隨后內(nèi)核就會做一些特殊處理:

  1. 通過 pte_wrprotect 對 MappedByteBuffer 在進程頁表中對應(yīng)的頁表項 pte 進行寫保護,變?yōu)橹蛔x權(quán)限。

  2. 通過 pte_mkclean 清除頁表項上的臟頁標記。

static bool page_mkclean_one(struct page *page, struct vm_area_struct *vma,
			    unsigned long address, void *arg)
{

	while (page_vma_mapped_walk(&pvmw)) {
		int ret = 0;

		address = pvmw.address;
		if (pvmw.pte) {
			pte_t entry;
			entry = ptep_clear_flush(vma, address, pte);
			entry = pte_wrprotect(entry);
			entry = pte_mkclean(entry);
			set_pte_at(vma->vm_mm, address, pte, entry);
		}
	return true;
}

這樣一來,在臟頁回寫完畢之后,MappedByteBuffer 在頁表中就變成只讀的了,這一切對用戶態(tài)的我們都是透明的,當再次對 MappedByteBuffer 寫入的時候就不是那么絲滑了,會觸發(fā)寫保護缺頁中斷(我們以為不會有缺頁中斷,其實是有的),在寫保護中斷的處理中,內(nèi)核會重新將頁表項 pte 變?yōu)榭蓪?,文件頁標記為臟頁。如果文件頁正在回寫,缺頁中斷會阻塞。如果臟頁積累的太多,這里也會同步回寫臟頁。

static vm_fault_t wp_page_shared(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    if (vma->vm_ops && vma->vm_ops->page_mkwrite) {
        // 設(shè)置頁表項為可寫
        // 標記文件頁為臟頁
        // 如果文件頁正在回寫則阻塞等待
        tmp = do_page_mkwrite(vmf);
    } 
    // 判斷是否需要同步回寫臟頁,
    fault_dirty_shared_page(vma, vmf->page);
    return VM_FAULT_WRITE;
}

所以并不是對 MappedByteBuffer 調(diào)用 mlock 之后就萬事大吉了,在遇到臟頁回寫的時候,MappedByteBuffer 依然會發(fā)生寫保護類型的缺頁中斷。在缺頁中斷處理中會等待臟頁的回寫,并且還可能會發(fā)生臟頁的同步回寫。這對 MappedByteBuffer 的寫入性能會有非常大的影響。

在明白這些問題之后,下面我們繼續(xù)來看 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對文件的寫入性能測試:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

運行結(jié)果:

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異

在筆者的測試環(huán)境中,我們看到 MappedByteBuffer 在對文件的寫入性能一路碾壓 FileChannel,并沒有出現(xiàn)被 FileChannel 反超的情況。但我們看到 MappedByteBuffer 從 4K 開始寫入性能是在逐漸下降的,而 FileChannel 的寫入性能卻在一路升高。

根據(jù)上面的分析,我們可以推斷出,后面隨著數(shù)據(jù)量的增大,由于 MappedByteBuffer 缺頁中斷瓶頸的影響,在 512M 后面某一個數(shù)據(jù)集下,F(xiàn)ileChannel 的寫入性能最終是會超過 MappedByteBuffer 的。

在本小節(jié)的開頭,筆者就強調(diào)了,本小節(jié)值得參考的是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集大小下的讀寫性能趨勢走向,而不是具體的性能數(shù)值。

MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異文章來源地址http://www.zghlxwxcb.cn/news/detail-844032.html

到了這里,關(guān)于MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!

本文來自互聯(lián)網(wǎng)用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務(wù),不擁有所有權(quán),不承擔相關(guān)法律責(zé)任。如若轉(zhuǎn)載,請注明出處: 如若內(nèi)容造成侵權(quán)/違法違規(guī)/事實不符,請點擊違法舉報進行投訴反饋,一經(jīng)查實,立即刪除!

領(lǐng)支付寶紅包贊助服務(wù)器費用

相關(guān)文章

  • 華為云RDS通用型(x86) vs 鯤鵬(ARM)架構(gòu)的性能對比

    華為云RDS通用型(x86) vs 鯤鵬(ARM)架構(gòu)的性能對比

    之前,我們對比了阿里云RDS的經(jīng)濟版(ARM)與x86版的性價比,這次我們來看看華為云的RDS MySQL的“通用型”(x86)與“鯤鵬通用增強型”(ARM)版本的情況如何。 這里依舊選擇了用戶較為常用的4c16g的規(guī)格進行測試,測試工具使用了sysbench的oltp_read_write模型進行測試。配置參數(shù)與選

    2024年02月03日
    瀏覽(52)
  • AWS基于x86 vs Graviton(ARM)的RDS MySQL性能對比

    AWS基于x86 vs Graviton(ARM)的RDS MySQL性能對比

    這是一個系列。在前面,我們測試了阿里云經(jīng)濟版(“ARM”)與標準版的性能/價格對比;華為云x86規(guī)格與ARM(鯤鵬增強)版的性能/價格對比?,F(xiàn)在,再來看看AWS的ARM版本的RDS情況 在2018年,AWS首次推出Graviton EC2實例,2020年7月AWS RDS正式支持Graviton 2的實例,就在前兩天,在最

    2024年02月05日
    瀏覽(40)
  • 分布式存儲 vs. 全閃集中式存儲:金融數(shù)據(jù)倉庫場景下的性能對比

    分布式存儲 vs. 全閃集中式存儲:金融數(shù)據(jù)倉庫場景下的性能對比

    作者:深耕行業(yè)的 SmartX 金融團隊?張德敏 近年來隨著金融行業(yè)的高速發(fā)展,經(jīng)營決策者及監(jiān)管機構(gòu)對信息時效性的要求越來越高,科技部門面臨諸多挑戰(zhàn)。例如,不少金融機構(gòu)使用數(shù)倉業(yè)務(wù)系統(tǒng),為公司高層提供日常經(jīng)營報表,同時支持監(jiān)管報送等應(yīng)用。該業(yè)務(wù)系統(tǒng)通常是

    2024年02月07日
    瀏覽(20)
  • Jest和Mocha對比:兩者之間有哪些區(qū)別?

    Jest和Mocha對比:兩者之間有哪些區(qū)別?

    所謂單元測試,是對軟件中單個功能組件進行測試的一種軟件測試方式,其目的是確保代碼中的每一個基本單元都能正常運行。因此,開發(fā)人員在應(yīng)用程序開發(fā)的整個過程(即代碼編寫過程)中都需要進行單元測試。在進入到軟件開發(fā)的下一階段之前,對程序進行單元測試是

    2024年02月14日
    瀏覽(23)
  • 簡述Keepalived LVS 原理以及兩者的優(yōu)缺點對比分析

    本文章主要圍繞Keepalived,lvs的工作原理以及兩者的優(yōu)缺點進行對比分析。My BLOG:https://blog.itwk.cc 什么是Keepalived? Keepalived是基于VRRP協(xié)議(Virtual Router Redundancy Protocol)是Linux下一個輕量級高可用解決方案(HA),其實兩種不同的含義,廣義來講,是指整個系統(tǒng)的高可用行,狹

    2023年04月08日
    瀏覽(19)
  • 代碼層面探索前端性能

    最近在做性能優(yōu)化,具體優(yōu)化手段,網(wǎng)上鋪天蓋地,這里就不重復(fù)了。 性能優(yōu)化可分為以下幾個維度:代碼層面、構(gòu)建層面、網(wǎng)絡(luò)層面。 本文主要是從代碼層面探索前端性能,主要分為以下 4 個小節(jié)。 使用 CSS 替代 JS 深度剖析 JS 前端算法 計算機底層 這里主要從動畫和 CS

    2024年02月08日
    瀏覽(14)
  • 【137期】面試官問:RocketMQ 與 Kafka 對比,談?wù)剝烧叩牟町悾?1)

    【137期】面試官問:RocketMQ 與 Kafka 對比,談?wù)剝烧叩牟町悾?1)

    先自我介紹一下,小編浙江大學(xué)畢業(yè),去過華為、字節(jié)跳動等大廠,目前阿里P7 深知大多數(shù)程序員,想要提升技能,往往是自己摸索成長,但自己不成體系的自學(xué)效果低效又漫長,而且極易碰到天花板技術(shù)停滯不前! 因此收集整理了一份《2024年最新Java開發(fā)全套學(xué)習(xí)資料》,

    2024年04月27日
    瀏覽(19)
  • 【Java】后端開發(fā)語言Java和C#,兩者對比注解和屬性的區(qū)別以及作用

    【Java】后端開發(fā)語言Java和C#,兩者對比注解和屬性的區(qū)別以及作用

    歡迎來到《小5講堂》 大家好,我是全棧小5。 這是《Java》序列文章,每篇文章將以博主理解的角度展開講解, 特別是針對知識點的概念進行敘說,大部分文章將會對這些概念進行實際例子驗證,以此達到加深對知識點的理解和掌握。 溫馨提示:博主能力有限,理解水平有限

    2024年01月16日
    瀏覽(28)
  • 大數(shù)據(jù)大比拼:Hive vs HBase,你知道兩者的區(qū)別和適用場景嗎?

    Apache Hive和Apache HBase是兩個非常流行的分布式數(shù)據(jù)存儲技術(shù)。盡管兩者都是Apache軟件基金會的項目,但它們被設(shè)計用于不同的用例。在本篇博客中,我們將介紹Hive和HBase的基本概念,以及它們的區(qū)別和應(yīng)用場景。 Apache Hive是一種基于Hadoop的數(shù)據(jù)倉庫軟件,它允許用戶使用SQL來

    2023年04月09日
    瀏覽(25)
  • 瀏覽器層面優(yōu)化前端性能(1):Chrom組件與進程/線程模型分析

    現(xiàn)階段的瀏覽器運行在一個單用戶,多合作,多任務(wù)的操作系統(tǒng)中。一個糟糕的網(wǎng)頁同樣可以讓一個現(xiàn)代的瀏覽器崩潰。其原因可能是一個插件出現(xiàn)bug,最終的結(jié)果是整個瀏覽器以及其他正在運行的標簽被銷毀。 現(xiàn)代操作系統(tǒng)已經(jīng)非常健壯了,它讓應(yīng)用程序在各自的進程中運

    2023年04月09日
    瀏覽(24)

覺得文章有用就打賞一下文章作者

支付寶掃一掃打賞

博客贊助

微信掃一掃打賞

請作者喝杯咖啡吧~博客贊助

支付寶掃一掃領(lǐng)取紅包,優(yōu)惠每天領(lǐng)

二維碼1

領(lǐng)取紅包

二維碼2

領(lǐng)紅包