【JAVA】网络编程

引言

在学习网络编程之前,我们编写的程序几乎都是"单机版"的------只能在本地运行,自娱自乐,无法与其他主机(用户)进行交互。

有些同学可能会产生误解:既然 Java 号称"一次编译,到处运行",那把程序拷贝到另一台安装了 Java 环境的电脑上运行,不也算是交互吗?其实并不是。那只是同一个程序在不同环境中运行,并没有真正的信息交流。

真正的"交互通信"更像是打电话------双方能够互相发送消息,并根据对方的内容做出回应。比如,我们在浏览器输入一个网址并访问网站时,其实就是网站的服务器发起了网络请求,服务器处理后再返回响应,我们才能看到网页内容。这才是真正的主机之间通信。那么问题来了:我们要如何在自己的程序中实现这种通信呢?答案就是利用 java.net 包。它为我们提供了丰富的类和接口,帮助我们轻松实现网络通信功能,从而开发出属于自己的网络应用程序。

网络基础知识

在学习网络编程之前,我们需要掌握一些基本的网络概念。这样在调用 java.net 包中的接口和类时,才能理解为什么需要某些参数,以及这些参数背后的意义。

如果缺少这些知识储备,直接上手网络编程往往会感到一头雾水。

不过,本篇文章重点是 Java 网络编程 ,因此不会花大量篇幅讲解网络原理,只会简要介绍网络分层模型(七层/五层)、传输层协议(TCP/UDP)、网络层(IP)以及数据链路层等核心内容。更深入的网络知识,我会在单独的文章中详细展开,尤其是 TCP 与 UDP,这也是面试中经常考到的高频知识点。


网络协议

那么,什么是网络协议?

简单来说,网络协议就是一套 通信规则和约定。在 Java 中,接口(interface)本质上就是一种规范,协议的作用与此类似。

你可以把网络想象成一门"语言":全世界的计算机要想互相通信,就必须遵循统一的规则,否则彼此无法理解数据。就像学习英语时,我们必须掌握词汇、语法和发音,遵守规范后,才能与他人顺畅交流。网络协议也是如此,它规定了数据在网络中的发送与接收方式,保证不同计算机之间能够"说同一种语言"。

但由于网络本身非常复杂,如果用一个庞大的协议去涵盖所有问题,协议会变得臃肿难学。为什么呢?因为设计网络需要考虑的事情很多:

  • 物理层 :用什么信号传输二进制数据?光信号、电信号还是无线信号?比如电信号中,高电平可能表示 1,低电平表示 0

  • 数据准确性 :信号在传输过程中可能受干扰,如何保证传输的 1 不被误判为 0?或者如果出现传输错误我们如何区分?又如何补救?这就需要校验与纠错机制。

  • 路径选择:数据要如何找到最优路径传递到目标主机?

  • 交付问题:数据到达目标主机后,应该交给哪个程序处理?

这些只是冰山一角,现实中的网络设计远比想象复杂。显然,如果用单一协议来解决所有问题,就会像在 Java 中把所有逻辑塞进一个方法里------不仅难以维护,也难以理解。

因此,网络协议被设计为 分层结构


分层的好处

  1. 各层功能相互独立,扩展灵活

    协议分层后,各层之间通过接口交互,互不影响。如果某一层需要优化或扩展,只需改动这一层即可,不会牵一发而动全身。

  2. 易于实现和维护

    分层将复杂问题拆解为多个小问题,每层只关注自己的功能,便于实现和维护。

  3. 协议制定更清晰

    各层只需定义自身的规则,避免了一个协议包揽所有内容导致的臃肿。学习和使用时也更直观,就像一个接口只包含必要的方法,而不是堆满难以区分的功能。

OSI 模型

最早提出的网络参考模型是 OSI 七层模型 ,它将网络划分为:
物理层 → 数据链路层 → 网络层 → 传输层 → 会话层 → 表示层 → 应用层

作为学习 Java 网络编程的同学,其实不必过于纠结底层(物理层、链路层等)的原理,因为这些已经由硬件和操作系统封装好了。我们在开发时,主要接触的就是 传输层到应用层 ,比如 TCP/UDP 协议 以及 HTTP 协议

不过 OSI 模型存在一些问题:

  • 制定周期太长,落地困难;

  • 协议过于复杂,运行效率低;

  • 层与层之间功能划分不够清晰,部分功能存在重复。

因此,虽然 OSI 模型更像是一个 理论指导标准 ,但在实际应用中,计算机网络普遍采用 TCP/IP 模型


TCP/IP 模型

TCP/IP 模型是一个 五层结构 ,自下而上分别是:
物理层 → 数据链路层 → 网络层 → 传输层 → 应用层

与 OSI 相比,它将 会话层、表示层和应用层 合并成了统一的 应用层。因此在一些书籍里,你也会看到"TCP/IP 四层模型"的说法(物理层通常被视为硬件实现,不作为协议层来讨论)。

