Nio
non-blocking io 非阻塞io
三大组件
channel
通道, 读写数据的双向通道
buffer
缓冲区
seletor
一个线程管理一个连接/线程池管理连接 socket连接是阻塞的
选择器 or 多路复用器
检查一个或多个 channel是否处于各种状态(可读、可写…)实现单个线程管理多个channel, channel非阻塞
ByteBuffer
使用byteBuffer的正确姿势
-
1创建byteBuffer 初始是写模式
-
2调用channel.read(buffer) 从channel中读数据到buffer (也可以理解为从channel里读 向buffer里写)
-
3调用buffer.flip() 切换至读模式
-
4从buffer中读取数据
-
5调用clear() or compact()切换至写模式
-
6 重复2-5步骤
Buffer源码
// Invariants (不变关系): mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
- capacity:缓冲区的容量。通过构造函数赋予,一旦设置,无法更改
- limit:缓冲区的界限。位于limit 后的数据不可读写。limit不能为负,并且不能大于其容量
- position:下一个读写位置的索引。position不能为负,并且不能大于limit
- mark:记录当前position的值。position被改变后,可以通过调用reset() 方法恢复到mark的位置。
/**
Buffer源码
**/
//filp源码
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
//clear源码
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
put()
- put()方法可以将一个数据放入到缓冲区中。
- 进行该操作后,postition的值会+1,指向下一个可以放入的位置。capacity = limit ,为缓冲区容量的值。
flip()
tips
连续调用两次flip 似乎什么也不会发生 但其实是limit在第二次调用变为0 会有问题
从写切换至读模式
写入123之后
调用flip后 (position = 0, limit到之前position位置, 因为就写了这么多,所有限制你读这么多)
从读切换至写模式
clear()
相当于回到最初始的状态
compact()
此方法为ByteBuffer的方法,而不是Buffer的方法
- compact会把未读完的数据向前压缩,然后切换到写模式
- 数据前移后,原位置的值并未清零,写时会覆盖之前的值
- compact比clear更耗性能,但compact能保存你未读取的数据,将新数据追加到为读取的数据之后;而clear则不行,若你调用了clear,则未读取的数据就无法再读取到了
rewind()
从头开始读,反复读
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
mark() & reset()
mark()记录一个position
reset()重置position到mark位置
public final Buffer mark() {
mark = position;
return this;
}
public final Buffer reset() {
int m = mark;
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
get(i)
获取指定位置的内容 不影响position
黏包半包实战
工具类BuyeBufferUtil.java在我上传的资源中可以下载
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
/**
* @author Nino
* @version 1.0
*/
public class TestByteBufferExam {
/**
网络上有多条数据发送给服务器, 数据之间使用 \n进行分隔
但由于某种原因这些数据在接收时 被进行了重新组合 例如原始的三条数据为
Hello,world\n
I'm zhangsan\n
How are you?\n
变成了下面两个byteBuffer (黏包, 半包现象 )
Hello,world\nI'm zhangsan\nHo
w are you?\n
现在要求你编写程序, 将错乱的数据恢复成原始的按\n分隔的数据
采用 分散读ScatteringReads 集中写GatheringWrites的思想
*/
public static void main(String[] args) {
ByteBuffer source = ByteBuffer.allocate(322);
source.put("Hello,world\nI'm zhangsan \nHo".getBytes(StandardCharsets.UTF_8));
split(source);
source.put("w are you?\n".getBytes(StandardCharsets.UTF_8));
split(source);
}
private static void split(ByteBuffer source) {
source.flip();
for (int i = 0; i < source.limit(); i++) {
if (source.get(i) == '\n') {
int length = i + 1 - source.position();
ByteBuffer target = ByteBuffer.allocate(length);
for (int j = 0; j < length; j++) {
target.put(source.get());
}
ByteBufferUtil.debugAll(target);
}
}
source.compact();
}
}
网络编程
Java NIO阻塞模式
服务器与客户端交互流程
1. 服务器启动
- 服务器创建一个
ServerSocketChannel
并绑定到一个端口。 - 将
ServerSocketChannel
配置为阻塞模式(默认即为阻塞模式)。 - 开始监听连接请求
ServerSocketChannel.accept();
此处会阻塞。
2. 客户端连接
- 客户端创建一个
SocketChannel
。 - 客户端的
SocketChannel
尝试连接到服务器的指定端口。 - 如果服务器正在监听且端口可用,连接建立成功。
3. 数据传输
- 服务器端:
- 接受客户端连接后,服务器获取一个与客户端通信的
SocketChannel
。 - 通过
SocketChannel
的read()
方法从客户端读取数据(在阻塞模式下,此方法会阻塞直到有数据可读)。 - 处理读取到的数据。
- 使用
SocketChannel
的write()
方法向客户端发送响应(同样,在阻塞模式下,此方法会阻塞直到数据完全写入)。
- 接受客户端连接后,服务器获取一个与客户端通信的
- 客户端:
- 通过
SocketChannel
的write()
方法向服务器发送数据(在阻塞模式下,此方法会阻塞直到数据完全写入)。 - 使用
SocketChannel
的read()
方法从服务器读取响应(同样,在阻塞模式下,此方法会阻塞直到有数据可读)。 - 处理读取到的响应。
- 通过
4. 连接关闭
- 当数据传输完成后,客户端和服务器都可以关闭各自的
SocketChannel
。 - 服务器可以继续监听新的连接请求。
/**
* 客户端代码
* @author Nino
* @version 1.0
*/
public class Server {
public static void main(String[] args) throws Exception{
//使用nio来理解阻塞模式 ,单线程
//1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
//2.绑定一个监听端口
ssc.bind(new InetSocketAddress(8080));
//3.建立和客户端的链接
List<SocketChannel> channels = new ArrayList<>();
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
while (true) {
//建立链接之后 可以返回一个和客户端通信的socketChannel
log.debug("connecting...");
SocketChannel sc = ssc.accept(); //阻塞方法 线程停止运行
log.debug("connected...{}", sc);
channels.add(sc);
//接受客户端发送的数据
for (SocketChannel channel : channels) {
log.debug("before read... {}", sc);
channel.read(byteBuffer); //阻塞方法 线程停止运行
byteBuffer.flip();
ByteBufferUtil.debugRead(byteBuffer);
byteBuffer.clear();
log.debug("after read...{}", sc);
}
}
}
}
/**
* @author Nino
* @version 1.0
* 服务端代码
*/
public class Client {
public static void main(String[] args) throws Exception{
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
System.out.println("1");
}
}
很明显地, 服务端在接受到上述地方有多处阻塞, 这样一板一眼地回应极大地降低了网络通信的效率,不利于多个客户端同时连接(服务端阻塞地等待客户端A发消息, 无法与客户端B建立连接)
tips:ServerSocketChannel和SocketChannel的区别
ServerSocketChannel | SocketChannel | |
---|---|---|
作用范围 | 服务端 | 客户端 |
功能 | 负责监听和接受连接 | 创建与服务端的连接并进行非阻塞的读写操作 |
特点 | 不直接传输数据 | 作为和服务端传输数据的通道对象 |
上述阻塞模式优化:
可以通过代码设置ServerSocketChannel对象为非阻塞模式
ServerSocketChannel ssc = ServerSocketChannel.open(); //默认阻塞模式
ssc.configureBlocking(true);
则服务器开始监听连接请求ServerSocketChannel.accept();
就不会阻塞, 若未建立连接变会返回null;
SocketChannel也可以用configureBlocking()方法设置非阻塞模式,通过SocketChannel
的read()
和write()
方法便不会阻塞,read()如果没有读到数据会返回0,可以根据返回值判断是否读取到数据,write()同理,
非阻塞模式下,客户端线程不会停下来, 因此可以有多个线程与服务端建立连接,但是服务端每次不断循环检查连接,同时也会不断检查是否读取到数据,空循环造成性能浪费,上述模式一般也不会开发中使用, 需要搭配nio的另一大“神器”selector
使用selector
创建selector
Selector selector = Selector.open();
多个连接会有多个SocketChannel, 需要将多个SocketChannel交给seletor管理, 即为注册
我们都知道Netty是基于事件驱动的网络应用程序框架,Java NIO中的事件类型涵盖了文件系统变化和网络I/O状态变化等多个方面, 这边先讨论IO事件
事件类型 | 事件触发条件 |
---|---|
accept | 当服务器端ServerSocketChannel 接收到新的连接时触发 |
connnect | 当客户端成功连接到服务器时触发(对于客户端的SocketChannel ) |
read | 当通道中有数据可读时触发 |
write | 当通道可以写入数据时触发 |
所以很显然 ServerSocketChannel只需要关注什么时候有新连接就行了,只关注accept
同理SocketChannel只需要关注数据的传输(read & write)
注册完之后可以使用以下代码实现
//创建ServerSocketChannel
ServerSocketChannel ssc = ServerSocketChannel.open();
//创建Selector
Selector selector = Selector.open();
//将ssc注册到selector上并且只关心ACCEPT事件
ssc.register(selector, SelectionKey.OP_ACCEPT, null);
while (true) {
//没有事件发生 线程阻塞 有事件 线程才会恢复运行
seletor.select();
//处理事件, selectionKeys 里面包含了所有已经准备就绪的通道所对应的 SelectionKey 对象。
Set<SelectionKey> selectionKeys = seletor.selectedKeys();
//遍历与处理
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
//只有一个ServiceSocketChannel 所以可以直接强转
ServerSocketChannel serverSocketChannel = (ServerSocketChannel)key.channel();
//这边就是针对ssc的accept事件做处理,不处理的话会死循环
SocketChannel socketChannel = serverSocketChannel.accept();
//可以用key.cancel();取消
}
}
socketChannel也要注册到selector上面, 关注read事件, 这样的话上面的代码需要修改,需要对不同事件(accept、read)做不同的判断
//创建ServerSocketChannel
ServerSocketChannel ssc = ServerSocketChannel.open();
//创建Selector
Selector selector = Selector.open();
//将ssc注册到selector上并且只关心ACCEPT事件
ssc.register(selector, SelectionKey.OP_ACCEPT, null);
while (true) {
//没有事件发生 线程阻塞 有事件 线程才会恢复运行
seletor.select();
//处理事件, selectionKeys 里面包含了所有已经准备就绪的通道所对应的 SelectionKey 对象。
Set<SelectionKey> selectionKeys = seletor.selectedKeys();
//遍历与处理
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();//需要手动移除!!!!!很重要
if (key.isAcceptable()) { // 需要根据事件类型分别判断
ServerSocketChannel serverSocketChannel = (ServerSocketChannel)key.channel();
//这边就是针对ssc的accept事件做处理,不处理的话会死循环
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.register(selector, SelectionKey.OP_READ, null);
} else if (key.isReadable()) {
try {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(16);
int read = channel.read(buffer);//如果正常断开 read的方法返回值是-1
if (read == -1) {
key.cancel();
} else {
buffer.flip();
deBugRead(buffer);
}
} catch(Exception e) {
key.cancel(); //问题:为什么要取消掉key, 一开始不都把selectedKey从集合中移除了吗
}
}
}
}
使用上的细节
tips
在处理SelectionKey集合时,有一点很重要:你需要在处理完每一个SelectionKey之后将其从集合中移除。这是因为在处理完一个SelectionKey后,它不会自动从Selector的已选择键集合SselectionKeys中移除,如果不手动移除,它会一直留在集合中,导致下一次select()方法调用时,仍然会包含这个SelectionKey,即使它已经不再就绪。
问题1
为什么nio不帮我们做好这个移除操作,而是需要用户手动移除?
Java NIO设计选择需要手动移除SelectionKey的原因是为了灵活性和效率。
- 灵活性:手动移除SelectionKey允许开发人员在选择何时移除键时有更多的控制。有时,可能需要在处理完SelectionKey后进行一些其他操作,而不是立即将其移除。这种灵活性可以根据具体的应用场景来定制,以满足不同的需求。
- 效率:自动移除SelectionKey可能会导致不必要的开销。如果在处理完SelectionKey后立即自动移除,这可能会引入一些额外的开销,尤其是当处理的SelectionKey数量很大时。手动移除SelectionKey可以让开发人员在适当的时候进行移除,从而更好地控制性能。
虽然手动移除SelectionKey可能会增加一些额外的工作量,但它确保了更大的灵活性和更高的性能,使得开发人员能够更好地控制和优化他们的代码。
问题2
为什么要取消掉key, 一开始不都把selectedKey从集合中移除了吗
将 SelectionKey 从 Selector 的 key set 中移除,但不调用 selectionKey.cancel()
来取消 SelectionKey 可能会导致一些问题:
状态不一致性:虽然你从 Selector 的 key set 中移除了 SelectionKey,但 Selector 仍然认为该 SelectionKey 是有效的。这可能导致 Selector 在下一次选择操作中(seletor.select(); seletor.selectedKeys())会再次返回这个已经处理过的 SelectionKey,导致重复处理 陷入死循环
💡select何时不阻塞
-
事件发生时
- 客户端发起连接请求, 触发accept事件
- 客户端发数据过来、客户端正常关闭、异常关闭时,都会触发read事件,另外如果发送的数据大于buffer缓冲区,会触发多次read事件
- channel可写会触发write事件
- linux下nio bug发生时(Oracle的锅)
-
调用 selector.wakeUp()
-
调用selector.close()
-
selector所在的线程interrupt
多线程优化Nio服务器
前面的代码只有一个selector,没有充分利用多核cpu,万一在某个事件上的处理过长会影响其他连接的接入
改进方法如下
- 单线程配一个selector 专门处理accept事件 (boss 只负责接待,建立连接)
- 创建cpu核心数的线程,每一个线程配一个选择器,并发处理read事件 (worker 只负责读写)
NIO vs BIO
IO模型
同步阻塞、同步非阻塞、多路复用、异步阻塞、异步非阻塞
当调用一次channel.read()之后,会切换至操作系统的内核态来完成真正的数据读取,而读取又分为两个阶段,分别为:
- 等待数据复制阶段
- 复制数据阶段
阻塞IO
用户线程被阻塞,读取期间什么都干不了
非阻塞IO
使用 while(true)
反复循环看有没有数据
只是在等待数据阶段非阻塞,真正到复制阶段也是阻塞的,而且涉及到多次用户态与内核态的切换,影响系统的性能
多路复用
一个selector可以监测多个channel 的事件 , 虽然也要等待, 但是等待返回后的多个事件处理时都不需要再等待
同步
线程自己获取结果, 一个线程从read -> 等待 -> 接受 全部完成
异步
线程自己不获取结果,由其他线程送结果, 一个线程发起调用, 另一个给你送结果
不存在异步阻塞
同步操作 | 异步操作 |
---|---|
阻塞IO | 异步非阻塞 |
非阻塞IO | |
多路复用 |
零拷贝
在正常的数据传输、网络通讯中, 需要将数据从磁盘拷贝到内核缓冲区,拷贝到用户缓冲区,拷贝到 scoket缓冲区 最后才通过网卡发送
这其中涉及到4次的数据拷贝与 三次的用户态与内核态的切换
而零拷贝(Zero-Copy) 则是一种无需将数据从一处内存缓冲区复制到另一处就可以完成从源端到目的端的传输的技术。这种技术可以有效地减少数据在内存之间的拷贝次数,从而提高数据处理的效率和性能。
ByteBuffer
类也支持零拷贝操作,通过 allocateDirect
方法可以分配直接缓冲区,这样 JVM 就可以利用操作系统的本地 I/O 操作来减少一次数据的拷贝
Linux2.4优化 使用transferTo方法
所谓的零拷贝并不是真正的无拷贝,而是不会拷贝重复数据到JVM内存中,零拷贝的好处在于可以,降低上下文切换的开销,从而提高系统的整体性能。