Java网络编程入门:从Socket到UDP/TCP实战,手把手实现可运行Demo
Java 网络编程入门:从 Socket 到 UDP/TCP 实战
网络编程是 JavaEE 开发的基石,它让我们写的程序能够跨越主机边界彼此通信。本文从 Socket 概念讲起,对比 TCP 与 UDP 两大传输层协议的特性,并通过可运行的代码示例,手把手带你实现 UDP 回显服务、TCP 服务端/客户端,以及基于线程池的并发模型,最后延伸讲解 IO 多路复用的核心思想。
前言
网络编程,说白了就是写代码实现跨主机的通信。
套接字(Socket)是一组网络编程相关函数的统称。我们编写网络程序,本质上是在调用操作系统提供的传输层能力。
传输层主要涉及两大协议:TCP 和 UDP,二者各自提供了一组 Socket 函数。
目录
- 前言
- 一、TCP 与 UDP 两种协议的区别
- 1.1 核心术语
- 1.2 协议特性对比
- 二、UDP 网络编程
- 2.1 核心类
- 2.2 基本使用(服务端 / 客户端)
- 2.3 实战:复用服务器实现翻译服务
- 三、TCP 网络编程
- 3.1 核心类
- 3.2 基本使用(服务端 / 客户端)
- 3.3 常见问题与优化
- 四、IO 多路复用
一、TCP 与 UDP 两种协议的区别
1.1 核心术语
连接(Connection)
网络连接是一个抽象概念,本质是通信双方各自保存了对方的核心信息(IP 地址与端口号)。
可靠传输(Reliable Transmission)
网络通信中会产生丢包——当传输的数据量超过链路限制时就会丢包,而数据要经过众多的路由器、交换机,任意一环超限都可能丢包。
面向字节流(Byte Stream)
读写方式类似操作文件,非常灵活:可以一次读一个字节,也可以一次读写任意长度的数据。
面向数据报(Datagram)
每次收发都必须以"一整个数据报"为单位,不能只取其中一部分。
全双工(Full Duplex)
一条连接上可以同时双向传输数据。
半双工(Half Duplex)
一条连接上同一时刻只能单向传输数据。
1.2 协议特性对比
TCP:有连接、可靠传输、面向字节流、全双工。
- 先保存对方信息(建立连接),用完后释放(断开连接)。
- 尽可能对抗丢包,把数据可靠送达对方;实在送不到,也会通知你对方是否收到。
- 读写方式类似文件,非常灵活。
- 一条连接可双向传输。
UDP:无连接、不可靠传输、面向数据报、全双工。
- 直接发送,不建立连接。
- 不管对方收没收到,听天由命。
- 每次收发都必须是完整的数据报。
- 一条连接可双向传输。
二、UDP 网络编程
UDP 主要涉及两个类。
2.1 核心类
DatagramSocket
表示一个 Socket 对象,相当于对网卡设备的抽象。后续只要操作这个对象,就能操作网卡。
private DatagramSocket datagramSocket =null;
public de7(int port) throws SocketException {
datagramSocket=new DatagramSocket(port);
}
这里的 port 是端口号,需要作为参数传入。
- 服务器:被动接受请求的一方。
- 客户端:主动发起请求的一方。
客户端需要事先知道服务器的 IP 和端口号(端口号是 2 字节整数,范围 0–65535;其中前 1024 个,即 0–1023,是知名端口,有特殊含义)。
IP 很好确定,但端口号需要由写服务器的程序员来指定。
DatagramSocket 对外主要提供两个方法:send 和 receive,参数都是下面要讲的 DatagramPacket。
send
receive
DatagramPacket
表示数据传输的"包"(数据报)。它的构造方法主要有两类:
- 传入一个新建的字节数组,用于等待接收数据;
- 传入数据、IP 和端口号,用于发送数据。
2.2 基本使用(服务端 / 客户端)
先来看服务端:
private DatagramSocket datagramSocket =null;
//这个就是个引用
public de7(int port) throws SocketException {
datagramSocket=new DatagramSocket(port);
//新建这个要带上端口号
}
public void run() throws IOException {
System.out.println("socket");
while(true){
//1.读取请求
DatagramPacket datagramPacket = new DatagramPacket(new byte[1024],1024);
//这个参数是你要传输的东西
datagramSocket.receive(datagramPacket);
//这个是你要接受的,用这个dgP来接受
String request=new String(datagramPacket.getData(),0,datagramPacket.getLength());
//这个参数代表的是,得到内容,起点,终点
//2.根据请求给出响应
String response =process(request);
//这个函数是你要怎么处理这个参数,你自己定义这个函数就行
//3.把响应回复给客户器
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,datagramPacket.getSocketAddress());
//这个是返回的,我们需要内容,长度,传给谁
datagramSocket.send(responsePacket);
//传值
}
}
public String process(String a ){
return a ;
}
public static void main(String[] args) throws IOException {
de7 server = new de7(10086);
server.run();
}//这里不关闭是因为这个会跟随这个进程结束
服务端有了,客户端呢?
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class de8 {
DatagramSocket kehu =null;
String serverip;
int serport;
public de8(int a,String b) throws SocketException {
kehu=new DatagramSocket();
//这个就不用指定端口号了,毕竟客户的端口有很多 ,你指定可能会和别的冲突,
//这个是他先打给你,只要你的电话固定就行,他电话无所谓,你只要在通讯录里找就行
this.serport=a;
this.serverip=b;
}
public void start() throws IOException {
System.out.println("kehu");
Scanner in = new Scanner(System.in);
while(true){
//控制台读取用户输入
String input = in.next();
//把用户输入转为包
DatagramPacket req =new DatagramPacket(input.getBytes(),input.getBytes().length, InetAddress.getByName(this.serverip),this.serport);
kehu.send(req);
DatagramPacket res =new DatagramPacket(new byte[1024],1024);
kehu.receive(res);
String ret = new String(res.getData(),0,res.getLength());
System.out.println(ret);
}
}
public static void main(String[] args) throws IOException {
de8 s =new de8(10086,"127.0.0.1");
//这个10086是de7里的,后面是本地ip
s.start();
}
}

