【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() 设置超时避免无限阻塞。

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

服务端:

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

客户端:

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 单独分配一个线程,或者使用线程池来提升并发处理能力。

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

服务端代码:

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

客户端代码:

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响应返回给浏览器。

代码如下:

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 端口访问网页。

代码实现

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文件查看是不是里面写入了响应体的内容,查看响应体长什么样子:

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/pingmian/96496.shtml
繁体地址,请注明出处:http://hk.pswp.cn/pingmian/96496.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

HTML标签关系详解:构建网页的骨架结构

前言 在上一篇教程中&#xff0c;我们学习了HTML5的基本结构。今天&#xff0c;让我们深入探讨HTML标签之间的关系。理解HTML标签之间的关系对于构建结构清晰、语义明确的网页至关重要。就像在现实生活中&#xff0c;建筑物的各个部分需要按照一定的规则组合在一起一样&#x…

238 除自身以外数组的的乘积

我的解法&#xff08;没头绪&#xff0c;参考AI的思路&#xff09; 好奇怪啊&#xff0c;这个题目&#xff0c;没什么思路 题目的主要难点是&#xff0c;如何通过这个线性运算得出所有的乘积和&#xff0c;同时不适用除法。 问了下AI&#xff0c;这种题目我可以从什么方向入手&…

智能体:从技术架构到产业落地的深度解析

在人工智能技术从 “感知智能” 向 “认知智能” 跨越的关键阶段&#xff0c;智能体&#xff08;Intelligent Agent&#xff09;作为具备自主决策与环境交互能力的核心载体&#xff0c;正成为连接 AI 算法与产业应用的重要桥梁。不同于传统被动执行指令的软件系统&#xff0c;智…

MATLAB基于组合近似模型和IPSO-GA的全焊接球阀焊接工艺参数优化研究

引言与研究背景 全焊接球阀的重要性&#xff1a;广泛应用于石油、天然气、化工等长输管道和关键装置&#xff0c;其安全性、密封性和耐久性至关重要。阀体一旦发生焊接缺陷&#xff0c;可能导致灾难性后果。 焊接工艺的挑战&#xff1a;焊接是一个涉及电、热、力、冶金的复杂瞬…

EzRemove(ezremove.ai)评测与实操:5 秒在线抠图、支持批量与换底(电商/设计团队提效指南)

摘要&#xff1a;做主图、白底图、海报的小伙伴&#xff0c;经常被“抠图—换底—导出”这套流程折磨。EzRemove 是一个在线 AI 抠图工具&#xff0c;支持5 秒自动抠图、透明 PNG 导出、批量处理、背景替换与基础编辑&#xff0c;无需安装本地软件&#xff0c;适合电商商家、设…

RStudio 教程:以抑郁量表测评数据分析为例

R 语言是一种专为统计计算、数据分析和图形可视化而设计的编程语言&#xff0c;在学术界和工业界都备受青睐。RStudio是一款为 R 语言量身打造的集成开发环境&#xff08;IDE&#xff09;。它如同一个功能强大的指挥中心&#xff0c;能够将数据科学工作所需的一切&#xff1a;控…

Rupert Baines加入CSA Catapult董事会

英国半导体行业领军人物鲁珀特贝恩斯&#xff08;Rupert Baines&#xff09;正式出任英国化合物半导体应用公司&#xff08;CSA Catapult&#xff09;非执行董事&#xff0c;宛若一位经验丰富的航海家将为这艘科技旗舰指引航向。这位三次成功创业退出的科技企业家&#xff0c;将…

第七篇:识破“共因失效”——如何阻止汽车系统的“团灭”危机

想象一下这个场景&#xff1a; 你精心设计了一套双备份的刹车系统&#xff0c;就像给车装了两条独立的刹车线&#xff0c;心想&#xff1a;“这下总万无一失了吧&#xff01;”结果&#xff0c;一场寒潮来袭&#xff0c;两条刹车线因为同一个原因——低温&#xff0c;同时被冻住…

健康大数据与传统大数据技术专业有何不同?

在“数据即资产”的时代&#xff0c;大数据技术已渗透至各行各业。从电商推荐到金融风控&#xff0c;从智能制造到智慧城市&#xff0c;数据驱动的决策模式正在重塑产业格局。然而&#xff0c;随着医疗健康领域数字化进程的加速&#xff0c;一个新兴且高度专业化的分支——健康…

图神经网络分享系列-SDNE(Structural Deep Network Embedding) (三)

目录 一、实验 1.1 数据集 1.2 基线算法 1.3 评估指标 1.4 参数设置 1.5 实验效果 1.5.1 网络重构 1.5.1.1 方法性能优势 1.5.1.2 特定数据集表现 1.5.1.3 模型对比分析 1.5.1.4 邻近性重要性验证 1.5.2 多标签分类 1.5.3 链路预测 1.5.4网络可视化的应用 1.6 参…

《WINDOWS 环境下32位汇编语言程序设计》第16章 WinSock接口和网络编程(1)

当今的时代是网络时代&#xff0c;网络给生活带来的影响超过了以往的任何事物&#xff0c;不管我们是用浏览器上网&#xff0c;是在打网络游戏&#xff0c;还是用MSN、QQ等即时通信软件和朋友聊天&#xff0c;网络的另一端实际上都是对应的网络应用程序在提供服务。大多数的网络…

【笔记】Windows 安装 TensorRT 10.13.3.9(适配 CUDA 13.0,附跨版本 CUDA 调用维护方案)

实操笔记 | Windows 安装 TensorRT 10.13.3.9&#xff08;适配 CUDA 13.0&#xff0c;含跨版本 CUDA 调用维护示例&#xff09;—— 系统 CUDA 13.0 与虚拟环境 CUDA 12.8 版本差异时&#xff0c;TensorRT 调用维护实例详见附录 本文针对 TensorRT-10.13.3.9.Windows.win10.cu…

如何关闭电脑安全和防护

了解你希望关闭电脑的安全和防护功能。⚠️请务必注意&#xff0c;关闭这些防护会使电脑暴露在安全风险中&#xff0c;仅建议在必要时&#xff08;如安装受信任但被误拦的软件、进行网络调试&#xff09;临时操作&#xff0c;完成后请立即重新开启。 下面是一个快速操作指南表格…

C# Entity Framework Core 的 CRUD 操作与关联查询实战示例

以下是基于 Entity Framework Core 的 CRUD 操作与关联查询实战示例&#xff0c;以 用户&#xff08;User&#xff09; 和 订单&#xff08;Order&#xff09; 实体为例&#xff08;一对多关系&#xff09;&#xff0c;包含完整代码和操作说明。一、基础准备1. 实体类定义&…

UniApp状态管理深度重构指南

作为专业智能创作助手&#xff0c;我将帮助你逐步理解并实现UniApp状态管理的深度重构。UniApp基于Vue.js框架&#xff0c;其状态管理通常使用Vuex&#xff0c;但随着应用规模扩大&#xff0c;状态管理可能变得臃肿、难以维护。深度重构旨在优化性能、提升可维护性&#xff0c;…

时序数据库:定义与基本特点

在当今的物联网&#xff08;IoT&#xff09;、 DevOps监控、金融科技和工业4.0时代&#xff0c;我们每时每刻都在产生海量的与时间紧密相关的数据。服务器CPU指标、智能电表读数、车辆GPS轨迹、股票交易记录……这些数据都有一个共同的核心特征&#xff1a;时间是它们不可分割的…

linux系统安装wps

在Linux系统上通过deb包安装WPS Office是个不错的选择。下面是一个主要步骤的概览&#xff0c;我会详细介绍每一步以及可能遇到的问题和解决方法。步骤概览关键操作说明/注意事项1. 下载DEB包访问WPS官网下载需选择与系统架构匹配的版本&#xff08;通常是AMD64&#xff09;2. …

git常见冲突场景及解决办法

场景1.假设一开始 本地拉取了远程最新的代码 就是说本地和远程此时一样 然后本地写了一个新需求git commit了 但是没有提交到远程仓库 然后另外一个地方提交了某个功能的新代码 到远程 此时本地和远程的代码不一样了 而且本地有已经 commit的 这时候 这个本地想同步远程的最新代…

Flink面试题及详细答案100道(41-60)- 状态管理与容错

《前后端面试题》专栏集合了前后端各个知识模块的面试题&#xff0c;包括html&#xff0c;javascript&#xff0c;css&#xff0c;vue&#xff0c;react&#xff0c;java&#xff0c;Openlayers&#xff0c;leaflet&#xff0c;cesium&#xff0c;mapboxGL&#xff0c;threejs&…

【二开】CRMEB开源版按钮权限控制

【二开】CRMEB开源版按钮权限控制使用方法v-unique_auth"order-refund"<el-dropdown-itemv-unique_auth"order-refund">立即退款</el-dropdown-item >或者 满足其中一个即可v-unique_auth"[order-delete,order-dels]"通过管理端权限…