傳統的IO編程
傳統的IO編程能夠實現客戶端和服務端的通信,但是確實阻塞IO。
下面我們通過一個簡單的例子來看一下:客戶端每隔兩秒發送一個帶有時間戳的"hello world"給服務端,服務端收到之後打印出來。
ServerSocket serverSocket = new ServerSocket(9999);
while(true){
try{
//阻塞方法獲取新的連接
Socket socket = serverSocket.accept();
new Thread(()->{
try{
int len;
byte[] data = new byte[1024];
InputStream is = socket.getInputStream();
//按照字節流大方式讀取數據
while( (len=is.read(data)) != -1){
System.out.println(new String(data, 0, len));
}
}catch(Exception e){
e.printStackTrace();
}
}).start();
}catch(Exception e){
e.printStackTrace();
}
}
server端首先創建一個serverSocket來監聽9999端口,每當獲取新的連接時,就會給每個連接創建一個新的線程,該線程負責從該連接中讀取數據,從而實現多線程服務端和客戶端的通信。
try{
Socket socket = new Socket(9999);
while(true){
socket.getOutputStream().write( (new Date() + ": hello world").getBytes());
Thread.sleep(2000);
}
}catch(Exception e){
e.printStackTrace();
}
客戶端每隔2s向服務端寫一個帶有時間戳的"hello world"。
IO編程模型在客戶端較少的情況下運行良好,但是對於客戶端比較多的業務來說,單機服務端可能需要支撐上千萬的連接,IO模型就不太合適了,原因如下:
1. 線程資源受限:線程是操作系統中非常寶貴的資源,同一時刻有大量的線程處於阻塞狀態時時非常嚴重的資源浪費,操作系統耗不起;
NIO是對阻塞IO的改進,它是非阻塞的IO。下面來描述一下NIO是如何解決上面三個問題的。
NIO 編程模型中,新來一個連接不再創建一個新的線程,而是可以把這條連接直接綁定到某個固定的線程,然後這條連接所有的讀寫都由這個線程來負責,那麼他是怎麼做到的?我們用一幅圖來對比一下 IO 與 NIO。
如上圖所示,IO 模型中,一個連接來了,會創建一個線程,對應一個 while 死循環,死循環的目的就是不斷監測這條連接上是否有數據可以讀,大多數情況下,1w 個連接裏面同一時刻只有少量的連接有數據可讀,因此,很多個 while 死循環都白白浪費掉了,因爲讀不出啥數據。
而在 NIO 模型中,他把這麼多 while 死循環變成一個while死循環,這個死循環由一個線程控制,那麼他又是如何做到一個線程,一個 while 死循環就能監測1w個連接是否有數據可讀的呢? 這就是 NIO 模型中 selector 的作用,一條連接來了之後,現在不創建一個 while 死循環去監聽是否有數據可讀了,而是直接把這條連接註冊到 selector 上,然後,通過檢查這個 selector,就可以批量監測出有數據可讀的連接,進而讀取數據。
由於 NIO 模型中線程數量大大降低,線程切換效率因此也大幅度提高。
IO讀寫是面向流的,一次性只能從流中讀取一個或者多個字節,並且在讀完之後,流無法再讀取,需要自己緩存數據,而NIO的讀寫是面向Buffer的,可以隨意的讀取任何一個字節數據,不需要自己混緩存數據,這一切只需要移動讀寫指針即可。
Buffer 作爲 IO 流中數據的緩衝區,而 Channel 則作爲 socket 的 IO 流與 Buffer 的傳輸通道。客戶端 socket 與服務端 socket 之間的 IO 傳輸不直接把數據交給 CPU 使用,而是先經過 Channel 通道把數據保存到 Buffer,然後 CPU 直接從 Buffer 區讀寫數據,一次可以讀寫更多的內容。
使用 Buffer 提高 IO 效率的原因(這裏與IO流裏面的 BufferedXXStream、BufferedReader、BufferedWriter 提高性能的原理一樣):IO 的耗時主要花在數據傳輸的路上,普通的 IO 是一個字節一個字節地傳輸,而採用了 Buffer 的話,通過 Buffer 封裝的方法(比如一次讀一行,則以行爲單位傳輸而不是一個字節一次進行傳輸)就可以實現“一大塊字節”的傳輸。
選擇器,實現一個單獨的線程來監控多個註冊在她上面的信道Channel,通過一定的選擇機制,實現多路複用的效果。
多路複用是指使用單線程也可以通過輪詢監控的方式實現多線程類似的效果。簡單的說就是,通過選擇機制,使用一個單獨的線程很容易來管理多個通道。
簡單講完了NIO相對於IO的優點之後,我們接下來系統的學習一下NIO中Buffer、Selector、Channel的使用方法,最後根據掌握知識使用NIO的方案替換掉IO的方案。
緩衝區是一個用於特定基本數據類型的容器。由 java.nio 包定義的,所有緩衝區都是 Buffer 抽象類的子類。 Java NIO 中的 Buffer 主要用於與 NIO 通道進行交互,數據是從通道讀入緩衝區,從緩衝區寫入通道中的。
1. 容量capacity: 表示Buffer的最大數據容量,緩衝區容量不能爲負,並且創建之後不能更改。如果寫入的數據超出了capacity,就會觸發異常。
2. 限制limit: 第一個不應該讀取或寫入的數據的索引,即位於 limit 後的數據 不可讀寫。緩衝區的限制不能爲負,並且不能大於其容量。
3. 位置position: 下一個要讀取或寫入的數據的索引。緩衝區的位置不能爲負,並且不能大於其限制。
4. 標記 (mark)與重置 (reset): 標記是一個索引,通過 Buffer 中的 mark() 方法指定Buffer 中一個特定的position,之後可以通過調用 reset() 方法恢復到這個position。
mark、position、limit、capacity遵守以下不變式: 0 <= mark <= position <= limit <= capacity
接下來我們以ByteBuffer爲例,來詳細的瞭解一下Buffer的用法。
ByteBuffer同樣是一個抽象類,我們通過allocate方法,最終創建的是HeapByteBuffer對象。
static ByteBuffer allocate(int capacity) 分配一個新的byte型緩衝區
static ByteBuffer allocateDirect(int capacity)
分配一個新的字節緩衝區,同allocate不同的是,緩衝區每一個字節都被初始化爲0
ByteBuffer buffer = ByteBuffer.allocate(10);
此時我們關注一下capacity、position、limit、remaining值得變化。
positon:0 remaining:10 limit:10 capacity:10
//remainning = limit - position
capacity表示容量的大小,爲初始化是傳入的值的大小,之後便不會變化。positon指向即將要操作的位置。在寫狀態下limit表示可寫的空間的大小。remaining表示剩餘可寫空間的大小。
1. abstract ByteBuffer put(byte b):將字節b寫入緩衝區當前的位置position,然後position+1
2. abstract ByteBuffer put(ByteBuffer src),將src中可讀的部分(也就是position到limit)寫入當前的緩衝區
3. ByteBuffer put(byte[] src, int offset, int length),把字節數組src從offset開始的length字節寫入緩衝區當前的位置position,然後position位置後移length個位置。
4. final ByteBuffer put(byte[] src):把字節數組src寫入緩衝區當前的位置position。
String str = "ABC";
byte[] bytes = str.getBytes();
buffer.put(bytes);
put完之後,我們嘗試從buffer中讀一些數據,flip方法是將寫模式變成讀模式,它的實現如下。(將剛剛寫入的數據讀出)
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;//清除mark
return this;
}
可以看到它把position的值變成了0,把position的值賦給了limit,表示從起始位置開始讀,來看一下調用之後值的變化。
position變成了0,limit變成了原來position的值,也就是3。remaining也爲3,capacity不變。
1. abstract byte get():從緩衝區中讀取當前位置position的字節,然後position後移一個位置
2. ByteBuffer get(byte[] dst):將字節緩衝區中的內容讀出,存入字節數組dst中
3. ByteBuffer get(byte[] dst, int offset, int length):把字節緩衝區中內容讀出,存入字節數組dst中。
Byte byte1 = buffer.get();
這裏調用一下mark,mark之後不會有變化,只是會把position的值賦值給mark,我們看下它的實現代碼。注意。此時mark的值變成了1,後邊會用到這個值。
public final Buffer mark() {
mark = position;
return this;
}
buffer.mark();
還記得前面,我們調用mark,把position的值賦值給mark。這次我們來調用reset,它的作用是把之前mark的值重新賦值給position。它的實現如下:
public final Buffer reset() {
int m = mark;
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
Byte byte2 = buffer.get();
buffer.reset()
在讀了一些數據之後,如果我們想重新讀怎麼辦?可以用rewind,它會把position的值置爲0,同時mark值恢復爲-1。
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
最後我們來看一下clear的用法,clear會把position、limit、capacity恢復到初始狀態,它的實現如下:
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
------------------------------------------------------------------
Selector、Channel的介紹見IO到NIO的前因後果,以及NIO的用法(2)——Selector、Channel