Java後端架構師的成長之路(三)——Java網絡編程Netty(1)

Netty介紹和應用場景

Netty介紹

  • Netty 是由 JBOSS 提供的一個 Java 開源框架,現爲 Github上的獨立項目
  • Netty 是一個異步的、基於事件驅動的網絡應用框架,用以快速開發高性能、高可靠性的網絡 IO 程序。
  • Netty主要針對在TCP協議下,面向Clients端的高併發應用,或者Peer-to-Peer場景下的大量數據持續傳輸的應用。
  • Netty本質是一個NIO框架,適用於服務器通訊相關的多種應用場景。
  • 要透徹理解Netty , 需要先學習 NIO , 這樣我們才能閱讀 Netty 的源碼。

Netty的應用場景

互聯網行業

  • 互聯網行業:在分佈式系統中,各個節點之間需要遠程服務調用,高性能的 RPC 框架必不可少,Netty 作爲異步高性能的通信框架,往往作爲基礎通信組件被這些 RPC 框架使用。
  • 典型的應用有:阿里分佈式服務框架 Dubbo 的 RPC 框架使用 Dubbo 協議進行節點間通信,Dubbo 協議默認使用 Netty 作爲基礎通信組件,用於實現各進程節點之間的內部通信。
    在這裏插入圖片描述

遊戲行業

  • 無論是手遊服務端還是大型的網絡遊戲,Java 語言得到了越來越廣泛的應用。
  • Netty 作爲高性能的基礎通信組件,提供了 TCP/UDP 和 HTTP 協議棧,方便定製和開發私有協議棧,賬號登錄服務器。
  • 地圖服務器之間可以方便的通過 Netty 進行高性能的通信。
    在這裏插入圖片描述

大數據領域

  • 經典的 Hadoop 的高性能通信和序列化組件 Avro 的 RPC 框架,默認採用 Netty 進行跨界點通信。
  • 它的 Netty Service 基於 Netty 框架二次封裝實現。
    在這裏插入圖片描述

其它開源項目使用到Netty

書籍

在這裏插入圖片描述

Java BIO 編程

IO模型基本說明

  • I/O 模型簡單的理解:就是用什麼樣的通道進行數據的發送和接收,很大程度上決定了程序通信的性能。
  • Java共支持3種網絡編程模型/IO模式:BIO、NIO、AIO。
  • Java BIO : 同步並阻塞(傳統阻塞型),服務器實現模式爲一個連接一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程進行處理,如果這個連接不做任何事情會造成不必要的線程開銷 【簡單示意圖】。
    在這裏插入圖片描述
  • Java NIO : 同步非阻塞,服務器實現模式爲一個線程處理多個請求(連接),即客戶端發送的連接請求都會註冊到多路複用器上,多路複用器輪詢到連接有I/O請求就進行處理 【簡單示意圖】。
    在這裏插入圖片描述
  • Java AIO(NIO.2) : 異步非阻塞,AIO 引入異步通道的概念,採用了 Proactor 模式,簡化了程序編寫,有效的請求才啓動線程,它的特點是先由操作系統完成後才通知服務端程序啓動線程去處理,一般適用於連接數較多且連接時間較長的應用。

BIO、NIO、AIO適用場景分析

  • BIO方式適用於連接數目比較小且固定的架構,這種方式對服務器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,但程序簡單易理解。
  • NIO方式適用於連接數目多且連接比較短(輕操作)的架構,比如聊天服務器,彈幕系統,服務器間通訊等。編程比較複雜,JDK1.4開始支持。
  • AIO方式使用於連接數目多且連接比較長(重操作)的架構,比如相冊服務器,充分調用OS參與併發操作,編程比較複雜,JDK7開始支持。

Java BIO

基本介紹

  • Java BIO 就是傳統的 java io 編程,其相關的類和接口在 java.io。
  • BIO(blocking I/O) : 同步阻塞,服務器實現模式爲一個連接一個線程,即客戶端有連接請求時服務器端就需要啓動一個線程進行處理,如果這個連接不做任何事情會造成不必要的線程開銷,可以通過線程池機制改善(實現多個客戶連接服務器)。
  • BIO方式適用於連接數目比較小且固定的架構,這種方式對服務器資源要求比較高,併發侷限於應用中,JDK1.4以前的唯一選擇,程序簡單易理解。

工作機制

工作原理圖

在這裏插入圖片描述

BIO編程簡單流程

  • 服務器端啓動一個ServerSocket。
  • 客戶端啓動Socket對服務器進行通信,默認情況下服務器端需要對每個客戶 建立一個線程與之通訊。
  • 客戶端發出請求後, 先諮詢服務器是否有線程響應,如果沒有則會等待,或者被拒絕。
  • 如果有響應,客戶端線程會等待請求結束後,在繼續執行。

應用實例

需求

  • 使用BIO模型編寫一個服務器端,監聽6666端口,當有客戶端連接時,就啓動一個線程與之通訊。
  • 要求使用線程池機制改善,可以連接多個客戶端。
  • 服務器端可以接收客戶端發送的數據(telnet 方式即可)。

代碼實現

public class BioServer {
    public static void main(String[] args) throws Exception {
        // 線程池機制
        // 思路:
        // 1、創建一個線程池
        // 2、如果有客戶端連接,就創建一個線程與之通信

        ExecutorService executorService = Executors.newCachedThreadPool();
        // 創建ServerSocket
        ServerSocket serverSocket = new ServerSocket(6666);
        System.out.println("服務器啓動了~~");
        while (true) {
            // 監聽
            System.out.println(Thread.currentThread().getName() + "等待連接...");
            final Socket socket = serverSocket.accept();
            System.out.println("==>> 連接到一個客戶端");
            // 創建一個線程,與之通信
            executorService.execute(() -> {
                handler(socket);
            });
        }
    }
    /**
     * 編寫一個方法,和客戶端通信
     */
    private static void handler(Socket socket) {
        byte[] bytes = new byte[1024];
        try (InputStream is = socket.getInputStream()) {
            // 打印當前線程信息
            printCurThreadInfo();
            // 循環讀取客戶端發送的數據
            while (true) {
                printCurThreadInfo();
                System.out.println("reading...");
                int read = is.read(bytes);
                if (read != -1) {
                    // 輸出客戶端發送的數據
                    System.out.println(new String(bytes, 0, read));
                } else {
                    break;
                }
            }
        } catch (Exception e) {
            System.out.println(e.getMessage());
        } finally {
            System.out.println("關閉和client的連接");
        }
    }

    private static void printCurThreadInfo() {
        Thread curThread = Thread.currentThread();
        System.out.printf("線程信息 id = %s, 名字 = %s\n", curThread.getId(), curThread.getName());
    }
}
  • 測試:
    在這裏插入圖片描述

問題分析

  • 每個請求都需要創建獨立的線程,與對應的客戶端進行數據 Read,業務處理,數據 Write 。
  • 當併發數較大時,需要創建大量線程來處理連接,系統資源佔用較大。
  • 連接建立後,如果當前線程暫時沒有數據可讀,則線程就阻塞在 Read 操作上,造成線程資源浪費。

Java NIO編程

基本介紹

  • Java NIO 全稱 java non-blocking IO,是指 JDK 提供的新 API。從 JDK1.4 開始,Java 提供了一系列改進的輸入/輸出的新特性,被統稱爲 NIO(即 New IO),是同步非阻塞的。
  • NIO 相關類都被放在 java.nio 包及子包下,並且對原 java.io 包中的很多類進行改寫。【基本案例】
/**
 * 舉例說明Buffer的使用
 */
public class BasicBuffer {
    public static void main(String[] args) {
        // 創建一個Buffer,大小爲5
        IntBuffer intBuffer = IntBuffer.allocate(5);
//        // 向buffer中存放數據
//        intBuffer.put(10);
//        intBuffer.put(11);
//        intBuffer.put(12);
//        intBuffer.put(13);
//        intBuffer.put(14);
        // for循環存放
        for (int i = 0; i < intBuffer.capacity(); i++) {
            intBuffer.put(i * 2);
        }
        // 如何從buffer讀取數據
        // 首先需要將buffer進行讀寫切換
        intBuffer.flip();

        while (intBuffer.hasRemaining()) {
            System.out.printf("%s\t", intBuffer.get());
        }
    }
}
// 輸出
// 0	2	4	6	8

在這裏插入圖片描述

  • NIO 有三大核心部分:Channel(通道)、Buffer(緩衝區)、Selector(選擇器)
  • NIO是面向緩衝區 ,或者面向塊編程的。數據讀取到一個它稍後處理的緩衝區,需要時可在緩衝區中前後移動,這就增加了處理過程中的靈活性,使用它可以提供非阻塞式的高伸縮性網絡
  • Java NIO的非阻塞模式,使一個線程從某通道發送請求或者讀取數據,但是它僅能得到目前可用的數據,如果目前沒有數據可用時,就什麼都不會獲取,而不是保持線程阻塞,所以直至數據變的可以讀取之前,該線程可以繼續做其他的事情。 非阻塞寫也是如此,一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。
  • 通俗理解:NIO是可以做到用一個線程來處理多個操作的。假設有10000個請求過來,根據實際情況,可以分配50或者100個線程來處理。不像之前的阻塞IO那樣,非得分配10000個。
  • HTTP2.0使用了多路複用的技術,做到同一個連接併發處理多個請求,而且併發請求的數量比HTTP1.1大了好幾個數量級。

NIO 和 BIO 的比較

  • BIO 以流的方式處理數據,而 NIO 以塊的方式處理數據,塊 I/O 的效率比流 I/O 高很多。
  • BIO 是阻塞的,NIO 則是非阻塞的。
  • BIO基於字節流和字符流進行操作,而 **NIO 基於 Channel(通道)和 Buffer(緩衝區)**進行操作,數據總是從通道讀取到緩衝區中,或者從緩衝區寫入到通道中。Selector(選擇器)用於監聽多個通道的事件(比如:連接請求,數據到達等),因此使用單個線程就可以監聽多個客戶端通道 。

NIO三大核心原理示意圖

在這裏插入圖片描述

  • 每個 Channel 都會對應一個Buffer。
  • Selector 對應一個線程, 一個線程對應多個 Channel(連接)。
  • 該圖反應了有三個 Channel 註冊到 該selector。//程序
  • 程序切換到哪個 Channel 是有事件決定的,Event 就是一個重要的概念。
  • Selector 會根據不同的事件,在各個通道上切換。
  • Buffer 就是一個內存塊,底層是有一個數組的。
  • 數據的讀取寫入是通過Buffer,這個和BIO是有本質區別的 , BIO 中要麼是輸入流、或者是輸出流;不能雙向;但是NIO的Buffer 是可以讀也可以寫,需要 flip 方法切換。
  • Channel 是雙向的,可以返回底層操作系統的情況,比如Linux,底層的操作系統通道就是雙向的。

緩存區(Buffer)

基本介紹

  • 緩衝區本質上是一個可以讀寫數據的內存塊,可以理解成是一個容器對象(含數組),該對象提供了一組方法,可以更輕鬆地使用內存塊,緩衝區對象內置了一些機制,能夠跟蹤和記錄緩衝區的狀態變化情況。Channel 提供從文件、網絡讀取數據的渠道,但是讀取或寫入的數據都必須經由 Buffer。
    在這裏插入圖片描述

