11. Java 网络编程

Java 网络编程:从地址到 TCP、UDP
网络编程就是让不同程序通过网络交换数据。我们先认识“找到哪台机器、找到哪个程序”,再看 Java 怎样读取网络资源,最后用 TCP、UDP 和多线程服务器完成简单通信。
1. 网络通信是什么
两台终端要交换数据,除了网线、无线网络等硬件,还要遵守共同的通信规则。这个规则就是网络协议。
例如,客户端发送一句“你好”,服务端要知道:数据从哪里来、内容怎样表示、什么时候算发送结束,以及应该怎样回复。协议就是对这些约定的规定。
常见的 TCP/IP 协议体系可以先按四层理解:
| 层 | 主要作用 | 常见例子 |
|---|---|---|
| 应用层 | 规定具体应用怎样交换数据 | HTTP、DNS |
| 传输层 | 负责端到端的数据传输 | TCP、UDP |
| 网络层 | 根据 IP 地址找到目标主机 | IP |
| 网络接口层 | 通过网卡、网线或无线信号传输 | 以太网、Wi-Fi |
发送数据时,数据会从应用层逐层交给下面的协议;接收时,再从下面逐层交给应用程序。每一层只关心自己的工作,所以更容易维护和替换。
2. IP 地址和端口
2.1 IP 地址:找到哪台机器
IP 地址用于标识网络中的主机。局域网练习时,127.0.0.1 和 localhost 都表示当前电脑,也就是让客户端连接本机上的服务端。
早期教材常按 A、B、C、D、E 类划分 IPv4 地址。实际开发更常接触子网掩码、私有地址和 IPv6,不需要把 A、B、C 类范围当成写程序的重点。
Java 用 InetAddress 表示 IP 地址和主机名:
java
import java.net.InetAddress;
InetAddress address = InetAddress.getByName("localhost");
System.out.println(address.getHostName()); // localhost(具体显示可能因系统而不同)
System.out.println(address.getHostAddress()); // 127.0.0.1 或 ::1
getByName() 会根据主机名查找地址,这一步可能访问 DNS,也可能直接使用本机 hosts 配置,因此方法声明中常见 UnknownHostException。
常用方法可以先这样记:
| 方法 | 作用 |
|---|---|
getLocalHost() |
获取本机的地址信息 |
getByName(String host) |
根据主机名或 IP 地址查找地址 |
getHostName() |
获取主机名 |
getHostAddress() |
获取文字形式的 IP 地址 |
2.2 端口:找到哪个程序
一台电脑可以同时运行多个网络程序。IP 地址只能找到这台电脑,还需要端口号找到具体的服务。
可以把 IP 比作大楼地址,把端口比作房间号:
text
localhost:3306 MySQL 常见端口
localhost:8080 Web 服务常见端口
127.0.0.1:8079 本文 TCP 示例使用的端口
IP 和端口合起来,才表示一个可以接收网络数据的服务端点。端口被其他程序占用时,服务端启动会失败,可以换一个未使用的端口。
3. InetAddress 和网络资源地址
3.1 URI:描述资源
URI 是统一资源标识符,用一套格式描述一个资源。它可以包含协议、主机、端口、路径和查询参数。
先只解析地址,不访问网络:
java
import java.net.URI;
URI uri = URI.create("https://example.com:443/docs/index.html?lang=zh");
System.out.println(uri.getScheme()); // https
System.out.println(uri.getHost()); // example.com
System.out.println(uri.getPort()); // 443
System.out.println(uri.getPath()); // /docs/index.html
这个例子只是在程序中拆分地址,所以运行不需要联网。URI 关注的是“怎样描述资源”,不负责直接建立连接。
3.2 URL:定位并访问资源
URL 是 URI 的一种具体形式,除了描述资源,还能转换成访问它的地址。Java 中可以先用 URI 检查和拆分地址,再调用 toURL()。
java
import java.net.URI;
import java.net.URL;
URI uri = URI.create("https://example.com/docs/index.html");
URL url = uri.toURL();
System.out.println(url.getProtocol()); // https
System.out.println(url.getHost()); // example.com
以前常见的 new URL(protocol, host, port, file) 也能创建 URL,但手动拆分时容易漏掉查询参数。已知完整地址时,使用 URI.toURL() 更直观。
URL 还提供 openStream(),可以直接取得输入流;实际读取网页时,通常更适合使用下面的 URLConnection,因为它可以先设置超时时间并查看内容类型。
3.3 URLConnection:打开连接并读取内容
URLConnection 表示一次资源连接。它可以设置超时时间、查看内容类型,再取得输入流读取数据。
下面沿用“读取网页文字”的例子,但只读取前几行,避免把整个网页打印出来:
java
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
URLConnection connection = URI.create("https://example.com/")
.toURL()
.openConnection();
connection.setConnectTimeout(3000); // 连接最多等待 3 秒
connection.setReadTimeout(3000); // 读取最多等待 3 秒
System.out.println(connection.getContentType());
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
connection.getInputStream(), StandardCharsets.UTF_8))) {
for (int i = 0; i < 3; i++) {
String line = reader.readLine();
if (line == null) {
break; // 提前读到文件末尾
}
System.out.println(line);
}
}
这里的顺序是:地址 URI → URL → URLConnection → 输入流。try-with-resources 会在读取结束或出现异常时关闭流。真实项目还要根据响应状态、字符集和内容大小处理错误;读取网络资源需要可用的网络连接。
4. TCP:先建立连接,再交换数据
TCP 是面向连接的传输协议。通信前先建立连接,数据按发送顺序可靠地交付,通信结束后关闭连接。
它适合登录、下单、文件传输等不能随意丢数据的场景。TCP 保证传输过程中的顺序和可靠性,但不等于加密;需要保密时要使用 TLS/HTTPS 等方案。
Java 中:
- 服务端用
ServerSocket监听端口。 - 服务端调用
accept()等待客户端。 - 客户端用
Socket连接服务端。 - 两端通过输入流和输出流交换数据。
常用 API:
| 类或方法 | 作用 |
|---|---|
new ServerSocket(port) |
在端口上创建服务端监听器 |
accept() |
等待并返回一个客户端 Socket |
new Socket(host, port) |
连接指定主机和端口 |
getInputStream() |
获取接收数据的输入流 |
getOutputStream() |
获取发送数据的输出流 |
close() |
关闭连接或监听器 |
4.1 服务端
先写服务端。这个入门例子只接收一个客户端请求并回复一次,便于看清连接过程:
java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(8079)) {
System.out.println("服务端已启动,等待客户端连接");
// accept() 会等待,直到有客户端连接进来。
try (Socket socket = server.accept();
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {
String request = input.readUTF();
System.out.println("收到客户端:" + request);
// 客户端也必须使用 readUTF(),两端的数据格式要对应。
output.writeUTF("服务端收到了你的请求");
output.flush();
}
}
}
}
readUTF() 会等待一条完整的 UTF 数据,客户端必须使用 writeUTF() 发送。flush() 把输出缓冲中的数据推给对方,关闭流时也会关闭对应的 socket。
4.2 客户端
服务端启动后,再运行客户端:
java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
public class Client {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket("127.0.0.1", 8079);
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
DataInputStream input = new DataInputStream(socket.getInputStream())) {
output.writeUTF("你好");
output.flush();
System.out.println("客户端收到:" + input.readUTF());
}
}
}
运行顺序是:先启动 Server,再启动 Client。预期结果类似:
text
服务端:收到客户端:你好
客户端:客户端收到:服务端收到了你的请求
服务端和客户端必须约定相同的端口、消息格式和收发顺序。例如服务端先 readUTF(),客户端就要先 writeUTF();如果两边都先读取,就会互相等待。
5. UDP:不建立连接,直接发送数据报
UDP 不需要像 TCP 那样先建立连接。发送方把数据放进一个数据报,写上目标 IP 和端口后直接发送。
UDP 开销小、速度快,适合实时性较强、偶尔丢包也能接受的场景,例如局域网发现、简单状态广播和部分实时数据。它不保证数据一定到达,也不保证顺序;需要可靠性时不能把 UDP 当成 TCP 使用。
Java 中:
DatagramSocket负责发送和接收数据报。DatagramPacket保存数据、长度、地址和端口。
常用 API:
| 类或方法 | 作用 |
|---|---|
new DatagramSocket(port) |
绑定本地端口,接收数据报 |
send(packet) |
发送一个数据报 |
receive(packet) |
等待并接收一个数据报 |
getData() |
获取数据缓冲区 |
getLength() |
获取本次数据报的实际长度 |
getAddress()、getPort() |
获取发送方地址和端口 |
5.1 接收方 TerminalA
先启动接收方,监听 8181 端口。注意读取消息时使用 packet.getLength(),因为缓冲区通常比实际消息大:
java
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
public class TerminalA {
public static void main(String[] args) throws Exception {
try (DatagramSocket socket = new DatagramSocket(8181)) {
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
socket.receive(packet); // 没有数据时会等待
String message = new String(
packet.getData(), packet.getOffset(), packet.getLength(),
StandardCharsets.UTF_8);
System.out.println("TerminalA 收到:" + message);
String reply = "TerminalA 已收到";
byte[] replyBytes = reply.getBytes(StandardCharsets.UTF_8);
DatagramPacket response = new DatagramPacket(
replyBytes, replyBytes.length,
new InetSocketAddress(packet.getAddress(), packet.getPort()));
socket.send(response); // 回到刚才发送方的地址和端口
}
}
}
5.2 发送方 TerminalB
再运行发送方,发送到 127.0.0.1:8181,然后等待 TerminalA 的回复:
java
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
public class TerminalB {
public static void main(String[] args) throws Exception {
try (DatagramSocket socket = new DatagramSocket()) {
byte[] bytes = "TerminalB 你好".getBytes(StandardCharsets.UTF_8);
DatagramPacket request = new DatagramPacket(
bytes, bytes.length, InetAddress.getByName("127.0.0.1"), 8181);
socket.send(request);
byte[] buffer = new byte[1024];
DatagramPacket response = new DatagramPacket(buffer, buffer.length);
socket.receive(response);
String message = new String(
response.getData(), response.getOffset(), response.getLength(),
StandardCharsets.UTF_8);
System.out.println("TerminalB 收到:" + message);
}
}
}
运行顺序是:先启动 TerminalA,再启动 TerminalB。UDP 的 receive() 也会等待,但它等的是一个数据报,不是 TCP 连接。收不到数据时可能一直等待,实际程序通常还要设置超时或处理取消。
6. 多线程 TCP:一个客户端对应一个任务
单线程服务端一次只处理一个客户端。如果服务端在处理第一个客户端时,第二个客户端也连接进来,第二个客户端就只能等待。
常见的入门改法是:主线程只负责接收连接,每接收一个 socket,就交给一个新线程处理。沿用前面的“客户端发送一句话”场景。
6.1 接收连接的服务端
java
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerThread {
public static void main(String[] args) {
try (ServerSocket server = new ServerSocket(8079)) {
System.out.println("多线程服务端已启动");
while (true) {
Socket socket = server.accept();
// 主线程马上接收下一个客户端,当前 socket 交给独立任务。
new Thread(new ServerRunnable(socket)).start();
}
} catch (IOException e) {
System.out.println("服务端启动或监听失败:" + e.getMessage());
}
}
}
6.2 处理一个客户端的任务
java
import java.io.DataInputStream;
import java.io.IOException;
import java.net.Socket;
public class ServerRunnable implements Runnable {
private final Socket socket;
public ServerRunnable(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try (Socket client = socket;
DataInputStream input = new DataInputStream(client.getInputStream())) {
System.out.println(Thread.currentThread().getName()
+ " 收到:" + input.readUTF());
} catch (IOException e) {
System.out.println("客户端通信失败:" + e.getMessage());
}
}
}
ServerRunnable 只负责一个客户端。处理完成后关闭自己的 socket,不影响服务端继续监听其他客户端。
6.3 多个客户端
原来的客户端循环启动 100 个线程,输出会很多,也不方便观察。先缩小为 3 个客户端:
java
public class ClientThread {
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
new Thread(new ClientRunnable(i), "客户端线程" + i).start();
}
}
}
每个客户端线程连接服务端,发送自己的编号:
java
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
public class ClientRunnable implements Runnable {
private final int number;
public ClientRunnable(int number) {
this.number = number;
}
@Override
public void run() {
try (Socket socket = new Socket("127.0.0.1", 8079);
DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {
output.writeUTF("我是客户端" + number + "号");
output.flush();
} catch (IOException e) {
System.out.println("客户端连接失败:" + e.getMessage());
}
}
}
启动 ServerThread 后,再启动 ClientThread。服务端会收到三条消息,但具体顺序不固定,因为三个线程是并发运行的。这里每个客户端只发一条消息,所以服务端读到一次 readUTF() 后就关闭连接;真实服务还要设计连接保持、客户端退出和线程数量限制。
如果要复现原笔记中“批量模拟 100 个客户端”的练习,只需要把循环上限改成 100:
java
for (int i = 0; i < 100; i++) {
new Thread(new ClientRunnable(i), "客户端线程" + i).start();
}
这个版本适合观察并发连接数量,不适合作为生产环境的线程模型。客户端很多时,应改用线程池、连接池或异步网络框架。
7. 常见问题
- 连接不上:检查服务端是否先启动、IP 和端口是否一致、端口是否已经被占用。
- 两端一直不动:常见原因是双方收发顺序不一致,或一方等待
readUTF()而另一方没有writeUTF()。 - 中文乱码:发送和接收要使用同一个字符集,例如都使用 UTF-8。
DataInputStream.readUTF()要和DataOutputStream.writeUTF()配对。 - UDP 读出多余字符:不要把整个缓冲区转成字符串,要使用数据报的实际长度。
- 资源没有关闭:优先使用
try-with-resources,让 socket 和流在结束时自动关闭。 - 把 TCP 当成消息队列:TCP 只提供连续的字节流,不保留“每次 write 对应一次 read”的消息边界。本文使用
writeUTF/readUTF,是因为它们额外写入了长度信息。 - 把 TCP 当成加密协议:TCP 本身不加密,涉及账号、密码等内容时要使用 TLS 或 HTTPS。
补充:继续学习
ServerSocket.accept()、Socket.getInputStream()和DatagramSocket.receive()都可能阻塞,需要考虑超时和取消。- 每个连接创建一个线程适合入门,连接很多时应学习线程池、NIO 或异步网络框架。
- 服务端不能信任客户端发送的内容,要校验长度、格式和权限。
- 生产环境通常不把端口写死在业务代码中,而是通过配置传入。
- URL 访问还要处理 HTTP 状态码、重定向、响应字符集和响应体大小。
URLConnection可以改为HttpURLConnection或现代 HTTP 客户端,以便读取状态码和设置请求头。
InetAddress API · URI API · URLConnection API · ServerSocket API · Socket API · DatagramSocket API

评论