网络编程
- 前言
- [前置认知:Socket 与 TCP、UDP核心区别](#前置认知:Socket 与 TCP、UDP核心区别)
-
- 什么是Socket
- [TCP 与 UDP 四大核心差异](#TCP 与 UDP 四大核心差异)
- [UDP Socket](#UDP Socket)
-
- DatagramSocket
- DatagramPacket
- [基础:UDP 回显服务器(Echo Sever)](#基础:UDP 回显服务器(Echo Sever))
-
- [UDP 服务端父类 UdpEchoServer](#UDP 服务端父类 UdpEchoServer)
- [UDP 客户端 UdpEchoClient](#UDP 客户端 UdpEchoClient)
- [扩展:继承实现 UDP 词典翻译服务器](#扩展:继承实现 UDP 词典翻译服务器)
- [TCP Socket](#TCP Socket)
-
- ServerSocket
- Socket
- [单线程 TCP 回显服务器](#单线程 TCP 回显服务器)
-
- [TCP 服务端 TcpEchoServer](#TCP 服务端 TcpEchoServer)
- [TCP 客户端 TcpEchoClient](#TCP 客户端 TcpEchoClient)
- [单线程 TCP 服务器致命缺陷:无法并发处理多客户端](#单线程 TCP 服务器致命缺陷:无法并发处理多客户端)
- [优化方案一:多线程 TCP 服务器](#优化方案一:多线程 TCP 服务器)
- [优化方案二:线程池 TCP 服务器](#优化方案二:线程池 TCP 服务器)
- 总结
前言
上一篇我们完整梳理了计算机网络底层理论:局域网与广域网组网、IP 与端口、TCP/IP 五层模型、数据封装与解封装全过程。本篇正式落地 Java 网络编程核心 ------Socket API,手把手实现两套完整通信程序:
- UDP 无连接通信:基础回显服务器、继承扩展英文词典翻译服务器;
- TCP 面向连接通信:单线程阻塞缺陷 → 多线程并发改造 → 线程池最终优化。
同时详解 TCP 与 UDP 四大核心区别、Socket 本质、文件描述符泄漏、并发阻塞经典问题,是 Java 网络编程入门必练代码。
前置认知:Socket 与 TCP、UDP核心区别
什么是Socket
Socket 是操作系统提供给应用层的网络编程API,本质是操作系统中一个特殊文件,将网卡硬件抽象成文件:
向 Socket 文件写入数据 = 通过网卡发送网络数据包
从 Socket 文件读取数据 = 通过网卡接收网卡数据包
Java 中分为两套API,分别对应传输层两大协议:UDP:DatagramSocket(收发载体)、DatagramPacket(UDP数据包);
TCP:ServerSocket(服务器监听端口)、Socket(客户端连接、数据读写)。
网络通信数据的基本单位:Datagram 数据报、Packet 数据包、Frame 数据帧、Segment 数据段。
TCP 与 UDP 四大核心差异
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接特性 | 有连接,通信前必须建立连接(三次握手) | 无连接,直接发送数据包,不保存对方信息 |
| 传输可靠性 | 可靠传输 | 不可靠传输 |
| 传输单位 | 面向字节流,以字节为单位流式传输 | 面向数据报,以完整 DatagramPacket 数据包为单位传输 |
| 通信模式 | 全双工,同一个连接可同时收发 | 全双工,同一个 Socket 可同时收发 |
注意 :可靠传输要付出代价,TCP协议实现复杂,传输效率更低;UDP开销极小,传输速度更快。
对于应用程序:
- 客户端:发起"建立连接"的动作。
- 服务器:把建好的连接从内核中拿到应用程序中。
UDP Socket
UDP 特点:无连接、每次发送数据包必须在 DatagramPacket 中携带目的IP+端口;服务器固定端口,客户端由系统随机分配端口。
DatagramSocket
使用 DatagramSocket 这个类来表示系统内部的Socket文件。
void receive(DatagramPacket p):接收
void send(DatagramPacket p):发送
void close()
DatagramPacket
使用 DatagramPacket 这个类来表示一个UDP数据报。
UDP是面向数据报的,每次进行传输,都要以UDP数据报(DatagramPacket)为基本单位。
基础:UDP 回显服务器(Echo Sever)
功能:客户端控制台输入字符串,服务器原样返回。
UDP 服务端父类 UdpEchoServer
java
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
//服务器
public class UdpEchoServer {
//创建一个 DatagramSocket 对象,后续操作网卡的基础
private DatagramSocket socket=null;
public UdpEchoServer(int port) throws SocketException {
socket=new DatagramSocket(port);
}
/* 服务器和客户端都需要创建 Socket 对象
1. 服务器的Socket一般要显示指定一个端口号,此时Socket对象就能绑定到这个指定的端口;
2. 客户端的Socket一般不能显示指定,此时系统会自动分配一个随机的端口。
3. 服务器手动指定端口不会冲突,因为一个服务器上都有哪些程序,使用哪些端口,都是可控的。但客户端不可控。
* */
public void start(){ //通过这个方法来启动服务器
System.out.println("UDP回显服务器启动");
//服务器7*24运行,死循环持续处理请求
while(true){
//1.准备空缓冲区,接收客户端数据包---读取请求并解析
byte[] buffer=new byte[4096];
DatagramPacket requestPacket =new DatagramPacket(buffer,buffer.length);
//阻塞等待客户端请求,没有数据则线程挂起
try {
socket.receive(requestPacket);//由receive内部对数据进行填充,数据以二进制形式存储到DatagramPacket中
//2.解析数据包为字符串,必须用实际收到长度getLength()
String request=new String(requestPacket.getData(),0,requestPacket.getLength());
//3.业务处理:回显逻辑交给process方法,支持子类重写(一个服务器的核心步骤)
String response=process(request);
//4.把响应写回客户端:构造响应数据包,携带客户端IP+端口
DatagramPacket responsePacket=new DatagramPacket(response.getBytes(),
response.getBytes().length,requestPacket.getSocketAddress());
//UDP 无连接,自身不会保存数据要发送给谁,就需要每次发送时,重新指定数据要发到哪里去
socket.send(responsePacket);
//打印通信日志
System.out.printf("[%s:%d] req=%s, resp=%s%n",
requestPacket.getAddress(), requestPacket.getPort(), request, response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
public String process(String s){
return s;
}
public static void main(String[] args) throws SocketException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
思考:
- Socket 也是文件,为什么不写close也没出现文件泄露?
因为 Socket 是文件描述符表的一个表项,每次打开一个文件,就会占用一个位置,文件描述符在PCB上(跟随进程),这个 Socket 在整个程序运行过程中都是需要使用的,当它不再使用时就意味着程序要结束了,进程结束,随之文件描述符表就会销毁,被系统自动回收。 - 什么时候才会出现泄漏?
代码中频繁地打开文件,但不关闭,在一个进程的运行过程中,不断积累打开的文件,逐渐消耗掉文件描述符表中的内容,最终消耗殆尽。若进程的生命周期很短则谈不上泄露。
UDP 客户端 UdpEchoClient
java
package network;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket=null;
private String serverIp=" ";
private int serverPort=0;
public UdpEchoClient(String ip,int port) throws SocketException {
//客户端不能手动指定端口,系统自动分配随机空闲端口
socket=new DatagramSocket();
serverIp=ip;
serverPort=port;
}
public void start(){
System.out.println("UDP客户端启动");
Scanner scanner=new Scanner(System.in);
while(true){
//1.从控制台读取数据,作为请求
System.out.print("-> ");
String request=scanner.next();
try {
//2.构造请求数据包,指定服务器IP+端口
DatagramPacket requestPacket=new DatagramPacket(request.getBytes(),
request.getBytes().length, InetAddress.getByName(serverIp),serverPort);
socket.send(requestPacket);
//3.接收服务器响应
byte[] buffer=new byte[4096];
DatagramPacket responsePacket=new DatagramPacket(buffer,buffer.length);
socket.receive(responsePacket);
//4.解析并打印响应
String response=new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws SocketException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
扩展:继承实现 UDP 词典翻译服务器
基于父类UdpEchoServer,仅重写 process 方法,通过 HashMap 实现英译汉字典查询。
java
package network;
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("dog","小狗");
dict.put("cat","小猫");
dict.put("pig","小猪");
}
//重写process方法
@Override
public String process(String s) {
return dict.getOrDefault(s,"词典中不存在该词语");
}
public static void main(String[] args) throws SocketException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
TCP Socket
TCP 是面向连接、面向字节流协议,传输的基本单位是字节。
ServerSocket
给服务器使用的类,使用这个类来绑定端口号。
Socket
既会给服务器使用,也会给客户端使用。
单线程 TCP 回显服务器
TCP 服务端 TcpEchoServer
java
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;
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(), clientSocket.getPort());
try(InputStream inputStream= clientSocket.getInputStream();
OutputStream outputStream= clientSocket.getOutputStream()) {
/* InputStream 和 OutputStream 就是字节流
通过 InputStream 进行 read 操作---接收
通过 OutputStream 进行 write 操作---发送
* */
Scanner scanner=new Scanner(inputStream);
PrintWriter printWriter=new PrintWriter(outputStream);
while(true){
if(!scanner.hasNext()){
//没有数据/连接断开,此时循环结束
System.out.printf("[%s:%d] 客户端下线%n",
clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
//1.读取请求(以空白符、换行作为分隔)
String request=scanner.next();
//2.根据请求,计算响应
String response=process(request);
//3.把响应写回到客户端
printWriter.println(response);
//刷新缓冲区,强制把内存数据写入网卡
printWriter.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 {
// 确保连接断开时关闭Socket,释放文件描述符
// 这里需要close是因为当前方法processConnection就是在处理一个连接,当该方法结束时,连接要断开;clientSocket在循环中,每次有一个新的客户端进来建立连接,都会创建出新的clientSocket
//DatagramSocket,ServerSocket在程序中只有一个对象,不close也没事
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 客户端 TcpEchoClient
java
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;
//在创建Socket的同时,和服务器建立连接,若连接建立失败,则对象new失败
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
// new Socket时,内核自动发起三次握手建立TCP连接
socket=new Socket(serverIp,serverPort);
}
public void start(){
Scanner scanner=new Scanner(System.in);
try(InputStream inputStream= socket.getInputStream();
OutputStream outputStream= socket.getOutputStream()){
PrintWriter printWriter=new PrintWriter(outputStream);
Scanner scannerNextwork=new Scanner(inputStream);
while(true){
System.out.print("-> ");
String request=scanner.next();
//发送请求
printWriter.println(request);
printWriter.flush();
//读取响应
String response=scannerNextwork.next();
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 服务器致命缺陷:无法并发处理多客户端
单线程服务器start()方法只有一个主线程:
- 主线程调用accept()拿到第一个客户端连接后,进入processConnection内部死循环;
- 如果第一个客户端一直不关闭连接、持续占用循环,主线程会阻塞在scanner.hasNext();
- 第二个客户端可以和服务器建立 TCP 连接(内核完成三次握手,与代码无关),连接会存入内核等待队列,但应用层永远无法调用accept()取出连接,第二个客户端完全无法通信。
优化方案一:多线程 TCP 服务器
每接收到一个客户端连接,创建一个独立新线程处理通信逻辑,主线程立刻回到accept()继续监听新连接,实现并发。
修改start():
java
public void start() throws IOException {
System.out.println("TCP多线程服务器启动");
while (true) {
Socket clientSocket = serverSocket.accept();
// 新建线程处理连接,主线程非阻塞立刻循环下一次accept
new Thread(() -> processConnection(clientSocket)).start();
}
}
缺点:高并发场景下,频繁创建、销毁线程会带来巨大开销,极端情况引发服务器卡顿。
因此可以使用线程池优化。
优化方案二:线程池 TCP 服务器
使用CachedThreadPool缓存线程,复用空闲线程,避免频繁创建销毁线程,是生产环境基础方案。
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public void start() throws IOException {
System.out.println("TCP线程池服务器启动");
// 可缓存线程池,自动扩容、复用空闲线程
ExecutorService pool = Executors.newCachedThreadPool();
while (true) {
Socket clientSocket = serverSocket.accept();
// 提交任务到线程池
pool.submit(() -> processConnection(clientSocket));
}
}
总结
- UDP 数据包解析必须使用getLength():不能用getBytes().length,字节数组缓冲区有多余空位,getLength()代表本次实际接收的字节长度;
- 字节与字符串长度区分:str.length()是字符个数,str.getBytes()是字节个数,包含中文时二者不一致,网络传输必须统一使用字节;
- TCP PrintWriter 必须 flush ():IO 缓冲区会积攒数据,不刷新缓冲区,数据不会真正发送到网卡;
- TCP ClientSocket 必须 finally 关闭:循环创建客户端连接,不手动关闭会持续占用文件描述符,引发泄漏;
- 单线程 TCP 天然不支持并发:外层 accept 循环和内层读写循环在同一个线程,必须通过多线程 / 线程池解耦;
- UDP 无连接:数据包必须携带完整目的地址;TCP 面向连接:连接建立后全程只需要读写流,无需重复携带地址。
本篇是「Java并发编程系列」的连载内容,点击链接查看完整系列: