咱們一直說,學習IO
和NIO
,但爲何要學習這些呢?
咱們分兩塊來看一下:html
本地的IO
很簡單,就是文件嘛,或者緩存,或者其餘的可保存數據的渠道。舉個很簡單的例子,若是咱們要把某些數據保存到文件裏面,好比咱們這篇文章,要保存到硬盤中,確定就須要寫入到硬盤中。這裏的寫入咱們就能夠稱爲涉及到IO
的使用。java
網絡的IO
理解起來會稍微不大同樣,由於他不像本地那麼直觀。
咱們先放一下,來複習一下網絡傳輸實際是怎樣的。程序員
服務器 -> 路由器 -> tcp/http/其餘協議 -> 路由器 -> 本地機器。通常狀況下咱們的理解是這樣的。若是咱們不往細了看,總體的流程是這樣的。但實際上從 服務器->路由器或者 路由器->本地機器這個過程當中涉及到 內核和 用戶態的一系列的協調,它們的協調處理才把數據真正傳輸完成。
服務器->路由器:這種狀況下,數據會由應用程序,即 用戶線程,經 內核線程,再經由 網卡,最後把數據傳輸到遠程機器,這裏數據在各個流程中的流轉,咱們也都稱他們涉及到IO
,由於他們涉及到存儲。
路由器->本地機器:這種狀況下,數據會由 網卡,經 內核線程,再傳到 用戶線程,即給到咱們的應用程序進行處理,這裏的流程中的轉換,也是涉及到IO
。
在Linux
和Unix
的哲學中,他們把全部的設備都當成是一個文件來處理,每個文件均可讀可寫,每個設備也是可讀可寫,這樣的抽象真是完美無缺。緩存
Java
程序員之痛曾已什麼時候,Java
程序員只有java.io
包中的那一系列相關的類,這些類,在咱們眼中稱爲BIO
,全稱爲Blocking-IO
,即阻塞性IO。什麼叫阻塞性IO呢?服務器
阻塞性IO要求應用程序在處理時,須要等待當前的IO徹底處理完成後才能夠繼續後面的操做,好比讀取文件,須要徹底讀取成功/或出現異常,才返回;寫入文件,則須要所有寫入成功後/或拋出異常才返回。阻塞,阻塞,就意味着你一旦開始作某件事情,就是必定要等到這件事作完才能夠。
這種狀況在正常狀況下是沒問題的,但試想一下,若是當前機器的IO
負載比較高,你這裏再來一個寫入文件的操做,是否是要等到天荒地老;或者你來個讀文件,原本都卡得快動不了了,你還讀文件,估計是更慘了。網絡
口說無憑,咱們來看段代碼,看看咱們以前是怎麼來對待這些IO
,而且被他們折磨的。框架
Server
阻塞性Server
有兩層概念:socket
Server
會一直等待客戶端的鏈接,一直到它正常創建鏈接,咱們的Server
都幹不了其餘事情。Server
還會一直等待客戶端的發送或者Server
會主動發送消息給客戶端 咱們直接看一下代碼:tcp
public class ServerSocketTest { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); //這裏會阻塞一直到有鏈接創建 Socket socket = serverSocket.accept(); //這裏讀取由客戶端發過來的內容 System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine()); socket.close(); serverSocket.close(); } }
能夠看到我這裏有兩行註釋,第一個是接收客戶端的鏈接,這裏是會阻塞,直到創建鏈接纔會正常返回。而第二個則會讀取客戶端發過來的內容,這裏會一直阻塞到客戶端調用write發送完成爲止。所以這裏對應了咱們上面說的兩層阻塞概念。ide
Client
阻塞性Client
也有一樣的兩層概念:
咱們一樣看一下代碼:
public class ClientSocketTest { public static void main(String[] args) throws IOException { //創建和服務端的鏈接 Socket socket = new Socket("localhost", 8080); //發送消息給服務端 socket.getOutputStream().write("helloworld".getBytes()); socket.close(); } }
這裏咱們演示發送消息給服務端。
單純說可能仍是比較難理解阻塞這個概念的,咱們能夠運行上面的示例。在Server
中的讀取客戶端輸入行設置斷點,在Client
中的發送消息設置斷點。按照如下的步驟進行調試
Server
Client
Server
——這裏咱們能夠發現執行完後會卡住Client
——這裏咱們繼續執行到socket.close
後,只有close
後纔會真正把消息發送出去。Server
,咱們發現已經正常返回了。從上面的現象,咱們能夠下結論,Server
在讀取Client
的發送數據時會阻塞,一直到收取消息完成,同理,Server
在發送數據到Client
時候也是同樣的,也是會阻塞直到發送完成。
看完上面的阻塞性代碼,你有什麼想法呢?
想一想,假設若是咱們這樣寫代碼,有多個客戶端同時鏈接的時候,要怎麼搞呢?
第一個客戶端鏈接成功,發送完成消息,斷開
第二個客戶端鏈接
...
就這樣,活生生變成了順序化的程序了。
那咱們應該怎麼辦呢?總不能就這樣將就用吧,讓每一個用戶等其餘人用完,估計會被用戶錘出翔啊。
這樣英年早逝還怎麼寫代碼呢?
聰明的程序員確定能想出辦法的。
既然它阻塞住了,那我就把它放到另一個線程處理唄,怎麼搞都不關我事。
那麼又有了這樣一個優化版本
說是阻塞的優化版,固然仍是阻塞了,不要想着能玩出什麼花。
public class ServerSocketTest { public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); while(true) { new Thread(() -> { //這裏會阻塞一直到有鏈接創建 Socket socket = null; try { socket = serverSocket.accept(); //這裏讀取由客戶端發過來的內容 System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine()); socket.close(); } catch (IOException e) { e.printStackTrace(); } }).start(); } } }
這裏咱們看到咱們來了個while(true)
這個很是嚇人的死循環,估計放其餘代碼裏面,頭都要被人打爆,但在這裏,是正常的,先不要激動。咱們這裏針對每個鏈接都起一個新的線程,這樣阻塞就不會影響到總體的運行了。
你們能夠再運行測試一下,看看是否是已經不會 阻塞 了。
Server
Client
,能夠設置斷點在大括號,模擬發送完消息暫停Server
的輸出咱們能夠看到有兩個輸出:
這下牛叉了,不 阻塞 了。但真的OK嗎?
咱們都知道,操做系統能夠啓用的線程數量是有限的,不能無限啓動,而且線程的上下文切換成本是很高的。若是不受限制地開線程,會致使系統CPU飆升,估計系統都會不可用。因此若是咱們用這種方式,假設有10個客戶端的時候,好像還沒啥事,但當去到100個,甚至500個的時候,估計系統都會開始運行緩慢了——咱們這種沒啥複雜業務的線程很快就結束了,對線程的佔用時間比較短,影響不算太大。但當業務複雜,每一個線程執行時間比較長的時候,就會出問題了。
從上面咱們瞭解到當線程數量一多的時候,就會致使系統出現各類各樣的問題。那應該怎麼辦呢?太多不行,那我限制一下總能夠了吧。我用線程池,限制能夠啓動的線程數量,這樣就不會由於線程數太多出問題了吧。
public class ServerSocketTest { private static final ExecutorService EXECUTOR_SERVICE = new ThreadPoolExecutor(5, 10, 3, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), r -> { Thread t = new Thread(r); t.setName("處理線程"); return new Thread(r); }); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(8080); while(true) { EXECUTOR_SERVICE.execute(() -> { //這裏會阻塞一直到有鏈接創建 Socket socket = null; try { socket = serverSocket.accept(); //這裏讀取由客戶端發過來的內容 System.out.println(new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine()); socket.close(); } catch (IOException e) { e.printStackTrace(); } }); } } }
先來看一下,咱們的線程池定義,5,10,3,10,這幾個是什麼鬼東西,不知道的能夠看看ThreadPoolExecutor
的JavaDoc
,Doug Lea大神寫得很是清楚了。我這裏大概描述下:
rejectHandler
,默認狀況下爲RejectedExecutionException
,即當有新的任務提交時,直接拒絕執行。注意,這裏的條件裏面的判斷條件都是運行的線程。不運行的是不算入數量裏面的。關於這個ThreadPoolExecutor
也是塊硬骨頭,後面再詳細聊聊,咱們仍是回到正題的IO這裏。
這裏咱們用了一個線程池去執行咱們的socket
鏈接後的處理邏輯——即咱們的阻塞讀取操做。那麼各個線程之間的阻塞就不會對其餘的線程形成影響。
但一樣的,有了線程池咱們就高枕無憂了嗎?
咱們看一下這裏咱們總的線程數是10(最大線程數量)+10(隊列數)=20,那假設20個線程都用完了,咱們的執行業務又須要去到幾秒鐘,那麼後面提交的就會被拒絕了。
有人說,那簡單,把線程數調大點,來個5000就行了。這。。。,估計沒仔細看前面的,回到前面看看,線程太大會致使切換損耗加大,對性能會有很大的影響。那不能調大線程數,那就加大隊列。呃,這也是能夠的,只是若是咱們的線程處理原本就慢,加大隊列只是徒增內存的壓力而已,並不會有任何用處。
那,咱們就沒辦法了嗎?乾瞪眼嗎?
程序員是不會認輸的。。。
因此纔有咱們這篇文章的NIO。
NIO
的橫空出世NIO
是啥東西來的?有些人叫New IO
,都2020年了,這JDK1.5
出的咱們還叫New IO
,這想一想都感受怪怪的。實際上在當時剛出的時間來看,叫New IO
是沒問題的,但慢慢隨着時間的推移,就不該該這樣的。而咱們看看New IO
的引入主要解決了什麼問題——阻塞。因此,咱們把NIO
稱爲Non-Blocking IO
會更合適一點,即非阻塞IO。
非阻塞就表明它不阻塞嗎?固然不是,NIO
也是支持阻塞調用的,就跟回到解放前同樣,用着複雜的NIO
的API幹着舊的java.io
乾的事情。這好很差,相信你有本身的見解。
爲了區分前面的普通IO和咱們如今的NIO
,咱們把以前的IO
稱爲BIO
,請你們注意。
NIO
真的是非阻塞嗎?前面咱們說了非阻塞不表明它就是原生非阻塞,你一樣能夠寫出阻塞的代碼。嗯,是的,咱們要回到解放前,來看看這種非通常的作法。
NIO
版阻塞Server
阻塞版的NIO
,服務端代碼咱們能夠看看。
public class BlockingMyServer { public static void main(String[] args) throws IOException { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(8080)); //這裏會阻塞一直到有鏈接創建 SocketChannel socketChannel = null; try { socketChannel = serverSocketChannel.accept(); //這裏讀取由客戶端發過來的內容 ByteBuffer byteBuffer = ByteBuffer.allocate(1024); int num = socketChannel.read(byteBuffer); System.out.println(new String(byteBuffer.array(), 0, num)); socketChannel.close(); } catch (IOException e) { e.printStackTrace(); } serverSocketChannel.close(); } }
從上面代碼咱們能夠看到,比正常的BIO代碼複雜了一些,主要是引入了一個新的ByteBuffer
類。這個類是啥東西呢?後面咱們再看,咱們先來看看這段代碼跟以前的BIO的有什麼流程上的區別嗎?while(true)
就不說了,只是寫法上的區別哈。咱們看到基本上大致流程一致:
NIO
版阻塞Client
阻塞版的NIO
客戶端代碼以下:
public class BlockingMyClient { public static void main(String[] args) throws IOException { //創建和服務端的鏈接 SocketChannel socket = SocketChannel.open(new InetSocketAddress(8080)); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); byteBuffer.put("helloworld".getBytes()); byteBuffer.flip(); //阻塞直到寫入成功 socket.write(byteBuffer); socket.close(); } }
咱們能夠看到大致流程也跟BIO客戶端代碼相似。但一樣也有一個奇怪的ByteBuffer
。
NIO
中的容器Buffer
咱們前面看到Server
和Client
都有一個ByteBuffer
,這究竟是個啥玩意。
那接下來咱們一塊兒來看一下Buffer
這個東西。
咱們首先能夠看到Buffer
這個類的JavaDoc
文檔的第一名話:
A container for data of a specific primitive type.
A buffer is a linear, finite sequence of elements of a specific primitive type. Aside from its content, the essential properties of a buffer are its capacity, limit, and position
咱們能夠看到Buffer
是基礎類型的容器,注意是基礎類型,而不是什麼自定義類型,而且它最重要的幾個屬性是capacity,limit,position,咱們來講一下這幾個概念:
故名思義,容量是指當前這個Buffer
最大能容納的內容,好比capacity
是20,那麼最大就只能容納20個咱們 指定類型的數據。
limit可能理解起來會比較難,它表示的是可讀或可寫的限制位置。
每個操做都會有它的起始位置,如讀即讀的起始位置,寫即寫的起始位置。
咱們用一張圖來幫忙理解:
來源:http://tutorials.jenkov.com/j...
在上面的Write Mode中,只有在position
和limit
中的空間是容許寫入,當大於limit
,則會拋出BufferOverflowException
;
而對於Read Mode來講是相似的,只有在position
和limit
中的空間是容許讀取的,當大於limit
,則會拋bm BufferUnderflowException
異常。
至於爲何這兩個異常不使用同一個,估計只有JSR
的專家才能解釋了。
有了這部分知識的補充,咱們回到上面的場景,咱們爲何要調用flip
呢,由於咱們put
完數據 後,此時的position
已是跟limit
是在同一個位置了,若是咱們此時調用write
,則會從當前的position
繼續讀數據以經過socket
傳輸,但這明顯是有問題的,後面並無任何數據 ,咱們須要把position
置到從頭開始,而且其餘的limit
也必須設置爲上次寫入的大小,由於須要調用flip
。
咱們直接看一下flip
的代碼就能夠容易理解了:
public final Buffer flip() { limit = position; position = 0; mark = -1; return this; }
把當前的位置,做爲可讀/可寫的限制,以後把位置置爲0,而把標記置爲未指定(-1)。挺好理解的。
在真正開始非阻塞的探索前,咱們先來看看多路複用這個東西。
這概念相應你們都挺熟的,畢竟提到多路複用基本上就至關因而poll
,select
,epoll
。
多路複用實際上有一個最大的好處:
系統負載小,由內核原生支持,不須要額外建立進程/線程。
說了這麼多,什麼叫多路複用呢?
多路複用的概念是這樣的:
有一個原生的進程能夠監視多個 描述符,一旦某個 描述符就緒,系統就能夠通知到應用程序,此時應用程序再根據相應的 描述符執行相應的 邏輯便可。
那它又跟NIO有啥關係呢?
咱們前面說了這麼多,IO的阻塞的最主要的緣由就是不知道讀寫何時結束。若是系統告訴我,何時能夠讀寫,那麼我在那個合適的時候去作合適的事情,那不就很省事了。其餘時間該幹嗎幹嗎去。
NIO
版非阻塞Server
前面咱們使用了NIO
實現了阻塞版的Server
,那感受真是酸爽,用一個原本不是這樣用的API
,硬是這樣搞,太彆扭了。因此,下面咱們來實現一版正常的NIO
的非阻塞的Server
,這裏咱們要用到上面說的多路複用的知識。
多路複用的概念在NIO
裏面的對應概念是Selector
。咱們直接來看代碼:
public class MyServer { public static void main(String[] args) throws IOException { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.bind(new InetSocketAddress("localhost", 8001)); Selector selector = Selector.open(); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); String str = ""; while(!Thread.currentThread().isInterrupted()) { //這裏是一直阻塞,直到有描述符就緒 selector.select(); Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = selectionKeys.iterator(); while(keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); keyIterator.remove(); //鏈接創建 if (key.isAcceptable()) { try { SocketChannel clientChannel = serverSocketChannel.accept(); clientChannel.configureBlocking(false); clientChannel.register(selector, SelectionKey.OP_READ); } catch (ClosedChannelException e) { e.printStackTrace(); } } //鏈接可讀,這時能夠直接讀 else if (key.isReadable()) { ByteBuffer readBuffer = ByteBuffer.allocate(1024); SocketChannel socketChannel = (SocketChannel) key.channel(); try { int num = socketChannel.read(readBuffer); str = new String(readBuffer.array(), 0, num); System.out.println("received message:" + str); } catch (IOException e) { e.printStackTrace(); } } } } } }
咱們能夠看到代碼比較複雜,先來理一下步驟:
ServerSocketChannel
,監聽8001
端口configureBlocking(false)
設置channel
爲非阻塞——關鍵 Selector.open
打開Selector
ACCEPT
select
判斷是否有就緒的描述符,這裏阻塞的 selectKeys
獲取就緒的描述符selectKeys
返回的描述符,進行相應的處理——這裏須要記得把處理完成的SelectionKey
刪除掉,即remove
register
對應的SelectionKey
咱們看到多路複用
的實現代碼比較複雜,步驟也比原來的BIO
的複製不少。但咱們須要看到這裏一個最大的進步就是由原來的等待
處理變成了由系統來通知咱們去處理。而這裏的通知,咱們是經過selectKeys
方法來實現的。
NIO
版非阻塞Client
咱們這裏的Client
也是使用多路複用的方式來使用,咱們直接看一下代碼。
public class MyClient { public static void main(String[] args) throws IOException { SocketChannel socketChannel = SocketChannel.open(); socketChannel.configureBlocking(false); socketChannel.connect(new InetSocketAddress("localhost", 8001)); Selector selector = Selector.open(); socketChannel.register(selector, SelectionKey.OP_CONNECT); while(!Thread.currentThread().isInterrupted()) { //阻塞直到有ready的SelectionKey返回 selector.select(); Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = keys.iterator(); while(keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); keyIterator.remove(); //鏈接已經創建了 if (key.isConnectable()) { try { socketChannel.finishConnect(); //註冊寫描述符 socketChannel.register(selector, SelectionKey.OP_WRITE); } catch (IOException e) { e.printStackTrace(); } } //socket可寫,能夠發東西給服務端了 else if (key.isWritable()) { ByteBuffer writeBuffer = ByteBuffer.allocate(1024); writeBuffer.put("hello world".getBytes()); try { writeBuffer.flip(); socketChannel.write(writeBuffer); } catch (IOException e) { e.printStackTrace(); } } } } } }
看完代碼,咱們梳理一下上面的步驟:
SocketChannel
,鏈接8001
端口configureBlock(false)
設置channel
爲非阻塞——關鍵 Selector.open
打開Selector
CONNECT
select
判斷是否有就緒的描述符,這裏阻塞的 selectKeys
獲取就緒的描述符selectKeys
返回的描述符,進行相應的處理——這裏須要記得把處理完成的SelectionKey
刪除掉,即remove
register
對應的SelectionKey
這裏咱們能夠看到步驟基本上跟服務端的步驟是一致的,只是初始的描述符不一致,server
是ACCEPT
,而client
是CONNECT
。
咱們一直說非阻塞IO,那什麼算是非阻塞IO。而咱們前面的BIO和NIO最大區別也就是在對IO的處理上。
BIO
BIO
使用的是直接調用讀/寫方法,一直到系統對其作出響應。
NIO
NIO
使用的阻塞描述符(或者說信號),直到信號OK了——即咱們代碼裏面的select
,直接返回,而後再進行處理,實際上在獲得描述符的時候仍是阻塞的,只是在真正執行讀/寫操做的時候,這個時候IO已是ready的狀態,這裏IO已經不是阻塞的狀態了。因此咱們這裏寫的非阻塞指的是IO,但描述符的獲取仍是阻塞的。
說了這麼多,咱們對NIO
和BIO
的一些介紹都已經基本上完了。如今基本上都比較少人直接使用NIO
或BIO
進行編碼,都是經過netty
或者其餘的一些高性能NIO
框架來使用。——dubbo
等在底層都使用了netty
做爲網絡層框架。
後面咱們會找機會介紹一下netty
在NIO
的使用上給予咱們的一些便利,和它爲何更適合咱們使用。