2.3 实战:复用服务器实现翻译服务
新建一个类,直接继承前面写好的服务器类,就能复用已有代码;想改逻辑时,重写对应方法即可。
import java.net.SocketException;
public class UDPfanyi extends de7{
public UDPfanyi(int port) throws SocketException {
super(port);
}
}
注意:main 方法不能被继承。
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class de8 {
DatagramSocket kehu =null;
String serverip;
int serport;
public de8(int a,String b) throws SocketException {
kehu=new DatagramSocket();
//这个就不用指定端口号了,毕竟客户的端口有很多 ,你指定可能会和别的冲突,
//这个是他先打给你,只要你的电话固定就行,他电话无所谓,你只要在通讯录里找就行
this.serport=a;
this.serverip=b;
}
public void start() throws IOException {
System.out.println("kehu");
Scanner in = new Scanner(System.in);
while(true){
//控制台读取用户输入
String input = in.next();
//把用户输入转为包
DatagramPacket req =new DatagramPacket(input.getBytes(),input.getBytes().length, InetAddress.getByName(this.serverip),this.serport);
kehu.send(req);
DatagramPacket res =new DatagramPacket(new byte[1024],1024);
kehu.receive(res);
String ret = new String(res.getData(),0,res.getLength());
System.out.println(ret);
}
}
public static void main(String[] args) throws IOException {
de8 s =new de8(10086,"127.0.0.1");
s.start();
}
}
最终运行效果如下:


下面这张图是我启动了两个端口号相同的服务器时产生的报错(端口被占用)。
三、TCP 网络编程
TCP 同样涉及两个核心类。
3.1 核心类
ServerSocket
只供服务器使用,负责统一接收连接到服务器的客户端。
Socket
客户端和服务器都能使用,负责针对每一个已建立的客户端连接提供具体服务。
注意:TCP 中没有类似 UDP 的 Packet 类,因为它传输的是字节流。
3.2 基本使用(服务端 / 客户端)
3.2.1 TCP 服务端
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
public class TCPs {
private ServerSocket serverSocket =null;
public TCPs(int port) throws IOException {
serverSocket=new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("start");
while(true){
//要先接受客户端的连接,然后才能进行通信
//如果有客户端和服务器建立好了联系,accept才能够返回
//否则会堵塞
Socket socket = serverSocket.accept();
processCon(socket);
}
}
private void processCon(Socket socket){
//在一次连接中可能有多个请求(只有一个是短链接,超过两个是长连接)
System.out.printf("[%s:%d] 客户端上线\n",socket.getInetAddress(),socket.getPort());
try(
InputStream inputStream =socket.getInputStream(); OutputStream outputStream= socket.getOutputStream()
) {
Scanner scanner =new Scanner(inputStream);
PrintWriter printWriter =new PrintWriter(outputStream);
while(true){
//处理多次请求和响应
//一次循环就是读写一个请求/响应
//1.读取请求并解析
// byte[] bytes = new byte[1024];
// int n =inputStream.read(bytes);
// String request = new String(bytes,0,n);
if(!scanner.hasNext()){
System.out.printf("[%s:%d] 客户端下线\n",socket.getInetAddress(),socket.getPort());
break;
}
String req =scanner.next();
String res= process(req);
printWriter.println(res);
System.out.printf("[%s:%d] req %s,res %s \n",socket.getInetAddress(),socket.getPort(),req,res);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private String process(String req) {
return req;
}
public static void main(String[] args) throws IOException {
TCPs s =new TCPs(10086);
s.start();
}
}
3.2.2 TCP 客户端
import java.io.*;
import java.net.Socket;
import java.util.Scanner;
public class TCPc {
private Socket socket =null;
public TCPc(String serip,int serport) throws IOException {
socket=new Socket(serip,serport);
};
public void run() {
System.out.println("TCPc start");
Scanner scanner =new Scanner(System.in);
try(InputStream inputStream =socket.getInputStream(); OutputStream outputStream=socket.getOutputStream()){
Scanner scanner1 =new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
while(true){
System.out.println("->");
String req=scanner.next();
writer.println(req);
if (!scanner1.hasNext()){
System.out.println("关闭");
break;
}
String res= scanner1.next();
System.out.println(res);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TCPc s =new TCPc("127.0.0.1",10086);
s.run();
}
}
3.3 常见问题与优化
3.3.1 缓冲区冲刷(flush)问题
启动 TCP 客户端后,你会发现服务端不断打印输出:

为什么中间输入的 “nihao” 没有反应?
因为数据还停留在缓冲区里等待写出。
这就像 C++ 里的 "\n" 和 endl 的区别。写过算法竞赛的同学都知道:
使用 endl 时的输出顺序是:
输入
输出
第二个输入
第二个输出
而使用 "\n" 时的输出顺序是:
输入
第二个输入
输出
第二个输出
原因就在于缓冲区:从硬盘读写很慢,所以数据先放进缓冲区,攒够一批再统一写出。因此 “nihao” 还停留在缓冲区里,没有真正写出去。
解决办法是调用 flush() 主动冲刷缓冲区。
改进版 TCP 服务端:
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
public class TCPs {
private ServerSocket serverSocket =null;
public TCPs(int port) throws IOException {
serverSocket=new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("start");
while(true){
//要先接受客户端的连接,然后才能进行通信
//如果有客户端和服务器建立好了联系,accept才能够返回
//否则会堵塞
Socket socket = serverSocket.accept();
processCon(socket);
}
}
private void processCon(Socket socket){
//在一次连接中可能有多个请求(只有一个是短链接,超过两个是长连接)
System.out.printf("[%s:%d] 客户端上线\n",socket.getInetAddress(),socket.getPort());
try(
InputStream inputStream =socket.getInputStream(); OutputStream outputStream= socket.getOutputStream()
) {
Scanner scanner =new Scanner(inputStream);
PrintWriter printWriter =new PrintWriter(outputStream);
while(true){
//处理多次请求和响应
//一次循环就是读写一个请求/响应
//1.读取请求并解析
// byte[] bytes = new byte[1024];
// int n =inputStream.read(bytes);
// String request = new String(bytes,0,n);
if(!scanner.hasNext()){
System.out.printf("[%s:%d] 客户端下线\n",socket.getInetAddress(),socket.getPort());
break;
}
String req =scanner.next();
String res= process(req);
printWriter.println(res);
printWriter.flush();
System.out.printf("[%s:%d] req %s,res %s \n",socket.getInetAddress(),socket.getPort(),req,res);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private String process(String req) {
return req;
}
public static void main(String[] args) throws IOException {
TCPs s =new TCPs(10086);
s.start();
}
}
改进版 TCP 客户端:
import java.io.*;
import java.net.Socket;
import java.util.Scanner;
public class TCPc {
private Socket socket =null;
public TCPc(String serip,int serport) throws IOException {
socket=new Socket(serip,serport);
};
public void run() {
System.out.println("TCPc start");
Scanner scanner =new Scanner(System.in);
try(InputStream inputStream =socket.getInputStream(); OutputStream outputStream=socket.getOutputStream()){
Scanner scanner1 =new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
while(true){
System.out.println("->");
String req=scanner.next();
writer.println(req);
writer.flush();
if (!scanner1.hasNext()){
System.out.println("关闭");
break;
}
String res= scanner1.next();
System.out.println(res);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TCPc s =new TCPc("127.0.0.1",10086);
s.run();
}
}
3.3.2 资源泄漏问题
下面这段代码创建了资源却没有回收:

如上图,你创建了资源但没有回收,会造成文件描述符(资源)泄漏:

加入对应的释放逻辑即可解决。
3.3.3 线程模型:从单线程到线程池
上面的写法只能处理"一对一"的连接,但现实中服务器往往需要"一对多",因此需要用多线程来解决:

按上图修改即可。我们为每次请求新建一个线程来处理。
当然,这是比较老的写法;比它更老的是使用多进程。但如果客户端越来越多,频繁地销毁再创建线程成本也很高,因此我们可以用线程池来优化:
public void start() throws IOException {
System.out.println("start");
ExecutorService executorService = Executors.newCachedThreadPool();
while(true){
//要先接受客户端的连接,然后才能进行通信
//如果有客户端和服务器建立好了联系,accept才能够返回
//否则会堵塞
Socket socket = serverSocket.accept();
// 所以我们创建一个新线程
executorService.submit(()->{
this.processCon(socket);
});
}
}
线程池完整的七个参数(corePoolSize / maximumPoolSize / keepAliveTime / unit / workQueue / threadFactory / handler)和提交流程,见《多线程进阶》第 11 章。这里补充最常被问到的四种拒绝策略:
| 策略 | 行为 |
|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException |
| CallerRunsPolicy | 让提交任务的线程自己跑这个任务 |
| DiscardPolicy | 静默丢弃,不抛异常 |
| DiscardOldestPolicy | 丢弃队列里最老的任务,再提交当前任务 |
那么还有没有问题?有的,兄弟们。如果某个客户端"逗留时间"短(这里指用户停留在该页面上的时长),且同一时刻线程总数不多,那没问题;但如果逗留时间长、线程越积越多,线程间切换的间隔自然就变长,又该怎么解决呢?
四、IO 多路复用
C++ 实现 IO 多路复用比较简单,但 Java 原生实现较复杂,因此 Java 一般不直接使用 NIO 相关类。这里只讲原理。
每个 Socket 并非一直活跃,大部分时间都在阻塞等待。因此我们可以让"一个线程处理多个客户端"。
举个吃饭的例子:
- 两个人吃饭,A 先去买、拿到 A 的饭后,再去买 B、拿到 B 的饭(单线程);
- 两个人分别去买,一个买 A、另一个买 B,各自拿到饭后汇合(多线程);
- 一个人先去买 A、再买 B,然后等 A、B 都完成(IO 多路复用)。
回到 TCP 代码,请求和响应都是以 \n 作为结束符的——我们正是用带 \n 的 println 来输出的:

如果改成 print 就 GG 了:

请求会一直阻塞在读取状态:

当然,后续我们一般会使用对 Socket 做了进一步封装的成熟产品。
小结
本文围绕 Java 网络编程展开:首先明确了 Socket 的本质是调用操作系统传输层能力的一组函数;随后对比了 TCP(有连接、可靠、面向字节流、全双工)与 UDP(无连接、不可靠、面向数据报、全双工)的核心差异;接着通过 DatagramSocket/DatagramPacket 与 ServerSocket/Socket 两组核心类,给出了可运行的 UDP 回显与 TCP 服务端/客户端示例;并针对 TCP 实践中的缓冲区冲刷、资源泄漏、单线程并发瓶颈,引出了 flush、资源回收与线程池等解决方案;最后简述了 IO 多路复用的"一个线程管理多连接"思想。掌握这些,就打下了 JavaEE 网络开发的基础。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)