1. BIO和NIO
我們平常使用的IO是BIO(Blocking-IO),即阻塞IO、而NIO(No-blocking-IO)則是非阻塞IO,二者有什么區別呢?
預先知識准備
- 同步:發起調用后,調用者一直處理任務至結束后才返回結果,期間不能執行其他任務
- 異步:發起調用后,調用者立即返回結果的標記(當結果出來后用回調等機制通知),期間可以執行其他任務
- 阻塞:發起請求后,發起者一直等待結果返回,期間不能執行其他任務
- 非阻塞:發起請求后,發起者不用一直等待結果,期間可以執行其他任務
- IO模式有五種(BIO、NIO、信號驅動IO、多路復用IO、異步IO)這里介紹同步阻塞和同步非阻塞IO,而剩下的后面回來填坑
NIO主要體現在網絡IO中,所以下面就圍繞網絡IO來說明,這里會涉及到傳統的BIO、網絡編程、反應器設計模式,如果不了解的童鞋這里有各自的傳送門 BIO ,[未完善]
二者區別
BIO | NIO | |
---|---|---|
類型 | 同步阻塞 | 同步非阻塞 |
面向 | 面向流 | 面向緩沖區 |
組件 | 無 | 選擇器 |
若沒有了解過NIO,那么列出的區別只需有個印象即可,后面會逐步說明
2.BIO
2.1 傳統BIO
傳統的IO其讀寫操作都阻塞在同一個線程之中,即在讀寫期間不能再接收其他請求
那么我們就來看看傳統BIO是怎么實現的,后面都以網絡編程的Socket為例,因其與后面的NIO有關
public class BIO {
public static void main(String[] args) throws IOException {
// 開個線程運行服務器端套接字
new Thread( () -> {
try {
// 建立服務器端套接字
ServerSocket serverSocket = new ServerSocket(8080);
// 該方法阻塞至有請求過來
Socket socket = serverSocket.accept();
// 獲取輸入流
int length = 0;
byte[] bytes = new byte[1024];
InputStream in = socket.getInputStream();
// 客戶端關閉輸出流這里才會讀取到-1,shutdownOutput或者close
while( (length = in.read(bytes)) != -1){
System.out.println(new String(bytes,0,length));
}
System.out.println("這里服務器端處理任務花費了10秒");
Thread.sleep(10000);
// 獲取輸出流
OutputStream out = socket.getOutputStream();
out.write( ("這里是服務器端發送給客戶端的消息: " + new Date()).getBytes() );
// 關閉資源
in.close();
out.close();
socket.close();
serverSocket.close();
} catch (Exception e) {
}
}).start();
// 開個線程運行客戶端套接字
new Thread( () -> {
try {
// 建立客戶端套接字
Socket socket = new Socket("127.0.0.1",8080);
// 獲取輸出流
OutputStream out = socket.getOutputStream();
out.write( ("這里是客戶端發送給服務器端的消息:" + new Date()).getBytes() );
// 關閉輸出流,讓服務器知道數據已經發送完畢,剩下接收數據了
socket.shutdownOutput();
// 獲取輸入流
int length = 0;
byte[] bytes = new byte[1024];
InputStream in = socket.getInputStream();
while( (length = in.read(bytes)) != -1){
System.out.println(new String(bytes,0,length));
}
// 關閉資源,若沒有關閉則會保持連接至超時,單線程服務器端就不能接收后來的連接請求
out.close();
in.close();
socket.close();
} catch (Exception e) {
}
}).start();
}
}
這里是客戶端發送給服務器端的消息:Sat Feb 08 15:14:55 GMT+08:00 2020
這里服務器端處理任務花費了10秒
這里是服務器端發送給客戶端的消息: Sat Feb 08 15:15:05 GMT+08:00 2020
-
從輸出可以看出,客戶端會一直等待阻塞直至服務器端返回內容
-
服務器端的accept()方法會阻塞當前線程,直至有請求發送過來才會繼續accept()方法下面的代碼
-
服務器端接收到一個請求后且該請求還沒處理完,后又再有一個請求過來,則后來的請求會被阻塞需排隊等待
-
客戶端打開輸出流若沒關閉,則服務器端是不知道客戶端數據已經發送完,會一直等待至超時 ,關閉方法:
- 客戶端socket.close(),整個連接也關閉了
- 客戶端socket.shutdownOutput(),單方面關閉輸出流,不關閉連接
- 客戶端的outputStream.close(),會造成socket被關閉
2.2 偽異步BIO
傳統的BIO是單線程的,一次只能處理一個請求,而我們可以改進為多線程,即服務器端每接收到一個請求就為該請求單獨創建一個線程,而主線程還是繼續監聽是否有請求過來,偽異步是因為accept方法到底還是同步的
public class SocketTest {
// 定義線程接口
class MyRunnable implements Runnable{
@Override
public void run(){
try {
Socket socket = new Socket("127.0.0.1",8080);
// 獲取輸出流
OutputStream out = socket.getOutputStream();
out.write( ("這里是客戶端發送給服務器端的消息:" + new Date()).getBytes() );
// 關閉輸出流,讓服務器知道數據已經發送完畢,剩下接收數據了
socket.shutdownOutput();
// 獲取輸入流
int length = 0;
byte[] bytes = new byte[1024];
InputStream in = socket.getInputStream();
while( (length = in.read(bytes)) != -1){
System.out.println(new String(bytes,0,length));
}
// 關閉資源
out.close();
in.close();
socket.close();
} catch (Exception e) {
}
}
}
public static void main(String[] args) throws IOException, InterruptedException {
// 開個線程運行服務器端套接字
new Thread( () -> {
try {
// 建立服務器端套接字
ServerSocket serverSocket = new ServerSocket(8080);
// 循環接收請求
while(true){
Socket socket = serverSocket.accept();
// 為每個請求單獨開線程,這里就不那么復雜使用線程池了
new Thread( () -> {
try {
// 獲取輸入流
int length = 0;
byte[] bytes = new byte[1024];
InputStream in = socket.getInputStream();
while( (length = in.read(bytes)) != -1){
System.out.println(new String(bytes,0,length));
}
// 獲取輸出流
OutputStream out = socket.getOutputStream();
out.write( ("這里是服務器端發送給客戶端的消息: " + new Date()).getBytes() );
// 關閉資源
in.close();
out.close();
socket.close();
} catch (Exception e) {
// TODO: handle exception
}
}).start();
}
} catch (Exception e) {
}
}).start();
// 創建多線程,調用類中接口(為了偷懶寫成這樣了。。。)
// 關注點在於偽異步,像線程計數器,,線程池,Lambda表達式也盡量少用,使代碼易懂
SocketTest socketTest = new SocketTest();
MyRunnable myRunnable = socketTest.new MyRunnable();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
}
}
這里是客戶端發送給服務器端的消息:Sat Feb 08 15:52:00 GMT+08:00 2020
這里是服務器端發送給客戶端的消息: Sat Feb 08 15:52:00 GMT+08:00 2020
這里是客戶端發送給服務器端的消息:Sat Feb 08 15:52:00 GMT+08:00 2020
這里是客戶端發送給服務器端的消息:Sat Feb 08 15:52:00 GMT+08:00 2020
這里是服務器端發送給客戶端的消息: Sat Feb 08 15:52:00 GMT+08:00 2020
這里是服務器端發送給客戶端的消息: Sat Feb 08 15:52:00 GMT+08:00 2020
- 服務器端每來一個請求就為之單獨創建線程來處理任務,使主線程可以繼續循環接收請求
- 客戶端的請求之間就互不干擾了,不用等待上一個請求處理完才處理下一個
- 其本質還是同步,使用了多線程才實現異步功能
- 使用多線程,若在多高並發情況下,會大量創建線程而導致內存溢出(可以使用線程池優化,但有界限池終究不是辦法)
3. NIO
- 看了上面那么多鋪墊,終於到我們的正題了。NIO主要使用在網絡IO中,當然文件IO也有使用,NIO在高並發的網絡IO中有極大的優勢,其在JDK1.4中引入,以我們傳統再傳統的開發環境--1.7中可以使用了
- 在單線程中,NIO在寫讀數據的時候可以同時執行其他任務,不必等數據完全讀寫而導致阻塞(后面有地方說明)
NIO的組成
- Buffer(緩沖區)
- Channel(通道)
- Selector(選擇器)
那么我們就來看看NIO的三個組成把
3.1 Buffer
NIO是面向緩沖區的,一次處理一個區的數據,在NIO中我們都是使用緩沖區來處理數據,即數據的讀入或寫出都要經過緩沖區
緩沖區的類型有:
-
ByteBuffer、
-
ShortBuffer、
-
IntBuffer、
-
LongBuffer、
-
FloatBuffer、
-
DoubleBuffer、
-
CharBuffer
最常用是ByteBuffer,記住后面要用到,可使用靜態方法獲取緩沖區:ByteBuffer.allocate(1024)
Buffer類中主要的方法:
返回類型 | 函數 | 解釋 |
---|---|---|
XXXBuffer | allocate(int capacity) | 返回指定容量的緩沖區 |
ByteBuffer | put(byte[] src) | 向緩沖區添加字節數組 |
ByteBuffer | get(byte[] dst) | 向緩沖區獲取字節數組 |
XXXBuffer | flip() | 切換成讀模式 |
XXXBuffer | clear() | 清除此緩沖區 |
其內部維護了幾個變量
// Invariants: mark <= position <= limit <= capacity
private int mark = -1; // 標記這里不講解
private int position = 0; //位置
private int limit; // 限制
private int capacity; // 容量大小
變量的變化:
初始化時:position為0,limit和capacity為容量大小,且capacity不變化,后面省略
put數據時:position為put進去數據大小(如放進5字節數據,則position=5),其余不變,正常默認為寫模式
切換讀模式:limit賦值為position的當前值,而position賦值為0
get數據時:讀取多少個數據,position就前進幾個位置
清空:調用clear(),變量變為初始化狀態,即position為0,limit為容量大小
3.2 Channel
通道主要是傳輸數據的,不進行數據操作,並且與流不同可以前后移動,而且通道是雙向的讀寫的,最重要的是Channel只能與Buffer交互,所以要使用NIO就要用Channel和Buffer來配合
其類型包括:
- FileChannel
- DatagramChannel
- SocketChannel:
- ServerSocketChannel
可以看出NIO主要支持網絡IO及文件IO,可通過靜態方法獲取:ServerSocketChannel.open(),然后通過ServerSocketChannel.socket()獲取對應的套接字,套接字的獲取通道方法前提是已經綁定了通道才行,不然空指針
通道的主要方法:
類型 | 函數名 | 解釋 |
---|---|---|
ServerSocketChannel | open | 返回對應的通道 |
int | read(ByteBuffer dst) | 從該通道讀取到給定緩沖區的字節序列 |
int | write(ByteBuffer src) | 從給定的緩沖區向該通道寫入一個字節序列 |
ServerSocketChannel | bind(SocketAddress local) | 將通道的套接字綁定到本地,設為監聽連接 |
SelectableChannel | configureBlocking(Boolean bool) | 設置通道的阻塞模式 |
SelectionKey | register(Selector sel, int ops) | 將通道注冊到選擇器 |
配合Channel和Buffer來簡單實現數據流通
int length = 0;
while( (length = inChannel.read()) != -1 ){
buffer.flip(); //切換讀模式
outChannel.write(buffer); // 數據寫入通道
buffer.clear(); // 清空緩沖區,實現可再寫入
}
3.3 Selector
NIO特有的組件(選擇器容器),注意只有在網絡IO中才具有非阻塞性,網絡IO中的套接字的通道才有非阻塞的配置。使用單線程通過Selector來輪詢監聽多個Channel,在IO事件還沒到達時不會陷入阻塞態等待。划重點:傳統BIO在事件還沒到達時該線程會被阻塞而等待,一次只能處理一個請求(可以使用多線程來提高處理能力)。而NIO在事件還沒到達是非阻塞輪詢監聽的,一次可以處理多個事件。使用一個線程來處理多個事件明顯比一個線程處理一個事件更優秀,獲取選擇器:Selector.open()
選擇器主要方法:
類型 | 方法名 | 解釋 |
---|---|---|
void | close | 關閉此選擇器 |
Selector | open | 打開選擇器 |
int | select | 選擇一組准備好的IO鍵 |
Set
|
selectedKeys | 返回選擇器的鍵集 |
這里補充一下注冊通道時返回的鍵的方法
XXXChannel | channel | 返回鍵對應的通道,類似於句柄 |
boolean | isAcceptable | 鍵對應的通道是否准備好了 |
boolean | isReadable | 鍵對應的通道是否可讀 |
boolean | isWritable | 鍵對應的通道是否可寫 |
3.4 使用事例
綜合上面BIO的 2.1和 2.2的代碼,客戶端基本不用改動,使用多線程來模擬多次請求,而重點改造在於服務器端
這里的服務器端用單線程來處理請求,即一對多使用了多路復用。若是BIO單線程則會阻塞,即一請求一應答
public class NIOTest {0.
// 定義線程接口
class MyRunnable implements Runnable{
@Override
public void run(){
try {
Socket socket = new Socket("127.0.0.1",8080);
// 獲取輸出流
OutputStream out = socket.getOutputStream();
out.write( ("這里是客戶端發送給服務器端的消息:" + new Date()).getBytes() );
// 關閉輸出流,讓服務器知道數據已經發送完畢,剩下接收數據了
socket.shutdownOutput();
// 獲取輸入流
int length = 0;
byte[] bytes = new byte[1024];
InputStream in = socket.getInputStream();
while( (length = in.read(bytes)) != -1){
System.out.println(new String(bytes,0,length));
}
// 這里故意不關閉資源,保持連接
// 如果是BIO單線程,沒有斷開連接,則會阻塞后面的請求
// 而NIO則不會阻塞,因為是多路復用
} catch (Exception e) {
}
}
}
public static void main(String[] args) throws UnknownHostException, IOException {
// 開個線程運行服務器端套接字
new Thread( () -> {
try {
// 靜態方法獲取選擇器
// 開啟選擇器的線程會被選擇器阻塞,所以要另開一個線程執行
Selector selector = Selector.open();
// 獲取服務器端通道並配置非阻塞
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 8080);
serverSocketChannel.bind(inetSocketAddress);
// Selector管理Channel,則需將對應的channel注冊上去,且指定類型
// 將服務器通道注冊到選擇器上,注冊為accept
// 可頻道為:一看能看出來不解釋了
/**
* SelectionKey.OP_CONNECT
* SelectionKey.OP_ACCEPT
* SelectionKey.OP_READ
* SelectionKey.OP_WRITE
*/
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while(true){
// 輪詢監聽是否有准備好的連接
if(selector.select() > 0){
// 獲取鍵集
Set<SelectionKey> set = selector.selectedKeys();
Iterator iterator = set.iterator();
// 迭代器迭代
while(iterator.hasNext()){
SelectionKey selectionKey = (SelectionKey) iterator.next();
// 接收連接事件
if(selectionKey.isAcceptable()){
SocketChannel socketChannel = serverSocketChannel.accept();
// 設置客戶端通道為非阻塞,不然選擇器會被阻塞,其存在沒有意義了
socketChannel.configureBlocking(false);
// 將客戶端通道注冊到選擇器上,使選擇器可以統一管理
socketChannel.register(selector, SelectionKey.OP_READ);
// 處理可讀事件
}else if(selectionKey.isReadable()){
// 通過key來獲取通道
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
// 配合緩沖區
ByteBuffer bytebuffer = ByteBuffer.allocate(1024);
int length = 0;
byte[] bytes = new byte[1024];
while( (length = socketChannel.read(bytebuffer)) != -1){
bytebuffer.flip();
// 將緩沖區數據放入字節數組,並輸出
bytebuffer.get(bytes, 0, length);
System.out.println(new String(bytes,0,length));
bytebuffer.clear();
}
}
// 取消選擇鍵,因為有些已經處理了
iterator.remove();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
// 調用類中接口,創建多線程(為了偷懶寫成這樣了。。。)
NIOTest NIOTest = new NIOTest();
MyRunnable myRunnable = NIOTest.new MyRunnable();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
new Thread(myRunnable).start();
}
}
- 上面客戶端故意不關閉連接,未超時情況下也能處理多請求,則說明NIO是非阻塞的,最大好處就在於這里
總結
挖坑:AIO異步的IO,基於網絡編程的Netty框架,越來越多的坑要填了 —_—!