Java 网络编程入门:从 Socket 到 UDP/TCP 实战

网络编程是 JavaEE 开发的基石,它让我们写的程序能够跨越主机边界彼此通信。本文从 Socket 概念讲起,对比 TCP 与 UDP 两大传输层协议的特性,并通过可运行的代码示例,手把手带你实现 UDP 回显服务、TCP 服务端/客户端,以及基于线程池的并发模型,最后延伸讲解 IO 多路复用的核心思想。

前言

网络编程,说白了就是写代码实现跨主机的通信。

套接字(Socket)是一组网络编程相关函数的统称。我们编写网络程序,本质上是在调用操作系统提供的传输层能力。

传输层主要涉及两大协议:TCP 和 UDP,二者各自提供了一组 Socket 函数。

目录

  • 前言
  • 一、TCP 与 UDP 两种协议的区别
    • 1.1 核心术语
    • 1.2 协议特性对比
  • 二、UDP 网络编程
    • 2.1 核心类
    • 2.2 基本使用(服务端 / 客户端)
    • 2.3 实战:复用服务器实现翻译服务
  • 三、TCP 网络编程
    • 3.1 核心类
    • 3.2 基本使用(服务端 / 客户端)
    • 3.3 常见问题与优化
  • 四、IO 多路复用

一、TCP 与 UDP 两种协议的区别

1.1 核心术语

连接(Connection)

网络连接是一个抽象概念,本质是通信双方各自保存了对方的核心信息(IP 地址与端口号)。

可靠传输(Reliable Transmission)

网络通信中会产生丢包——当传输的数据量超过链路限制时就会丢包,而数据要经过众多的路由器、交换机,任意一环超限都可能丢包。

面向字节流(Byte Stream)

读写方式类似操作文件,非常灵活:可以一次读一个字节,也可以一次读写任意长度的数据。

面向数据报(Datagram)

每次收发都必须以"一整个数据报"为单位,不能只取其中一部分。

全双工(Full Duplex)

一条连接上可以同时双向传输数据。

半双工(Half Duplex)

一条连接上同一时刻只能单向传输数据。

1.2 协议特性对比

TCP:有连接、可靠传输、面向字节流、全双工。

  1. 先保存对方信息(建立连接),用完后释放(断开连接)。
  2. 尽可能对抗丢包,把数据可靠送达对方;实在送不到,也会通知你对方是否收到。
  3. 读写方式类似文件,非常灵活。
  4. 一条连接可双向传输。

UDP:无连接、不可靠传输、面向数据报、全双工。

  1. 直接发送,不建立连接。
  2. 不管对方收没收到,听天由命。
  3. 每次收发都必须是完整的数据报。
  4. 一条连接可双向传输。

二、UDP 网络编程

UDP 主要涉及两个类。

2.1 核心类

DatagramSocket

表示一个 Socket 对象,相当于对网卡设备的抽象。后续只要操作这个对象,就能操作网卡。

private DatagramSocket datagramSocket =null;



public de7(int port) throws SocketException {
    datagramSocket=new DatagramSocket(port);
}

这里的 port 是端口号,需要作为参数传入。

  • 服务器:被动接受请求的一方。
  • 客户端:主动发起请求的一方。

客户端需要事先知道服务器的 IP 和端口号(端口号是 2 字节整数,范围 0–65535;其中前 1024 个,即 0–1023,是知名端口,有特殊含义)。

IP 很好确定,但端口号需要由写服务器的程序员来指定。

DatagramSocket 对外主要提供两个方法:sendreceive,参数都是下面要讲的 DatagramPacket

send

receive

DatagramPacket

表示数据传输的"包"(数据报)。它的构造方法主要有两类:

  1. 传入一个新建的字节数组,用于等待接收数据;
  2. 传入数据、IP 和端口号,用于发送数据。

2.2 基本使用(服务端 / 客户端)

