目录
1、NIO
2、NIO 和 IO 的区别
1. 阻塞 vs 非阻塞
2. 一个线程 vs 多个连接
3. 面向流 vs 面向缓冲
4. 多路复用
3、Channel & Buffer
(1)Channel:双向通道
(2)Buffer:缓冲区
(3)ByteBuffer(通用的Buffer)
(一)ByteBuffer 正确使用方法
(二)ByteBuffer 结构
(三)ByteBuffer 分配空间
(四)ByteBuffer 中数据的读和写
4、Selector
(1)Selector 的工作原理
(2)register() 方法详解
(3)selectedKeys() 方法详解
(4)为什么 Channel 必须设置为非阻塞模式
1、NIO
NIO 是 New I/O 的缩写,意思就是“新型输入输出”,它是在 Java 1.4 版本里加进来的,中文可以叫“新 I/O”,也叫“非阻塞 I/O”。NIO 其实就是对传统的阻塞 IO 做了个加强版,专门为了解决以前处理大量数据或者很多并发连接时性能不够好的问题。
NIO 的牛掰之处在于,它提供了全新的一套操作数据的方式,比如:
- 非阻塞模式:你可以发起 IO 操作而不用等着它完成,干别的事也行。
- 多路复用(Selector):一个线程能管理好多个连接,不需要每个连接都分配一个线程。
- 缓冲区读写(Buffer):NIO 用缓冲区来存数据,读写的时候可以灵活处理,不用一次性读完或写完。
NIO 主要有三个核心组件:
- 缓冲区(Buffer):就像个数据临时仓库,所有读写数据的操作都要经过它来处理。
- 通道(Channel):有点像以前的输入输出流,但它可以非阻塞地传数据,不用傻等。
- 选择器(Selector):这玩意儿相当于一个“事件监听器”,可以监听多个连接的事件(比如有数据可读、可写等),然后让线程去处理,这样一个线程就能同时管好多连接。
NIO 让程序处理网络通信和文件操作更加高效,尤其是当你要处理很多并发连接的时候(比如高并发服务器),它表现得非常出色。说白了,NIO 就是让你写的程序跑得更快、更省资源,特别适合那些要处理大量网络连接或者数据传输的场景。
2、NIO 和 IO 的区别
在 Java 里,NIO(New I/O)和传统的 IO 最大的区别就在于如何处理数据和如何利用系统资源。
1. 阻塞 vs 非阻塞
-
传统 IO(阻塞 IO):如果你用传统的 IO 去读取数据,就像你打电话给外卖员问订单进展,直到对方告诉你信息之前,你只能一直举着电话不能干别的。也就是说,线程在等待 I/O 操作完成时,会被 卡住,要等到数据读完或写完才能继续干其他事。
-
NIO(非阻塞 IO):你打电话给外卖员后,他可能说"稍等,我还在路上",然后你就可以继续干别的事,比如刷个视频,再过一会儿再去看看外卖有没有到。这时,线程不会因为等待而停下来,你可以处理其他任务,再定期看看数据准备好了没有。不会因为没有数据而一直卡在那里。
2. 一个线程 vs 多个连接
-
传统 IO:每个网络连接都会单独给你开一个线程,线程就像一个专职服务员,每个客人(连接)都配一个服务员。问题来了:如果餐厅里客人太多,光请服务员就累死老板了。线程多了,系统的性能也会受到影响,因为线程多了之后管理它们、切换任务等会让系统变慢。
-
NIO:NIO 就聪明多了,它就像一个服务员可以同时服务很多桌子(连接),当某桌的客人喊服务时,服务员才会过去处理。这样,只用少量的线程就能服务大量的网络连接,减少了资源的浪费。
3. 面向流 vs 面向缓冲
-
传统 IO:数据是 按流(Stream)处理 的,意思就是你每次只能顺序处理数据,一点一点从头看到尾,就像看电视时从头开始看,不能快进。
-
NIO:NIO 用的是 缓冲区(Buffer),就像在看视频时你可以拖动进度条去看想看的片段。你可以先把数据放到缓冲区里,然后根据需要随时读写,不需要按照固定顺序来。
4. 多路复用
-
传统 IO:每个连接(比如一个客户端连接到服务器)都是一对一的,没法让一个线程同时处理多个连接。
-
NIO:NIO 有个“多路复用器”(Selector),它就像一个大屏幕显示所有的订单状态,服务员可以随时查看哪个订单状态有变化(比如哪个客户数据准备好了),然后再去处理,这样就不用每个订单派一个人盯着了。
3、Channel & Buffer
(1)Channel:双向通道
想象一下,Channel 就像一条双向的水管,水可以从管道里流进来,也可以流出去。在 NIO 中,Channel 也是这样,可以用来读数据(从 Channel 里往 Buffer 里流)和写数据(把 Buffer 里的数据流到 Channel 里)。和传统的输入输出流(Stream)相比,Channel 的功能更强大,因为 Stream 要么是读数据,要么是写数据,而 Channel 可以同时进行这两项操作。
常见的 Channel 类型:
- FileChannel:用来操作文件的通道,可以读写文件数据。
- DatagramChannel:用于 UDP 网络通信的通道,适合需要快速传输小数据包的场景。
- SocketChannel:用于 TCP 网络通信的通道,保证数据的可靠性,适合大多数网络应用。
- ServerSocketChannel:专门用于处理服务器端的 Socket 连接,能够接收来自客户端的连接请求。
这里有个简单的示意图,展示了 Channel 和 Buffer 之间的关系:
(2)Buffer:缓冲区
Buffer 就是用来临时存储数据的容器,可以理解为一个数据仓库。在读写数据的时候,数据首先会放到 Buffer 里,再从 Buffer 进行操作。Buffer 的好处是让数据处理变得更加高效,因为可以一次性读写一大块数据,而不是每次都一个字节一个字节地处理。
常见的 Buffer 类型:
- ByteBuffer:处理字节数据的缓冲区,可以直接用来读写文件和网络数据。
- MappedByteBuffer:将文件的某部分映射到内存,可以高效地读写大文件。
- DirectByteBuffer:直接在内存中分配,不会受 Java 堆的限制,适合高性能应用。
- HeapByteBuffer:在 Java 堆内存中分配,普通使用,性能稍逊色。
- ShortBuffer:处理短整型数据的缓冲区。
- IntBuffer:处理整型数据的缓冲区。
- LongBuffer:处理长整型数据的缓冲区。
- FloatBuffer:处理浮点型数据的缓冲区。
- DoubleBuffer:处理双精度浮点型数据的缓冲区。
- CharBuffer:处理字符数据的缓冲区。
其中,ByteBuffer 是一个通用且灵活的选择,适用于大多数应用场景。如果在特定场景下遇到性能瓶颈或有特殊需求,再考虑使用其他类型的 Buffer(如 MappedByteBuffer 或 DirectByteBuffer)。
(3)ByteBuffer(通用的Buffer)
(一)ByteBuffer 正确使用方法
当你在使用 NIO 里的 ByteBuffer
时,操作起来可能有些步骤需要特别注意,尤其是在读写数据的过程中。让我们用简单的步骤来介绍一下:
- 写数据到 buffer:首先,我们需要往
ByteBuffer
里写数据,比如通过channel.read(buffer)
。 - 切换为读模式:写完后要告诉
ByteBuffer
,我们现在要读数据了。为此,需要调用flip()
,这一步相当于把写好的内容准备好给我们读取。 - 从 buffer 读数据:我们可以用
buffer.get()
来读取数据。 - 切换回写模式:当我们想再次写入新的数据时,要让
ByteBuffer
回到写模式,可以调用clear()
或compact()
,重新开始写入新的数据。 - 重复以上步骤:通常我们会重复这几步来处理 I/O 操作。
(二)ByteBuffer 结构
为了更好地理解 ByteBuffer
,我们需要了解它的三个核心属性:capacity
、position
和 limit
。这些属性是掌握 ByteBuffer
读写的关键。
- capacity:容量,表示这个 buffer 最多可以容纳多少数据。
- position:当前读写的位置。在写模式下,
position
表示下一个写入数据的位置;在读模式下,position
表示下一个要读取的位置。 - limit:写模式下,
limit
通常等于capacity
,表示可以写入的最大位置;读模式下,limit
表示你可以读到的最后一个位置,防止越界读取。
1.初始阶段:当我们新建一个 ByteBuffer
时,position
从 0 开始,limit
等于 capacity
。也就是说,缓冲区可以从头开始写入数据,最多写满整个容量。
2.写模式:我们可以往 ByteBuffer
中写入数据,比如我们写了 4 个字节,position
就会移动到第 4 个字节的位置,而 limit
依然是 capacity
。
3.切换为读模式:调用 flip()
后,position
会切换为 0,表示准备从头开始读取数据,limit
切换到我们最后写入的位置,防止我们读到还没有写的数据。
4.读数据:当我们读取数据时,position
会随着我们读取的字节数移动,直到达到 limit
为止。
5.清空缓冲区:当我们调用 clear()
后,ByteBuffer
又回到最初的写模式状态,position
归零,limit
回到 capacity
。
6.使用 compact():如果我们没有读完所有的数据,但又想往缓冲区写入新数据,可以用 compact()
。它会把未读的数据移到缓冲区的开始位置,然后把 position
移到未读数据之后的位置,方便我们继续写入。
(三)ByteBuffer 分配空间
在 NIO 中,ByteBuffer
有两种常见的分配方式:一种是通过堆内存(Heap Buffer)分配,另一种是通过直接内存(Direct Buffer)分配,两种方式各有优缺点。
1. 堆内存分配(Heap Buffer)
这是最常用、最简单的一种方式,直接从 JVM 的堆中分配内存。通过 ByteBuffer.allocate()
方法实现。
ByteBuffer heapBuffer = ByteBuffer.allocate(16);
这种方式创建的缓冲区是基于堆内存的,JVM 可以直接管理这些内存。堆缓冲区有以下特点:
- 读写性能:因为数据在堆中,访问速度较快,但是当进行 I/O 操作时,数据可能需要从堆内存复制到内核空间的 I/O 缓冲区,所以对于 I/O 密集型操作来说效率稍低。
- 垃圾回收:缓冲区的生命周期由 JVM 管理,垃圾回收器可以自动清理不再使用的缓冲区。这也意味着频繁创建和销毁堆缓冲区可能会导致更频繁的垃圾回收(GC),影响性能。
2. 直接内存分配(Direct Buffer)
通过 ByteBuffer.allocateDirect()
方法,可以分配一个直接内存缓冲区,这种方式的缓冲区直接在操作系统的内存中分配,不经过 JVM 的堆。
ByteBuffer directBuffer = ByteBuffer.allocateDirect(16);
直接缓冲区的特点是:
- I/O 性能:由于数据直接分配在操作系统的内存中,在进行 I/O 操作时,不需要将数据从 JVM 堆内存复制到内核空间且不受垃圾回收影响,从而提高了 I/O 性能。
- 分配和释放成本高:因为直接内存是由操作系统分配和管理的,分配和释放的成本较高,且需要显式释放,否则可能出现内存泄漏(Java 的垃圾回收机制不能自动清理直接缓冲区)。
(四)ByteBuffer 中数据的读和写
1.写数据到 ByteBuffer
准备空间:首先,我们需要为 ByteBuffer
分配空间,比如用 ByteBuffer.allocate(size)
。这就像买了一个空箱子,决定它的大小。
ByteBuffer buffer = ByteBuffer.allocate(16);
写入数据:接下来,我们可以用 put()
方法把数据放进这个缓冲区。记住,这个过程是在“写模式”下进行的,也就是说,你可以不断地将数据写入这个缓冲区,直到达到它的容量限制。
buffer.put((byte) 1); // 写入 1
buffer.put((byte) 2); // 写入 2
切换到读模式:写完数据后,我们需要调用 flip()
方法来切换到“读模式”。这个方法会设置缓冲区的读取位置,也就是告诉缓冲区:接下来我要读取你里面的数据了。
buffer.flip(); // 切换到读模式
2.从 ByteBuffer 读取数据
读取数据:一旦切换到读模式,就可以使用 get()
方法来读取数据。这个方法会从缓冲区的当前位置读取数据,并自动移动读取位置。
byte firstValue = buffer.get(); // 读取第一个值
byte secondValue = buffer.get(); // 读取第二个值
处理读取后的状态:在读取完数据后,缓冲区的 position
会向前移动,表示我们已经读取了这些数据。如果我们想再次写入新的数据,就需要调用 clear()
或 compact()
方法。这两个方法在前面也有提到。
- clear():这个方法会重置缓冲区,设置
position
为 0,limit
为容量,准备再次写入数据。但是这会清空已经读取的数据,所有内容都会被丢弃。
buffer.clear(); // 清空缓冲区,准备写入新数据
- compact():这个方法会把未读取的部分(即还没被读的内容)移动到缓冲区的开始位置,然后再准备写入新数据。这种方式可以保留尚未读取的数据。
buffer.compact(); // 将未读数据压缩到前面,并准备写入新数据
3.读写数据总结:
所以,ByteBuffer
的读和写过程大致可以归纳为:
- 分配空间:创建一个新的缓冲区。
- 写数据:用
put()
方法向缓冲区写入数据。 - 切换模式:使用
flip()
方法切换到读模式。 - 读数据:用
get()
方法读取数据。 - 清理或压缩:调用
clear()
或compact()
方法以便再次写入数据。
通过这种方式,我们就能高效地在 ByteBuffer
中读写数据,为 NIO 的性能优化奠定基础。
4、Selector
在 NIO 中,Selector 是一个非常重要的组件,它的作用是让一个线程能够同时管理多个 Channel。Selector 可以被视作一个 大管家,管理多个 门(Channel)。当有客人(事件)到访时,管家会及时通知你,让你去接待他们。这样,你就不需要为每个客人都派一个服务员(线程),从而节省资源。
(1)Selector 的工作原理
Selector 的工作流程通常包括以下几个步骤:
- 注册 Channel:首先,你需要将要管理的 Channel 注册到 Selector 上,告诉 Selector 哪些 Channel 需要关注。
- 调用 select() 方法:然后,调用 Selector 的
select()
方法。这个方法会阻塞,直到至少有一个注册的 Channel 发生读写就绪事件。也就是说,Selector 会在这里“守门”,等待事件的发生。 - 处理事件:一旦有 Channel 有事件发生(例如有数据可读或可写),
select()
方法会返回这些事件,然后你可以在一个线程中处理所有的 Channel 事件。这种方式避免了因为某个 Channel 的事件而让线程被阻塞。
代码示例:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.SelectableChannel;
import java.util.Iterator;
public class NioSelectorExample {
public static void main(String[] args) throws IOException {
// 创建 Selector
Selector selector = Selector.open();
// 创建 ServerSocketChannel,并设置为非阻塞模式
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
// 将 serverChannel 注册到 Selector,监听接受连接事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("Server is listening on port 8080...");
while (true) {
// 阻塞等待事件发生
selector.select();
// 获取所有已准备好的事件
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isAcceptable()) {
// 接受新的连接
SocketChannel clientChannel = serverChannel.accept();
clientChannel.configureBlocking(false);
// 注册到 Selector,监听读取事件
clientChannel.register(selector, SelectionKey.OP_READ);
System.out.println("Accepted new connection from " + clientChannel.getRemoteAddress());
} else if (key.isReadable()) {
// 读取数据
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(256);
int bytesRead = clientChannel.read(buffer);
if (bytesRead == -1) {
// 客户端关闭连接
clientChannel.close();
System.out.println("Closed connection from " + clientChannel.getRemoteAddress());
} else {
// 处理读取的数据
String message = new String(buffer.array()).trim();
System.out.println("Received: " + message);
}
}
}
}
}
}
在这个示例中,我们主要关注 Channel的register() 方法、Selector的selectedKeys() 方法 和 为什么被注册到 Selector 中的 Channel 需要通过 configureBlocking(false) 方法设置为非阻塞的。
(2)register()
方法详解
作用:register()
方法用于将一个 Channel 注册到 Selector。这使得 Selector 能够监视该 Channel 上的特定事件(如连接、读取或写入)。每个 Channel 在注册时可以指定一个或多个感兴趣的事件(如连接、读取或写入)。
方法签名:
public SelectionKey register(Selector sel, int ops) throws ClosedChannelException;
参数:
Selector sel
:需要注册的 Selector。int ops
:感兴趣的操作类型,例如SelectionKey.OP_ACCEPT
(接收连接)、SelectionKey.OP_READ
(可读)、SelectionKey.OP_WRITE
(可写)。
返回值:返回一个 SelectionKey
,用于标识该 Channel 的注册状态。
(3)selectedKeys()
方法详解
作用:selectedKeys()
方法返回一个 Set<SelectionKey>
,包含了上一次调用 select()
方法后,所有已准备好的事件的 SelectionKey。它可以检查哪些 Channel 上发生了感兴趣的事件,并进行相应的处理。
返回值:返回的 Set<SelectionKey>
中包含了所有已准备好处理的 Key,开发者可以通过这些 Key 获取具体的 Channel 和事件类型。
注意:处理完一个 SelectionKey
后,必须手动从集合中移除它。selectedKeys()
的集合不会自动移除已处理的 Key。如果不手动移除,下一次事件循环时,你将继续处理已完成的事件,可能导致重复处理。示例:调用remove()方法移除已处理的 SelectionKey
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理 key 的逻辑
iterator.remove(); // 处理后手动移除
}
(4)为什么 Channel 必须设置为非阻塞模式
在 NIO 中,注册到 Selector 的 Channel 必须设置为非阻塞模式,这里有几个原因:
(一)避免线程阻塞:如果 Channel 是阻塞的,当调用 select()
方法时,如果某个 Channel 的 I/O 操作未准备好(例如,没有数据可读),则相关的线程会被阻塞,无法继续处理其他 Channel 的事件。这就违背了使用 Selector 的初衷。
(二)提高资源利用率:通过使用非阻塞模式,线程可以在等待 I/O 事件的同时处理其他任务。这样,CPU 资源得以更有效地利用,避免了线程因等待 I/O 操作而闲置。
(三)单线程管理多个 Channel:NIO 的设计初衷是让单个线程能够高效地管理多个 Channel。非阻塞模式使得线程可以在一个事件循环中处理多个 Channel 的状态变化,而不会被单个 Channel 的状态阻塞。
推荐:
【Redis】Redis中的 AOF(Append Only File)持久化机制-CSDN博客https://blog.csdn.net/m0_65277261/article/details/142661193?spm=1001.2014.3001.5502【MySQL】常见的SQL优化方式(二)-CSDN博客https://blog.csdn.net/m0_65277261/article/details/142610165?spm=1001.2014.3001.5502