网络(二)|Java Socket 编程全实战:UDP 回显 / 词典服务器、TCP 单线程→多线程线程池演进

网络编程

  • 前言
  • [前置认知: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,手把手实现两套完整通信程序:

  1. UDP 无连接通信:基础回显服务器、继承扩展英文词典翻译服务器;
  2. TCP 面向连接通信:单线程阻塞缺陷 → 多线程并发改造 → 线程池最终优化。

同时详解 TCP 与 UDP 四大核心区别、Socket 本质、文件描述符泄漏、并发阻塞经典问题,是 Java 网络编程入门必练代码。

前置认知:Socket 与 TCP、UDP核心区别

什么是Socket

Socket 是操作系统提供给应用层的网络编程API,本质是操作系统中一个特殊文件,将网卡硬件抽象成文件:

  1. 向 Socket 文件写入数据 = 通过网卡发送网络数据包

  2. 从 Socket 文件读取数据 = 通过网卡接收网卡数据包
    Java 中分为两套API,分别对应传输层两大协议:

  3. UDP:DatagramSocket(收发载体)、DatagramPacket(UDP数据包);

  4. TCP:ServerSocket(服务器监听端口)、Socket(客户端连接、数据读写)。

网络通信数据的基本单位:Datagram 数据报、Packet 数据包、Frame 数据帧、Segment 数据段。

TCP 与 UDP 四大核心差异

对比维度 TCP UDP
连接特性 有连接,通信前必须建立连接(三次握手) 无连接,直接发送数据包,不保存对方信息
传输可靠性 可靠传输 不可靠传输
传输单位 面向字节流,以字节为单位流式传输 面向数据报,以完整 DatagramPacket 数据包为单位传输
通信模式 全双工,同一个连接可同时收发 全双工,同一个 Socket 可同时收发

注意 :可靠传输要付出代价,TCP协议实现复杂,传输效率更低;UDP开销极小,传输速度更快。

对于应用程序:

  1. 客户端:发起"建立连接"的动作。
  2. 服务器:把建好的连接从内核中拿到应用程序中。

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();
    }
}

思考:

  1. Socket 也是文件,为什么不写close也没出现文件泄露?
    因为 Socket 是文件描述符表的一个表项,每次打开一个文件,就会占用一个位置,文件描述符在PCB上(跟随进程),这个 Socket 在整个程序运行过程中都是需要使用的,当它不再使用时就意味着程序要结束了,进程结束,随之文件描述符表就会销毁,被系统自动回收。
  2. 什么时候才会出现泄漏?
    代码中频繁地打开文件,但不关闭,在一个进程的运行过程中,不断积累打开的文件,逐渐消耗掉文件描述符表中的内容,最终消耗殆尽。若进程的生命周期很短则谈不上泄露。

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()方法只有一个主线程:

  1. 主线程调用accept()拿到第一个客户端连接后,进入processConnection内部死循环;
  2. 如果第一个客户端一直不关闭连接、持续占用循环,主线程会阻塞在scanner.hasNext();
  3. 第二个客户端可以和服务器建立 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));
    }
}

总结

  1. UDP 数据包解析必须使用getLength():不能用getBytes().length,字节数组缓冲区有多余空位,getLength()代表本次实际接收的字节长度;
  2. 字节与字符串长度区分:str.length()是字符个数,str.getBytes()是字节个数,包含中文时二者不一致,网络传输必须统一使用字节;
  3. TCP PrintWriter 必须 flush ():IO 缓冲区会积攒数据,不刷新缓冲区,数据不会真正发送到网卡;
  4. TCP ClientSocket 必须 finally 关闭:循环创建客户端连接,不手动关闭会持续占用文件描述符,引发泄漏;
  5. 单线程 TCP 天然不支持并发:外层 accept 循环和内层读写循环在同一个线程,必须通过多线程 / 线程池解耦;
  6. UDP 无连接:数据包必须携带完整目的地址;TCP 面向连接:连接建立后全程只需要读写流,无需重复携带地址。
    本篇是「Java并发编程系列」的连载内容,点击链接查看完整系列:

🔹 上一篇:网络初识|基础入门:局域网广域网、IP 端口、TCP/IP 五层模型与封装解封装全过程

👉 点击直达「Java后端基础专栏合集

相关推荐
minglie11 小时前
zynq中axi_dma的三个实验-第1课:PL搭建
学习
小二·1 小时前
WebGPU 浏览器端跑大模型:让AI在网页里跑起来(WebLLM/Transformers.js实战)
开发语言·javascript·人工智能
一路向北North1 小时前
Spring Security OAuth2.0(22):分布式系统授权-搭建网关
java·后端·spring
小猴子爱上树1 小时前
Temu批量视频翻译Python实现方案
开发语言·python·音视频
leagsoft_10032 小时前
从“策略看不清”到“风险可收敛”:某高端精密制造企业的网络安全策略治理实践
网络·web安全·制造
存在的五月雨2 小时前
SpringBoot后端限制重复提交
java·spring boot·后端
xianrenli382 小时前
Android14自动时区更新
android·学习·framework·aosp14·自动时区
IDIOSF2 小时前
R语言 下载安装教程(附安装包)R语言4.5安装步骤(保姆级)
开发语言·其他·r语言
乱七八糟的屋子2 小时前
LibYAML超全教程-C语言YAML解析与生成
c语言·开发语言·配置文件·yaml解析·libyaml·开源库实战
Sam_Deep_Thinking2 小时前
一次线程池线上故障复盘:四层防线如何避免数据丢失
java·面试·程序员