1、基本常識
Socket 是應用層與 TCP/IP 協議族通信的中間軟件抽象層,是一組接口,使用了門面模式對應用層隱藏了傳輸層以下的實現細節。TCP 用主機的 IP 地址加上主機端口號作為 TCP 連接的端點,該端點叫做套接字 Socket。
比如三次握手,調用 Socket.connect() 就能完成,應用開發者無須關心如何具體實現三次握手。
長連接與短連接沒有哪一個更好之說,只是結合具體業務使用哪一個更合適。
任何網絡通信編程關注的三件事:
- 連接(客戶端連接服務器,服務器接收客戶端的連接)
- 讀網絡數據
- 寫網絡數據
常見的網絡編程方式有三種:
- BIO:阻塞式 IO,當線程無法讀取到數據或無法寫入數據時,線程會進入阻塞狀態
- NIO:非阻塞式 IO,也稱 IO 多路復用,即一個線程為多個客戶端執行讀寫操作。當一個客戶端無法讀寫數據即將陷入阻塞狀態之前,線程會切換到其他客戶端的讀寫工作中,避免阻塞帶來的效率低下問題
- AIO:異步 IO,Linux 的異步 IO 實際上是通過 NIO 實現的,而 Windows 才提供了真正的異步 IO,因此在 Linux 和 Java 這一側關注的是 BIO 與 NIO
2、BIO
服務端通過 ServerSocket 獲取到客戶端的連接 Socket,為每個連接分配一個單獨的線程,通過 IO 流進行同步阻塞式通信:
public class Server {// 別用 CachedThreadPool,與 new Thread() 沒啥區別private static ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2);public static void main(String[] args) {ServerSocket serverSocket = null;try {serverSocket = new ServerSocket();serverSocket.bind(new InetSocketAddress(10001));System.out.println("Start server...");while (true) {executorService.submit(new ServerTask(serverSocket.accept()));}} catch (IOException e) {e.printStackTrace();} finally {try {if (serverSocket != null) {serverSocket.close();}} catch (IOException e) {e.printStackTrace();}}}static class ServerTask implements Runnable {private Socket socket;public ServerTask(Socket socket) {this.socket = socket;}@Overridepublic void run() {try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) {String userName = objectInputStream.readUTF();objectOutputStream.writeUTF("Hello," + userName);objectOutputStream.flush();} catch (IOException e) {e.printStackTrace();}}}
}
客戶端使用 Socket 連接綁定服務器端口后與服務器通信:
public class Client {public static void main(String[] args) throws IOException {Socket socket = new Socket();socket.connect(new InetSocketAddress("127.0.0.1", 10001));try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream())) {objectOutputStream.writeUTF("James");objectOutputStream.flush();System.out.println(objectInputStream.readUTF());} finally {socket.close();}}
}
3、NIO
首先要清楚一點,在配置參數相同的情況下,單次網絡通信,BIO 的效率是比 NIO 高的,但是由于 NIO 中一個服務端線程可以與多個客戶端通信,所以 NIO 這個 IO 多路復用的機制,總體上比 BIO 效率更高。從成本角度考慮,NIO 節省成本,而 BIO 則是以成本換效率。
3.1 三大核心組件
NIO 的三大核心組件:
- Selector:選擇器,也稱為輪詢代理器或事件訂閱器,可以在一個單獨的線程中操作 Selector 選擇不同的 Channel,從而實現在一個線程中管理多個通道。應用程序向 Selector 注冊需要其關注的 Channel 以及 Channel 感興趣的 IO 事件,Selector 內則保存已經注冊的 Channel 的容器
- Channels:通道,是應用程序與操作系統讀寫數據的渠道,通道中的數據總是先讀到 Buffer 或從 Buffer 寫入。Selector 注冊的是 SelectableChannel,其子類 ServerSocketChannel 支持應用程序向操作系統注冊 IO 多路復用的端口監聽,同時支持 TCP 和 UDP;而另一個子類 SocketChannel 則是 TCP Socket 的監聽通道
- Buffer:本質上就是一個數組,其內存被包裝成 Buffer 對象并提供了方便訪問該內存的方法。僅與 Channel 做數據交換。
3.2 重要概念 SelectionKey
除此之外還有一個重要概念 SelectionKey,表示 SelectableChannel 在 Selector 中注冊的標識。Channel 向 Selector 注冊時,會創建 SelectionKey 建立 Channel 與 Selector 的聯系,同時維護 Channel 事件。
SelectionKey 有四種類型:
- OP_READ:操作系統讀緩沖區可讀,并非所有時刻都有數據可讀,因此需要注冊該操作
- OP_WRITE:操作系統寫緩沖區有空閑空間,一般情況下都有空閑空間,因此沒必要注冊該類型,否則浪費 CPU;但如果是寫密集型的任務,比如下載文件,緩沖區可能會滿,此時就需要注冊該操作類型,并在寫完后取消注冊
- OP_CONNECT:只給客戶端使用,在 SocketChannel.connect() 連接成功后就緒
- OP_ACCEPT:只給服務器使用,在接收到客戶端連接請求時就緒
這四種類型也再次闡明了網絡編程關注的三件事:連接(客戶端連接服務器,服務器接收客戶端的連接)、讀、寫網絡數據。
不同的 Channel 允許注冊的事件類型不同:
- 服務器 ServerSocketChannel:僅 OP_ACCEPT
- 服務器 SocketChannel:OP_READ、OP_WRITE
- 客戶端 SocketChannel:OP_READ、OP_WRITE 和 OP_CONNECT
3.3 Buffer
緩沖區本質上是一塊可以寫入數據,然后可以從中讀取數據的內存(其實就是數組),這塊內存被包裝成 NIO Buffer 對象,并提供了一組方法,用來方便的訪問該塊內存。
Buffer 位于 Channel 和應用程序之間。應用程序對外寫數據時,是寫到 Buffer,由 Channel 將 Buffer 中的數據讀出并發送出去;讀也是類似的,數據是先從 Channel 讀到 Buffer 后,應用程序再讀 Buffer 中的數據。
Buffer 有三個重要屬性:
- capacity:內存容量,只能寫 capacity 個 byte、long、char 類型數據,Buffer 滿了之后需要通過讀數據或清除數據將其清空后,才能繼續寫數據
- position:表示操作數據的位置,寫模式下,每寫完一個數據會向下移動一個單位,最大為 capacity - 1;讀模式下,每讀完一個數據會向前移動到下一個可讀的位置。讀寫模式切換時,position 會被重置為 0
- limit:寫模式下表示最多能向 Buffer 中寫多少數據,此時 limit 等于 capacity;讀模式下表示最多能讀到多少數據,切換到讀模式時,limit 會被置為寫模式下的 position,即可讀取此前所有寫入的數據
Buffer 既可以讀也可以寫,需要通過 flip() 從寫模式切換到讀模式,而當讀完數據后,可以通過 clear() 或 compact() 清理緩沖區并切換成寫模式,其中前者會清空整個緩沖區,而后者則只清除已經讀取過的數據。
完整的通信結構如下:
大致步驟:
- 服務端 ServerSocketChannel 向 Selector 注冊 OP_ACCEPT
- 客戶端連接服務器,Selector 會通知 ServerSocketChannel 連接事件,此時 ServerSocketChannel 可以產生一個 SocketChannel 與客戶端進行通信,并注冊 OP_READ
- 客戶端發送數據,Selector 會通知服務端的 SocketChannel 讀取數據,這些數據會被寫入 Buffer,服務器的應用程序可以從 Buffer 中讀取這些數據
- 當服務器的應用程序發送應答消息給客戶端時,是向 Buffer 中寫入數據,SocketChannel 會從 Buffer 中讀取這些數據并發送出去
BIO 時,假如分三次向對端寫 100 個字節,那么就要進行三次系統調用。而使用 NIO,可以將 100 個字節寫入 Buffer,從 Buffer 讀取數據再進行一次系統調用就可以發送數據了。由于系統調用會消耗大量系統資源,所以 NIO 是提升了性能的。類似的,BIO 在讀取數據時,不論從系統讀取到多少數據都要經過一次系統調用交給應用程序,而 NIO 可以將從操作系統讀取的數據先存入 Buffer 中,然后從 Buffer 通過一次系統調用傳輸給應用程序。
3.4 NIO 編程實踐
基礎使用代碼見 GitHub 上相關章節,注意事項見課程文檔。這里主要說一下在讀寫數據時為什么一般不注冊寫事件 OP_WRITE。
一般情況下,服務器在寫數據時,是不注冊 OP_WRITE 直接通過 SocketChannel.write() 寫的:
private void handleInput(SelectionKey key) throws IOException {// 由于 SelectionKey 是可以取消的,因此使用前需要先判斷是否可用if (key.isValid()) {if (key.isAcceptable()) {// 只有 ServerSocketChannel 才關注 OP_ACCEPTServerSocketChannel ssc = (ServerSocketChannel) key.channel();// 獲取和客戶端通信的 SocketSocketChannel sc = ssc.accept();System.out.println("有客戶端連接");sc.configureBlocking(false);sc.register(selector, SelectionKey.OP_READ);}// 讀數據if (key.isReadable()) {SocketChannel sc = (SocketChannel) key.channel();// 如果要讀取的數據多于 1024 字節,那么讀事件會被觸發多次直到讀完ByteBuffer buffer = ByteBuffer.allocate(1024);int readBytes = sc.read(buffer);if (readBytes > 0) {// 因為 Channel 寫入了 Buffer,因此讀的時候需要進行模式切換buffer.flip();// 讀取數據做業務處理byte[] bytes = new byte[readBytes];buffer.get(bytes);String message = new String(bytes, "UTF-8");System.out.println("服務器收到消息: " + message);String result = Const.response(message);// 發送應答消息doWrite(sc, result);} else if (readBytes < 0) {// 小于 0 說明鏈路已經關閉,釋放資源key.cancel();sc.close();}}}}private void doWrite(SocketChannel sc, String result) throws IOException {byte[] bytes = result.getBytes();ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);// 將字節數組復制到 writeBufferwriteBuffer.put(bytes);// 切換到讀模式writeBuffer.flip();sc.write(writeBuffer);}
假如想在 OP_WRITE 下向客戶端寫數據,就要修改為如下這樣:
private void handleInput(SelectionKey key) throws IOException {// 由于 SelectionKey 是可以取消的,因此使用前需要先判斷是否可用if (key.isValid()) {...// 添加寫數據邏輯if (key.isWritable()) {System.out.println("writable...");SocketChannel sc = (SocketChannel) key.channel();ByteBuffer attachment = (ByteBuffer) key.attachment();if (attachment.hasRemaining()) {System.out.println("write :" + sc.write(attachment) + " byte");} else {// 寫完數據后要取消對寫事件的注冊,否則系統會一直通知寫事件key.interestOps(SelectionKey.OP_READ);}}}}private void doWrite(SocketChannel sc, String result) throws IOException {byte[] bytes = result.getBytes();ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);// 將字節數組復制到 writeBufferwriteBuffer.put(bytes);// 切換到讀模式writeBuffer.flip();
// sc.write(writeBuffer);// register() 注冊哪一個事件就只關注該事件,因此這里在注冊寫事件時不要忘了讀,同時將 writeBuffer// 作為附件也一并注冊sc.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, writeBuffer);}
之所以在寫完之后要取消對寫事件的關注,主要是因為讀寫事件的觸發機制是不一樣的。當客戶端向服務器發送數據時,服務端有數據可讀,就會觸發 OP_READ 事件。
而 OP_WRITE 則不同,當通信雙方 Socket 連接成功后,操作系統會為每個 Socket 創建兩個操作系統級別的緩存(注意并不是應用程序中用到的 Buffer,應用程序是感知不到這個緩存的,這個緩存在操作系統內核中),一個輸出緩存,一個輸入緩存。當輸入緩存中有對端發來的數據時,就會觸發 OP_READ 事件,而輸出緩存中,只要有空閑空間,就會一直不停的觸發 OP_WRITE。
通常都是要寫的數據非常多,數據量大于緩沖區需要多次寫的時候,才注冊 OP_WRITE。
3.5 Reactor 模式
Reactor 翻譯為“反應器”,可以延伸為“倒置”、“控制逆轉”,即事件處理程序不調用反應器,而是向反應器注冊一個事件處理器,當事件到來時調用事件處理程序做出反應。這種控制逆轉又稱為“好萊塢法則”。
NIO 的 Selector 就扮演著 Reactor 的角色。Reactor 模式又可以分為三種流程:
- 單線程 Reactor 模式:服務器全程只使用一個線程,即 IO 操作(accept、read、write)與業務操作(decode、compute、encode)都在一個線程上處理。這樣有一個問題增大 IO 響應的時間。示意圖如下:
- 單線程 Reactor,工作者線程池:添加工作者線程池,將非 IO 操作從 Reactor 線程中移出交給工作者線程池執行。這種模式在處理大并發、大數據量的業務時是不合適的。因為面對成百上千的 IO 操作,一個線程的處理能力始終是有限的。再比如讀取 10M 的數據,在讀取時其他 IO 操作是無法進行的。示意圖如下:
- 多 Reactor 線程模式:針對第二種模式的缺點,再引入一個 Reactor 線程池。Reactor 線程池中的每一 Reactor 線程都會有自己的 Selector、線程和分發的事件循環邏輯。mainReactor 可以只有一個,但 subReactor 一般會有多個。mainReactor 線程主要負責接收客戶端的連接請求,然后將接收到的 SocketChannel 傳遞給 subReactor,由 subReactor 來完成和客戶端的通信。示意圖如下:
Reactor 模式看似與觀察者模式很像,二者的主要區別是觀察者模式與單個事件源關聯,而反應器模式則與多個事件源關聯。當一個主體發生改變時,所有依屬體都得到通知。