初識NIO之Java小Demo

innoyiya發表於2018-08-21

Java中的IO、NIO、AIO:

BIO:在Java1.4之前,我們建立網路連線均使用BIO,屬於同步阻塞IO。預設情況下,當有一條請求接入就有一條執行緒專門接待。所以,在客戶端向服務端請求時,會詢問是否有空閒執行緒進行接待,如若沒有則一直等待或拒接。當併發量小時還可以接受,當請求量一多起來則會有許多執行緒生成,在Java中,多執行緒的上下文切換會消耗計算機有限的資源和效能,造成資源浪費。

NIO:NIO的出現是為了解決再BIO下的大併發量問題。其特點是能用一條執行緒管理所有連線。如下圖所示:

圖片來自網路
NIO是同步非阻塞模型,通過一條執行緒控制選擇器(Selector)來管理多個Channel,減少建立執行緒和上下文切換的浪費。當執行緒通過選擇器向某條Channel請求資料但其沒有資料時,是不會阻塞的,直接返回,繼續幹其他事。而一旦某Channel就緒,執行緒就能去呼叫請求資料等操作。當該執行緒對某條Channel進行寫操作時同樣不會被阻塞,所以該執行緒能夠對多個Channel進行管理。

NIO是面向緩衝流的,即資料寫入寫出都是通過 Channel —— Buffer 這一途徑。(雙向流通)

AIO:與之前兩個IO模型不同的是,AIO屬於非同步非阻塞模型。當進行讀寫操作時只須呼叫api的read方法和write方法,這兩種方法均是非同步。對於讀方法來說,當有流可讀取時,作業系統會將可讀的流傳入read方法的緩衝區,並通知應用程式;對於寫操作而言,當作業系統將write方法傳遞的流寫入完畢時,作業系統主動通知應用程式。換言之就是當呼叫完api後,作業系統完成後會呼叫回撥函式。

總結:一般IO分為同步阻塞模型(BIO),同步非阻塞模型(NIO),非同步阻塞模型,非同步非阻塞模型(AIO)

同步阻塞模型指的是當呼叫io操作時必須等到其io操作結束

同步非阻塞模型指當呼叫io操作時不必等待可以繼續幹其他事,但必須不斷詢問io操作是否完成。

非同步阻塞模型指應用呼叫io操作後,由作業系統完成io操作,但應用必須等待或去詢問作業系統是否完成。

非同步非阻塞指應用呼叫io操作後,由作業系統完成io操作並呼叫回撥函式,應用完成放手不管。

NIO的小Demo之服務端

首先,先看下服務端的大體程式碼

public class ServerHandle implements Runnable{
    //帶引數建構函式
    public ServerHandle(int port){
        
    }
    //停止方法
    public void shop(){
        
    }
    //寫方法
    private void write(SocketChannel socketChannel, String  response)throws IOException{
        
    }
    //當有連線進來時的處理方法
    private void handleInput(SelectionKey key) throws IOException{
        
    } 
    
    //服務端執行主體方法
    @Override
    public void run() {
    
    }
}
複製程式碼

首先我們先看看該服務端的建構函式的實現:

public ServerHandle(int port){
        try {
            //建立選擇器
            selector = Selector.open();
            //開啟監聽通道
            serverSocketChannel = ServerSocketChannel.open();
            //設定為非阻塞模式
            serverSocketChannel.configureBlocking(false);
            //傳入埠,並設定連線佇列最大為1024
            serverSocketChannel.socket().bind(new InetSocketAddress(port),1024);
            //監聽客戶端請求
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            //標記啟動標誌
            started = true;
            System.out.println("伺服器已啟動,埠號為:" + port);
        } catch (IOException e){
            e.printStackTrace();
            System.exit(1);
        }
    }
複製程式碼

在這裡建立了選擇器和監聽通道,並將該監聽通道註冊到選擇器上並選擇其感興趣的事件(accept)。後續其他接入的連線都將通過該 監聽通道 傳入。