下面我们逐层来简单理解:

1. 数据链路层

负责 相邻节点之间的数据传输 。节点可以是计算机、路由器、交换机等设备。

主要解决的问题包括:

  • 如何找到下一个节点?

  • 如何检测数据是否出错?

    常见协议有 以太网协议(Ethernet)PPP 协议等。


2. 网络层

负责 选择路径 ,让数据能从起点主机到达目标主机。

你可以把它类比成快递路线规划:并不是最短路径就一定最快,有时需要根据网络情况绕路以避免拥堵。

常见协议有 IP 协议(IPv4/IPv6)ICMP 协议等。


3. 传输层

负责 端到端(进程到进程)的传输

主机上的每个应用程序都通过 端口号 来区分,就像房子里的房间号,一个端口号对应一个程序的位置。

知名协议:

  • TCP(传输控制协议):可靠传输,保证数据不丢失、不乱序。

  • UDP(用户数据报协议):不保证可靠性,但效率高。


4. 应用层

应用层负责面向用户提供具体的服务,也是程序员最常接触的一层。在开发过程中,我们需要在应用层根据请求生成相应的结果,这既包括返回哪些数据,也包括如何将这些数据展示给用户。也就是当数据到达主机后,应用层决定如何处理和展示

常见协议有:

  • HTTP/HTTPS:网页访问

  • FTP:文件传输

  • SMTP/POP3/IMAP:电子邮件

比如,当你在浏览器中输入网址并回车时,就发起了一次 HTTP 请求,服务器返回响应后,浏览器再将其渲染成网页。

数据链路层

数据链路层负责解决两个节点之间的数据传输问题 。在这一层中,有一个核心概念------MAC 地址

MAC 地址是网络设备的唯一标识,由厂商在设备出厂时向权威组织申请分配。它长度为 6 字节(48 位),可表示数百亿个地址,足以满足当前的需求。

为什么要使用 MAC 地址?

  • 寻址作用:根据 MAC 地址找到下一跳节点的位置。

  • 识别作用:接收方通过 MAC 地址判断该数据帧是否发给自己;若不是,则继续转发到目标 MAC 地址。

为了让大家更容易理解这个过程。接下来会用数据链路层的常用协议:以太网协议给大家举例。首先是要清楚协议里面到底有什么,也就是它的格式是什么。

协议本质上就是一段二进制数据,不同位置的比特有不同含义。例如:

  • 前 6 字节:目的 MAC 地址

  • 后 6 字节:源 MAC 地址

  • 类型字段:指明交付给上层的哪种网络层协议

这些位于数据前端的额外信息称为首部 ;而位于数据尾端的附加信息则称为尾部 。其中,首部包含了目的地址和源地址,而尾部常见的字段是 CRC(循环冗余校验码)

CRC 的作用是检测数据传输过程中的错误:

  • 发送方:根据要发送的数据计算出 CRC 值,并附加在帧尾。

  • 接收方:收到数据后重新计算 CRC,并与帧中携带的 CRC 比较。若二者不同,说明数据在传输中出错,该帧将被丢弃。

下面举一个例子,让大家更清楚看到在数据链路层是如何工作的。假设例子中小A和小C电脑没有直连的线,只能通过小B传输。

在网络传输过程中,数据会按照五层协议逐层封装 :每一层在源数据外层加上本层规定的控制信息,形成一个新的数据单元。接收方在收到数据包时,则会逐层拆解,依次去掉各层的首部和尾部,最终还原出原始数据。

上面举的小 A → 小 B → 小 C 的例子,只是为了帮助大家直观理解数据链路层的工作原理,因此进行了简化。在真实网络中,主机之间几乎不会直接通过网线相连,更不会像例子里的小 B 那样替别人转发数据。

  • 主机网卡的设计只负责接收和处理发给自己的数据帧 (目的 MAC 地址与自己一致,或是广播帧),其余数据帧会直接丢弃,而不会转发。

  • 在实际环境下,通常会借助交换机来实现转发。交换机提供更多接口,每台主机只需接入交换机,就能与其上的任意主机通信。

  • 此外,交换机还能维护 MAC 地址表,根据目的地址快速查找并转发到目标端口,效率远比简单直连更高。

因此,可以把前面的例子看作教学化的简化演示:它帮助我们理解 MAC 地址在逐跳传输中的作用。但在真实网络中,只有像交换机、路由器这样的设备才会承担转发任务,而主机只负责收发自身的数据,如果不是发送给自己的数据包会直接丢弃。

网络层

网络层涉及的概念更多,例如局域网(LAN)广域网(WAN)以及IP 地址

局域网与广域网

局域网和广域网其实是相对的概念,范围的大小取决于参照对象:

  • 在家里,手机和电脑都连到同一个路由器、同一个 WiFi,就可以看作是一个局域网

  • 在寝室,如果你开手机热点,同学连上你的热点,你们两部手机就组成了一个小小的局域网。相对而言,整个校园网 就是更大的网络,可以视作广域网

  • 但换个角度看,校园网与城市范围内的**城域网(MAN)**相比,又是一个局域网。

