一、传输层两大协议:TCP vs UDP
传输层负责端到端的数据传输,TCP与UDP是最核心的两个协议,二者设计思想完全不同。
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接模式 | 面向连接,通信前建立连接(三次握手),通信结束释放连接 | 无连接,不需要建立连接,直接发送数据包 |
| 可靠性 | 可靠传输:确认应答、丢包重传、超时重传、流量控制,保证数据完整有序到达 | 不可靠传输:不保证送达、不保证顺序,存在丢包、乱序风险 |
| 数据载体 | 字节流,没有报文边界 | 数据报(Datagram),每个数据包是独立完整报文,有边界 |
| 传输效率 | 开销大,速度相对慢 | 开销极小,传输速度快 |
| 通信模式 | 全双工,双方可以同时收发数据 | 全双工 |
| 典型场景 | 文件上传下载、网页浏览,对数据完整性要求高 | 直播、语音通话、游戏,允许少量丢包,追求低延迟 |
通俗类比: TCP=打电话:拨号建立连接,通话,挂断;必须接通才能说话,说话内容不会丢失错乱。 UDP=寄明信片:写好直接扔出去,不确认对方是否收到,速度快,但明信片可能丢失、顺序错乱。
补充概念
- 全双工 :通信双方同一时刻既能发也能收;半双工:同一时刻只能一方发送一方接收。TCP为全双工通信。
- IP地址 :定位网络中的一台主机;端口号 :定位主机上运行的进程,取值
0‑65535,1‑1023为系统预留端口,开发自定义使用1024以上端口。 - 回环地址
127.0.0.1:仅本机内部访问,不会经过网卡;跨机器通信,需要填写本机局域网公网IP,注意防火墙放行端口。
二、Socket基础概念
Socket(套接字),是应用层和传输层之间的编程接口,程序通过Socket完成网络数据收发。 Java针对两个协议提供两套API:
- TCP:
ServerSocket(服务端) +Socket(客户端),依靠IO流完成数据读写 - UDP:
DatagramSocket(收发工具,客户端服务端通用) +DatagramPacket(数据包容器)
核心:Socket只是接口,真正干活的是操作系统内核。
三、UDP编程详解
UDP没有连接,没有握手,所有数据封装为DatagramPacket数据包,DatagramSocket负责发送接收数据包。
核心API说明
DatagramSocket:UDP套接字- 服务端:创建时绑定固定端口,等待接收数据包
- 客户端:可以不指定端口,操作系统自动分配随机端口
receive(DatagramPacket p):阻塞方法,没有数据包就一直等待send(DatagramPacket p):发送数据包
DatagramPacket:数据包对象- 接收包:只提供字节缓冲区,等待socket把收到的数据填充进来
- 发送包:必须传入数据字节数组、目标IP、目标端口
getAddress()/getPort():获取数据包发送方的IP与端口,用于回复数据getLength():获取本次收到数据的真实字节长度
UDP Echo回显服务
功能:客户端发送消息,服务端收到后原样返回给客户端
UDP服务端
java
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
// 指定了一个固定端口号, 让服务器来使用.
socket = new DatagramSocket(port);
}
public void start() throws IOException {
// 启动服务器
System.out.println("服务器启动");
while (true) {
// 循环一次, 就相当于处理一次请求.
// 处理请求的过程, 典型的服务器都是分成三个步骤的.
// 1. 读取请求并解析.
// DatagramPacket 表示一个 UDP 数据报. 此处传入的字节数组, 就保存 UDP 的载荷部分.
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
// 把读取到的二进制数据, 转成字符串. 只是构造有效的部分.
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2. 根据请求, 计算响应. (服务器最关键的逻辑)
// 但是此处写的是回显服务器. 这个环节相当于省略了.
String response = process(request);
// 3. 把响应返回给客户端
// 根据 response 构造 DatagramPacket, 发送给客户端.
// 此处不能使用 response.length()
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
// 此处还不能直接发送. UDP 协议自身没有保存对方的信息(不知道发给谁)
// 需要指定 目的 ip 和 目的端口.
socket.send(responsePacket);
// 4. 打印一个日志
System.out.printf("[%s:%d] req: %s, resp: %s\n", requestPacket.getAddress().toString(), requestPacket.getPort(),
request, response);
}
}
// 后续如果要写别的服务器, 只修改这个地方就好了.
// 不要忘记, private 方法不能被重写. 需要改成 public
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
UDP客户端
java
package network;
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
// UDP 本身不保存对端的信息, 就自己的代码中保存一下
private String serverIp;
private int serverPort;
// 和服务器不同, 此处的构造方法是要指定访问的服务器的地址.
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 从控制台读取用户输入的内容.
System.out.println("请输入要发送的内容:");
if (!scanner.hasNext()) {
break;
}
String request = scanner.next();
// 2. 把请求发送给服务器, 需要构造 DatagramPacket 对象.
// 构造过程中, 不光要构造载荷, 还要设置服务器的 IP 和端口号
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIp), serverPort);
// 3. 发送数据报
socket.send(requestPacket);
// 4. 接收服务器的响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
// 5. 从服务器读取的数据进行解析, 打印出来.
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
UDP易错点
receive()阻塞,没有数据就卡住程序。- 读取数据必须使用
getLength(),不能直接new String(buffer),buffer数组后面全是空白垃圾数据。 - 接收缓冲区数组大小有限,数据包大于缓冲区会发生截断丢数据。
- UDP无连接:服务端没有
accept();客户端不需要显式建立连接。 - 想要回复客户端,IP和端口全部来自收到的
DatagramPacket对象。
四、TCP编程详解
TCP面向连接;服务端ServerSocket监听端口,调用accept()阻塞等待客户端连接;客户端Socket主动发起连接。通信依靠InputStream、OutputStream字节流完成。
核心API说明
ServerSocket(int port):TCP服务端,绑定监听端口accept():阻塞方法 ,等待客户端连接;连接成功返回一个和客户端通信的Socket对象。
Socket:TCP通信套接字对象getInputStream():获取输入流,读取对方发来数据getOutputStream():获取输出流,向对方发送数据getRemoteSocketAddress():获取对端地址信息
重点:
ServerSocket只负责接受连接;真正收发数据,靠accept()返回出来的Socket对象。
TCP多线程服务端
java
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoServer {
private ServerSocket serverSocket = null;
// 这里和 UDP 服务器类似, 也是在构造对象的时候, 绑定端口号.
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器");
// 这种情况一般不会使用 fixedThreadPool, 意味着同时处理的客户端连接数目就固定了.
ExecutorService executorService = Executors.newCachedThreadPool();
while (true) {
// tcp 来说, 需要先处理客户端发来的连接.
// 通过读写 clientSocket, 和客户端进行通信.
// 如果没有客户端发起连接, 此时 accept 就会阻塞.
// 主线程负责进行 accept, 每次 accept 到一个客户端, 就创建一个线程, 由新线程负责处理客户端的请求.
Socket clientSocket = serverSocket.accept();
// 使用多线程的方式来调整
// Thread t = new Thread(() -> {
// processConnection(clientSocket);
// });
// t.start();
// 使用线程池来调整
executorService.submit(() -> {
processConnection(clientSocket);
});
}
}
// 处理一个客户端的连接.
// 可能要涉及到多个客户端的请求和响应.
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 针对 InputStream 套了一层
Scanner scanner = new Scanner(inputStream);
// 针对 OutputStream 套了一层
PrintWriter writer = new PrintWriter(outputStream);
// 分成三个步骤
while (true) {
// 1. 读取请求并解析. 可以直接 read, 也可以借助 Scanner 来辅助完成.
if (!scanner.hasNext()) {
// 连接断开了
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 返回响应到客户端
// outputStream.write(response.getBytes());
writer.println(response);
writer.flush();
// 打印日志
System.out.printf("[%s:%d] req: %s, resp: %s\n", clientSocket.getInetAddress(), clientSocket.getPort(),
request, response);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
clientSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
TCP客户端
java
package network;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
// 直接把字符串的 IP 地址, 设置进来.
// 127.0.0.1 这种字符串
socket = new Socket(serverIp, serverPort);
}
public void start() {
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
// 为了使用方便, 套壳操作
Scanner scannerNet = new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
// 从控制台读取请求, 发送给服务器.
while (true) {
// 1. 从控制台读取用户输入
String request = scanner.next();
// 2. 发送给服务器
writer.println(request);
// 加上刷新缓冲区操作, 才是真正发送数据
writer.flush();
// 3. 读取服务器返回的响应.
String response = scannerNet.next();
// 4. 打印到控制台
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
}
TCP易错点
- TCP是字节流无边界:多次发送的数据内核缓冲区会合并,读的时候分不清消息边界,业务层需要自定义协议(分隔符、长度前缀)切分报文。
accept()和读写 clientSocket都会阻塞。单线程无法并发服务多客户端,必须引入多线程。- 资源关闭:Socket、IO流需要关闭释放端口;程序异常崩溃可能端口残留占用。
- 乱码问题:收发两端字符串编码格式必须统一。
五、TCP与UDP共性总结
- 阻塞问题 :
accept()、socket.receive()、IO流read()全部会阻塞线程。并发场景需要多线程,后续进阶NIO非阻塞IO。 - 端口占用异常:端口被别的进程占用,启动直接报错;测试注意关闭旧程序释放端口。
- 本机测试与跨机器测试 :
127.0.0.1只能本机访问;跨机器要填写真实局域网IP,防火墙放行端口。 - 数据读取长度陷阱 :UDP必须使用
getLength();TCP字节流注意区分读到的有效字节长度和缓冲区数组总长度。 - TCP字节流无报文边界;UDP数据包自带边界,这是二者编程最大区别。
- 异常处理:网络属于IO操作,大量抛出受检异常,生产环境需要完善异常捕获。
六、总结
- TCP面向连接、可靠、字节流;UDP无连接、不可靠、数据报,选型看业务场景。
- Java Socket本质封装操作系统内核网络能力,TCP靠IO流通信;UDP靠数据包通信。
- 默认BIO阻塞模型:想要并发处理多个客户端,TCP需要多线程。
- BIO是入门基础,生产环境会进一步学习NIO非阻塞网络编程。