一、Socket通信基本示例
這種模式是基礎,必須掌握,后期對Socket的優化都是在這個基礎上的,也是為以后學習NIO做鋪墊。
服務端監聽一個端口,等待連接的到來:
package com.sjk.socket.onlysend;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class SocketServer {
public static void main(String[] args) throws IOException {
// 1.創建Socket服務端,監聽指定端口
ServerSocket serverSocket = new ServerSocket(6666);
// 2.等待客戶端連接
System.out.println("server將一直等待連接的到來");
Socket socket = serverSocket.accept();
// 3.建立好連接之后,從Socket獲取輸入流,並建立緩沖區進行讀取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("get message from client: " + sb);
// 4.關閉流、Socket
inputStream.close();
socket.close();
serverSocket.close();
}
}
客戶端通過ip和端口,連接到指定的server,然后通過Socket獲得輸出流,並向其輸出內容,服務器會獲得消息:
package com.sjk.socket.onlysend;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
public class SocketClient {
public static void main(String[] args) throws IOException {
// 1.建立Socket,指定IP和端口
Socket socket = new Socket("localhost", 6666);
// 2.獲取輸出流
OutputStream outputStream = socket.getOutputStream();
// 3.向服務端發送消息
String message = "Hello, Server!";
outputStream.write(message.getBytes("UTF-8"));
// 4.關閉流,Socket
outputStream.close();
socket.close();
}
}
通過這個例子應該掌握並了解:
- Socket服務端和客戶端的基本編程
- 傳輸編碼統一指定,防止亂碼
這個例子做為學習的基本例子,實際開發中會有各種變形,比如客戶端在發送完消息后,需要服務端進行處理並返回。
二、消息通信優化
2.1 雙向通信,發送消息並接受消息
與之前server的不同在於,當讀取完客戶端的消息后,打開輸出流,將指定消息發送回客戶端:
package com.sjk.socket.waitreceive;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class SocketServer {
public static void main(String[] args) throws IOException {
// 1.創建Socket服務端,監聽指定端口
ServerSocket serverSocket = new ServerSocket(6666);
// 2.等待客戶端連接
System.out.println("server將一直等待連接的到來");
Socket socket = serverSocket.accept();
// 3.建立好連接之后,從Socket獲取輸入流,並建立緩沖區進行讀取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
//只有當客戶端關閉它的輸出流的時候,服務端才能取得結尾的-1
while ((len = inputStream.read(bytes)) != -1) {
//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("get message from client: " + sb);
// 4.從Socket獲取輸出流,並向客戶端發送消息
OutputStream outputStream = socket.getOutputStream();
String message = "Hello Client,I get the message.";
outputStream.write(message.getBytes("UTF-8"));
// 5.關閉流、Socket
inputStream.close();
outputStream.close();
socket.close();
serverSocket.close();
}
}
客戶端也有相應的變化,在發送完消息時,調用關閉輸出流方法,然后打開輸出流,等候服務端的消息:
package com.sjk.socket.onlysend;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class SocketClient {
public static void main(String[] args) throws IOException {
// 1.建立Socket,指定IP和端口
Socket socket = new Socket("localhost", 6666);
// 2.獲取輸出流
OutputStream outputStream = socket.getOutputStream();
// 3.向服務端發送消息
String message = "Hello, Server!";
outputStream.write(message.getBytes("UTF-8"));
//通過shutdownOutput高速服務器已經發送完數據,后續只能接受數據
socket.shutdownOutput();
// 4.獲取輸入流,接收服務端的消息
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("get message from server: " + sb);
// 5.關閉流,Socket
inputStream.close();
outputStream.close();
socket.close();
}
}
2.2 使用場景
這個模式的使用場景一般用在,客戶端發送命令給服務器,然后服務器相應指定的命令,如果只是客戶端發送消息給服務器,然后讓服務器返回收到消息的消息,這就有點過分了,這就是完全不相信Socket的傳輸安全性,要知道它的底層可是TCP,如果沒有發送到服務器端是會拋異常的,這點完全不用擔心。
2.3 如何告知對方已發送完命令
其實這個問題還是比較重要的,正常來說,客戶端打開一個輸出流,如果不做約定,也不關閉它,那么服務端永遠不知道客戶端是否發送完消息,那么服務端會一直等待下去,直到讀取超時。所以怎么告知服務端已經發送完消息就顯得特別重要。
2.3.1 通過Socket關閉
當Socket關閉的時候,服務端就會收到響應的關閉信號,那么服務端也就知道流已經關閉了,這個時候讀取操作完成,就可以繼續后續工作。
但是這種方式有一些缺點:
* 客戶端Socket關閉后,將不能接受服務端發送的消息,也不能再次發送消息。
* 如果客戶端想再次發送消息,需要重現創建Socket連接。
2.3.2 通過Socket關閉輸出流的方式
這種方式調用的方法是:
socket.shutdownOutput();
而不是(outputStream為發送消息到服務端打開的輸出流):
outputStream.close();
如果關閉了輸出流,那么相應的Socket也將關閉,和直接關閉Socket一個性質。
調用Socket的shutdownOutput()方法,底層會告知服務端我這邊已經寫完了,那么服務端收到消息后,就能知道已經讀取完消息,如果服務端有要返回給客戶的消息那么就可以通過服務端的輸出流發送給客戶端,如果沒有,直接關閉Socket。
這種方式通過關閉客戶端的輸出流,告知服務端已經寫完了,雖然可以讀到服務端發送的消息,但是還是有一點點缺點:
* 不能再次發送消息給服務端,如果再次發送,需要重新建立Socket連接
這個缺點,在訪問頻率比較高的情況下將是一個需要優化的地方。
2.3.3 通過約定符號
這種方式的用法,就是雙方約定一個字符或者一個短語,來當做消息發送完成的標識,通常這么做就需要改造讀取方法。
假如約定單端的一行為end,代表發送完成,例如下面的消息,end則代表消息發送完成:
hello
end
那么服務端響應的讀取操作需要進行如下改造:
Socket socket = server.accept();
// 建立好連接后,從socket中獲取輸入流,並建立緩沖區進行讀取
BufferedReader read=new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8"));
String line;
StringBuilder sb = new StringBuilder();
while ((line = read.readLine()) != null && "end".equals(line)) {
//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8
sb.append(line);
}
可以看見,服務端不僅判斷是否讀到了流的末尾,還判斷了是否讀到了約定的末尾。
這么做的優缺點如下:
* 優點:不需要關閉流,當發送完一條命令(消息)后可以再次發送新的命令(消息)
* 缺點:需要額外的約定結束標志,太簡單的容易出現在要發送的消息中,誤被結束,太復雜的不好處理,還占帶寬
經過了這么多的優化還是有缺點,難道就沒有完美的解決方案嗎,答案是有的,看接下來的內容。
2.3.4 通過指定長度
三、服務端優化
3.1 服務端並發處理能力
在上面的例子中,服務端僅僅只是接受了一個Socket請求,並處理了它,然后就結束了,但是在實際開發中,一個Socket服務往往需要服務大量的Socket請求,那么就不能再服務完一個Socket的時候就關閉了,這時候可以采用循環接受請求並處理的邏輯:
package com.sjk.socket.multiserver;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class SocketServer {
public static void main(String[] args) throws IOException {
// 1.創建Socket服務端,監聽指定端口
ServerSocket serverSocket = new ServerSocket(6666);
// 2.等待客戶端連接
System.out.println("server將一直等待連接的到來");
while (true) {
Socket socket = serverSocket.accept();
// 3.建立好連接之后,從Socket獲取輸入流,並建立緩沖區進行讀取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("get message from client: " + sb);
// 4.關閉流、Socket
inputStream.close();
socket.close();
}
}
}
這種一般也是新手寫法,但是能夠循環處理多個Socket請求,不過當一個請求的處理比較耗時的時候,后面的請求將被阻塞,所以一般都是用多線程的方式來處理Socket,即每有一個Socket請求的時候,就創建一個線程來處理它。
不過在實際生產中,創建的線程會交給線程池來處理,為了:
- 線程復用,創建線程耗時,回收線程慢。
- 防止短時間內高並發,指定線程池大小,超過數量將等待,防止短時間創建大量線程導致資源耗盡,服務掛掉。
package com.sjk.socket.multiserver;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SocketServer {
public static void main(String[] args) throws IOException {
// 1.創建Socket服務端,監聽指定端口
ServerSocket serverSocket = new ServerSocket(6666);
// 2.等待客戶端連接
System.out.println("server將一直等待連接的到來");
//如果使用多線程,那就需要線程池,防止並發過高時創建過多線程耗盡資源
ExecutorService threadPool = Executors.newFixedThreadPool(100);
while (true) {
Socket socket = serverSocket.accept();
Runnable runnable = () -> {
try {
// 3.建立好連接之后,從Socket獲取輸入流,並建立緩沖區進行讀取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8
sb.append(new String(bytes, 0, len, "UTF-8"));
}
System.out.println("get message from client: " + sb);
// 4.關閉流、Socket
inputStream.close();
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
};
threadPool.submit(runnable);
}
}
}
使用線程池的方式,算是一種成熟的方式。可以應用在生產中。
3.2 服務端其他屬性
ServerSocket有以下3個屬性。
- SO_TIMEOUT:表示等待客戶連接的超時時間。一般不設置,會持續等待。
- SO_REUSEADDR:表示是否允許重用服務器所綁定的地址。一般不設置,經我的測試沒必要,下面會進行詳解。
- SO_RCVBUF:表示接收數據的緩沖區的大小。一般不設置,用系統默認就可以了。
具體詳細的解釋可以參照下面。
3.3 性能再次提升
當現在的性能還不能滿足需求的時候,就需要考慮使用NIO,這不是本篇的內容,后續會貼出。
四、Socket的其它知識
其實如果經常看有關網絡編程的源碼的話,就會發現Socket還是有很多設置的,可以學着用,但是還是要有一些基本的了解比較好。下面就對Socket的Java API中涉及到的進行簡單講解。首先呢Socket有哪些可以設置的選項,其實在SocketOptions接口中已經都列出來了:
- int TCP_NODELAY = 0x0001:對此連接禁用 Nagle 算法。
- int SO_BINDADDR = 0x000F:此選項為 TCP 或 UDP 套接字在 IP 地址頭中設置服務類型或流量類字段。
- int SO_REUSEADDR = 0x04:設置套接字的 SO_REUSEADDR。
- int SO_BROADCAST = 0x0020:此選項啟用和禁用發送廣播消息的處理能力。
- int IP_MULTICAST_IF = 0x10:設置用於發送多播包的傳出接口。
- int IP_MULTICAST_IF2 = 0x1f:設置用於發送多播包的傳出接口。
- int IP_MULTICAST_LOOP = 0x12:此選項啟用或禁用多播數據報的本地回送。
- int IP_TOS = 0x3:此選項為 TCP 或 UDP 套接字在 IP 地址頭中設置服務類型或流量類字段。
- int SO_LINGER = 0x0080:指定關閉時逗留的超時值。
- int SO_TIMEOUT = 0x1006:設置阻塞 Socket 操作的超時值: ServerSocket.accept(); SocketInputStream.read(); DatagramSocket.receive(); 選項必須在進入阻塞操作前設置才能生效。
- int SO_SNDBUF = 0x1001:設置傳出網絡 I/O 的平台所使用的基礎緩沖區大小的提示。
- int SO_RCVBUF = 0x1002:設置傳入網絡 I/O 的平台所使用基礎緩沖區的大小的提示。
- int SO_KEEPALIVE = 0x0008:為 TCP 套接字設置 keepalive選項時
- int SO_OOBINLINE = 0x1003:置 OOBINLINE 選項時,在套接字上接收的所有 TCP 緊急數據都將通過套接字輸入流接收。
上面只是簡單介紹了下(來源Java API),下面有對其中的某些的詳細講解,沒講到的后續如果用到會補上。
4.1 客戶端綁定端口
服務端綁定端口是可以理解的,因為要監聽指定的端口,但是客戶端為什么要綁定端口,說實話我覺得這么做的人有點2,或許有的網絡安全策略配置了端口訪出,使用戶只能使用指定的端口,那么這樣的配置也是挺2的,直接說就可以不要留面子。
當然首先要理解的是,如果沒有指定端口的話,Socket會自動選取一個可以用的端口,不用瞎操心的。
但是你非得指定一個端口也是可以的,做法如下,這時候就不能用Socket的構造方法了,要一步一步來:
// 要連接的服務端IP地址和端口
String host = "localhost";
int port = 55533;
// 與服務端建立連接
Socket socket = new Socket();
socket.bind(new InetSocketAddress(55534));
socket.connect(new InetSocketAddress(host, port));
這樣做就可以了,但是當這個程序執行完成以后,再次執行就會報,端口占用異常:
java.net.BindException: Address already in use: connect
明明上一個Socket已經關閉了,為什么再次使用還會說已經被占用了呢?如果你是用netstat 命令來查看端口的使用情況:
netstat -n|findstr "55533"
TCP 127.0.0.1:55534 127.0.0.1:55533 TIME_WAIT
就會發現端口的使用狀態為TIME_WAIT,說到這你需要有一點TCP連接的基本常識,簡單來說,當連接主動關閉后,端口狀態變為TIME_WAIT,其他程序依然不能使用這個端口,防止服務端因為超時重新發送的確認連接斷開對新連接的程序造成影響。
TIME_WAIT的時間一般有底層決定,一般是2分鍾,還有1分鍾和30秒的。
所以,客戶端不要綁定端口,不要綁定端口,不要綁定端口。
4.2 讀超時SO_TIMEOUT
讀超時這個屬性還是比較重要的,當Socket優化到最后的時候,往往一個Socket連接會一直用下去,那么當一端因為異常導致連接沒有關閉,另一方是不應該持續等下去的,所以應該設置一個讀取的超時時間,當超過指定的時間后,還沒有讀到數據,就假定這個連接無用,然后拋異常,捕獲異常后關閉連接就可以了,調用方法為:
public void setSoTimeout(int timeout) throws SocketException
timeout - 指定的以毫秒為單位的超時值。設置0為持續等待下去。建議根據網絡環境和實際生產環境選擇。
這個選項設置的值將對以下操作有影響:
- ServerSocket.accept()
- SocketInputStream.read()
- DatagramSocket.receive()
4.3 設置連接超時
這個連接超時和上面說的讀超時不一樣,讀超時是在建立連接以后,讀數據時使用的,而連接超時是在進行連接的時候,等待的時間。
4.4 判斷Socket是否可用
當需要判斷一個Socket是否可用的時候,不能簡簡單單判斷是否為null,是否關閉,下面給出一個比較全面的判斷Socket是否可用的表達式,這是根據Socket自身的一些狀態進行判斷的,它的狀態有:
- bound:是否綁定
- closed:是否關閉
- connected:是否連接
- shutIn:是否關閉輸入流
- shutOut:是否關閉輸出流
socket != null && socket.isBound() && !socket.isClosed() && socket.isConnected()&& !socket.isInputShutdown() && !socket.isOutputShutdown()
建議如此使用,但這只是第一步,保證Socket自身的狀態是可用的,但是當連接正常創建后,上面的屬性如果不調用本方相應的方法是不會改變的,也就是說如果網絡斷開、服務器主動斷開,Java底層是不會檢測到連接斷開並改變Socket的狀態,所以,真實的檢測連接狀態還是得通過額外的手段,有兩種方式。
4.4.1 自定義心跳包
雙方需要約定,什么樣的消息屬於心跳包,什么樣的消息屬於正常消息,假設你看了上面的章節現在說就容易理解了,我們定義前兩個字節為消息的長度,那么我們就可以定義第3個字節為消息的屬性,可以指定一位為消息的類型,1為心跳,0為正常消息。那么要做的有如下:
- 客戶端發送心跳包
- 服務端獲取消息判斷是否是心跳包,若是丟棄
- 當客戶端發送心跳包失敗時,就可以斷定連接不可用
具體的編碼不再貼出,自己實現即可。
4.4.2 通過發送緊急數據
Socket自帶一種模式,那就是發送緊急數據,這有一個前提,那就是服務端的OOBINLINE不能設置為true,它的默認值是false。
OOBINLINE的true和false影響了什么:
- 對客戶端沒有影響
- 對服務端,如果設置為true,那么服務端將會捕獲緊急數據,這會對接收數據造成混淆,需要額外判斷
發送緊急數據通過調用Socket的方法:
socket.sendUrgentData(0);
發送數據任意即可,因為OOBINLINE為false的時候,服務端會丟棄掉緊急數據。
當發送緊急數據報錯以后,我們就會知道連接不通了。
4.4.3 真的需要判斷連接斷開嗎
通過上面的兩種方式已經可以判斷出連接是否可用,然后我們就可以進行后續操作,可是請大家認真考慮下面的問題:
1.發送心跳成功時確認連接可用,當再次發送消息時能保證連接還可用嗎?即便中間的間隔很短。
2.如果連接不可用了,你會怎么做?重新建立連接再次發送數據?還是說單單只是記錄日志?
3.如果你打算重新建立連接,那么發送心跳包的意義何在?為何不在發送異常時再新建連接?
如果你認真考慮了上面的問題,那么你就會覺得發送心跳包完全是沒有必要的操作,通過發送心跳包來判斷連接是否可用是通過捕獲異常來判斷的。那么我們完全可以在發送消息報出IO異常的時候,在異常中重新發送一次即可,這兩種方式的編碼有什么不同呢,下面寫一寫偽代碼。
提前檢測連接是否可用:
//有一個連接中的socket
Socket socket=...
//要發送的數據
String data="";
try{
//發送心跳包或者緊急數據,來檢測連接的可用性
}catch (Excetption e){
//打印日志,並重連Socket
socket=new Socket(host,port);
}
socket.write(data);
直接發送數據,出異常后重新連接再次發送:
//有一個連接中的socket
Socket socket=...
//要發送的數據
String data="";
try{
socket.write(data);
}catch (Excetption e){
//打印日志,並重連Socket
socket=new Socket(host,port);
socket.write(data);
}
通過比較可以發現兩種方式的特點,現在簡單介紹下:
- 兩種方式均可實現連接斷開重新連接並發送
- 提前檢測,再每次發送消息的時候都要檢測,影響效率,占用帶寬
希望大家認真考慮,做出自己的選擇。
4.5 設置端口重用SO_REUSEADDR
首先,創建Socket時,默認是禁止的,設置true有什么作用呢,Java API中是這么介紹的:
關閉 TCP 連接時,該連接可能在關閉后的一段時間內保持超時狀態(通常稱為 TIME_WAIT 狀態或 2MSL 等待狀態)。對於使用已知套接字地址或端口的應用程序而言,如果存在處於超時狀態的連接(包括地址和端口),可能不能將套接字綁定到所需的 SocketAddress 上。
使用 bind(SocketAddress) 綁定套接字前啟用 SO_REUSEADDR 允許在上一個連接處於超時狀態時綁定套接字。
一般是用在綁定端口的時候使用,但是經過我的測試建議如下:
- 服務端綁定端口后,關閉服務端,重新啟動后不會提示端口占用
- 客戶端綁定端口后,關閉,即便設置ReuseAddress為true,即便能綁定端口,連接的時候還是會報端口占用異常
綜上所述,不建議綁定端口,也沒必要設置ReuseAddress,當然ReuseAddress的底層還是和硬件有關系的,或許在你的機器上測試結果和我不一樣,若是如此和平台相關性差異這么大配置更是不建議使用了。
4.6 設置關閉等待SO_LINGER
Java API的介紹是:啟用/禁用具有指定逗留時間(以秒為單位)的 SO_LINGER。最大超時值是特定於平台的。 該設置僅影響套接字關閉。
大家都是這么說的,當調用Socket的close方法后,沒有發送的數據將不再發送,設置這個值的話,Socket會等待指定的時間發送完數據包。說實話,經過我簡單的測試,對於一般數據量來說,幾十K左右,即便直接關閉Socket的連接,服務端也是可以收到數據的。
所以對於一般應用沒必要設置這個值,當數據量發送過大拋出異常時,再來設置這個值也不晚。那么到達逗留超時值時,套接字將通過 TCP RST 強制性 關閉。啟用超時值為零的選項將立即強制關閉。如果指定的超時值大於 65,535,則其將被減少到 65,535。
4.7 設置發送延遲策略TCP_NODELAY
一般來說當客戶端想服務器發送數據的時候,會根據當前數據量來決定是否發送,如果數據量過小,那么系統將會根據Nagle 算法(暫時還沒研究),來決定發送包的合並,也就是說發送會有延遲,這在有時候是致命的,比如說對實時性要求很高的消息發送,在線對戰游戲等,即便數據量很小也要求立即發送,如果稍有延遲就會感覺到卡頓,默認情況下Nagle 算法是開啟的,所以如果不打算有延遲,最好關閉它。這樣一旦有數據將會立即發送而不會寫入緩沖區。
但是對延遲要求不是特別高下還是可以使用的,還是可以提升網絡傳輸效率的。
4.8 設置輸出輸出緩沖區大小SO_RCVBUF/SO_SNDBUF
- SO_SNDBUF:發送緩沖
- SO_RCVBUF:接收緩沖
默認都是8K,如果有需要可以修改,通過相應的set方法。不建議修改的太小,設置太小數據傳輸將過於頻繁。太大了將會造成消息停留。
不過我對這個經過測試后有以下結論: - 當數據填滿緩沖區時,一定會發送
- 當數據沒有填滿緩沖區時也會發送,這個算法還是上面說的Nagle 算法
4.9 設置保持連接存活SO_KEEPALIVE
雖然說當設置連接連接的讀超時為0,即無限等待時,Socket不會被主動關閉,但是總會有莫名其妙的軟件來檢測你的連接是否有數據發送,長時間沒有數據傳輸的連接會被它們關閉掉。
因此通過設置這個選項為true,可以有如下效果:當2個小時(具體的實現而不同)內在任意方向上都沒有跨越套接字交換數據,則 TCP 會自動發送一個保持存活的消息到對面。將會有以下三種響應:
1.返回期望的ACK。那么不通知應用程序(因為一切正常),2 小時的不活動時間過后,TCP 將發送另一個探頭。
2.對面返回RST,表明對面掛了,但是又好了,Socket依然要關閉
3.沒有響應,說明對面掛了,這時候關閉Socket
所以對於構建長時間連接的Socket還是配置上SO_KEEPALIVE比較好。
4.10 異常:java.net.SocketException: Connection reset by peer
這個異常的含義是,我正在寫數據的時候,你把連接給關閉了。這個異常在一般正常的編碼是不會出現這個異常的,因為用戶通常會判斷是否讀到流的末尾了,讀到末尾才會進行關閉操作,如果出現這個異常,那就檢查一下判斷是否讀到流的末尾邏輯是否正確。
五、關於Socket的理解
5.1 Socket和TCP/IP
最近在看《TCP/IP詳解 卷1:協議》,關於TCP/IP我覺得講解的非常詳細,我做了點摘抄,可以大致看看,非常建議大家閱讀下這本書。通常TCP/IP分為四層:
也就是說Socket實際上是歸屬於應用層,使用的事運輸層的TCP,使用SocketServer監聽的端口,也是可以被Telnet連接的。可以看下面兩行代碼:
ServerSocket server = new ServerSocket(port);
Socket socket = server.accept();
在什么情況獲取到這個Socket呢,通過理論加測試,結論是在三次握手操作后,系統才會將這個連接交給應用層,ServerSocket 才知道有一個連接過來了。那么系統當接收到一個TCP連接請求后,如果上層還沒有接受它(假如SocketServer循環處理Socket,一次一個),那么系統將緩存這個連接請求,既然是緩存那么就是有限度的,書上介紹的是緩存3個,但是經過我的本機測試是50個,也就是說,系統將會為應用層的Socket緩存50和TCP連接(這是和系統底層有關系的),當超過指定數量后,系統將會拒絕連接。
假如緩存的TCP連接請求發送來數據,那么系統也會緩存這些數據,等待SocketServer獲得這個連接的時候一並交給它,這個會在后期學習NIO進行詳解。
換句話說,系統接收TCP連接請求放入緩存隊列,而SocketServer從緩存隊列獲取Socket。
而上面例子中的為了讓服務端知道發送完消息的,關閉輸出流的操作:
socket.shutdownOutput();
其實是對應着四次揮手的第一次:
也就是上面說的主動關閉,FIN_WAIT_1,這樣服務端就能得知客戶端發送完消息,此時服務端可以選擇關閉連接,也可以選擇發送數據后關閉連接:
這就是TCP所說的半關閉。其實很多知識都是想通的,多學點基礎知識還是有必要的。
5.2 Socket和RMI
RMI基礎知識就不多介紹了(后續會寫,敬請期待),現在假定你對RMI有所了解,那么一般就會對這兩種技術有所比較。或者說在應用的時候就會想用那種技術比較好。
RMI全稱:Remote Method Invocation-遠程方法調用,通過名字其實就能對這種技術有個初步的了解。現在我就簡單說說我對這兩種技術的想法。
這個待寫,等我寫完RMI博客的時候補上,那時候會更細致的了解下。
5.3 DatagramSocket與Socket
這一段涉及到UDP,依然和上面一樣,后續會補上。
5.4 拆包和黏包
使用Socket通信的時候,或多或少都聽過拆包和黏包,如果沒聽過而去貿然編程那么偶爾就會碰到一些莫名其妙的問題,所有有這方面的知識還是比較重要的,至少知道怎么發生,怎么防范。
現在先簡單說明下拆包和黏包的原因:
-
拆包:當一次發送(Socket)的數據量過大,而底層(TCP/IP)不支持一次發送那么大的數據量,則會發生拆包現象。
-
黏包:當在短時間內發送(Socket)很多數據量小的包時,底層(TCP/IP)會根據一定的算法(指Nagle)把一些包合作為一個包發送。
首先可以明確的是,大部分情況下我們是不希望發生拆包和黏包的(如果希望發生,什么都去做即可),那么怎么去避免呢,下面進行詳解?
5.4.1 黏包
首先我們應該正確看待黏包,黏包實際上是對網絡通信的一種優化,假如說上層只發送一個字節數據,而底層卻發送了41個字節,其中20字節的I P首部、 20字節的T C P首部和1個字節的數據,而且發送完后還需要確認,這么做浪費了帶寬,量大時還會造成網絡擁堵。當然它還是有一定的缺點的,就是因為它會合並一些包會導致數據不能立即發送出去,會造成延遲,如果能接受(一般延遲為200ms),那么還是不建議關閉這種優化,如果因為黏包會造成業務上的錯誤,那么請改正你的服務端讀取算法(協議),因為即便不發生黏包,在服務端緩存區也可能會合並起來一起提交給上層,推薦使用長度+類型+數據模式。
如果不希望發生黏包,那么通過禁用TCP_NODELAY即可,Socket中也有相應的方法:
void setTcpNoDelay(boolean on)
通過設置為true即可防止在發送的時候黏包,但是當發送的速率大於讀取的速率時,在服務端也會發生黏包,即因服務端讀取過慢,導致它一次可能讀取多個包。
5.4.2 拆包
這個問題應該引起重視,在TCP/IP詳解中說過:最大報文段長度(MSS)表示TCP傳往另一端的最大塊數據的長度。當一個連接建立時,連接的雙方都要通告各自的 MSS。客戶端會盡量滿足服務端的要求且不能大於服務端的MSS值,當沒有協商時,會使用值536字節。雖然看起來MSS值越大越好,但是考慮到一些其他情況,這個值還是不太好確定,具體詳見《TCP/IP詳解 卷1:協議》。
如何應對拆包,其實在上面2.3節已經介紹過了,那就是如何表明發送完一條消息了,對於已知數據長度的模式,可以構造相同大小的數組,循環讀取,示例代碼如下:
int length=1024;//這個是讀取的到數據長度,現假定1024
byte[] data=new byte[1024];
int readLength=0;
while(readLength<length){
int read = inputStream.read(data, readLength, length-readLength);
readLength+=read;
}
這樣當循環結束后,就能讀取到完整的一條數據,而不需要考慮拆包了。