目錄
NIO三大組件
一. ByteBuffer
基本用法
DirectByteBuffer與HeapByteBuffer對比
字符串轉ByteBuffer
ByteBuffer.wrap(byte[] )
粘包與拆包
文件編程
零拷貝transferTo
二. 阻塞與非阻塞Channel
三. Selector
SelectionKey(重點)
SelectionKey四種類型
key.cancel()
事件類型
iter.remove()
selectKeys中的事件要么處理、要么取消
key.cancel()的應用場景
selectionKey附件
TCP編程都要考慮的:消息邊界處理
ByteBuffer大小分配
寫出內容過多問題-使用write事件(重點)
通過可寫事件-分批多次進行大文件寫出
多路復用器
selector.select()如何退出阻塞
NIO多線程優化
阻塞隊列還可以放入Runnable任務
NIO概念剖析
BIO與NIO
IO模型
阻塞read()
非阻塞read()
異步IO
內存映射與零拷貝
mmap+write
sendFile
再次對比DirectByteBuffer與HeapByteBuffer
為何IO操作都需要將JVM內存數據拷貝到堆外內存?
那為什么Java不全部通過JNI操作堆外內存來寫代碼呢?
普通BIO
DirectByteBuffer存在的意義(重點)
沒有mmap內存映射產生的DirectByteBuffer對象之前
直接內存的開辟示意圖
Nio如何管理堆外內存的釋放
回收流程
Netty的異步調用與異步IO模型
視頻鏈接
NIO三大組件
NIO三大組件:Channel、Buffer、Selector
Selector適合channel連接數特別多,但是每個channel上來回的流量低的場景
一. ByteBuffer
基本用法
int count = channel.read(buffer),如果返回-1,表示channel中的數據讀取完畢了
- 在寫入模式下,limit就是能最大的寫入位置
- 在讀取模式下,limit就是最大的讀取位置
compact()兩個作用:壓縮、切換到寫模式
DirectByteBuffer與HeapByteBuffer對比
HeapByteBuffer因為是分配在JVM堆上的,所以如果一輪gc這個HeapByteBuffer還沒有被處理完,就不能被回收,那么如果此HeapByteBuffer在新生代,就會被通過復制算法,復制到s區(產生多次數據拷貝),這就是HeapByteBuffer的使用,會受到gc影響的一種表現
DirectByteBuffer,是分配在JVM堆外的,是要調用,系統調用,直接在JVM進程堆(JVM堆外)中分配的,所以分配效率較HeapByteBuffer低一些,但是因為JVM進程堆不受gc影響,不會因為發生了GC,而導致DirectByteBuffer對應的堆外空間被到處復制轉移(復制轉移是會產生開銷的)
字符串轉ByteBuffer
第一種模式寫入數據后,要先flip()切換為讀模式,否則就會讀到空數據
ByteBuffer.wrap(byte[] )
wrap效果等于第二種模式,包裝以后,也會直接切換為讀模式
粘包與拆包
發送方,肯定是想一次攢多條消息一起發送,效率更高,所以就是因為發送方想一次攢一波兒數據一起發,從而導致接收方,產生了粘包的可能
比如上面,發送方可能就是把第8、9、10行的三個數據包,一起發送給了接收方
文件編程
channel的寫入能力是有上限的,如果channel對應的本地發送緩沖區滿了,tcp還沒來得及把堆在發送緩沖區中的數據發出去,此時channel.write()就往發送緩沖區寫入數據就會一直寫入0字節,所以要通過上面的hasRemaining()的方式,分多次往channel中寫入
零拷貝transferTo
當要傳輸的文件大于2G時,要分多次傳輸
我覺得上面函數的第二個參數,應該寫size,不應該是left
二. 阻塞與非阻塞Channel
IO的api默認是阻塞模式,如果想使用非阻塞NIO,那么ServerSocketChannel和SocketChannel都要分別設置非阻塞模式
三. Selector
SelectionKey(重點)
無論是serverSocketChannel,還是socketChannel,往selector上注冊后,都會返回唯一一個魏穎的SelectionKey注冊鍵
- serverSocketChannel對應唯一一個SelectionKey,可以關注accept事件
- 從每個socketChannel對應SelectionKey身上,可以取出唯一對應的那個socketChannel,和一個attachment附件
- 一個socketChannel對應的唯一SelectionKey,可以同時關注read事件和write事件
- 當socketChannel上無論有read事件,還是write事件時,selector.select()都會將這個socketChannel對應的唯一SelectionKey返回。但是可以通過key.readable()或者key.writeable()判斷此時返回的事件究竟是何種類型
SelectionKey四種類型
sscKey關注了accept事件
那么當客戶端有新的連接進來時,sscKey這個SelectionKey就會出現在selector.select()返回的SelectionKey的集合中。也就是說,此時代碼B處的某一個key才可能是sscKey
可以看到,當有一個連接事件進來時,selector.select()返回的SelectionKey還是第28行對應的sscKey,只不過在第28行時,sscKey管理的serverSocketChannel上還沒有accept事件達到。
而當有新的客戶端連接serverSocketChannel時,就代表sscKey管理的serverSocketChannel上有accept事件達到了,此時,selector.select()返回的SelectionKey還是第28行對應的sscKey,此時,通過sscKey就能拿到這個新達到的accept事件
key.cancel()
如果有了事件不處理,上面的代碼就會一直死循環,因為selector.select()一直會把sscKey給返回
如果拿到事件就是不想處理這個事件,可以cancel
此時key.cancel()實現的效果是,直接把此selectionKey從selector身上拿掉了,也就是說,以后本selector就不再管理此selectionKey對應的channel了,也就是說,以后本selector就不再監控來自這個channel上的任何事件了(這個channel就放養了,無人看管,無人關心了)
事件類型
iter.remove()
當第42行,將accept事件處理以后,就會將sscKey@59a6e353這個selectionKey上的accept事件給去掉
sscKey@59a6e353這個selectionKey此時,還在綠色框中,也就是selector.select()時,還是會將這個selectionKey給拿出來,但是這個selectionKey上此時已經沒有accept事件了
第二輪while循環,此時selector.select()返回的集合中有兩個selectionKey,一個sscKey和scKey,只不過sscKey上的accept事件已經不存在了,在上一輪while循環被取走了,所以此時在想通過sscKey上取accept事件就取不到了,所以上面代碼第42行會返回null,導致代碼第43行報了NPE
講了這么多,我們處理完一個selector.select()返回的集合中的某個selectionKey身上掛載的事件后,就要把這個selectionKey,從綠色框的selectionKey集合中給移除
右邊紅色框,就是每個selector的紅黑樹集合,這個保存著所有注冊到本selector身上的channel(包括serverSocketChannel和socketChannel)
右邊綠色框,就是每次epoll_wait()返回的“有事件到達的selectionKey集合”,只不過每次用戶空間把這個集合取過去后,處理完每個selectionKey當前身上掛載的事件后,需要用戶空間手動調用iter.remove()把這個selectionKey,從“有事件到達的selectionKey集合”中手動刪除
selectKeys中的事件要么處理、要么取消
當客戶端連接強制斷開時(比如客戶端直接宕機了),服務端再通過客戶端的channel去read()就會拋異常,會導致服務端線程掛掉,所以服務端需要catch這個IO異常
key.cancel()的應用場景
客戶端連接關閉后,會引發一個Read事件,讀取這個Read事件會拋出IOExcepiton
所以此時要做的就是,通過調用key.cancel(),取消此selectionKey背后對應的channel,在selector身上的注冊,讓selector不再管理這個已經死了的客戶端channel了
不管是客戶端強制斷開,還是正常channel.close(),服務端都會受到一個read事件
只不過異常斷開時,服務端調用channel.read()會拋出IOException。正常斷開時,服務端調用channel.read()會返回-1。
但是不管是客戶端連接時強制斷開,還是正常channel.close()導致的斷開,服務端都要通過key.cancel(),把這個channel對應的SelectionKey從selector的紅黑樹中刪除,也就是,刪除這個channel在selector身上的注冊行為
selectionKey附件
SelectionKey - Channel - attachment,都是一一對應的
通過這種方式,給每個channel附帶一個唯一對應的ByteBuffer
TCP編程都要考慮的:消息邊界處理
通過fileSize(固定字節數) + fileBuffer(可變字節數)的形式,來處理消息邊界問題。
http也是這種形式,有contentLength請求頭
ByteBuffer大小分配
寫出內容過多問題-使用write事件(重點)
可以看到發送能力是有限的,當發送緩沖區寫滿了,第38行的寫入就會寫不進去了,所以返回的寫入字節數是0
這樣的實現模式,本身是沒有問題的,如果3000w字節的數據,如果沒有發送完,那么左側的epoll處理主線程就會一直卡在上圖紅框的while循環中,別的channel的話,epoll處理主線程此時就無暇去處理它們。這不符合我們非阻塞IO的設計思想
我們希望是,當發送緩沖區滿時,epoll處理主線程就不一直卡在上圖紅框的while循環中(產生空轉,因為只要發送緩沖區滿,while循環就會一直空轉耗費CPU),而是去處理別的channel的事件
(等上一個channel的發送緩沖區空了,觸發一個Write事件,表示發送緩沖區空了,可以寫了,此時,epoll處理主線程再對這個channel寫入上一輪沒有寫完的數據)
此時,寫事件會覆蓋關注的讀事件
此時,就表示我們通過scKey這一個SelectionKey,同時關注了Read和Write事件
此后,如果此scKey對應的channel對應的發送緩沖區空了以后,就會觸發一個Write的可寫事件,然后代碼就會進入第47行的if判斷中,開始把上一輪沒有寫完的buffer中的剩余數據,繼續去寫
這樣,就把原來的while循環寫,變成了對Write事件的多次觸發
大數據buffer寫完后,還需要將它從附件處刪除,同時取消關注Write事件
可寫事件Write的觸發機制,就是當發送緩沖區空時,可以寫數據了,那么就會觸發可寫事件,從而epoll.select()就能拿到這個可寫事件
通過可寫事件-分批多次進行大文件寫出
可寫事件,只有在要寫的數據太多時,才去使用
通過可寫事件,來處理channel.write()一次寫不完一整個大文件的情況。
那么就可以知道,如果你每次就寫個幾個字節,幾十上百個字節,那么直接通過channel.write(),把數據一次性寫出去就好了,根本不需要可寫事件Write的相關邏輯參與
可以看到,當這個channel對應的發送緩沖區寫滿的時候,再通過channel.write()來往channel對應的發送緩沖區中寫數據,會直接返回0,也就是寫入了0字節。
后續,寫入線程就會多次空轉,嘗試往發送緩沖區中寫入數據,那么我們可以通過,寫入發送緩沖區寫滿了以后,就通過selectionKey關注這個channel的write事件,那么當這個channel對應的發送緩沖區又可以寫數據時,selector.select()就會探測到一個write可寫事件,這個時候,我們的寫入線程,再通過channel.write()來往channel對應的發送緩沖區中寫數據,就能避免寫入線程因為發送換成區滿而產生的多次空轉問題
那么此時,如果第36行一次沒有把全部大文件寫完,那么就會多次進入第47行開始的可寫事件,也就是相當于,把原來的while循環寫,轉化為了多次可寫事件的處理
因為key身上掛的附件buffer,可能大小有1個G,當我們把這個附件buffer中的數據全部寫完了以后,要取消附件buffer的掛載,讓JVM去gc回收這篇1個G的大內存,不然一直讓它占著1個G的大內存是非常不合適的
另外,我們把1個G的內容寫完了以后,還要取消關注可寫事件,等到后續又有新的大文件的寫需求時,先嘗試寫一次,如果又沒有寫完,再讓selector去關注這個channel上的可寫事件
多路復用器
selector.select()如何退出阻塞
NIO多線程優化
這里實際上,就是boss線程,再往Worker的阻塞隊列中,投入了一個Runnable任務,并喚醒在selector.select()處阻塞worker線程
阻塞隊列還可以放入Runnable任務
當selector.select()處于阻塞狀態時,直接socketChannel.register(selector)來往selector上注冊事件監聽時,也會被阻塞住
只有先讓worker線程,從selector.select()被喚醒,然后worker線程自己從阻塞隊列中去task執行
注意,當前阻塞隊列中,放的是一個Runnable任務
NIO概念剖析
BIO與NIO
BIO是更加高層次的API,比如它們不會關心到發送緩沖區,接收緩沖區的邏輯。比如,前面的例子,寫大量數據時,就會關心發送區慢了,本次就不發了,而是去關注write事件,等下一次write事件達到時,再去發
IO模型
阻塞read()
非阻塞read()
比如有網卡的數據真正達到網卡緩沖區,需要被從網卡緩沖區復制到內核緩沖區時,用戶線程調用的read()一樣還是會被阻塞住
多路復用器
BIO 同步阻塞、NIO 和 EPOLL都是同步非阻塞
異步IO
異步,一定是非阻塞的。沒有異步阻塞的說法
內存映射與零拷貝
mmap+write
使用MappedByteBuffer
內存映射
內存文件映射適用于對大文件的讀寫。虛擬地址空間有一塊區域: “Memory mapped region for shared libraries” ,這段區域就是在內存映射文件的時候將某一段的虛擬地址和文件對象的某一部分建立起映射關系,此時并沒有拷貝數據到內存中去,而是當進程代碼第一次引用這段代碼內的虛擬地址時,觸發了缺頁異常,這時候OS根據映射關系直接將文件的相關部分數據拷貝到進程的用戶私有空間中去
只用3次拷貝,減少了1次
rocketmq是mmap+write,kafka是sendFile
sendFile
數據,不在經過用戶空間,
零拷貝,指的是不再需要把數據,拷貝到用戶空間內存(JVM內存,就是用戶空間內存)
不再需要CPU拷貝,只需要DMA去執行拷貝動作
再次對比DirectByteBuffer與HeapByteBuffer
為何IO操作都需要將JVM內存數據拷貝到堆外內存?
- 因為JNI操作的內存空間數據,不能隨便被GC從而挪動位置,所以Java程序,不能通過JNI直接分配內存空間來進行代碼邏輯的書寫,因為可能上一秒通過JNI記錄的是固定地址,而GC會導致固定地址內部的數據被挪走到別處
- 但是,Java程序可以通過JNI分配堆外內存,并直接操作堆外內存,堆外內存的地址就是固定的,不會隨意被GC動作給挪走
那為什么Java不全部通過JNI操作堆外內存來寫代碼呢?
- 因為,Java的特色就是能自動進行垃圾回收,而只有堆內內存空間的數據,才能通過JVM垃圾收集器進行自動的垃圾回收
- 所以,如果Java全部使用JNI分配堆外內存,那么就沒有辦法再使用JVM提供的垃圾收集器進行自動的垃圾回收
- 而JNI操作的堆外內存,才是直面各底層硬件的內存,所有的硬件上的數據讀寫都要先經過堆外內存
JVM堆內內存,JNI是不能直接訪問和操作的。所以,JVM堆內空間要想拿到磁盤中的數據,必須先通過把磁盤數據拿到內核緩沖區中來,然后Java代碼再通過read()系統調用,從內核緩沖區中拿數據到JVM堆內來
不能直接通過調用JNI方法,把內核緩沖區中的數據,寫入到JVM堆內。只能是磁盤數據先到內核緩沖區,然后用戶通過read()系統調用,把內核緩沖區中的數據,再讀取JVM堆內
DirectByteBuffer分配的堆外內存,本質是調用操作系統的malloc(),分配的JVM進程的用戶堆空間的內存
因為,DirectByteBuffer對應的堆外地址,和內核緩沖區,共用同一個片物理頁。所以,我們程序員通過Java代碼往DirectByteBuffer對應的堆外地址put了一些數據,就相當于直接寫入了內核緩沖區,而且put操作,還不會產生系統調用
普通BIO
但是,JDK還是提供了一種走捷徑的方式,通過內存映射mmap,拿到文件映射的address,從而用戶可以通過Java代碼,直接讀寫這個address對應的堆外內存空間。
而不用先在JVM堆內用戶空間搞一個byte[],然后再把這個byte[]中的內容寫入堆外內存空間,或者把堆外內存空間的數據讀入byte[]中,從而減少了數據在JVM堆內的拷貝過程
DirectByteBuffer存在的意義(重點)
- 有了mmap內存映射產生的DirectByteBuffer對象,使得Java程序,也有了能直接操作堆外內存地址空間的機會
- 堆外內存空間才是直接面對各個底層硬件的,比如底層的磁盤,或者網卡
- 只有堆外內存空間的數據,才是能直接去往各底層硬件讀寫的,JVM堆內空間數據,是不能直接往各底層硬件讀寫的
沒有mmap內存映射產生的DirectByteBuffer對象之前
Java程序,想將數據寫入到網卡,只能先在Java用戶空間生成byte[]并寫滿數據,然后通過調用write()系統調用先把用戶空間的byte[]寫入到堆外內存空間,然后通過系統調用flush(),把堆外內存空間的數據,寫入到網卡緩沖區
直接內存的開辟示意圖
比如定義:DirectByteBuffer dbb =?ByteBuffer.allocateDirect(1024);底層是:
public static ByteBuffer allocateDirect(int capacity) {return new DirectByteBuffer(capacity);
}
示意圖:
Nio如何管理堆外內存的釋放
回收流程
NIO中如何使用虛引用管理堆外內存原理_虛引用 堆外內存-CSDN博客
Netty的異步調用與異步IO模型
Netty的異步指的是調用方式的異步,不是指的IO模型的異步。指的是請求的發送,和響應的接收,分別是不同的IO線程在處理
Netty的IO模型還是基于多路復用器的同步非阻塞IO
?
視頻鏈接
黑馬程序員Netty全套教程, netty深入淺出Java網絡編程教程_嗶哩嗶哩_bilibili