杰瑞科技汇

java socket 传输数据

  1. 核心概念:Socket、ServerSocket、IP 地址、端口号。
  2. 通信流程:客户端和服务器端的基本交互步骤。
  3. 代码示例:一个完整的、可运行的客户端/服务器端示例,包括字符串和文件传输。
  4. 关键问题与最佳实践:阻塞与非阻塞、流(Stream)的关闭、异常处理、数据粘包问题及解决方案。
  5. 进阶:使用 NIO (New I/O) 提高性能。

核心概念

Socket (套接字)

Socket 是网络通信的端点,你可以把它想象成一个电话,应用程序通过这个“电话”来发送和接收数据,在 Java 中,java.net.Socket 类代表客户端,java.net.ServerSocket 类代表服务器端。

java socket 传输数据-图1
(图片来源网络,侵删)

ServerSocket (服务器套接字)

服务器使用 ServerSocket 来“监听”某个特定的端口,等待客户端的连接请求,当一个客户端连接时,ServerSocket 会创建一个新的 Socket 对象与该客户端进行一对一的通信。

IP 地址

网络中设备的唯一标识,168.1.100www.google.com,Java 中用 java.net.InetAddress 类来表示。

端口号

一个设备上可以同时运行多个网络服务(如 Web 服务器、邮件服务器等),端口号用于区分这些不同的服务,范围是 0-65535,0-1023 是知名端口,一般应用程序应避免使用,客户端连接时需要指定服务器的 IP 地址和端口号。


通信流程

服务器端

  1. 创建 ServerSocket:绑定一个端口号,开始监听客户端连接。
  2. 接受连接:调用 accept() 方法,此方法会阻塞(程序暂停执行),直到有客户端连接。
  3. 获取通信流:一旦有客户端连接,accept() 方法会返回一个新的 Socket 对象,通过这个 Socket 对象,我们可以获取输入流(InputStream,用于读取客户端发来的数据)和输出流(OutputStream,用于向客户端发送数据)。
  4. 读写数据:使用输入流读取数据,使用输出流发送数据。
  5. 关闭资源:通信结束后,关闭对应的流和 Socket 连接。

客户端

  1. 创建 Socket:指定服务器的 IP 地址和端口号,向服务器发起连接请求。
  2. 获取通信流:连接成功后,通过 Socket 对象获取输入流和输出流。
  3. 读写数据:使用输出流向服务器发送数据,使用输入流读取服务器返回的数据。
  4. 关闭资源:通信结束后,关闭流和 Socket 连接。

代码示例

下面我们创建一个简单的 "Echo" 服务器,客户端发送什么消息,服务器就原样返回什么。

java socket 传输数据-图2
(图片来源网络,侵删)

服务器端代码 (EchoServer.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;
public class EchoServer {
    public static void main(String[] args) {
        int port = 12345; // 定义服务器监听的端口号
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器已启动,正在监听端口 " + port + "...");
            // accept() 方法会阻塞,直到有客户端连接
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已连接。");
            // 使用 try-with-resources 自动关闭流
            try (
                // 获取输入流,用于读取客户端发送的数据
                BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                // 获取输出流,用于向客户端发送数据
                PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
            ) {
                String inputLine;
                // 循环读取客户端发送的数据
                while ((inputLine = in.readLine()) != null) {
                    System.out.println("收到客户端消息: " + inputLine);
                    // 将收到的消息回显给客户端
                    out.println("服务器回复: " + inputLine);
                    // 如果客户端发送 "exit",则退出循环
                    if ("exit".equalsIgnoreCase(inputLine)) {
                        break;
                    }
                }
            } catch (IOException e) {
                System.err.println("与客户端通信时发生错误: " + e.getMessage());
            } finally {
                System.out.println("客户端 " + clientSocket.getInetAddress().getHostAddress() + " 已断开连接。");
                clientSocket.close(); // 关闭与客户端的连接
            }
        } catch (IOException e) {
            System.err.println("服务器启动或运行时发生错误: " + e.getMessage());
        }
    }
}

