Java Socket网络编程|TCP与UDP深度梳理

一、传输层两大协议:TCP vs UDP

传输层负责端到端的数据传输,TCP与UDP是最核心的两个协议,二者设计思想完全不同。

对比维度 TCP UDP
连接模式 面向连接,通信前建立连接(三次握手),通信结束释放连接 无连接,不需要建立连接,直接发送数据包
可靠性 可靠传输:确认应答、丢包重传、超时重传、流量控制,保证数据完整有序到达 不可靠传输:不保证送达、不保证顺序,存在丢包、乱序风险
数据载体 字节流,没有报文边界 数据报(Datagram),每个数据包是独立完整报文,有边界
传输效率 开销大,速度相对慢 开销极小,传输速度快
通信模式 全双工,双方可以同时收发数据 全双工
典型场景 文件上传下载、网页浏览,对数据完整性要求高 直播、语音通话、游戏,允许少量丢包,追求低延迟

通俗类比: TCP=打电话:拨号建立连接,通话,挂断;必须接通才能说话,说话内容不会丢失错乱。 UDP=寄明信片:写好直接扔出去,不确认对方是否收到,速度快,但明信片可能丢失、顺序错乱。

补充概念

  1. 全双工 :通信双方同一时刻既能发也能收;半双工:同一时刻只能一方发送一方接收。TCP为全双工通信。
  2. IP地址 :定位网络中的一台主机;端口号 :定位主机上运行的进程,取值0‑655351‑1023为系统预留端口,开发自定义使用1024以上端口。
  3. 回环地址127.0.0.1:仅本机内部访问,不会经过网卡;跨机器通信,需要填写本机局域网公网IP,注意防火墙放行端口。

二、Socket基础概念

Socket(套接字),是应用层和传输层之间的编程接口,程序通过Socket完成网络数据收发。 Java针对两个协议提供两套API:

  • TCP:ServerSocket(服务端) + Socket(客户端),依靠IO流完成数据读写
  • UDP:DatagramSocket(收发工具,客户端服务端通用) + DatagramPacket(数据包容器)

核心:Socket只是接口,真正干活的是操作系统内核。

三、UDP编程详解

UDP没有连接,没有握手,所有数据封装为DatagramPacket数据包,DatagramSocket负责发送接收数据包。

核心API说明

  1. DatagramSocket:UDP套接字
    • 服务端:创建时绑定固定端口,等待接收数据包
    • 客户端:可以不指定端口,操作系统自动分配随机端口
    • receive(DatagramPacket p)阻塞方法,没有数据包就一直等待
    • send(DatagramPacket p):发送数据包
  2. DatagramPacket:数据包对象
    • 接收包:只提供字节缓冲区,等待socket把收到的数据填充进来
    • 发送包:必须传入数据字节数组、目标IP、目标端口
    • getAddress() / getPort():获取数据包发送方的IP与端口,用于回复数据
    • getLength():获取本次收到数据的真实字节长度

UDP Echo回显服务

功能:客户端发送消息,服务端收到后原样返回给客户端

UDP服务端

java 复制代码
package network;

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 表示一个 UDP 数据报. 此处传入的字节数组, 就保存 UDP 的载荷部分.
            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. 把响应返回给客户端
            //    根据 response 构造 DatagramPacket, 发送给客户端.
            //    此处不能使用 response.length()
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
                    requestPacket.getSocketAddress());
            //    此处还不能直接发送. UDP 协议自身没有保存对方的信息(不知道发给谁)
            //    需要指定 目的 ip 和 目的端口.
            socket.send(responsePacket);

            // 4. 打印一个日志
            System.out.printf("[%s:%d] req: %s, resp: %s\n", requestPacket.getAddress().toString(), requestPacket.getPort(),
                    request, response);
        }
    }

    // 后续如果要写别的服务器, 只修改这个地方就好了.
    // 不要忘记, private 方法不能被重写. 需要改成 public
    public String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();
    }
}

UDP客户端

java 复制代码
package network;

import java.io.IOException;
import java.net.*;
import java.util.Scanner;

public class UdpEchoClient {
    private DatagramSocket socket = null;