Buffer類及其子類

  • 在 NIO 中,Buffer 是一個頂層父類,它是一個抽象類,類的層級關係圖:
    在這裏插入圖片描述
  • 常見Buffer子類一覽
* ByteBuffer,存儲字節數據到緩衝區
* ShortBuffer,存儲字符串數據到緩衝區
* CharBuffer,存儲字符數據到緩衝區
* IntBuffer,存儲整數數據到緩衝區
* LongBuffer,存儲長整型數據到緩衝區
* DoubleBuffer,存儲小數到緩衝區
* FloatBuffer,存儲小數到緩衝區
  • Buffer類定義了所有的緩衝區都具有的四個屬性來提供關於其所包含的數據元素的信息:
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述
  • Buffer類相關方法一覽:
    在這裏插入圖片描述

ByteBuffer

  • 從前面可以看出對於 Java 中的基本數據類型(boolean除外),都有一個 Buffer 類型與之相對應,最常用的自然是ByteBuffer 類(二進制數據),該類的主要方法如下:
    在這裏插入圖片描述

通道(Channel)

基本介紹

  • NIO的通道類似於流,但有些區別如下:
* 通道可以同時進行讀寫,而流只能讀或者只能寫
* 通道可以實現異步讀寫數據
* 通道可以從緩衝讀數據,也可以寫數據到緩衝

在這裏插入圖片描述

  • BIO 中的 stream 是單向的,例如 FileInputStream 對象只能進行讀取數據的操作,而 NIO 中的通道(Channel)是雙向的,可以讀操作,也可以寫操作。
  • Channel在NIO中是一個接口public interface Channel extends Closeable{}
  • 常用的 Channel 類有:FileChannel、DatagramChannel、ServerSocketChannel 和 SocketChannel。【ServerSocketChanne 類似 ServerSocket , SocketChannel 類似 Socket】。
    在這裏插入圖片描述
  • FileChannel 用於文件的數據讀寫,DatagramChannel 用於 UDP 的數據讀寫,ServerSocketChannel 和 SocketChannel 用於 TCP 的數據讀寫。

FileChannel

FileChannel主要用來對本地文件進行 IO 操作,常見的方法有:

  • public int read(ByteBuffer dst):從通道讀取數據並放到緩衝區中。
  • public int write(ByteBuffer src):把緩衝區的數據寫到通道中。
  • public long transferFrom(ReadableByteChannel src, long position, long count):從目標通道中複製數據到當前通道。
  • public long transferTo(long position, long count, WritableByteChannel target):把數據從當前通道複製給目標通道。

應用實例

本地文件寫數據

  • 需求:使用前面學習過的ByteBuffer(緩衝) 和 FileChannel(通道), 將 “hello,尚硅谷” 寫入到file01.txt 中,文件不存在就創建。
    在這裏插入圖片描述
  • 代碼實現:
