1. IO的背景
在java的软件设计开发中,通信架构是不可避免的,我们在进行不同系统或者不同进程之间的数据交互,或者在高并发下的通信场景下都需要用到网络通信相关的技术,对于一些经验丰富的程序员来说,Java早期的网络通信架构存在一些缺陷,其中最令人恼火的是基于性能低下的同步阻塞式的I/O通信(BIO)。
随着互联网开发下通信性能的高要求,Java在2002年开始支持了非阴塞式的I/O通信技术(NIO)。大多数读者在学习网络通信相关技术的 时候,都只是接触到零碎的通信技术点,没有完整的技术体系架构,以至于对Java的通信场景总是没有清晰的解决方案。本次课程将通过大量清晰直接的案例从最基础的BlO式通信开始介绍到NIO、AIO,读者可以清晰的了解到 阻塞、非阻塞、同步、异步的现象、概念和特征以及优缺点。
本课程结合了大量的案例让读者可以快速了解每种通信架构的使用。
概念解释
阻塞、非阻塞表示一个对象对访问它的线程所产生的反馈,它控制自己如何响应别人,它决定了访问它的线程是否处于阻塞状态
- 阻塞:一个对象容器在某些场景下,访问它的线程必须得等待它的某些条件成立才能成功拿到响应结果,在等待期间,这个对象容器一直阻塞着访问它的线程,该线程无法做其他事情,只能等待。
- 比如java中的阻塞队列,当队列中没有元素时,尝试获取元素的线程必须得一直等待,直到队列中有元素加入才能正常获取;当队列中元素已经满了时,尝试添加元素,必须等待队列中有空闲位置时才能添加成功。否则,访问队列的线程就一直在等待。
- 比如线程中的锁机制,有的锁是阻塞锁。如果该锁已经被线程持有,则另外一个线程尝试获取它,就一直得等待。等待期间什么时候也做不了。
- 比如多线程中的Lock,其lock方法就是一个阻塞方法,只要锁没有释放,就一直阻塞访问它的线程。
- 非阻塞:和阻塞相反,非阻塞指一个对象容器,访问它的线程永远不会阻塞,无论如何该容器都会立即响应该线程的访问,立马返回结果。
- 比如java中的非阻塞队列,当队列中没有元素时,访问它的线程尝试获取元素会理解得到一个空值,而不会阻塞。其他场景类似。
- 比如非阻塞锁,当其他线程已经持有锁,当前线程尝试获取该锁,会立即得到一个响应结果false,表示获取锁失败,而不会一直阻塞等待锁的释放。
- 超时阻塞:和阻塞类型,一个对象容器会在一定时间范围内阻塞访问它的线程,超过指定时间后则会立即响应一个结果给线程。
- 比如Lock中的tryLock,可以设置在指定时间段内阻塞,超过该时间则立即响应。
- 比如redisson中的tryLocK,同样的原理。
同步、异步表示一个对象访问其他对象所产生的行为,它控制自己如何访问别人,它决定了当前线程如何调度别人
- 同步:一个对象在访问其他对象时的方式,如果是同一个线程去访问,则认为是同步。
- 异步:一个对象访问其他对象时,重新启动一个新的线程去访问,则主线程就可以立即响应去做其他事情。
2. 通信技术整体解决的问题
- 局域网内的通信要求
- 多系统间的底层消息传递机制
- 高并发下,大数据量的通信场景需要,如netty
- 游戏行业,无论是手游服务端,还是大型的网络游戏,java语言都得到越来越广泛的应用
3. IO模型演变
3.1 I/O模型基本说明
I/O模型:就是用什么样的通道或者说是通信模式和架构进行数据的传输和接收,很大程度上决定了程序通信的性能 , Java共支持3种网络编程的I/O模型:BlO. NIO. AlO
实际通信需求下,要根据不同的业务场景和性能需求决定选择不同的I/O模型
3.2 IO模型
Java BIO
同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器 端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销【简单示意图 】
java NIO
Java NIO:同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注 册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理【简单示意图】
java AIO
java AIO(NIO.2):异步异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完 成了再通知服务器应用去启动线程进行处理,一般适用于连接数较多且连接时间较长的应用
3.3 BIO、NIO、AIO适用场景分析
1、 BIO方式适用于连接数目比小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中, jDK1.4以前的唯一选择,但程序简单易理解。
2、 NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。 编程比较复杂,jDK1 .4开始支持。
3、 AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作, 编程比较复杂,JDK7开始支持。
4. 深入BIO
5. 深入NIO
5.1 java NIO基本介绍
java NlO (New lO)也有人称之为java non-blocking IO,是Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java lO API。
NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。
NIO将以更加高效的方式进行文件的读写操作。
NIO可以理解为非阻塞IO,传统的IO 的read和write只能阻塞执行,线程在读写期间不能干其他事情,比如调用socket. read()时,如果服务器一 直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式
NIO相关类都被放在java.nio包及子包下,并且对原 Java.io 包中的很多类进行改写。
NIO有三大核心部分:Channel(通道),Buffer(缓冲区),Selector(选择器)
Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据;如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程 可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有1000个请求过来,根据实际情况,可以分酉己 20或者80个线程来处理。不像之前的阻塞IO那样,非得分配1000个。
5.2 NIO和BIO的比较
BlO以流的方式处理数据,而NIO以块的方式处理数据,块I/O的效率比流I/O高很多
BlO是阻塞的,NIO则是非阴塞的
BlO基于字节流和字符流进行操作,而NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道
NIO:面向缓存区(Buffer)、非阻塞(Non Blocking IO)、选择器(Selector)。
BIO:面向流(Stream)、阻塞IO(Blocking IO)。
5.3 Selector底层理论-多路复用
Java IO中的多路复用(Multiplexing)是一种提高IO操作效率的技术,通常用于网络编程中,特别是在处理大量并发连接时。
多路复用的核心思想是通过一个线程同时监听多个IO通道的状态变化,避免每个通道都占用一个线程。
这种机制常用于非阻塞IO(NIO)中,Java中的Selector
类就是实现多路复用的一个关键组件。
- 多路:指的就是多个管道或者通道,即通信架构模型中的Channel。
- 复用:指的是多个管道或者通道,复用一个线程,基于事件监听机制,一旦通道发生指定事件,才调度监听线程去执行任务。 实际上NIO中的监听线程是一直在启动着的,轮询去监听所有的管道是否有事件发生。
工作原理
Selector:Java NIO中的
Selector
对象可以注册多个通道(Channel
),然后可以通过一个单独的线程调用select()
方法,检查这些通道是否有事件(如数据可读、可写,或连接就绪等)发生。Channel:通道(
Channel
)是数据传输的载体,类似于传统IO中的流(Stream
),但它是双向的。每个通道可以注册到Selector
上并监听特定的事件(如OP_READ
、OP_WRITE
等)。SelectionKey:通道在注册到选择器时,会返回一个
SelectionKey
对象,用于标识通道与选择器之间的关系,并记录这个通道所关心的事件。
使用步骤
创建Selector:首先需要创建一个
Selector
对象。Selector selector = Selector.open();
注册Channel:将一个或多个通道(通常是
SocketChannel
或ServerSocketChannel
)注册到Selector
,并指定需要监听的事件类型。channel.configureBlocking(false); SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
事件轮询:通过
Selector
的select()
方法轮询事件,这个方法会阻塞直到至少有一个通道有事件发生。while (true) { int readyChannels = selector.select(); if (readyChannels == 0) continue; Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = selectedKeys.iterator(); while (keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if (key.isAcceptable()) { // 处理连接接受事件 } else if (key.isReadable()) { // 处理读事件 } else if (key.isWritable()) { // 处理写事件 } keyIterator.remove(); } }
优点
- 高效的资源利用:避免每个连接都分配一个线程,节省了系统资源。
- 支持大量并发连接:适合处理大量短连接或长连接,尤其是在网络服务器的场景中。
使用场景
Java NIO中的多路复用技术通常用于开发高性能的网络服务器,如聊天服务器、Web服务器等,这些服务器需要同时处理大量客户端的请求。
总体而言,多路复用通过高效的线程管理和事件驱动的模型,实现了在Java中处理并发网络连接的高性能IO操作。
5.4 Selector多路复用中的事件和spring中的事件有什么区别?
Java IO 中的事件和 Spring 事件机制中的事件有着不同的原理和应用场景。
1. Java IO 中的事件
Java IO 中的事件指的是 IO 操作的状态变化,比如:
- 可读事件(Readable Event):当通道中有数据可读时触发。
- 可写事件(Writable Event):当通道可以写数据时触发。
- 连接事件(Acceptable Event):当服务器通道上有新的客户端连接到来时触发。
这些事件是由操作系统的底层机制(如 epoll、kqueue 等)检测到的,Java 的 NIO 使用 Selector
类来监听这些事件。其主要目的是提高 IO 操作的效率,通过一个线程监控多个 IO 通道的状态变化。
2. Spring 事件机制中的事件
Spring 事件机制中的事件则属于应用层级别的事件系统,用于在应用程序内部进行松耦合的事件发布和订阅。
在 Spring 中:
- 事件发布者(Publisher):发布一个事件给
ApplicationContext
。 - 事件监听者(Listener):监听某个事件,并在事件发生时执行相应的处理逻辑。
事件发布和监听通过 Spring 的 ApplicationEventPublisher
和 ApplicationListener
实现。比如,当某个业务操作(如用户注册)完成后,可以发布一个事件通知系统的其他部分进行处理(如发送欢迎邮件等)。
// 定义一个自定义事件
public class UserRegisteredEvent extends ApplicationEvent {
private String username;
public UserRegisteredEvent(Object source, String username) {
super(source);
this.username = username;
}
public String getUsername() {
return username;
}
}
// 发布事件
@Component
public class UserService {
@Autowired
private ApplicationEventPublisher eventPublisher;
public void registerUser(String username) {
// 业务逻辑
eventPublisher.publishEvent(new UserRegisteredEvent(this, username));
}
}
// 监听事件
@Component
public class WelcomeEmailListener implements ApplicationListener<UserRegisteredEvent> {
@Override
public void onApplicationEvent(UserRegisteredEvent event) {
// 发送欢迎邮件
System.out.println("Sending welcome email to " + event.getUsername());
}
}
区别总结
- 层次和目的:
- Java IO 中的事件是底层操作系统通知的 IO 状态变化事件,用于处理网络通信和并发 IO 操作。
- Spring 事件机制是应用层的事件通知,用于实现模块之间的松耦合和异步处理。
- 处理方式:
- Java IO 事件多路复用是由单线程或少量线程通过轮询方式监控多个 IO 通道,并基于操作系统的支持进行高效的资源管理。
- Spring 事件机制通过事件发布者和监听者来处理业务逻辑中的特定事件,通常涉及异步或跨模块通信的场景。
因此,虽然两者都涉及“事件”,但它们的原理和应用场景完全不同。
进一步总结,个人理解如下:
也就是说,多路复用中对事件的监听是由服务端单独启动一个线程通过轮训扫描的方式,从操作系统的反馈中主动感知事件的发生。而spring中的事件,实际上是由客户端,这就是事件发起者进行回调事件监听者的逻辑从而触发的。在spring中,事件的发布和监听本质上完全可以由同一个线程完成,因为spring中对于事件的监听,实际上根本没有独立启动一个专门的线程去监听,监听者只不过是一个注册到客户端的对象而已,当客户端发布事件后,实际上触发监听逻辑,是同一个线程回调了监听器的回调逻辑而已。我理解的对吗
你的理解大体上是正确的,下面我来进一步澄清一下这两个机制的工作原理和区别:
1. Java IO 多路复用中的事件监听
- 线程模型:在 Java NIO 中,多路复用机制通常由一个专门的线程(或少数几个线程)来负责,这个线程会通过调用
Selector.select()
方法,阻塞等待操作系统通知哪个通道发生了某种感兴趣的事件(例如可读、可写等)。 - 轮询机制:这个线程会不断地进行轮询检查,看看哪些通道已经准备好进行 IO 操作。这个过程是主动的,即线程通过不断查询操作系统反馈来检测事件的发生。
- 事件处理:当某个通道的事件发生后,线程会处理这个事件,例如读取数据、接受新的连接等。这个处理过程通常会在同一个线程中进行(尽管在复杂的应用中,处理逻辑可能会被分发到其他线程中进行)。
2. Spring 事件机制中的事件发布与监听
- 事件发布与监听的线程关系:Spring 中的事件发布和监听大多在同一个线程中完成。发布事件的线程在调用
ApplicationEventPublisher.publishEvent()
时,会同步地通知所有注册的监听器去处理这个事件。这意味着事件的处理逻辑(监听器)是在发布事件的同一个线程中执行的。 - 回调机制:Spring 的事件监听机制更像是一个回调机制。监听器(
ApplicationListener
)注册到事件发布者(ApplicationContext
),当某个事件发布时,监听器的回调方法会被同步调用。 - 异步处理:当然,Spring 也支持异步事件处理。如果你希望事件的处理逻辑在另一个线程中执行,可以将监听器方法标记为异步的(例如使用
@Async
注解),Spring 会使用线程池来异步处理事件。
总结
- Java IO 多路复用:依赖操作系统的底层支持,由一个或少数几个线程进行轮询式的事件监听和处理,是一种主动检测的机制。
- Spring 事件机制:事件的发布和监听通常在同一个线程中完成,是一种基于回调的机制,事件监听器相当于注册到发布者的一段处理逻辑,当事件发生时被调用。
所以,Java IO 的事件监听和处理是操作系统级别的,并且依赖于线程的轮询机制,而 Spring 的事件机制则是应用层级别的,主要用于模块之间的松耦合和业务逻辑的异步处理。你描述的 Spring 事件机制的工作原理是正确的,它并不需要独立启动一个线程来监听事件。
6. 回顾网络模型
Java中的网络通信模型涉及多个层次,包括低层次的通信协议(如TCP/IP)、高层次的抽象(如Java的网络库)和设计模式。以下是一个全面总结,涵盖关键技术原理和Java实现:
一、基础网络通信模型
1. OSI七层模型
网络通信的基础是OSI模型,包括以下层次:
- 物理层:物理连接和信号传输。
- 数据链路层:数据帧的传输和链路控制。
- 网络层:IP地址寻址和路由选择(如IPv4、IPv6)。
- 传输层:可靠传输的实现(如TCP/UDP)。
- 会话层:管理会话连接。
- 表示层:数据编码和加密。
- 应用层:网络服务接口(如HTTP、FTP)。
Java的网络通信主要关注传输层及以上。
2. TCP/IP协议栈
- 三次握手(TCP连接建立)
- 第一次握手:客户端发送
SYN
包,表示请求建立连接。 - 第二次握手:服务器收到
SYN
后,返回SYN-ACK
。 - 第三次握手:客户端收到
SYN-ACK
后,发送ACK
确认。
- 第一次握手:客户端发送
- 四次挥手(TCP连接断开)
- 第一次挥手:客户端发送
FIN
,请求断开连接。 - 第二次挥手:服务器收到
FIN
后,返回ACK
。 - 第三次挥手:服务器发送
FIN
,表示准备断开。 - 第四次挥手:客户端收到
FIN
后,返回ACK
,连接断开。
- 第一次挥手:客户端发送
在Java中: 使用Socket
或ServerSocket
实现TCP通信。通信中三次握手和四次挥手由操作系统完成,开发者无需手动处理。
二、Java中的网络通信API
1. Java IO模型
阻塞式I/O(BIO)
- 使用
Socket
和ServerSocket
类。 - 特点:每个连接占用一个线程,适用于连接数较少的场景。
- 示例代码:
ServerSocket serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept(); // 阻塞等待连接
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
String inputLine;
while ((inputLine = in.readLine()) != null) {
out.println("Echo: " + inputLine);
}
socket.close();
serverSocket.close();
非阻塞式I/O(NIO)
- 使用
Selector
和Channel
。 - 特点:单线程管理多连接,适用于高并发场景。
- 示例代码:
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) {
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
clientChannel.read(buffer);
buffer.flip();
clientChannel.write(buffer);
}
keys.remove(key);
}
}
2. 高级抽象(Netty框架)
Netty是一个基于Java NIO的异步事件驱动网络框架,支持多种协议和高性能通信。
- 特点:高并发、可扩展、支持TCP/UDP等协议。
- 示例代码:
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new StringDecoder(), new StringEncoder(), new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
ctx.writeAndFlush("Echo: " + msg);
}
});
}
});
ChannelFuture future = bootstrap.bind(8080).sync();
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
三、网络通信模型类型
1. 阻塞式通信
每个连接使用单独的线程,操作简单,适用于小型应用。
2. 非阻塞式通信
通过轮询和事件通知机制提升性能。
3. Reactor模型
- 单Reactor单线程:一个线程处理所有事件,适用于轻量级应用。
- 单Reactor多线程:Reactor线程负责分发任务,工作线程负责处理任务。
- 多Reactor多线程:多个Reactor实例,适用于大型应用。
4. Proactor模型
事件完成后再通知应用,适用于高延迟网络。
四、协议支持
1. HTTP/HTTPS
- 使用
HttpURLConnection
或HttpClient
类。 - 示例:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
2. WebSocket
- Java支持通过
javax.websocket
进行双向通信。 - 示例:
@ClientEndpoint
public class WebSocketClient {
@OnOpen
public void onOpen(Session session) {
session.getAsyncRemote().sendText("Hello");
}
@OnMessage
public void onMessage(String message) {
System.out.println("Received: " + message);
}
}
五、优化与实践
- 线程池:使用
ExecutorService
管理线程。 - 连接池:重用连接,减少开销。
- 负载均衡:分散请求,提高系统稳定性。
- 数据序列化:使用
JSON
或Protobuf
优化数据传输。
通过以上技术和模型,Java提供了灵活且高效的网络通信支持,可满足不同场景的需求。
7. NIO和BIO的比较
NIO(Non-blocking I/O)和BIO(Blocking I/O)之间的非阻塞性主要体现在数据读取和写入的方式,以及对资源的管理上。这种非阻塞性并不是单一地由Buffer、Channel 或 Selector 某个组件实现,而是它们协同工作的结果。以下是详细分析:
1. BIO的阻塞性
工作机制:
- 在BIO中,当一个线程执行I/O操作时,必须等到数据完全准备好或操作完成后才能继续。例如:
- accept():阻塞直到有新连接。
- read():阻塞直到有数据可读。
- write():阻塞直到数据完全写入。
特点:
- 每个客户端连接占用一个线程。
- 如果线程正在等待I/O完成(如等待数据到来),该线程会被阻塞,资源浪费严重。
2. NIO的非阻塞性
工作机制:
NIO的非阻塞性体现在:
- Channel和Buffer:数据的读取和写入通过
Channel
完成,配合Buffer
操作,Channel
本身支持非阻塞模式。 - Selector选择器:可以注册多个Channel,轮询这些Channel的状态,实现一个线程管理多个连接。
核心组件解析:
- Channel(通道):
- 是BIO中
Socket
的替代品,但它是双向的,即既可以读也可以写。 - 支持非阻塞模式:
read()
:当没有数据时直接返回0
而不阻塞线程。write()
:当不能立即写入时直接返回0
而不阻塞线程。
- 非阻塞模式使得线程在I/O操作中无需等待,能够快速切换到其他任务。
- 是BIO中
- Buffer(缓冲区):
- 是数据的临时存储区,与
Channel
配合使用。 - 并不是直接实现非阻塞的原因,而是用来更高效地管理数据传输。
- 是数据的临时存储区,与
- Selector(选择器):
- 是实现非阻塞的关键。
- 一个线程可以注册多个
Channel
到一个Selector
上,通过Selector
的轮询机制(如select()
方法)监听多个Channel的状态变化。 - 当某个Channel准备好进行
read
或write
时,Selector会通知线程,而不是线程主动等待。
示例对比:
BIO读取数据:
InputStream in = socket.getInputStream();
byte[] buffer = new byte[1024];
int bytesRead = in.read(buffer); // 阻塞等待数据
NIO读取数据:
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false); // 设置为非阻塞模式
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buffer); // 非阻塞,可能立即返回0
if (bytesRead > 0) {
buffer.flip(); // 准备读数据
}
3. NIO的单线程与非阻塞性
单线程模型:
在BIO中,每个连接需要一个独立线程,而NIO通过Selector实现了单线程处理多连接:
- 一个线程可以同时管理多个Channel,通过Selector的事件通知机制获取状态变化。
- 每次调用
select()
方法,线程会返回所有准备好进行I/O操作的Channel集合。
核心流程:
- 一个线程管理Selector。
- Selector注册多个Channel。
- Selector不断轮询检查Channel是否有可处理事件(如READ、WRITE、CONNECT)。
- 准备好的Channel被处理后,线程继续轮询下一个事件。
非阻塞性实现:
- 非阻塞的Channel使线程不会因I/O操作而阻塞。
- Selector避免了线程因等待多个连接的I/O事件而阻塞。
示例代码:
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞,直到至少一个事件发生
Set<SelectionKey> selectedKeys = selector.selectedKeys();
for (SelectionKey key : selectedKeys) {
if (key.isAcceptable()) {
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
clientChannel.read(buffer); // 非阻塞读取
}
}
selectedKeys.clear(); // 清理已处理的事件
}
4. NIO的优点
- 高效资源利用:一个线程可以管理多个连接。
- 减少阻塞:避免线程被单一I/O操作阻塞。
- 高并发:更适合连接数高但单连接处理量较低的场景(如聊天室、实时推送服务)。
5. NIO非阻塞的局限性
- 复杂性增加:需要处理
Channel
、Selector
等细节,代码复杂。 - 延迟性问题:非阻塞模式在某些场景下可能增加I/O延迟。
- 线程数并非无限减少:高性能场景下仍需多线程配合(如Reactor多线程模型)。
总结
- NIO的非阻塞性主要体现在Channel和Selector的使用上,Buffer是辅助实现高效数据传输的工具。
- 单线程和非阻塞性的结合使得NIO能够高效地处理大量并发连接,但需要更复杂的代码设计来管理I/O事件。
8. Java BIO 案例
java中的IO操作主要用来操作输入流和输出流。
为了方便下面程序的演示,我们先创建一个工具类:
package zeh.myjavase.code00common;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
// 文件操作-工具类
public class FileHandleUtil {
// 创建文件和上层目录
public static void createFileAndDirectory(File f) {
if (f != null) {
if (!f.isDirectory()) {
// 取出文件父目录
File parentFiles = f.getParentFile();
if (parentFiles != null) {
if (!parentFiles.exists()) {
System.out.println("----开始递归创建文件夹----");
// 递归创建指定文件夹
parentFiles.mkdirs();
System.out.println("----递归创建文件夹结束----");
} else {
System.out.println("---指定文件夹存在----");
}
}
if (!f.exists()) {
System.out.println("----开始创建文件----");
try {
// 如果直接是文件的话,则创建该文件
f.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("----创建文件结束----");
} else {
System.out.println("----指定文件存在----");
}
}
}
}
// 下述方法作用:递归删除指定目录
// 如何判断目录:当传入的文件对象,其实体确实存在子目录或者子文件,则此时文件对象就是目录,否则就不是目录,当做文件对象处理
public static void deleteFile(File file) {
if (file != null) {
if (file.exists()) {
if (file.isDirectory()) {
File f[] = file.listFiles();
if (f != null) {
for (int i = 0; i < f.length; i++) {
deleteFile(f[i]);
f[i].delete();
}
file.delete();
}
} else {
file.delete();
}
} else {
System.out.println("----要删除的文件(目录)不存在----");
}
}
}
// 下述方法作用:在实例化输出流前,验证File对象路径的完整性,创建目录和文件文件对象,永远都是完整路径,除非传入的是根目录,否则传入的永远都是完整路径,按照文件对象去操作,而不是目录对象
public static boolean checkFile(File file, boolean isCreateFile) {
boolean checkResult = true;
if (file != null) {
if (!file.isDirectory()) {
File parentFiles = file.getParentFile();
if (parentFiles != null) {
if (!parentFiles.exists()) {
// 如果父目录不存在,则直接创建父目录(包含创建父目录的多个层级)
parentFiles.mkdirs();
}
}
if (isCreateFile) {
if (!file.exists()) {
try {
file.createNewFile();
} catch (IOException e) {
System.out.println("读取数据异常");
}
}
} else {
if (!file.exists()) {
String errorMessage = "----文件[" + file + "]不存在----";
try {
throw new FileNotFoundException(errorMessage);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
checkResult = false;
}
}
}
} else {
checkResult = false;
}
return checkResult;
}
// 下述方法作用:判断isSaveTxt,若为false,则清空result目录
public static void clearResultDir(boolean isSaveTxt) {
File result = new File("result");
if (!isSaveTxt) {
// false则清空result目录
FileHandleUtil.deleteFile(result);
System.out.println("isSaveTxt设置为false,result目录清空");
} else {
// true则什么都不做
;
}
}
}
上面的工具类封装了和文件相关的几个方法:
第一个方法:接收一个文件对象,然后递归创建该文件的所有上层目录和当前文件。
第二个方法:接收一个文件对象,如果文件对象是一个目录,则递归删除所有子目录;如果就是一个具体的文件,则直接删除文件。
checkFile:接收一个文件对象和是否要创建文件的标志,如果要创建文件则递归创建,否则不创建。
9. 向文件中写入数据
9.1 FileOutputStream向文件中写入数据
测试 OutputStream 流向文件中写入数据
package zeh.myjavase.code26io;
import zeh.myjavase.code00common.FileHandleUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class Demo01CreateFileAndOutput {
private static String filePath = "C:" + File.separator + "D" + File.separator + "FileClass" + File.separator + "zhaoerhu.txt";
public static void main(String args[]) throws IOException {
File f = new File(filePath);
FileHandleUtil.createFileAndDirectory(f);
OutputStream out = new FileOutputStream(f);
// 写入每一个内容时,后面都拼接了换行符\n,这样写入的内容是换行的,否则不换行
buildFileContent().stream().map(c -> c + "\n").forEach(e -> {
try {
out.write(e.getBytes(StandardCharsets.UTF_8));
} catch (IOException e1) {
e1.printStackTrace();
}
});
out.close();
System.out.println("=====" + f.getCanonicalPath());
}
private static List<String> buildFileContent() {
return Stream.of("我是赵二虎", "who are u?", "daisy", "Daniel", "i love Daisy!!!").collect(Collectors.toList());
}
}
(1)上面的方法要求必须传入一个文件的绝对路径,并且这个路径不能是一个目录,必须是一个文件的权限定绝对路径。
(2)如果这个路径中的上级目录不存在,则会自动创建。
(3)在通过OutputStream输出流向文件中写入数据时,循环写入,并且每条数据后都拼接了一个换行符 \n ,否则写入的数据将不会换行。
(4)FileOutputStream输出流只能向文件中写入东西。
观察下效果:
文件路径是C:\D\FileClass\zhaoerhu.txt
我是赵二虎
who are u?
daisy
Daniel
i love Daisy!!!
9.2 PrintStream向文件中写入数据
PrintStream实际上使用的最多,它是打印流,它的作用就是向目的地去输出一些东西,直接写入。
比如,目的是是控制台,它就向控制台打印东西。
目的地是网络,它就向网络打印东西。
当然,我们可以将目的地设置为一个文件,这样它就可以向文件中打印东西。
因此,它比上面的FileOutputStream更灵活。
package zeh.myjavase.code26io;
import zeh.myjavase.code00common.FileHandleUtil;
import java.io.*;
import java.nio.charset.StandardCharsets;
// 从键盘中读取内容,写入到文件中去
// 输入流定位操作对象为键盘
// 输出流定位操作对象为文件
public class Demo02CreateFileAndOutputToFile {
private static String filePath = "C:" + File.separator + "D" + File.separator + "FileClass" + File.separator + "zhaoerhu2.txt";
// 使用打印流的write方法写入数据
private static class PrintStreamWrite {
public static void main(String[] args) throws FileNotFoundException {
BufferedReader buf = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入东西:");
String content = null;
PrintStream filePrint = initPrintStream();
try {
boolean flag = true;
while (flag) {
content = buf.readLine();
if ("end".equals(content)) {
return;
}
// write方法需要转换成字节数组,并且转换前就需要拼接上换行符
filePrint.write((content + "\n").getBytes(StandardCharsets.UTF_8));
System.out.println("----[" + content + "]");
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (buf != null) {
try {
buf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (filePrint != null) {
filePrint.close();
}
}
}
}
// 初始化打印流
private static PrintStream initPrintStream() throws FileNotFoundException {
PrintStream filePrint = null;
File f = new File(filePath);
FileHandleUtil.createFileAndDirectory(f);
filePrint = new PrintStream(new FileOutputStream(f, true));
return filePrint;
}
}
(1)上面程序递归创建了目标文件。
(2)传入目标文件创建了打印流,意味着打印流的目的地被设置为目标文件。
(3)使用打印流的write方法向目标文件写入数据。
(4)write方法写入数据只能写入二进制数据,因此需要转换成byte数组。
(5)默认是不会换行的,需要手动拼接换行符。
(6)上面之所以创建了内部类的方式,目的是为了多写几个测试main方法,至于为啥不用Junit呢?是因为通过System.in从键盘读取数据,Junit不支持。
在砂锅面类中新加一个内部类,使用print方法打印:
// print方法打印不会自动换行,需要手动拼接换行符
public static class PrintStreamPrint {
public static void main(String[] args) throws FileNotFoundException {
BufferedReader buf = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入东西:");
String content = null;
PrintStream filePrint = initPrintStream();
try {
boolean flag = true;
while (flag) {
content = buf.readLine();
if ("end".equals(content)) {
return;
}
// print方法不会自动换行,需要我们拼接换行符
filePrint.print(content + "\n");
System.out.println("----[" + content + "]");
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (buf != null) {
try {
buf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (filePrint != null) {
filePrint.close();
}
}
}
}
(1)print方法和write方法相比,能优雅一点,可以支持多种数据类型的直接打印,并不一定是byte数组。
(2)print方法同样不支持自动换行。
使用println方法打印,自动换行,实际中使用最多:
// println方法会自动换行
private static class PrintStreamPrintln {
public static void main(String[] args) {
System.out.println("请输入东西:");
String content = null;
try (BufferedReader buf = new BufferedReader(new InputStreamReader(System.in));
PrintStream filePrint = initPrintStream();) {
boolean flag = true;
while (flag) {
content = buf.readLine();
if ("end".equals(content)) {
return;
}
// print方法不会自动换行,需要我们拼接换行符
filePrint.println(content + "\n");
System.out.println("----[" + content + "]");
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
(1)println方法能自动换行。
(2)上述代码使用try-with-resources替代了各种流操作时复杂的finally关闭流的动作,交给编译器替我们优化。
下面再介绍一种方法:
private static class PrintStreamOfSystem{
public static void main(String[] args) throws FileNotFoundException {
BufferedReader buf = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入东西:");
String content = null;
// 为了后面将标准输出流再重新定向到控制台,此处在重定向前先保存下标准输出流
PrintStream filePrintBefore = System.out;
PrintStream filePrint = initPrintStream();
try {
boolean flag = true;
while (flag) {
content = buf.readLine();
if ("end".equals(content)) {
return;
}
// 重定向到文件
System.setOut(filePrint);
System.out.println(content);
// 重新定向到标准输出流,这个标准输出流是前面重定向之前保存的
System.setOut(filePrintBefore);
System.out.println("----[" + content + "]");
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (buf != null) {
try {
buf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (filePrint != null) {
filePrint.close();
}
}
}
}
(1)全称直接使用System.out.println()去向文件中写入东西。
(2)System.setOut(filePrint)用于重置系统默认的输出流,默认是向控制台打印的。
(3)在重置前应该缓存原始的打印流,以便后续修改回来。
(4)重置后,就可以直接使用System.out的方式向目标文件写入数据,并自动换行。
(5)System.out也提供不换行的方法print()。
9.3 使用相对路径向文件中输出东西
package zeh.myjavase.code26io;
import zeh.myjavase.code00common.FileHandleUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
// 使用相对路径向文件中输出东西
public class Demo03FileOutputStreamToFile {
// 相对路径,相对当前代码工作根目录的路径
private static String filePath = "FileClass" + File.separator + "zhaoerhu3.txt";
public static void main(String args[]) throws IOException {
File f = new File(filePath);
FileHandleUtil.createFileAndDirectory(f);
OutputStream out = new FileOutputStream(f);
String str = "wo shi zhaoerhu!";
out.write(str.getBytes());
out.close();
}
}
相对路径和绝对路径:
在不同的系统中,绝对路径的写法是不同的,在windows系统中绝对路径是已盘符为起点的,而在linux系统中绝对路径的起点是根目录。
相对路径:就是相对于某个条件的路径
案例:
windows系统:
绝对路径: c:/java/hello.java
表示:在C盘下的java文件夹中的hello.java文件
linux系统:
绝对路径:/home/java/hello.java
表示:根目录下的家目录下的java文件夹中的hello.java文件
不同的方法所表示的相对路径是不同的,就拿Java来说吧:
new File()方法中也支持传入相对路径,它是相对于当前工作空间根目录而言的路径。
class.getResource()和classLoader.getResource()方法也支持传入相对路径,但它们是相对于当前工程的classpath而言的。
10. 对NIO中Selector选择器单线程的理解
在NIO中,通过 Selector
选择器用单线程监听多个 Channel
上的事件,确实是从传统的“一请求一线程”优化为“一线程多请求”的模式。这个模式并不会因为使用一个线程监听而导致性能下降,反而在大多数场景下会显著提高性能。以下是原因分析和机制解释:
1. NIO 的核心:非阻塞 I/O
在 NIO 中,Selector
的核心特点是非阻塞,与传统 BIO 模式中的阻塞调用完全不同:
- 在 BIO 中,每个线程都阻塞等待一个连接上的 I/O 操作完成。
- 在 NIO 中,单个线程通过
Selector
非阻塞地轮询多个Channel
的事件(如OP_ACCEPT
、OP_READ
等),只处理那些真正准备好的事件。
因为线程不会阻塞在单个连接上,而是动态分配时间给有事件的连接,所以一个线程可以管理大量的连接。
2. Selector 的线程模型
Selector 的工作模式大致如下:
- 监听事件:一个线程轮询
Selector
,检查注册的Channel
是否有 I/O 事件发生(如可读、可写、连接建立等)。 - 事件触发:当有事件发生时,线程从
Selector
获取就绪的Channel
集合。 - 任务分发:根据事件类型,线程进行相应处理(如读取数据、响应请求等),通常配合线程池处理复杂任务。
这种模型的效率来自:
- 事件驱动:线程只处理有事件的连接,而不是阻塞在无事件的连接上。
- 任务分离:复杂的 I/O 处理(如业务逻辑)可以交给线程池,而监听任务仍由一个线程完成。
3. 性能对比
NIO 模式在以下场景下性能更优:
- 大量连接但低频操作:比如聊天服务器、WebSocket 服务。这些连接大多处于空闲状态,
Selector
可以高效管理。 - 高并发场景:BIO 为每个连接创建一个线程,线程上下文切换和资源开销巨大;而 NIO 使用一个线程监听多个连接,减少了线程切换开销。
但也存在需要注意的情况:
- 如果每个连接的数据流量非常大或需要复杂的业务处理,单线程可能成为瓶颈。此时,可以将 I/O 操作和业务逻辑分开,比如通过
Selector
管理事件,但使用线程池处理耗时操作。
4. 单线程模式的优势
尽管单线程监听看起来“资源少”,但其核心是I/O 多路复用,避免了阻塞和多线程管理问题。优点如下:
- 减少线程开销:避免了为每个连接创建线程的资源消耗。
- 高效利用 CPU:单线程负责事件监听,不浪费时间在空闲连接上。
- 更高可扩展性:适合处理成千上万的连接。
5. 适当扩展线程模型
在一些高性能场景中,NIO 服务器也可以进一步优化:
- 主从线程模型:主线程监听连接事件,子线程池处理具体的读写和业务逻辑。
- 多 Reactor 模型:多个
Selector
负责不同的Channel
集合,由多个线程并发运行,进一步提高性能。
例如,Netty 框架中采用了典型的主从多线程模型:
- 主线程池负责监听连接(
BossGroup
)。 - 子线程池负责处理数据读写(
WorkerGroup
)。
总结
单线程监听多个 Channel
的模式不会降低性能,反而通过非阻塞和事件驱动的机制提升了 I/O 的效率。但如果任务处理耗时较长,可以结合线程池或其他多线程模型来进一步优化性能。在大多数高并发场景下,NIO 的这种模式表现非常出色。
11. NIO相对于BIO主要做了什么改进?
个人理解如下 NIO相对于BIO主要做了3个事情:
- 1.优化了服务端线程,即通过一个Selector线程监听多个Channel事件
- 2.将原有的线程阻塞改为了非阻塞,即Selector并不会阻塞在某个Channel上等待某个事件发生,而是轮询遍历,有事件发生就处理,没事件发生就继续转圈
- 3.使用Channel替代了传统的Stream,Channel结合Buffer的方式提升了处理IO事件的效率。
你的理解是非常正确的!你很好地总结了 NIO 相对于传统 BIO 的几个关键优化点。以下是对你的理解的进一步确认和补充说明:
1. 优化服务端线程:通过一个 Selector
监听多个 Channel
在 BIO 模式中,服务端的每个客户端连接都会分配一个线程,这种方式在高并发场景下会造成线程资源耗尽的问题。而 NIO 的 Selector
模式,通过单个线程管理多个连接的 I/O 事件,大大减少了线程数量,优化了线程资源的使用。
补充:
- 单个线程监听的核心是利用了操作系统的多路复用机制(如
epoll
、select
等),能够高效地监听多个Channel
的状态变化。 - 如果需要更高性能,NIO 可以配合线程池将复杂的业务逻辑从监听线程中分离出来,进一步提升吞吐量。
2. 将阻塞改为非阻塞:轮询事件,而不是阻塞等待
BIO 模式下,每个线程在处理 I/O 操作时是阻塞的,比如读取数据时会等待数据到来,写数据时可能会等待缓冲区可用。而 NIO 采用了非阻塞的方式,Selector
会轮询所有注册的 Channel
,只处理那些真正准备好 I/O 的事件。
补充:
- 非阻塞的核心在于线程不再被无效的等待时间占用,从而可以用更少的线程处理更多的连接。
- 虽然非阻塞方式效率更高,但轮询本身会有一定开销。因此在实现中通常会使用事件触发机制(如
epoll
)来优化轮询效率。
3. 使用 Channel
替代传统的 Stream
,结合 Buffer
提升效率
传统 BIO 模式中,数据的读写是基于 InputStream
和 OutputStream
的,这种方式的效率较低,而且操作通常是面向字节的,不够灵活。NIO 使用了 Channel
和 Buffer
组合,大幅提升了 I/O 的处理能力。
补充:
- Channel 的双向性:传统
Stream
是单向的,InputStream
只能读,OutputStream
只能写,而Channel
同时支持读写操作。 - Buffer 的高效性:NIO 中的数据读写是基于
Buffer
的,支持灵活的数据操作(如翻转、标记等),并且可以直接与操作系统的 I/O 缓冲区交互,减少了数据拷贝的开销。 - 零拷贝:在一些场景下(如文件传输),NIO 可以利用操作系统提供的零拷贝功能(如
FileChannel.transferTo
和transferFrom
),进一步提升性能。
总结补充
你的总结已经很好了,以下是额外的几点拓展:
- 可扩展性:NIO 的设计思想非常适合高并发场景。通过
Selector
管理Channel
,极大减少了线程数,避免了线程上下文切换和内存开销问题。 - 灵活性:BIO 是同步阻塞的,而 NIO 是非阻塞的,允许更灵活地设计异步和并发处理机制。
- 更高级的 API 支持:基于 NIO,还可以构建更高级的网络框架,如 Netty,提供了事件驱动、线程池管理等功能。
所以,你的理解非常全面和正确,同时可以继续深入研究 NIO 的一些高阶用法,比如 异步文件通道(AsynchronousFileChannel) 或 非阻塞 Socket(SocketChannel) 等!
文档信息
- 本文作者:Marshall