什么是 Socket 字节流?
在 Java 网络编程中,Socket(套接字)是网络通信的端点,通过 Socket,两个不同主机上的应用程序可以进行双向通信。
“字节流”指的是以 字节(byte) 为基本单位进行数据传输的方式,这与“字符流”(以 char 为单位)相对,字节流是网络通信中最底层、最通用的方式,因为它可以传输任何类型的数据,包括文本、图片、音频、视频等二进制文件。
Java 提供了两个核心类来处理基于 TCP 协议的字节流 Socket 通信:
java.net.Socket: 客户端 Socket。java.net.ServerSocket: 服务器端 Socket。
它们分别使用 InputStream 和 OutputStream 来进行数据的读取和写入。
核心类和方法
客户端 (Socket)
-
创建 Socket:
Socket(String host, int port): 创建一个流套接字并将其连接到指定主机上的指定端口号。Socket(InetAddress address, int port): 使用指定的 IP 地址和端口号创建套接字。
-
获取输入流:
InputStream getInputStream(): 返回此套接字的输入流,客户端通过这个流从服务器读取数据。
-
获取输出流:
OutputStream getOutputStream(): 返回此套接字的输出流,客户端通过这个流向服务器写入数据。
-
关闭 Socket:
void close(): 关闭此套接字,关闭Socket会自动关闭其关联的输入流和输出流。
服务器端 (ServerSocket)
-
创建 ServerSocket:
ServerSocket(int port): 创建绑定到指定端口的服务器套接字。
-
监听并接受连接:
Socket accept(): 侦听并接受到此套接字的连接,这是一个阻塞方法,如果没有客户端连接,程序会一直等待在这里,直到有客户端连接成功,然后返回一个代表该客户端连接的Socket对象。
一个简单的 Echo Server 示例
下面是一个经典的“回显服务器”示例,客户端发送什么,服务器就原样返回什么,这个例子能清晰地展示字节流的双向通信过程。
服务器端代码 (EchoServer.java)
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
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("服务器启动,等待客户端连接...");
// accept() 方法会阻塞,直到有客户端连接
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 1. 获取客户端的输入流,用于读取客户端发送的数据
InputStream inputStream = clientSocket.getInputStream();
// 2. 获取客户端的输出流,用于向客户端发送数据
OutputStream outputStream = clientSocket.getOutputStream();
byte[] buffer = new byte[1024];
int bytesRead;
// 3. 循环读取客户端发送的数据
while ((bytesRead = inputStream.read(buffer)) != -1) {
// 4. 将读取到的数据写回到客户端的输出流(回显)
outputStream.write(buffer, 0, bytesRead);
System.out.println("回显了 " + bytesRead + " 个字节");
}
System.out.println("客户端断开连接。");
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
}
}
客户端代码 (EchoClient.java)
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;
public class EchoClient {
public static void main(String[] args) {
// 服务器的 IP 地址和端口号
String host = "localhost"; // 或 "127.0.0.1"
int port = 12345;
try (Socket socket = new Socket(host, port);
// 1. 获取服务器的输入流,用于读取服务器返回的数据
InputStream inputStream = socket.getInputStream();
// 2. 获取服务器的输出流,用于向服务器发送数据
OutputStream outputStream = socket.getOutputStream();
// 使用 Scanner 从控制台读取用户输入
Scanner scanner = new Scanner(System.in)) {
System.out.println("已连接到服务器,请输入要发送的消息 (输入 'exit' 退出):");
while (true) {
String message = scanner.nextLine();
if ("exit".equalsIgnoreCase(message)) {
break;
}
// 3. 将用户输入的消息转换为字节数组并发送到服务器
byte[] sendBuffer = message.getBytes();
outputStream.write(sendBuffer);
// 4. 创建一个接收缓冲区,用于读取服务器返回的数据
byte[] receiveBuffer = new byte[1024];
int bytesRead = inputStream.read(receiveBuffer);
// 5. 将接收到的字节数组转换为字符串并打印
if (bytesRead != -1) {
String echoMessage = new String(receiveBuffer, 0, bytesRead);
System.out.println("服务器回显: " + echoMessage);
}
}
} catch (UnknownHostException e) {
System.err.println("找不到服务器: " + e.getMessage());
} catch (IOException e) {
System.err.println("I/O错误: " + e.getMessage());
}
System.out.println("客户端关闭。");
}
}
如何运行
-
先运行服务器:
java EchoServer
你会看到控制台输出:
服务器启动,等待客户端连接... -
再运行客户端:
java EchoClient
客户端会连接到服务器,并提示你输入消息。
-
测试通信: 在客户端控制台输入任意文本,
Hello, Server!,然后按回车。- 客户端会收到服务器的回显:
服务器回显: Hello, Server! - 服务器控制台会打印:
回显了 14 个字节 - 输入
exit可以退出客户端。
- 客户端会收到服务器的回显:
关键点与注意事项
1 阻塞 I/O (Blocking I/O)
在上面的例子中,serverSocket.accept() 和 inputStream.read() 都是阻塞方法。
accept(): 服务器线程会一直卡在这里,直到有新的客户端连接进来。read(): 无论是服务器还是客户端,当调用read()时,线程都会阻塞,直到有数据可读,或者流被关闭(返回-1)。
这意味着,一个简单的服务器一次只能为一个客户端服务,当它在为客户端 A 服务时,如果客户端 B 尝试连接,它必须等待服务器处理完客户端 A 的连接后才能被接受。
2 缓冲区的重要性
我们使用 byte[] buffer = new byte[1024]; 这样的字节数组作为缓冲区。
- 为什么需要缓冲区? 网络传输是分批次进行的。
read()方法不一定一次就能读取到所有你发送的数据,它可能只读取了其中一部分,你需要在一个循环中反复调用read(),直到读取完毕。 - 如何知道读取了多少数据?
read()方法返回的int值就是实际读取到的字节数,在写入时,你必须使用这个值 (bytesRead) 作为长度,而不是整个缓冲区的长度,否则可能会把上次读取留下的旧数据也发送出去。
3 字符编码问题
当传输的是文本时,String.getBytes() 和 new String(byte[]) 涉及到字符编码的转换。
- 默认编码: 如果不指定编码,它会使用 JVM 的默认字符集(通常是
UTF-8,但最好明确指定)。 - 最佳实践: 为了避免乱码,强烈建议在转换时明确指定编码格式。
改进后的客户端发送代码:
// 明确指定使用 UTF-8 编码
byte[] sendBuffer = message.getBytes("UTF-8");
outputStream.write(sendBuffer);
改进后的服务器接收代码:
// 明确指定使用 UTF-8 解码 String echoMessage = new String(receiveBuffer, 0, bytesRead, "UTF-8");
4 资源管理 (try-with-resources)
在示例中,我们使用了 try-with-resources 语句(try (Socket socket = ...))。
- 这个语句可以自动实现
AutoCloseable接口的对象的关闭操作。 - 当
try块执行完毕(无论正常结束还是发生异常),socket、inputStream、outputStream等资源都会被自动关闭,这比传统的try-finally更简洁、更安全。
字节流 vs. 字符流
| 特性 | 字节流 (InputStream / OutputStream) |
字符流 (Reader / Writer) |
|---|---|---|
| 基本单位 | 字节 | 字符 (char, 通常是 2 字节) |
| 适用场景 | 传输二进制数据(图片、视频、PDF等)或文本(需处理编码) | 专门为文本设计,处理字符编码更方便 |
| 性能 | 更底层,性能通常更高 | 需要进行字节到字符的转换,开销稍大 |
| 网络编程 | 最常用,是网络通信的基础 | 也可以使用,但通常需要包装在字节流外,如 InputStreamReader |
| Java 类 | Socket.getInputStream(), Socket.getOutputStream() |
Socket.getInputStream() -> InputStreamReader -> BufferedReader |
对于通用的 Socket 编程,尤其是在传输非文本数据或需要精确控制数据时,字节流是首选,如果确定只传输文本,并且希望简化编码转换,可以考虑使用 InputStreamReader 和 OutputStreamWriter 将字节流包装成字符流。
进阶主题
当需要处理多个客户端时,简单的阻塞模型就不够用了,这时需要更高级的 I/O 模型:
- 多线程: 为每个客户端连接创建一个新的线程,这是最直接的方法,但会消耗大量系统资源,当连接数非常多时(如数万),会导致性能下降。
- I/O 多路复用: 使用
java.nio包中的Selector,Channel,Buffer,一个线程可以管理多个连接,极大地提高了服务器的并发处理能力,这是现代高性能网络框架(如 Netty)的基础。