可见,"局域"与"广域"并没有绝对的标准,而是依赖于比较对象。

IP 地址与短缺问题

IP 地址是网络层中最重要的概念,用于标识主机的唯一位置,就像快递的收件人地址一样。

IPv4 在设计之初采用 4 字节(32 位) 表示,最大可分配约 42 亿个地址。然而,随着电脑、手机以及越来越多的物联网设备接入网络,这些地址早已不够用。为此,人们提出了几种解决方案:

  1. 动态分配 IP

    设备只有在上网时才会被分配 IP,用完释放回收再分配给其他设备。这样提高了利用率,但并没有增加总量,属于"治标不治本"。

  2. NAT(网络地址转换)

    将 IP 地址划分为公网 IP内网 IP

    • 公网 IP 唯一,能被全球访问。

    • 内网 IP 仅在局域网内唯一,不同局域网之间可以重复使用。

    通信时,局域网出口设备(如路由器)会将内网 IP 替换为公网 IP,并建立一个映射表:

    复制代码
    内网 IP ↔ 公网 IP:端口 

    这样,百度等公网服务器收到请求时看到的源地址就是公网 IP;返回响应时,数据先到达出口设备,再由它根据映射表准确转发到对应的内网主机。

    如果局域网内有多台主机同时访问百度,区分的依据就是"端口号"。这个端口由 NAT 设备分配,需要和传输层的端口号加以区分。

    例子:在同一个局域网内,主机A和主机B都 访问了百度,那么路由器的映射表应如下:

    主机A的IP地址 :客户端随机分配的端口 ↔ 路由器公网IP:50001(端口)

    主机B的IP地址 : 客户端随机分配的端口 ↔ 路由器公网IP:50002(端口)

    所以当响应返回的时候也能通过端口号的不同来区分,应该映射成哪一个地址。

    数据包传输的时候也是有带IP和端口的。所以可以根据端口区分。这里客户端随机分配的端口,就相当于是开了一个房间,端口的值就是房间号,当响应返回的时候主机知道应该返回给哪一个程序,就是根据端口来确定的。而路由器的端口则是为了区分不同的主机映射关系。

  3. IPv6

    IPv6 是根本性的解决方案,采用 16 字节(128 位) 表示地址,理论上可以给地球上每一粒沙子分配一个独立 IP。IPv6 空间几乎无限,也彻底解决了地址不足问题。

    不过,IPv4 已经广泛使用,因此需要一个较长的过渡期。目前中国的多数应用和设备已经支持 IPv6,正逐步向 IPv6 网络迁移。现阶段,主流做法仍是 NAT + 动态分配

IP地址格式

IP地址分为两个部分,网络号和主机号。网络号:标识网段,保证相互连接的两个网段具有不同的标识。主机号:标识主机,同一网段内,主机之间具有相同的网络号,但是必须有不同的主机号(这里就是前面说的局域网中IP不能相同)。通过合理的设置网络号和主机号,就可以保证在相互连接的网络中,每台主机的IP地址都是唯一的。

同一个局域网中,主机之间的网络号是相同的,主机号必须不同。在相邻的两个局域网中,要求网络号是不同的。

那么如何划分网络号和主机号呢?这就需要通过子网掩码。子网掩码有32位,它的规定是它的左边一定都是1,右边一定都是0。不会01混着,左边都是连续的1接着到右边连续的0。把IP地址和子网掩码做按位与运算就是网络号。

特殊IP

如果一个IP地址,主机号为0,此时这个IP就表示网络号,例如192.168.0.0,代码当前局域网。

如果一个IP地址,主机号为1,此时这个IP往往表示这个局域网的"网关",192.168.0.1代表局域网的网关(通常是路由器的IP)。网关的角色通常就是路由器,把守这当前局域网和其他局域网之间的出入口。当然路由器的IP也可以自己更改,不是强制要求主机号要为1,只是习惯用法。

如果一个IP地址,主机号为全1,此时这个IP表示广播IP。用点分十进制表示就是255.255.255.255。

127.*开头,都是环回IP。典型的就是127.0.0.1,表示当前主机地址。

网络层如何工作的?

网络层最重要的就是路由选择。这些都是由路由器完成的。路由的选择是"启发式"的。过程非常类似于问路。网络数据包到达路由的时候,路由器自身有一个路由表的数据结构(路由表就是这个路由器认的路),一个路由器无法认识到网络的全貌,但是可以认识附近的一部分。

如果目的IP路由器认识,就会给出一个明确的路线。如果目的IP不认识,路由器就会把数据报转发给一个"更见多识广"的路由器(在路由表里有个默认的选项是下一跳)。

