基於NIO的Socket通信(使用Java NIO的綜合示例講解)

本篇文章並不是NIOSocket的入門文章,如果你在看完後有些難度可以先學習基礎知識後再進行閱讀,但是本文的一些概念不論是入門還是學習已久的人都會有些許收穫。(感覺基礎不足的可以閱讀前文兩個鏈接來獲取更多的細節)

一、NIO的簡介

Java NIO( non-blocking IO)是從Java 1.4版本開始引入的一個新的IO API,Java NIO提供了與標準IO不同的IO工作方式:

IO NIO
面向流 面向緩衝區
阻塞 非阻塞

1. 面向流與面向緩衝
Java NIO和IO之間第一個最大的區別是,IO是面向流的,NIO是面向緩衝區的。 Java IO面向流意味着每次從流中讀一個或多個字節,直至讀取所有字節,它們沒有被緩存在任何地方。此外,它不能前後移動流中的數據。如果需要前後移動從流中讀取的數據,需要先將它緩存到一個緩衝區。 Java NIO的緩衝導向方法略有不同。數據讀取到一個它稍後處理的緩衝區,需要時可在緩衝區中前後移動。這就增加了處理過程中的靈活性。但是,還需要檢查是否該緩衝區中包含所有您需要處理的數據。而且,需確保當更多的數據讀入緩衝區時,不要覆蓋緩衝區裏尚未處理的數據。
2. 阻塞與非阻塞IO

Java IO的各種流是阻塞的。這意味着,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些數據被讀取,或數據完全寫入。該線程在此期間不能再幹任何事情了。 Java NIO的非阻塞模式,使一個線程從某通道發送請求讀取數據,但是它僅能得到目前可用的數據,如果目前沒有數據可用時,就什麼都不會獲取。而不是保持線程阻塞,所以直至數據變的可以讀取之前,該線程可以繼續做其他的事情。 非阻塞寫也是如此。一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。 線程通常將非阻塞IO的空閒時間用於在其它通道上執行IO操作,所以一個單獨的線程現在可以管理多個輸入和輸出通道(channel)。
3. 選擇器(Selectors)

Java NIO的選擇器允許一個單獨的線程來監視多個輸入通道,你可以註冊多個通道使用一個選擇器,然後使用一個單獨的線程來“選擇”通道:這些通道里已經有可以處理的輸入,或者選擇已準備寫入的通道。這種選擇機制,使得一個單獨的線程很容易來管理多個通道。
注意:
NIO的出現並不是要替代傳統IO,而是在彌補傳統IO的部分缺點,使用的場景不同。NIO雖然是非阻塞的,但是你在使用非阻塞write(ByteBuffer bf)時,當數據量較大時,可能會出現沒有傳輸完整的現象,如果要保證數據傳輸完整,就要通過代碼while(bf.hasReaming())while(已傳輸len<文件size)去持續寫入。這樣的話會出現代碼干預使其變成阻塞的了。而經過jdk不斷的優化,inputSream和OutputStream幾乎是傳輸速度最快的了,可以接近磁盤的寫/讀峯值(除了map內存映射),但是在有新的連接請求時卻需要新的線程來控制,而線程的創建開銷過於龐大,在線程間切換也有較大的消耗。總的來說,NIO適用於多條連接、多次請求,即需要多個線程多次操作,使用NIO可以減少創建線程和線程頻繁切換的消耗;傳統IO適用於少量用戶,每次請求需要傳輸大量數據,但是請求次數少,使用傳統IO可以提高傳輸的效率。(這裏所說的NIO代指非阻塞IO,傳統IO代表阻塞IO)

二、在閱讀示例前需要注意的一些知識點

可以簡單閱讀,查漏補缺,如果都已理解可以直接跳至代碼部分,如果有部分不懂也可以閱讀示例代碼後再來印證。

1. ByteBuffer

1)幾個重要的屬性:

  • capacity :緩衝區的容量,ByteBuffer實際上就是Byte數組的抽象結構,容量和數組的長度一致

  • position:下一個寫入/讀取的索引

  • limit :第一個不應該寫入/讀取的索引

  • mark: 一個標記點,在使用reset()後,可將position重新置於mark標記處

    每次get()/get(byte[] bytes)put(byte x)/put(byte[] bytes)都會移動position。當position==limit時繼續操作會產生異常。前面這種稱之爲相對讀/寫,每次從position位置開始操作,還有一種絕對位置操作get(int index)/put(byte x, int index),從給定的index處操作,該種操作不會影響postion。

2)幾個常用易混的操作:

  1. clear()

    public final Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }
    
  2. flip()

    public final Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }
    
  3. rewind()

    public final Buffer rewind() {
        position = 0;
        mark = -1;
        return this;
    }
    
  4. reset()

    public final Buffer reset() {
           int m = mark;
           if (m < 0)
               throw new InvalidMarkException();
           position = m;
           return this;
       }
    

    5.compact() 壓縮

    	public ByteBuffer compact() {
    	        System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
    	        position(remaining());
    	        limit(capacity());
    	        discardMark();
    	        return this;
    	    }
    