public class FileChannelCase01 {
    public static void main(String[] args) {
        String str = "Hello, 尚硅谷";
        // 創建一個輸出流
        try (FileOutputStream fos = new FileOutputStream("netty-workspace/atguigu-netty/file01.txt")) {
            // fileChannel真實類型是FileChannelImpl
            FileChannel fileChannel = fos.getChannel();
            // 創建一個緩衝區
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
            // 將str放入byteBuffer
            byteBuffer.put(str.getBytes());
            // 對byteBuffer反轉
            byteBuffer.flip();
            // 將byteBuffer數據寫入到fileChannel
            fileChannel.write(byteBuffer);
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

本地文件讀數據

  • 需求:使用前面學習過的ByteBuffer(緩衝) 和 FileChannel(通道), 將 file01.txt 中的數據讀入到程序,並顯示在控制檯屏幕,假定文件已經存在。
    在這裏插入圖片描述
  • 代碼實現:
public class FileChannelCase02 {
    public static void main(String[] args) {
        File file = new File("netty-workspace/atguigu-netty/file01.txt");
        // 創建一個輸入流
        try (FileInputStream fis = new FileInputStream(file)) {
            // 通過fileInputStream獲取對應的FileChannel
            FileChannel fileChannel = fis.getChannel();
            // 創建緩衝區
            ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
            // 將通道的的數據讀到buffer
            fileChannel.read(byteBuffer);
            // 將byteBuffer的字節數據轉爲String
            System.out.println(new String(byteBuffer.array()));
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

使用一個Buffer完成文件讀取

  • 需求:使用 FileChannel(通道) ,及其 read、write方法,完成文件的拷貝,拷貝一個文本文件 1.txt。
    在這裏插入圖片描述
  • 代碼實現:
public class FileChannelCase03 {
    public static void main(String[] args) {
        File file1 = new File("netty-workspace/atguigu-netty/1.txt");
        File file2 = new File("netty-workspace/atguigu-netty/2.txt");
        // 創建一個輸入流
        try (FileInputStream fis = new FileInputStream(file1);
             FileOutputStream fos = new FileOutputStream(file2)) {
            FileChannel fileChannel1 = fis.getChannel();
            FileChannel fileChannel2 = fos.getChannel();
            ByteBuffer byteBuffer = ByteBuffer.allocate(512);

            while (true) {
                /*
                public final Buffer clear() {
                    position = 0;
                    limit = capacity;
                    mark = -1;
                    return this;
                }
                */
                // 這裏有一個重要的操作,一定不要忘了
                byteBuffer.clear();
                int read = fileChannel1.read(byteBuffer);
                if (read == -1) {
                    break;
                }
                byteBuffer.flip();
                // 將buffer中的數據數據寫入到fileChannel2
                fileChannel2.write(byteBuffer);
            }
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

拷貝文件transferFrom 方法

  • 需求:使用 FileChannel(通道) 的方法 transferFrom ,完成文件的拷貝。
  • 代碼實現:
public class FileChannelCase04 {
    public static void main(String[] args) {
        String srcFile = "netty-workspace/atguigu-netty/1.jpg";
        String destFile = "netty-workspace/atguigu-netty/2.jpg";
        // 創建一個輸入流
        try (FileInputStream fis = new FileInputStream(srcFile);
             FileOutputStream fos = new FileOutputStream(destFile)) {
            FileChannel srcChannel = fis.getChannel();
            FileChannel destChannel = fos.getChannel();

            destChannel.transferFrom(srcChannel, 0, srcChannel.size());
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

關於 Buffer 和 Channel 的注意事項和細節

  • ByteBuffer 支持類型化的put 和 get, put 放入的是什麼數據類型,get就應該使用相應的數據類型來取出,否則可能有 BufferUnderflowException 異常。
public class ByteBufferPutGetCase {
    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(64);

        // 類型化方式放入數據
        byteBuffer.putInt(100);
        byteBuffer.putLong(9);
        byteBuffer.putChar('尚');
        byteBuffer.putShort((short) 4);

        // 取出
        byteBuffer.flip();

        System.out.println(byteBuffer.getInt());
        System.out.println(byteBuffer.getLong());
        System.out.println(byteBuffer.getChar());
        System.out.println(byteBuffer.getShort());
    }
}
100
94
public class ByteBufferPutGetCase {
    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(64);

        // 類型化方式放入數據
        byteBuffer.putInt(100);
        byteBuffer.putLong(9);
        byteBuffer.putChar('尚');
        byteBuffer.putShort((short) 4);

        // 取出
        byteBuffer.flip();

        System.out.println(byteBuffer.getShort());
        System.out.println(byteBuffer.getInt());
        System.out.println(byteBuffer.getLong());
        System.out.println(byteBuffer.getLong());
    }
}
0
6553600
613402
Exception in thread "main" java.nio.BufferUnderflowException
	at java.nio.Buffer.nextGetIndex(Buffer.java:506)
	at java.nio.HeapByteBuffer.getLong(HeapByteBuffer.java:412)
	at com.netty.nio.ByteBufferPutGetCase.main(ByteBufferPutGetCase.java:30)
  • 可以將一個普通 Buffer 轉成只讀 Buffer。
public class ReadOnlyBufferCase {
    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(64);
        for (int i = 0; i < 64; i++) {
            byteBuffer.put((byte) i);
        }
        byteBuffer.flip();

        // 得到一個只讀buffer
        ByteBuffer readOnlyBuffer = byteBuffer.asReadOnlyBuffer();
        System.out.println(readOnlyBuffer.getClass());

        while (readOnlyBuffer.hasRemaining()) {
            System.out.printf("%d ", readOnlyBuffer.get());
        }
        System.out.println();

//        readOnlyBuffer.put((byte) 100);
//        Exception in thread "main" java.nio.ReadOnlyBufferException
//        at java.nio.HeapByteBufferR.put(HeapByteBufferR.java:172)
//        at com.netty.nio.ReadOnlyBufferCase.main(ReadOnlyBufferCase.java:26)
    }
}
  • NIO 還提供了 MappedByteBuffer, 可以讓文件直接在內存(堆外的內存)中進行修改, 而如何同步到文件由NIO 來完成。
public class MappedByteBufferCase {
    public static void main(String[] args) throws Exception {
        try (RandomAccessFile raf = new RandomAccessFile("netty-workspace/atguigu-netty/1.txt", "rw")) {
            // 獲取對應的通道
            FileChannel fileChannel = raf.getChannel();
            /**
             * 參數1:FileChannel.MapMode.READ_WRITE 使用的讀寫模式
             * 參數2:可以直接修改的起始位置
             * 參數3:映射到內存的大小,即將 1.txt 的多少個字節映射到內存
             */
            MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

            mappedByteBuffer.put(0, (byte) 'H');
            mappedByteBuffer.put(3, (byte) '9');
        }
    }
}
  • 前面我們講的讀寫操作,都是通過一個Buffer 完成的,NIO 還支持 通過多個Buffer (即 Buffer 數組) 完成讀寫操作,即 Scattering 和 Gathering。
/**
 * Scattering:將數據寫入到buffer時,可以採用buffer數組,依次寫入【分散】
 * Gathering:從buffer讀取數據時,可以採用buffer數組,依次讀
 */
public class ScatteringAndGatheringCase {
    public static void main(String[] args) throws Exception {
        try (ServerSocketChannel ssChannel = ServerSocketChannel.open()) {
            InetSocketAddress inetSocketAddress = new InetSocketAddress(7000);

            // 綁定端口到socket,並啓動
            ssChannel.socket().bind(inetSocketAddress);

            // 創建buffer數組
            ByteBuffer[] byteBuffers = new ByteBuffer[2];
            byteBuffers[0] = ByteBuffer.allocate(5);
            byteBuffers[1] = ByteBuffer.allocate(3);

            // 等待客戶端連接
            SocketChannel socketChannel = ssChannel.accept();

            int maxLength = 8;
            // 循環讀取
            while (true) {
                int byteRead = 0;
                while (byteRead < maxLength) {
                    long read = socketChannel.read(byteBuffers);
                    byteRead += read;
                    System.out.println("byteRead = " + byteRead);

                    // 使用流打印
                    Arrays.asList(byteBuffers).stream().map(buffer ->
                            "position=" + buffer.position() + ", limit=" + buffer.limit()
                    ).forEach(System.out::println);
                }
                // 將所有的buffer進行反轉
                Arrays.asList(byteBuffers).stream().forEach(Buffer::flip);

                // 將數據讀出顯示到客戶端
                long byteWrite = 0;
                while (byteWrite < maxLength) {
                    long write = socketChannel.write(byteBuffers);
                    byteWrite += write;
                }

                // 將所有的buffer進行clear
                Arrays.asList(byteBuffers).stream().forEach(Buffer::clear);

                System.out.println("byteRead=" + byteRead + ", byteWrite=" + byteWrite);
            }
        }
    }
}

選擇器(Selector)

基本介紹

  • Java 的 NIO,用非阻塞的 IO 方式。可以用一個線程,處理多個的客戶端連接,這時就會使用到Selector(選擇器)
  • Selector 能夠檢測多個註冊的通道上是否有事件發生(注意:多個Channel以事件的方式可以註冊到同一個Selector),如果有事件發生,便獲取事件然後針對每個事件進行相應的處理。這樣就可以只用一個單線程去管理多個通道,也就是管理多個連接和請求。
  • 只有在 連接/通道 真正有讀寫事件發生時,纔會進行讀寫,就大大地減少了系統開銷,並且不必爲每個連接都創建一個線程,不用去維護多個線程。
  • 避免了多線程之間的上下文切換導致的開銷。

Selector示意圖和特點說明

在這裏插入圖片描述

  • Netty 的 IO 線程 NioEventLoop 聚合了 Selector(選擇器,也叫多路複用器),可以同時併發處理成百上千個客戶端連接。
  • 當線程從某客戶端 Socket 通道進行讀寫數據時,若沒有數據可用時,該線程可以進行其他任務。
  • 線程通常將非阻塞 IO 的空閒時間用於在其他通道上執行 IO 操作,所以單獨的線程可以管理多個輸入和輸出通道。
  • 由於讀寫操作都是非阻塞的,這就可以充分提升 IO 線程的運行效率,避免由於頻繁 I/O 阻塞導致的線程掛起。
  • 一個 I/O 線程可以併發處理 N 個客戶端連接和讀寫操作,這從根本上解決了傳統同步阻塞 I/O 一連接一線程模型,架構的性能、彈性伸縮能力和可靠性都得到了極大的提升。

Selector類的相關方法

Selector 類是一個抽象類, 常用方法和說明如下:
在這裏插入圖片描述

注意事項

  • NIO中的 ServerSocketChannel功能類似ServerSocket,SocketChannel功能類似Socket。
  • selector 相關方法說明:
* selector.select()//阻塞
* selector.select(1000);//阻塞1000毫秒,在1000毫秒後返回
* selector.wakeup();//喚醒selector
* selector.selectNow();//不阻塞,立馬返還

NIO非阻塞網絡編程

原理分析

NIO 非阻塞 網絡編程相關的(Selector、SelectionKey、ServerScoketChannel和SocketChannel) 關係梳理圖:
在這裏插入圖片描述

  • 當客戶端連接時,會通過 ServerSocketChannel 得到 SocketChannel。
  • Selector 進行監聽 select 方法,返回有事件發生的通道個數。
  • 將 socketChannel 註冊到 Selector 上,register(Selector sel, int ops),一個selector上可以註冊多個SocketChannel。
  • 註冊後返回一個 SelectionKey,會和該Selector 關聯(集合)。
  • 進一步得到各個 SelectionKey (有事件發生)。
  • 再通過 SelectionKey 反向獲取 SocketChannel,方法 channel()。
  • 可以通過得到的 channel,完成業務處理。

快速入門

需求

  • 編寫一個 NIO 入門案例,實現服務器端和客戶端之間的數據簡單通訊(非阻塞)。
  • 目的:理解NIO非阻塞網絡編程機制。

代碼實現

  • 服務端:
public class NioServer {
    private static final int TIME_1_SECOND = 1000;

    public static void main(String[] args) throws Exception {
        // 創建 ServerSocketChannel -> SocketChannel
        try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {
            // 得到一個Selector對象
            try (Selector selector = Selector.open()) {
                // 綁定一個端口,在服務端監聽
                serverSocketChannel.socket().bind(new InetSocketAddress(6666));
                // 設置爲非阻塞
                serverSocketChannel.configureBlocking(false);
                // 把serverSocketChannel註冊到selector,其關心的事件是OP_ACCEPT
                serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
                // 循環等待客戶端連接
                do {
                    if (selector.select(TIME_1_SECOND) == 0) {
                        // 等待1s,沒有事件發生
                        System.out.println("服務端等待了1秒,無連接");
                        continue;
                    }
                    // 如果 selector.select() 返回大於0,表示已經獲取到事件
                    // 則通過 selector 進一步得到各個 SelectionKey (有事件發生)
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    // 遍歷selectionKeys,再通過 SelectionKey  反向獲取 SocketChannel
                    Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
                    while (keyIterator.hasNext()) {
                        SelectionKey key = keyIterator.next();
                        // 根據這個key對應的通道發生的事件,做出相應的處理
                        if (key.isAcceptable()) {
                            // 如果是OP_ACCEPT,即有新的客戶端連接,則爲該客戶端生成一個SocketChannel
                            SocketChannel socketChannel = serverSocketChannel.accept();
                            // 將SocketChannel設置爲非阻塞
                            socketChannel.configureBlocking(false);
                            System.out.println(socketChannel.hashCode() + "客戶端連接成功~~");
                            // 將當前的這個socketChannel註冊到selector,關注事件爲SelectionKey.OP_READ
                            // 同時給socketChannel關聯一個buffer
                            socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                        }
                        if (key.isReadable()) {
                            // 如果是OP_READ,反向獲取 SocketChannel
                            SocketChannel channel = (SocketChannel) key.channel();
//                            // 獲取到該Channel關聯的buffer
//                            ByteBuffer buffer = (ByteBuffer) key.attachment();
//                            // 從通道讀取buffer裏面的數據
//                            channel.read(buffer);
//                            System.out.println("From Client: " + new String(buffer.array(), 0, buffer.position()));
                            int byteRead = 0;
                            ByteBuffer buffer = ByteBuffer.allocate(1024);
                            while (true) {
                                buffer.clear();
                                int read = channel.read(buffer);
                                if (read == -1) {
                                    // 當讀取長度爲-1時,表示客戶端斷開連接,服務端應當關閉channel
                                    // 或解除對 {@link SelectionKey.OP_READ} 的監聽
                                    System.out.println(channel.hashCode() + "客戶端斷開連接!!");
                                    channel.close();
                                    break;
                                }
                                if (read == 0) {
                                    break;
                                }
                                byteRead += read;
                            }
                            if (byteRead != 0) {
                                System.out.println("From Client: " + new String(buffer.array(), 0, byteRead));
                            }
                        }
                        // 手動從集合中移除掉當前的SelectionKey,防止重複操作
                        keyIterator.remove();
                    }
                } while (true);
            }
        }
    }
}
  • 客戶端:
public class NioClient {
    private static final String SERVER_HOST_NAME = "127.0.0.1";
    private static final int SERVER_PORT = 6666;

    public static void main(String[] args) throws Exception {
        // 得到一個SocketChannel
        try (SocketChannel socketChannel = SocketChannel.open()) {
            // 設置非阻塞
            socketChannel.configureBlocking(false);
            // 連接服務器端
            if (!socketChannel.connect(new InetSocketAddress(SERVER_HOST_NAME, SERVER_PORT))) {
                while (!socketChannel.finishConnect()) {
                    System.out.println("因爲連接需要時間,客戶端不會阻塞,可以處理其他事情..");
                }
            }
            System.out.println("連接服務器成功~~");
            // 如果連接成功,就發送數據
            String content = "Hello, 尚硅谷";
            // Wraps a byte array into a buffer
            ByteBuffer buffer = ByteBuffer.wrap(content.getBytes());
            // 發送數據,將buffer數據寫入到channel
            socketChannel.write(buffer);
            System.in.read();
        }
    }
}
  • 結果輸出:
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
1360875712客戶端連接成功~~
From Client: Hello, 尚硅谷
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
服務端等待了1秒,無連接
1360875712客戶端斷開連接!!
服務端等待了1秒,無連接
服務端等待了1秒,無連接
...

SelectionKey

SelectionKey,表示 Selector 和網絡通道的註冊關係,共四種:

  • int OP_ACCEPT:有新的網絡連接可以 accept,值爲 16。
  • int OP_CONNECT:代表連接已經建立,值爲 8。
  • int OP_READ:代表讀操作,值爲 1。
  • int OP_WRITE:代表寫操作,值爲 4。
  • 源碼中:
    在這裏插入圖片描述
  • SelectionKey 相關方法:
    在這裏插入圖片描述
    在這裏插入圖片描述

ServerSocketChannel

  • ServerSocketChannel 在服務器端監聽新的客戶端 Socket 連接,相關方法如下:
    在這裏插入圖片描述
    在這裏插入圖片描述

SockerChannel

  • SocketChannel,網絡 IO 通道,具體負責進行讀寫操作。NIO 把緩衝區的數據寫入通道,或者把通道里的數據讀到緩衝區。
  • 相關方法如下:
    在這裏插入圖片描述
    在這裏插入圖片描述

應用實例-羣聊系統

需求

  • 編寫一個 NIO 羣聊系統,實現服務器端和客戶端之間的數據簡單通訊(非阻塞)。
  • 實現多人羣聊。
  • 服務器端:可以監測用戶上線、離線,並實現消息轉發功能。
  • 客戶端:通過channel 可以無阻塞發送消息給其它所有用戶,同時可以接受其它用戶發送的消息(有服務器轉發得到)。
  • 目的:進一步理解NIO非阻塞網絡編程機制。

代碼實現

  • 服務端:
public class GroupChatServer {
    private Selector selector;
    private ServerSocketChannel listenChannel;

    public GroupChatServer() {
        try {
            // 獲取選擇器
            selector = Selector.open();
            // 獲得ServerSocketChannel
            listenChannel = ServerSocketChannel.open();
            // 綁定端口
            listenChannel.socket().bind(new InetSocketAddress(PORT));
            // 設置非阻塞
            listenChannel.configureBlocking(false);
            // 將listenChannel註冊到selector
            listenChannel.register(selector, SelectionKey.OP_ACCEPT);
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

    /**
     * 監聽
     */
    private void listen() {
        try {
            // 循環處理
            do {
                int count = selector.select();
                if (count > 0) {
                    // 有事件,則獲取selectionKey集合,並進行遍歷
                    Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
                    while (keyIterator.hasNext()) {
                        SelectionKey key = keyIterator.next();
                        // 監聽accept事件
                        if (key.isAcceptable()) {
                            SocketChannel socketChannel = listenChannel.accept();
                            socketChannel.configureBlocking(false);
                            socketChannel.register(selector, SelectionKey.OP_READ);
                            System.out.println(socketChannel.getRemoteAddress() + " 上線");
                        }
                        if (key.isReadable()) {
                            // 處理讀
                            readData(key);
                        }
                        // 刪除當前key,防止重複處理
                        keyIterator.remove();
                    }
                } else {
                    System.out.println("等待連接...");
                }
            } while (true);
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }

    /**
     * 讀取客戶端消息
     */
    private void readData(SelectionKey key) {
        SocketChannel channel = null;
        try {
            // 得到channel
            channel = (SocketChannel) key.channel();
            // 創建buffer
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

            int readCount = channel.read(byteBuffer);
            if (readCount > 0) {
                String content = new String(byteBuffer.array());
                System.out.println("From Client: " + content.trim());
                // 向其他客戶端轉發消息
                sendInfoToOtherClients(content, channel);
            }
        } catch (IOException e) {
            try {
                System.out.println(channel.getRemoteAddress() + " 離線了...");
                key.cancel();
                channel.close();
            } catch (IOException ex) {
                System.out.println(ex.getMessage());
            }
        }
    }

    /**
     * 轉發消息給其他客戶端(通道)
     */
    private void sendInfoToOtherClients(String content, SocketChannel selfChannel) throws IOException {
        System.out.println("服務器轉發消息中...");
        // 遍歷所有註冊到selector上的SocketChannel,並排除自己
        for (SelectionKey key : selector.keys()) {
            Channel channel = key.channel();
            if (channel instanceof SocketChannel && channel != selfChannel) {
                SocketChannel targetChannel = (SocketChannel) channel;
                targetChannel.configureBlocking(false);
                // 將msg寫入buffer
                ByteBuffer byteBuffer = ByteBuffer.wrap(content.getBytes());
                // 將buffer的數據寫入通道
                targetChannel.write(byteBuffer);
            }
        }
    }

    public static void main(String[] args) {
        GroupChatServer server = new GroupChatServer();
        server.listen();
    }
}
  • 客戶端:
public class GroupChatClient {
    private Selector selector;
    private SocketChannel socketChannel;
    private String username;

    public GroupChatClient() {
        try {
            selector = Selector.open();
            socketChannel = SocketChannel.open(new InetSocketAddress(IP, PORT));
            socketChannel.configureBlocking(false);
            socketChannel.register(selector, SelectionKey.OP_READ);
            username = socketChannel.getLocalAddress().toString().substring(1);
            System.out.println(username + " is ok ...");
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }

    /**
     * 向服務器發送消息
     */
    public void sendInfo(String content) {
        content = username + "說: " + content;
        try {
            socketChannel.write(ByteBuffer.wrap(content.getBytes()));
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }

    /**
     * 讀取從服務端回覆的消息
     */
    public void readInfo() {
        try {
            int readCount = selector.select();
            if (readCount > 0) {
                Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
                while (keyIterator.hasNext()) {
                    SelectionKey key = keyIterator.next();
                    if (key.isReadable()) {
                        SocketChannel channel = (SocketChannel) key.channel();
                        channel.configureBlocking(false);
                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        channel.read(byteBuffer);
                        System.out.println(new String(byteBuffer.array()).trim());
                    }
                }
                keyIterator.remove();
            } else {
//                System.out.println("沒有可用的通道...");
            }
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }

    public static void main(String[] args) {
        GroupChatClient client = new GroupChatClient();
        // 啓動一個線程,每隔3秒讀取從服務器發送的數據
        new Thread(() -> {
            do {
                client.readInfo();
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (Exception e) {
                    System.out.println(e.getMessage());
                }
            } while (true);
        }).start();
        // 發送數據給服務器端
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNextLine()) {
            client.sendInfo(scanner.nextLine());
        }
    }
}
  • 測試:
============= GroupChatServer
/127.0.0.1:51326 上線
/127.0.0.1:51333 上線
/127.0.0.1:51341 上線
From Client: 127.0.0.1:51326說: 我是51326
服務器轉發消息中...
From Client: 127.0.0.1:51333說: 我是51333
服務器轉發消息中...
From Client: 127.0.0.1:51341說: 我是51341
服務器轉發消息中...

============= GroupChatClient1
127.0.0.1:51326 is ok ...
我是51326
127.0.0.1:51333說: 我是51333
127.0.0.1:51341說: 我是51341

============= GroupChatClient2
127.0.0.1:51333 is ok ...
127.0.0.1:51326說: 我是51326
我是51333
127.0.0.1:51341說: 我是51341

============= GroupChatClient3
127.0.0.1:51341 is ok ...
127.0.0.1:51326說: 我是51326
127.0.0.1:51333說: 我是51333
我是51341

NIO與零拷貝

零拷貝基本介紹

  • 零拷貝是網絡編程的關鍵,很多性能優化都離不開。
  • 在 Java 程序中,常用的零拷貝有 mmap(內存映射) 和 sendFile。那麼,他們在 OS 裏,到底是怎麼樣的一個的設計?

傳統IO

  • Java 傳統 IO 和 網絡編程的一段代碼:
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

在這裏插入圖片描述

  • DMA:direct memory access 直接內存拷貝(不使用CPU)。
  • mmap 優化:mmap 通過內存映射,將文件映射到內核緩衝區,同時,用戶空間可以共享內核空間的數據。這樣,在進行網絡傳輸時,就可以減少內核空間到用戶空間的拷貝次數。如下圖:
    在這裏插入圖片描述
  • sendFile 優化:Linux 2.1 版本 提供了 sendFile 函數,其基本原理如下:數據根本不經過用戶態,直接從內核緩衝區進入到 Socket Buffer,同時,由於和用戶態完全無關,就減少了一次上下文切換:
    在這裏插入圖片描述
  • Linux 在 2.4 版本中,做了一些修改,避免了從內核緩衝區拷貝到 Socket buffer 的操作,直接拷貝到協議棧,從而再一次減少了數據拷貝。具體如下圖和小結:這裏其實有 一次cpu 拷貝 kernel buffer -> socket buffer 但是,拷貝的信息很少,比lenght , offset , 消耗低,可以忽略。
    在這裏插入圖片描述

零拷貝的再次理解

  • 我們說零拷貝,是從操作系統的角度來說的。因爲內核緩衝區之間,沒有數據是重複的(只有 kernel buffer 有一份數據)。
  • 零拷貝不僅僅帶來更少的數據複製,還能帶來其他的性能優勢,例如更少的上下文切換,更少的 CPU 緩存僞共享以及無 CPU 校驗和計算。

mmap 和 sendFile 的區別

  • mmap 適合小數據量讀寫,sendFile 適合大文件傳輸。
  • mmap 需要 4 次上下文切換,3 次數據拷貝;sendFile 需要 3 次上下文切換,最少 2 次數據拷貝。
  • sendFile 可以利用 DMA 方式,減少 CPU 拷貝,mmap 則不能(必須從內核拷貝到 Socket 緩衝區)。

NIO 零拷貝案例

需求

  • 使用傳統的IO 方法傳遞一個大文件.
  • 使用NIO 零拷貝方式傳遞(transferTo)一個大文件。
  • 看看兩種傳遞方式耗時時間分別是多少。

傳統IO-文件傳輸

  • 服務端:
public class OldIOServer {
    public static void main(String[] args) throws Exception {
        try (ServerSocket serverSocket = new ServerSocket(8000)) {
            do {
                Socket socket = serverSocket.accept();
                try (DataInputStream dis = new DataInputStream(socket.getInputStream())) {
                    byte[] bytes = new byte[4096];
                    while (true) {
                        int readCount = dis.read(bytes, 0, bytes.length);
                        if (readCount == -1) {
                            break;
                        }
                    }
                }
            } while (true);
        }
    }
}
  • 客戶端:
public class OldIOClient {
    public static void main(String[] args) throws Exception {
        try (Socket socket = new Socket("localhost", 8000)) {
            String fileName = "netty-workspace/atguigu-netty/test.zip";
            try (FileInputStream fis = new FileInputStream(fileName)) {
                DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
                byte[] bytes = new byte[4096];
                long total = 0;
                long readCount;
                long startTime = System.currentTimeMillis();
                while ((readCount = fis.read(bytes)) >= 0) {
                    total += readCount;
                    dos.write(bytes);
                }
                long endTime = System.currentTimeMillis();
                System.out.println("發送總字節數:" + total + ",耗時:" + (endTime - startTime) + "ms");
            }
        }
    }
}

零拷貝-文件傳輸

  • 服務端:
public class NewIOServer {
    public static void main(String[] args) throws Exception {
        try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {
            ServerSocket serverSocket = serverSocketChannel.socket();
            serverSocket.bind(new InetSocketAddress(8000));
            ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
            do {
                SocketChannel socketChannel = serverSocketChannel.accept();
                int readCount = 0;
                while (readCount != -1) {
                    readCount = socketChannel.read(byteBuffer);
                    // 倒帶
                    /*
                    public final Buffer rewind() {
                        position = 0;
                        mark = -1;
                        return this;
                    }
                     */
                    byteBuffer.rewind();
                }
            } while (true);
        }
    }
}
  • 客戶端:
public class NewIOClient {
    public static void main(String[] args) throws Exception {
        try (SocketChannel socketChannel = SocketChannel.open()) {
            socketChannel.connect(new InetSocketAddress("localhost", 8000));
            String fileName = "netty-workspace/atguigu-netty/test.zip";
            try (FileChannel fileChannel = new FileInputStream(fileName).getChannel()) {
                long startTime = System.currentTimeMillis();
                // 在linux下,一個transferTo方法就可以完成傳輸
                // 在windows下,一次transferTo只能發送8M數據,需要分段傳輸文件
                long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
                long endTime = System.currentTimeMillis();
                System.out.println("發送的總字節數:" + transferCount + ", 耗時:" + (endTime - startTime) + "ms");
            }
        }
    }
}
  • transferTo:
    在這裏插入圖片描述

Java AIO基本介紹

  • JDK 7 引入了 Asynchronous I/O,即 AIO。在進行 I/O 編程中,常用到兩種模式:Reactor和 Proactor。Java 的 NIO 就是 Reactor,當有事件觸發時,服務器端得到通知,進行相應的處理。
  • AIO 即 NIO2.0,叫做異步不阻塞的 IO。AIO 引入異步通道的概念,採用了 Proactor 模式,簡化了程序編寫,有效的請求才啓動線程,它的特點是先由操作系統完成後才通知服務端程序啓動線程去處理,一般適用於連接數較多且連接時間較長的應用。
  • 目前 AIO 還沒有廣泛應用,Netty 也是基於NIO, 而不是AIO, 因此我們就不詳解AIO了,有興趣的同學可以參考 <<Java新一代網絡編程模型AIO原理及Linux系統AIO介紹>> http://www.52im.net/thread-306-1-1.html

BIO、NIO、AIO比較

在這裏插入圖片描述
舉例說明:

  • 同步阻塞:到理髮店理髮,就一直等理髮師,直到輪到自己理髮。
  • 同步非阻塞:到理髮店理髮,發現前面有其它人理髮,給理髮師說下,先幹其他事情,一會過來看是否輪到自己。
  • 異步非阻塞:給理髮師打電話,讓理髮師上門服務,自己幹其它事情,理髮師自己來家裏給你理髮。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章