杰瑞科技汇

java socket 多线程编程

核心思想

一个简单的 Socket 服务器一次只能处理一个客户端的请求,当服务器正在处理客户端 A 的请求时,客户端 B 发来的连接请求只能排队等待,直到客户端 A 断开连接,这在高并发场景下是完全不可接受的。

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

多线程的解决方案是:

  1. 主线程(或称监听线程):只负责一个任务——在指定的端口上 accept()(接受)新的客户端连接。
  2. 工作线程:每当 accept() 成功一个新连接,就立即创建一个新的线程来专门处理这个客户端的后续所有通信(读取数据、处理业务逻辑、发送响应)。

这样一来,主线程可以立即返回,继续监听下一个客户端连接,而每个客户端的通信任务都在自己的独立线程中并行处理,互不干扰。


编程模型

下面我们介绍两种最主流的模型,并给出代码示例。

为每个客户端创建一个线程(One-Thread-Per-Client)

这是最直观、最容易理解的模型。

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

工作流程:

  1. 服务器启动,创建一个 ServerSocket,绑定到指定端口。
  2. 在一个无限循环中,调用 serverSocket.accept() 阻塞,等待客户端连接。
  3. 当一个客户端连接成功时,accept() 返回一个代表该客户端连接的 Socket 对象。
  4. 立即创建一个新的 ClientHandler 线程,并将这个 Socket 对象传递给该线程。
  5. 主线程立即回到步骤 2,继续等待下一个客户端。
  6. 新创建的 ClientHandler 线程负责与该客户端进行全生命周期的通信(读写数据),直到客户端断开连接后,线程结束。

优点:

  • 逻辑简单:每个客户端一个线程,代码结构清晰,易于理解和实现。
  • 并发性好:多个客户端可以真正地同时进行 I/O 操作。

缺点:

  • 资源消耗大:每个客户端都会创建一个线程,如果客户端数量巨大(成千上万),JVM 中会存在大量线程,这会消耗大量内存和 CPU 进行线程上下文切换,导致服务器性能急剧下降甚至崩溃。
  • 稳定性差:线程数量不受控制,容易因过多线程而导致系统资源耗尽。

代码示例:

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