那有没有可能问了一大圈也没有找到目的地呢?也是有可能的,比如IP地址不存在,或者不可达。数据包通常会有一个生存时间TTL。TTL的单位是次数,数据传输是,每经过一个路由器转发TTL就-1,如果减到0了,此时就要把包丢弃(不再继续转发了)。预期正常情况下,数据包是可以在很短的次数内就能传输到世界上任何一个主机上的。TTL的初始值是一个整数,一般是32/64/128这样的数。

为什么说在很短的次数就能传输到世界上任何一个主机上呢?毕竟网络结构这么庞大。这是基于一个社会科学上的假设:六度空间理论。这个理论的核心就是,如果你想认识一个人,你就去问你的朋友中有没有认识这个人的人,如果没有,则朋友继续跟自己的朋友们传达。一般经过6层朋友,就可以认识这个人了。

我们在可以在命令行测试一下,命令行有个ping命令,里面就可以查看TTL。我们可以ping一个国内网站和一个国外网站查看。

我们可以看到访问百度网站,只经过了11跳就访问到了(初始值应该是64)。再来访问一个国外的网站github。可以看到经过了16跳才访问到(初始值128)。

再测试一个不可达的,youtube。可以看到请求超时。

传输层

传输层最常用的两个协议就是UDP和TCP,而本文要叙述的网络编程也正是由这两个协议封装的类。所以本文来讲传输层是重点,但是也是只需要对这两个协议有个基本认识即可,在另外的文章会讲到它们具体是如何实现的。传输层是端到端之间的传输。这不是说就不需要底层那些协议和网络路径运输数据了。只是我们在学习每层协议的时候,只需要关注对等的实体即可,这样会更容易理解本层协议,因为下层协议都已经封装好了,我们可以不予理会,直接看做端到端的运输,这样理解即可。

TCP

TCP是有连接,可靠传输,面向字节流,全双工。首先解释一下什么叫做连接。就是说主机在进行网络通信前必须先和要通信的主机建立连接,才可以进行后续通信的操作。有点像打电话,只有对方接听你的电话(同意建立连接),你们才可以相互交流。如果对方拒绝接听你的电话(拒绝建立连接),那么就无法进行后续的通信交流操作。

可靠传输指的就是发送方可以知道接收方有没有收到数据,因为网络上常常会有意外的情况,数据包可能会丢失(网络拥塞,中间路由器处理不过来就会把新来的包丢弃,或者误码也会被丢弃)。所以知道接收方有没有收到很重要,TCP采取了确认应答机制来确定接收方有没有收到。具体就是接收方如果收到数据包,要马上回给发送方一个ack包表示收到了。如果发送方没有收到ack,就能采取一些补救措施,比如重新传数据。这里千万不要误认为可靠就是指的安全或者是一定能把数据送达。

面向字节流就是指TCP发送数据包的方式是没有明确的边界界定的。就像字节流那样,直接把数据传输过去,接收方自己按需读取。只要触发一个请求就写入字节流中,TCP发送数据包可能把第一次和第二次请求混合着发,这是取决于TCP的分包机制,TCP只管向字节流里面拿去待发送的数据然后自行决定如何分包发送。所以接收方这边也是用字节流接收的,你如果一个包一个包接收读取,可能读出的数据是一个半数据(第二次请求的部分数据和第一次请求的数据混合发)。

全双工就是既能收消息也能发消息。这两个功能可以同时进行。 于全双工对应的就是半双工,他只能同时做一件事,要么收消息,要么发消息。

我们可以简单看一下TCP协议有哪些字段,本篇文章不会每个字段都解释,我会在另外一篇TCP详解的文章讲解。其实TCP的格式也是连在一起的长条形的,图片做换行处理只是为了排版好看。其实这些字段都是在一行的。

源/目的端口号:表示数据是从哪个进程来,到哪个进程去;

4位TCP报头长度:表示该TCP头部有多少个32位bit(有多少个4字节);所以TCP头部最大长度是15 * 4 = 60字节。

6位标志位:

URG:紧急指针是否有效

ACK:确认号是否有效

PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走

RST:对方要求重新建立连接;我们把携带RST标识的称为复位报文段

SYN:请求建立连接;我们把携带SYN标识的称为同步报文段

FIN:通知对方,本端要关闭了,我们称携带FIN标识的为结束报文段

现在不理解标志位每位的意思不要紧,只做了解。但是在讲可靠传输的时候提到过ACK,如果是应答报文的话,标志位ACK这里就会被标为1。

UDP

UDP是无连接,不可靠传输,面向数据报,全双工。首先解释一下什么叫做无连接,和TCP的有连接对应。UDP更像是发短信,不需要经过对方的同意(不需要建立连接),直接就可以把短信发送到对方手机上。至于不可靠这里也是和TCP的可靠相对应的,也就是不能知道发送数据后,接收方是不是真的收到了数据。

面向数据报,就是UDP的每次请求都是单独包装成一个数据报发送的,也就是说UDP是一个数据报一个数据报发送的。接收方每次接收一个数据报里面就是一次请求的数据。全双工指能同时收发消息。

