Java Socket 编程完全指南
目录
-
(图片来源网络,侵删)- 什么是 Socket?
- 网络通信模型:C/S (客户端/服务器)
- 两种主要的 Socket 类型
- Socket 通信流程
-
java.net.ServerSocketjava.net.Socketjava.net.InetAddress- 输入/输出流:
InputStream和OutputStream
-
- Echo 服务器代码详解
- Echo 客户端代码详解
- 如何运行与测试
- 代码流程总结
-
- 为什么需要多线程?
- 实现方式一:为每个客户端创建一个新线程
- 实现方式二:使用线程池 (更优)
-
- 使用
BufferedReader和PrintWriter进行文本通信 - 处理
IOException和异常关闭 - 半关闭 Socket (
shutdownOutput) - 超时设置 (
setSoTimeout) - NIO (New I/O) 简介
- 使用
第一部分:基础概念
什么是 Socket?
你可以把 Socket(套接字)想象成是两个程序之间进行网络通信的“电话插座”,一个程序将“听筒”(Socket)插入这个插座,另一个程序也将“听筒”插入同一个插座(通过 IP 地址和端口号定位),这样它们之间就可以通过“电话线”(网络)进行通话了。
在 Java 中,Socket 是网络通信的端点,它封装了复杂的底层网络协议(如 TCP/IP),让开发者可以方便地进行网络编程。
网络通信模型:C/S (客户端/服务器)
绝大多数网络应用都采用客户端/服务器模型:
- 服务器:一个在已知地址(IP 地址和端口号)上运行并等待连接的程序,它被动地接收来自客户端的请求。
- 客户端:一个主动发起连接请求到服务器的程序。
一个典型的交互流程是:
- 服务器启动,在某个端口上监听。
- 客户端知道服务器的 IP 和端口,发起连接请求。
- 服务器接受连接请求。
- 双方建立连接,开始通过 Socket 进行数据传输。
- 通信结束后,关闭连接。
两种主要的 Socket 类型
Java 主要支持两种类型的 Socket,对应两种不同的传输层协议:
-
TCP Socket (流式 Socket)
- 协议:TCP (Transmission Control Protocol)
- 特点:
- 面向连接:通信前必须先建立连接(三次握手)。
- 可靠传输:通过确认、重传、排序等机制确保数据无差错、不丢失、不重复地到达。
- 面向字节流:数据被看作是一个无结构的字节流。
- 全双工通信:双方可以同时发送和接收数据。
- 类比:打电话,你必须先拨号并对方接听后才能开始通话,通话内容清晰可靠。
- Java 类:
java.net.Socket(客户端),java.net.ServerSocket(服务器端)。
-
UDP Socket (数据报 Socket)
- 协议:UDP (User Datagram Protocol)
- 特点:
- 无连接:发送数据前不需要建立连接,直接将数据打包(数据报)发送出去。
- 不可靠传输:不保证数据是否到达,也不保证顺序,可能会丢失、重复或乱序。
- 面向数据报:每次发送的数据都是一个独立的数据报。
- 传输速度快,开销小。
- 类比:寄明信片,你写好地址扔进邮筒,但无法保证对方一定能按顺序收到。
- Java 类:
java.net.DatagramSocket,java.net.DatagramPacket。
本教程将重点介绍最常用、最可靠的 TCP Socket。
Socket 通信流程
一个典型的 TCP Socket 通信流程如下:
服务器端:
- 创建
ServerSocket对象,并绑定到一个具体的端口上,开始监听客户端连接。 - 调用
accept()方法,这是一个阻塞方法,它会一直等待,直到有客户端连接上来。 - 当
accept()返回时,它会返回一个新的Socket对象,这个Socket代表了与那个特定客户端的连接通道。 - 通过这个新的
Socket获取输入流和输出流,与客户端进行数据交互。 - 通信结束后,关闭
Socket。
客户端:
- 创建
Socket对象,指明需要连接的服务器的 IP 地址和端口号。 - 如果连接成功,客户端会获取到服务器的
Socket对象。 - 通过这个
Socket获取输入流和输出流,与服务器进行数据交互。 - 通信结束后,关闭
Socket。
第二部分:核心 API 简介
java.net.ServerSocket
服务器端使用的类,用于监听客户端的连接请求。
ServerSocket(int port): 创建一个服务器套接字,并绑定到指定的端口。Socket accept(): 监听并接受到此套接字的连接,此方法在连接建立之前会一直阻塞。void close(): 关闭此套接字。
java.net.Socket
客户端使用的类,也可以被 ServerSocket.accept() 返回,代表一个连接。
Socket(String host, int port): 创建一个流套接字并将其连接到指定主机上的指定端口号。InputStream getInputStream(): 返回此套接字的输入流。OutputStream getOutputStream(): 返回此套接字的输出流。void close(): 关闭此套接字。
java.net.InetAddress
InetAddress 类是 Java 对 IP 地址的抽象,它既可以是 IPv4 地址,也可以是 IPv6 地址。
static InetAddress getByName(String host): 通过主机名或 IP 地址字符串获取InetAddress实例。- 示例:
InetAddress.getByName("www.google.com")或InetAddress.getByName("127.0.0.1")。
- 示例:
输入/输出流:InputStream 和 OutputStream
Socket 获取的流是原始的字节流 (InputStream/OutputStream),直接读写二进制数据比较麻烦,通常我们会将其包装成更高级的流,如 DataInputStream/DataOutputStream 或 BufferedReader/PrintWriter。
第三部分:动手实践 - 简单的 Echo 服务器与客户端
“Echo”服务器是一个经典的教学示例,它的功能很简单:客户端发送什么消息,服务器就原样返回什么消息。
Echo 服务器代码
// EchoServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
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();
// 获取客户端的地址信息
InetAddress clientAddress = clientSocket.getInetAddress();
System.out.println("客户端已连接: " + clientAddress.getHostAddress());
// 获取输入流,用于读取客户端发送的数据
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);
// 如果客户端发送 "bye",则结束循环
if ("bye".equalsIgnoreCase(inputLine)) {
break;
}
}
System.out.println("客户端断开连接。");
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
}
}
Echo 客户端代码
// 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 = "127.0.0.1"; // 服务器地址,localhost
int port = 12345; // 服务器端口
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("请输入消息,输入 'bye' 退出:");
String userInput;
// 循环读取用户输入
while ((userInput = stdIn.readLine()) != null) {
// 将用户输入发送给服务器
out.println(userInput);
// 读取并打印服务器的回显
String response = in.readLine();
System.out.println("服务器响应: " + response);
if ("bye".equalsIgnoreCase(userInput)) {
break;
}
}
} catch (UnknownHostException e) {
System.err.println "未知的主机: " + hostname);
e.printStackTrace();
} catch (IOException e) {
System.err.println("I/O 发生错误: " + e.getMessage());
e.printStackTrace();
}
}
}
如何运行与测试
-
编译代码:打开终端,进入两个
.java文件所在的目录,运行:javac EchoServer.java EchoClient.java
-
启动服务器:在一个终端窗口中运行服务器:
java EchoServer
你会看到输出:
服务器已启动,等待客户端连接... -
启动客户端:在另一个终端窗口中运行客户端:
java EchoClient
你会看到输出:
已连接到服务器。 -
交互:在客户端的终端中输入任何文本,然后按回车,你会在客户端的终端中看到服务器的回显消息。
请输入消息,输入 'bye' 退出: 你好,服务器! 服务器响应: 服务器回显: 你好,服务器! Java Socket 很有趣 服务器响应: 服务器回显: Java Socket 很有趣 bye 服务器响应: 服务器回显: bye -
关闭:当客户端输入
bye后,程序会退出,观察服务器的终端,它会显示客户端断开连接。。
-
服务器:
- 创建
ServerSocket并绑定端口。 accept()阻塞等待连接。- 连接建立后,通过
Socket获取InputStream和OutputStream。 - 使用
BufferedReader逐行读取客户端消息。 - 使用
PrintWriter将消息回写给客户端。 - 循环直到收到结束信号或客户端断开。
- 创建
-
客户端:
- 创建
Socket并指定服务器的 IP 和端口。 - 连接成功后,通过
Socket获取InputStream和OutputStream。 - 使用
BufferedReader读取用户控制台输入。 - 使用
PrintWriter将输入发送给服务器。 - 使用
BufferedReader读取服务器返回的响应。 - 循环直到用户输入结束信号。
- 创建
第四部分:处理多客户端连接
上面的示例只能处理一个客户端连接,当服务器在 accept() 处等待时,它无法响应其他客户端的连接请求,要处理多个客户端,最常用的方法是多线程。
实现方式一:为每个客户端创建一个新线程
思想:主线程(服务器线程)只负责监听和接受连接,每当有一个新的客户端连接,就创建一个新的线程来处理这个客户端的所有通信,主线程则立即返回 accept() 状态,等待下一个客户端。
修改后的服务器代码 (MultiThreadEchoServer.java)
// MultiThreadEchoServer.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 MultiThreadEchoServer {
public static void main(String[] args) {
int port = 12345;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("多线程服务器已启动,等待客户端连接...");
while (true) { // 无限循环,持续接受连接
Socket clientSocket = serverSocket.accept();
System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
// 为每个客户端创建一个新的线程来处理
ClientHandler clientHandler = new ClientHandler(clientSocket);
new Thread(clientHandler).start();
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
}
}
}
// 定义一个专门的类来处理客户端的逻辑
class ClientHandler implements Runnable {
private Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
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("[" + Thread.currentThread().getName() + "] 收到客户端消息: " + inputLine);
out.println("服务器回显: " + inputLine);
if ("bye".equalsIgnoreCase(inputLine)) {
break;
}
}
} catch (IOException e) {
// 客户端异常断开时,会打印此信息
System.out.println("客户端处理异常或断开连接: " + e.getMessage());
} finally {
try {
if (clientSocket != null && !clientSocket.isClosed()) {
clientSocket.close();
System.out.println("客户端连接已关闭。");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
这种方式的问题:如果客户端数量巨大,会创建大量线程,每个线程都会占用一定的内存和 CPU 资源,可能会导致服务器资源耗尽。
实现方式二:使用线程池 (更优)
思想:创建一个固定大小的线程池,当有新客户端连接时,从线程池中取出一个空闲线程来处理它,如果所有线程都在忙,新连接的请求需要等待,直到有线程空闲,这可以避免无限制地创建线程,提高了系统的稳定性和资源利用率。
使用线程池的服务器代码 (ThreadPoolEchoServer.java)
// ThreadPoolEchoServer.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 ThreadPoolEchoServer {
// 使用固定大小的线程池,10 个线程
private static final ExecutorService threadPool = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
int port = 12345;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("线程池服务器已启动,等待客户端连接...");
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());
} finally {
// 当服务器关闭时,关闭线程池
System.out.println("服务器关闭,正在关闭线程池...");
threadPool.shutdown();
}
}
}
// ClientHandler 类与上面的 MultiThreadEchoServer 中的完全相同
class ClientHandler implements Runnable {
// ... (代码同上) ...
}
第五部分:高级主题与最佳实践
使用 BufferedReader 和 PrintWriter 进行文本通信
在上面的例子中,我们已经使用了这两个类,它们是进行文本行通信的最佳选择。
PrintWriter(socket.getOutputStream(), true):第二个参数true表示自动刷新,这样每当调用println()方法后,输出缓冲区会自动清空,确保数据能立即发送出去,这对于交互式应用非常重要,否则客户端可能会一直等待。BufferedReader(new InputStreamReader(socket.getInputStream())):InputStreamReader将字节流转换为字符流,BufferedReader则提供按行读取的高效方法。
处理 IOException 和异常关闭
网络编程充满了不确定性,客户端可能会突然断开网络、关闭程序,或者服务器重启,必须妥善处理 IOException。
SocketException: Connection reset:这是最常见的异常之一,通常表示客户端或另一方异常关闭了连接。try-with-resources:强烈推荐使用try-with-resources语句(如try (Socket socket = ...)),它会自动调用close()方法,确保流和 Socket 在使用完毕后被正确关闭,即使发生了异常。finally块:如果不使用try-with-resources,应该在finally块中确保关闭资源,防止资源泄露。
半关闭 Socket (shutdownOutput)
我们可能想告诉对方:“我已经发送完所有数据了,但你还可以继续发给我”,这时可以只关闭输出流,而不关闭整个 Socket。
socket.shutdownOutput(): 关闭此 Socket 的输出流,调用此方法后,这个 Socket 的输出流就不能再写入数据了,如果尝试写入,会抛出IOException,但输入流仍然可以正常使用。- 对应的,
socket.shutdownInput()用于关闭输入流。
这在需要区分“数据结束”和“连接结束”的场景中非常有用。
超时设置 (setSoTimeout)
Socket 的 accept(), read() 等方法都是阻塞的,可能会无限期等待,为了避免程序永久阻塞,可以设置超时时间。
socket.setSoTimeout(int timeout): 设置超时时间(毫秒)。- 对于
ServerSocket,accept()方法如果在指定时间内没有连接,会抛出SocketTimeoutException。 - 对于
Socket,read()方法如果在指定时间内没有数据可读,会抛出SocketTimeoutException。
- 对于
NIO (New I/O) 简介
传统的 Socket 编程基于阻塞 I/O (Blocking I/O),当一个线程在 read() 或 accept() 时,它会被阻塞,无法做任何其他事情,这就是为什么我们需要为每个连接创建一个线程。
Java NIO (New I/O) 引入了一种非阻塞 I/O (Non-blocking I/O) 模型,其核心是:
- Channel (通道):类似流,但可以双向读写,并且支持异步操作。
- Buffer (缓冲区):所有数据都是通过 Buffer 进行读写。
- Selector (选择器):这是 NIO 的精髓,一个
Selector可以同时监控多个Channel的状态(如:是否有连接到来、是否有数据可读),这样,一个线程就可以管理成百上千个连接,极大地提高了系统的并发性能。
NIO 的实现比传统 BIO 更复杂,但对于构建高性能、高并发的网络服务器(如聊天室、游戏服务器)是必不可少的,当你的应用需要处理成千上万的并发连接时,就应该考虑学习和使用 NIO。
第六部分:总结与资源
- Socket 是 Java 网络编程的基础,它为程序提供了网络通信的端点。
- TCP Socket 是最常用、最可靠的通信方式,它面向连接,提供字节流服务。
- 基本流程:服务器
bind->listen->accept;客户端connect->通信->close。 - 多客户端处理:通过多线程或线程池来避免主线程阻塞,实现并发处理。
- 文本通信:优先使用
BufferedReader和PrintWriter,并开启自动刷新。 - 异常处理:网络环境不稳定,必须妥善处理
IOException,并确保资源被正确关闭。 - 性能优化:对于高并发场景,传统 BIO 的多线程模型效率低下,应考虑使用 NIO 技术。
学习资源
- 官方文档:
- 在线教程:
- Baeldung - Java Socket Programming (英文,非常详细)
- W3Schools - Java Networking (入门友好)
- 书籍:
《Java网络编程(第4版)》 - 经典之作,内容详尽。
- 实践项目:
- 聊天室:尝试实现一个支持多人同时在线聊天的服务器。
- 文件传输:实现一个客户端从服务器下载文件,或上传文件到服务器。
- HTTP 服务器:尝试用 Socket 写一个简单的 HTTP 服务器,能处理
GET请求并返回一个 HTML 页面,这是理解 Web 工作原理的绝佳练习。
希望这份教程能帮助你顺利开启 Java Socket 编程之旅!祝你学习愉快!