先来看服务端:

    private DatagramSocket datagramSocket =null;

		//这个就是个引用

    public de7(int port) throws SocketException {
        datagramSocket=new DatagramSocket(port);
        //新建这个要带上端口号
    }
    public void run() throws IOException {
        System.out.println("socket");
        while(true){
            //1.读取请求
            DatagramPacket datagramPacket = new DatagramPacket(new byte[1024],1024);
            //这个参数是你要传输的东西
            datagramSocket.receive(datagramPacket);
            //这个是你要接受的,用这个dgP来接受
            String request=new String(datagramPacket.getData(),0,datagramPacket.getLength());
            //这个参数代表的是,得到内容,起点,终点
            //2.根据请求给出响应
            String response =process(request);
			//这个函数是你要怎么处理这个参数,你自己定义这个函数就行
            //3.把响应回复给客户器
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,datagramPacket.getSocketAddress());
            //这个是返回的,我们需要内容,长度,传给谁
            datagramSocket.send(responsePacket);
            //传值
        }
    }
    public String process(String a ){
        return a ;
    }
    public static void main(String[] args) throws IOException {
        de7 server = new de7(10086);
        server.run();
    }//这里不关闭是因为这个会跟随这个进程结束

服务端有了,客户端呢?

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

public class de8 {
    DatagramSocket kehu =null;
    String serverip;
    int serport;
    public de8(int a,String b) throws SocketException {
        kehu=new DatagramSocket();
        //这个就不用指定端口号了,毕竟客户的端口有很多 ,你指定可能会和别的冲突,
        //这个是他先打给你,只要你的电话固定就行,他电话无所谓,你只要在通讯录里找就行
        this.serport=a;
        this.serverip=b;
    }
    public void start() throws IOException {
        System.out.println("kehu");
        Scanner in = new Scanner(System.in);
        while(true){
            //控制台读取用户输入
            String input = in.next();
            //把用户输入转为包
            DatagramPacket req =new DatagramPacket(input.getBytes(),input.getBytes().length, InetAddress.getByName(this.serverip),this.serport);
            kehu.send(req);
            DatagramPacket res =new DatagramPacket(new byte[1024],1024);
            kehu.receive(res);
            String ret = new String(res.getData(),0,res.getLength());
            System.out.println(ret);
        }
    }

    public static void main(String[] args) throws IOException {
        de8 s =new de8(10086,"127.0.0.1");
        //这个10086是de7里的,后面是本地ip
        s.start();
    }
}

image-20251029212917991

2.3 实战:复用服务器实现翻译服务

新建一个类,直接继承前面写好的服务器类,就能复用已有代码;想改逻辑时,重写对应方法即可。

import java.net.SocketException;

public class UDPfanyi extends de7{
        public UDPfanyi(int port) throws SocketException {
                super(port);
        }
}

注意:main 方法不能被继承。

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

public class de8 {
    DatagramSocket kehu =null;
    String serverip;
    int serport;
    public de8(int a,String b) throws SocketException {
        kehu=new DatagramSocket();
        //这个就不用指定端口号了,毕竟客户的端口有很多 ,你指定可能会和别的冲突,
        //这个是他先打给你,只要你的电话固定就行,他电话无所谓,你只要在通讯录里找就行
        this.serport=a;
        this.serverip=b;
    }
    public void start() throws IOException {
        System.out.println("kehu");
        Scanner in = new Scanner(System.in);
        while(true){
            //控制台读取用户输入
            String input = in.next();
            //把用户输入转为包
            DatagramPacket req =new DatagramPacket(input.getBytes(),input.getBytes().length, InetAddress.getByName(this.serverip),this.serport);
            kehu.send(req);
            DatagramPacket res =new DatagramPacket(new byte[1024],1024);
            kehu.receive(res);
            String ret = new String(res.getData(),0,res.getLength());
            System.out.println(ret);
        }
    }

    public static void main(String[] args) throws IOException {
        de8 s =new de8(10086,"127.0.0.1");
        s.start();
    }
}

最终运行效果如下:

image-20251030145201666

image-20251030145229602

下面这张图是我启动了两个端口号相同的服务器时产生的报错(端口被占用)。

三、TCP 网络编程

TCP 同样涉及两个核心类。

3.1 核心类

ServerSocket

只供服务器使用,负责统一接收连接到服务器的客户端。

Socket

客户端和服务器都能使用,负责针对每一个已建立的客户端连接提供具体服务。

注意:TCP 中没有类似 UDP 的 Packet 类,因为它传输的是字节流。

3.2 基本使用(服务端 / 客户端)

3.2.1 TCP 服务端
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;

