首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >JavaEE —— 网路编程 UDP TCP

JavaEE —— 网路编程 UDP TCP

作者头像
Han.miracle
发布2025-12-23 09:51:54
发布2025-12-23 09:51:54
150
举报

 UDP数据报套接字编程(User Datagram Protocol)

核心特性

说明(结合之前的 UDP 字典服务器场景)

无连接

客户端与服务器无需提前建立连接,客户端直接发送数据报(DatagramPacket),服务器通过端口监听接收,无需 “三次握手”

面向数据报

数据以独立的 “数据包” 为单位传输,每个数据包包含完整的目标地址和端口,独立处理,不合并、不拆分

不可靠传输

不保证数据到达顺序、完整性,丢失不重传、重复不丢弃、乱序不排序,依赖上层业务处理可靠性(如字典查询可容忍偶尔丢失)

低延迟、高效率

无连接建立 / 关闭、无确认重传等开销,传输速度快,适合对延迟敏感、对可靠性要求不高的场景

无流量 / 拥塞控制

发送方按自身速率发送数据,不考虑接收方处理能力和网络状态

API 介绍

DatagramSocket:

DatagramSocket 是UDP 的Socket,用于发送和接收UDP数据报。

DatagramSocket 构造方法: 就是  打开 ‘文件’

DatagramSocket 方法:receive 就是读取 ,send 就是写

DatagramPacket

DatagramPacket是UDP Socket发送和接收的数据报。

DatagramPacket表示UDP完整的数据报

DatagramPacket 构造方法:

UDP数据包的载荷数据,就饿可以通过构造方法来指定

DatagramPacket 方法:

构造UDP发送的数据报时,需要传入 SocketAddress ,该对象可以使用 InetSocketAddress 来创建。

InetSocketAddress

InetSocketAddress ( SocketAddress 的子类)构造方法:

注意:真实的服务器响应与请求是不一样的,这个是回显服务器

代码演示:
UDPSevrve实现:
核心思路:

具体实现:

  • DatagramPacket是 Java 中用于表示 UDP 数据报的类。
  • new byte[4096]:创建一个长度为 4096 字节的字节数组,用来存储接收到的 UDP 数据报的载荷部分(即实际传输的有效数据)。
  • length: 4096:指定接收数据的最大长度为 4096 字节,确保接收到的数据不会超出字节数组的容量。
将 UDP 数据报中接收到的二进制数据转换为字符串
  • requestPacket.getData():获取存储 UDP 数据报载荷的字节数组(即接收到的二进制数据)。
  • offset: 0:指定从字节数组的起始位置(第 0 个字节)开始转换。
  • requestPacket.getLength():获取实际接收到的数据长度,确保只转换有效数据部分。
打印 UDP 通信请求与响应日志
receive() 方法的 DatagramPacket 参数是一个典型的"输出型参数":

输出型参数:调用方提供一个"空容器",方法执行后这个容器被填充了数据。

为什么不用手动close 关闭socket?

 UDP 服务器中Socket对象的生命周期和资源释放:

  • Socket 对象的生命周期:在 UDP 服务器中,Socket对象会伴随服务器的整个运行过程,从服务器启动到停止,始终用于处理客户端的 UDP 数据报通信。
  • 资源释放机制:当服务器进程结束(如关闭服务器程序)时,操作系统会自动释放该进程在 PCB(进程控制块)文件描述符表中占用的所有资源,因此不需要手动调用close方法来释放Socket资源。

PCB 是 进程控制块(Process Control Block)的缩写,是操作系统用于管理进程的核心数据结构

服务器启动后,客户端还没请求时,服务器逻辑在做什么?

服务器会在 socket.receive(requestPacket) 处阻塞,直到客户端发送 UDP 请求才会继续执行。

代码演示:
代码语言:javascript
复制
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();
    }
}
UDPClient实现:

我们的数据包中,只存储了他自己的IP和端口号,所以客户端这里要记一下服务器的IP和端口号

