Socket网络编程入门:UDP+TCP双案例手把手教你写服务器
目录
前言:为什么要学网络编程?
一、网络编程基础概念
1.1 什么是网络编程
1.2 发送端与接收端
1.4 客户端与服务端
二、Socket套接字是什么?
2.1 套接字的概念
2.3 TCP vs UDP 核心对比
三、UDP数据报套接字编程
3.1 UDP通信模型
3.2 核心API介绍
DatagramSocket
DatagramPacket
3.3 实战一:Echo回显服务器
UDP Echo 服务端
UDP Echo 客户端
3.4 实战二:英译汉词典服务器
四、TCP流套接字编程
4.1 TCP通信模型
4.2 核心API介绍
ServerSocket
Socket
4.3 实战:Echo回显服务器
TCP Echo 服务端
TCP Echo 客户端
五、服务器性能优化
5.1 为什么需要多线程?
5.2 多线程版本服务器
5.3 线程池版本服务器
六、长短连接详解
短连接
长连接
两者对比
七、常见坑点:端口被占用怎么办?
怎么查哪个进程占了端口?
怎么解决?
八、全文总结
前言:为什么要学网络编程?
大家好,今天我们来聊一个Java后端开发绕不开的话题——网络编程。
你有没有想过,我们每天刷抖音、逛淘宝、玩网游,这些数据是怎么从服务器传到你手机上的?背后的技术支撑就是网络编程。学会了Socket套接字,你就能自己写服务器、写客户端,真正理解数据是怎么在网络中传输的。
这篇文章我会用最通俗的语言,配合完整的代码案例,带你从零入门UDP和TCP两种套接字编程。看完你就能动手写出自己的第一个网络程序!
本文面向人群:有Java基础、想入门网络编程的同学
你将收获:Socket概念 + UDP/TCP原理 + 4个完整实战代码 + 常见坑点避坑指南
一、网络编程基础概念
1.1 什么是网络编程
先给大家一个直观的理解:你在浏览器打开优酷看视频,本质上就是通过网络,获取到了网络上的一个视频资源。和你本地打开一个视频文件类似,只是这个资源的来源是网络。
网络编程,就是指网络上的不同主机,通过不同的进程,以编程的方式实现网络通信(也就是网络数据传输)。
划重点:只要是不同进程之间通过网络传输数据,就算网络编程。哪怕是同一台电脑上开两个程序互相发数据,也属于网络编程的范畴。
我们开发的时候,为了方便调试,通常都是在自己电脑上开两个进程模拟客户端和服务端。但真实场景下,肯定是分布在不同主机上的:
进程A:负责获取网络资源(比如你的浏览器)
进程B:负责提供网络资源(比如网站的服务器)
1.2 发送端与接收端
在一次网络数据传输中:
发送端:数据的发送方进程,发送端所在的主机叫源主机
接收端:数据的接收方进程,接收端所在的主机叫目的主机
这里要注意,发送端和接收端是相对的概念,只是一次数据传输产生流向后的叫法。A给B发数据时,A是发送端、B是接收端;反过来B给A回数据时,B就变成了发送端、A变成了接收端。
1.4 客户端与服务端
在常见的网络场景中,我们把提供服务的一方叫服务端,获取服务的一方叫客户端。
服务端一般提供两种服务:
客户端获取资源:比如你刷B站,视频资源都在服务器上,你作为客户端去取
客户端保存资源:比如你发朋友圈,把图片文字上传到微信服务器存起来
最经典的客户端-服务端模型流程是这样的:
客户端先发送请求到服务端
服务端根据请求数据,执行业务处理
服务端返回响应:发送处理结果
客户端根据响应数据,展示处理结果
二、Socket套接字是什么?
2.1 套接字的概念
Socket(套接字),是操作系统提供的用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。简单说,Socket就是操作系统给我们程序员提供的一套API,让我们能方便地进行网络数据传输。
基于Socket套接字的网络程序开发,就是我们说的网络编程。
2.3 TCP vs UDP 核心对比
很多同学刚学的时候容易搞混TCP和UDP,这里给大家整理了一张对比表,一目了然:
对比维度 | TCP(传输控制协议) | UDP(用户数据报协议) |
|---|---|---|
连接性 | 有连接,通信前需三次握手建立 | 无连接,直接发送数据 |
可靠性 | 可靠传输,确认、重传、流量控制 | 不可靠,不保证送达,丢包不重传 |
传输形式 | 面向字节流,无边界 | 面向数据报,有明确边界 |
传输效率 | 相对较低,有额外开销 | 高,开销小速度快 |
数据大小 | 无限制 | 单次最多64K |
应用场景 | 网页、文件传输、邮件等需可靠的场景 | 直播、游戏、视频会议等实时性优先场景 |
面试常考:TCP和UDP的区别是后端面试高频题,一定要记牢!
三、UDP数据报套接字编程
3.1 UDP通信模型
UDP是无连接、面向数据报的协议,意思就是每次发数据都不用建立连接,一次把整个数据报发出去,接收端也必须一次收完。
Java中使用UDP协议通信,主要基于两个类:
DatagramSocket:数据报套接字,用来发送和接收数据
DatagramPacket:数据报包,就是具体要发送或接收的数据
一次完整的UDP请求-响应流程是这样的:
接收端先创建DatagramSocket,绑定端口,调用receive()方法阻塞等待数据
发送端创建DatagramSocket,构造DatagramPacket(包含数据+目标IP和端口),调用send()发送
接收端收到数据,receive()方法返回,从DatagramPacket中取出数据
接收端处理业务,构造响应的DatagramPacket,send()发回给发送端
发送端调用receive()等待响应,收到后处理
3.2 核心API介绍
DatagramSocket
DatagramSocket就是UDP的Socket,用于发送和接收UDP数据报。
构造方法:
方法签名 | 说明 |
|---|---|
DatagramSocket() | 创建UDP套接字,绑定本机随机端口(一般客户端用) |
DatagramSocket(int port) | 创建UDP套接字,绑定指定端口(一般服务端用) |
常用方法:
方法签名 | 说明 |
|---|---|
void receive(DatagramPacket p) | 接收数据报,没数据就阻塞等待 |
void send(DatagramPacket p) | 发送数据报,直接发不阻塞 |
void close() | 关闭套接字,释放资源 |
DatagramPacket
DatagramPacket就是UDP发送和接收的数据报包。
构造方法:
方法签名 | 说明 |
|---|---|
DatagramPacket(byte[] buf, int length) | 构造接收用的数据报,数据存buf里,指定接收长度 |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) | 构造发送用的数据报,指定数据+目标地址 |
常用方法:
方法签名 | 说明 |
|---|---|
InetAddress getAddress() | 获取对方的IP地址 |
int getPort() | 获取对方的端口号 |
byte[] getData() | 获取数据报中的数据 |
3.3 实战一:Echo回显服务器
我们先来写一个最简单的回显服务器:客户端发什么,服务器就原样返回什么。这是网络编程的"Hello World"。
UDP Echo 服务端
服务器的核心逻辑就是三步走:
读取请求并解析:receive()接收客户端发来的数据报
根据请求计算响应:回显服务就是请求等于响应
把响应写回客户端:构造响应数据报,send()发回
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 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. 把响应写回给客户端 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress()); socket.send(responsePacket); // 打印日志,方便观察 System.out.printf("[%s:%d] req: %s, resp: %s\n", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response); } } // 根据请求计算响应,回显就是原样返回 public String process(String request) { return request; } public static void main(String[] args) throws IOException { UdpEchoServer server = new UdpEchoServer(9090); server.start(); } }UDP Echo 客户端
客户端的逻辑也是四步走:
从控制台读取用户输入
构造请求对象,发给服务器
读取服务器的响应
显示到屏幕上
import java.io.IOException; import java.net.*; import java.util.Scanner; public class UdpEchoClient { private DatagramSocket socket = null; private String serverIp; private int serverPort; public UdpEchoClient(String ip, int port) throws SocketException { serverIp = ip; serverPort = port; // 客户端不指定端口,系统自动分配空闲端口 socket = new DatagramSocket(); } public void start() throws IOException { Scanner scanner = new Scanner(System.in); System.out.println("客户端启动!"); while (true) { // 1. 从控制台读取用户输入 System.out.print("-> "); String request = scanner.next(); // 2. 构造请求对象,发给服务器 DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIp), serverPort); socket.send(requestPacket); // 3. 读取服务器响应 DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096); socket.receive(responsePacket); String response = new String(responsePacket.getData(), 0, responsePacket.getLength()); // 4. 显示到屏幕 System.out.println(response); } } public static void main(String[] args) throws IOException { UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090); client.start(); } }运行方式:先启动服务端,再启动客户端,在客户端控制台输入文字,回车就能看到服务器回显回来的内容。注意127.0.0.1是本机回环地址,本地调试用。
3.4 实战二:英译汉词典服务器
学会了回显服务器,我们来升级一下,做一个英译汉词典服务器。客户端输入英文单词,服务器返回对应的中文翻译。
这里我们用继承的方式,直接复用UdpEchoServer的代码,只需要重写process方法就行,非常方便!
import java.net.SocketException; import java.util.HashMap; import java.util.Map; public class UdpDictServer extends UdpEchoServer { private Map<String, String> dict = new HashMap<>(); public UdpDictServer(int port) throws SocketException { super(port); // 初始化词典,这里简单放几个单词 dict.put("cat", "小猫"); dict.put("dog", "小狗"); dict.put("hello", "你好"); dict.put("world", "世界"); dict.put("java", "爪哇编程语言"); // 可以继续加千千万万个单词... } // 重写process方法,实现查词典的逻辑 @Override public String process(String request) { // 查到就返回翻译,查不到返回提示 return dict.getOrDefault(request, "该词没有查询到!"); } public static void main(String[] args) throws Exception { UdpDictServer server = new UdpDictServer(9090); server.start(); } }客户端不用改,直接用之前的UdpEchoClient就行,输入英文单词就能拿到翻译了。
设计思想:你看,通过继承+重写,我们把网络通信的通用逻辑和业务逻辑分开了。以后不管做什么业务,只要重写process方法就行,网络部分完全不用动。这就是代码复用的魅力!
四、TCP流套接字编程
4.1 TCP通信模型
TCP是有连接、面向字节流的协议。和UDP不同,TCP通信前必须先建立连接,就像打电话先拨号接通了才能说话。
Java中TCP通信主要用两个类:
ServerSocket:服务端用的,负责监听端口,等待客户端连接
Socket:双方建立连接后,用Socket来收发数据,通过输入输出流操作
TCP通信的基本流程:
服务端创建ServerSocket,绑定端口,调用accept()阻塞等待客户端连接
客户端创建Socket,指定服务端IP和端口,发起连接请求
连接建立成功后,服务端accept()返回一个Socket对象
双方通过Socket获取InputStream和OutputStream,像读写文件一样收发数据
通信结束,关闭Socket释放资源
4.2 核心API介绍
ServerSocket
ServerSocket是创建TCP服务端Socket的API。
方法签名 | 说明 |
|---|---|
ServerSocket(int port) | 创建服务端Socket,绑定指定端口 |
Socket accept() | 监听端口,有客户端连接就返回Socket,否则阻塞等待 |
void close() | 关闭套接字 |
Socket
Socket就是客户端和服务端建立连接后,用来收发数据的对象。不管客户端还是服务端,连接建立后都用Socket来通信。
方法签名 | 说明 |
|---|---|
Socket(String host, int port) | 创建客户端Socket,连接指定主机的指定端口 |
InetAddress getInetAddress() | 返回连接的对端地址 |
InputStream getInputStream() | 获取输入流,用来读数据 |
OutputStream getOutputStream() | 获取输出流,用来写数据 |
注意:TCP是面向字节流的,所以我们是通过InputStream和OutputStream来读写数据,就和操作本地文件的流一模一样,非常好上手!
4.3 实战:Echo回显服务器
我们同样来写一个TCP版本的Echo回显服务器,对比一下和UDP版本有什么不同。
TCP Echo 服务端
服务端的思路:
创建ServerSocket绑定端口
调用accept()等待客户端连接
连接建立后,通过Socket获取输入输出流
循环读取请求、计算响应、返回响应
客户端断开后,关闭Socket
import java.io.*; import java.net.ServerSocket; import java.net.Socket; public class TcpEchoServer { private ServerSocket serverSocket = null; // 绑定端口号 public TcpEchoServer(int port) throws IOException { serverSocket = new ServerSocket(port); } // 启动服务器 public void start() throws IOException { System.out.println("服务器启动!"); while (true) { // 等待客户端连接 Socket clientSocket = serverSocket.accept(); processConnection(clientSocket); } } // 处理一个连接的逻辑 private void processConnection(Socket clientSocket) { System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort()); // 获取输入输出流 try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { // 一次连接中可能有多次请求响应 while (true) { // 1. 读取请求,用Scanner方便读取文本 Scanner scanner = new Scanner(inputStream); if (!scanner.hasNext()) { // 读取完毕,客户端下线 System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort()); break; } String request = scanner.next(); // 2. 根据请求计算响应 String response = process(request); // 3. 把响应写回客户端,用PrintWriter方便写文本 PrintWriter writer = new PrintWriter(outputStream); // 用println带上换行,方便客户端scanner读取 writer.println(response); writer.flush(); // 刷新缓冲区,确保数据真的发出去 // 打印日志 System.out.printf("[%s:%d] req: %s, resp: %s\n", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); } } catch (IOException e) { e.printStackTrace(); } finally { // 确保关闭socket释放资源 try { clientSocket.close(); } catch (IOException e) { e.printStackTrace(); } } } public String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(9090); server.start(); } }TCP Echo 客户端
import java.io.*; import java.net.Socket; import java.util.Scanner; public class TcpEchoClient { private Socket socket = null; // 创建Socket时就会建立TCP连接 public TcpEchoClient(String serverIp, int serverPort) throws IOException { socket = new Socket(serverIp, serverPort); } public void start() { System.out.println("客户端启动"); Scanner scannerConsole = new Scanner(System.in); try (InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()) { while (true) { // 1. 从控制台输入 System.out.print("-> "); String request = scannerConsole.next(); // 2. 把请求发给服务器 PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(request); printWriter.flush(); // 别忘了flush! // 3. 读取服务器响应 Scanner scannerNetwork = new Scanner(inputStream); String response = scannerNetwork.next(); // 4. 打印结果 System.out.println(response); } } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090); client.start(); } }注意两个坑:
1. 一定要用println而不是print,末尾带换行,这样对方的scanner.next()才能正确读取
2. 写完一定要flush()!输出流有缓冲区,不刷新数据可能还在缓冲区里没发出去
五、服务器性能优化
5.1 为什么需要多线程?
刚才写的TCP服务器有个大问题:单线程的,一次只能处理一个客户端连接。
因为accept()接收到一个连接后,就进入processConnection()方法循环处理这个客户端的请求了。在这个客户端断开之前,accept()不会再被调用,新的客户端就连不上了。
这肯定不行,真实的服务器要同时服务成百上千个客户端。怎么解决?很简单,每个客户端连接分配一个线程,让它们并发处理。
5.2 多线程版本服务器
改造很简单,只需要修改start方法,每次accept到一个连接,就开一个新线程去处理:
public void start() throws IOException { System.out.println("服务器启动!"); while (true) { Socket clientSocket = serverSocket.accept(); // 每个客户端连接开一个线程处理 Thread t = new Thread(() -> { processConnection(clientSocket); }); t.start(); } }就改这么几行,服务器就能同时处理多个客户端了!主线程只负责接客(accept),接到了就交给子线程去服务,主线程继续接下一个。
5.3 线程池版本服务器
多线程版本虽然能用,但如果客户端特别多,频繁创建销毁线程开销很大。这时候就可以用线程池来优化,复用线程减少开销。
public void start() throws IOException { System.out.println("服务器启动!"); // 创建一个缓存线程池 ExecutorService service = Executors.newCachedThreadPool(); while (true) { Socket clientSocket = serverSocket.accept(); // 提交任务到线程池 service.submit(new Runnable() { @Override public void run() { processConnection(clientSocket); } }); } }小知识:newCachedThreadPool会自动创建线程,空闲线程会被回收,适合短任务多的场景。如果是长连接场景,可能需要自定义线程池参数更合适。
六、长短连接详解
TCP是有连接的,那连接什么时候关呢?这就引出了短连接和长连接的概念。
短连接
每次收发完一次数据就关闭连接,就是短连接。也就是说,短连接一次只能收发一次数据。下次要发数据,得重新建立连接。
长连接
不关闭连接,一直保持连接状态,双方不停的收发数据,就是长连接。长连接可以多次收发数据。
两者对比
对比项 | 短连接 | 长连接 |
|---|---|---|
建连关连开销 | 每次请求都要建连关连,开销大 | 只建一次连接,后续直接传输,开销小 |
主动发请求 | 一般是客户端主动发 | 客户端和服务端都可以主动发 |
适用场景 | 请求频率不高,如浏览网页 | 通信频繁,如聊天室、实时游戏 |
扩展了解:我们上面写的Echo服务器用的就是长连接模式,客户端连上后可以一直发消息。而HTTP协议早期用的是短连接,现在HTTP/1.1默认也是长连接了。
另外,基于BIO(同步阻塞IO)的长连接每个连接占一个线程,并发高了扛不住。真正的高并发服务一般用NIO(同步非阻塞IO)来实现长连接,性能会好很多。
七、常见坑点:端口被占用怎么办?
初学Socket编程,最容易遇到的报错就是端口被占用。如果你看到这个异常:
java.net.BindException: Address already in use: JVM_Bind不用慌,就是你要绑定的端口已经被别的进程占了。一个端口同一时间只能被一个进程绑定。
怎么查哪个进程占了端口?
Windows下打开cmd,输入命令:
netstat -ano | findstr 8888会输出类似这样的结果,最后一列就是进程PID:
TCP 0.0.0.0:8888 0.0.0.0:0 LISTENING 4588
然后打开任务管理器,在详细信息里按PID排序,找到PID为4588的进程,就知道是谁占了端口。
怎么解决?
占端口的进程不需要了:直接结束那个进程,再启动你的程序就行
占端口的进程还要用:改你自己程序的端口号,换一个没被占用的端口
小技巧:一般开发调试用8080、9090、8888这些端口比较多,被占用了就换个数字试试,比如9091、9092。
最后,我们来梳理一下这篇文章的核心知识点,帮大家巩固记忆:
网络编程基础:网络编程就是不同主机的进程通过网络传输数据。核心概念有发送端/接收端、请求/响应、客户端/服务端。
Socket套接字:操作系统提供的网络通信API,主要分两类:
UDP数据报套接字:无连接、不可靠、面向数据报、速度快
TCP流套接字:有连接、可靠传输、面向字节流、稳定可靠
UDP编程:核心类是DatagramSocket和DatagramPacket。服务端循环receive接收请求→process处理→send返回响应。
TCP编程:核心类是ServerSocket和Socket。服务端accept等待连接→建立连接后通过InputStream/OutputStream读写数据,和操作文件流一模一样。
服务器优化:单线程服务器一次只能处理一个客户端,用多线程或线程池可以实现并发处理。
长短连接:短连接一次请求一关连接,长连接保持连接多次通信。请求频率低用短连接,通信频繁用长连接。
常见坑:端口被占用报BindException,用netstat查PID结束进程或换端口。
网络编程是Java后端的基础中的基础,不管是做Web开发、微服务还是分布式系统,底层都离不开Socket。建议大家把文中的代码都亲手敲一遍跑起来,真正理解数据是怎么收发的。
如果这篇文章对你有帮助,欢迎点赞收藏关注,后续还会更新更多网络编程进阶内容!