JavaSE-11-网络编程(详细版)
TCP协议是面向连接的通信协议,即在传输数据前先在客户端和服务器端建立逻辑连接,然后再传输数据。它提供了两台计算机之间可靠无差错的数据传输。TCP通信过程如下图所示:TCP ==> Transfer Control Protocol ==> 传输控制协议 TCP协议的特点 * 面向连接的协议 * 只能由客户端主动发送数据给服务器端,服务器端接收到数据之后,可以给客户端响应数据。* 通过三次握手建立
JavaSE-11-网络编程(详细版)
现今社会,网络无处不在,我们上网刷视频、购物、打游戏,都离不开网络通信的支持。没有网络,你就是在玩单机游戏,只能在自己的电脑上折腾,没办法访问其他计算机的资源,无法和其他计算机服务器通信。
网络协议,使用最广泛的是http协议,而它的底层依赖于tcp/ip协议,七层网络协议结构,从上往下,依次依赖,直接从应用层http到物理光缆,这就是说,咱们计算机最终形态还是通过物理的光纤电缆进行信息传递和通信。而无线wifi也是达到一定局域网,比如:家里、公司。才能使用,手机、电脑上有无线网卡才能接收到信号。
本文主要讲述Java在网络编程这块知识点,包括:基础网络概念、通信协议(TCP、UDP)、底层实现(BIO、NIO、AIOI)等内容,辅以多个实战案例,加深理解。
- IP:网络世界标识一台通信设备的地址。
- port:标识设备里面众多应用程序的其中一个,程序启动后会占用端口进行监听,端口具有排他性。
- http:http协议互联网世界应用最广泛的协议,基于tcp/ip实现,请求/响应模式。
- UDP:无连接,基于数据包,发出去就不管了,性能好,可能丢失数据。
- TCP:有连接,基于通信管道,可靠传输。
- BS:指的是浏览器-服务器架构,
browser-server - CS:指的是客户端(一般指PC桌面)-服务器架构,
client-server - Socket:表示客户端程序,套接字,Java底层封装了通信细节
- ServerSocket:表示服务端程序,Java底层封装了通信细节
- BIO:同步阻塞式通信,线程要与客户端耦合,没有数据还要死等!并发越高,死的越快!!
- NIO:同步非阻塞:只需要开始一个线程接收无数个客户端,再开启一个线程负责轮询所有的客户端是否有数据,有数据才开启一个线程处理它。适合连接多但是数据短的连接。性能较好!!
- AIO:异步非阻塞,依赖操作系统的事件通知,IO完成了,通知应用层进行回调处理。
一、基础网络概念
1.1 软件结构
我们常说网络通信,但具体到应用,也就是不同计算机软件之间的通信,因为每台计算机上都有许多应用程序,而浏览器软件属于使用比较多的软件,它的作用是通过输入地址,获取其他计算机服务器的网页资源,接着在本地浏览器中渲染,就成我们看到的页面。在一些购物网站上,博客网站,你甚至还可以进行交互。这种协作模式称之为:浏览器-服务器结构,简称:B/S结构。browser and server。而浏览器使用的就是http协议,请求-响应模式,web协议,应用非常广泛。
一般桌面应用程序,比如:常见的办公软件:wps、百度网盘;社交软件:qq、微信等,也是和他们的服务器进行通信的,这些供应商,一般提供客户端给我们下载,本地安装,与他们的服务器进行通信。供应商提供了完整客户端+服务端软件。消费者通过客户端发送接收信息,进行享受服务。这样的结构,称之为:C/S 结构,client and server。
总得来说,无论是浏览器还是桌面应用,都是客户端,具备与服务器通信的能力。区别在于:浏览器提供的网页入口,可以很轻松地升级迭代;而桌面应用的客户端,需要自己确认是否下载更新包,然后进行更新,可以自己决定是否更新迭代。服务器则都是供应商提供,供应商给用户提供的入口,浏览器使用的是web网页,桌面应用的是一个个app包、exe执行包等。
当然,充当客户端角色的,还可以根据操作系统,设备来区分。电脑端的,也称pc端;手机端的,ios、安卓应用;网页端,web、h5;还有一些嵌套在手机应用上自家生态系统的小程序应用、或公众号(微信)、或服务号(支付宝),这些轻量级的小应用,都是依托于供应商自己搭建的平台来提供入口,提供运行环境,都是套娃。
硬件(电脑、手机、平板)-> 操作系统OS(windows、Android、ios、嵌入式系统)-> 浏览器、桌面应用、手机app -> 特定App(微信、支付宝、银行app)-> 小程序(微信小程序、支付宝小程序、抖音小程序……)
-> H5(可选,本质也是web网页,只是在手机端使用,适配手机浏览器,或其他小程序、公众号嵌套使用)
C/S结构
全称为Client/Server结构,是指客户端和服务器结构。常见程序有QQ、迅雷等软件。
B/S结构
全称为Browser/Server结构,是指浏览器(也是客户端的一种)和服务器结构。常见浏览器有谷歌、必应等。
两种架构各有优势,但是无论哪种架构,都离不开网络的支持。网络编程,就是在一定的协议下,实现两台计算机的通信程序。
1.2 网络通信协议
网络通信协议:通信协议是对计算机必须遵守的规则,只有遵守这些规则,计算机之间才能进行通信。这就好比在道路中行驶的汽车一定要遵守交通规则一样,协议中对数据的传输格式、传输速率、传输步骤等做了统一规定,通信双方必须同时遵守,最终完成数据交换。
就是一份抽象的说明书,没有具体实现,但是指引了实现的方向,规范了什么是对与错,类比Java概念:接口、抽象方法、类。而具体实现协议的内容,则称为:对象。
TCP/IP协议: 传输控制协议/因特网互联协议(Transmission Control Protocol/Internet Protocol),是Internet最基本、最广泛的协议。它定义了计算机如何连入因特网,以及数据如何在它们之间传输的标准。它的内部包含一系列的用于处理数据通信的协议,并采用了4层的分层模型,每一层都呼叫它的下一层所提供的协议来完成自己的需求。
1.3 协议分类
通信协议是比较复杂的,所以java设计者提供了java.net ,辅助我们进行网络编程开发。使用这些包的类和接口即可,它们提供低层次的通信细节(封装),不用考虑通信的细节(封装层次)。
java.net 包中提供了两种常见的网络协议的支持:TCP和UDP。
TCP:传输控制协议 (Transmission Control Protocol)。TCP协议是面向连接的通信协议,即传输数据之前,在发送端和接收端建立逻辑连接(new Socket(ip,port)),然后再传输数据(read、write),它提供了两台计算机之间可靠无差错的数据传输。三次握手:TCP协议中,在发送数据的准备阶段,客户端与服务器之间的三次交互,以保证连接的可靠。
第一次握手:客户端尝试请求连接
客户端向服务器端发出连接请求,等待服务器确认。服务器你死了吗?【服务端先启动,客户端后启动】
第二次握手:服务端接收并回应
服务器端向客户端回送一个响应,通知客户端收到了连接请求。我活着啊!!
第三次握手:客户端确认连接
客户端再次向服务器端发送确认信息,确认连接。整个交互过程如下图所示。我知道了!!
完成三次握手,连接建立后,客户端和服务器就可以开始进行数据传输了。由于这种面向连接的特性,TCP协议可以保证传输数据的安全,所以应用十分广泛,例如:
1、下载文件(缺失数据、文件不完整、拿来也没用)
2、浏览网页(页面内容缺失,看了也花眼)
new Socket(xxx,8080) // 构建客户端时,此时已经在尝试建立与服务器的连接了,如果三次握手不成功,则会报错。所以这里保证了数据传输的可靠性。
UDP:用户数据报协议(User Datagram Protocol)。UDP协议是一个面向无连接的协议。传输数据时,不需要建立连接,不管对方端服务是否启动,直接将数据、数据源和目的地都封装在数据包中,直接发送。每个数据包的大小限制在64k以内。它是不可靠协议,因为无连接,所以传输速度快,但是容易丢失数据。例如:
1、视频会议(影像模糊、说话断断续续,但问题不大,还能使用)
2、QQ聊天(发过去了,有没有人收到?!平台作为中间转发者保证数据达到)
1.4 网络编程三要素
协议
计算机网络通信必须遵守的规则,制定了统一规范,大家才能按约定发送和接收信息,有效通信。
IP地址
IP地址:指互联网协议地址(Internet Protocol Address),俗称IP。IP地址用来给一个网络中的计算机设备做唯一的编号。假如我们把“个人电脑”比作“一台电话”的话,那么“IP地址”就相当于“电话号码”。
IP地址分类 :
- IPv4:是一个32位的二进制数,通常被分为4个字节,表示成
a.b.c.d的形式,例如192.168.65.100。其中a、b、c、d都是0~255之间的十进制整数,那么最多可以表示42亿个。 - IPv6:由于互联网的蓬勃发展,IP地址的需求量愈来愈大,但是网络地址资源有限,使得IP的分配越发紧张。有资料显示,全球IPv4地址在2011年2月分配完毕。为了扩大地址空间,拟通过IPv6重新定义地址空间,采用128位地址长度,每16个字节一组,分成8组十六进制数,表示成
ABCD:EF01:2345:6789:ABCD:EF01:2345:6789,号称可以为全世界的每一粒沙子编上一个网址,这样就解决了网络地址资源数量不够的问题。
常用命令
- 查看本机IP地址,在控制台输入:
ipconfig
- 检查网络是否连通,在控制台输入:
ping 空格 IP地址 ping 220.181 .57 .216 ping www.baidu.com
特殊的IP地址
- 本机IP地址:
127.0.0.1、localhost。
端口号
网络的通信,本质上是两个进程(应用程序)的通信。每台计算机都有很多的进程,那么在网络通信时,如何区分这些进程呢?端口!这些端口相当于通信的口子,这个应用程序占用了,其他程序就只能使用其他端口。
如果说IP地址可以唯一标识网络中的设备,那么端口号就可以唯一标识设备中的进程(应用程序)了。
类比:IP-一个小区地址,端口号-具体房子门牌号
端口号:用两个字节表示的整数,它的取值范围是0~65535(2的16次方)。其中,0~1023之间的端口号用于一些知名的网络服务和应用,普通的应用程序需要使用1024以上的端口号。如果端口号被另外一个服务或应用所占用,会导致当前程序启动失败。
利用协议+IP地址+端口号 三元组合,就可以标识网络中的进程了,那么进程间的通信就可以利用这个标识与其它进程进行交互。
InetAddress表示IP
InetAddress 类是 Java 网络编程中的一个核心类,用于表示互联网协议(IP)地址,一个对象表示一个IP。它提供了多个方法来处理 IP 地址相关的操作。主要功能:
- 表示 IP 地址:
InetAddress类表示 Internet 上的主机地址,包括 IPv4 和 IPv6 地址。 - 主机名解析:可以将主机名解析为 IP 地址,也可以将 IP 地址反向解析为主机名。
- 地址信息获取:提供获取主机地址和主机名的方法。
常用实例方法:
-
getLocalHost():返回本地主机的InetAddress对象
-
getLoopbackAddress():返回回环地址(通常是 127.0.0.1)
-
getByName(String host):根据主机名或 IP 地址字符串创建InetAddress对象
-
getAddress():返回 IP 地址的字节数组表示
-
getHostName():返回主机名
-
getHostAddress():返回 IP 地址字符串
静态方法:
InetAddress.getLocalHost()获取本地主机地址InetAddress.getLoopbackAddress()获取回环地址InetAddress.getByName("172.26.192.1")根据 IP 地址创建对象
注意:
InetAddress类是不可变的(immutable)- 该类的一些方法可能涉及网络请求,因此可能抛出
UnknownHostException - 支持 IPv4 和 IPv6 两种地址格式
public static void main (String[] args) throws Exception { // 静态方法,获得IP地址对象,表示一个IP地址 System.out.println(InetAddress.getLocalHost()); // 本地主机名+ip地址,如:SK-20241207WBUL/172.26.192.1 System.out.println(InetAddress.getLoopbackAddress()); // 回环地址(固定):localhost/127.0.0.1 // 根据IP地址字符串、域名(IP的别名,方便记忆)、主机名(host,表示任意网络设备)入参,获取IP地址对象 System.out.println( "========IP入参===============" ); InetAddress address = InetAddress.getByName( "172.26.192.1" ); System.out.println(Arrays.toString(address.getAddress())); System.out.println(address.getHostName()); System.out.println(address.getHostAddress()); System.out.println( "========域名入参===============" ); InetAddress address2 = InetAddress.getByName( "www.google.com" ); System.out.println(Arrays.toString(address2.getAddress())); System.out.println(address2.getHostName()); System.out.println(address2.getHostAddress()); System.out.println( "========主机名入参===============" ); InetAddress address3 = InetAddress.getByName( "SK-20241207WBUL" ); System.out.println(Arrays.toString(address3.getAddress())); System.out.println(address3.getHostName()); System.out.println(address3.getHostAddress()); }
输出:
SK-20241207WBUL/ 172.26 .192 .1 localhost/ 127.0 .0 .1 ========IP入参=============== [- 84 , 26 , - 64 , 1 ] SK-20241207WBUL.mshome.net 172.26 .192 .1 ========域名入参=============== [- 57 , 59 , - 108 , 96 ] www.google.com 199.59 .148 .96 ========主机名入参=============== [- 84 , 26 , - 64 , 1 ] SK-20241207WBUL 172.26 .192 .1
Host、域名、IP地址
-
- IP地址(Internet Protocol Address)
- 是网络中设备的唯一标识符
- IPv4格式:如
172.26.192.1(四组0-255的数字) -
IPv6格式:如
2001:0db8:85a3:0000:0000:8a2e:0370:7334 -
- 域名(Domain Name)
- 是便于人类记忆的网站地址表示形式
- 例如:
www.google.com、github.com - 通过DNS系统解析为对应的IP地址
-
先在本地host找映射,没有再去找浏览器缓存,路由器DNS,互联网DNS,目标是找到:IP
-
- Host(主机)
- 指网络中的任何设备(计算机、服务器等)
- 可以通过IP地址或主机名来标识
-
在
InetAddress类中,host可以是主机名、域名或IP地址 -
域名是IP地址的别名:域名通过DNS解析映射到具体的IP地址
- Host是通用概念:可以用IP地址或域名来标识一个host
- 一对一或多对一关系:一个IP地址可以对应多个域名,但一个域名通常对应一个IP地址
实例:
InetAddress address = InetAddress.getByName( "172.26.192.1" );
这里的 "172.26.192.1" 是一个IP地址,但也可以是:
- 域名:
"www.example.com" - 主机名:
"localhost" - 回环地址:
"127.0.0.1"
InetAddress 类会自动处理这些不同形式的host标识符,并将其统一表示为网络地址对象。
二、TCP编程
2.1 TCP协议概述
- TCP协议是面向连接的通信协议,即在传输数据前先在客户端和服务器端建立逻辑连接,然后再传输数据。它提供了两台计算机之间可靠无差错的数据传输。TCP通信过程如下图所示:
TCP ==> Transfer Control Protocol ==> 传输控制协议 TCP协议的特点 * 面向连接的协议 * 只能由客户端主动发送数据给服务器端,服务器端接收到数据之后,可以给客户端响应数据。 * 通过三次握手建立连接,连接成功形成数据传输通道。 * 通过四次挥手断开连接 * 基于IO流进行数据传输 * 传输数据大小没有限制 * 因为面向连接的协议,速度慢,但是是可靠的协议。 TCP协议的使用场景 * 文件上传和下载 * 邮件发送和接收 * 远程登录 TCP协议相关的类 * Socket * 一个该类的对象就代表一个客户端程序。 * ServerSocket * 一个该类的对象就代表一个服务器端程序。 Socket类构造方法 * Socket(String host, int port) * 根据ip地址字符串和端口号创建客户端Socket对象 * 注意事项:只要执行该方法,就会立即连接指定的服务器程序,如果连接不成功,则会抛出异常。 如果连接成功,则表示三次握手通过。 Socket类常用方法 * OutputStream getOutputStream () ; 获得字节输出流对象 * InputStream getInputStream () ;获得字节输入流对象
2.2 TCP通信案例
2.3 服务端Server
public class TCPServer { public static void main (String[] args) throws IOException { server_one_client( 8081 ); } public static void server_one_client ( int port) throws IOException { // 创建服务端,占用8081端口 ServerSocket serverSocket = new ServerSocket (port); System.out.println( "服务端已启动,等待连接……" ); // 阻塞等待客户端连接 Socket client = serverSocket.accept(); // 读取client的请求 InputStream is = client.getInputStream(); // 相当于打开了一个网络资源连接句柄 // String s = new String(is.readAllBytes()); // 不要使用这个方法,读取全部,会阻塞 System.out.print( "收到客户端的请求:" ); byte [] buf = new byte [ 1024 ]; int len = is.read(buf); // 会阻塞读取资源 System.out.println( new String (buf, 0 ,len, StandardCharsets.UTF_8)); // 输出响应给client OutputStream os = client.getOutputStream(); String msg = "收到你的请求了,请三个工作日后来拿结果报告,你的事件处理回执单号:" +System.currentTimeMillis(); os.write(msg.getBytes()); os.flush(); client.shutdownOutput(); // 关闭网络连接 client.close(); serverSocket.close(); } }
ServerSocket 类的核心方法
构造方法
ServerSocket(int port):创建一个服务器套接字,绑定到指定的端口
ServerSocket serverSocket = new ServerSocket ( 8081 );
这行代码创建了一个绑定到 8081 端口的服务器套接字,开始监听该端口上的连接请求。
accept() 方法
accept():监听并接受到此套接字的连接,这是一个阻塞方法
Socket client = serverSocket.accept();
这是 ServerSocket 最重要的方法,它会一直阻塞等待客户端连接请求,当有客户端连接时返回一个 Socket 对象用于与该客户端通信。
其他常用方法
bind(SocketAddress endpoint):将 ServerSocket 绑定到特定地址(IP 地址和端口号)bind(SocketAddress endpoint, int backlog):将 ServerSocket 绑定到特定地址,并指定最大连接等待队列长度close():关闭服务器套接字
serverSocket.close();
释放绑定的端口和相关资源
isClosed():判断 ServerSocket 是否已关闭isBound():判断 ServerSocket 是否已绑定到地址setSoTimeout(int timeout):设置 accept() 方法的超时时间,超时后抛出SocketTimeoutExceptiongetLocalPort():返回此套接字监听的本地端口号getInetAddress():返回服务器套接字绑定的本地地址
工作流程
ServerSocket 的工作流程如下:
-
- 创建和绑定:
new ServerSocket(8081)创建并绑定到 8081 端口
- 创建和绑定:
-
- 监听连接:
serverSocket.accept()阻塞等待客户端连接
- 监听连接:
-
- 处理通信:通过返回的
Socket对象与客户端进行数据交换
- 处理通信:通过返回的
-
- 资源释放:处理完客户端请求后调用
serverSocket.close()关闭服务器套接字
- 资源释放:处理完客户端请求后调用
这种模式是典型的 TCP 服务器实现方式,适用于一对一的客户端-服务器通信场景。
2.4 客户端client
public class TCPClient { public static void main (String[] args) throws Exception { one_client(InetAddress.getByName( "127.0.0.1" ), 8081 ); } public static void one_client (InetAddress ip, int port) throws IOException { // 创建客户端,连接服务端 Socket client = new Socket (ip, port); System.out.println( "客户端启动,连接上了服务端" ); // 发送请求给服务端 String request = "你好工作人员,我这里需要办理签证,这是资料" ; OutputStream os = client.getOutputStream(); os.write(request.getBytes(StandardCharsets.UTF_8)); os.flush(); // 强制刷新输出流,将本地缓存的数据都立即发送出去 client.shutdownOutput(); // 关闭socket的输出流,表示客户端不再发送数据 // 收到服务端的回复 InputStream is = client.getInputStream(); // String s = new String(is.readAllBytes()); // 不要使用该方法,有毒,会阻塞,等待连接关闭 System.out.print( "收到服务端的回复:" ); byte [] b = new byte [ 1024 ]; int len = is.read(b); System.out.println( new String (b, 0 ,len, StandardCharsets.UTF_8)); // 关闭资源 client.close(); } }
Socket 类的主要方法
构造方法
Socket(InetAddress address, int port):创建一个流套接字并将其连接到指定 IP 地址的指定端口号Socket(String host, int port):创建一个流套接字并将其连接到指定主机名和端口号
常用方法
getOutputStream():返回此套接字的输出流,用于向服务器发送数据getInputStream():返回此套接字的输入流,用于接收服务器发送的数据shutdownOutput():关闭套接字的输出流,表示客户端不再发送数据- close():关闭套接字连接,释放相关资源
write 和 flush 方法
write 方法
OutputStream 的 write 方法用于将数据发送到服务器:
os.write(request.getBytes(StandardCharsets.UTF_8));
- write(byte[] b):将指定字节数组中的所有字节写入输出流
- 这个方法将字符串转换为 UTF-8 编码的字节后发送给服务器
flush 方法
os.flush();
flush():刷新输出流,强制将缓冲区中所有已写入但尚未发送的数据立即发送出去- 在网络编程中,这个方法确保数据真正发送到服务器,而不是停留在本地缓冲区中
这两个方法配合使用,可以确保客户端的数据完整地发送到服务器。在调用 shutdownOutput() 之前使用 flush() 是一个好习惯,确保所有数据都被发送出去。
三、UDP编程
3.1 UDP协议概述
UDP是无连接通信协议,即在数据传输时,数据的发送端和接收端不建立逻辑连接。简单来说,当一台计算机向另外一台计算机发送数据时,发送端不会确认接收端是否存在,就会发出数据,同样接收端在收到数据时,也不会向发送端反馈是否收到数据。
由于使用UDP协议消耗资源小,通信效率高,所以通常都会用于音频、视频和普通数据的传输例如视频会议都使用UDP协议,因为这种情况即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。
但是在使用UDP协议传送数据时,由于UDP的面向无连接性,不能保证数据的完整性,因此在传输重要数据时不建议使用UDP协议。
UDP协议的特点
- 面向无连接的协议
- 发送端只管发送,不确认对方是否能收到。
- 基于数据包进行数据传输。
- 发送数据的大小限制64K以内
- 因为面向无连接,速度快,但是不可靠。
UDP协议的使用场景
- 即时通讯
- 在线视频
- 网络语音电话
两个核心类
-
- DatagramPacket:数据包对象;作用:用来封装要发送或要接收的数据,比如:集装箱
-
- DategramSocket:发送对象;作用:用来发送或接收数据包,比如:码头
DatagramPacket类构造方法
-
- DatagramPacket(byte[] buf, int length, InetAddress address, int port)
- 创建发送端数据包对象
- buf:要发送的内容,字节数组
- length:要发送内容的长度,单位是字节
- address:接收端的IP地址对象
-
port:接收端的端口号
-
- DatagramPacket(byte[] buf, int length)
- 创建接收端的数据包对象
- buf:用来存储接收到内容
- length:能够接收内容的长度
DatagramPacket类常用方法
* int getLength() 获得实际接收到的字节个数
DatagramSocket类构造方法
* DatagramSocket() 创建发送端的Socket对象,系统会随机分配一个端口号。
* DatagramSocket(int port) 创建接收端的Socket对象并指定端口号
DatagramSocket类成员方法
* void send(DatagramPacket dp) 发送数据包
* void receive(DatagramPacket p) 接收数据包
3.2 发送端
public class UDPClient { public static void main (String[] args) throws Exception { // 创建发送的数据包 String msg = "hello,server,my name is client" ; byte [] data = msg.getBytes(StandardCharsets.UTF_8); // 标记数据包发送地址,所以入参有IP+端口(7777目标端口),以及发送数据 DatagramPacket packet = new DatagramPacket (data, 0 , data.length, InetAddress.getLocalHost(), 7777 ); System.out.println( "待发送内容:" +msg); // 创建发送socket DatagramSocket sendSocket = new DatagramSocket ( 8888 ); // 自身启动一个socket程序,并占用8888端口 // 发送 sendSocket.send(packet); System.out.println( "发送成功!并且关闭" ); // 关闭资源 sendSocket.close(); } }
3.3 接收端
public class UDPServer { public static void main (String[] args) throws Exception { // 创建接收数据包 int len = 1024 ; byte [] recData = new byte [len]; // 此时数据包作为接收包,仅入参初始化后的字节数组容器即可,用于接收数据 DatagramPacket packet = new DatagramPacket (recData, 0 ,len); // 里面的其他参数,在接收数据时被系统调用并设置 // 创建接收端 DatagramSocket recSocket = new DatagramSocket ( 7777 ); // 启动socket程序并占用7777端口 System.out.println( "接收端启动完毕……" ); // 此时作为接收端存在 // 接收数据并读取 recSocket.receive(packet); // 实际接收长度 int length = packet.getLength(); System.out.println( "接收到内容:" + new String (recData, 0 ,length, StandardCharsets.UTF_8)); // 获得发送端信息 InetAddress sendAddress = packet.getAddress(); int port = packet.getPort(); System.out.println( "发送方的信息:" +sendAddress.getHostAddress()+ ",端口:" +port); // 关闭连接 recSocket.close(); } }
结果:
3.4 数据包DatagramPacket
作用:作为发送和接收数据的载体。
怎样区分这个数据包是发送数据,还是接收数据的呢?看构造的参数!
思考一下,如果作为一个发送数据的包,这个包裹需要什么信息,才能准确【投递】到目的地?
- 发送的数据,要吧!
byte[],带上具体需要发送的数据,offset和length - 目的地,要吧,ip+端口,这里是
InetAddress表示地址
结论:有地址入参的,就是发送数据。
而作为接收数据,那就好构造了,就构造一个接收容器,byte[]即可,带上长度。
类图
address,发送地址,ipport,发送端口length,发送或接收长度buf,数据容器offset,开始读取数据的地方,偏移量
3.5 发送接收DatagramSocket
因为是无连接的情况,和数据包一样,也是具有发送和接收的功能,具体归属性质,取决于构造入参。
思考一下?怎么构建一个发送端?
- 因为不需要和接收端建立联系,所以只需要正常打开个socket程序即可,占用一个端口
-
send(数据包) -
同理,接收端也是如此,占用一个端口,表示接收端即可
receive(数据包)
类图
四、BIO综合案例
4.1 网络文件上传
-
- 【客户端】输入流,从硬盘读取文件数据到程序中。
-
- 【客户端】输出流,写出文件数据到服务端。
-
- 【服务端】输入流,读取文件数据到服务端程序。
-
- 【服务端】输出流,写出文件数据到服务器硬盘中。
-
- 【服务端】获取输出流,回写数据。
-
- 【客户端】获取输入流,解析回写数据。
服务器不关闭资源,无限循环等待客户端连接。一个客户端连接,使用一个线程处理。
- 【客户端】获取输入流,解析回写数据。
本地测试文件拷贝
public class FileTest { public static void main (String[] args) throws IOException { Files.copy(Path.of( "client-test.jpg" ), Path.of( "client-test-copy.jpg" ), StandardCopyOption.REPLACE_EXISTING); System.out.println( "文件拷贝成功" ); } }
文件服务器
public class FileServer { public static void main (String[] args) throws Exception { // 创建文件服务器 int port = 8080 ; ServerSocket server = new ServerSocket (port); System.out.println( "文件服务器启动成功!占用端口:" +port); // 处理逻辑:接收文件流,保存到本地,关闭此次客户端连接 int clientNum = 0 ; // 等待客户端连接 while ( true ) { Socket client = server.accept(); // 阻塞等待建立客户端连接 clientNum++; System.out.printf( "客户端%s号成功连接!%n" ,clientNum); // 无限循环,不关闭服务端,每个客户端连接使用一个线程处理 new Thread (() -> { // 读取客户端上传的内容 try ( InputStream is = client.getInputStream(); OutputStream os = client.getOutputStream(); BufferedOutputStream bos = new BufferedOutputStream ( new FileOutputStream ( String.format( "client_%s.jpg" ,System.currentTimeMillis()))); ) { // 拿个桶去接 byte [] cache = new byte [ 1024 * 8 ]; int sum = 0 ; int len ; while ((len = is.read(cache)) != - 1 ) { bos.write(cache, 0 ,len); sum = sum + len; } bos.flush(); // 写个回执给客户端,告诉它文件接收完毕 String res = String.format( "文件接收完毕,共计:%s字节" ,sum); byte [] resData = res.getBytes(StandardCharsets.UTF_8); os.write(resData, 0 ,resData.length); // 强转输出 client.shutdownOutput(); // 关闭资源 client.close(); } catch (Exception e) { e.printStackTrace(); } }).start(); } } }
文件客户端
public class FileClient { public static void main (String[] args) throws Exception { // 创建文件客户端 Socket client = new Socket ( "localhost" , 8080 ); // 服务器地址+端口 System.out.println( "客户端连接成功!" ); // 上传本地文件 try ( FileInputStream fis = new FileInputStream ( "client-test.jpg" ); OutputStream os = client.getOutputStream(); InputStream isc = client.getInputStream() ) { byte [] cache = new byte [ 1024 ]; int len; while ((len = fis.read(cache)) != - 1 ) { os.write(cache, 0 ,len); } // 强制把缓存的数据传输出去,告诉服务端:俺发送完了 client.shutdownOutput(); System.out.println( "客户端上传完毕!" ); // 接收服务器返回的回执 byte [] recData = new byte [ 1024 ]; int read = isc.read(recData); String content = new String (recData, 0 ,read, StandardCharsets.UTF_8); System.out.println( "服务器回执:" +content); // 关闭资源 client.close(); } } }
4.2 模拟B\S服务器
模拟网站服务器,使用浏览器访问自己编写的服务端程序,查看网页效果。
案例分析
-
- 准备页面数据,web文件夹。
-
- 我们模拟服务器端,ServerSocket类监听端口,使用浏览器访问,查看网页效果
案例实现
浏览器工作原理是遇到图片会开启一个线程进行单独的访问,因此在服务器端加入线程技术。
public static void main (String[] args) throws IOException { // 构建图片服务器,供浏览器访问 int port = 8090 ; ServerSocket webServer = new ServerSocket (port); System.out.println( "图片服务器已启动!主目录:/pic" ); // 接收浏览器请求 while ( true ) { Socket client = webServer.accept(); System.out.println( "有浏览器发送连接请求,连接建立成功!" ); new Thread ( new PicHandler (client)).start(); } }
public static class PicHandler implements Runnable { private final Socket socket; public PicHandler (Socket socket) { this .socket = socket; } @Override public void run () { // 获取浏览器请求 try ( // 将字节流转为字符流,并使用缓冲流包装,提升读取效率,最佳实战 BufferedReader bd = new BufferedReader ( new InputStreamReader (socket.getInputStream())); OutputStream os = socket.getOutputStream(); ) { String first = bd.readLine(); // 首行内容 System.out.println( "首行内容:" +first); String[] arr = first.split( " " ); // 根据http协议,请求报文首行格式,使用空格分组。如:http/1.1 /pic/1.jpg String path = arr[ 1 ].substring( 1 ); // 获取图片访问地址,如:pic/1.jpg // 完整请求 // bd.lines().forEach(System.out::println); // 会阻塞 os.write( "HTTP/1.1 200 Ok \r\r" .getBytes(StandardCharsets.UTF_8)); FileInputStream fis = null ; try { fis = new FileInputStream (path); // 写出图片 os.write( "content-type:image/jpg\r\n" .getBytes(StandardCharsets.UTF_8)); os.write( "\r\n" .getBytes(StandardCharsets.UTF_8)); byte [] picData = new byte [ 1024 ]; int len; while ((len = fis.read(picData)) != - 1 ) { os.write(picData, 0 ,len); } } catch (FileNotFoundException e) { os.write( "content-type:text/plain\r\n" .getBytes(StandardCharsets.UTF_8)); os.write( "\r\n" .getBytes(StandardCharsets.UTF_8)); os.write( "文件找不到" .getBytes(Charset.forName( "GBK" ))); } // 强制输出缓冲区数据,告诉浏览器,数据发送完毕 socket.shutdownOutput(); socket.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } } }
访问效果
五、NIO编程
5.1 NIO概述
1、同步与异步(synchronous/asynchronous)
同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回(由另外一条线程去执行支线任务,一般不是当前线程执行),通常依靠事件、回调等机制来实现任务间次序关系(子任务完成后,执行的线程会回调预设的逻辑接口,前提是要注册好逻辑处理器)。
2、阻塞与非阻塞(线程状态)
在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如ServerSocket新连接建立完毕(accept),或者数据读取(read)、写入(write)操作完成;而非阻塞则是不管IO操作是否结束,直接返回,相应操作在后台继续处理。
在Java1.4之前的I/O系统中,提供的都是面向流的I/O系统,系统一次一个字节地处理数据,一个输入流产生一个字节的数据,一个输出流消费一个字节的数据,面向流的I/O速度非常慢,而在Java 1.4中推出了NIO,这是一个面向块的I/O系统,系统以块的方式处理处理,每一个操作在一步中产生或者消费一个数据库,按块处理要比按字节处理数据快的多。
在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
5.2 NIO(同步+非阻塞)
NIO之所以是同步,是因为它的accept/read/write方法的内核I/O操作都会阻塞当前线程。
首先,我们要先了解一下NIO的三个主要组成部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
Channel(通道)
Channel(通道):Channel是一个对象,可以通过它读取和写入数据。可以把它看做是IO中的流,不同的是:
- Channel是双向的,既可以读又可以写,而流是单向的(in -> out)
- Channel可以进行异步的读写
- 对Channel的读写必须通过buffer对象(分离操作和数据,解耦)
正如上面提到的,所有数据都通过Buffer对象处理,所以永远不会将字节直接写入到Channel中,相反是将数据写入到Buffer中;同样也不会从Channel中读取字节,而是将数据从Channel读入Buffer,再从Buffer获取这个字节。因为Channel是双向的,所以Channel可以比流更好地反映出底层操作系统的真实情况。特别是在Unix模型中,底层操作系统通常都是双向的。
在Java NIO中的Channel主要有如下几种类型:
- FileChannel:从文件读取数据的
- DatagramChannel:读写UDP网络协议数据
- SocketChannel:读写TCP网络协议数据
- ServerSocketChannel:可以监听TCP连接
Buffer(缓冲区)
结论:封装数据读写操作,不是单纯字节数组,通过4个属性,标记元素下标,进行精确读写。
Buffer是一个对象,它包含一些要写入或者读到Stream对象的。应用程序不能直接对 Channel 进行读写操作,而必须通过 Buffer 来进行,即 Channel 是通过 Buffer 来读写数据的。
在NIO中,所有的数据都是用Buffer处理的,它是NIO读写数据的中转池。Buffer实质上是一个数组,通常是一个字节数据,但也可以是其他类型的数组。但一个缓冲区不仅仅是一个数组,重要的是它提供了对数据的结构化访问,而且还可以跟踪系统的读写进程。
因为这4个属性:
// Invariants: mark <= position <= limit <= capacity private int mark = - 1 ; // 标记,便于复位 private int position = 0 ; // 开始读取或写入的位置,相当于start private int limit; // 不能读取或写入的第一个元素位置,相当于end,初始化时= capacity private final int capacity; // 初始化时就定义的容器容量大小,最多能装多少
关系:
0 <= mark <= position <= limit <= capacity
读写数据遵循以下四个步骤:
-
- 写入数据到 Buffer;
-
- 调用 flip() 方法;
-
- 从 Buffer 中读取数据;
-
- 调用 clear() 方法或者 compact() 方法。
filp方法,切换起始位置,就是为了能从0开始读取数据:
public Buffer flip () { limit = position; // 此时的position为写入数据的当前索引位置 position = 0 ; // 重置读取位置为0 mark = - 1 ; return this ; }
clear方法,则是清空缓冲区,相当于初始化时的状态
public Buffer clear () { position = 0 ; limit = capacity; mark = - 1 ; return this ; }
当向 Buffer 写入数据时,Buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 Buffer 的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear() 或 compact() 方法。clear() 方法会清空整个缓冲区。compact() 方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
Buffer主要有如下几种:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
CopyFile执行三个基本的操作:创建一个Buffer,然后从源文件读取数据到缓冲区,然后再将缓冲区写入目标文件。
public class NIOCopyFile { public static void main (String[] args) throws IOException { // 读取文件输入流 FileChannel is = new FileInputStream ( "pic/1.jpg" ).getChannel(); // 文件输出流 FileChannel os = new FileOutputStream ( "2-cop.jpg" ).getChannel(); // 创建容器,特殊的字节数组,通过几个属性,精细化读取写入操作 ByteBuffer buf = ByteBuffer.allocate( 1024 ); // 循环读取 while ( true ) { // 将流读取到容器 int read = is.read(buf); // -1表示读取结束 if (read == - 1 ) { System.out.println( "读取完毕" ); break ; } // 切换容器模式,进行写出 buf.flip(); os.write(buf); // 写完清空一下 buf.clear(); } // 关闭资源 is.close(); os.close(); System.out.println( "文件拷贝成功!" ); } }
Selector(选择器)
结论:一个监听器对应多个事件源(连接、通道),并且基于系统级别的事件机制,拆分整个流程为4个事件,订阅相关事件,系统级层面通过select、epoll机制主动告知应用层面去处理,高效!
首先需要了解一件事情就是线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。
Selector是一个对象,它可以注册到很多个Channel上,监听各个Channel上发生的事件,并且能够根据事件情况决定Channel读写。这样,通过一个线程管理多个Channel,就可以处理大量网络连接了。(一对多,一个监听器,监听多个连接通道发生的事件情况)
有了Selector,我们就可以利用一个线程来处理所有的channels。线程之间的切换对操作系统来说代价是很高的,并且每个线程也会占用一定的系统资源。所以,对系统来说使用的线程越少越好。
selector 解决了BIO中建立连接后,accept-read,等待读取请求的阻塞时间,将这一块由于线程挂起等待的时间,给大大的优化了,通过系统层级的epoll机制,由事件驱动去等待,有情况了,再通知selector应用线程去处理。只是,这个过程,唯一需要需要做的就是主动检查各个通道事件,因为epoll只是更新了事件的情况,具体哪些通道的事件需要怎么处理,就需要自己把控了。
提一嘴,更进一步的AIO:
而后续的AIO,则可以把这一块主动检查也优化掉,实现真正的异步,通过回调接口,让系统层级得到结果后调用提前实现好的回调,做到异步+非阻塞。
1、如何创建一个Selector
Selector 就是您注册对各种 I/O 事件兴趣的地方,而且当那些事件发生时,就是这个对象告诉您所发生的事件。
Selector selector = Selector.open(); // 打开一个监听器,创建一个监听器,监听谁呢?等待注册
2、注册Channel到Selector
为了能让Channel和Selector配合使用,我们需要把Channel注册到Selector上。通过调用 channel.register()方法来实现注册(为什么不是selector.register(xxChannel)呢?):
channel.configureBlocking( false ); SelectionKey key = channel.register(selector,SelectionKey.OP_READ);
注意,注册的Channel 必须设置成异步模式才可以,否则异步IO就无法工作,这就意味着我们不能把一个FileChannel注册到Selector,因为FileChannel没有异步模式,但是网络编程中的SocketChannel是可以的。
3、关于SelectionKey(将BIO过程拆分为4个主要事件)
请注意对register()的调用的返回值是一个SelectionKey(建立起监听器-连接通道的关系)。 SelectionKey 代表这个通道在此 Selector 上注册。当某个 Selector 通知您某个传入事件时,它是通过提供对应于该事件的 SelectionKey 来进行的。SelectionKey 还可以用于取消通道的注册。
SelectionKey中包含如下属性(就是一个pojo模型model,想想是不是这样子,没啥神秘):
- The interest set:自己感兴趣的事件
- The ready set:来自系统反馈的事件
- The Channel:连接通道,等同socket,双向读写
- The Selector:监听器,一对多个channel,减少线程切换
- An attached object (optional):附带一个背包,想装啥就装啥
(1) Interest set(来自系统反馈的事件)
就像我们在前面讲到的把Channel注册到Selector来监听感兴趣的事件,interest set就是你要选择的感兴趣的事件的集合。你可以通过SelectionKey对象来读写interest set:
int interestSet = selectionKey.interestOps(); boolean isInterestedInAccept = interestSet & SelectionKey.OP_ACCEPT; boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT; boolean isInterestedInRead = interestSet & SelectionKey.OP_READ; boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;
通过上面例子可以看到,我们可以通过用AND 和SelectionKey 中的常量做运算,从SelectionKey中找到我们感兴趣的事件。
常见操作:使用按位运算,高效管理成千上万的通道事件
// 1. 添加事件 key.interestOps(key.interestOps() | newEvent); // 按位或 // 2. 移除事件 key.interestOps(key.interestOps() & ~eventToRemove); // 按位与非,等于排除 // 3. 切换事件(移除一些,添加一些) key.interestOps((key.interestOps() & ~oldEvents) | newEvents); // 排除之后添加 // 4. 检查事件 boolean hasEvent = (key.interestOps() & eventToCheck) != 0 ; // 按位与,检查是否存在事件 // 5. 设置特定事件组合 key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE); // 按位或,指定事件组合
(2) Ready Set(来自系统反馈的事件)
ready set 是通道已经准备就绪的操作的集合。在一次选Selection之后,你应该会首先访问这个ready set。可以这样访问ready集合:
int readySet = selectionKey.readyOps();
可以用像检测interest集合那样的方法,来检测Channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:
selectionKey.isAcceptable(); selectionKey.isConnectable(); selectionKey.isReadable(); selectionKey.isWritable();
(3) Channel 和 Selector(建立对应关系)
我们可以通过SelectionKey获得Selector和注册的Channel(像个模型,封装了这两者,建立了关系,解耦了):
Channel channel = selectionKey.channel(); Selector selector = selectionKey.selector();
(4) Attach一个对象(加个背包)
可以将一个对象或者更多信息attach 到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:
selectionKey.attach(theObject); Object attachedObj = selectionKey.attachment();
还可以在用register()方法向Selector注册Channel的时候附加对象。如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject); // 返回个回执
4、关于SelectedKeys()
生产系统中一般会额外进行就绪状态检查,一旦调用了select()方法,它就会返回一个数值,表示一个或多个通道已经就绪,然后你就可以通过调用selector.selectedKeys()方法返回的SelectionKey集合来获得就绪的Channel。
Set<SelectionKey> selectedKeys = selector.selectedKeys(); // 这就是一对多的关系
当你通过Selector注册一个Channel时,channel.register()方法会返回一个SelectionKey对象,这个对象就代表了你注册的Channel。这些对象可以通过selectedKeys()方法获得。你可以通过迭代这些selected key来获得就绪的Channel。
Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { // a connection was accepted by a ServerSocketChannel. } else if (key.isConnectable()) { // a connection was established with a remote server. } else if (key.isReadable()) { // a channel is ready for reading } else if (key.isWritable()) { // a channel is ready for writing } keyIterator.remove(); }
这个循环遍历selected key的集合中的每个key,并对每个key做测试来判断Channel处于事件就绪中,采取对应的事件处理,相当于使用一条线程不断轮询所有连接通道,检查每个通道的所有事件,哪些事件时OK的(isXX返回true的那些)然后处理即可。
类比:这相当于,有一个代表,比如:班主任(一条线程),帮全班同学(keys)去询问每个科目老师(事件):“小A,语文考了多少分(读事件),数学考了多少分(写事件),英语考了多少分(连接就绪事件)……”
请注意循环中最后的keyIterator.remove()方法。Selector对象并不会从自己的selected key集合中自动移除SelectionKey实例。我们需要在处理完一个Channel的时候自己去移除。当下一次Channel就绪的时候,Selector会再次把它添加到selected key集合中。这个只是本次迭代器不断循环处理,清除已处理channel的过程,并不会接触selector和channel的关系。
SelectionKey.channel()方法返回的Channel需要转换成你具体要处理的类型,比如是ServerSocketChannel或者SocketChannel等等。
IO 都是同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel(就绪事件),来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高
5.3 NIO与BIO的比较
这是一个很好的问题。让我通过对比BIO和NIO来详细解释它们的差异,以及NIO的事件处理机制。
基本概念对比
| 特性 | BIO (Blocking I/O) | NIO (Non-blocking I/O) |
|---|---|---|
| I/O模式 | 阻塞式 | 非阻塞式 |
| 处理方式 | 面向流(Stream) | 面向缓冲区(Buffer) |
| 并发模型 | 一个连接一个线程 | 一个线程处理多个连接 |
| 数据传输 | 单向(输入/输出流) | 双向(Channel) |
代码实现对比
BIO 聊天室客户端示例:
// BIO客户端连接和读取 Socket socket = new Socket ( "localhost" , 8080 ); // 阻塞直到连接建立 // 读取数据 - 阻塞直到有数据可读 BufferedReader reader = new BufferedReader ( new InputStreamReader (socket.getInputStream())); String message = reader.readLine(); // 阻塞等待 // 写入数据 - 阻塞直到数据发送完成 PrintWriter writer = new PrintWriter (socket.getOutputStream()); writer.println( "Hello" ); // 阻塞直到发送完成
NIO 聊天室客户端示例:
// NIO客户端连接 SocketChannel channel = SocketChannel.open(); channel.configureBlocking( false ); // 设置非阻塞模式 channel.connect( new InetSocketAddress ( "localhost" , 8080 )); // 立即返回 // 注册到选择器 Selector selector = Selector.open(); channel.register(selector, SelectionKey.OP_CONNECT); // 事件驱动处理 while ( true ) { selector.select(); // 阻塞等待事件 // 处理不同类型的事件 }
5.4 NIO的四个核心事件
NIO将整个I/O过程拆分为四个核心事件,这些事件通过SelectionKey的常量表示:
1. OP_ACCEPT (Accept事件)
- 适用对象:
ServerSocketChannel - 触发条件:有新的客户端连接请求
- 处理方式:接受连接,创建
SocketChannel
if (key.isAcceptable()) { ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel(); SocketChannel clientChannel = serverChannel.accept(); // 接受连接 clientChannel.configureBlocking( false ); clientChannel.register(selector, SelectionKey.OP_READ); // 注册读事件 }
2. OP_CONNECT (Connect事件)
- 适用对象:
SocketChannel - 触发条件:客户端连接操作完成
- 处理方式:完成连接,注册读事件
if (key.isConnectable()) { SocketChannel channel = (SocketChannel) key.channel(); if (channel.finishConnect()) { // 完成连接 key.interestOps(SelectionKey.OP_READ); // 切换到读事件 } }
3. OP_READ (Read事件)
- 适用对象:
SocketChannel - 触发条件:通道中有数据可读
- 处理方式:从通道读取数据
if (key.isReadable()) { SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate( 1024 ); int bytesRead = channel.read(buffer); // 读取数据 if (bytesRead > 0 ) { // 处理数据 } }
4. OP_WRITE (Write事件)
- 适用对象:
SocketChannel - 触发条件:通道可以写入数据
- 处理方式:向通道写入数据
if (key.isWritable()) { SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.wrap( "Hello" .getBytes()); channel.write(buffer); // 写入数据 }
5.5 事件关系和处理流程
1. 事件之间的关系
服务器端事件流程: OP_ACCEPT → OP_READ → (可选)OP_WRITE → OP_READ... 客户端事件流程: OP_CONNECT → OP_READ → (可选)OP_WRITE → OP_READ...
2. 事件处理示例
// 服务器端事件处理循环 while ( true ) { selector.select(); // 等待事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); if (key.isAcceptable()) { // 处理新连接 handleAccept(key); } else if (key.isReadable()) { // 处理读事件 handleRead(key); } else if (key.isWritable()) { // 处理写事件 handleWrite(key); } } }
3. 事件状态转换
// 事件注册和转换示例 // 1. 初始注册连接事件 channel.register(selector, SelectionKey.OP_CONNECT); // 2. 连接完成后转换为读事件 key.interestOps(SelectionKey.OP_READ); // 3. 需要发送数据时添加写事件 key.interestOps(key.interestOps() | SelectionKey.OP_WRITE); // 4. 发送完成后移除写事件 key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
5.6 NIO实例
服务器端流程
public class NIOServerExample { public void start () throws IOException { Selector selector = Selector.open(); ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.configureBlocking( false ); serverChannel.bind( new InetSocketAddress ( 8080 )); // 1. 注册接受连接事件 serverChannel.register(selector, SelectionKey.OP_ACCEPT); while ( true ) { selector.select(); // 等待事件 for (SelectionKey key : selector.selectedKeys()) { if (key.isAcceptable()) { // 2. 接受新连接,注册读事件 SocketChannel client = serverChannel.accept(); client.configureBlocking( false ); client.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 3. 读取数据 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate( 1024 ); int bytesRead = client.read(buffer); if (bytesRead > 0 ) { // 处理数据... // 如果需要回复,可以注册写事件 key.interestOps(SelectionKey.OP_WRITE); } } else if (key.isWritable()) { // 4. 写入数据 SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.wrap( "Hello" .getBytes()); client.write(buffer); // 写完后恢复为读事件 key.interestOps(SelectionKey.OP_READ); } } selector.selectedKeys().clear(); } } }
客户端流程
public class NIOClientExample { public void connect () throws IOException { Selector selector = Selector.open(); SocketChannel channel = SocketChannel.open(); channel.configureBlocking( false ); // 1. 连接服务器,注册连接事件 channel.connect( new InetSocketAddress ( "localhost" , 8080 )); channel.register(selector, SelectionKey.OP_CONNECT); while ( true ) { selector.select(); for (SelectionKey key : selector.selectedKeys()) { if (key.isConnectable()) { // 2. 完成连接,注册读事件 if (channel.finishConnect()) { key.interestOps(SelectionKey.OP_READ); } } else if (key.isReadable()) { // 3. 读取服务器响应 ByteBuffer buffer = ByteBuffer.allocate( 1024 ); int bytesRead = channel.read(buffer); // 处理数据... } else if (key.isWritable()) { // 4. 发送数据到服务器 ByteBuffer buffer = ByteBuffer.wrap( "Hello Server" .getBytes()); channel.write(buffer); key.interestOps(SelectionKey.OP_READ); } } selector.selectedKeys().clear(); } } }
5.7 小结
NIO通过将I/O操作分解为四个核心事件,实现了事件驱动的异步I/O处理:
-
- OP_ACCEPT:处理新连接请求
-
- OP_CONNECT:处理连接完成事件
-
- OP_READ:处理数据读取事件
-
- OP_WRITE:处理数据写入事件
这种设计允许单个线程处理多个连接,大大提高了系统的并发性能和资源利用率,特别适用于高并发场景。
六、AIO编程
AIO是异步IO的缩写,虽然NIO在网络操作中,提供了非阻塞的方法,但是NIO的IO行为还是同步的。对于NIO来说,我们的业务线程是在IO操作准备好时,得到通知,接着就由这个线程自行进行IO操作,IO操作本身是同步的。
6.1 AIO(异步+非阻塞)
但是对AIO来说,则更加进了一步,它不是在IO准备好时再通知线程(还得阻塞等完成),而是在IO操作已经完成后,再给线程发出通知(回调告诉完成得怎么样)。因此AIO是不会阻塞的,此时我们的业务逻辑将变成一个回调函数,等待IO操作完成后,由系统自动触发。
与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。 即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。
在JDK1.7中,这部分内容被称作NIO.2,主要在Java.nio.channels包下增加了下面四个异步通道:
AsynchronousSocketChannelAsynchronousServerSocketChannelAsynchronousFileChannelAsynchronousDatagramChannel
在AIO socket编程中,服务端通道是AsynchronousServerSocketChannel,这个类提供了一个open()静态工厂,一个bind()方法用于绑定服务端IP地址(还有端口号),另外还提供了accept()用于接收用户连接请求。在客户端使用的通道是AsynchronousSocketChannel,这个通道处理提供open静态工厂方法外,还提供了read和write方法。
在AIO编程中,发出一个事件(accept read write等)之后要指定事件处理类(回调函数),AIO中的事件处理类是CompletionHandler,这个接口定义了如下两个方法,分别在异步操作成功和失败时被回调。
- void completed(V result, A attachment);
- void failed(Throwable exc, A attachment);
6.2 AIO与NIO比较
AIO (Asynchronous I/O) 和 NIO (Non-blocking I/O) 都是 Java 提供的高性能 I/O 处理方式,但它们在设计思想和实现机制上有根本性的不同。
NIO (Non-blocking I/O)
- 模式:同步非阻塞
- 核心组件:Channel、Buffer、Selector
- 处理方式:事件驱动,需要主动检查事件
- 线程模型:Reactor 模式
AIO (Asynchronous I/O)
- 模式:异步非阻塞
- 核心组件:AsynchronousChannel、CompletionHandler
- 处理方式:回调机制,系统通知完成
- 线程模型:Proactor 模式
NIO 工作原理(事件轮询)
// NIO 需要不断轮询检查事件 Selector selector = Selector.open(); channel.register(selector, SelectionKey.OP_READ); while ( true ) { // 阻塞等待事件发生 selector.select(); // 需要手动遍历处理事件 for (SelectionKey key : selector.selectedKeys()) { if (key.isReadable()) { // 手动读取数据 channel.read(buffer); } } }
AIO 工作原理(回调通知)
// AIO 通过回调处理,无需轮询 AsynchronousSocketChannel channel = AsynchronousSocketChannel.open(); // 异步读取,完成后自动回调 channel.read(buffer, null , new CompletionHandler <Integer, Void>() { @Override public void completed (Integer result, Void attachment) { // 数据读取完成后的处理 System.out.println( "读取了 " + result + " 字节" ); // 继续下一次读取 channel.read(buffer, null , this ); } @Override public void failed (Throwable exc, Void attachment) { // 读取失败的处理 exc.printStackTrace(); } });
6.3 代码实现对比
NIO 聊天室客户端
public class NIOChatClient { private Selector selector; private SocketChannel socketChannel; public void connect () throws IOException { selector = Selector.open(); socketChannel = SocketChannel.open(); socketChannel.configureBlocking( false ); socketChannel.connect( new InetSocketAddress ( "localhost" , 8080 )); socketChannel.register(selector, SelectionKey.OP_CONNECT); } public void start () { new Thread (() -> { try { while ( true ) { selector.select(); // 阻塞等待事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); if (key.isConnectable()) { handleConnect(key); } else if (key.isReadable()) { handleRead(key); } } } } catch (Exception e) { e.printStackTrace(); } }).start(); } private void handleConnect (SelectionKey key) throws IOException { SocketChannel channel = (SocketChannel) key.channel(); if (channel.finishConnect()) { key.interestOps(SelectionKey.OP_READ); System.out.println( "连接成功" ); } } private void handleRead (SelectionKey key) throws IOException { SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate( 1024 ); int bytesRead = channel.read(buffer); if (bytesRead > 0 ) { buffer.flip(); byte [] data = new byte [buffer.remaining()]; buffer.get(data); System.out.println( "收到: " + new String (data)); } } }
AIO 聊天室客户端
每个操作,注册一个回调处理器:成功、失败的时候该怎样处理,预设。这就是回调通知,系统级别通知应用程序结果,所以应用程序的线程可以非阻塞,干其他事,这效率不就是杠杠的吗?
public class AIOChatClient { private AsynchronousSocketChannel clientChannel; public void connect () throws Exception { clientChannel = AsynchronousSocketChannel.open(); // 异步连接 clientChannel.connect( new InetSocketAddress ( "localhost" , 8080 ), null , new CompletionHandler <Void, Void>() { @Override public void completed (Void result, Void attachment) { System.out.println( "连接成功" ); // 连接成功后开始读取数据 startRead(); } @Override public void failed (Throwable exc, Void attachment) { System.out.println( "连接失败: " + exc.getMessage()); } }); } private void startRead () { ByteBuffer buffer = ByteBuffer.allocate( 1024 ); // 异步读取,完成后自动回调 clientChannel.read(buffer, buffer, new CompletionHandler <Integer, ByteBuffer>() { @Override public void completed (Integer result, ByteBuffer attachment) { if (result > 0 ) { attachment.flip(); byte [] data = new byte [attachment.remaining()]; attachment.get(data); System.out.println( "收到: " + new String (data)); attachment.clear(); } // 继续下一次读取 clientChannel.read(attachment, attachment, this ); } @Override public void failed (Throwable exc, ByteBuffer attachment) { System.out.println( "读取失败: " + exc.getMessage()); } }); } public void sendMessage (String message) { ByteBuffer buffer = ByteBuffer.wrap((message + "\n" ).getBytes()); // 异步写入 clientChannel.write(buffer, null , new CompletionHandler <Integer, Void>() { @Override public void completed (Integer result, Void attachment) { System.out.println( "发送成功: " + result + " 字节" ); } @Override public void failed (Throwable exc, Void attachment) { System.out.println( "发送失败: " + exc.getMessage()); } }); } public static void main (String[] args) throws Exception { AIOChatClient client = new AIOChatClient (); client.connect(); // 等待连接建立 Thread.sleep( 1000 ); // 发送消息 Scanner scanner = new Scanner (System.in); while ( true ) { String message = scanner.nextLine(); if (message != null && !message.isEmpty()) { client.sendMessage(message); } } } }
AIO 聊天室服务端
操作方法:accept、read、write + CompletionHandler回调处理器
public class AIOChatServer { private AsynchronousServerSocketChannel serverChannel; public void start () throws Exception { serverChannel = AsynchronousServerSocketChannel.open(); serverChannel.bind( new InetSocketAddress ( 8080 )); System.out.println( "AIO聊天室服务端启动,监听端口:8080" ); // 异步接受连接 serverChannel.accept( null , new CompletionHandler <AsynchronousSocketChannel, Void>() { @Override public void completed (AsynchronousSocketChannel clientChannel, Void attachment) { System.out.println( "新客户端连接" ); // 继续接受下一个连接 serverChannel.accept( null , this ); // 处理客户端通信 handleClient(clientChannel); } @Override public void failed (Throwable exc, Void attachment) { System.out.println( "接受连接失败: " + exc.getMessage()); } }); // 保持服务端运行 Thread.currentThread().join(); } private void handleClient (AsynchronousSocketChannel clientChannel) { ByteBuffer buffer = ByteBuffer.allocate( 1024 ); // 异步读取客户端数据 clientChannel.read(buffer, buffer, new CompletionHandler <Integer, ByteBuffer>() { @Override public void completed (Integer result, ByteBuffer attachment) { if (result > 0 ) { attachment.flip(); byte [] data = new byte [attachment.remaining()]; attachment.get(data); String message = new String (data).trim(); System.out.println( "收到消息: " + message); // 回显消息 ByteBuffer response = ByteBuffer.wrap(( "Echo: " + message + "\n" ).getBytes()); clientChannel.write(response, null , new CompletionHandler <Integer, Void>() { @Override public void completed (Integer result, Void attachment) { System.out.println( "回显消息发送成功" ); } @Override public void failed (Throwable exc, Void attachment) { System.out.println( "回显消息发送失败: " + exc.getMessage()); } }); attachment.clear(); } // 继续读取 clientChannel.read(attachment, attachment, this ); } @Override public void failed (Throwable exc, ByteBuffer attachment) { System.out.println( "读取客户端数据失败: " + exc.getMessage()); } }); } public static void main (String[] args) throws Exception { new AIOChatServer ().start(); } }
6.4 核心区别总结
| 特性 | NIO | AIO |
|---|---|---|
| 编程模型 | 同步非阻塞 | 异步非阻塞 |
| 事件处理 | 主动轮询检查(应用 -> 系统) | 被动回调通知(系统 -> 应用) |
| 线程使用 | 需要线程轮询事件 | 系统通知,无需轮询 |
| 复杂度 | 相对复杂,需要管理Selector | 相对简单,回调处理 |
| 性能 | 高性能,适合中高并发 | 更高性能,适合高并发 |
| 资源消耗 | 较少 | 更少(系统级优化) |
6.5 解决的问题
NIO 解决的问题
-
- 传统BIO的阻塞问题:每个连接需要一个线程
-
- 线程资源浪费:大量线程处于等待状态(资源浪费)
-
- 并发性能限制:受限于线程数量
AIO 解决的问题
-
- NIO的轮询开销:不需要主动检查事件
-
- 线程利用率:线程可以在等待时处理其他任务
-
- 系统级优化:利用操作系统异步I/O能力
-
- 编程简化:回调机制更直观(提前注册处理器)
6.6 适用场景
NIO 适用场景
- 中高并发应用
- 需要精确控制I/O操作
- 对内存使用有严格要求
- 需要兼容旧版本Java
AIO 适用场景
- 高并发、高吞吐量应用
- 大量I/O操作的应用
- 对响应时间要求极高的系统
- 可以使用Java 7+的新特性
6.7 实际性能对比
// 性能测试示例 public class PerformanceComparison { // NIO 处理大量连接 public void testNIO () { // 需要一个线程管理Selector // 大量连接时,事件处理可能成为瓶颈 } // AIO 处理大量连接 public void testAIO () { // 每个操作都是异步的 // 系统自动管理,无需额外线程轮询 // 更好的扩展性 } }
AIO 是 NIO 的进一步发展,通过真正的异步机制解决了 NIO 需要主动轮询的开销问题,提供了更好的性能和更简单的编程模型。不过 AIO 的使用需要操作系统支持,并且在某些场景下可能不如 NIO 灵活。
Netty框架:基于NIO 2.0(AIO)封装好的通信框架,性能优异,稳定好,代码简单,大型技术大量公司经过实战使用以后,发现各方面都很好!!
七、BIO、NIO、AIO总结
从BIO到NIO,再到AIO,IO模型的改变,恰恰体现了利用其不断优化的硬件、操作系统机制,如:系统级别的epoll事件机制,得到了应用层面更高的性能。
7.1 BIO (Blocking I/O) - 同步阻塞I/O
特点
- 阻塞模型:每个连接需要一个线程处理
- 面向流:InputStream/OutputStream
- 简单易用:编程模型直观,易于理解
- 资源消耗大:高并发时线程数量激增
- 适用场景:连接数较少且固定的架构
核心类
// 服务端核心类 java.net.ServerSocket // 服务端套接字 java.net.Socket // 客户端套接字 java.io.InputStream // 输入流 java.io.OutputStream // 输出流 // 示例 ServerSocket server = new ServerSocket ( 8080 ); Socket client = server.accept(); // 阻塞等待 InputStream input = client.getInputStream(); OutputStream output = client.getOutputStream();
7.2 NIO (Non-blocking I/O) - 同步非阻塞I/O
特点
- 非阻塞模型:一个线程可以处理多个连接
- 面向缓冲区:Buffer、Channel、Selector
- 事件驱动:通过Selector监听多种事件
- 高性能:避免了大量线程的创建和切换
- 复杂性:编程模型相对复杂,需要管理状态
核心类
// 核心组件 java.nio.channels.Channel // 通道接口 java.nio.channels.Selector // 选择器 java.nio.Buffer // 缓冲区抽象类 java.nio.ByteBuffer // 字节缓冲区 // 具体实现类 java.nio.channels.ServerSocketChannel // 服务端通道 java.nio.channels.SocketChannel // 客户端通道 java.nio.channels.SelectionKey // 选择键 // 示例 Selector selector = Selector.open(); ServerSocketChannel server = ServerSocketChannel.open(); server.configureBlocking( false ); server.register(selector, SelectionKey.OP_ACCEPT); while ( true ) { selector.select(); // 处理事件... }
7.3 AIO (Asynchronous I/O) - 异步非阻塞I/O
特点
- 异步模型:真正的异步I/O操作
- 回调机制:通过CompletionHandler处理结果
- 系统级优化:利用操作系统异步I/O能力
- 资源消耗最小:无需轮询,系统自动通知
- 依赖操作系统:需要操作系统支持异步I/O
核心类
// 核心组件 java.nio.channels.AsynchronousChannel // 异步通道接口 java.nio.channels.CompletionHandler // 完成处理器接口 // 具体实现类 java.nio.channels.AsynchronousServerSocketChannel // 异步服务端通道 java.nio.channels.AsynchronousSocketChannel // 异步客户端通道 // 示例 AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(); server.bind( new InetSocketAddress ( 8080 )); server.accept( null , new CompletionHandler <AsynchronousSocketChannel, Void>() { @Override public void completed (AsynchronousSocketChannel client, Void attachment) { // 连接完成回调 // 处理客户端... } @Override public void failed (Throwable exc, Void attachment) { // 连接失败回调 } });
7.4 详细对比表
| 特性 | BIO | NIO | AIO |
|---|---|---|---|
| I/O模型 | 同步阻塞 | 同步非阻塞 | 异步非阻塞 |
| 编程难度 | 简单 | 复杂 | 中等 |
| 并发性能 | 低(线程数限制) | 高(事件驱动) | 很高(系统异步) |
| 资源消耗 | 高(多线程) | 低(少量线程) | 最低(系统级) |
| 适用场景 | 连接数少且固定 | 连接数多,短连接 | 连接数多,长连接 |
| 操作系统依赖 | 无 | 无 | 有(需支持异步I/O) |
7.5 核心类关系图
BIO 类关系
ServerSocket ↓ accept() Socket ↓ getInputStream() InputStream ←→ OutputStream
NIO 类关系
Selector ←→ ServerSocketChannel ←→ SelectionKey ↓ register() SocketChannel ←→ ByteBuffer
AIO 类关系
AsynchronousServerSocketChannel ↓ accept() AsynchronousSocketChannel ↓ read/write() CompletionHandler (回调处理)
7.6 使用场景建议
BIO 适用于
- 连接数较少的应用
- 对编程简单性要求高
- 服务器资源充足
- 例如:传统的Web应用、小型系统
NIO 适用于
- 高并发、短连接应用
- 需要处理大量连接
- 对性能有一定要求
- 例如:聊天服务器、游戏服务器
AIO 适用于
- 超高并发、长连接应用
- 对响应时间要求极高
- 服务器资源有限
- 例如:实时消息系统、高频交易系统
这三种I/O模型各有优势,选择时需要根据具体的应用场景、性能要求和开发复杂度来综合考虑。
八、最后
我们从网络通信的三要素:IP地址、端口、通信协议讲起,深入其Java的socket机制,实现TCP、UDP协议,了解http应用层下的传输层协议;然后深入Java早期的IO模型,BIO,同步阻塞,通过ServerSocket、Socket、IO流,建立简单的服务端-客户端模型,但是很快发现这种模型,只能一个连接一个地处理,每个连接都需要一个线程去处理,读取写入数据也是单向的,通过io流,即使后面使用了线程池,减少线程损耗,也会因为在accept、read、write等io操作时阻塞而显得低效。
这时Java1.4版本迎来第二种IO模型,NIO,同步非阻塞,将建立连接后等待读写数据的io操作时间给大大优化掉,利用系统级别的epoll机制,事件轮询机制,让系统通过事件去通知应用程序io事件的完成,这期间只需要少量的线程就可以完成了以前BIO几百上千个线程要干的事,性能得到极大的提升。当然这里的select方法调用还是阻塞的,而且需要有一个主线程去主动轮询各个通道(TCP的实现)的事件,才能更好地处理数据。
NIO将BIO的过程,巧妙地分割为4个事件:accpet-接收(服务端)、connect-连接(客户端)、read-读取、write-写出。而事件管理器selectionKey通过按位运算,数据容器ByteBuffer通过包装4个属性增强数据的精细化操作,大大提高了连接请求的读写操作的性能。总结就是:NIO通过底层epoll事件通知机制,高效性能的类设计,实现了整体IO性能的提升。
而AIO在Java1.7版本推出,在NIO的基础上,更进一步,通过回调机制,NIO的同步变为异步(系统通知结果),建立连接accept、connect到数据的read、write,每个操作都绑定一个结果处理器,预设成功或失败时的处理逻辑。实现真正的异步非阻塞,性能得到近一步提升。这是通过回调机制实现,就是系统级调用我们提前实现的成功回调或失败回调,实现更高的并发,更高的性能。而Netty就是依据此,封装了更加容易使用的API,可以说是很多中间件都会依赖它来实现高效的通信。正如,我们依赖springboot来快速开发应用,而Netty可以用来快速实现较为底层的中间件程序,或者自定义通信协议等,在传输层TCP/UDP层读写数据,自定义协议。
若想开发或实现一些较为底层的程序,网络编程必须掌握好,可以帮助排查问题,更为深底层定制化开发,而不仅限于CRUD,而Netty可以作为下一个阶段很好的学习框架,体会作者更为巧妙地封装和抽象,把Java原生的AIO封装得更为易用高效。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)