然後就是寫方法的實現:

    private void doWrite(SocketChannel channel, String response) throws IOException {
        byte[] bytes = response.getBytes();
        ByteBuffer wirteBuffer = ByteBuffer.allocate(bytes.length);
        wirteBuffer.put(bytes);
        //將寫模式改為讀模式
        wirteBuffer.flip();
        //寫入管道
        channel.write(wirteBuffer);
    }
複製程式碼

其次是當由事件傳入時,即對連線進來的連結的處理方法

    private void handleInput(SelectionKey key) throws IOException{
        //當該鍵可用時
        if (key.isValid()){
            if (key.isAcceptable()){
                //返回該金鑰建立的通道。
                ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
                通過該通道獲取連結進來的通道
                SocketChannel socketChannel = serverSocketChannel.accept();
                socketChannel.configureBlocking(false);
                socketChannel.register(selector, SelectionKey.OP_READ);
            }
            if (key.isReadable()){
                //返回該金鑰建立的通道。
                SocketChannel socketChannel = (SocketChannel) key.channel();
                ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                int readBytes = socketChannel.read(byteBuffer);
                if (readBytes > 0){
                    byteBuffer.flip();
                    byte[] bytes = new byte[byteBuffer.remaining()];
                    byteBuffer.get(bytes);
                    String expression = new String(bytes, "UTF-8");
                    System.out.println("伺服器收到的資訊:" + expression);
                    //此處是為了區別列印在工作臺上的資料是由客戶端產生還是服務端產生
                    doWrite(socketChannel, "+++++" + expression + "+++++");
                } else if(readBytes == 0){
                    //無資料,忽略
                }else if (readBytes < 0){
                    //資源關閉
                    key.cancel();
                    socketChannel.close();
                }
            }
        }
    }
複製程式碼

這裡要說明的是,只要ServerSocketChannel及SocketChannel向Selector註冊了特定的事件,Selector就會監控這些事件是否發生。 如在構造方法中有一通道serverSocketChannel註冊了accept事件。當其就緒時就可以通過呼叫selector的selectorKeys()方法,訪問”已選擇鍵集“中的就緒通道。

壓軸方法:

    @Override
    public void run() {
        //迴圈遍歷
        while (started) {
            try {
                //當沒有就緒事件時阻塞
                selector.select();
                //返回就緒通道的鍵
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = keys.iterator();
                SelectionKey key;
                while (iterator.hasNext()){
                    key = iterator.next();
                    //獲取後必須移除,否則會陷入死迴圈
                    iterator.remove();
                    try {
                        //對就緒通道的處理方法,上述有描述
                        handleInput(key);
                    } catch (Exception e){
                        if (key != null){
                            key.cancel();
                            if (key.channel() != null) {
                                key.channel().close();
                            }
                        }
                    }
                }
            }catch (Throwable throwable){
                throwable.printStackTrace();
            }
        }
    }
複製程式碼

此方法為服務端的主體方法。大致流程如下:

  1. 開啟ServerSocketChannel,監聽客戶端連線
  2. 繫結監聽埠,設定連線為非阻塞模式(阻塞模式下不能註冊到選擇器)
  3. 建立Reactor執行緒,建立選擇器並啟動執行緒
  4. 將ServerSocketChannel註冊到Reactor執行緒中的Selector上,監聽ACCEPT事件
  5. Selector輪詢準備就緒的key
  6. Selector監聽到新的客戶端接入,處理新的接入請求,完成TCP三次握手,簡歷物理鏈路
  7. 設定客戶端鏈路為非阻塞模式
  8. 將新接入的客戶端連線註冊到Reactor執行緒的Selector上,監聽讀操作,讀取客戶端傳送的網路訊息 非同步讀取客戶端訊息到緩衝區
  9. 呼叫write將訊息非同步傳送給客戶端

NIO的小Demo之客戶端

public class ClientHandle implements Runnable{
    //建構函式,構造時順便繫結
    public ClientHandle(String ip, int port){
        
    }
    //處理就緒通道
    private void handleInput(SelectionKey key) throws IOException{
        
    }
    //寫方法(與服務端的寫方法一致)
    private void doWrite(SocketChannel channel,String request) throws IOException{
        
    }
    //連線到服務端
    private void doConnect() throws IOException{
        
    }
    //傳送資訊
    public void sendMsg(String msg) throws Exception{
        
    }
}
複製程式碼

