文章目录
- [Ⅰ. UDP 和 TCP 的区别](#Ⅰ. UDP 和 TCP 的区别)
- [Ⅱ. UDP 套接字编程](#Ⅱ. UDP 套接字编程)
- [Ⅲ. TCP 套接字编程](#Ⅲ. TCP 套接字编程)

Ⅰ. UDP 和 TCP 的区别
| 特性 | TCP(传输控制协议) | UDP(用户数据报协议) |
|---|---|---|
| 类型 | 面向连接、面向字节流 | 无连接、面向数据报 |
| 可靠性 | 可靠,数据按顺序到达、无丢包 | 不可靠,可能丢包、乱序 |
| 缓冲区 | 既有接收缓冲区,也有发送缓冲区 | 只有接收缓冲区 |
| 速度 | 较慢(需握手、确认等) | 较快(没有连接管理) |
| 应用场景 | 文件传输、Web、聊天 | 视频直播、游戏、广播 |
| Java支持类 | Socket、ServerSocket |
DatagramSocket、DatagramPacket |
Ⅱ. UDP 套接字编程
一、常用方法
UDP是无连接的,因此每次发送数据报前都需要指定目标地址和目标端口(而TCP则只需要创建套接字时候绑定即可)- 在
Java中创建一个DatagramSocket对象,就是在操作系统中打开了一个socket文件,通过这个文件,可以读写数据,而该文件负责将内容与网卡进行交互,从而达到网络通信功能。 DatagramPacket就是一个数据报,DatagramSocket是真正的套接字文件,使用时候就是通过DatagramSocket中的send/receive方法来传输一个个数据报DatagramPacket。
| 类名 | 返回值 | 功能 |
|---|---|---|
| DatagramSocket() | 构造方法 | 创建一个 UDP 套接字,系统会随机分配一个可用端口 |
| DatagramSocket(int port) | 构造方法 | 绑定到指定端口 |
| DatagramSocket(int port, InetAddress laddr) | 构造方法 | 绑定到指定 IP 和端口 |
| DatagramSocket(SocketAddress bindaddr) | 构造方法 | 使用 InetSocketAddress 绑定地址和端口 |
| send(DatagramPacket p) | void | 发送一个数据报包到目标地址 |
| receive(DatagramPacket p) | void | 阻塞接收一个数据报包 |
| close() | void | 关闭套接字,释放资源 |
| isClosed() | boolean | 判断套接字是否已关闭 |
| setSoTimeout(int timeout) | void | 设置接收数据的超时时间(毫秒) |
| getSoTimeout() | int | 获取当前接收超时时间 |
| setReuseAddress(boolean on) | void | 设置是否允许地址重用 |
| getLocalPort() | int | 获取本地绑定的端口号 |
| getLocalAddress() | InetAddress | 获取本地绑定的 IP 地址 |
| DatagramPacket(byte[] buf, int length) | 构造方法 | 创建空数据报,用于接收数据 |
| DatagramPacket(byte[] buf, int length, InetAddress addr, int port) | 构造方法 | 创建用于发送的数据报,指定目标地址和端口 |
| DatagramPacket(byte[] buf, int offset, int length, InetAddress addr, int port) | 构造方法 | 发送指定字节数据,支持偏移和长度控制 |
| DatagramPacket(byte buf[], int offset, int length, SocketAddress address) | 构造方法 | 创建数据报,通过 SocketAddress 直接指定端口和地址 |
| getData() | byte[] | 获取数据报的缓冲区内容 |
| setData(byte[] buf) | void | 设置数据缓冲区 |
| getLength() | int | 获取数据报中有效数据长度 |
| setLength(int length) | void | 设置数据报的有效数据长度 |
| getSocketAddress() | SocketAddress | 获取地址、端口的一个结构体 相当于下面两个方法的结合 |
| getAddress() | InetAddress | 获取目标地址或发送者地址 |
| getPort() | int | 获取目标端口或发送者端口 |
| setAddress(InetAddress addr) | void | 设置数据包的目标地址 |
| setPort(int port) | void | 设置数据包的目标端口 |
💥注意事项:
- 要构造
InetAddress的话,需要调用InetAddress.getByName(s)静态方法 ,其中s是地址字符串,比如"127.0.0.1",最后就能拿到的就是地址为s的InetAddress对象! - 一般构造
DatagramPacket用getSocketAddress()的版本比较方便,因为这个方法实际上就包括了端口和地址,如下图所示:

- 创建
DatagramPacket的时候要传入的byte数组,实际上是一个应用层的缓冲区 ,通常建议缓冲区大小设置为 大于或等于预期最大UDP包大小 ,比如4096或8192。- 此外,要使用
req.getLength()来获取实际接收数据的长度,而不是buf.length,即在读取数据时要用new String(req.getData(), 0, req.getLength()),避免读到多余的空字节。
- 此外,要使用
二、服务端
- 接收客户端发来的请求,并且进行解析
- 处理请求
- 封装成数据报进行响应
java
/**
* 服务器不需要知道数据从哪里来,所以不需要用字段来存放客户端的IP和端口,直接从请求中获取即可
*/
public class Server {
private DatagramSocket socket = null; // 通信就靠DatagramSocket对象
public Server(int port) throws SocketException {
socket = new DatagramSocket(port); // 服务端需要指定port,让别人来连接
}
public void start() throws IOException {
System.out.println("服务器启动!");
while(true) {
// 1. (阻塞)接收客户端发来的请求,并且进行解析
DatagramPacket req = new DatagramPacket(new byte[4096], 4096);
socket.receive(req);
String data = new String(req.getData(), 0, req.getLength()); // 从 DatagramPacket 取到有效的数据
// 2. 处理请求
String outcome = func(data);
// 3. 封装成数据报进行响应(注意要填写目的端口和ip)
DatagramPacket resp = new DatagramPacket(outcome.getBytes(),
0,
outcome.getBytes().length,
//req.getAddress(), req.getPort()); // 可以这样子写,但是麻烦,推荐下面的写法
req.getSocketAddress());
socket.send(resp);
// 打印日志,看看效果
System.out.printf("[%s:%d] req: %s, resp: %s\n", req.getAddress(), req.getPort(), data, outcome);
}
}
// 业务代码(不是现在的重点)
public String func(String data) {
return data;
}
public static void main(String[] args) throws IOException {
Server server = new Server(8080);
server.start();
}
}
三、客户端
- 构造请求数据报,注意要传入目的端口和IP到数据报中
- 发送请求到服务器
- 接收服务器发来的响应,然后进行解析
java
/**
* 1. 因为客户端在发送数据报的时候需要知道服务器的IP和端口,并且由于UDP
* 每次发送数据都得传入服务器的IP和端口,所以需要用单独的字段来保存
*
* 2. 创建DatagramSocket时候传入的端口是指定客户端自己在本机的端口,
* 而不是服务器的端口,注意和上面区分开!
*/
public class Client {
private int port; // 服务器的端口
private String addr; // 服务器的地址
private DatagramSocket socket = null;
public Client(int port, String addr) throws SocketException {
this.port = port;
this.addr = addr;
socket = new DatagramSocket(); // 让系统自动分配端口号
}
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner sc = new Scanner(System.in);
while(true) {
// 1. 构造请求数据报,注意要传入目的端口和IP到数据报中
System.out.print("请输入要发送给服务器的信息:");
String message = sc.nextLine();
DatagramPacket req = new DatagramPacket(message.getBytes(),
0,
message.getBytes().length,
InetAddress.getByName(addr), // 利用静态方法getByName构造InetAddress
port);
// 2. 发送请求到服务器
socket.send(req);
// 3. 接收服务器发来的响应,然后进行解析
DatagramPacket resp = new DatagramPacket(new byte[4096], 4096);
socket.receive(resp);
String data = new String(resp.getData(), 0, resp.getLength());
// 进行日志输出,查看效果
System.out.println("响应:" + data);
}
}
public static void main(String[] args) throws IOException {
Client client = new Client(8080, "127.0.0.1");
client.start();
}
}
运行效果如下所示:

Ⅲ. TCP 套接字编程
一、常用方法
TCP连接只需要在创建套接字时候绑定端口和地址即可 ,而不需要像UDP一样每次发送数据的时候都要指定目标端口和地址!ServerSocket这个类主要负责建立连接、监听新连接,而不负责数据的接收和发送!Socket这个类主要负责数据的接收和发送!- 因为
TCP是面向字节流的,所以实际上数据的接收和发送,都是通过Socket获取套接字文件的输入输出流InputStream/OutputStream,然后以字节为单位,来处理该底层套接字 ,本质还是文件IO。
- 因为
- 在
Java中,如果你没有显式调用connect()创建连接,但你直接使用Socket.getOutputStream().write(...)来写数据,则系统会在你第一次写数据的时候自动调用connect()建立连接!
| 方法 / 构造方法 | 返回值类型 | 功能说明 |
|---|---|---|
| ServerSocket() | 构造方法 | 创建未绑定的服务器套接字 |
| ServerSocket(int port) | 构造方法 | 创建并绑定到指定端口的套接字 |
| ServerSocket(int port, int backlog) | 构造方法 | 指定端口与连接请求队列长度 |
| ServerSocket(int port, int backlog, InetAddress bindAddr) | 构造方法 | 指定端口、队列长度和绑定地址 |
| accept() | Socket | 阻塞等待客户端连接,返回通信用的 Socket |
| close() | void | 关闭服务器套接字,释放资源 |
| isClosed() | boolean | 判断服务器套接字是否关闭 |
| getInetAddress() | InetAddress | 获取绑定的本地 IP 地址 |
| getLocalPort() | int | 获取绑定的本地端口 |
| setSoTimeout(int timeout) | void | 设置 accept() 阻塞的超时时间(毫秒) |
| getSoTimeout() | int | 获取当前 accept() 超时时间 |
| Socket() | 构造方法 | 创建未连接的套接字(用于延迟连接) |
| Socket(String host, int port) | 构造方法 | 创建并连接到指定主机和端口 |
| Socket(InetAddress address, int port) | 构造方法 | 同上,使用 IP 地址连接 |
| getInputStream() | InputStream | 获取输入流,用于接收数据 |
| getOutputStream() | OutputStream | 获取输出流,用于发送数据 |
| close() | void | 关闭连接,释放资源 |
| isClosed() | boolean | 判断是否关闭连接 |
| isConnected() | boolean | 判断是否连接成功 |
| isBound() | boolean | 判断是否已绑定本地地址 |
| getInetAddress() | InetAddress | 获取远程地址 |
| getPort() | int | 获取远程端口号 |
| getLocalAddress() | InetAddress | 获取本地地址 |
| getLocalPort() | int | 获取本地端口号 |
| setSoTimeout(int timeout) | void | 设置输入流读取的超时时间 |
| getSoTimeout() | int | 获取读取超时时间 |
二、服务端
- 监听新连接
- 创建新线程来处理新连接
Socket,防止主线程阻塞- 读取请求并进行解析
- 将解析后的请求进行业务处理
- 响应结果给客户端
- 关闭
Socket,防止资源泄露问题
这里需要关闭
Socket对象的原因是一个服务器会创建很多新线程来处理不同的连接,这些连接本质都是套接字文件,如果没有释放文件资源的话,最后就会导致资源泄露问题!至于
ServerSocket和DatagramSocket为什么不需要释放,是因为它们全局只有一个对象,而且生命周期是随着程序生命周期的,所以不会出现资源泄露问题!此外
Scanner和PrintWriter也不需要释放,因为它们是从 Socket.getXXX() 获得的,本质还是 Socket 对象套接字文件对应的流对象,所以不需要关心 Scanner 和 PrintWriter 的释放问题!
💥注意事项:
- 因为
Scanner这里使用的是nextLine()和hasNextLine(),所以PrintWriter在使用的时候不能用write(),而要用println(),因为write()是不带换行符的,此时就算回车结束了,字符串也不会带回车,那么进入判断语句中就卡在那里了!- 结论:用
Scanner.nextLine()读取,就一定要println()写入。
- 结论:用
- 在用
PrintWriter写入数据到套接字文件后,要调用flush()进行缓冲区刷新,不然只能缓冲区快满了自动刷新!
① 多线程版本
java
/**
* 服务器只需要指定端口即可
*/
public class Server {
private ServerSocket socket = null;
public Server(int port) throws IOException {
socket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("TCP服务器启动!");
while(true) {
// 1. 监听新连接
Socket conn = socket.accept();
System.out.println("获取到新连接:" + conn.getInetAddress() + "/" + conn.getPort());
// 2. 创建新线程来处理新连接,防止主线程阻塞
new Thread(() -> {
work(conn);
}).start();
}
}
// 新连接实际上要处理的任务
private void work(Socket conn) {
try (InputStream in = conn.getInputStream();
OutputStream out = conn.getOutputStream();
Scanner sc = new Scanner(in);
PrintWriter pw = new PrintWriter(out)) {
while(true) {
// 3. 读取请求并进行解析
if(sc.hasNextLine() == false) {
// 要判断是否断开连接:如果客户端断开连接了,则会返回false
System.out.printf("[%s:%d] 客户端下线!\n", conn.getInetAddress().toString(), conn.getPort());
break;
}
String req = sc.nextLine();
// 4. 将解析后的请求进行业务处理
String resp = func(req);
// 5. 响应结果给客户端
pw.println(resp); // ✔自动添加换行符,满足服务端 Scanner.nextLine(),使用write则会死循环!
pw.flush(); // ❗❗❗细节❗❗❗
// 搞一下日志输出看看效果
System.out.printf("[%s:%d] req: %s, resp: %s\n", conn.getInetAddress().toString(), conn.getPort()
, req, resp);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
// 6. 释放Socket资源
try {
conn.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private String func(String data) {
return data;
}
public static void main(String[] args) throws IOException {
Server server = new Server(9090);
server.start();
}
}
② 线程池版本
只需要改动上面 start() 函数里面创建线程的方式即可:
java
public void start() throws IOException {
System.out.println("TCP服务器启动!");
while(true) {
// 监听新连接
Socket conn = socket.accept();
System.out.println("获取到新连接:" + conn.getInetAddress() + "/" + conn.getPort());
// 创建线程池来处理新连接,推荐用newCachedThreadPool
ExecutorService pool = Executors.newCachedThreadPool();
pool.submit(() -> {
work(conn);
});
}
}
三、客户端
- 输入数据,写入
Socket对象中,然后进行刷新 - 接收服务器的响应
java
public class Client {
private Socket conn = null;
public Client(String addr, int port) throws IOException {
conn = new Socket(addr, port);
}
public void start() {
System.out.println("客户端启动!");
Scanner tmp = new Scanner(System.in);
try (InputStream in = conn.getInputStream();
OutputStream out = conn.getOutputStream();
Scanner sc = new Scanner(in);
PrintWriter pw = new PrintWriter(out)) {
while(true) {
// 1. 输入数据,写入Socket中,然后刷新
System.out.print("请输入要发送的数据:");
String req = tmp.nextLine();
pw.println(req); // ✔自动添加换行符,满足服务端 Scanner.nextLine(),使用write则会死循环!
pw.flush(); // ❗❗❗细节❗❗❗
// 2. 接收服务器的响应
if(sc.hasNextLine() == false) {
System.out.println("客户端断开连续!");
break;
}
String resp = sc.nextLine();
// 打印日志看看效果
System.out.printf("[%s:%d] req: %s, resp: %s\n", conn.getInetAddress().toString(),
conn.getPort(),
req, resp);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
Client client = new Client("127.0.0.1", 9090);
client.start();
}
}