客户端访问服务器时,IP 和端口是如何分配的?

-目的 IP:服务器的 IP(serverIP)。

-目的端口:服务器的端口(serverport)。

-源 IP:客户端所在主机的 IP。

-源端口:操作系统随机分配的空闲端口。

getByName(): 解析服务器 IP 地址
127.0.0.1(环回 IP)
服务器与客户端之间要联网么?
代码演示:
代码语言:javascript
复制
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 Dict Server 实现:

字典服务器的实现是基于UDPServer实现:

其他的都一样,接收的数据需要处理一下,需要在我们构造方法设置的字典里,寻找有没有英文对应的

只需要重写一下处理请求返回相应的数据

代码语言:javascript
复制
import java.io.IOException;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;

public class UdpDictServer extends UdpEchoServer {
    private Map<String, String> dict = new HashMap<String, String>();

    public UdpDictServer(int port) throws SocketException {
        // 调用父类构造方法这句代码, 必须放到子类构造方法的第一行
        super(port);

        dict.put("hello", "你好");
        dict.put("world", "世界");
        dict.put("cat", "小猫");
        dict.put("dog", "小狗");
        dict.put("pig", "小猪");
        // 可以添加更多更多的数据.
    }

    @Override
    public String process(String request) {
        return dict.getOrDefault(request, "没有找到该单词");
    }

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

TCP数据报套接字编程(Transmission Control Protocol)

概念

1. ServerSocket:TCP 服务端的 “大门”
  • 它是服务端的 “监听接口”,负责绑定端口、等待客户端连接
    • 用 ServerSocket(int port) 绑定端口后,就像在这个端口上 “开了一扇门”,持续监听是否有客户端来敲门。
    • 调用 accept() 方法时,服务端会 “阻塞等待”—— 直到有客户端发起连接请求,才会返回一个 Socket 对象(相当于给这个客户端 “开了一扇专属的小门”)。
2. Socket:客户端与服务端的 “专属通道”
  • 客户端创建 Socket 时,需指定服务端的 IP 和端口(相当于 “主动敲门”);
  • 服务端通过 accept() 得到的 Socket,是与该客户端的双向通信通道
    • 服务端和客户端都可以通过这个 Socket 的输入流(getInputStream())读数据,输出流(getOutputStream())写数据。

核心特性(与 UDP 对比,呼应之前的网络通信场景):

  1. 面向连接:通信前需通过 “三次握手” 建立连接,通信结束后通过 “四次挥手” 关闭连接;
  2. 可靠传输:保证数据的完整性、有序性,丢失会重传、重复会丢弃、乱序会排序;
  3. 面向字节流:数据以字节流形式传输,而非独立数据包;
  4. 有流量控制和拥塞控制:避免因发送方速率过快导致接收方处理不及,或网络拥堵;
  5. 延迟略高:因连接建立、确认等机制,传输效率低于 UDP,但可靠性更强。
三次握手:
四次挥手:

API 介绍

ServerSocket

ServerSocket 是创建TCP服务端Socket的API。

ServerSocket 构造方法:
ServerSocket 方法:

Socket

Socket 是客户端Socket,或服务端中接收到客⼾端建立连接(accept方法)的请求后,返回的服 务端Socket。 不管是客⼾端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及⽤来与对方收发数据的。

Socket 构造方法:

代码演示

TCPEchoClient:
Java 网络编程中 IO 流的 “缓冲机制”:

核心是解释 writer.println(request) 并非 “立即把数据发送到服务器”,而是经历了 “内存缓冲区暂存” 的中间步骤。

1. 代码与操作的含义
  • String request = scanner.next();:从控制台读取用户输入的字符串。
  • writer.println(request);:通过 PrintWriter(或类似输出流)尝试 “发送数据”,但这个操作并非直接把数据发送到网卡
2. 缓冲机制的底层逻辑

在 Java 的 IO 流(包括网络编程的套接字流)中,为了提升 IO 效率,会引入 “输出缓冲区”(属于内存空间的一部分):