該方法將postion和limit之間的空間(未操作)複製到0~(limit-position)也就是remaining()的位置,也就是說把未使用的放到前面,通常搭配flip()使用,因爲壓縮後limit=capacity,position=(limit-position),經過flip後,position變爲0,limit變爲(limit-position),剛好就是未操作的空間。
3)中文亂碼

  1. 在發送端使用ByteBuffer.warp(string.getBytes(“utf-8”));
  2. 在接收端使用CharSet.forName(“utf-8”).decode(bytebuffer);
    本文示例因在一臺機器,所以編碼一致,爲了突出主題,沒有使用編碼統一。

2. 選擇器與I/O多路複用

1) Selector選擇器是NIO技術中的核心組件,可以調用SelectableChannel類的public abstract SelectionKey register(Selector sel, int ops, Object att)throws ClosedChannelException方法來向通道註冊事件(也就是向通道註冊感興趣的事件),包括OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT,分別對應讀取、寫入,請求連接、響應連接請求。
2)一些零散的知識點,在寫代碼時需要注意的

  1. NIo並不一定是單線程,在jdk的源碼中,每註冊1023個通道,selector會創建新的線程
  2. 每一個selector有三個鍵集,keys、selected keys、cancel keys
  3. 每個通道對應一個SelectionKey,不管註冊多少次返回的都是相同的一個鍵。
  4. 將通道註冊到selector一定要配置爲非阻塞,否則會發生異常
  5. 註冊後事件存在於keys中,經過 select() 後,系統發現有事件準備好了,該事件的key就會被選擇,加入selectedKeys中,select()返回值爲已準備好的key數量,該方法爲阻塞方法,只有返回值爲大於0才返回(經過很長一段時間後無果也會返回),可以被wakeUp()方法喚醒
  6. read、accept、connect 調用select() 後通常會阻塞,因爲不是每刻都有數據寫入需要讀取,有連接請求、需要響應。但是write幾乎是不阻塞的,所以一定要注意在註冊OP_WRITE時,一般要在寫完之後,註冊爲OP_READ(),來使下一次的select()方法阻塞,不然在輪詢時會發生死循環。即使你remove()也只是從selectedKeys中刪除,下一次還是會從keys中移到selectedKeys中。而keys中的元素無法直接刪除(會發生異常),必須等待channel關閉或者key被cancel。
  7. select()方法的機制是等待通知,在系統中有操作準備好後,通知jvm,而不是循環輪詢。
  8. select()方法執行後,再註冊的事件,select()不會去關心
  9. connect()方法是非阻塞的,這意味着連接可能尚未建立成功,該方法已經返回,使用時可能是未創建完成的連接。因此在使用時應當注意,可以通過添加while (!socket.finishConnect());來保證連接創建完成。
  10. key在cancel後不會立即刪除,而是在下一次select()方法調用時再移除。已關閉的通道的key也會在select()時移除。
  11. Socket通信在關閉時會給對方發送報文,會觸發對方註冊的OP_READ,但是在read()時卻會因爲已經關閉,而發生異常。 注意代碼中如何判斷對方已關閉。

三、示例代碼

1. 客戶端

public class Socket {
    public static void main(String[] args) {
        try {
            //初始化客戶端
            SocketChannel socket = SocketChannel.open();
            socket.configureBlocking(false);
            Selector selector = Selector.open();
            //註冊連接事件
            socket.register(selector, SelectionKey.OP_CONNECT);
            //發起連接
            socket.connect(new InetSocketAddress("localhost", 9999));
            //開啓控制檯輸入監聽
            new ChatThread(selector, socket).start();
            Calendar ca = Calendar.getInstance();
            //輪詢處理
            while (true) {
                if (socket.isOpen()) {
                    //在註冊的鍵中選擇已準備就緒的事件
                    selector.select();
                    //已選擇鍵集
                    Set<SelectionKey> keys = selector.selectedKeys();
                    Iterator<SelectionKey> iterator = keys.iterator();
                    //處理準備就緒的事件
                    while (iterator.hasNext()) {
                        SelectionKey key = iterator.next();
                        //刪除當前鍵,避免重複消費
                        iterator.remove();
                        //連接
                        if (key.isConnectable()) {
                            //在非阻塞模式下connect也是非阻塞的,所以要確保連接已經建立完成
                            while (!socket.finishConnect()) {
                                System.out.println("連接中");
                            }
                            socket.register(selector, SelectionKey.OP_READ);
                        }
                        //控制檯監聽到有輸入,註冊OP_WRITE,然後將消息附在attachment中
                        if (key.isWritable()) {
                            //發送消息給服務端
                            socket.write((ByteBuffer) key.attachment());
                            /*
	                            已處理完此次輸入,但OP_WRITE只要當前通道輸出方向沒有被佔用
	                            就會準備就緒,select()不會阻塞(但我們需要控制檯觸發,在沒有輸入時
	                            select()需要阻塞),因此改爲監聽OP_READ事件,該事件只有在socket
	                            有輸入時select()纔會返回。
                            */
                            socket.register(selector, SelectionKey.OP_READ);
                            System.out.println("==============" + ca.getTime() + " ==============");
                        }
                        //處理輸入事件
                        if (key.isReadable()) {

                            ByteBuffer byteBuffer = ByteBuffer.allocate(1024 * 4);
                            int len = 0;
                            //捕獲異常,因爲在服務端關閉後會發送FIN報文,會觸發read事件,但連接已關閉,此時read()會產生異常
                            try {

                                if ((len = socket.read(byteBuffer)) > 0) {
                                    System.out.println("接收到來自服務器的消息\t");
                                    System.out.println(new String(byteBuffer.array(), 0, len));
                                }
                            } catch (IOException e) {
                                System.out.println("服務器異常,請聯繫客服人員!正在關閉客戶端.........");
                                key.cancel();
                                socket.close();
                            }
                            System.out.println("=========================================================");
                        }
                    }
                } else {
                    break;
                }
            }

        } catch (IOException e) {
            System.out.println("客戶端異常,請重啓!");
        }
    }
}

