多客户端服务端与多线程通信 | JavaSE

多客户端服务端与多线程通信

一、学习目标

学完本章,你应该能够:

  1. 能够解释为什么上一章的单线程服务端无法同时持续处理多个 TCP 客户端。
  2. 能够理解“主线程负责接收连接、子线程负责处理连接”的经典阻塞式服务器模型。
  3. 能够使用 while + accept() 持续接受多个客户端。
  4. 能够为每个客户端 Socket 创建独立线程。
  5. 能够实现一个 ServerReader 线程类持续读取指定客户端的数据。
  6. 能够理解不同客户端为什么可以同时连接服务端同一个监听端口。
  7. 能够正确处理客户端断开和 Socket 资源释放。
  8. 能够理解“一连接一线程”模型的扩展性问题,为下一章线程池做好准备。

二、核心知识

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 知识问答

  1. 单线程 TCP 服务端为什么难以同时持续处理多个客户端?
  2. accept()readUTF() 分别可能阻塞在什么地方?
  3. 多客户端服务端中主线程主要负责什么?
  4. 每个子线程主要负责什么?
  5. 为什么需要 while(true) + accept()
  6. 为什么一个客户端连接通常对应一个 Socket
  7. 多个客户端为什么可以同时连接服务端 9999?
  8. Client A 长时间不发消息,为什么不会阻止 Client B 的消息处理?
  9. 客户端断开后对应工作线程应该怎样处理?
  10. 一连接一线程模型有什么扩展性问题?
  11. 为什么下一步需要线程池?
  12. 多线程服务器是否天然线程安全?

7.2 代码阅读

阅读:

while (true) {

    Socket socket =
            serverSocket.accept();

    new ServerReader(
            socket
    ).start();
}

回答:

  1. 哪个线程执行这段代码?
  2. accept() 每成功一次得到什么?
  3. ServerReader 与 Socket 是什么对应关系?
  4. start() 后谁负责该客户端?
  5. 主线程接下来做什么?

再阅读:

@Override
public void run() {

    while (true) {

        String message =
                input.readUTF();

        System.out.println(
                message
        );
    }
}

回答:

  1. 这是谁的循环?
  2. 如果客户端暂时没消息,该线程怎样?
  3. 是否会阻塞服务器主线程?
  4. 客户端断开后为什么不能无限继续这个循环?

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()
        );
    }
}

要求说明:

  1. 外层循环负责什么?
  2. 内层循环负责什么?
  3. 主线程什么时候才能回到下一次 accept()
  4. 应该如何利用 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

要求:

  1. 记录每个客户端对应的端口。
  2. 说明每一个客户端对应哪个线程。
  3. 让 Client B 退出。
  4. 验证 Client A 和 Client C 是否仍然可以通信。
  5. 说明为什么 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 服务端的核心模型已经真正掌握。