  • 调用 writer.println(...) 时,数据会先被写入到内存中的输出缓冲区(即注释里的 “发送缓冲区”),而不是直接通过网卡发送到服务器。
  • 只有当缓冲区满了、调用 writer.flush() 方法、或者流被关闭时,缓冲区里的数据才会被 “刷出”,真正通过网卡发送到服务器。
3. 为什么要这样设计?

缓冲区是为了减少底层 IO 操作的频率。如果每次调用 println 都直接触发网卡发送,会因为频繁的硬件交互导致性能低下。通过 “先存到内存缓冲区,批量发送” 的方式,能大幅提升 IO 效率。

4. 实际开发的注意点

如果忽视这个机制,可能会出现 “数据发送延迟” 或 “数据丢失” 的问题。比如:

  • 若代码中没有调用 writer.flush(),且缓冲区没满、流也没关闭,服务器就会收不到数据;
  • 解决方法:
    • 手动调用 writer.flush(),强制把缓冲区数据刷到网卡;
    • 或在创建 PrintWriter 时开启 “自动刷新”,例如 new PrintWriter(outputStream, true)(第二个参数为 true 时,调用 println 后会自动触发 flush)。
Java 中 PrintWriter 缓冲机制与方法行为write() 与 println :
1. PrintWriter 的缓冲机制

PrintWriter 是 Java 用于字符输出的工具类,默认带有输出缓冲区(数据先存到内存缓冲区,而非直接发送到目标设备 / 网络)。只有当缓冲区满、调用 flush() 方法、或流关闭时,数据才会真正被发送。

2. println() 与 write() 的行为差异

方法

缓冲行为

触发发送的条件

println()

数据先存入缓冲区

需显式调用 flush()、缓冲区满、或流关闭;若创建 PrintWriter 时指定 autoFlush = true(如 new PrintWriter(out, true)),则 println() 后会自动触发 flush

write()

数据同样先存入缓冲区(本质与 println 无差异)

需显式调用 flush()、缓冲区满、或流关闭;write() 本身不会自动触发 flush

3. “套壳” 的含义与影响

这里的 “套壳” 可理解为 PrintWriter 的缓冲策略配置(如是否启用 autoFlush)。若未对 PrintWriter 做特殊配置(即 “不套壳”),println() 和 write() 都会依赖缓冲区逻辑;只有显式配置 autoFlush 或主动调用 flush(),才能确保数据及时发送。

实际开发注意事项
  • 若需数据 “立即发送”,需主动调用 writer.flush(),或在创建 PrintWriter 时开启自动刷新(new PrintWriter(outputStream, true))。
  • 忽视缓冲机制可能导致 “数据延迟发送” 或 “数据丢失”(如程序异常退出时缓冲区数据未被刷出)。
Java 网络编程中数据传输与Scanner读取逻辑:
一、数据发送与服务器处理的关系

当执行 writer.print(request) 时,客户端已将数据成功发送至服务器并被接收,但服务器尚未对该数据进行业务逻辑层面的处理(如回声响应、数据解析等)。

二、ScannerhasNext()/next()行为解析
注意事项:
  • 若需服务器及时处理客户端数据,需确保数据传输时包含明确的空白符(或自定义分隔符),避免因Scanner的阻塞逻辑导致处理延迟。
  • 服务器端需结合Scanner的行为,通过合理的 IO 策略(如readLine()、自定义协议解析)确保数据被完整、及时地处理。

所以这里就产生了一个问题:

代码语言:javascript
复制
wrinter.printLn();
wrinter.println服务器为什么没有处理?

Scanner 的 hasNext() 方法是 ** 基于 “分隔符” 来判定是否存在下一个 “标记(token)”** 的,默认规则如下:

1. 默认分隔符与结束条件

Scanner 默认的分隔符是空白符,包括:

  • 换行符(\n)、回车符(\r
  • 空格()、制表符(\t)、翻页符等

当 hasNext() 执行时,它会持续扫描输入,直到遇到 “空白符”,此时认为一个 “完整的标记” 存在,hasNext() 返回 true;如果输入流已经关闭且没有更多可扫描的内容,则返回 false

println我们熟知这个是有换行符打印

hasNext是识别空白符结束读取的一个函数

所以你的客户端传入的数据,犹豫print 是没有换行这个操作的,就在这里堵塞住了(仍在这里等待接下里的输入知道识别到空白符)

 服务器用 Scanner.next()/hasNext() 等依赖 “空白符(换行、空格等)” 的方式读取,而客户端 print 未发送换行符,会导致服务器读取阻塞,无法触发处理逻辑

代码语言:javascript
复制
package network;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.Scanner;

public class TcpEchoClient {
    private Socket socket = null;

    public TcpEchoClient(String serverIp, int serverPort) throws IOException {
        // 客户端在 new Socket 的时候, 就会和服务器建立 TCP 连接.
        // 此时少了服务器 IP 和 端口.
        socket = new Socket(serverIp, serverPort);
        // socket.connect(new InetSocketAddress(InetAddress.getByName(serverIp), serverPort));
    }

    public void start() {
        System.out.println("client start!");


        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {
            Scanner scanner = new Scanner(System.in);

            Scanner scannerNetwork = new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);

            while (true) {
                // 1. 从控制台读取用户输入.
                System.out.print("-> ");
                String request = scanner.next();
                // 2. 把请求发送给服务器.
                writer.println(request);
                writer.flush();
                // 3. 从服务器读取响应
                if (!scannerNetwork.hasNext()) {
                    break;
                }
                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();
    }
}
TCPEchoServer:
代码语言:javascript
复制
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;

    public TcpEchoServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("server start!");

        // 不能使用 FixedThreadPool, 线程数目固定.
        ExecutorService executorService = Executors.newCachedThreadPool();

        while (true) {
            // 首先要先接受客户端的连接, 然后才能进行通信.
            // 如果有客户端和服务器建立好了连接, accept 能够返回.
            // 否咋 accept 会阻塞.
            // [有连接]
            Socket socket = serverSocket.accept();
            // 通过这个方法处理这个客户端整个的连接过程.
            // 直接调用 processConnection, 此时就会 "顾此失彼" 一旦进入到 processConnection 方法
            // 就不能再次调用 accept
            // processConnection(socket);

            // 此处创建新线程. 在新线程里, 调用 processConnection
//            Thread t = new Thread(() -> {
//                processConnection(socket);
//            });
//            t.start();

            // 如果当前线程数目进一步增多, 创建销毁进一步频繁, 此时线程创建销毁开销不可忽视了.
            // 使用线程池, 是进一步的改进手段.
            executorService.submit(() -> {
                processConnection(socket);
            });
        }
    }

    private void processConnection(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 writer = new PrintWriter(outputStream);

            while (true) {
                // 处理多次请求/响应的读写操作.
                // 一次循环就是读写一个请求/响应
                // 1. 读取请求并解析 (可以直接使用 Scanner 完成)
                if (!scanner.hasNext()) {
                    // 客户端关闭了连接
                    System.out.printf("[%s:%d] 客户端下线!\n", socket.getInetAddress(), socket.getPort());
                    // 假设这里有一系列的逻辑呢??
                    break;
                }
                String request = scanner.next();

                // 2. 根据请求计算响应
                String response = process(request);

                // 3. 把响应写回客户端
                writer.println(response);
                writer.flush();

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

        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                // 假如在这之前也有一系列逻辑呢?
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

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

    public static void main(String[] args) throws IOException {
        TcpEchoServer server = new TcpEchoServer(9090);
        server.start();
    }
}
TCPEchoServer:
代码语言:javascript
复制
package network;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class TcpDictServer extends TcpEchoServer {
    private Map<String, String> dict = new HashMap<>();

    public TcpDictServer(int port) throws IOException {
        super(port);

        dict.put("hello", "你好");
        dict.put("world", "世界");
        dict.put("cat", "小猫");
        dict.put("dog", "小狗");
    }

    public String process(String request) {
        return dict.getOrDefault(request, "没有找到该单词");
    }

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

注意事项:

Allow multi! instances:

同时启动多个 IntelliJ IDEA 实例,方便并行处理不同项目或任务。

其核心含义是:允许同一应用程序同时启动并运行多个独立的实例。每个实例拥有各自独立的进程、内存空间和运行状态,彼此互不干扰。

作用场景:当启用该设置时,用户可多次打开同一程序(如多次启动文档编辑软件处理不同文件、同时运行多个工具类应用等);若禁用,则可能仅能运行一个实例,再次尝试启动时会激活已运行的实例而非新建。

第一步找到:

第二步点击:

第三步:

第四步:勾选上红色部分即可

Java 网络编程中出现的端口占用异常(BindException: Address already in use: bind

异常原因

该异常表明你要启动的 TcpEchoServer 试图绑定的端口,已经被其他进程(或同一程序的另一个实例)占用了。

TCP和UDP 协议不同,可以是使用同一个端口号,是不会冲突的

协议相同,就需要干掉上一个进程

注意:端口只能被一个进程绑定

勾选上这个,这样我们就可以启动多个客户端连接服务器了

新问题又来了。我这个这里的有两个客户端连接服务器,由于while 和 这里的hasNext的共同作用下,我们就会堵塞在客户端1这里,客户端2 就不会有任何的反应和日志

单线程 TCP 服务器的并发缺陷

这就是多线程的由来,所以我们就可以使用多线程去解决这个问题

1. 单线程服务器的核心流程与阻塞点

服务器代码的核心逻辑是:

  • 外层 while(true) 循环通过 serverSocket.accept() 等待客户端连接(accept() 是阻塞方法,没有新连接时会一直卡住);
  • 一旦接收到客户端连接(如客户端 1),就调用 processConnection(clientSocket) 处理该连接;
  • processConnection 方法中又有一个 while(true) 循环,通过 scanner.hasNext() 等待客户端发送请求(hasNext() 也是阻塞方法,客户端不发数据时会一直卡住)。
2. 并发缺陷:无法同时处理多个连接

单线程模式下,服务器同一时间只能执行一个操作,导致两个关键阻塞点冲突:

  • 冲突 1:accept() 与 processConnection 无法并行当服务器在 processConnection 中等待客户端 1 发送请求时(阻塞在 hasNext()),会一直停留在该流程,无法回到外层循环执行 accept(),此时新的客户端(如客户端 2)发起连接请求,服务器无法响应,导致新连接被阻塞。
  • 冲突 2:单个客户端的 “不发送请求” 会阻塞所有连接若客户端 1 连接后不发送任何数据,服务器会一直阻塞在 scanner.hasNext(),既无法处理客户端 1 的后续请求,也无法接受其他新客户端的连接,导致整个服务器 “卡住”。

服务器端口固定,多个客户端通过 “不同的本地端口 + 五元组唯一性”,可以同时连接到服务器的同一个端口

在网络通信中,多个客户端可以同时连接到服务器的同一个端口,核心原因是通过 **“五元组”(源 IP、源端口、目的 IP、目的端口、传输层协议)** 来区分不同的连接,具体说明如下:

1. 端口的角色区分
  • 服务器端口:是服务器用于监听客户端连接请求的固定端口(如 HTTP 的 80 端口、HTTPS 的 443 端口)。无论有多少客户端连接,服务器始终通过这个端口接收连接请求。
  • 客户端端口:是客户端发起连接时,由系统随机分配的临时端口(通常在 1024~65535 之间)。每个客户端的本地端口不同,用于标识 “该客户端的哪个进程在通信”。
2. 五元组的唯一性保障

即使多个客户端连接到服务器的同一个端口,每个连接的 “五元组” 都是唯一的:

  • 例如,客户端 A(IP:192.168.1.2,端口:50000)连接服务器(IP:192.168.1.100,端口:8080,协议:TCP),五元组是 (192.168.1.2, 50000, 192.168.1.100, 8080, TCP)
  • 客户端 B(IP:192.168.1.3,端口:50001)连接同一服务器端口,五元组是 (192.168.1.3, 50001, 192.168.1.100, 8080, TCP)
  • 这两个五元组完全不同,因此服务器能准确区分是哪个客户端的连接。
3. 实际场景举例

以常见的 Web 浏览为例:

  • 服务器(如某网站)仅需监听 80 端口(HTTP),即可同时处理成千上万的浏览器客户端请求;
  • 每个浏览器(客户端)在发起连接时,系统会为其分配一个唯一的临时端口(如 51234、51235 等),服务器通过五元组识别不同的连接,实现 “多客户端同时通信”。

注意:线程本身的资源开销线程池管理结构的开销方面看的话,我们消耗的资源又太多了,所以使用线程池控制

一、线程本身的资源消耗
1. 线程创建时的消耗
  • 内存消耗:每个线程在 Java 中会分配栈空间,默认栈大小约为 1MB(可通过 JVM 参数 -Xss 调整,如 -Xss256k 可将栈空间缩小到 256KB)。若线程池配置 10 个核心线程,默认栈空间消耗约为 10MB(10 × 1MB)。
  • CPU 消耗:线程创建时需完成 “栈初始化、线程控制块(TCB)注册、类加载” 等操作,这会带来一定的 CPU 开销(但相比频繁创建 / 销毁线程,线程池的 “复用” 会大幅降低此开销)。
2. 线程销毁时的消耗

线程销毁主要是回收栈空间和操作系统线程资源

  • 内存回收:栈空间会被 JVM 垃圾回收器回收,开销取决于栈大小和 GC 策略;
  • 操作系统资源回收:如线程调度相关的内核资源,开销与操作系统实现有关(通常可忽略)。
二、线程池管理结构的消耗

线程池内部包含工作队列、线程状态控制器、Worker 线程集合等对象,这些结构的资源消耗相对较小:

  • 工作队列:如 LinkedBlockingQueue 初始时仅占用少量内存(存储队列节点的引用),后续随任务数量动态变化;
  • 线程池控制器:如 ctl 变量(用于管理线程池状态和线程数),仅占用几个字节的内存;
  • Worker 集合:存储线程引用的集合类(如 HashSet),内存消耗可忽略。
三、消耗的可控性与优化

线程池的资源消耗是高度可配置的,可通过以下方式优化:

UDP 和 TCP 数据传输单位 的核心区别:

UDP就是以DatagramPacket 作为单位的

TCP则是 以字节为单位,实际上一个请求,往往是由多个字节构成的

1. UDP 的传输单位:DatagramPacket(数据报包)

UDP 是基于 “数据报” 的无连接协议,每次发送 / 接收数据都以 DatagramPacket 为独立单元。一个 DatagramPacket 包含:

  • 待传输的字节数据;
  • 目标主机的 IP 地址和端口(发送时);
  • 源主机的 IP 地址和端口(接收时)。它是一个 “自包含” 的数据包,发送方发一个 DatagramPacket,接收方就会以同样的单位接收,天然具备 “数据边界”。
2. TCP 的传输单位:字节流

TCP 是基于 “字节流” 的面向连接协议,没有明确的 “数据包” 边界,数据以连续的字节流形式传输。这意味着:

  • 发送方可以分多次发送字节,接收方会将这些字节拼接成一个连续的流;
  • 应用层需要自己处理 “数据边界”(比如通过换行符、自定义长度字段等方式,区分一个请求的开始和结束)。
3. 实际请求的构成

无论 UDP 还是 TCP,一个业务请求(如一次消息、一次文件片段)往往由多个字节构成

  • 对于 UDP,这些字节会被封装在一个 DatagramPacket 中发送;
  • 对于 TCP,这些字节会作为字节流的一部分发送,需应用层自行定义规则来解析请求的边界。

这种传输单位的差异,直接导致了 UDP “发送即完整”、TCP “流式无边界” 的特性,也决定了两者在可靠性、传输效率、使用场景上的不同(如 UDP 适合音视频直播,TCP 适合文件传输、网页访问等)。

服务端客户端Socket 释放的问题?

服务端的生命周期是贯穿整个进程,所以随着这个进程的结束而结束

客户端的生命周期是随着用户的登录而创建连接产生的,所以是随着连接的连接和断开为一个生命周期的

一、服务端 SocketServerSocket)的释放

服务端的 ServerSocket 主要负责监听客户端连接请求,其生命周期与服务进程强关联:

1. 释放时机
  • 正常释放:当服务进程终止时,ServerSocket 会随进程退出自动释放资源。
  • 主动释放:若需在进程运行中关闭服务端监听(如服务优雅停机),需显式调用 serverSocket.close(),这会立即停止监听新连接,并释放占用的端口和文件描述符。
2. 客户端连接 Socket 的释放(服务端视角)

服务端在接收到客户端连接后,会创建一个新的 Socket 实例用于与该客户端通信(如多线程服务端中,每个客户端对应一个线程和一个 Socket):

  • 释放时机:当客户端断开连接(或服务端主动关闭连接)时,需调用 socket.close() 释放资源。若未主动关闭,会导致文件描述符泄漏,最终可能因资源耗尽导致服务不可用。
  • 异常处理:若客户端异常断开(如网络中断),服务端需通过 IO 异常捕获(如 read() 返回 -1 或抛出 IOException)来感知,并及时关闭对应的 Socket
二、客户端 Socket 的释放

客户端 Socket 是主动发起连接的一方,生命周期与 “连接建立→通信→连接断开” 强关联:

1. 释放时机
  • 主动释放:当客户端通信完成后,需显式调用 socket.close() 关闭连接,释放本地端口和网络资源。
  • 自动释放:若未主动关闭,Socket 会在垃圾回收时被回收,但该过程不可控,可能导致资源长期占用(如端口被占用、网络缓冲区未释放)。
2. 最佳实践:使用 try-with-resources 自动管理

Java 中推荐使用try-with-resources 语法Socket 实现了 AutoCloseable 接口),确保无论是否发生异常,Socket 都会被自动关闭:

代码语言:javascript
复制
try (Socket socket = new Socket(serverIp, serverPort);
     PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
     BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
    // 通信逻辑
} catch (IOException e) {
    // 异常处理
}
// 代码块结束后,socket、out、in 会自动调用 close() 释放资源

或者

代码语言:javascript
复制
 try() {
} catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
    
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
IO 多路复用、Java NIO、Netty:
1. IO 多路复用(I/O Multiplexing)

是一种高效的网络 I/O 模型,核心能力是让一个线程同时监听多个 I/O 通道(如 Socket)。当某个通道有数据可读 / 可写时,再触发处理逻辑。

  • 优势:避免了 “一个线程只能处理一个连接” 的传统阻塞 IO 瓶颈,大幅提升并发处理能力(比如同时处理上千个客户端连接)。
  • 底层依赖:依赖操作系统的原生机制(如 Linux 的selectpollepoll),JVM 本身没有 “原生 API”,需要通过封装这些系统调用实现。
2. Java NIO(New IO)

是 Java 对IO 多路复用机制的封装,提供了Channel(通道)、Selector(选择器,实现多路复用)、Buffer(缓冲区)等组件,实现了非阻塞 IO和高效的多路复用能力。

  • 意义:让 Java 程序能以更高效的方式处理大量并发连接(比如高并发的网络服务器),解决了传统 BIO(阻塞 IO)“一个连接一个线程” 的资源浪费问题。
  • 不足:原生 NIO API 使用较复杂,且存在一些技术细节坑(如 Selector 空轮询、线程安全问题等)。
3. Netty 框架

基于 Java NIO 的高性能网络框架,它对 NIO 进行了更友好的封装和增强,解决了 NIO 原生 API 的易用性问题,同时提供了丰富的功能(如编解码、心跳检测、断线重连、流量控制等)。

  • 应用场景:被广泛用于高性能网络应用,如 RPC 框架(Dubbo)、消息中间件(RocketMQ)、游戏服务器、网关等。
  • 学习价值:是 Java 领域 “高性能网络编程” 的核心技术栈之一,因此后续会有专门课程深入讲解。

服务器引入多线程

如果只是单个线程,无法同时响应多个客户端. 此处给每个客户端都分配⼀个线程

代码语言:javascript
复制
 // 启动服务器
 
public void start() throws IOException {
 System.out.println("服务器启动!");
 while (true) {
 Socket clientSocket = serverSocket.accept();
 Thread t = new Thread(() -> {
 processConnection(clientSocket);
 });
 t.start();
 }
 }

服务器引入线程池

为了避免频繁的创建销毁线程池,也可以引入线程池。

代码语言:javascript
复制
 // 启动服务器
 
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);
 }
 });