public class TCPs {
    private ServerSocket serverSocket =null;
    public TCPs(int port) throws IOException {
        serverSocket=new ServerSocket(port);
    }
    public void start() throws IOException {
        System.out.println("start");
        while(true){
            //要先接受客户端的连接,然后才能进行通信

            //如果有客户端和服务器建立好了联系,accept才能够返回
            //否则会堵塞
            Socket socket = serverSocket.accept();
            processCon(socket);
        }
    }

    private void processCon(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 printWriter =new PrintWriter(outputStream);
            while(true){
                //处理多次请求和响应
                //一次循环就是读写一个请求/响应
                //1.读取请求并解析
//                byte[] bytes = new byte[1024];
//                int n =inputStream.read(bytes);
//                String request = new String(bytes,0,n);
                if(!scanner.hasNext()){
                    System.out.printf("[%s:%d] 客户端下线\n",socket.getInetAddress(),socket.getPort());
                    break;
                }
                String req =scanner.next();
                String res= process(req);
                printWriter.println(res);
                System.out.printf("[%s:%d] req %s,res %s \n",socket.getInetAddress(),socket.getPort(),req,res);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

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

    public static void main(String[] args) throws IOException {
        TCPs s =new TCPs(10086);
        s.start();
    }
}
3.2.2 TCP 客户端
import java.io.*;
import java.net.Socket;
import java.util.Scanner;

public class TCPc {
    private Socket socket =null;
    public TCPc(String serip,int serport) throws IOException {
        socket=new Socket(serip,serport);
    };
    public void run()  {
        System.out.println("TCPc start");
        Scanner scanner =new Scanner(System.in);
        try(InputStream inputStream =socket.getInputStream(); OutputStream outputStream=socket.getOutputStream()){
            Scanner scanner1 =new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);
            while(true){
                System.out.println("->");
                String req=scanner.next();
                writer.println(req);
                
                if (!scanner1.hasNext()){
                    System.out.println("关闭");
                    break;
                }
                String res= scanner1.next();
                System.out.println(res);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    public static void main(String[] args) throws IOException {
        TCPc s =new TCPc("127.0.0.1",10086);
        s.run();
    }
}


3.3 常见问题与优化

3.3.1 缓冲区冲刷(flush)问题

启动 TCP 客户端后,你会发现服务端不断打印输出:

image-20251030161250375

为什么中间输入的 “nihao” 没有反应?

因为数据还停留在缓冲区里等待写出。

这就像 C++ 里的 "\n"endl 的区别。写过算法竞赛的同学都知道:

使用 endl 时的输出顺序是:

输入

输出

第二个输入

第二个输出

而使用 "\n" 时的输出顺序是:

输入

第二个输入

输出

第二个输出

原因就在于缓冲区:从硬盘读写很慢,所以数据先放进缓冲区,攒够一批再统一写出。因此 “nihao” 还停留在缓冲区里,没有真正写出去。

解决办法是调用 flush() 主动冲刷缓冲区。

改进版 TCP 服务端:

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;

public class TCPs {
    private ServerSocket serverSocket =null;
    public TCPs(int port) throws IOException {
        serverSocket=new ServerSocket(port);
    }
    public void start() throws IOException {
        System.out.println("start");
        while(true){
            //要先接受客户端的连接,然后才能进行通信

            //如果有客户端和服务器建立好了联系,accept才能够返回
            //否则会堵塞
            Socket socket = serverSocket.accept();
            processCon(socket);
        }
    }

    private void processCon(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 printWriter =new PrintWriter(outputStream);
            while(true){
                //处理多次请求和响应
                //一次循环就是读写一个请求/响应
                //1.读取请求并解析
//                byte[] bytes = new byte[1024];
//                int n =inputStream.read(bytes);
//                String request = new String(bytes,0,n);
                if(!scanner.hasNext()){
                    System.out.printf("[%s:%d] 客户端下线\n",socket.getInetAddress(),socket.getPort());
                    break;
                }
                String req =scanner.next();
                String res= process(req);
                printWriter.println(res);
                printWriter.flush();
                System.out.printf("[%s:%d] req %s,res %s \n",socket.getInetAddress(),socket.getPort(),req,res);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

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

    public static void main(String[] args) throws IOException {
        TCPs s =new TCPs(10086);
        s.start();
    }
}

改进版 TCP 客户端:

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

public class TCPc {
    private Socket socket =null;
    public TCPc(String serip,int serport) throws IOException {
        socket=new Socket(serip,serport);
    };
    public void run()  {
        System.out.println("TCPc start");
        Scanner scanner =new Scanner(System.in);
        try(InputStream inputStream =socket.getInputStream(); OutputStream outputStream=socket.getOutputStream()){
            Scanner scanner1 =new Scanner(inputStream);
            PrintWriter writer = new PrintWriter(outputStream);
            while(true){
                System.out.println("->");
                String req=scanner.next();
                writer.println(req);
                writer.flush();
                if (!scanner1.hasNext()){
                    System.out.println("关闭");
                    break;
                }
                String res= scanner1.next();
                System.out.println(res);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    public static void main(String[] args) throws IOException {
        TCPc s =new TCPc("127.0.0.1",10086);
        s.run();
    }
}

3.3.2 资源泄漏问题

下面这段代码创建了资源却没有回收:

image-20251030163137164

如上图,你创建了资源但没有回收,会造成文件描述符(资源)泄漏:

image-20251030163507275

加入对应的释放逻辑即可解决。

3.3.3 线程模型:从单线程到线程池

上面的写法只能处理"一对一"的连接,但现实中服务器往往需要"一对多",因此需要用多线程来解决:

image-20251201162014800

按上图修改即可。我们为每次请求新建一个线程来处理。

当然,这是比较老的写法;比它更老的是使用多进程。但如果客户端越来越多,频繁地销毁再创建线程成本也很高,因此我们可以用线程池来优化:

 public void start() throws IOException {
        System.out.println("start");
        ExecutorService executorService = Executors.newCachedThreadPool();
        while(true){
            //要先接受客户端的连接,然后才能进行通信

            //如果有客户端和服务器建立好了联系,accept才能够返回
            //否则会堵塞
            Socket socket = serverSocket.accept();
//            所以我们创建一个新线程
            executorService.submit(()->{
                this.processCon(socket);
            });
        }
    }

线程池完整的七个参数(corePoolSize / maximumPoolSize / keepAliveTime / unit / workQueue / threadFactory / handler)和提交流程,见《多线程进阶》第 11 章。这里补充最常被问到的四种拒绝策略

策略 行为
AbortPolicy(默认) 抛 RejectedExecutionException
CallerRunsPolicy 让提交任务的线程自己跑这个任务
DiscardPolicy 静默丢弃,不抛异常
DiscardOldestPolicy 丢弃队列里最老的任务,再提交当前任务

那么还有没有问题?有的,兄弟们。如果某个客户端"逗留时间"短(这里指用户停留在该页面上的时长),且同一时刻线程总数不多,那没问题;但如果逗留时间长、线程越积越多,线程间切换的间隔自然就变长,又该怎么解决呢?

四、IO 多路复用

C++ 实现 IO 多路复用比较简单,但 Java 原生实现较复杂,因此 Java 一般不直接使用 NIO 相关类。这里只讲原理。

每个 Socket 并非一直活跃,大部分时间都在阻塞等待。因此我们可以让"一个线程处理多个客户端"。

举个吃饭的例子:

  • 两个人吃饭,A 先去买、拿到 A 的饭后,再去买 B、拿到 B 的饭(单线程);
  • 两个人分别去买,一个买 A、另一个买 B,各自拿到饭后汇合(多线程);
  • 一个人先去买 A、再买 B,然后等 A、B 都完成(IO 多路复用)。

回到 TCP 代码,请求和响应都是以 \n 作为结束符的——我们正是用带 \nprintln 来输出的:

image-20251201170416695

如果改成 print 就 GG 了:

image-20251201170530911

请求会一直阻塞在读取状态:

image-20251201170449282

当然,后续我们一般会使用对 Socket 做了进一步封装的成熟产品。

小结

本文围绕 Java 网络编程展开:首先明确了 Socket 的本质是调用操作系统传输层能力的一组函数;随后对比了 TCP(有连接、可靠、面向字节流、全双工)与 UDP(无连接、不可靠、面向数据报、全双工)的核心差异;接着通过 DatagramSocket/DatagramPacketServerSocket/Socket 两组核心类,给出了可运行的 UDP 回显与 TCP 服务端/客户端示例;并针对 TCP 实践中的缓冲区冲刷、资源泄漏、单线程并发瓶颈,引出了 flush、资源回收与线程池等解决方案;最后简述了 IO 多路复用的"一个线程管理多连接"思想。掌握这些,就打下了 JavaEE 网络开发的基础。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