什么是 Socket?
Socket(套接字) 是网络通信的端点,不管是客户端还是服务端都需要这个端点,用于发送和接收数据。它封装了TCP/IP 协议栈,使得开发者无需关心底层网络细节,只需调用 API 即可实现网络通信。Socket 通信通常采用 C/S(客户端/服务器)模型,服务器端:监听某个端口,等待客户端连接。客户端:主动向服务器发起连接请求,建立通信通道。Socket 提供了一种端到端的通信机制,使得应用程序可以通过 IP 地址 + 端口号 的方式建立连接并传输数据。
在 Java 中,Socket 网络编程主要基于 TCP/IP 协议栈,建立连接的方式主要有两种:面向连接的 TCP 方式和无连接的 UDP 方式,其中 TCP 是最常用的连接方式。
TCP 是一种可靠的、面向连接的协议,其连接建立需要经过 "三次握手" 过程。Java 中通过Socket
(客户端)和ServerSocket
(服务器端)实现。
首先要建立起连接,然后象征性写个1进去,传输也是需要通过流,所以调用socket.getOutputStream()。
java
public class Client {
private Socket socket;
public Client(){
try {
System.out.println("connecting");
socket=new Socket("localhost",8088);
//socket=new Socket("176.55.4.34",8088);
System.out.println("finish");
} catch (IOException e) {
e.printStackTrace();
}
}
public void start()
{
try {
OutputStream os=socket.getOutputStream();
os.write(1);
}
catch (IOException e)
{
e.printStackTrace();
}
}
public static void main(String[] args) {
Client client=new Client();
client.start();
}
}
在new一个Socket对象的时候,第一个参数给想要连接的ip地址,而想要连接到本地也不需要特地去查自己的网络ip是多少,只需要给个localhost即可;第二个参数表示服务器监听的端口号(范围是 0-65535
),用于区分同一台服务器上的不同网络服务。端口号的作用是:当客户端连接到服务器的 IP 地址后,通过端口号找到服务器上对应的应用程序(如 Web 服务常用 80 端口,FTP 常用 21 端口,此处 8088 是自定义的服务端口)。
如果想让别人连接自己的服务器,仍需要得知自己的ip地址,按住win+R后回车,会弹出一个终端,在里面输入命令ipconfig回车,就可以看到本机的ip地址。

