多客户端服务端与多线程通信 | JavaSE
多客户端服务端与多线程通信
一、学习目标
学完本章,你应该能够:
- 能够解释为什么上一章的单线程服务端无法同时持续处理多个 TCP 客户端。
- 能够理解“主线程负责接收连接、子线程负责处理连接”的经典阻塞式服务器模型。
- 能够使用
while + accept()持续接受多个客户端。 - 能够为每个客户端
Socket创建独立线程。 - 能够实现一个
ServerReader线程类持续读取指定客户端的数据。 - 能够理解不同客户端为什么可以同时连接服务端同一个监听端口。
- 能够正确处理客户端断开和 Socket 资源释放。
- 能够理解“一连接一线程”模型的扩展性问题,为下一章线程池做好准备。
二、核心知识
2.1 上一章的问题到底在哪里
上一章服务端大致是:
ServerSocket serverSocket =
new ServerSocket(9999);
Socket socket =
serverSocket.accept();
DataInputStream input =
new DataInputStream(
socket.getInputStream()
);
while (true) {
String message =
input.readUTF();
System.out.println(message);
}
这个程序:
accept Client A
↓
获取 A 的 Socket
↓
while true
↓
一直 readUTF(A)
主线程从此一直围绕:
Client A
工作。
即使此时:
Client B
Client C
Client D
也尝试连接:
Server : 9999
应用程序主线程也没有回到:
serverSocket.accept();
继续处理它们。
这就是问题:
一个执行线程既负责接受新连接,又负责长期读取某一个客户端,会发生职责冲突。
2.2 为什么阻塞会导致单线程服务端无法同时服务多个客户端
假设:
Server Main Thread
执行:
Socket socket =
serverSocket.accept();
Client A 到达。
于是:
Main Thread
↓
处理 Client A
↓
input.readUTF()
如果 A 此时一分钟都不发送数据:
Main Thread
就可能一直等待:
A 的下一条消息
那么:
Client B
即使已经请求连接,应用程序主线程也没有进入下一次:
accept()
因此我们需要把:
等待新客户端
与:
持续处理某个已有客户端
拆给不同线程。
2.3 多客户端服务端的核心解决方案
经典模型:
Server Main Thread
ServerSocket:9999
│
│ accept()
▼
Client A Socket
│
└────→ Thread A
Main Thread
重新 accept()
│
▼
Client B Socket
│
└────→ Thread B
Main Thread
重新 accept()
│
▼
Client C Socket
│
└────→ Thread C
最终:
Server
Main Thread
│
┌────────┼────────┐
▼ ▼ ▼
Thread A Thread B Thread C
│ │ │
▼ ▼ ▼
Client A Client B Client C
职责非常清楚:
主线程
负责:
不断 accept()
接受新连接
子线程
负责:
绑定一个 Socket
持续读取这个客户端消息
2.4 “一个客户端连接对应一个线程”
本章采用最直接的模型:
One Connection
↓
One Thread
例如:
Socket A
↓
Thread A
Socket B
↓
Thread B
Socket C
↓
Thread C
于是:
Thread A
如果阻塞在:
inputA.readUTF();
不会影响:
Thread B
Thread C
Main Thread
继续执行。
这正是多线程的价值。
2.5 网络编程和多线程终于真正汇合
之前学习:
08 多线程与并发
可能还是:
线程打印数字
线程抢票
线程安全
线程池
现在多线程拥有了非常实际的网络场景。
服务器需要同时面对:
用户 A
用户 B
用户 C
用户 D
...
每个用户的网络读取操作都可能:
阻塞
所以:
网络 IO
+
多线程
是一个天然组合。
同时网络通信又使用:
InputStream
OutputStream
DataInputStream
DataOutputStream
因此现在实际上已经形成:
Java 网络服务端
┌──────┼──────┐
│ │ │
IO Thread Socket
这是 JavaSE 知识第一次大规模融合。
2.6 主线程的任务
主线程只做:
while (true) {
Socket socket =
serverSocket.accept();
new ServerReader(
socket
).start();
}
注意:
serverSocket.accept();
仍然会阻塞。
但是现在:
阻塞 accept
只代表:
当前没有新的客户端连接。
已有客户端已经分别由:
Thread A
Thread B
Thread C
负责。
所以它们仍然可以继续通信。
2.7 ServerReader 的任务
每接受一个客户端:
Socket socket =
serverSocket.accept();
就创建:
new ServerReader(socket)
把这个 Socket 交给线程对象。
因此:
class ServerReader extends Thread {
private Socket socket;
}
这里的:
socket
表示:
当前线程负责的那个客户端连接。
线程启动:
reader.start();
之后:
run()
就会在独立子线程中执行。
三、使用方法
3.1 多客户端服务端主程序
import java.net.ServerSocket;
import java.net.Socket;
public class TCPMultiServer {
public static void main(String[] args)
throws Exception {
System.out.println(
"=== TCP 多客户端服务端启动 ==="
);
try (
ServerSocket serverSocket =
new ServerSocket(9999)
) {
while (true) {
Socket socket =
serverSocket.accept();
String client =
socket.getInetAddress()
.getHostAddress()
+
":"
+
socket.getPort();
System.out.println(
"客户端上线:"
+ client
);
ServerReader reader =
new ServerReader(
socket
);
reader.start();
}
}
}
}
核心实际上只有:
while (true) {
Socket socket =
serverSocket.accept();
new ServerReader(
socket
).start();
}
3.2 主线程执行模型
启动:
ServerSocket : 9999
执行:
accept();
Client A 到达:
Main Thread
↓
accept A
↓
创建 Thread A
↓
Thread A.start()
↓
Main Thread 立即回来
↓
再次 accept()
因此主线程没有进入:
A 的消息读取循环
这正是关键。
3.3 编写客户端处理线程 ServerReader
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.Socket;
public class ServerReader extends Thread {
private final Socket socket;
public ServerReader(
Socket socket
) {
this.socket = socket;
}
@Override
public void run() {
String client =
socket.getInetAddress()
.getHostAddress()
+
":"
+
socket.getPort();
try (
Socket clientSocket =
socket;
DataInputStream input =
new DataInputStream(
clientSocket
.getInputStream()
)
) {
while (true) {
String message =
input.readUTF();
System.out.println(
"[" + client + "] "
+ message
);
}
} catch (EOFException e) {
System.out.println(
"客户端正常下线:"
+ client
);
} catch (IOException e) {
System.out.println(
"客户端连接异常:"
+ client
+ ","
+ e.getMessage()
);
}
}
}
3.4 为什么 Socket 要传入构造器
主线程:
Socket socket =
serverSocket.accept();
这个 Socket 专门表示:
刚刚连接进来的客户端
然后:
new ServerReader(socket)
把它交给对应线程。
于是:
Thread A
持有 Socket A
Thread B
持有 Socket B
Thread C
持有 Socket C
互不混淆。
3.5 为什么 socket 字段适合使用 final
private final Socket socket;
表示:
一个
ServerReader对象创建后,它负责的客户端 Socket 不再改变。
这非常符合设计语义:
ServerReader A
永远负责 Client A
而不是运行到一半:
突然改成 Client B
3.6 多个客户端怎么测试
客户端不需要重新设计。
直接使用上一章:
TCPChatClient
然后在 IDEA 中同时启动多个实例。
例如:
Client A
Client B
Client C
它们都连接:
127.0.0.1:9999
但操作系统可能为客户端分配不同临时端口:
Client A
127.0.0.1:52101
Client B
127.0.0.1:52102
Client C
127.0.0.1:52103
服务端:
127.0.0.1:9999
保持不变。
3.7 为什么多个客户端可以连接同一个服务器端口
初学者容易产生疑问:
不是说端口不能重复占用吗?
要区分:
监听 Socket
和:
已建立 TCP 连接
ServerSocket:
Server
192.168.1.100:9999
监听 9999。
客户端 A:
192.168.1.10:52001
→
192.168.1.100:9999
客户端 B:
192.168.1.11:52002
→
192.168.1.100:9999
客户端 C:
192.168.1.12:52003
→
192.168.1.100:9999
这些 TCP 连接具有不同的:
客户端 IP
客户端端口
所以可以区分。
简化理解:
TCP Connection
=
本地 IP
+
本地 Port
+
远端 IP
+
远端 Port
因此:
服务器端口相同
不代表:
所有连接都是同一个连接
四、原理与进阶
4.1 两层循环模型
现在服务端出现了非常典型的:
两层循环
但它们不在同一个线程里。
主线程循环
while (true) {
socket =
serverSocket.accept();
}
负责:
接受客户端
每个子线程自己的循环
while (true) {
input.readUTF();
}
负责:
读取当前客户端
可以画成:
Main Thread
│
├── accept A → Thread A → read A → read A → ...
│
├── accept B → Thread B → read B → read B → ...
│
├── accept C → Thread C → read C → read C → ...
│
└── accept D → Thread D → read D → read D → ...
这张图是本章最重要的结构。
4.2 一个线程阻塞不会阻塞其他线程
假设:
Client A
30 秒不说话。
那么:
Thread A
阻塞在:
input.readUTF();
但:
Thread B
仍可以:
input.readUTF();
读取 B。
主线程也仍然可以:
accept();
接受 Client D。
所以:
Thread A 的阻塞
≠
整个服务器阻塞
这是多线程服务器设计的关键意义。
4.3 客户端下线为什么通常表现为读取异常或流结束
TCP 是持续连接。
当客户端:
socket.close();
服务端对应线程仍可能正阻塞:
input.readUTF();
随着对端关闭,服务端输入方向达到流结束。
对于:
readUTF()
这种要求读取完整格式数据的方法,可能得到:
EOFException
如果连接被异常重置,则可能出现其他:
IOException
所以服务器线程需要:
正常下线
异常断线
的处理逻辑。
4.4 为什么客户端断开后线程应该结束
如果:
Client A
已经下线:
Socket A
不再可用。
那么:
Thread A
也就失去了存在价值。
正确生命周期:
客户端连接
↓
创建线程
↓
持续服务
↓
客户端断开
↓
释放 Socket
↓
线程 run() 结束
↓
线程生命周期结束
不能留下大量:
已经没有客户端
却永远不退出的线程
4.5 一连接一线程模型的问题
当前设计:
1 Client
=
1 Thread
如果:
10 个客户端
大约创建:
10 个工作线程
100 个:
100 个线程
10000 个:
10000 个线程
问题逐渐出现:
- 线程对象需要内存
- 每个线程需要栈空间
- 操作系统需要调度线程
- 大量上下文切换产生开销
- 恶意连接可能导致线程数量爆炸
- 服务端资源不可控
所以:
“一个客户端直接 new 一个 Thread”可以帮助我们理解并发服务器,但不是大型服务器无限扩展的最终方案。
下一章就是:
09-09
线程池优化网络服务端
4.6 为什么线程池更合理
当前:
new ServerReader(socket)
.start();
意味着:
来了一个连接
↓
创建一个线程
下一章会升级:
来了一个连接
↓
封装成任务
↓
提交线程池
↓
由有限数量线程处理
即:
Socket
↓
Runnable
↓
ExecutorService
这样能够限制:
线程数量
并实现:
线程复用
任务队列
拒绝策略
这正是前面 08-09、08-10 线程池知识的实际应用。
五、实践应用
5.1 多客户端聊天服务基础
当前服务端已经可以同时接收:
A:你好
B:我上线了
C:今天学 Java 网络编程
但只是:
客户端 → 服务端
服务器打印消息。
还没有:
A 发消息
↓
Server
↓
转给 B、C
如果后续增加:
在线 Socket 集合
例如:
Map<Socket, String>
就可以记录:
Socket A → LingXi
Socket B → UserB
Socket C → UserC
再遍历这些 Socket 的:
getOutputStream()
就能实现:
群聊
这正是后续 JavaSE 综合项目:
局域网即时通信系统
的基础。
5.2 即时通信系统的雏形
完整聊天系统通常会进一步变成:
Server
Online Socket Map
│
┌────────┼────────┐
▼ ▼ ▼
Client A Client B Client C
服务端每个客户端仍然需要:
持续读消息
同时还需要:
向其他客户端写消息
这会进一步涉及:
- 集合
- 多线程
- Socket
- DataInputStream
- DataOutputStream
- 消息协议
- 用户上下线
之前学过的 JavaSE 内容会在这里全部组合起来。
六、常见问题
6.1 为什么上一章不能同时服务多个客户端?
因为主线程:
accept 一个客户端
↓
一直 read 这个客户端
无法回去继续执行:
accept();
6.2 多客户端服务端最核心的设计是什么?
一句话:
主线程循环 accept,每个 Socket 交给一个独立线程处理。
6.3 accept 本身阻塞,会影响已经连接的客户端吗?
不会。
主线程阻塞:
accept()
只是在等待:
新的连接
已经建立的连接由:
对应子线程
负责。
6.4 每一个子线程负责什么?
负责:
一个客户端 Socket
以及这个连接上的:
持续读取 / 后续业务处理
6.5 一个客户端下线会不会导致整个服务器退出?
正确设计下不会。
只应该:
对应的 ServerReader 结束
主线程和其他客户端线程继续运行。
6.6 所有客户端为什么都能连接 9999?
因为 9999 是:
服务端监听端口
不同 TCP 连接还包含不同的:
客户端 IP
客户端 Port
所以连接可以被区分。
6.7 为什么不能无限 new Thread?
因为线程属于:
有限系统资源
大量线程会带来:
内存
调度
上下文切换
资源耗尽
问题。
下一章使用线程池控制。
6.8 一个 ServerReader 可以处理两个 Socket 吗?
本章设计中不应该。
我们建立明确关系:
一个 ServerReader
↓
一个 Socket
↓
一个客户端
这样职责最清晰。
6.9 客户端异常断开为什么不能简单忽略?
因为服务器还需要:
关闭 Socket
结束线程
移除在线用户
更新在线状态
否则可能出现:
资源泄漏
无效在线用户
无效 Socket
6.10 多线程服务器一定线程安全吗?
不一定。
当前每个线程只操作自己的:
Socket
共享数据很少。
但是以后增加:
Map<Socket, String> onlineUsers
多个线程共同:
添加用户
删除用户
遍历用户
就会出现真正的:
共享可变数据
届时要重新考虑:
- 线程安全集合
- synchronized
- Lock
- 并发容器
所以:
使用多线程以后,不代表程序自动线程安全。
七、练习与验收
7.1 知识问答
- 单线程 TCP 服务端为什么难以同时持续处理多个客户端?
accept()和readUTF()分别可能阻塞在什么地方?- 多客户端服务端中主线程主要负责什么?
- 每个子线程主要负责什么?
- 为什么需要
while(true) + accept()? - 为什么一个客户端连接通常对应一个
Socket? - 多个客户端为什么可以同时连接服务端 9999?
- Client A 长时间不发消息,为什么不会阻止 Client B 的消息处理?
- 客户端断开后对应工作线程应该怎样处理?
- 一连接一线程模型有什么扩展性问题?
- 为什么下一步需要线程池?
- 多线程服务器是否天然线程安全?
7.2 代码阅读
阅读:
while (true) {
Socket socket =
serverSocket.accept();
new ServerReader(
socket
).start();
}
回答:
- 哪个线程执行这段代码?
accept()每成功一次得到什么?ServerReader与 Socket 是什么对应关系?start()后谁负责该客户端?- 主线程接下来做什么?
再阅读:
@Override
public void run() {
while (true) {
String message =
input.readUTF();
System.out.println(
message
);
}
}
回答:
- 这是谁的循环?
- 如果客户端暂时没消息,该线程怎样?
- 是否会阻塞服务器主线程?
- 客户端断开后为什么不能无限继续这个循环?
7.3 手写代码
关闭 AI 自动补全。
从零完成:
MultiClientServer
要求:
ServerSocket监听 9999- 主线程无限循环
accept()获取客户端- 打印客户端 IP 和端口
- 每个 Socket 交给独立线程
再独立完成:
ServerReader
要求:
- 继承
Thread - 构造器接收
Socket - 获取
DataInputStream - 循环
readUTF() - 打印来源与消息
- 正确处理客户端下线
- 释放 Socket
7.4 Debug
下面程序为什么仍然不能真正持续支持多个客户端?
while (true) {
Socket socket =
serverSocket.accept();
DataInputStream input =
new DataInputStream(
socket.getInputStream()
);
while (true) {
System.out.println(
input.readUTF()
);
}
}
要求说明:
- 外层循环负责什么?
- 内层循环负责什么?
- 主线程什么时候才能回到下一次
accept()? - 应该如何利用 Thread 重构?
7.5 综合训练
同时启动:
Client A
Client B
Client C
服务端输出格式:
客户端上线:
127.0.0.1:xxxxx
[127.0.0.1:xxxxx] Hello
[127.0.0.1:yyyyy] Java
[127.0.0.1:zzzzz] Network
要求:
- 记录每个客户端对应的端口。
- 说明每一个客户端对应哪个线程。
- 让 Client B 退出。
- 验证 Client A 和 Client C 是否仍然可以通信。
- 说明为什么 B 下线不会终止整个服务端。
7.6 本章验收
如果能够闭卷画出:
ServerSocket
accept
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
Socket A Socket B Socket C
│ │ │
▼ ▼ ▼
Thread A Thread B Thread C
│ │ │
read A read B read C
│ │ │
Client A Client B Client C
并能够回答:
为什么主线程只负责 accept?
为什么客户端读取要放进独立线程?
为什么一个客户端阻塞不会阻塞其他客户端?
为什么“一个客户端一个线程”仍然不是最终服务器方案?
那么多客户端 TCP 服务端的核心模型已经真正掌握。