杰瑞科技汇

Java socket 接收数据,如何高效处理与避免阻塞?

核心概念

在开始之前,有几个核心概念必须理解:

Java socket 接收数据,如何高效处理与避免阻塞?-图1
(图片来源网络,侵删)
  1. Socket (套接字):网络通信的端点,你可以把它想象成一个“电话听筒”,通过它,你的程序可以和远程程序建立连接并发送/接收数据。
  2. ServerSocket (服务器套接字):服务器端用来“监听”特定端口,等待客户端连接请求的类。
  3. :数据在网络中是以字节流的形式传输的。
    • InputStream:用于读取(接收)数据。
    • OutputStream:用于写入(发送)数据。
  4. 阻塞:一个线程在执行某个操作时,如果该操作不能立即完成,线程就会被“挂起”,直到操作完成或超时。accept() 方法会阻塞,直到有客户端连接;read() 方法会阻塞,直到有数据可读。

传统阻塞式 Socket (BIO - Blocking I/O)

这是最经典、最容易理解的方式,服务器为每个客户端连接都创建一个新的线程来处理。

服务器端代码

服务器的主要任务是:

  1. 在指定端口上创建一个 ServerSocket 并开始监听。
  2. 循环调用 accept() 方法,等待客户端连接。accept() 是一个阻塞方法,会一直等待,直到有客户端连接上来。
  3. 当一个客户端连接成功,accept() 返回一个代表该客户端连接的 Socket 对象。
  4. 为这个新的 Socket 创建一个独立的线程,在该线程中处理数据的接收和发送,这样就不会影响主线程去接受其他客户端的连接。
// Server.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
    public static void main(String[] args) {
        int port = 8080;
        // try-with-resources 确保 ServerSocket 在使用后被自动关闭
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器启动,正在监听端口 " + port + "...");
            // accept() 是一个阻塞方法,会一直等待客户端连接
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
            // 获取输入流,用于接收客户端发送的数据
            InputStream inputStream = clientSocket.getInputStream();
            // 使用 BufferedReader 可以方便地按行读取文本数据
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            String line;
            // readLine() 也是一个阻塞方法,会一直等待客户端发送一行数据
            while ((line = reader.readLine()) != null) {
                System.out.println("收到客户端消息: " + line);
                // 如果客户端发送了 "exit",则关闭连接
                if ("exit".equalsIgnoreCase(line)) {
                    System.out.println("客户端请求断开连接。");
                    break;
                }
            }
            // 关闭客户端连接和资源
            System.out.println("与客户端的连接已关闭。");
            clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

客户端代码

客户端的主要任务是:

  1. 创建一个 Socket 对象,指定服务器的 IP 地址和端口号,如果服务器拒绝连接或无法连接,这里会抛出异常。
  2. 连接成功后,获取输入流,用于接收服务器发送的数据。
// Client.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.Socket;
import java.net.UnknownHostException;
public class Client {
    public static void main(String[] args) {
        String serverHost = "127.0.0.1"; // 本地回环地址,即本机
        int port = 8080;
        try (Socket socket = new Socket(serverHost, port)) {
            System.out.println("成功连接到服务器 " + serverHost + ":" + port);
            // 获取输入流,用于接收服务器发送的数据
            InputStream inputStream = socket.getInputStream();
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            // 假设服务器会先发一条欢迎消息
            String welcomeMessage = reader.readLine();
            System.out.println("服务器欢迎消息: " + welcomeMessage);
            // 在实际应用中,客户端也会持续监听服务器发来的消息
            // ...
        } catch (UnknownHostException e) {
            System.err.println("找不到主机: " + serverHost);
        } catch (IOException e) {
            System.err.println("I/O 发生错误: " + e.getMessage());
        }
    }
}

阻塞式 Socket 的优缺点

  • 优点:代码简单直观,易于理解和实现。
  • 缺点:性能差,在高并发场景下,每个连接都需要一个线程,线程的创建和销毁会消耗大量资源,并且线程数量受限于操作系统,无法支持成千上万的并发连接,这被称为“C10K 问题”。

非阻塞式 Socket (NIO - New I/O)

为了解决 BIO 的高并发问题,Java 1.4 引入了 NIO,NIO 的核心是 非阻塞选择器

Java socket 接收数据,如何高效处理与避免阻塞?-图2
(图片来源网络,侵删)

核心概念

  1. Channel (通道):类似传统的流,但双向的,既可以读也可以写。
  2. Buffer (缓冲区):所有数据都通过 Buffer 进行读写,数据先被读入 Buffer,再从 Buffer 中取出。
  3. Selector (选择器):NIO 的核心,一个 Selector 可以同时监听多个 Channel 的事件(如连接就绪、数据可读等),当一个或多个 Channel 有事件发生时,Selector 会将这些 Channel 返回给应用程序进行处理,这样就用一个线程就可以管理多个连接,极大地提高了效率。

服务器端 NIO 代码示例

这个例子展示了如何使用 SelectorServerSocketChannel 来创建一个非阻塞的服务器。

// NioServer.java
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
public class NioServer {
    public static void main(String[] args) throws IOException {
        // 1. 创建 Selector 和 ServerSocketChannel
        Selector selector = Selector.open();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8081));
        // 设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 2. 将 ServerSocketChannel 注册到 Selector,监听 "连接" 事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("NIO 服务器启动,监听端口 8081...");
        // 3. 循环等待新的事件
        while (true) {
            // select() 是阻塞方法,直到至少有一个通道在你注册的事件上就绪
            selector.select();
            // 获取所有已就绪的 SelectionKey
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                // 4. 根据不同的事件类型进行处理
                if (key.isAcceptable()) {
                    // a. 连接就绪事件:接受新的连接
                    handleAccept(serverSocketChannel, selector);
                } else if (key.isReadable()) {
                    // b. 数据可读事件:读取客户端数据
                    handleRead(key);
                }
                // 处理完的 SelectionKey 需要手动从集合中移除
                keyIterator.remove();
            }
        }
    }
    private static void handleAccept(ServerSocketChannel serverSocketChannel, Selector selector) throws IOException {
        SocketChannel clientChannel = serverSocketChannel.accept();
        if (clientChannel != null) {
            System.out.println("新客户端连接: " + clientChannel.getRemoteAddress());
            clientChannel.configureBlocking(false);
            // 将新的 SocketChannel 注册到 Selector,监听 "可读" 事件
            clientChannel.register(selector, SelectionKey.OP_READ);
        }
    }
    private static void handleRead(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        int bytesRead = clientChannel.read(buffer);
        if (bytesRead == -1) {
            // 客户端关闭了连接
            System.out.println("客户端 " + clientChannel.getRemoteAddress() + " 已断开连接。");
            clientChannel.close();
            key.cancel();
            return;
        }
        // 将 Buffer 从写模式切换到读模式
        buffer.flip();
        byte[] data = new byte[buffer.remaining()];
        buffer.get(data);
        String message = new String(data);
        System.out.println("收到客户端 " + clientChannel.getRemoteAddress() + " 的消息: " + message);
        // 简单回显
        // clientChannel.write(ByteBuffer.wrap(("Echo: " + message).getBytes()));
    }
}

NIO 的优缺点

  • 优点
    • 高并发:单个线程可以处理成千上万的连接,非常适合高并发场景。
    • 资源占用少:不需要为每个连接创建一个线程,大大减少了线程切换和上下文切换的开销。
  • 缺点
    • 代码复杂:相比 BIO,NIO 的代码量更大,逻辑更复杂,需要理解 SelectorChannelBuffer 之间的配合。
    • 开发门槛高:需要开发者具备更深的网络和 I/O 知识。

总结与对比

特性 阻塞式 非阻塞式
I/O 模型 阻塞 I/O (BIO) 非阻塞 I/O (NIO)
线程模型 一个连接一个线程 一个线程(或少量线程)管理多个连接
并发能力 低,受限于线程数 非常高,可轻松处理万级并发
代码复杂度 简单,易于理解 复杂,需要学习 SelectorChannel
适用场景 连接数少、简单的应用,如学习、小型工具 高并发、高性能的服务器应用,如聊天室、Web 服务器

如何选择?

  • 初学者或简单应用:从 阻塞式 Socket 开始,它能帮助你理解网络通信的基本原理,代码简单,易于调试。
  • 生产环境、高并发应用:必须使用 NIO,现在主流的框架(如 Netty、Vert.x)都是基于 NIO 或 AIO (异步I/O) 构建的,它们极大地简化了 NIO 的复杂度,是构建高性能网络服务的首选,对于大多数 Java 直接使用 Netty 这样的框架是更实际的选择,而不是从零开始写 NIO 代码。
Java socket 接收数据,如何高效处理与避免阻塞?-图3
(图片来源网络,侵删)
分享:
扫描分享到社交APP
上一篇
下一篇