在 Java 語言中,傳統的 Socket 編程分爲兩種實現方式,這兩種實現方式也對應着兩種不一樣的傳輸層協議:TCP 協議和 UDP 協議,但做爲互聯網中最經常使用的傳輸層協議 TCP,在使用時卻會致使粘包和半包問題,因而爲了完全的解決此問題,便誕生了此篇文章。java
TCP 全稱是 Transmission Control Protocol(傳輸控制協議),它由 IETF 的 RFC 793 定義,是一種面向鏈接的點對點的傳輸層通訊協議。git
TCP 經過使用序列號和確認消息,從發送節點提供有關傳輸到目標節點的數據包的傳遞的信息。TCP 確保數據的可靠性,端到端傳遞,從新排序和重傳,直到達到超時條件或接收到數據包的確認爲止。github
TCP 是 Internet 上最經常使用的協議,它也是實現 HTTP(HTTP 1.0/HTTP 2.0)通信的基礎,當咱們在瀏覽器中請求網頁時,計算機會將 TCP 數據包發送到 Web 服務器的地址,要求它將網頁返還給咱們,Web 服務器經過發送 TCP 數據包流進行響應,而後瀏覽器將這些數據包縫合在一塊兒以造成網頁。編程
TCP 的所有意義在於它的可靠性,它經過對數據包編號來對其進行排序,並且它會經過讓服務器將響應發送回瀏覽器說「已收到」來進行錯誤檢查,所以在傳輸過程當中不會丟失或破壞任何數據。數組
目前市場上主流的 HTTP 協議使用的版本是 HTTP/1.1,以下圖所示:
瀏覽器
粘包問題是指當發送兩條消息時,好比發送了 ABC 和 DEF,但另外一端接收到的倒是 ABCD,像這種一次性讀取了兩條數據的狀況就叫作粘包(正常狀況應該是一條一條讀取的)。
緩存
半包問題是指,當發送的消息是 ABC 時,另外一端卻接收到的是 AB 和 C 兩條信息,像這種狀況就叫作半包。
服務器
這是由於 TCP 是面向鏈接的傳輸協議,TCP 傳輸的數據是以流的形式,而流數據是沒有明確的開始結尾邊界,因此 TCP 也沒辦法判斷哪一段流屬於一個消息。網絡
緩衝區又稱爲緩存,它是內存空間的一部分。也就是說,在內存空間中預留了必定的存儲空間,這些存儲空間用來緩衝輸入或輸出的數據,這部分預留的空間就叫作緩衝區。socket
緩衝區的優點以文件流的寫入爲例,若是咱們不使用緩衝區,那麼每次寫操做 CPU 都會和低速存儲設備也就是磁盤進行交互,那麼整個寫入文件的速度就會受制於低速的存儲設備(磁盤)。但若是使用緩衝區的話,每次寫操做會先將數據保存在高速緩衝區內存上,當緩衝區的數據到達某個閾值以後,再將文件一次性寫入到磁盤上。由於內存的寫入速度遠遠大於磁盤的寫入速度,因此當有了緩衝區以後,文件的寫入速度就被大大提高了。
接下來咱們用代碼來演示一下粘包和半包問題,爲了演示的直觀性,我會設置兩個角色:
而後經過打印服務器端接收到的信息來觀察粘包和半包問題。
服務器端代碼以下:
/** * 服務器端(只負責接收消息) */ class ServSocket { // 字節數組的長度 private static final int BYTE_LENGTH = 20; public static void main(String[] args) throws IOException { // 建立 Socket 服務器 ServerSocket serverSocket = new ServerSocket(9999); // 獲取客戶端鏈接 Socket clientSocket = serverSocket.accept(); // 獲得客戶端發送的流對象 try (InputStream inputStream = clientSocket.getInputStream()) { while (true) { // 循環獲取客戶端發送的信息 byte[] bytes = new byte[BYTE_LENGTH]; // 讀取客戶端發送的信息 int count = inputStream.read(bytes, 0, BYTE_LENGTH); if (count > 0) { // 成功接收到有效消息並打印 System.out.println("接收到客戶端的信息是:" + new String(bytes)); } count = 0; } } } }
客戶端代碼以下:
/** * 客戶端(只負責發送消息) */ static class ClientSocket { public static void main(String[] args) throws IOException { // 建立 Socket 客戶端並嘗試鏈接服務器端 Socket socket = new Socket("127.0.0.1", 9999); // 發送的消息內容 final String message = "Hi,Java."; // 使用輸出流發送消息 try (OutputStream outputStream = socket.getOutputStream()) { // 給服務器端發送 10 次消息 for (int i = 0; i < 10; i++) { // 發送消息 outputStream.write(message.getBytes()); } } } }
以上程序的通信結果以下圖所示:
經過上述結果咱們能夠看出,服務器端發生了粘包和半包的問題,由於客戶端發送了 10 次固定的「Hi,Java.」的消息,正常的結果應該是服務器端也接收到了 10 次固定的消息纔對,但現實的結果並不是如此。
粘包和半包的解決方案有如下 3 種:
那麼接下來咱們就來演示一下,以上解決方案的具體代碼實現。
固定緩衝區大小的實現方案,只須要控制服務器端和客戶端發送和接收字節的(數組)長度相同便可。
服務器端實現代碼以下:
/** * 服務器端,改進版本一(只負責接收消息) */ static class ServSocketV1 { private static final int BYTE_LENGTH = 1024; // 字節數組長度(收消息用) public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(9091); // 獲取到鏈接 Socket clientSocket = serverSocket.accept(); try (InputStream inputStream = clientSocket.getInputStream()) { while (true) { byte[] bytes = new byte[BYTE_LENGTH]; // 讀取客戶端發送的信息 int count = inputStream.read(bytes, 0, BYTE_LENGTH); if (count > 0) { // 接收到消息打印 System.out.println("接收到客戶端的信息是:" + new String(bytes).trim()); } count = 0; } } } }
客戶端實現代碼以下:
/** * 客戶端,改進版一(只負責接收消息) */ static class ClientSocketV1 { private static final int BYTE_LENGTH = 1024; // 字節長度 public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 9091); final String message = "Hi,Java."; // 發送消息 try (OutputStream outputStream = socket.getOutputStream()) { // 將數據組裝成定長字節數組 byte[] bytes = new byte[BYTE_LENGTH]; int idx = 0; for (byte b : message.getBytes()) { bytes[idx] = b; idx++; } // 給服務器端發送 10 次消息 for (int i = 0; i < 10; i++) { outputStream.write(bytes, 0, BYTE_LENGTH); } } } }
以上代碼的執行結果以下圖所示:
從以上代碼能夠看出,雖然這種方式能夠解決粘包和半包的問題,但這種固定緩衝區大小的方式增長了沒必要要的數據傳輸,由於這種方式當發送的數據比較小時會使用空字符來彌補,因此這種方式就大大的增長了網絡傳輸的負擔,因此它也不是最佳的解決方案。
這種解決方案的實現思路是將請求的數據封裝爲兩部分:數據頭+數據正文,在數據頭中存儲數據正文的大小,當讀取的數據小於數據頭中的大小時,繼續讀取數據,直到讀取的數據長度等於數據頭中的長度時才中止。
由於這種方式能夠拿到數據的邊界,因此也不會致使粘包和半包的問題,但這種實現方式的編碼成本較大也不夠優雅,所以不是最佳的實現方案,所以咱們這裏就略過,直接來看最終的解決方案吧。
以特殊字符結尾就能夠知道流的邊界了,所以也能夠用來解決粘包和半包的問題,此實現方案是咱們推薦最終解決方案。
這種解決方案的核心是,使用 Java 中自帶的 BufferedReader
和 BufferedWriter
,也就是帶緩衝區的輸入字符流和輸出字符流,經過寫入的時候加上 \n
來結尾,讀取的時候使用 readLine
按行來讀取數據,這樣就知道流的邊界了,從而解決了粘包和半包的問題。
服務器端實現代碼以下:
/** * 服務器端,改進版三(只負責收消息) */ static class ServSocketV3 { public static void main(String[] args) throws IOException { // 建立 Socket 服務器端 ServerSocket serverSocket = new ServerSocket(9092); // 獲取客戶端鏈接 Socket clientSocket = serverSocket.accept(); // 使用線程池處理更多的客戶端 ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 150, 100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000)); threadPool.submit(() -> { // 消息處理 processMessage(clientSocket); }); } /** * 消息處理 * @param clientSocket */ private static void processMessage(Socket clientSocket) { // 獲取客戶端發送的消息流對象 try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(clientSocket.getInputStream()))) { while (true) { // 按行讀取客戶端發送的消息 String msg = bufferedReader.readLine(); if (msg != null) { // 成功接收到客戶端的消息並打印 System.out.println("接收到客戶端的信息:" + msg); } } } catch (IOException ioException) { ioException.printStackTrace(); } } }
PS:上述代碼使用了線程池來解決多個客戶端同時訪問服務器端的問題,從而實現了一對多的服務器響應。
客戶端的實現代碼以下:
/** * 客戶端,改進版三(只負責發送消息) */ static class ClientSocketV3 { public static void main(String[] args) throws IOException { // 啓動 Socket 並嘗試鏈接服務器 Socket socket = new Socket("127.0.0.1", 9092); final String message = "Hi,Java."; // 發送消息 try (BufferedWriter bufferedWriter = new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()))) { // 給服務器端發送 10 次消息 for (int i = 0; i < 10; i++) { // 注意:結尾的 \n 不能省略,它表示按行寫入 bufferedWriter.write(message + "\n"); // 刷新緩衝區(此步驟不能省略) bufferedWriter.flush(); } } } }
以上代碼的執行結果以下圖所示:
本文咱們講了 TCP 粘包和半包問題,粘包是指讀取到了兩條信息,正常狀況下消息應該是一條一條讀取的,而半包問題是指讀取了一半信息。致使粘包和半包的緣由是 TCP 的傳輸是以流的形式進行的,而流數據是沒有明確的開始和結尾標識的,所以就致使了此問題。
本文咱們提供了 3 種粘包和半包的解決方案,其中最推薦的是使用 BufferedReader
和 BufferedWriter
按行來讀、寫和區分消息,也就是本文的第三種解決方案。
https://zhuanlan.zhihu.com/p/126279630
https://www.jianshu.com/p/6a4ec6095f2c
關注公衆號「Java中文社羣」發現更多幹貨。
查看 Github 發現更多精彩:https://github.com/vipstone/algorithm