长短连接 

TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:

短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能⼀次收发 数据。

长连接:不关闭连接,⼀直保持连接状态,双方不停的收发数据,即是长连接。也就是说,⻓连接可以多次收发数据。

对比以上长短连接,两者区别如下:

• 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要 第⼀次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时 的,长连接效率更⾼。

• 主动发送请求不同:短连接⼀般是客户端主动向服务端发送请求;而长连接可以是客⼾端主动发送 请求,也可以是服务端主动发。

• 两者的使用场景有不同:短连接适用于客⼾端请求频率不高的场景,如浏览网页等。长连接适⽤于 客户端与服务端通信频繁的场景,如聊天室,实时游戏等。

扩展了解:

基于BIO(同步阻塞IO)的长连接会⼀直占用系统资源。对于并发要求很高的服务端系统来说,这样的 消耗是不能承受的。

由于每个连接都需要不停的阻塞等待接收数据,所以每个连接都会在⼀个线程中运行。

⼀次阻塞等待对应着⼀次请求、响应,不停处理也就是长连接的特性:⼀直不关闭连接,不停的处理 请求。

实际应用时,服务端⼀般是基于NIO(即同步非阻塞IO)来实现长连接,性能可以极大的提升。 

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  •  UDP数据报套接字编程(User Datagram Protocol)
    • API 介绍
      • DatagramSocket:
      • DatagramPacket
      • InetSocketAddress
      • 代码演示:
  • TCP数据报套接字编程(Transmission Control Protocol)
    • 概念
      • 1. ServerSocket:TCP 服务端的 “大门”
      • 2. Socket:客户端与服务端的 “专属通道”
    • 核心特性(与 UDP 对比,呼应之前的网络通信场景):
      • 三次握手:
      • 四次挥手:
    • API 介绍
      • ServerSocket
    • 代码演示
      • TCPEchoClient:
      • TCPEchoServer:
      • TCPEchoServer:
  • 注意事项:
    • Allow multi! instances:
      • 异常原因
    • 单线程 TCP 服务器的并发缺陷
      • 1. 单线程服务器的核心流程与阻塞点
      • 2. 并发缺陷:无法同时处理多个连接
      • 一、线程本身的资源消耗
      • 二、线程池管理结构的消耗
      • 三、消耗的可控性与优化
    • UDP 和 TCP 数据传输单位 的核心区别:
      • 1. UDP 的传输单位:DatagramPacket(数据报包)
      • 2. TCP 的传输单位:字节流
      • 3. 实际请求的构成
      • 一、服务端 Socket(ServerSocket)的释放
      • 二、客户端 Socket 的释放
      • IO 多路复用、Java NIO、Netty:
  • 服务器引入多线程
  • 服务器引入线程池
  • 长短连接 
    • 扩展了解:
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档