作者: 一字馬胡
轉載標志 【2017-11-24】
更新日志
日期 | 更新內容 | 備注 |
---|---|---|
2017-11-24 | 新建文章 | 以前學習java NI/O的時候寫的文章,復制過來的,格式改了不少,所以難免有錯誤,不斷更新 |
一、Java OIO
Java OIO (Java Old I/O)代表著的是一種阻塞I/O,所謂阻塞I/O,就是函數調用之后會一直阻塞直到函數返回正確值或者出錯或者被中斷,而在函數返回之前,該調用之后的代碼將不會被執行,也就是說,你必須要等到這個函數返回(無論多久),你才能繼續做接下來的事情。有時候這樣的編程模型是必須的,比如我們必須依賴從數據庫中讀取到的數據以作為依據去執行接下來的代碼邏輯,這樣的編程模型是在一個假設下成立的,這個假設就是:認為阻塞等待函數返回是值得的,后面的代碼就好像被鎖住了一樣,需要獲取到一把鑰匙才能打開鎖以繼續執行,而獲取這把鎖的唯一方法就是從阻塞中返回一種結果,然后根據不同的結果來打開不同的鎖。這種I/O編程模型是簡單的,你不需要為如何編寫代碼而緊皺眉頭,但是這種編程模型的缺陷也是很明顯的,因為,很多情況下,我們并不需要等待結果立刻返回,我們更希望提前提交任務,然后去做一些其他的事情,然后在必須獲取結果才能繼續的時候才阻塞等待獲取,而這個時候可能函數早已返回,已經不需要阻塞了,這樣的編程模型使得我們的工作更加高效,這其實也是并發編程的模型,這樣的模型確實可以提高我們的代碼的效率,但是寫代碼的難度就上升了一些,可能我們需要非常小心的安排代碼的順序,并且在必要的時候釋放一些資源等。但是為了提高效率解決編程的復雜性是值得的。有必要清晰一下下面的概念:
- 阻塞I/O
- 非阻塞I/O
- 同步I/O
- 異步I/O
每一次I/O操作都會涉及下面的兩個過程:
- 數據被copy到操作系統內核的緩沖區中
- 數據從操作系統內核緩存區copy到用戶進程空間中
而這兩個過程分別對應著下面的兩個過程:
- 內核等待IO數據準備完成
- 進程將數據從內核copy到自己的地址空間內
上面四個概念的區別,可以通過下面的準則區分:
- 調用函數之后如果函數立即返回無論數據準備完成與否,則為非阻塞IO,否則為阻塞IO(重點在于調用線程是否會被阻塞)
- 在做真正的IO操作的時候如果會阻塞調用線程,則為同步IO,否則為異步IO(重點在于真正執行IO操作的時候對調用線程是否可感知)
根據上面的判斷準則,OIO是阻塞的同步IO,而NIO是非阻塞的同步IO,NIO依然不是異步的,因為真正執行IO操作(比如read)的時候調用線程依然會被阻塞以等待結果(當內核數據還沒有準備好的時候,是不會阻塞線程的,但是當內核已經準備好數據之后,進程需要將數據從內核拷貝到自己的地址空間這個步驟是阻塞的),Netty框架則基于NIO使得IO操作變成了異步的,所以Netty是一個異步的IO框架。
二、I/O多路復用技術
說到IO多路復用,馬上應該想select、poll、epoll等機制。多路復用技術說的是,一個線程可以監聽多個文件描述符,如果那個準備好了就處理哪個,這和傳統的線程模型是有顯著的區別的。傳統的IO處理做法是,使用一個線程監聽端口,進來一個請求,則新建一個線程處理該請求。這樣的線程模型非常簡單,弊端也是非常明顯的,比如一個流量非常大的服務使用這樣的線程模型來承接請求,那么服務的可用性是非常差的,當然,有一個方案可能比這個好一些,那就是使用線程池,并且設置等待隊列,這樣的話,線程不需要頻繁的被創建,當一個請求完成處理之后,線程就可以空閑出來接收新的請求,當線程池里的線程都被占用了之后,請求會被放到等待隊列,等待線程來拉取,這樣的解決方案貌似非常先進,確實,這樣的方案比起一開始的方案好很多,對于業務非常簡單的服務,使用這樣的方案應該可以承接不小的流量,但是對于業務足夠復雜的場景來說,這樣的方案依然會有風險,因為線程池滿了之后,請求會被緩存起來啊,那緩存就需要空間來存放啊,那么這個隊列的大小就是有約束的啊,不可能無限大啊,那如果緩存隊列被打滿了呢?那么接下來的請求將會被丟棄,對于用戶而言就是,我明明點擊了屏幕,但是沒有任何動靜?。浚?!這樣的后果就是,用戶會再次點擊,再次點擊,再次點擊....這樣的后果對于服務端來說就是請求越來越多,對于用戶來說就是,“多么垃圾的app啊”。所以,這樣的方案依然得慎用。對于業務足夠復雜,流量足夠大的場景來說,選擇多路復用技術是必須的。
2.1 select
下面是select的處理流程,select的具體操作步驟:
- 1、拷貝nfds、readfds、writefds和exceptfds到內核(自己感興趣的描述符)
- 2、遍歷[0,nfds)范圍內的每個流,調用流所對應的設備的驅動poll函數
- 3、檢查是否有流發生,如果有發生,把流設置對應的類別,并執行4,如果沒有流發生,執行5。或者timeout=0,執行4
- 4、select返回
- 5、select阻塞當前進程,等待被流對應的設備喚醒,當被喚醒時,執行2。或者timeout到期,執行4
select的缺陷:
- (1)每次調用select,都需要把fd集合從用戶態拷貝到內核態
- (2)同時每次調用select都需要在內核遍歷傳遞進來的所有fd
- (3)select支持的文件描述符數量很小,默認是1024
2.2 poll 和epoll
poll和select差不多,但是poll不再告訴內核文件描述符的范圍,而是告訴內核自己感興趣的文件描述符集合,這樣的話就沒必要去詢問自己不感興趣的文件描述符了。epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎么解決的呢?在此之前,我們先看一下epoll和select和poll的調用接口上的不同,select和poll都只提供了一個函數——select或者poll函數。而epoll提供了三個函數,epoll_create,epoll_ctl和epoll_wait,epoll_create是創建一個epoll句柄;epoll_ctl是注冊要監聽的事件類型;epoll_wait則是等待事件的產生。那我們從select/poll的三個缺點的解決方案來看下epoll的實現:
- 缺點1:每次調用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大
epoll的解決方案:對于第一個缺點,epoll的解決方案在epoll_ctl函數中。每次注冊新的事件到epoll句柄中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把所有的fd拷貝進內核,而不是在epoll_wait的時候重復拷貝。epoll保證了每個fd在整個過程中只會拷貝一次。 - 缺點2:同時每次調用select都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大
epoll的解決方案: 對于第二個缺點,epoll的解決方案不像select或poll一樣每次都把current輪流加入fd對應的設備等待隊列中,而只在epoll_ctl時把current掛一遍(這一遍必不可少)并為每個fd指定一個回調函數,當設備就緒,喚醒等待隊列上的等待者時,就會調用這個回調函數,而這個回調函數會把就緒的fd加入一個就緒鏈表)。epoll_wait的工作實際上就是在這個就緒鏈表中查看有沒有就緒的fd - 缺點3:select支持的文件描述符數量太小了,默認是1024
epoll的解決方案:epoll沒有這個限制,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大于2048,舉個例子,在1GB內存的機器上大約是10萬左右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統內存關系很大。
2.3 select、poll、epoll總結
概括:
type | desc |
---|---|
Select | select本質上是通過設置或者檢查存放fd標志位的數據結構來進行下一步處理。這樣所帶來的缺點是:(1) 單個進程可監視的fd數量被限制 (2) 需要維護一個用來存放大量fd的數據結構,這樣會使得用戶空間和內核空間在傳遞該結構時復制開銷大 (3) 對socket進行掃描時是線性掃描 |
Poll | poll本質上和select沒有區別,它將用戶傳入的數組拷貝到內核空間,然后查詢每個fd對應的設備狀態,如果設備就緒則在設備等待隊列中加入一項并繼續遍歷,如果遍歷完所有fd后沒有發現就緒設備,則掛起當前進程,直到設備就緒或者主動超時,被喚醒后它又要再次遍歷fd。這個過程經歷了多次無謂的遍歷。它沒有最大連接數的限制,原因是它是基于鏈表來存儲的,但是同樣有一個缺點:大量的fd的數組被整體復制于用戶態和內核地址空間之間,而不管這樣的復制是不是有意義。poll還有一個特點是“水平觸發”,如果報告了fd后,沒有被處理,那么下次poll時會再次報告該fd。 |
Epoll | epoll支持水平觸發和邊緣觸發,最大的特點在于邊緣觸發,它只告訴進程哪些fd剛剛變為就需態,并且只會通知一次。在前面說到的復制問題上,epoll使用mmap減少復制開銷。還有一個特點是,epoll使用“事件”的就緒通知方式,通過epoll_ctl注冊fd,一旦該fd就緒,內核就會采用類似callback的回調機制來激活該fd,epoll_wait便可以收到通知 |
注:水平觸發(level-triggered)——只要滿足條件,就觸發一個事件(只要有數據沒有被獲取,內核就不斷通知你);邊緣觸發(edge-triggered)——每當狀態變化時,觸發一個事件。
區別:
type | Select | Poll | EPoll |
---|---|---|---|
支持最大連接數 | 1024(x86) or 2048(x64) | 無上限 | 無上限 |
IO效率 | 每次調用進行線性遍歷,時間復雜度為O(N) | 每次調用進行線性遍歷,時間復雜度為O(N) | 使用“事件”通知方式,每當fd就緒,系統注冊的回調函數就會被調用,將就緒fd放到rdllist里面,這樣epoll_wait返回的時候我們就拿到了就緒的fd。時間發復雜度O(1) |
fd拷貝 | 每次select都拷貝 | 每次poll都拷貝 | 調用epoll_ctl時拷貝進內核并由內核保存,之后每次epoll_wait不拷貝 |
select,poll實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。而epoll其實也需要調用epoll_wait不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,并喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,但是select和poll在“醒著”的時候要遍歷整個fd集合,而epoll在“醒著”的時候只要判斷一下就緒鏈表是否為空就行了,這節省了大量的CPU時間。這就是回調機制帶來的性能提升。
select,poll每次調用都要把fd集合從用戶態往內核態拷貝一次,并且要把current往設備等待隊列中掛一次,而epoll只要一次拷貝,而且把current往等待隊列上掛也只掛一次(在epoll_wait的開始,注意這里的等待隊列并不是設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省不少的開銷。
三、Channel
channel是什么?NIO的channel類似于一種流,可以從channel讀取數據,也可以向channel寫數據,Channel在NIO中扮演著傳輸數據的角色,而接下來介紹的Buffer則扮演著存儲數據的角色。NIO提供了很多的channel。
- FileChannel:從文件中讀寫數據(阻塞)
- DatagramChannel:通過UDP讀寫網絡中的數據
- SocketChannel:通過TCP讀寫網絡中的數據
- ServerSocketChannel:可以監聽新進來的TCP連接,像Web服務器那樣。對每一個新進來的連接都會創建一個SocketChannel
需要特別注意的是,除了FileChannel之外,其他的Channel都可以設置為非阻塞模式,而FileChannel無法切換為非阻塞模式。
下面的代碼展示了如何新建一個FileChannel:
RandomAccessFile rf = new RandomAccessFile(file, mode);
FileChannel inChannel = rf.getChannel();
獲取到Channel之后,我們就可以在Channel上做IO操作了。
四、Buffer
Buffer是一個緩沖區,用于存儲從Channel中讀取到的數據,或者將buffer作為參數傳遞給Channel來將buffer中的數據寫到Channel里面去。NIO提供了很多的Buffer:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
- MappedByteBuffer
- DirectByteBuffer
為了理解Buffer的工作原理,需要熟悉它的三個屬性:
- capacity
- position
- limit
capacity
作為一個內存塊,Buffer有一個固定的大小值,也叫“capacity”.你只能往里寫capacity個byte、long,char等類型。一旦Buffer滿了,需要將其清空(通過讀數據或者清除數據)才能繼續寫數據往里寫數據。
position
當你寫數據到Buffer中時,position表示當前的位置。初始的position值為0.當一個byte、long等數據寫到Buffer后, position會向前移動到下一個可插入數據的Buffer單元。position最大可為capacity – 1。當讀取數據時,也是從某個特定位置讀。當將Buffer從寫模式切換到讀模式,position會被重置為0. 當從Buffer的position處讀取數據時,position向前移動到下一個可讀的位置。在寫模式下,Buffer的limit表示你最多能往Buffer里寫多少數據。 寫模式下,limit等于Buffer的capacity。當切換Buffer到讀模式時, limit表示你最多能讀到多少數據。因此,當切換Buffer到讀模式時,limit會被設置成寫模式下的position值。換句話說,你能讀到之前寫入的所有數據(limit被設置成已寫數據的數量,這個值在寫模式下就是position)。
使用Buffer讀寫數據一般遵循以下四個步驟:
- 寫入數據到Buffer
- 調用
flip()
方法 - 從Buffer中讀取數據
- 調用
clear()
方法或者compact()
方法
當向buffer寫入數據時,buffer會記錄下寫了多少數據。一旦要讀取數據,需要通過flip()方法將Buffer從寫模式切換到讀模式。在讀模式下,可以讀取之前寫入到buffer的所有數據。一旦讀完了所有的數據,就需要清空緩沖區,讓它可以再次被寫入。有兩種方式能清空緩沖區:調用clear()或compact()方法。clear()方法會清空整個緩沖區。compact()方法只會清除已經讀過的數據。任何未讀的數據都被移到緩沖區的起始處,新寫入的數據將放到緩沖區未讀數據的后面。以下是所有Buffer共有的方法概要,具體的Buffer提供的接口可能稍有不同,可以參考jdk文檔來查看具體的操作。這里需要特別提到一下MappedByteBuffer和DirectByteBuffer,有什么特別的嘛?前者使用了一種類似于mmap(文件映射內存)的技術,而后者申請的內存是堆外內存,也就是申請的內存不是jvm管理的,這樣的好處的明顯的,前者可以將文件的部分或者全部內容映射到內存中,實現了讀寫文件就好像是讀寫內存一樣高效, 后者實現了所謂的“零拷貝”。
“零拷貝”是指計算機操作的過程中,CPU不需要為數據在內存之間的拷貝消耗資源。而它通常是指計算機在網絡上發送文件時,不需要將文件內容拷貝到用戶空間(User Space)而直接在內核空間(Kernel Space)中傳輸到網絡的方式。
Non-Zero Copy方式:
Zero Copy方式:
Zero Copy的模式中,避免了數據在用戶空間和內存空間之間的拷貝,從而提高了系統的整體性能。Linux中的sendfile()以及Java NIO中的FileChannel.transferTo()方法都實現了零拷貝的功能。非直接內存方式,數據需要在如下空間進行復制:
JVM Heap <——> JVM用戶空間 <——> OS內核空間 <——> 網卡驅動空間;
直接內存方式時,數據需要在如下空間進行復制:
JVM用戶空間 <——> OS內核空間 <——> 網卡驅動空間
所以當進行大量網絡通信時采用直接內存方式,將減少一次復制,以及在Heap上對象的創建,將提高系統性能DirectByteBuffer屬于直接訪問內存方式,其空間位于JVM用戶空間,不能由GC回收。java基于Cleaner和PhantomReference進行存儲空間回收,也可以手動調用Cleaner進行回收。
五、Selector
Selector(選擇器)使得NIO中能夠監聽一到多個通道,并且知道這些通道是否為讀寫做好準備的組件,這樣一個線程可以通過管理多個Channel,進而管理多個網絡連接。使用一個線程管理多個網絡連接的好處在于可以避免線程間切換的開銷。下面示范如何以一個Selector管理Channel。
首先是Selector的建立
//通過靜態的open()方法得到一個Selector
Selector selector = Selector.open();
然后是向Selector注冊一個ServerSocketChannel并監聽連接事件:
//對于監聽的端口打開一個ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//注冊到Selector的Channel必須設置為非阻塞模式,否則實現不了異步IO
serverSocketChannel.configureBlocking(false);
ServerSocket serverSocket = serverSocketChannel.socket();
InetSocketAddress address = new InetSocketAddress(8080);
serverSocket.bind(address);
//第二個參數是表明這個Channel感興趣的事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
與Selector同時使用的Channel必須處于非阻塞模式,這意味著FileChannel不能用于Selector,因為它不能切換到非阻塞通道;而套接字通道都是可以的。register的第二個參數表明了該Channel感興趣的事件,具體的事件分為四個類型:
1.Connect
2.Accept
3.Read
4.Write
具體來說某個channel成功連接到另一個服務器稱為“連接就緒”。一個server socket channel準備好接收新進入的連接稱為“接收就緒”。一個有數據可讀的通道可以說是“讀就緒”。等待寫數據的通道可以說是“寫就緒”。這些事件可以用SelectionKey的四個常量來表示:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
上面的Channel只是注冊了一個事件,但實際上是可以同時注冊多個事件的,比如可以像下面這樣同時注冊"接收就緒"和"讀就緒"兩個事件:
//使用"|"連接同時注冊多個事件
serverSocketChannel
.register(selector, SelectionKey.OPACCEPT|SelectionKey.OPREAD);
SelectionKey
上面向Selector注冊Channel后返回了一個SelectionKey對象,這個對象包含了一些很有用的信息集:
- interest集合
- ready集合
- Channel
- Selector
interest集合即上面Channel注冊時添加的感興趣的事件集合,我們可以通過調用SelectionKey 的interestOps()方法得到一個int數字,然后通過“&”位操作來確定具體有哪些感興趣的集合:
int interestSet = key.interestOps();
//是否包含ACCEPT事件
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
//是否包含CONNECT事件
boolean isInterestedInConnect = (interestSet & SelectionKey.OP_CONNECT) == SelectionKey.OP_CONNECT;
boolean isInterestedInRead = (interestSet & SelectionKey.OP_READ) == SelectionKey.OP_READ;
boolean isInterestedInWrite = (interestSet & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE;
ready集合表明該Selector上已經就緒的事件,可以通過key.readyOps()獲得一個數字,然后通過上面同樣的方式拿到就緒的集合;但是,也可以使用下面這些更加簡潔的方法判斷:
//四個返回boolean值的方法,可以用于判斷目前Selector上有哪些事件已經就緒
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
可以很簡單的拿到這個SelectinKey關聯的Selector和Channel,如下所示:
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
監聽Selector選擇通道
當向Selector注冊了幾個Channel之后,就可以調用幾個重載的select()方法來檢測是否有通道已經就緒了。具體的來說,Selector的select()方法有以下三種形式:
int select()
int select(long timeout)
int selectNow()
第一個方法會阻塞直到至少有一個通道就緒然后返回;第二個方法和第一個方法類似但不會一直阻塞而是至多會阻塞timeout時間;第三個方法不會阻塞,無論有無就緒的通道都會立即返回,如果沒有就緒的通道會返回0。這些方法返回的int值表明該Selector上就緒通道的數量,準確的來說是自上次調用select()方法后有多少通道變成就緒狀態。如果調用select()方法,因為有一個通道變成就緒狀態,返回了1,若再次調用select()方法,如果另一個通道就緒了,它會再次返回1。如果對第一個就緒的channel沒有做任何操作,現在就有兩個就緒的通道,但在每次select()方法調用之間,只有一個通道就緒了。如果調用select()方法表明至少有一個通道就緒了,那么就可以通過selector.selectedKeys()方法來獲得具體就緒的通道,這個方法的返回值是Set<SelectionKey>。如上面所介紹的我們可以很方便的通過SelectionKey找到就緒的事件以及對應的Channel,下面的代碼示例了如何遍歷這個Set:
Set<SelectionKey> selectionKeySet = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeySet.iterator();
while (iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
if(selectionKey.isAcceptable()){
// a connection was accepted by a ServerSocketChannel.
}else if(selectionKey.isConnectable()){
// a connection was established with a remote server.
}else if(selectionKey.isWritable()){
// a channel is ready for writing
}else if(selectionKey.isReadable()){
// a channel is ready for reading
}
iterator.remove();
}
注意末尾的remove()方法,當處理完一個SelectionKey之后,必須手動的將其從Set中移除,Selector本身不會進行這個工作,所以需要我們手動移除避免下一次重復處理。
ServerSocketChannel
其實從上面的代碼中我們已經看到了,ServerSocketChannel和ServerSocket所起的作用是一致的,都是用來監聽tcp連接的;值得注意的就是ServerSocketChannel是可以設置為非阻塞模式的,這時候它的accept()方法在沒有連接進入的情況下總是返回null。下面的代碼示例了ServerSocketChannel的基本用法:
//ServerSocketChannel對象通過靜態方法獲取
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//具體的端口綁定操作還是通過關聯的ServerSocket實現
ServerSocket ss = serverSocketChannel.socket();
InetSocketAddress address = new InetSocketAddress(8080);
ss.bind(address);
//ServerSocketChannel可以被設置成非阻塞的模式,這是和Selector配合使用的基礎
serverSocketChannel.configureBlocking(false);
while (true){
//accept()方法用于監聽進來的連接,如果被設置為非阻塞模式,那么當沒有連接時總是返回null
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) {
//do something with socketChannel...
}
}
SocketChannel
Java NIO中的SocketChannel是一個連接到TCP網絡套接字的通道,和Socket是類似的??梢酝ㄟ^以下2種方式創建SocketChannel:
1、 打開一個SocketChannel并連接到互聯網上的某臺服務器。
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost",80));
2 、一個新連接到達ServerSocketChannel時,會創建一個SocketChannel。如上面介紹ServerSocketChannel的代碼所示SocketChannel的數據讀寫和FileChannel沒有什么不同,都是需要借助Buffer;值得注意的是SocketChannel是可以工作在非阻塞模式下的,這時候的read()、write()方法都會直接返回,這種模式主要是為了配合Selector來實現異步非阻塞IO。