首先先看建構函式的實現:

    public ClientHandle(String ip,int port) {
        this.host = ip;
        this.port = port;
        try{
            //建立選擇器
            selector = Selector.open();
            //開啟監聽通道
            socketChannel = SocketChannel.open();
            //如果為 true,則此通道將被置於阻塞模式;如果為 false,則此通道將被置於非阻塞模式
            socketChannel.configureBlocking(false);
            started = true;
        }catch(IOException e){
            e.printStackTrace();
            System.exit(1);
        }
    }
複製程式碼

接下來看對就緒通道的處理辦法:

    private void handleInput(SelectionKey key) throws IOException{
        if(key.isValid()){
            SocketChannel sc = (SocketChannel) key.channel();
            if(key.isConnectable()){
                //這裡的作用將在後面的程式碼(doConnect方法)說明
                if(sc.finishConnect()){
                    System.out.println("已連線事件");
                }
                else{
                    System.exit(1);
                }
            }
            //讀訊息
            if(key.isReadable()){
                //建立ByteBuffer,並開闢一個1k的緩衝區
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                //讀取請求碼流,返回讀取到的位元組數
                int readBytes = sc.read(buffer);
                //讀取到位元組,對位元組進行編解碼
                if(readBytes>0){
                    buffer.flip();
                    //根據緩衝區可讀位元組數建立位元組陣列
                    byte[] bytes = new byte[buffer.remaining()];
                    //將緩衝區可讀位元組陣列複製到新建的陣列中
                    buffer.get(bytes);
                    String result = new String(bytes,"UTF-8");
                    System.out.println("客戶端收到訊息:" + result);
                }lse if(readBytes==0){
                    //忽略
                }else if(readBytes<0){
                    //鏈路已經關閉,釋放資源
                    key.cancel();
                    sc.close();
                }
            }
        }
    }
複製程式碼

在run方法之前需先看下此方法的實現:

    private void doConnect() throws IOException{
        
        if(socketChannel.connect(new InetSocketAddress(host,port))){
            System.out.println("connect");
        }
        else {
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
            System.out.println("register");
        }
    }
複製程式碼

當SocketChannel工作於非阻塞模式下時,呼叫connect()時會立即返回: 如果連線建立成功則返回的是true(比如連線localhost時,能立即建立起連線),否則返回false。

在非阻塞模式下,返回false後,必須要在隨後的某個地方呼叫finishConnect()方法完成連線。 當SocketChannel處於阻塞模式下時,呼叫connect()時會進入阻塞,直至連線建立成功或者發生IO錯誤時,才從阻塞狀態中退出。

所以該程式碼在connect服務端後返回false(但還是有作用的),並在else語句將該通道註冊在選擇器上並選擇connect事件。

客戶端的run方法:

    @Override
    public void run() {
        try{
            doConnect();
        }catch(IOException e){
            e.printStackTrace();
            System.exit(1);
        }
        //迴圈遍歷selector
        while(started){
            try{
                selector.select();
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> it = keys.iterator();
                SelectionKey key ;
                while(it.hasNext()){
                    key = it.next();
                    it.remove();
                    try{
                        handleInput(key);
                    }catch(Exception e){
                        if(key != null){
                            key.cancel();
                            if(key.channel() != null){
                                key.channel().close();
                            }
                        }
                    }
                }
            }catch(Exception e){
                e.printStackTrace();
                System.exit(1);
            }
        }
        //selector關閉後會自動釋放裡面管理的資源
        if(selector != null){
            try{
                selector.close();
            }catch (Exception e) {
                e.printStackTrace();
            }
        }

    }
複製程式碼

傳送資訊到服務端的方法:

    public void sendMsg(String msg) throws Exception{
        //覆蓋其之前感興趣的事件(connect),將其更改為OP_READ
        socketChannel.register(selector, SelectionKey.OP_READ);
        doWrite(socketChannel, msg);
    }
複製程式碼

完整程式碼:

服務端:

/**
 * Created by innoyiya on 2018/8/20.
 */
