一:socket通信基本原理。
首先socket 通信是基於TCP/IP 網絡層上的一種傳送方式,我們通常把TCP和UDP稱為傳輸層。
如上圖,在七個層級關系中,我們將的socket屬於傳輸層,其中UDP是一種面向無連接的傳輸層協議。UDP不關心對端是否真正收到了傳送過去的數據。如果需要檢查對端是否收到分組數據包,或者對端是否連接到網絡,則需要在應用程序中實現。UDP常用在分組數據較少或多播、廣播通信以及視頻通信等多媒體領域。在這里我們不進行詳細討論,這里主要講解的是基於TCP/IP協議下的socket通信。
socket是基於應用服務與TCP/IP通信之間的一個抽象,他將TCP/IP協議里面復雜的通信邏輯進行分裝,對用戶來說,只要通過一組簡單的API就可以實現網絡的連接。借用網絡上一組socket通信圖給大家進行詳細講解:

首先,服務端初始化ServerSocket,然后對指定的端口進行綁定,接着對端口及進行監聽,通過調用accept方法阻塞,此時,如果客戶端有一個socket連接到服務端,那么服務端通過監聽和accept方法可以與客戶端進行連接。
二:socket通信基本示例:
在對socket通信基本原理明白后,那我們就寫一個最簡單的示例,展示童鞋們常遇到的第一個問題:客戶端發送消息后,服務端無法收到消息。
服務端:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; public class ServerSocketTest { public static void main(String[] args) { try { // 初始化服務端socket並且綁定9999端口 ServerSocket serverSocket = new ServerSocket(9999); //等待客戶端的連接 Socket socket = serverSocket.accept(); //獲取輸入流 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream())); //讀取一行數據 String str = bufferedReader.readLine(); //輸出打印 System.out.println(str); } catch (IOException e) { e.printStackTrace(); } } }
客戶端:
import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStreamWriter; import java.net.Socket; import java.net.UnknownHostException; public class ClientSocket { public static void main(String[] args) { try { Socket socket = new Socket("127.0.0.1", 9999); BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); String str = "你好,這是我的第一個socket"; bufferedWriter.write(str); //刷新輸入流 bufferedWriter.flush(); } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }
啟動服務端:發現正常,等待客戶端的的連接
啟動客戶端:發現客戶端啟動正常后,馬上執行完后關閉。同時服務端控制台報錯:

然后好多童鞋,就拷貝這個java.net.SocketException: Connection reset上王查異常,查詢解決方案,搞了半天都不知道怎么回事。解決這個問題我們首先要明白,socket通信是阻塞的,他會在以下幾個地方進行阻塞。第一個是accept方法,調用這個方法后,服務端一直阻塞在哪里,直到有客戶端連接進來。第二個是read方法,調用read方法也會進行阻塞。通過上面的示例我們可以發現,該問題發生在read方法中。有朋友說是Client沒有發送成功,其實不是的,我們可以通debug跟蹤一下,發現客戶端發送了,並且沒有問題。而是發生在服務端中,當服務端調用read方法后,他一直阻塞在哪里,因為客戶端沒有給他一個標識,告訴是否消息發送完成,所以服務端還在一直等待接受客戶端的數據,結果客戶端此時已經關閉了,就是在服務端報錯:java.net.SocketException: Connection reset
那么理解上面的原理后,我們就能明白,客戶端發送完消息后,需要給服務端一個標識,告訴服務端,我已經發送完成了,服務端就可以將接受的消息打印出來。
通常大家會用以下方法進行進行結束:
socket.close() 或者調用socket.shutdownOutput();方法。調用這倆個方法,都會結束客戶端socket。但是有本質的區別。socket.close() 將socket關閉連接,那邊如果有服務端給客戶端反饋信息,此時客戶端是收不到的。而socket.shutdownOutput()是將輸出流關閉,此時,如果服務端有信息返回,則客戶端是可以正常接受的。現在我們將上面的客戶端示例修改一下啊,增加一個標識告訴流已經輸出完畢:

console服務端正常輸出了
上面的示例中scoket客戶端和服務端固然可以通信,但是客戶端每次發送信息后socket就需要關閉,下次如果需要發送信息,需要socket從新啟動,這顯然是無法適應生產環境的需要。比如在我們是實際應用中QQ,如果每次發送一條信息,就需要重新登陸QQ,我估計這程序不是給人設計的,那么如何讓服務可以連續給服務端發送消息?下面我們通過while循環進行簡單展示:
服務端:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; public class ServerSocketTest { public static void main(String[] args) { try { // 初始化服務端socket並且綁定9999端口 ServerSocket serverSocket = new ServerSocket(9999); //等待客戶端的連接 Socket socket = serverSocket.accept(); //獲取輸入流,並且指定統一的編碼格式 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); // BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream())); //讀取一行數據 String str; //通過while循環不斷讀取信息 while((str=bufferedReader.readLine()) != null) { //輸出打印 System.out.println(str); } } catch (IOException e) { e.printStackTrace(); } } }
客戶端:
import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.net.Socket; import java.net.UnknownHostException; public class ClientSocket { public static void main(String[] args) { try { //初始化一個socket Socket socket = new Socket("127.0.0.1", 9999); //通過socket獲取字符流 BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); //通過標准輸入流獲取字符流 BufferedReader bufferedReader =new BufferedReader(new InputStreamReader(System.in,"UTF-8")); while(true) { String str = bufferedReader.readLine(); bufferedWriter.write(str); bufferedWriter.write("\n"); bufferedWriter.flush(); } } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }
客戶端console:
服務端console:
大家可以看到,通過一個while 循環,就可以實現客戶端不間斷的通過標准輸入流讀取來的消息,發送給服務端。在這里有個細節,大家看到沒有,我客戶端沒有寫socket.close() 或者調用socket.shutdownOutput();服務端是如何知道客戶端已經輸入完成了?服務端接受數據的時候是如何判斷客戶端已經輸入完成呢?這就是一個核心點,雙方約定一個標識,當客戶端發送一個標識給服務端時,表明客戶端端已經完成一個數據的載入。而服務端在結束數據的時候,也通過這個標識進行判斷,如果接受到這個標識,表明數據已經傳入完成,那么服務端就可以將數據度入后顯示出來。
在上面的示例中,客戶端端在循環發送數據時候,每發送一行,添加一個換行標識“\n”標識,在告訴服務端我數據已經發送完成了。而服務端在讀取客戶數據時,通過while ((str = bufferedReader.readLine())!=null)去判斷是否讀到了流的結尾,負責服務端將會一直阻塞在哪里,等待客戶端的輸入。
通過while方式,我們可以實現多個客戶端和服務端進行聊天。但是,下面敲黑板,划重點。由於socket通信是阻塞式的,假設我現在有A和B倆個客戶端同時連接到服務端的上,當客戶端A發送信息給服務端后,那么服務端將一直阻塞在A的客戶端上,不同的通過while循環從A客戶端讀取信息,此時如果B給服務端發送信息時,將進入阻塞隊列,直到A客戶端發送完畢,並且退出后,B才可以和服務端進行通信。簡單地說,我們現在實現的功能,雖然可以讓客戶端不間斷的和服務端進行通信,與其說是一對一的功能,因為只有當客戶端A關閉后,客戶端B才可以真正和服務端進行通信,這顯然不是我們想要的。 下面我們通過多線程的方式給大家實現正常人類的思維。
四:多線程下socket編程
服務端:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.net.ServerSocket; import java.net.Socket; public class ServerSocketTest { public static void main(String[] args) { try { // 初始化服務端socket並且綁定9999端口 ServerSocket serverSocket = new ServerSocket(9999); while(true) { //等待客戶端的連接 final Socket socket = serverSocket.accept(); //每當有一個客戶端連接進來后,就啟動一個單獨的線程進行處理 new Thread(new Runnable(){ @Override public void run() { //獲取輸入流,並且指定統一的編碼格式 BufferedReader bufferedReader =null; try { bufferedReader =new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8")); //讀取一行數據 String str; //通過while循環不斷讀取信息, while ((str = bufferedReader.readLine())!=null){ //輸出打印 System.out.println("客戶端說:"+str); } } catch (UnsupportedEncodingException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }).start(); } } catch (IOException e) { e.printStackTrace(); } } }
客戶端:
public class ClientSocket { public static void main(String[] args) { try { //初始化一個socket Socket socket = new Socket("127.0.0.1", 9999); //通過socket獲取字符流 BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); //通過標准輸入流獲取字符流 BufferedReader bufferedReader =new BufferedReader(new InputStreamReader(System.in,"UTF-8")); while(true) { String str = bufferedReader.readLine(); bufferedWriter.write(str); bufferedWriter.write("\n"); bufferedWriter.flush(); } } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }
啟動服務端
啟動兩個客戶端A 、 B
客戶端A console:
客戶端B console:

服務端 console:
通過這里我們可以發現,客戶端A和客戶端B同時連接到服務端后,都可以和服務端進行通信,也不會出現前面講到使用while(true)時候客戶端A連接時客戶端B不能與服務端進行交互的情況。在這里我們看到,主要是通過服務端的 new Thread(new Runnable() {}實現的,每一個客戶端連接進來后,服務端都會單獨起個一線程,與客戶端進行數據交互,這樣就保證了每個客戶端處理的數據是單獨的,不會出現相互阻塞的情況,這樣就基本是實現了QQ程序的基本聊天原理。
但是實際生產環境中,這種寫法對於客戶端連接少的的情況下是沒有問題,但是如果有大批量的客戶端連接進行,那我們服務端估計就要歇菜了。假如有上萬個socket連接進來,服務端就是新建這么多進程,反正樓主是不敢想,而且socket 的回收機制又不是很及時,這么多線程被new 出來,就發送一句話,然后就沒有然后了,導致服務端被大量的無用線程暫用,對性能是非常大的消耗,在實際生產過程中,我們可以通過線程池技術,保證線程的復用,下面請看改良后的服務端程序。
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ServerSocketTest { public static void main(String[] args) { try { // 初始化服務端socket並且綁定9999端口 ServerSocket serverSocket = new ServerSocket(9999); //創建一個線程池 ExecutorService executorService = Executors.newFixedThreadPool(100); while(true) { //等待客戶端的連接 final Socket socket = serverSocket.accept(); //每當有一個客戶端連接進來后,就啟動一個單獨的線程進行處理 Thread thread = new Thread(new Runnable(){ @Override public void run() { //獲取輸入流,並且指定統一的編碼格式 BufferedReader bufferedReader =null; try { bufferedReader =new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8")); //讀取一行數據 String str; //通過while循環不斷讀取信息, while ((str = bufferedReader.readLine())!=null){ //輸出打印 System.out.println("客戶端說:"+str); } } catch (UnsupportedEncodingException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }); executorService.execute(thread); } } catch (IOException e) { e.printStackTrace(); } } }
通過線程池技術,我們可以實現線程的復用。其實在這里executorService.submit在並發時,如果要求當前執行完畢的線程有返回結果時,這里面有一個大坑,在這里我就不一一詳細說明.
在實際應用中,socket發送的數據並不是按照一行一行發送的,比如我們常見的報文,那么我們就不能要求每發送一次數據,都在增加一個“\n”標識,這是及其不專業的,在實際應用中,通過是采用數據長度+類型+數據的方式,在我們常接觸的熱Redis就是采用這種方式,
五:socket 指定長度發送數據
在實際應用中,網絡的數據在TCP/IP協議下的socket都是采用數據流的方式進行發送,那么在發送過程中就要求我們將數據流轉出字節進行發送,讀取的過程中也是采用字節緩存的方式結束。那么問題就來了,在socket通信時候,我們大多數發送的數據都是不定長的,所有接受方也不知道此次數據發送有多長,因此無法精確地創建一個緩沖區(字節數組)用來接收,在不定長通訊中,通常使用的方式時每次默認讀取8*1024長度的字節,若輸入流中仍有數據,則再次讀取,一直到輸入流沒有數據為止。但是如果發送數據過大時,發送方會對數據進行分包發送,這種情況下或導致接收方判斷錯誤,誤以為數據傳輸完成,因而接收不全。在這種情況下就會引出一些問題,諸如半包,粘包,分包等問題,為了后續一些例子中好理解,我在這里直接將半包,粘包,分包概念性東西在寫一下(引用度娘)
5.1 半包
接受方沒有接受到一個完整的包,只接受了部分。
原因:TCP為提高傳輸效率,將一個包分配的足夠大,導致接受方並不能一次接受完。
影響:長連接和短連接中都會出現
5.2 粘包
發送方發送的多個包數據到接收方接收時粘成一個包,從接收緩沖區看,后一包數據的頭緊接着前一包數據的尾。
分類:一種是粘在一起的包都是完整的數據包,另一種情況是粘在一起的包有不完整的包
出現粘包現象的原因是多方面的:
1)發送方粘包:由TCP協議本身造成的,TCP為提高傳輸效率,發送方往往要收集到足夠多的數據后才發送一包數據。若連續幾次發送的數據都很少,通常TCP會根據優化算法把這些數據合成一包后一次發送出去,這樣接收方就收到了粘包數據。
2)接收方粘包:接收方用戶進程不及時接收數據,從而導致粘包現象。這是因為接收方先把收到的數據放在系統接收緩沖區,用戶進程從該緩沖區取數據,若下一包數據到達時前一包數據尚未被用戶進程取走,則下一包數據放到系統接收緩沖區時就接到前一包數據之后,而用戶進程根據預先設定的緩沖區大小從系統接收緩沖區取數據,這樣就一次取到了多包數據。
5.3分包
分包(1):在出現粘包的時候,我們的接收方要進行分包處理;
分包(2):一個數據包被分成了多次接收;
原因:1. IP分片傳輸導致的;2.傳輸過程中丟失部分包導致出現的半包;3.一個包可能被分成了兩次傳輸,在取數據的時候,先取到了一部分(還可能與接收的緩沖區大小有關系)。
影響:粘包和分包在長連接中都會出現
那么如何解決半包和粘包的問題,就涉及一個一個數據發送如何標識結束的問題,通常有以下幾種情況
固定長度:每次發送固定長度的數據;
特殊標示:以回車,換行作為特殊標示;獲取到指定的標識時,說明包獲取完整。
字節長度:包頭+包長+包體的協議形式,當服務器端獲取到指定的包長時才說明獲取完整;
所以大部分情況下,雙方使用socket通訊時都會約定一個定長頭放在傳輸數據的最前端,用以標識數據體的長度,通常定長頭有整型int,短整型short,字符串Strinng三種形式。
下面我們通過幾個簡單的小示例,演示發送接受定長數據,前面我們講過通過特殊標識的方式,可是有什么我們發送的數據比較大,並且數據本身就會包含我們約定的特殊標識,那么我們在接受數據時,就會出現半包的情況,通過這種情況下,我們都是才有包頭+包長+包體的協議模式,每次發送數據的時候,我們都會固定前4個字節為數據長度,那到數據長度后,我們就可以非常精確的創建一個數據緩存區用來接收數據。
那么下面就先通過包類型+包長度+消息內容定義一個socket通信對象,數據類型為byte類型,包長度為int類型,消息內容為byte類型。
首先我們創建服務端socket
public class ServerSocketTest { public static void main(String[] args) { try { // 初始化服務端socket並且綁定9999端口 ServerSocket serverSocket = new ServerSocket(9999); Socket client = serverSocket.accept(); InputStream inputStream = client.getInputStream(); DataInputStream dataInputStream =new DataInputStream(inputStream); while(true){ byte b = dataInputStream.readByte(); int len = dataInputStream.readInt(); byte[] data =new byte[len -5]; dataInputStream.readFully(data); String str =new String(data); System.out.println("獲取的數據類型為:"+b); System.out.println("獲取的數據長度為:"+len); System.out.println("獲取的數據內容為:"+str); } } catch (IOException e) { e.printStackTrace(); } } }
public class ClientSocket { public static void main(String[] args) { try { //初始化一個socket Socket socket = new Socket("127.0.0.1", 9999); OutputStream outputStream = socket.getOutputStream(); DataOutputStream dataOutputStream =new DataOutputStream(outputStream); Scanner scanner =new Scanner(System.in); if(scanner.hasNext()) { String str = scanner.next(); System.out.println("clinet str:" + str); int type =1; byte[] data = str.getBytes(); System.out.println("clinet str length:" + data.length); int len = data.length +5; dataOutputStream.writeByte(type); dataOutputStream.writeInt(len); dataOutputStream.write(data); dataOutputStream.flush(); } } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }
客戶端socket創建后,我們通過dataOutputStream輸出流中的writeByte()方法,設置數據類型,writeInt()方法設置數據長度,然后通過write()方法將數據發送到服務端進行通信,發送完畢后,為了確保數據完全發送,通過調用flush()方法刷新緩沖區。
下面我們通過控制可以看到服務端接受數據的情況:
console客戶端發送數據:

服務端console接受數據:
上面服務端分別接受到數據的類型,長度和詳細內容,具體下面的錯誤異常是由於客戶端發送一次后關閉,服務端任在接受數據,就會出現連接重置的錯誤,這是一個簡單的通過數據類型+數據長度+數據內容的方法發送數據的一個小例子,讓大家了解socket通信數據發送的原理,在實際應用中,原理不出其左右,只是在業務邏輯上完善而已。
六:socket 建立長連接
在了解socket長連接和短連接之前,我們先通過一個概念性的東西,理解一下什么叫長連接,什么叫短連接,長連接的原理和短連接的原理,
6.1 長連接
指在一個連接上可以連續發送多個數據包,在連接保持期間,如果沒有數據包發送,需要雙方發鏈路檢測包。整個通訊過程,客戶端和服務端只用一個Socket對象,長期保持Socket的連接。
6.2 短連接
短連接服務是每次請求都建立鏈接,交互完之后關閉鏈接,
6.3 長連接與短連接的優勢
長連接多用於操作頻繁,點對點的通訊,而且連接數不能太多情況。每個TCP連接都需要三步握手,這需要時間,如果每個操作都是短連接,再操作的話那么處理速度會降低很多,所以每個操作完后都不斷開,下次處理時直接發送數據包就OK了,不用建立TCP連接。例如:數據庫的連接用長連接,如果用短連接頻繁的通信會造成socket錯誤,而且頻繁的socket 創建也是對資源的浪費。
而像WEB網站的http服務一般都用短鏈接,因為長連接對於服務端來說會耗費一定的資源,而像WEB網站這么頻繁的成千上萬甚至上億客戶端的連接用短連接會更省一些資源,如果用長連接,而且同時有成千上萬的用戶,如果每個用戶都占用一個連接的話,那可想而知吧。所以並發量大,但每個用戶無需頻繁操作情況下需用短連好。(度娘)
在這章之前,你看到所有的例子,都是短連接,每次連接完畢后,都是自動斷開,如果需要重新連接,則需要建立新的連接對象,比如像前一章我們看到的例子中,服務端有connection reset錯誤,就是短連接的一種。接下來,我們主要講解一下長連接原理,在實際應用中,長連接他並不是真正意義上的長連接,(他不像我們打電話一樣,電話通了之后一直不掛的這種連接)。他們是通過一種稱之為心跳包或者叫做鏈路檢測包,去定時檢查socket 是否關閉,輸入/輸出流是否關閉。
在這里有個問題,也是好多初學者比較困惑的,也是好多初學socket時候,遇到的一個問題,那就是socket是通過流的方式通信的,既然關閉流,就是關閉socket,那么長連接不是很簡單嗎?就是我們讀取流中的信息后,不關閉流,等下次使用時,直接往流中扔數據不就行了?
針對這個問題,我做個詳細的解答,盡可能的描述清楚,首先我們socket是針對應用層與TCP/ip數據傳輸協議封裝的一套方案,那么他的底層也是通過Tcp/Tcp/ip或則UDP通信的,所以說socket本身並不是一直通信協議,而是一套接口的封裝。而tcp/IP協議組里面的應用層包括FTP、HTTP、TELNET、SMTP、DNS等協議,我們知道,http1.0是短連接,http1.1是長連接,我們在打開http通信協議里面在Response headers中可以看到這么一句Connection:keep-alive。他是干什么的,他就是表示長連接,但是他並不是一直保持的連接,他有一個時間段,如果我們想一直保持這個連接怎么辦?那就是在制定的時間內讓客戶端和服務端進行一個請求,請求可以是服務端發起,也可以是客戶端發起,通常我們是在客戶端不定時的發送一個字節數據給服務端,這個就是我們稱之為心跳包,想想心跳是怎么跳動的,是不是為了檢測人活着,心會定時的跳動,就是這個原理。