杰瑞科技汇

java socket多线程编程

一个线程处理一个客户端连接

在单线程的 Socket 编程中,服务器一次只能处理一个客户端的请求,当服务器与客户端 A 通信时,它就无法响应客户端 B 的请求,这显然不能满足高并发的需求。

java socket多线程编程-图1
(图片来源网络,侵删)

多线程 Socket 编程的核心思想是: 每当有一个新的客户端连接请求到达时,服务器就创建一个新的线程来专门处理这个客户端的所有通信。

这样,主线程就可以继续监听新的客户端连接,而其他线程则可以并行地与各自的客户端进行通信,互不干扰。


编程模型与步骤

下面我们通过一个最经典的 “Echo 服务器” 来演示多线程 Socket 编程的模型,Echo 服务器的功能很简单:它接收客户端发来的任何消息,然后将原样返回给客户端。

我们将代码分为三个部分:

java socket多线程编程-图2
(图片来源网络,侵删)
  1. 服务器端
  2. 客户端
  3. 为每个客户端服务的任务类

服务器端代码

服务器的主要职责是:

  • 在一个固定的端口上启动一个 ServerSocket,监听客户端的连接请求。
  • 当一个客户端连接时,accept() 方法会返回一个代表该连接的 Socket 对象。
  • 创建一个新的 Thread,并将处理这个客户端通信的任务交给它。
  • 立即返回,继续监听下一个客户端连接。