public class Service {
    private static int DEFAULT_POST = 12345;
    private static ServerHandle serverHandle;
    public static void start(){
        start(DEFAULT_POST);
    }

    public static synchronized void start(int post) {
        if (serverHandle != null){
            serverHandle.shop();
        }
        serverHandle = new ServerHandle(post);
        new Thread(serverHandle,"server").start();
    }
}
複製程式碼

服務端主體:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * Created by innoyiya on 2018/8/20.
 */
public class ServerHandle implements Runnable{

    private Selector selector;
    private ServerSocketChannel serverSocketChannel;
    private volatile boolean started;

    public ServerHandle(int port){
        try {
            //建立選擇器
            selector = Selector.open();
            //開啟監聽通道
            serverSocketChannel = ServerSocketChannel.open();
            //設定為非阻塞模式
            serverSocketChannel.configureBlocking(false);
            //判定埠,並設定連線佇列最大為1024
            serverSocketChannel.socket().bind(new InetSocketAddress(port),1024);
            //監聽客戶端請求
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            //標記啟動標誌
            started = true;
            System.out.println("伺服器已啟動,埠號為:" + port);
        } catch (IOException e){
            e.printStackTrace();
            System.exit(1);
        }
    }
    public void shop(){
        started = false;
    }

    private void doWrite(SocketChannel channel, String response) throws IOException {
        byte[] bytes = response.getBytes();
        ByteBuffer wirteBuffer = ByteBuffer.allocate(bytes.length);
        wirteBuffer.put(bytes);
        wirteBuffer.flip();
        channel.write(wirteBuffer);
    }

    private void handleInput(SelectionKey key) throws IOException{
        if (key.isValid()){
            if (key.isAcceptable()){
                ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
                SocketChannel socketChannel = serverSocketChannel.accept();
                socketChannel.configureBlocking(false);
                socketChannel.register(selector, SelectionKey.OP_READ);
            }
            if (key.isReadable()){
                SocketChannel socketChannel = (SocketChannel) key.channel();
                ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                int readBytes = socketChannel.read(byteBuffer);
                if (readBytes > 0){
                    byteBuffer.flip();
                    byte[] bytes = new byte[byteBuffer.remaining()];
                    byteBuffer.get(bytes);
                    String expression = new String(bytes, "UTF-8");
                    System.out.println("伺服器收到的資訊:" + expression);
                    doWrite(socketChannel, "+++++" + expression + "+++++");
                } else if (readBytes < 0){
                    key.cancel();
                    socketChannel.close();
                }
            }
        }
    }

    @Override
    public void run() {
        //迴圈遍歷
        while (started) {
            try {
                selector.select();
                //System.out.println(selector.select());
                Set<SelectionKey> keys = selector.selectedKeys();
                //System.out.println(keys.size());
                Iterator<SelectionKey> iterator = keys.iterator();
                SelectionKey key;
                while (iterator.hasNext()){
                    key = iterator.next();
                    iterator.remove();
                    try {
                        handleInput(key);
                    } catch (Exception e){
                        if (key != null){
                            key.cancel();
                            if (key.channel() != null) {
                                key.channel().close();
                            }
                        }
                    }
                }
            }catch (Throwable throwable){
                throwable.printStackTrace();
            }
        }
    }
}
複製程式碼

客戶端:

/**
 * Created by innoyiya on 2018/8/20.
 */
public class Client {
    private static String DEFAULT_HOST = "localhost";
    private static int DEFAULT_PORT = 12345;
    private static ClientHandle clientHandle;
    private static final String EXIT = "exit";

    public static void start() {
        start(DEFAULT_HOST, DEFAULT_PORT);
    }

    public static synchronized void start(String ip, int port) {
        if (clientHandle != null){
            clientHandle.stop();
        }
        clientHandle = new ClientHandle(ip, port);
        new Thread(clientHandle, "Server").start();
    }

    //向伺服器傳送訊息
    public static boolean sendMsg(String msg) throws Exception {
        if (msg.equals(EXIT)){
            return false;
        }
        clientHandle.sendMsg(msg);
        return true;
    }

}
複製程式碼

客戶端主體程式碼:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * Created by innoyiya on 2018/8/20.
 */