UDP协议格式也是大致了解一下即可,其实可以看到UDP其实少很多字段,因为它的功能比TCP少很多。这里需要注意的是16位UDP长度指的是整个数据报(UDP首部+UDP数据)的长度。

UDP编程

DatagramSocket 是 Java 对 UDP 的封装类,基于它即可实现 UDP 数据传输。本文示例采用经典的服务器/客户端模型:服务器在固定端口上监听客户端请求,客户端在需要时向服务器发送数据,服务器接收并返回响应。

  • DatagramSocket 的创建

    • new DatagramSocket(int port):在本机的所有网络接口(通配地址)上绑定指定端口,适合服务器。

    • new DatagramSocket():在本机随机分配一个空闲端口,常用于客户端发起请求时使用临时端口。因为客户端是在用户的主机上的,如果代码里面把客户端端口定死了,很可能和用户主机上的其他某个应用起冲突,但是程序员又无法事先得知用户主机上有哪些应用。而服务端则不同,是程序员可以清楚指定一个不冲突的端口。并且服务器的端口必须实现规定好,这样客户端才知道向哪个端口发送数据。

    • new DatagramSocket(int port, InetAddress bindAddr):在指定的本地地址(某个网卡)和端口上绑定,适用于多网卡环境下需指定出/入接口的场景。

  • DatagramPacket 的用途

    • 接收用:new DatagramPacket(byte[] buf, int length)receive() 会把网络层收到的数据放入 buf,并更新 packet.getLength()packet.getAddress()packet.getPort()(发送方地址/端口)。

    • 发送用:new DatagramPacket(byte[] buf, int length, InetAddress dest, int port),发送时需要目标地址和端口。

    • 注意:若到达的数据比接收缓冲区 buf 大,超出部分会被截断并丢弃。

  • 实践要点

    • 服务端必须知道并绑定一个固定端口,客户端一般使用无参构造获得临时端口。

    • 若主机有多块网卡,指定 InetAddress 可控制流量走哪块网卡;否则默认由操作系统选择。

    • DatagramSocket.receive() 是阻塞调用,也就是说如果一直没有收到数据就会阻塞在该行代码,可通过 setSoTimeout() 设置超时避免无限阻塞。

我先写一个简单的回显服务器(客户端发啥,服务器回啥)的示例,在示例中就能知道它的语法。

服务端:

java 复制代码
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