java
public class Server {
private ServerSocket ss;
public Server()
{
try {
System.out.println("正在启动服务端");
ss=new ServerSocket(8088);
System.out.println("启动服务端成功");
} catch (IOException e) {
e.printStackTrace();
}
}
public void start()
{
try {
System.out.println("等待客户端链接");
Socket socket = ss.accept();
System.out.println("一个客户端链接成功");
InputStream is=socket.getInputStream();
int x=is.read(bytes);
System.out.println(x);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Server server=new Server();
server.start();
}
}
这样在启动服务端后就会提示正在等待客户端连接,这时候再去启动客户端,就可以得到客户端输入的那个1了。
如果想要接收文本,根据流的传输特性,那就要将文本字符串转为字节流进行传输。但是这个字节流有到底有多少个字节,服务端是不知道的,所以应提前传一个长度,告诉服务端应该先创建这么大的一个字节数组。
java
String s = sc.nextLine();
if("exit".equals(s))
break;
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
os.write(bytes.length);
os.write(bytes);
java
byte[] bytes = new byte[len];
is.read(bytes);
String message = new String(bytes, StandardCharsets.UTF_8);
System.out.println("客户端说:" + message);
但是想让它的功能更齐全,要让客户端发送消息,服务端收到后客户端可以继续发送信息,而不是发送了一次信息就结束了。
那就让客户端进入死循环,直到输入的是exit就退出。而服务端就只能根据流中是否还有内容来判断是否结束循环,如果读到了-1就说明读到了流的结尾。
java
public class Client {
private Socket socket;
public Client()
{
try {
System.out.println("connecting");
socket=new Socket("localhost",8088);
System.out.println("connect finish");
} catch (IOException e) {
e.printStackTrace();
}
}
public void start()
{
try {
Scanner scanner = new Scanner(System.in);
OutputStream os=socket.getOutputStream();
while(true)
{
String input=scanner.nextLine();
if("exit".equals(input))
break;
byte[] bytes =input.getBytes(StandardCharsets.UTF_8);
os.write(bytes.length);
os.write(bytes);
}
} catch (IOException e) {
e.printStackTrace();
}
finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Client client = new Client();
client.start();
}
}
java
public class Server {
private ServerSocket ss;
public Server()
{
try {
System.out.println("启动服务器中");
ss=new ServerSocket(8088);
System.out.println("启动服务器成功");
} catch (IOException e) {
e.printStackTrace();
}
}
public void start()
{
try {
System.out.println("waiting connect");
Socket socket = ss.accept();
System.out.println("finished connect");
InputStream is=socket.getInputStream();
int len;
while ((len= is.read())!=-1)
{
byte[] bytes = new byte[len];
is.read(bytes);
String s = new String(bytes, StandardCharsets.UTF_8);
System.out.println("服务器说:"+s);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
}
在socket网络编程中,为了能让程序能同时响应多个连接请求并处理数据,行处理多个客户端连接,消除 IO 阻塞导致的效率瓶颈,提高资源利用率和程序响应速度,采用前面的多线程处理。
在服务端的start里面创建线程,随后调用该线程的start,同时新定义一个ClientHandler类实现runnable接口,把想要服务端接收消息的这个工作放到这里面运行。
java
public class Server {
private ServerSocket serverSocket;
public Server(){
try {
System.out.println("正在启动服务端...");
serverSocket = new ServerSocket(8088);
System.out.println("服务端启动成功!");
} catch (IOException e) {
e.printStackTrace();
}
}
public void start(){
try {
while(true) {
System.out.println("等待客户端链接...");
Socket socket = serverSocket.accept();
System.out.println("一个客户端链接了!");
//启动一个线程来处理该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread t = new Thread(handler);
t.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
private class ClientHandler implements Runnable{
private Socket socket;
public ClientHandler(Socket socket){
this.socket = socket;
}
@Override
public void run() {
try {
InputStream in = socket.getInputStream();
int len;
while((len = in.read())!=-1) {
byte[] data = new byte[len];
in.read(data);
String message = new String(data, StandardCharsets.UTF_8);
System.out.println("客户端说:" + message);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
三次握手和四次挥手
三次握手是 TCP 协议建立连接的核心过程,用于在客户端和服务器之间建立可靠的双向通信链路。它的本质是通过三次交互,让双方确认彼此的 "发送" 和 "接收" 能力均正常,从而为后续的数据传输奠定基础。
第一次握手:客户端向服务端发送一个带有 SYN
(同步序列编号)标志的数据包,并生成一个随机的初始序列号(假设为 seq = x
)。服务器状态从 "关闭状态" 进入 "同步收到状态(SYN-RCVD)。服务器确认 "客户端能发送,自己能接收"(因为收到了客户端的 SYN)。第一次握手失败是 TCP 连接建立阶段最常见的问题之一,本质是 "客户端的连接请求未被服务器正确接收或处理"。TCP 通过超时重传机制尽可能规避临时网络故障,若最终失败则向应用层反馈具体错误,确保连接建立的可靠性和透明度。
第二次握手:是从服务端返回一个带有 SYN
和 ACK
(确认)标志的数据包给客户端。ACK = x + 1
(表示已收到客户端的序列号 x,下次期望接收 x+1);服务器自己生成一个随机初始序列号 seq = y
。客户端从 "同步发送状态(SYN-SENT)" 进入 "建立连接状态(ESTABLISHED)"。客户端确认 "服务器能发送,自己能接收"(因为收到了服务器的 SYN+ACK)。假如只有两次握手:服务器收到旧请求后会直接建立连接,但客户端早已放弃,导致服务器资源浪费。
第三次握手:客户端发送一个带有 ACK
标志的数据包给服务端,其中 ACK = y + 1
(表示已收到服务器的序列号 y,下次期望接收 y+1)。服务器状态从 "SYN-RCVD" 进入 "ESTABLISHED",从此连接正式建立。服务器确认 "客户端能接收,自己能发送"(因为收到了客户端的 ACK)。
最终双方确认:双向通信的发送和接收通道均无问题。三次握手时,服务器必须收到客户端的第三次 ACK 才确认连接,而旧请求的第三次 ACK 不会出现(客户端已忽略),因此能过滤无效请求。

如果直接强制断开连接(如进程崩溃、网络突然中断、主动关闭 socket 但不发送 FIN 包等)会导致连接状态异常和资源泄漏,而编译器就直接报错了。不执行四次挥手直接断开,本质是破坏了 TCP 的 "有序释放" 机制,会导致未断开的一方因 "不知情" 而长期占用资源,甚至引发系统级问题(如服务器 FD 耗尽)。四次挥手的核心意义就是通过双向确认,确保双方都能 "优雅释放" 资源,避免此类问题。实际开发中,需通过保活机制、超时设置等手段,降低强制断开的影响。
第一次挥手 :客户端向服务端发送一个带有 FIN
(结束)标志的数据包,序列号为 seq = u
(表示客户端已完成数据发送,请求关闭连接)。客户端状态从 "ESTABLISHED(已连接)" 进入 "FIN-WAIT-1(等待结束 1)" 状态,等待服务器的确认。
长时间未收到 ACK,客户端会触发超时重传机制。服务器因未收到 FIN,会一直保持ESTABLISHED
状态,占用资源(如文件描述符),成为 "僵尸连接"。若重传多次后仍无响应,客户端会判定连接异常,主动关闭连接,进入CLOSED
状态。
第二次挥手 :服务器会返回一个带有 ACK
(确认)标志的数据包,确认号为 ACK = u + 1
,序列号为 seq = v
(表示已收到客户端的 FIN 请求)。服务器状态从 "ESTABLISHED" 进入 "CLOSE-WAIT(关闭等待)" 状态,此时服务器仍可向客户端发送未完成的数据。客户端收到 ACK 后,从 "FIN-WAIT-1" 进入 "FIN-WAIT-2(等待结束 2)" 状态,等待服务器的 FIN 请求。
此时服务端可能还有数据要发送给客户端(比如未发送完的响应数据),所以不会立即发 FIN
。服务端会继续处理应用程序的数据,直到这一块的所有数据发送完毕。
分块传输本质是在网络限制、资源效率、可靠性之间做平衡:通过将大数据拆分为小块,适配底层网络特性、降低资源消耗、配合 TCP 的控制机制,最终实现高效、稳定的传输。这也是为什么 Socket 编程中,通常会用循环读取 / 写入的方式(如每次读取 1024 字节)分块处理数据,而不是一次性传输。服务端应用程序调用 close()
或 shutdown()
时,操作系统才会触发 FIN
(第三次挥手)。
第三次挥手 :当服务器确认自己的数据已发送完毕,发送一个带有 FIN
标志的数据包,序列号为 seq = w
,确认号仍为 ACK = u + 1
。当服务器确认自己的数据已发送完毕,发送一个带有 FIN
标志的数据包,序列号为 seq = w
,确认号仍为 ACK = u + 1
。服务器状态从 "CLOSE-WAIT" 进入 "LAST-ACK(最后确认)" 状态,等待客户端的最终确认。
第四次挥手 :客户端返回一个带有 ACK
标志的数据包,确认号为 ACK = w + 1
,序列号为 seq = u + 1
。含义:"服务器,我收到你可以关闭连接的通知了,现在确认关闭。"客户端状态从 "FIN-WAIT-2" 进入 "TIME-WAIT(时间等待)" 状态,等待一段时间(通常为 2MSL,即两倍的最大报文段寿命,约 1-4 分钟)后关闭连接。服务器收到 ACK 后,从 "LAST-ACK" 进入 "CLOSED(已关闭)" 状态,连接正式关闭。
很多人可能会觉得第四次挥手显得有点多余,但这可能导致客户端端口被长期占用,短期内无法复用该端口建立新连接(尤其客户端频繁断开重连时,可能导致端口耗尽)。

四次挥手的问题核心是数据包丢失 / 延迟和状态同步失败。TCP 通过超时重传(确保关键包被接收)、TIME-WAIT 状态(避免旧数据干扰)、保活机制(检测死连接)等机制缓解这些问题,但仍需应用层配合(如合理设置超时、主动释放资源),才能确保连接 "优雅关闭"。
"三次握手过程中,如果第三次 ACK 丢失,服务器会怎么做?"
坑点:误以为服务器直接关闭,忽略重传机制。正确回答:服务器处于SYN_RECV
状态,会启动重传计时器,每隔一段时间重传SYN+ACK
(通常重传 3-5 次);若始终未收到 ACK,服务器会释放连接,不再等待。
"初始序列号(ISN)是固定的吗?为什么?"
坑点:认为 ISN 固定(如 1),但其实并不是固定为1,同时忽略安全风险。正确回答:ISN 不是固定的,而是随机生成的(由系统根据时间、随机数等动态计算)。若固定,攻击者可能猜测后续序列号,伪造报文攻击(如 TCP 会话劫持),随机 ISN 可降低风险。
"服务器出现大量 CLOSE_WAIT 状态,可能的原因是什么?"
坑点:归咎于网络问题,忽略应用层逻辑。正确回答:CLOSE_WAIT 是服务器收到 FIN 并回复 ACK 后,未发送自己的 FIN 时的状态,说明应用程序未调用 close () 关闭连接(如代码漏写、处理逻辑阻塞导致无法执行关闭)。需检查应用程序是否及时释放连接。