public class ClientHandle implements Runnable{
    private String host;
    private int port;
    private Selector selector;
    private SocketChannel socketChannel;
    private volatile boolean started;

    public ClientHandle(String ip,int port) {
        this.host = ip;
        this.port = port;
        try{
            //建立選擇器
            selector = Selector.open();
            //開啟監聽通道
            socketChannel = SocketChannel.open();
            //如果為 true,則此通道將被置於阻塞模式;如果為 false,則此通道將被置於非阻塞模式
            socketChannel.configureBlocking(false);
            started = true;
        }catch(IOException e){
            e.printStackTrace();
            System.exit(1);
        }
    }
    public void stop(){
        started = false;
    }
    
    private void handleInput(SelectionKey key) throws IOException{
        if(key.isValid()){
            SocketChannel sc = (SocketChannel) key.channel();
            if(key.isConnectable()){
                if(sc.finishConnect()){
                    System.out.println("已連線事件");
                }
                else{
                    System.exit(1);
                }
            }
            //讀訊息
            if(key.isReadable()){
                //建立ByteBuffer,並開闢一個1M的緩衝區
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                //讀取請求碼流,返回讀取到的位元組數
                int readBytes = sc.read(buffer);
                //讀取到位元組,對位元組進行編解碼
                if(readBytes>0){
                    //將緩衝區當前的limit設定為position=0,用於後續對緩衝區的讀取操作
                    buffer.flip();
                    //根據緩衝區可讀位元組數建立位元組陣列
                    byte[] bytes = new byte[buffer.remaining()];
                    //將緩衝區可讀位元組陣列複製到新建的陣列中
                    buffer.get(bytes);
                    String result = new String(bytes,"UTF-8");
                    System.out.println("客戶端收到訊息:" + result);
                } else if(readBytes<0){
                    key.cancel();
                    sc.close();
                }
            }
        }
    }
    //非同步傳送訊息
    private void doWrite(SocketChannel channel,String request) throws IOException{
        byte[] bytes = request.getBytes();
        ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
        writeBuffer.put(bytes);
        //flip操作
        writeBuffer.flip();
        //傳送緩衝區的位元組陣列
        channel.write(writeBuffer);

    }
    private void doConnect() throws IOException{
        if(socketChannel.connect(new InetSocketAddress(host,port))){
            System.out.println("connect");
        }
        else {
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
            System.out.println("register");
        }
    }
    public void sendMsg(String msg) throws Exception{
        //覆蓋其之前感興趣的事件,將其更改為OP_READ
        socketChannel.register(selector, SelectionKey.OP_READ);
        doWrite(socketChannel, msg);
    }

    @Override
    public void run() {
        try{
            doConnect();
        }catch(IOException e){
            e.printStackTrace();
            System.exit(1);
        }
        //迴圈遍歷selector
        while(started){
            try{
                selector.select();

                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> it = keys.iterator();
                SelectionKey key ;
                while(it.hasNext()){
                    key = it.next();
                    it.remove();
                    try{
                        handleInput(key);
                    }catch(Exception e){
                        if(key != null){
                            key.cancel();
                            if(key.channel() != null){
                                key.channel().close();
                            }
                        }
                    }
                }
            }catch(Exception e){
                e.printStackTrace();
                System.exit(1);
            }
        }
        //selector關閉後會自動釋放裡面管理的資源
        if(selector != null){
            try{
                selector.close();
            }catch (Exception e) {
                e.printStackTrace();
            }
        }

    }
}
複製程式碼

測試類:

import java.util.Scanner;

/**
 * Created by innoyiya on 2018/8/20.
 */
public class Test {
    public static void main(String[] args) throws Exception {
        Service.start();
        Thread.sleep(1000);
        Client.start();
        while(Client.sendMsg(new Scanner(System.in).nextLine()));
    }
}
複製程式碼

控制檯列印:

伺服器已啟動,埠號為:12345
register
已連線事件
1234
伺服器收到的資訊:1234
客戶端收到訊息:+++++1234+++++
5678
伺服器收到的資訊:5678
客戶端收到訊息:+++++5678+++++
複製程式碼

如有不妥之處,請告訴我。

相關文章