2. 服務端

public class ServerSocket {
    public static void main(String[] args) {
        try {
            //服務初始化
            ServerSocketChannel serverSocket = ServerSocketChannel.open();
            //設置爲非阻塞
            serverSocket.configureBlocking(false);
            //綁定端口
            serverSocket.bind(new InetSocketAddress("localhost", 9999));
            //註冊OP_ACCEPT事件(即監聽該事件,如果有客戶端發來連接請求,則該鍵在select()後被選中)
            Selector selector = Selector.open();
            serverSocket.register(selector, SelectionKey.OP_ACCEPT);
            Calendar ca = Calendar.getInstance();
            System.out.println("服務端開啓了");
            System.out.println("=========================================================");
            //輪詢服務
            while (true) {
                //選擇準備好的事件
                selector.select();
                //已選擇的鍵集
                Iterator<SelectionKey> it = selector.selectedKeys().iterator();
                //處理已選擇鍵集事件
                while (it.hasNext()) {
                    SelectionKey key = it.next();
                    //處理掉後將鍵移除,避免重複消費(因爲下次選擇後,還在已選擇鍵集中)
                    it.remove();
                    //處理連接請求
                    if (key.isAcceptable()) {
                        //處理請求
                        SocketChannel socket = serverSocket.accept();
                        socket.configureBlocking(false);
                        //註冊read,監聽客戶端發送的消息
                        socket.register(selector, SelectionKey.OP_READ);
                        //keys爲所有鍵,除掉serverSocket註冊的鍵就是已連接socketChannel的數量
                        String message = "連接成功 你是第" + (selector.keys().size() - 1) + "個用戶";
                        //向客戶端發送消息
                        socket.write(ByteBuffer.wrap(message.getBytes()));
                        InetSocketAddress address = (InetSocketAddress) socket.getRemoteAddress();
                        //輸出客戶端地址
                        System.out.println(ca.getTime() + "\t" + address.getHostString() +
                                ":" + address.getPort() + "\t");
                        System.out.println("客戶端已連接");
                        System.out.println("=========================================================");
                    }
               
                    if (key.isReadable()) {
                        SocketChannel socket = (SocketChannel) key.channel();
                        InetSocketAddress address = (InetSocketAddress) socket.getRemoteAddress();
                        System.out.println(ca.getTime() + "\t" + address.getHostString() +
                                ":" + address.getPort() + "\t");
                        ByteBuffer bf = ByteBuffer.allocate(1024 * 4);
                        int len = 0;
                        byte[] res = new byte[1024 * 4];
                        //捕獲異常,因爲在客戶端關閉後會發送FIN報文,會觸發read事件,但連接已關閉,此時read()會產生異常
                        try {
                            while ((len = socket.read(bf)) != 0) {
                                bf.flip();
                                bf.get(res, 0, len);
                                System.out.println(new String(res, 0, len));
                                bf.clear();
                            }
                            System.out.println("=========================================================");
                        } catch (IOException e) {
                            //客戶端關閉了
                            key.cancel();
                            socket.close();
                            System.out.println("客戶端已斷開");
                            System.out.println("=========================================================");
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
            System.out.println("服務器異常,即將關閉..........");
            System.out.println("=========================================================");
        }
    }
}

3. 控制檯監聽線程

public class ChatThread extends Thread {

    private Selector selector;
    private SocketChannel socket;

    public ChatThread(Selector selector, SocketChannel socket) {
        super();
        this.selector = selector;
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            //等待連接建立
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Scanner scanner = new Scanner(System.in);
        System.out.println("請輸入您要發送給服務端的消息");
        System.out.println("=========================================================");
        while (scanner.hasNextLine()) {
            String s = scanner.nextLine();
            try {
                //用戶已輸入,註冊寫事件,將輸入的消息發送給客戶端
                socket.register(selector, SelectionKey.OP_WRITE, ByteBuffer.wrap(s.getBytes()));
                //喚醒之前因爲監聽OP_READ而阻塞的select()
                selector.wakeup();
            } catch (ClosedChannelException e) {
                e.printStackTrace();
            }
        }
    }
}

參考書籍:
NIO與Socket編程技術指南 -高巖洪 著

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章