    // UDP 本身不保存对端的信息, 就自己的代码中保存一下
    private String serverIp;
    private int serverPort;

    // 和服务器不同, 此处的构造方法是要指定访问的服务器的地址.
    public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
        this.serverIp = serverIp;
        this.serverPort = serverPort;
        socket = new DatagramSocket();
    }

    public void start() throws IOException {
        Scanner scanner = new Scanner(System.in);
        while (true) {
            // 1. 从控制台读取用户输入的内容.
            System.out.println("请输入要发送的内容:");
            if (!scanner.hasNext()) {
                break;
            }
            String request = scanner.next();
            // 2. 把请求发送给服务器, 需要构造 DatagramPacket 对象.
            //    构造过程中, 不光要构造载荷, 还要设置服务器的 IP 和端口号
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(serverIp), serverPort);
            // 3. 发送数据报
            socket.send(requestPacket);
            // 4. 接收服务器的响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket);
            // 5. 从服务器读取的数据进行解析, 打印出来.
            String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
            System.out.println(response);
        }
    }

    public static void main(String[] args) throws IOException {
        UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}

UDP易错点

  1. receive()阻塞,没有数据就卡住程序。
  2. 读取数据必须使用getLength(),不能直接new String(buffer),buffer数组后面全是空白垃圾数据。
  3. 接收缓冲区数组大小有限,数据包大于缓冲区会发生截断丢数据。
  4. UDP无连接:服务端没有accept();客户端不需要显式建立连接。
  5. 想要回复客户端,IP和端口全部来自收到的DatagramPacket对象。

四、TCP编程详解

TCP面向连接;服务端ServerSocket监听端口,调用accept()阻塞等待客户端连接;客户端Socket主动发起连接。通信依靠InputStreamOutputStream字节流完成。

核心API说明

  1. ServerSocket(int port):TCP服务端,绑定监听端口
    • accept()阻塞方法 ,等待客户端连接;连接成功返回一个和客户端通信的Socket对象。
  2. Socket:TCP通信套接字对象
    • getInputStream():获取输入流,读取对方发来数据
    • getOutputStream():获取输出流,向对方发送数据
    • getRemoteSocketAddress():获取对端地址信息

重点:ServerSocket只负责接受连接;真正收发数据,靠accept()返回出来的Socket对象。

TCP多线程服务端