客户端代码 (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 {
    public static void main(String[] args) {
        String hostName = "localhost"; // 服务器地址,localhost 表示本机
        int port = 12345;             // 服务器端口号
        try (
            // 创建 Socket 并连接到服务器
            Socket socket = new Socket(hostName, port);
            // 获取输入流,用于读取服务器返回的数据
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            // 获取输出流,用于向服务器发送数据
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
            // 用于从控制台读取用户输入
            stdIn = new BufferedReader(new InputStreamReader(System.in));
        ) {
            System.out.println("已连接到服务器 " + hostName + ":" + port);
            System.out.println("请输入消息 (输入 'exit' 退出):");
            String userInput;
            // 循环读取用户输入
            while ((userInput = stdIn.readLine()) != null) {
                // 将用户输入发送给服务器
                out.println(userInput);
                // 读取并打印服务器的回复
                String response = in.readLine();
                System.out.println("服务器回复: " + response);
                if ("exit".equalsIgnoreCase(userInput)) {
                    break;
                }
            }
        } catch (UnknownHostException e) {
            System.err.println("找不到主机: " + hostName);
            System.exit(1);
        } catch (IOException e) {
            System.err.println("无法连接到主机 " + hostName + " 或在通信时发生错误。");
            System.exit(1);
        }
    }
}

如何运行:

  1. 先运行 EchoServer 类。
  2. 然后运行 EchoClient 类(可以运行多次,模拟多个客户端)。
  3. 在客户端的控制台输入消息,按回车,就能看到服务器的回复。

关键问题与最佳实践

阻塞与非阻塞

  • 阻塞:默认情况下,socket.accept()inputStream.read() 等方法都是阻塞的,线程会一直等待,直到有数据到达或连接建立,这在高并发场景下效率很低,因为一个线程只能处理一个客户端。
  • 非阻塞:可以使用 Java NIO (New I/O) 来实现非阻塞 I/O,一个线程可以管理多个连接,大大提高性能,我们会在后面简单介绍。

流的关闭与资源管理

  • try-with-resources:这是 Java 7+ 推荐的最佳实践,只要实现了 AutoCloseable 接口(如 Socket, ServerSocket, InputStream, OutputStream 等),就可以在 try 语句中声明,它们会在 try 块执行完毕后自动关闭,即使发生异常也能正确关闭,避免了资源泄漏。
  • 关闭顺序:通常先关闭外层流(如 BufferedReader),再关闭底层的 Socket,但使用 try-with-resources 会自动处理好这个问题。

异常处理

网络编程充满了不确定性,因此必须妥善处理 IOException 和其他可能的异常(如 UnknownHostException)。

数据粘包问题

这是基于 TCP 的 Socket 编程中一个经典问题。

  • 原因:TCP 是一个“流”协议,它只保证数据按顺序、无差错地到达,但不保证每次 read() 读取的数据就是一个完整的“包”,发送方多次 write() 的数据可能会被 TCP 协议栈合并成一个大的数据包发送,接收方一次 read() 可能会读取到多个包的数据,或者只读取到一个包的一部分。
  • 场景:在上述 EchoServer 中,我们使用 readLine() 读取一行,因为它以换行符 \n 作为结束标志,所以可以避免粘包问题,但如果传输的是二进制数据(如文件、图片),问题就会暴露。
  • 解决方案
    1. 固定长度:发送方将每个数据包都填充到固定的长度,接收方每次读取固定长度的数据。
    2. 特殊分隔符:像 readLine() 一样,在数据包之间使用特殊字符(如 \n, \r\n 或自定义分隔符)进行分割,简单,但分隔符不能出现在数据内容中。
    3. 长度前缀最常用和最健壮的方法,在每个数据包的头部加上一个固定长度的字段,用于描述这个数据包的长度,接收方先读取这个长度字段,然后根据长度值去读取指定长度的数据。
      • 实现示例
        • 发送方:out.write(lengthBytes); out.write(dataBytes); out.flush();
        • 接收方:byte[] lenBuf = new byte[4]; in.read(lenBuf); int length = ByteBuffer.wrap(lenBuf).getInt(); byte[] dataBuf = new byte[length]; in.read(dataBuf);

进阶:使用 NIO (New I/O)

传统的 BIO (Blocking I/O) 模型是“一个连接一个线程”,当有成千上万的连接时,会创建大量线程,导致资源耗尽和性能下降。

java socket 传输数据-图3
(图片来源网络,侵删)

Java NIO 提供了非阻塞 I/O 和选择器(Selector)机制,可以用一个或少量线程来管理成千上万的连接。

NIO 核心组件

  1. Channel (通道):类似流,但双向的,可以读也可以写。SocketChannelServerSocketChannel 是 NIO 中对应的 Socket 通道。
  2. Buffer (缓冲区):数据被读入 Buffer 或从 Buffer 写出,所有读写操作都是通过 Buffer 完成的。
  3. Selector (选择器)Selector 是一个可以检查一个或多个通道状态的对象的组件,一个线程可以管理多个 Channel,通过 Selector 查询哪些通道已经准备好进行 I/O 操作(如可读、可写)。

NIO 工作流程(服务器端)

  1. 创建一个 ServerSocketChannel 并设置为非阻塞模式。
  2. ServerSocketChannel 注册到 Selector 上,并监听 OP_ACCEPT(接受连接)事件。
  3. 循环调用 selector.select(),它会阻塞直到至少有一个通道准备好进行 I/O。
  4. 获取 SelectorKeys(已准备好的通道集合),遍历每个 Key
  5. 如果是 OP_ACCEPT 事件,则接受连接,并将新的 SocketChannel 注册到 Selector 上,监听 OP_READ 事件。
  6. 如果是 OP_READ 事件,则从 SocketChannel 读取数据到 Buffer 中,处理数据,然后可以将 SocketChannel 注册到 Selector 上监听 OP_WRITE 事件(如果需要写数据)。
  7. 处理完 Key 后,将其从 selectedKeys 集合中移除。

NIO 的实现比 BIO 复杂得多,但性能优势巨大,是实现高性能网络服务(如 Netty、Jetty 等框架)的基础。

特性 传统 BIO (Blocking I/O) NIO (New I/O)
模型 一个连接一个线程 一个或少量线程管理多个连接
核心 Socket, ServerSocket, InputStream/OutputStream Channel, Buffer, Selector
I/O 模式 阻塞 非阻塞
适用场景 连接数不多、简单的应用 高并发、高性能的服务器应用
复杂度 简单 复杂

对于初学者,掌握传统的 Socket 编程是基础,当你需要构建高性能、高并发的网络应用时,就应该学习和使用 NIO 或者基于 NIO 的高层框架(如 Netty)。

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