一、nio简介
当涉及到高效的网络编程时,Java的NIO(New Input/Output)多路复用是一个重要的概念。NIO多路复用允许单个线程管理多个连接(Socket通道),通过选择器(Selector)同时监控多个通道的状态,从而实现高效的网络通信。在传统的Java I/O模型中,每个连接都需要一个独立的线程来处理,这会导致线程的创建和销毁开销较大,同时线程之间的切换也会消耗大量的资源。而NIO多路复用通过使用选择器(Selector)来监控多个通道的状态,将连接的管理交由一个单独的线程来处理,从而大大减少了线程的数量。选择器(Selector)是NIO多路复用的核心组件,它会不断地轮询注册在其上的通道(Channel),并且只选择处于就绪状态的通道进行处理。通道可以被注册到选择器上,以便在通道准备好进行读、写或接受连接时得到通知。通过NIO多路复用,我们可以使用单个线程同时管理多个连接,而不需要为每个连接创建一个独立的线程。这种方式可以极大地提高系统的并发处理能力,减少线程的开销,并且减少了线程切换的消耗。
java传统BIO
再java传统的Bio网络编程中,一个线程处理一个连接,如果连接发生阻塞的话,线程也会随之一起陷入阻塞,这样会在一定程度下降低网络通信的效率。
java nio多路复用
在NIO中,线程会操作一个选择器(selector),而selector将会调度其内部注册的channel,channel将会处理各种各样的操作,当某个操作陷入阻塞时,selector将会进行调度,将其他channel的操作调度由线程处理,这种将多个channel交由selector在一个线程中调度处理,这种架构处理方式就是多路复用。通过多路复用,可以在极大程度上提升网络数据传输的效率。
接下来,笔者将详细介绍java nio中的三大组件,Buffer,Channel和Selector。
二、nio三大组件
1.ByteBuffer
Java中的ByteBuffer类是NIO包中的一个类,用于对字节数据进行高效操作。ByteBuffer 类提供了一种灵活的方式来操作字节数据,可以读取、写入、复制、截取等操作,主要用于实现数据的缓存。
(1)ByteBuffer的创建
ByteBuffer是一个抽象类,所以无法直接使用new关键字来创建对象。创建ByteBuffer对象主要使用ByteBuffer类中的两个静态方法来创建,ByteBuffer.allocate(int capacity)和ByteBuffer.allocateDirect(int capacity)
//导入包
import java.nio.ByteBuffer;
public class MainTest {
public static void main(String ... args) {
//使用allocate开辟空间,其内存分配由jvm负责,受到jvm堆内存机制的影响
ByteBuffer buffer1 = ByteBuffer.allocate(10);
//方法会在本地内存中创建一个新的ByteBuffer对象,
//这种分配方式是在操作系统的本地内存中进行的,不受 Java 堆内存管理机制的影响
ByteBuffer buffer2 = ByteBuffer.allocateDirect(10);
}
}
(2)position和limit
在 Java 中的 ByteBuffer 类中,position和 limit是ByteBuffer中的两个重要的属性,用于控制读写操作的位置和范围。
position 表示当前的读写位置,在进行写操作时,数据将会被写入到 position 所指定的位置;在进行读操作时,数据将会从 position 所指定的位置开始读取。初始时,position 通常为 0。
limit 表示在读取操作时的数据范围限制,即可以读取的数据范围从 0 到 limit-1。在写入数据时,limit 通常会被设置为 ByteBuffer 的容量,表示可以写入的最大数据量。
通过控制 position 和 limit,可以有效地进行读写操作,确保不会读取或写入超出范围的数据。 在使用 ByteBuffer 时,通常会结合使用其他方法来处理 position 和 limit,比如 flip 方法用于切换读写模式,rewind 方法用于重置 position,clear 方法用于清空并重置 position 和 limit 等。
当向ByteBuffer中写入数据时:
当从ByteBuffer中读取数据时:
(3)flip()、rewind()、clear()
flip():将缓冲区从写模式切换到读模式,重置 position 为 0。
rewind():将 position 设置为 0,限制保持不变,可以重新读取缓冲区中的数据。
clear():清空缓冲区,重置 position 和 limit 为初始位置,数据不会被清除,但处于“已遗忘”状态。
为了方便理解,ByteBuffer的其余介绍及用法将会结合Channel一起介绍。
2.Channel
在 Java NIO 中,一个重要的概念是 Channel(通道),它代表了与数据源或数据目标之间
的连接,可以进行读取和写入数据。 Channel 和传统的流相比,具有以下特点:
1.双向性:Channel 既可以用于读取数据,也可以用于写入数据,而流是单向的。
2.非阻塞:Channel 支持非阻塞模式,可以进行异步 I/O 操作,提高了程序的性能。
3.可选择性:Channel 可以注册到 Selector上,实现多路复用,可以同时管理多个 Channel 的 I/O 操作。
Java 中常用的 Channel 类型包括:
1.FileChannel:用于文件的读写操作。
2.SocketChannel:用于 TCP 网络通信中的客户端。
3.ServerSocketChannel:用于 TCP 网络通信中的服务器端。
4.DatagramChannel:用于 UDP 网络通信。
通过 Channel,Java NIO 提供了一种更灵活、高效的 I/O 编程方式,特别适用于网络通信和文件操作等场景。
这里使用FileChannel结合ByteBuffer来简单实现一下读取文本文件的功能步骤如下:
1)首先获取文件传输的channel,可以通过FileInputStream来获取
2)创建ByteBuffer缓冲区,大小为4字节,方便演示当缓冲区大小不足时,如何循环读取文件数据
3)调用channel的read方法循环读取channel中的字节流,并放进ByteBuffer中。channel的read方法返回读取数据的长度,当没有读取到数据时会返回-1,可以用于作为循环终止条件。
4)循环读取ByteBuffer中缓存的数据,调用ByteBuffer的hasRemaining方法来判断是否还有剩余数据没有读取,作为循环终止的条件。
public class Test {
public static void main(String ... args) throws Exception {
//获取文件传输通道(channel)
FileChannel channel = new FileInputStream("F:\\java\\java_work\\Netty\\src\\main\\resources\\data.txt").getChannel();
//创建ByteBuffer缓冲数组,大小为4字节
ByteBuffer buffer = ByteBuffer.allocate(4);
//读取数据放入buffer并判断channel中的数据是否读完
while (channel.read(buffer) != -1) {
//当要读取ByteBuffer中的数据是,一定切记要调用flip切换为读模式
buffer.flip();
//判断buffer中是否还有数据未读取
while (buffer.hasRemaining()) {
//从buffer的position指针的位置下获取一个字节数据,并向后移动position指针
byte b = buffer.get();
//打印
System.out.print((char) b);
}
//读取完毕后要清空buffer
buffer.clear();
}
}
}
当然,这样单个字节读取打印遇到中文是会乱码的,如果buffer缓冲空间足够大,且需要打印中文等字符,可以使用java的StandardCharsets类来进行解码。
public class Test {
public static void main(String ... args) throws Exception {
FileChannel channel = new FileInputStream("F:\\java\\java_work\\Netty\\src\\main\\resources\\data.txt").getChannel();
ByteBuffer buffer = ByteBuffer.allocate(256);
channel.read(buffer);
//切换为读模式
buffer.flip();
//使用StandCharsets中的静态工具类来对其进行解码并打印
System.out.println(StandardCharsets.UTF_8.decode(buffer));
}
}
3.Selector
Selector是java nio编程中的核心组件,是nio多路复用的高效的 I/O 监听器。它用于监听多个通道的事件,例如读、写和连接就绪等,从而在单个线程中处理多个通道的 I/O 操作。这种机制可以大大减少线程的数量,提高系统的吞吐量和响应性能。
(1)Selector的获取
通过Selector的open方法,即可获取Selector类
Selector selector = Selector.open();
(2)channel的注册
获取到了Selector类之后,需要向其注册channel,而selector将会监听和调度这些channel的事件
//获取服务器通信的channel
ServerSocketChannel ssc = ServerSocketChannel.open();
//将其设置为非阻塞模式
ssc.configureBlocking(false);
//为channel绑定监听端口
ssc.bind(new InetSocketAddress(8080));
//获取selector类
Selector selector = Selector.open();
//调用channel的register方法来注册进指定的selector中,并声明该channel对什么事件感兴趣
//当该事件发生时,selector将会调度该channel进行处理,这里的事件为accept事件
/*该代码也可等同于
ssc.register(selector, SelectionKey.OP_ACCEPT);
*/
ssc.register(selector, 0).interestOps(SelectionKey.OP_ACCEPT);
以上代码使用的channel是用于服务器通信的,需将其设置为非阻塞模式,并且注册进selector中,selector中可以注册多个channel,并且需要声明该channel感兴趣的事件,方便selector调度。接下来,我们将完善以上代码,实现Server和Client之间的Nio通信。
三、实现简单的客户端服务器通信
1.Server
我们从channel注册部分的代码开始完善。首先,因为服务器需要持续处理请求,所以需要写入一个死循环,然后在循环中调用selector的select()方法,调用该方法将会陷入阻塞,直到事件发生时,代码才会往下执行。
public class Test {
public static void main(String ... args) throws Exception {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
ssc.register(selector, 0).interestOps(SelectionKey.OP_ACCEPT);
ssc.register(selector, SelectionKey.OP_ACCEPT);
//循环监听
while (true) {
//阻塞监听事件,事件发生时向下执行
selector.select();
}
}
}
当事件触发时,获取selector中注册的channel的key的列表对每个channel进行处理,需要注意的是,这里需要使用迭代器,而不能简单的使用循环,因为当处理完一个channel事件时,需要立即把该channel从SelectionKey列表中移除,否则可能会重复处理造成异常。
public class Test {
public static void main(String ... args) throws Exception {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
ssc.register(selector, 0).interestOps(SelectionKey.OP_ACCEPT);
ssc.register(selector, SelectionKey.OP_ACCEPT);
//循环监听
while (true) {
//阻塞监听事件,事件发生时向下执行
selector.select();
//获取selector中channel的SelectionKey列表迭代器,依次进行处理
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
//循环处理迭代器的元素
while (iter.hasNext()) {
//获取selectionKey
SelectionKey key = iter.next();
//移除当前Key,防止重复处理
iter.remove();
//...
}
}
}
}
对触发了事件的channel需要对其事件进行判断并进行处理,我们先处理触发了accep事件的逻辑。当触发了accept时,先通过key获取channel并强转为需要的channel类型,因为我们之前注册通道时,对accept感兴趣的channel为ServerSocketChannel,所以需要强转为该类型,然后调用其accept方法建立连接,并会返回SocketChannel,需要将该channel也注册进selector中,并且设置感兴趣的事件为read,通过该channel进行事件的处理。
public class Test {
public static void main(String ... args) throws Exception {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
ssc.register(selector, 0).interestOps(SelectionKey.OP_ACCEPT);
ssc.register(selector, SelectionKey.OP_ACCEPT);
//循环监听
while (true) {
//阻塞监听事件,事件发生时向下执行
selector.select();
//获取selector中channel的SelectionKey列表迭代器,依次进行处理
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
//循环处理迭代器的元素
while (iter.hasNext()) {
//获取selectionKey
SelectionKey key = iter.next();
//移除当前Key,防止重复处理
iter.remove();
//判断该channel感兴趣的事件是否是accept
if (key.isAcceptable()) {
//通过key获取channel并强转为需要的类型
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
//通过accept方法建立连接,并返回SocketChannel
SocketChannel accept = serverSocketChannel.accept();
//将该channel也设置为非阻塞模式
accept.configureBlocking(false);
//将该channel注册进selector中,并指明感兴趣的事件为读事件
accept.register(selector, SelectionKey.OP_READ);
}
}
}
}
}
然后处理读事件,同样通过key获取并强转为指定的channel,创建ByteBuffer缓冲数组存放数据,调用channel的read方法来向buffer中读取数据,读取完毕后再向客户端写数据,这里需要注意的是,为了防止客户端无法一次性读取完毕所有的服务端发送的数据,所以可能需要多次发送(即多次向SocketChannel中写数据),所以我们需要把没有读取完的buffer挂载到key上,并再为其指定一个写事件,用于下次循环时继续处理该写事件。需要注意的时,如果客户端中途断开的话,会触发异常,所以这里需要使用try-catch,当异常触发时,需要调用key的cancel方法,否则selector会重复处理。
public class Test {
public static void main(String ... args) throws Exception {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
ssc.register(selector, 0).interestOps(SelectionKey.OP_ACCEPT);
ssc.register(selector, SelectionKey.OP_ACCEPT);
//循环监听
while (true) {
//阻塞监听事件,事件发生时向下执行
selector.select();
//获取selector中channel的SelectionKey列表迭代器,依次进行处理
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
//循环处理迭代器的元素
while (iter.hasNext()) {
//获取selectionKey
SelectionKey key = iter.next();
//移除当前Key,防止重复处理
iter.remove();
//判断该channel感兴趣的事件是否是accept
if (key.isAcceptable()) {
//通过key获取channel并强转为需要的类型
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
//通过accept方法建立连接,并返回SocketChannel
SocketChannel accept = serverSocketChannel.accept();
//将该channel也设置为非阻塞模式
accept.configureBlocking(false);
//将该channel注册进selector中,并指明感兴趣的事件为读事件
accept.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
try {
//获取ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
//获取channel
SocketChannel sc = (SocketChannel) key.channel();
//从channel中读取数据
int len = sc.read(buffer);
//如果没有数据读取到就跳过
if (len <= 0) continue;
//切换为读模式
buffer.flip();
//对数据进行解码并打印
System.out.println(StandardCharsets.UTF_8.decode(buffer));
//向客户端发送的数据
String content = "message from server....";
//对数据编码为字节数组
ByteBuffer writeBuffer = StandardCharsets.UTF_8.encode(content);
//写入channel
sc.write(buffer);
//如果客户端一次性无法读取完毕,需将其挂载到key上,并增加写事件,用于下次处理
if (writeBuffer.hasRemaining()) {
//将buffer挂载到key上,下次写事件处理
key.attach(writeBuffer);
//增加写事件的监听
key.interestOps(key.interestOps() + SelectionKey.OP_WRITE);
}
} catch (Exception e) {
e.printStackTrace();
//如果触发异常需调用该方法,否则会进入重复处理
key.cancel();
}
}
}
}
}
}
最后,我们处理写事件的逻辑。首先获取key上挂载的ByteBuffer对其继续处理,判断ByteBuffer是否还有剩余数据,如果已经读取完毕则将buffer从key上移除,然后去除key上的读事件,如果没有写入完毕,则继续写入。
public class Test {
public static void main(String ... args) throws Exception {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
ssc.register(selector, 0).interestOps(SelectionKey.OP_ACCEPT);
ssc.register(selector, SelectionKey.OP_ACCEPT);
//循环监听
while (true) {
//阻塞监听事件,事件发生时向下执行
selector.select();
//获取selector中channel的SelectionKey列表迭代器,依次进行处理
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
//循环处理迭代器的元素
while (iter.hasNext()) {
//获取selectionKey
SelectionKey key = iter.next();
//移除当前Key,防止重复处理
iter.remove();
//判断该channel感兴趣的事件是否是accept
if (key.isAcceptable()) {
//通过key获取channel并强转为需要的类型
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
//通过accept方法建立连接,并返回SocketChannel
SocketChannel accept = serverSocketChannel.accept();
//将该channel也设置为非阻塞模式
accept.configureBlocking(false);
//将该channel注册进selector中,并指明感兴趣的事件为读事件
accept.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
try {
//获取ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
//获取channel
SocketChannel sc = (SocketChannel) key.channel();
//从channel中读取数据
int len = sc.read(buffer);
//如果没有数据读取到就跳过
if (len <= 0) continue;
//切换为读模式
buffer.flip();
//对数据进行解码并打印
System.out.println(StandardCharsets.UTF_8.decode(buffer));
//向客户端发送的数据
String content = "message from server....";
//对数据编码为字节数组
ByteBuffer writeBuffer = StandardCharsets.UTF_8.encode(content);
//写入channel
sc.write(buffer);
//如果客户端一次性无法读取完毕,需将其挂载到key上,并增加写事件,用于下次处理
if (writeBuffer.hasRemaining()) {
//将buffer挂载到key上,下次写事件处理
key.attach(writeBuffer);
//增加写事件的监听
key.interestOps(key.interestOps() + SelectionKey.OP_WRITE);
}
} catch (Exception e) {
e.printStackTrace();
//如果触发异常需调用该方法,否则会进入重复处理
key.cancel();
}
} else if (key.isWritable()) {
//获取key上挂载的buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
SocketChannel socketChannel = (SocketChannel) key.channel();
//如果没有剩余数据
if (!buffer.hasRemaining()) {
//移除key上挂载的buffer
key.attach(null);
//去除写事件
key.interestOps(key.interestOps() - SelectionKey.OP_WRITE);
continue;
}
socketChannel.write(buffer);
}
}
}
}
}
至此,服务端代码完毕,接下来处理客户端的逻辑。
2.Client
这里的客户端我们做简单处理,只发送单条数据,所以不必使用Selector。
public class Client {
public static void main(String ... args) throws IOException {
//创建通道
SocketChannel sc = SocketChannel.open();
//建立连接
sc.connect(new InetSocketAddress("localhost", 8080));
//向服务器端发送数据
sc.write(StandardCharsets.UTF_8.encode("message from client"));
//接受服务器端数据的缓存(这里我们故意将大小开辟的小一点,验证服务器端代码能正常处理
//客户端缓冲区过小的问题
ByteBuffer readBuffer = ByteBuffer.allocate(2);
while (true) {
//读取数据
int len = sc.read(readBuffer);
//若读取完毕则关闭连接
if (len <= 0) {
sc.close();
break;
}
//将buffer切换为读模式
readBuffer.flip();
//打印读取到的数据
System.out.print(StandardCharsets.UTF_8.decode(readBuffer));
//清空buffer,确保下次读入数据正常
readBuffer.clear();
}
}
}
3.测试
先启动服务器,再启动客户端,最终两边结果正常打印,代码正常。
四、总结
nio多路复用在极大程度上提高了网络通信的效率,nio主要由Channel, Buffer,Selector三大组件组成,其作用分别为,数据传输的通道,数据缓存,调度Channel,作为Netty框架的底层实现,只有掌握了基本的java nio编程,才能为之后Netty的学习打下坚实的基础