java 复制代码
package network;

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;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TcpEchoServer {
    private ServerSocket serverSocket = null;

    // 这里和 UDP 服务器类似, 也是在构造对象的时候, 绑定端口号.
    public TcpEchoServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("启动服务器");

        // 这种情况一般不会使用 fixedThreadPool, 意味着同时处理的客户端连接数目就固定了.
        ExecutorService executorService = Executors.newCachedThreadPool();

        while (true) {
            // tcp 来说, 需要先处理客户端发来的连接.
            // 通过读写 clientSocket, 和客户端进行通信.
            // 如果没有客户端发起连接, 此时 accept 就会阻塞.

            // 主线程负责进行 accept, 每次 accept 到一个客户端, 就创建一个线程, 由新线程负责处理客户端的请求.
            Socket clientSocket = serverSocket.accept();

            // 使用多线程的方式来调整
//            Thread t = new Thread(() -> {
//                processConnection(clientSocket);
//            });
//            t.start();

            // 使用线程池来调整
            executorService.submit(() -> {
                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 套了一层
            Scanner scanner = new Scanner(inputStream);
            // 针对 OutputStream 套了一层
            PrintWriter writer = new PrintWriter(outputStream);
            // 分成三个步骤
            while (true) {
                // 1. 读取请求并解析. 可以直接 read, 也可以借助 Scanner 来辅助完成.
                if (!scanner.hasNext()) {
                    // 连接断开了
                    System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }
                String request = scanner.next();
                // 2. 根据请求计算响应
                String response = process(request);
                // 3. 返回响应到客户端
                // outputStream.write(response.getBytes());
                writer.println(response);
                writer.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 {
            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

    private String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        TcpEchoServer server = new TcpEchoServer(9090);
        server.start();
    }
}

TCP客户端

java 复制代码
package network;

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;

    public TcpEchoClient(String serverIp, int serverPort) throws IOException {
        // 直接把字符串的 IP 地址, 设置进来.
        // 127.0.0.1 这种字符串
        socket = new Socket(serverIp, serverPort);
    }

    public void start() {
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {
            // 为了使用方便, 套壳操作
            Scanner scannerNet = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);

            // 从控制台读取请求, 发送给服务器.
            while (true) {
                // 1. 从控制台读取用户输入
                String request = scanner.next();
                // 2. 发送给服务器
                writer.println(request);
                //    加上刷新缓冲区操作, 才是真正发送数据
                writer.flush();
                // 3. 读取服务器返回的响应.
                String response = scannerNet.next();
                // 4. 打印到控制台
                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易错点

  1. TCP是字节流无边界:多次发送的数据内核缓冲区会合并,读的时候分不清消息边界,业务层需要自定义协议(分隔符、长度前缀)切分报文。
  2. accept()和读写 clientSocket都会阻塞。单线程无法并发服务多客户端,必须引入多线程。
  3. 资源关闭:Socket、IO流需要关闭释放端口;程序异常崩溃可能端口残留占用。
  4. 乱码问题:收发两端字符串编码格式必须统一。

五、TCP与UDP共性总结

  1. 阻塞问题accept()socket.receive()、IO流read()全部会阻塞线程。并发场景需要多线程,后续进阶NIO非阻塞IO。
  2. 端口占用异常:端口被别的进程占用,启动直接报错;测试注意关闭旧程序释放端口。
  3. 本机测试与跨机器测试127.0.0.1只能本机访问;跨机器要填写真实局域网IP,防火墙放行端口。
  4. 数据读取长度陷阱 :UDP必须使用getLength();TCP字节流注意区分读到的有效字节长度和缓冲区数组总长度。
  5. TCP字节流无报文边界;UDP数据包自带边界,这是二者编程最大区别。
  6. 异常处理:网络属于IO操作,大量抛出受检异常,生产环境需要完善异常捕获。

六、总结

  1. TCP面向连接、可靠、字节流;UDP无连接、不可靠、数据报,选型看业务场景。
  2. Java Socket本质封装操作系统内核网络能力,TCP靠IO流通信;UDP靠数据包通信。
  3. 默认BIO阻塞模型:想要并发处理多个客户端,TCP需要多线程。
  4. BIO是入门基础,生产环境会进一步学习NIO非阻塞网络编程。
相关推荐
至为芯1 小时前
PY32F002B至为芯集成32位ARM内核的低成本MCU单片机
单片机·集成电路·芯片
亮工硬件1 小时前
1-8 简易串口通讯协议(UART + 自定义帧协议)
笔记·stm32·单片机·嵌入式硬件·学习
三佛科技-187366133972 小时前
小家电/玩具MCU选型指南,国产低成本MCU替代之选
单片机·嵌入式硬件
花 满 楼2 小时前
TPWM — PWM 测量模式(TIM PWM Measurement Mode)
单片机·嵌入式硬件·fpga开发
周洲083012 小时前
STM32 最简工程搭建|标准库/HAL库规范化模板,零冗余可直接落地
stm32·单片机·嵌入式硬件
ACP广源盛1392462567313 小时前
M6/M5 Pro Mac mini 端侧 AI 落地@ACP#YLB3116 中端多盘存储扩展在 AI 服务中的机会与应用场景
大数据·网络·数据库·人工智能·嵌入式硬件·macos
ACP广源盛1392462567314 小时前
M6/M5 Pro Mac mini 端侧 AI 新形态@ACP#GSV5800 Serdes 长距离视频传输在 AI 服务中的机会与落地场景
大数据·网络·数据库·人工智能·嵌入式硬件·macos·音视频
OIDCAT15 小时前
国产USB转千兆网卡芯片CH398-RTL8153国产替代实测与选型
网络·嵌入式硬件·智能硬件·国产芯片·usb转千兆网卡
HRTOS16 小时前
HRTOS 4.0 驱动库:06_Storage 存储设备驱动——24C02 EEPROM与I²C驱动开发
c语言·驱动开发·嵌入式硬件·51单片机