// EchoServer.java
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class EchoServer {
    // 服务器监听的端口号
    private static final int PORT = 12345;
    public static void main(String[] args) {
        // 使用 try-with-resources 确保 ServerSocket 被正确关闭
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            System.out.println("服务器已启动,正在监听端口 " + PORT + "...");
            // 无限循环,持续监听客户端连接
            while (true) {
                // accept() 是一个阻塞方法,等待客户端连接
                // 当有客户端连接时,它会返回一个 Socket 对象
                Socket clientSocket = serverSocket.accept();
                System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
                // 创建一个新的线程来处理这个客户端的通信
                // 将 clientSocket 和客户端地址信息传递给线程任务
                ClientHandler handler = new ClientHandler(clientSocket);
                new Thread(handler).start();
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

客户端任务类

这是多线程模型的核心,这个类实现了 Runnable 接口,它的实例会被每个客户端线程执行,它的职责是:

  • Socket 获取输入流,读取客户端发来的数据。
  • Socket 获取输出流,将数据写回给客户端。
  • 处理通信结束后,关闭相关的资源。
// ClientHandler.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
public class ClientHandler implements Runnable {
    private final Socket clientSocket;
    public ClientHandler(Socket socket) {
        this.clientSocket = socket;
    }
    @Override
    public void run() {
        // 使用 try-with-resources 确保流和 socket 被正确关闭
        try (
            // 获取客户端的输入流,用于读取数据
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            // 获取客户端的输出流,用于发送数据
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
        ) {
            String inputLine;
            // 循环读取客户端发送的数据
            // readLine() 也是一个阻塞方法,会等待客户端发送一行数据
            while ((inputLine = in.readLine()) != null) {
                System.out.println("收到来自 " + clientSocket.getInetAddress().getHostAddress() + " 的消息: " + inputLine);
                // 将收到的消息回显给客户端
                out.println("服务器回显: " + inputLine);
            }
        } catch (IOException e) {
            // 当客户端断开连接时,会抛出异常,这里捕获并打印
            // 正常情况下,客户端关闭连接是 expected behavior,所以这里可以不打印堆栈
            System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已断开连接。");
        } finally {
            // 确保在任务结束时关闭 socket
            try {
                if (clientSocket != null && !clientSocket.isClosed()) {
                    clientSocket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

客户端代码

客户端相对简单,它只需要连接到服务器,然后发送和接收数据即可。

// EchoClient.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
public class EchoClient {
    private static final String HOSTNAME = "localhost";
    private static final int PORT = 12345;
    public static void main(String[] args) {
        // 使用 try-with-resources 确保 socket 和流被正确关闭
        try (
            Socket socket = new Socket(HOSTNAME, PORT);
            // 获取服务器的输入流,用于读取服务器返回的数据
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            // 获取服务器的输出流,用于发送数据
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
            // 获取控制台输入流,用于从键盘读取用户输入
            BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))
        ) {
            System.out.println("已连接到服务器。");
            System.out.println("请输入消息,输入 'exit' 退出:");
            String userInput;
            // 循环读取用户在控制台的输入
            while ((userInput = stdIn.readLine()) != null) {
                if ("exit".equalsIgnoreCase(userInput)) {
                    break;
                }
                // 将用户输入发送给服务器
                out.println(userInput);
                // 读取服务器返回的回显消息
                String response = in.readLine();
                System.out.println("服务器响应: " + response);
            }
        } catch (UnknownHostException e) {
            System.err.println("不知道的主机: " + HOSTNAME);
            System.exit(1);
        } catch (IOException e) {
            System.err.println("无法获取 I/O 针对 " + HOSTNAME + " 的连接");
            System.exit(1);
        }
    }
}

如何运行

  1. 编译所有 Java 文件:

    java socket多线程编程-图3
    (图片来源网络,侵删)
    javac EchoServer.java ClientHandler.java EchoClient.java
  2. 启动服务器:

    java EchoServer

    你会看到控制台输出:服务器已启动,正在监听端口 12345...

  3. 启动第一个客户端: 打开一个新的终端窗口,运行:

    java EchoClient

    你会看到:已连接到服务器,请输入消息,输入 'exit' 退出:

  4. 启动第二个客户端: 再打开一个新的终端窗口,运行:

    java EchoClient

    同样,你会看到连接成功的提示。

  5. 测试通信:

    • 第一个客户端的窗口输入 "Hello from Client 1",按回车。
      • 第一个客户端会收到:服务器响应: 服务器回显: Hello from Client 1
      • 服务器的控制台会打印:收到来自 127.0.0.1 的消息: Hello from Client 1
    • 第二个客户端的窗口输入 "Hello from Client 2",按回车。
      • 第二个客户端会收到:服务器响应: 服务器回显: Hello from Client 2
      • 服务器的控制台会打印:收到来自 127.0.0.1 的消息: Hello from Client 2

你会发现,两个客户端可以同时与服务器通信,互不影响,这就是多线程的魅力。


进阶与优化

上面的模型虽然简单易懂,但在生产环境中存在一些问题,需要进一步优化。

线程池

问题:如果同时有成千上万的客户端连接,服务器会创建成千上万个线程,这会消耗大量内存和 CPU 资源,导致系统性能急剧下降甚至崩溃。

解决方案:使用线程池。 线程池可以复用已经创建的线程,避免了频繁创建和销毁线程的开销,当有新的任务时,从线程池中获取一个空闲线程来执行;当任务执行完毕后,线程不会销毁,而是返回线程池中等待下一个任务。

优化后的服务器代码

// EchoServerWithThreadPool.java
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class EchoServerWithThreadPool {
    private static final int PORT = 12345;
    // 使用固定大小的线程池,50 个线程
    private static final int THREAD_POOL_SIZE = 50;
    public static void main(String[] args) {
        // 创建一个固定大小的线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            System.out.println("服务器已启动,使用线程池,正在监听端口 " + PORT + "...");
            while (true) {
                Socket clientSocket = serverSocket.accept();
                System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
                // 将任务提交给线程池执行,而不是创建新线程
                threadPool.execute(new ClientHandler(clientSocket));
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
            e.printStackTrace();
        } finally {
            // 关闭线程池(优雅地)
            threadPool.shutdown();
        }
    }
}

ClientHandler.javaEchoClient.java 的代码保持不变。

更健壮的客户端处理

问题ClientHandler 中的 while ((inputLine = in.readLine()) != null) 循环依赖于客户端在关闭连接时正确地关闭输出流,如果客户端异常崩溃,这个线程可能会一直阻塞。

解决方案

  • 设置超时:可以在 Socket 上设置 setSoTimeout(),这样 readLine() 在指定时间内没有数据读取到就会抛出 SocketTimeoutException,从而让线程有机会检查连接状态并退出。
  • 心跳机制:客户端定期发送一个简单的“心跳”包,服务器收到后就知道客户端还活着,如果一段时间内没收到心跳,就认为客户端已断开,主动关闭连接。

NIO (New I/O) 模型

问题:传统的 I/O (BIO - Blocking I/O) 模式中,每个连接都需要一个线程,即使是空闲的线程也会占用内存,对于连接数非常多但大部分是空闲的场景(如聊天服务器),BIO 效率不高。

解决方案:使用 Java NIO。 NIO 使用非阻塞 I/O多路复用机制(如 Selector),可以用一个或几个线程来管理成千上万个连接,当某个连接有数据可读或可写时,Selector 会通知线程去处理,否则线程可以去处理其他连接,这是实现高性能网络服务器的关键技术。

特性 简单多线程模型 线程池模型 NIO 模型
核心思想 一个客户端一个线程 线程复用 非阻塞 I/O + 多路复用
优点 编程简单,逻辑清晰 资源利用率高,性能稳定 高并发,低资源消耗
缺点 资源消耗大,无法处理大量连接 线程数需要合理配置 编程模型复杂,理解门槛高
适用场景 学习、原型开发、连接数较少的应用 连接数较多,但可控的通用应用 超高并发,如 IM 服务器、Web 服务器

对于初学者来说,掌握 “服务器主线程监听 + 每个客户端一个工作线程” 的模型是至关重要的基础,在此基础上,再学习如何使用线程池进行优化,最后可以挑战更高性能的 NIO 模型。

分享:
扫描分享到社交APP
上一篇
下一篇