public class UDPServer {
    public static void main(String[] args) throws SocketException {
        try(DatagramSocket socket = new DatagramSocket(8888)){
            byte[] buf = new byte[1024];
            DatagramPacket packet = new DatagramPacket(buf, buf.length);
            while(true){
                socket.receive(packet);  // 准备接收客户端发送的数据
                //  读取
                int length = packet.getLength();  // 此时packet已经填充好了接收到的数据,返回的长度是接收数据的实际长度
                String s = new String(buf, 0, length);
                System.out.println(packet.getAddress() + ":" + packet.getPort() + "对服务器说:" + s);
                byte[] data = s.getBytes();
                DatagramPacket sendPacket = new DatagramPacket(data, data.length, packet.getAddress(), packet.getPort());
                // 给客户端返回一模一样的内容
                socket.send(sendPacket);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

客户端:

java 复制代码
public class UDPClient {
    public static void main(String[] args) {
        try(DatagramSocket socket = new DatagramSocket()) {
            Scanner sc = new Scanner(System.in);
            while (true) {
               System.out.print("请输入你要对服务器发送的话:");
               String s = sc.nextLine();
               if (s.equals("exit")) {
                   break;
               }
                byte[] data = s.getBytes();
               DatagramPacket sendPacket = new DatagramPacket(data, data.length, InetAddress.getByName("localhost"), 8888);
               socket.send(sendPacket);
               DatagramPacket receivePacket = new DatagramPacket(new byte[1024], 1024);
               socket.receive(receivePacket);
               int length = receivePacket.getLength();
               System.out.println("服务器回复你:" + new String(receivePacket.getData(), 0, length));
           }
        } catch (SocketException e) {
            throw new RuntimeException(e);
        } catch (UnknownHostException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

运行效果:

从服务端的打印可以看到,客户端的端口确实是随机分配的。

总结

整个程序的运行过程如下:

  1. 进程与端口

    • 客户端和服务器各自运行在主机上的一个进程中。进程需要通过 端口号 来标识,才能在网络通信中被准确定位。

    • 服务端端口号固定 :由程序员指定,例如 8888,这样客户端才能知道去哪里发送数据。因为服务器由我们掌控,可以避免冲突。

    • 客户端端口号随机:通常由操作系统分配一个临时端口。因为客户端的主机环境不确定,避免手动指定导致冲突。

  2. 客户端流程

    • 从键盘读取用户输入。

    • 将数据转为字节数组,封装到 DatagramPacket 中,并指定 目的 IP + 目的端口

    • 发送数据包。

    • 构造一个接收数据用的 DatagramPacket,调用 receive 方法阻塞等待服务器的响应。

    • 使用 packet.getLength() 获取真实数据长度(而不是缓冲区大小),再解码成字符串。

    • 整个过程可放入循环中,用户输入 "exit" 时退出程序。由于 UDP 无连接,所以不需要像 TCP 那样通知服务器关闭。

  3. 服务器流程

    • 在指定端口(如 8888)创建 DatagramSocket,进入循环。

    • 调用 receive 方法阻塞等待数据(这是 UDP 编程里唯一会阻塞的方法)。

    • 接收到数据后,取出内容,并打印 客户端地址 + 端口,这就相当于日志记录。

    • 再将接收到的内容重新封装为 DatagramPacket,发送回客户端,完成回显。

  4. 注意事项

    • 构造 DatagramPacket 时,发送长度必须是字节数组的长度,而不是字符串的长度。因为字符串和字节的对应关系依赖于编码方式,尤其是中文等多字节字符,字符串长度和字节长度可能不一致。

    • 缓冲区大小(如 1024 字节)只是接收的容器,真正收到多少数据要看 getLength(),否则可能出现脏数据或乱码。

TCP编程

在 TCP 编程中,服务端通常通过 ServerSocket 类来监听客户端的连接请求。顾名思义,ServerSocket 专门用于服务端,它需要绑定到一个 端口号,作为服务器进程在网络中的唯一标识。

核心方法是 accept()

  • 当没有客户端请求时,它会阻塞等待;

  • 一旦有客户端发起连接,accept() 就会返回一个新的 Socket 对象。

需要注意的是:

  • ServerSocket 自身只负责"接收请求、建立连接";

  • 而返回的 Socket 才是 真正用于数据传输的通道

我们知道TCP是面向字节流的,所以在 Socket 中,提供了输入流和输出流:

  • 输入流InputStream):用来读取客户端发送的数据;

  • 输出流OutputStream):用来向客户端发送响应。

通常,服务端会:

  1. 使用 ServerSocket 在指定端口监听;

  2. 调用 accept() 接收客户端连接;

  3. 使用返回的 Socket 进行双向通信;

  4. 通信完成后关闭 Socket,最后再关闭 ServerSocket

如果需要同时处理多个客户端,可以为每个 Socket 单独分配一个线程,或者使用线程池来提升并发处理能力。

接下来继续用回显服务器进行举例:

服务端代码:

java 复制代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;


public class TCPServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(9999);  // 开启服务器进程端口
        while(true){
            Socket socket = serverSocket.accept();  // 等待接收客户端请求
            System.out.println("服务器已和 【" + socket.getInetAddress() + " : " +  socket.getPort() + "】建立连接");
            process(socket);
        }
    }

    public static void process(Socket socket) {
        new Thread(() -> {  // 由于需要对每个建立连接的客户端实时监听请求,所以需要多线程,进行同时处理
            try(BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                PrintWriter out = new PrintWriter(socket.getOutputStream())){
                while(true){  // 需要反复监听客户端有没有发请求,所有用while循环读取
                    String s = reader.readLine();  // 读取客户端数据
                    if("exit".equals(s)){
                        break;  // 如果收到的消息为exit,则说明客户端请求断开连接
                    }
                    System.out.println("客户端【"+ socket.getInetAddress() + " : " +  socket.getPort() + "】说:" + s);
                    out.println(s); // 给客户端返回响应,由于是回显服务器,客户端发啥回啥
                    out.flush(); // PrintWriter类带有缓冲区,需要flush将数据刷新出去,以确保发送
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }finally {
                try {
                    socket.close();
                    System.out.println("已和客户端【"+ socket.getInetAddress() + " : " +  socket.getPort() + "】断开连接:");
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }).start();
    }
}

客户端代码:

java 复制代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;

public class TCPClient {
    public static void main(String[] args) {
        try(Socket socket = new Socket(InetAddress.getByName("localhost"), 9999);
            BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            PrintWriter outer =  new PrintWriter(socket.getOutputStream())){// 和服务器建立连接
            Scanner sc = new Scanner(System.in);
            while(true){
                System.out.print("请输入你想对服务器发送的话: ");
                String s = sc.nextLine();
                outer.println(s); // 向服务器发送数据
                outer.flush();  // 刷新缓冲区确保数据真的发送出去
                if("exit".equals(s)){  // 如果为exit就退出
                    break;
                }
                String res = reader.readLine();  // 接收响应
                System.out.println("服务器回复:: " + res);
            }
        } catch (UnknownHostException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

运行效果:

总结

1. 进程与端口

  • 和 UDP 一样,客户端和服务器各自运行在主机的一个进程里,依靠 端口号 来标识。

  • 服务端端口固定 (例如 9999),这样客户端才能知道去哪连接。

  • 客户端端口则由操作系统分配一个临时端口。

不同之处在于:TCP 是面向连接的 ,在真正传输数据前,必须先完成 三次握手,建立连接。


2. 客户端流程

  1. 创建 Socket,指定服务器 IP + 端口,主动发起连接。

  2. 从键盘读取用户输入。

  3. 调用 PrintWriter 向服务器发送数据。因为有缓冲区,必须调用 flush() 确保数据真正发出去。

  4. 调用 BufferedReader.readLine() 等待服务器响应。

  5. 如果输入 "exit",客户端主动断开连接,释放资源。


3. 服务端流程

  1. 创建 ServerSocket,绑定端口并等待连接。

  2. 调用 accept() 方法阻塞,直到有客户端请求到来。

    • 这时服务器会为每个客户端生成一个新的 Socket,表示与该客户端的连接。
  3. 每个客户端连接交给独立线程处理(否则多个客户端会相互阻塞)。

  4. 在子线程中:

    • 使用 BufferedReader 持续读取客户端发送的数据。

    • 如果收到 "exit",关闭该客户端连接。

    • 否则打印日志,并用 PrintWriter 将消息原样返回。

  5. 客户端断开时,关闭对应的 Socket,并打印断开提示。

4.注意事项

  1. 带缓冲区的输出流必须 flush()

    • 在 TCP 编程中,如果使用 PrintWriterBufferedWriter 等带缓冲区的输出流,写入数据后必须调用 flush(),否则数据可能一直停留在缓冲区里,只有缓冲区满时才会真正发送。

    • 这点在 回显服务器即时交互场景 下尤其重要,否则会出现"客户端发了消息但迟迟收不到响应"的情况。

  2. 服务器端必须显式关闭 Socket

    • 当客户端断开连接时,服务端线程会结束循环,这时一定要显式调用 socket.close() 释放资源。

    • 原因:

      • Socket 底层依赖的是操作系统的文件描述符(FD),并非普通的 Java 对象。Java 只是做了封装,方便程序员调用。

      • JVM 的垃圾回收器只负责托管 Java 堆内存对象,而底层操作系统资源(如文件、网络套接字)并不受 GC 直接管理。

      • 对普通 Java 对象来说,内存回收的延迟不会造成致命问题,因为 JVM 最终一定会释放堆空间。但 Socket 属于有限的系统级资源,如果依赖 GC 触发 finalize() 回收,不仅时机不可控(可能长时间不回收),而且还可能因为 FD 数量耗尽,导致新客户端无法建立连接,最终造成服务端崩溃。这就是所谓的 资源泄露

  3. 客户端 Socket 不需要特别担心泄露

    • 客户端通常只会维护有限个 Socket,数量可控,不会像服务端那样"一来一个请求就创建一个连接"。

    • 所以客户端退出时即使忘记 close(),一般也不会导致大规模的资源浪费,但依旧推荐养成显式关闭的习惯。

  4. ServerSocket 一般不用关闭

    • 服务端通常会长时间运行,如果关闭 ServerSocket,就等于停止对新连接的监听,相当于让服务停机。

    • 只有在服务端要整体下线时,才会去关闭 ServerSocket


和UDP 的对比

  • UDP

    • 无连接,不需要显式关闭"连接资源"。

    • 客户端和服务端都只有一个 DatagramSocket,资源消耗固定。

    • 就算不 close(),最多就是占用一个端口,不会因为高并发出现"资源膨胀"的问题。

  • TCP

    • 面向连接,每接入一个客户端,服务端就会分配一个新的 Socket

    • 如果不主动关闭,资源会不断累积,形成泄露,最终影响整个服务器的稳定性。

有趣的案例

网页案例

有了网络通信机制,我们同样也可以完成像我们平时访问网页的那样的案例。我们不用在写一个客户端程序发送请求,就用浏览器充当客户端,在浏览器敲下服务器的地址,发送请求。但是需要注意的是浏览器的请求还涉及一个应用层协议HTTP协议。这个请求我们不用管,因为在输入网址敲下回车的时候浏览器就已经包装好了HTTP请求发送。我们需要关注的是接收到请求之后如何返回响应,因为要在浏览器看到响应,就必须使用HTTP协议。我们可以简单了解一下HTTP响应的格式,首先的它的响应头格式:

HTTP的版本 + 空格 + 状态码(200表示请求成功)+ 空格 + 响应描述

复制代码
HHTP/Version 状态码 响应描述

例如:
HTTP/1.1 200 ok

中间还有很多其他字段可以设置,但是我们就不具体讲了,感兴趣的参考另一篇博文:

https://blog.csdn.net/qq_56776909/article/details/133220078?spm=1001.2014.3001.5501

这些字段之后,加一个空行,空行后面就是我们要向客户端返回的数据。

有了这些了解之后,我们就可以按照HTTP协议的格式自行构造一个HTTP响应返回给浏览器。

代码如下:

java 复制代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Random;
public class Test2 {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(9999);
        System.out.println("Server started on port 9999...");

        while (true) {
            Socket socket = serverSocket.accept();
            String client = socket.getInetAddress().getHostAddress() + ":" + socket.getPort();
//            BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//            String line;
//            while ((line = reader.readLine()) != null && !line.isEmpty()) {  // 打印请求行
//                System.out.println(line);
//            }
            // 打印客户端 IP 和端口
            System.out.println("客户端连接" + client );
            String[] colors = new String[]{"red","pink","blue","green","yellow","orange","purple"};
            int len = colors.length;
            String color = colors[new Random().nextInt(len)];
            // 简单的HTTP响应
            String response =
                    "HTTP/1.1 200 OK\r\n" +
                    "\r\n" +
                    "<h1 style = \"color:" + color + "\">Hello, 【" + client + "】!</h1>";

            socket.getOutputStream().write(response.getBytes("gbk"));  // 浏览器
            socket.close();
        }
    }

}

代码里面注释掉的是打印HTTP协议的请求,如果感兴趣可以打印看看。

运行服务器代码之后,我们可以打开浏览器敲下地址和端口,就可以收到响应:

可以每新开一个标签页访问服务器地址,客户端的端口都是不一样的。

爬虫案例

在第一个案例中,我们是利用浏览器直接作为客户端来访问网页,这样就省去了写客户端代码的步骤。但实际上,我们也可以自己写客户端程序,像浏览器一样发送请求并接收响应,从而获取网页内容。按理说,这是完全可行的。

网页访问是通过 HTTP请求 完成的,因此我们可以构造一个 HTTP 请求来获取网页内容。

需要注意的是,现在几乎所有网站都是 HTTPS 的,而普通的 Socket 无法处理 HTTPS,需要使用 SSLSocket。为了演示方便,这里选择一个可以通过 HTTP 访问的网页,例如百度(虽然只能访问旧版本页面,但仅用于学习 Socket 原理,这没关系)。

如何获取 IP 和端口

很多人会疑问:我们不知道百度的 IP 和端口,如何创建 Socket 对象呢?

  • IP 地址可以直接用域名,Socket 构造方法可以传入域名,它会自动解析 IP。

  • 端口如果不指定,HTTP 默认端口是 80,所以我们可以直接使用 80 端口访问网页。

代码实现

java 复制代码
import java.io.*;
import java.net.Socket;


public class Test {
    public static void main(String[] args) throws IOException {
        String host = "www.baidu.com";
        Socket socket = new Socket(host, 80);
        PrintWriter out = new PrintWriter(socket.getOutputStream());
        // 发送 HTTP GET 请求
        out.println("GET / HTTP/1.1");
        out.println("Host:" + host);
        out.println("Connection:close");  // 如果不关闭连接,代码程序会一直运行和服务端保持连接
        out.println();
        out.flush();
        PrintWriter printWriter = new PrintWriter(new FileWriter("src//baidu.html"));  // 保存到网页
        BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        String s = reader.readLine();
        System.out.println("打印响应头:");
        while (!s.isEmpty()) {
            System.out.println(s);
            s = reader.readLine();
        }
        System.out.println("打印响应体:");
        while(s != null) {
            s = reader.readLine();
            System.out.println(s);
            printWriter.println(s);
        }
        printWriter.flush();
    }
}

注意点

  1. 响应头与响应体由空行分隔,因此我们用两次循环分别读取。

  2. 响应体才是真正的网页内容 ,所以只将响应体写入 baidu.html 文件。

  3. 如果网站使用 HTTPS,就需要使用 SSLSocket 或者直接使用高级 HTTP 库(如 HttpClientJsoup)。

运行程序后,可以在 src/baidu.html 文件中查看网页内容,确认爬取成功。

运行结果如下:

我们可以打开baidu.html文件查看是不是里面写入了响应体的内容,查看响应体长什么样子:

这并不是百度的网址,而是刚刚我们写入的文件。可以看到确实是把百度网页爬取下来了。

相关推荐
煎饼皮皮侠2 小时前
【图解】idea中快速查找maven冲突
java·maven·intellij-idea·冲突
科兴第一吴彦祖2 小时前
在线会议系统是一个基于Vue3 + Spring Boot的现代化在线会议管理平台,集成了视频会议、实时聊天、AI智能助手等多项先进技术。
java·vue.js·人工智能·spring boot·推荐算法
工一木子2 小时前
HashMap源码深度解析:从“图书馆“到“智能仓库“的进化史
java·源码·hashmap
is08152 小时前
NFS 服务器 使用
服务器·网络
平生不喜凡桃李2 小时前
C++ 异常
android·java·c++
SamDeepThinking2 小时前
用设计模式重构核心业务代码的一次实战
java·后端·设计模式
endcy20163 小时前
mybatis-plus多租户兼容多字段租户标识
java·mybatis-plus·多租户
言之。3 小时前
TCP 拥塞控制设计空间课程要点总结
网络·网络协议·tcp/ip
Freed&3 小时前
《没有架构图?用 netstat、ss、tcpdump 还原服务连接与数据流向》
网络·测试工具·tcpdump