服务器端代码 (MultiThreadServer.java)

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MultiThreadServer {
    // 使用线程池来优化“为每个客户端创建一个线程”的模型
    // 可以避免无限制地创建线程
    private static final int THREAD_POOL_SIZE = 10;
    public static void main(String[] args) {
        int port = 8888;
        // 创建一个固定大小的线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器启动,监听端口 " + port + "...");
            while (true) {
                // accept() 是阻塞方法,等待客户端连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("客户端连接成功: " + clientSocket.getInetAddress().getHostAddress());
                // 将处理客户端的任务提交给线程池执行
                threadPool.execute(new ClientHandler(clientSocket));
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
        } finally {
            // 关闭线程池
            threadPool.shutdown();
        }
    }
}
// 客户端处理器,一个线程处理一个客户端
class ClientHandler implements Runnable {
    private final Socket clientSocket;
    public ClientHandler(Socket socket) {
        this.clientSocket = socket;
    }
    @Override
    public void run() {
        // 使用 try-with-resources 确保 I/O 流和 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() + " 的消息: " + inputLine);
                // 简单的回显服务器逻辑
                out.println("服务器回复: " + inputLine);
            }
        } catch (IOException e) {
            // 当客户端断开连接时,readLine() 会返回 null,此时会捕获到 SocketException
            // 这是正常流程,可以打印日志或忽略
            System.out.println("客户端 " + clientSocket.getInetAddress() + " 已断开连接。");
        } finally {
            try {
                clientSocket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

客户端代码 (SimpleClient.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 SimpleClient {
    public static void main(String[] args) {
        String hostname = "localhost";
        int port = 8888;
        try (
            Socket socket = new Socket(hostname, port);
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))
        ) {
            System.out.println("已连接到服务器,输入消息并发送,输入 'exit' 退出。");
            String userInput;
            while (true) {
                System.out.print("请输入消息: ");
                userInput = stdIn.readLine();
                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 发生错误: " + e.getMessage());
            System.exit(1);
        }
    }
}

使用线程池(ThreadPool)

这是对模型一的重大优化,我们不再为每个客户端都创建一个新线程,而是从一个预先创建好的线程池中取出一个空闲线程来处理任务。

工作流程:

  1. 服务器启动,创建一个 ServerSocket 和一个固定大小的 ExecutorService(线程池)。
  2. 主线程进入无限循环,调用 serverSocket.accept() 等待连接。
  3. 当一个客户端连接成功时,创建一个 Runnable 任务(ClientHandler)。
  4. 将这个任务提交给线程池 (threadPool.execute(task))。
  5. 主线程立即返回,继续监听。
  6. 线程池会从其内部的队列中取出一个空闲线程来执行这个任务,处理与该客户端的通信。

优点:

  • 资源可控:线程数量上限由线程池大小决定,避免了因线程过多而导致系统崩溃。
  • 性能提升:线程的创建和销毁是昂贵的操作,线程池复用已创建的线程,减少了这部分开销。
  • 管理方便:可以方便地设置线程池的核心参数(核心大小、最大大小、队列等)。

缺点:

  • 实现比模型一稍微复杂一点,但仍然是业界标准做法。
  • 如果所有线程都在忙,新的客户端连接请求会在线程池的队列中等待,直到有线程空闲。

代码示例:

上面的 MultiThreadServer.java 已经是使用线程池的版本了!这是现代 Java 网络编程推荐的方式,我们再看一个更完整的例子,使用 ExecutorServicesubmit 方法。

// ... (ClientHandler 类与上面完全相同) ...
public class ThreadPoolServer {
    private static final int PORT = 8888;
    private static final int MAX_THREADS = 50; // 最大线程数
    public static void main(String[] args) {
        // 创建一个固定大小的线程池
        ExecutorService executor = Executors.newFixedThreadPool(MAX_THREADS);
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            System.out.println("线程池服务器已启动,监听端口 " + PORT + "...");
            while (true) {
                Socket clientSocket = serverSocket.accept();
                System.out.println("接受到新连接: " + clientSocket.getInetAddress());
                // 提交任务到线程池
                executor.submit(new ClientHandler(clientSocket));
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 优雅关闭服务器
            System.out.println("正在关闭服务器...");
            executor.shutdown(); // 停止接受新任务
            // 可以添加等待当前任务完成的逻辑
            // executor.awaitTermination(...);
        }
    }
}

高级模型:NIO (New I/O) 与非阻塞

当连接数变得非常巨大(C10K 问题)时,即使是线程池模型也可能力不从心,因为每个线程都需要消耗一定的内存和 CPU 资源。

Java NIO (New I/O) 提供了非阻塞 I/O 的解决方案,其核心思想是单线程处理多个连接

核心组件:

  1. Channel (通道):类似传统的 Socket,但它是双向的,可以同时进行读写。
  2. Buffer (缓冲区):数据读写都通过 Buffer 进行,数据先被读入 Buffer,再从 Buffer 写出。
  3. Selector (选择器):这是 NIO 的精髓,一个 Selector 可以轮询多个 Channel 的状态,判断它们是否已经准备好进行 I/O 操作(有数据可读,或可以写入数据)。

工作流程:

  1. 将所有客户端的 SocketChannel 注册到 Selector 上,并指定我们关心的事件(如 SelectionKey.OP_READ)。
  2. 启动一个或几个(通常很少)工作线程。
  3. 在一个循环中,调用 selector.select(),这个方法是阻塞的,但它不是在等待数据,而是在等待至少一个注册的 Channel 变为“就绪”状态。
  4. select() 返回后,获取所有“就绪”的 Channel 的集合。
  5. 遍历这个集合,对每个就绪的 Channel 执行相应的 I/O 操作。
  6. 处理完后,继续下一次 select() 循环。

优点:

  • 极高的并发能力:用少量线程(甚至一个线程)就能处理成千上万的连接,资源消耗极低。
  • 可伸缩性强:非常适合构建高并发的网络服务。

缺点:

  • 编程模型复杂:需要理解 Channel, Buffer, Selector 等概念,代码比传统 BIO 更难编写和调试。
  • 粘包/半包问题:NIO 的 Buffer 模式需要自己处理 TCP 流中的数据边界问题,而 BIO 的 readLine() 或固定长度读取则相对简单。

适用场景:

  • 高性能 Web 服务器(如 Netty, Jetty 的底层就是 NIO)。
  • 聊天服务器、游戏服务器等需要处理大量长连接的场景。

总结与选择

特性 每客户端一线程 线程池 NIO 非阻塞
核心思想 一客户端一线程 线程池复用线程 单/多线程轮询多 Channel
并发能力 低(受限于线程数) 中(受限于线程池大小) 极高(C10K+)
资源消耗
实现复杂度 简单 较简单 复杂
适用场景 学习、演示、少量客户端 通用推荐、中等并发 高并发、高性能服务器

给你的建议:

  • 初学者或学习目的:从“为每个客户端创建一个线程”的模型开始,理解其工作原理。
  • 实际项目开发直接使用线程池模型,它是性能、复杂度和稳定性的最佳平衡点,适用于绝大多数应用场景。
  • 追求极致性能:学习并使用 NIO 模型,或者直接使用基于 NIO 的高性能网络框架,如 NettyMina 或 Vert.x,这些框架已经为你封装了 NIO 的复杂性,提供了更高级的 API。
分享:
扫描分享到社交APP
上一篇
下一篇