目录
NIO三大组件
一. ByteBuffer
基本用法
DirectByteBuffer与HeapByteBuffer对比
字符串转ByteBuffer
ByteBuffer.wrap(byte[] )
粘包与拆包
文件编程
零拷贝transferTo
二. 阻塞与非阻塞Channel
三. Selector
SelectionKey(重点)
SelectionKey四种类型
key.cancel()
事件类型
iter.remove()
selectKeys中的事件要么处理、要么取消
key.cancel()的应用场景
selectionKey附件
TCP编程都要考虑的:消息边界处理
ByteBuffer大小分配
写出内容过多问题-使用write事件(重点)
通过可写事件-分批多次进行大文件写出
多路复用器
selector.select()如何退出阻塞
NIO多线程优化
阻塞队列还可以放入Runnable任务
NIO概念剖析
BIO与NIO
IO模型
阻塞read()
非阻塞read()
异步IO
内存映射与零拷贝
mmap+write
sendFile
再次对比DirectByteBuffer与HeapByteBuffer
为何IO操作都需要将JVM内存数据拷贝到堆外内存?
那为什么Java不全部通过JNI操作堆外内存来写代码呢?
普通BIO
DirectByteBuffer存在的意义(重点)
没有mmap内存映射产生的DirectByteBuffer对象之前
直接内存的开辟示意图
Nio如何管理堆外内存的释放
回收流程
Netty的异步调用与异步IO模型
视频链接
NIO三大组件
NIO三大组件:Channel、Buffer、Selector
Selector适合channel连接数特别多,但是每个channel上来回的流量低的场景
一. ByteBuffer
基本用法
int count = channel.read(buffer),如果返回-1,表示channel中的数据读取完毕了
- 在写入模式下,limit就是能最大的写入位置
- 在读取模式下,limit就是最大的读取位置
compact()两个作用:压缩、切换到写模式
DirectByteBuffer与HeapByteBuffer对比
HeapByteBuffer因为是分配在JVM堆上的,所以如果一轮gc这个HeapByteBuffer还没有被处理完,就不能被回收,那么如果此HeapByteBuffer在新生代,就会被通过复制算法,复制到s区(产生多次数据拷贝),这就是HeapByteBuffer的使用,会受到gc影响的一种表现
DirectByteBuffer,是分配在JVM堆外的,是要调用,系统调用,直接在JVM进程堆(JVM堆外)中分配的,所以分配效率较HeapByteBuffer低一些,但是因为JVM进程堆不受gc影响,不会因为发生了GC,而导致DirectByteBuffer对应的堆外空间被到处复制转移(复制转移是会产生开销的)
字符串转ByteBuffer
第一种模式写入数据后,要先flip()切换为读模式,否则就会读到空数据
ByteBuffer.wrap(byte[] )
wrap效果等于第二种模式,包装以后,也会直接切换为读模式
粘包与拆包
发送方,肯定是想一次攒多条消息一起发送,效率更高,所以就是因为发送方想一次攒一波儿数据一起发,从而导致接收方,产生了粘包的可能
比如上面,发送方可能就是把第8、9、10行的三个数据包,一起发送给了接收方
文件编程
channel的写入能力是有上限的,如果channel对应的本地发送缓冲区满了,tcp还没来得及把堆在发送缓冲区中的数据发出去,此时channel.write()就往发送缓冲区写入数据就会一直写入0字节,所以要通过上面的hasRemaining()的方式,分多次往channel中写入
零拷贝transferTo
当要传输的文件大于2G时,要分多次传输
我觉得上面函数的第二个参数,应该写size,不应该是left
二. 阻塞与非阻塞Channel
IO的api默认是阻塞模式,如果想使用非阻塞NIO,那么ServerSocketChannel和SocketChannel都要分别设置非阻塞模式
三. Selector
SelectionKey(重点)
无论是serverSocketChannel,还是socketChannel,往selector上注册后,都会返回唯一一个魏颖的SelectionKey注册键
- serverSocketChannel对应唯一一个SelectionKey,可以关注accept事件
- 从每个socketChannel对应SelectionKey身上,可以取出唯一对应的那个socketChannel,和一个attachment附件
- 一个socketChannel对应的唯一SelectionKey,可以同时关注read事件和write事件
- 当socketChannel上无论有read事件,还是write事件时,selector.select()都会将这个socketChannel对应的唯一SelectionKey返回。但是可以通过key.readable()或者key.writeable()判断此时返回的事件究竟是何种类型
SelectionKey四种类型
sscKey关注了accept事件
那么当客户端有新的连接进来时,sscKey这个SelectionKey就会出现在selector.select()返回的SelectionKey的集合中。也就是说,此时代码B处的某一个key才可能是sscKey
可以看到,当有一个连接事件进来时,selector.select()返回的SelectionKey还是第28行对应的sscKey,只不过在第28行时,sscKey管理的serverSocketChannel上还没有accept事件达到。
而当有新的客户端连接serverSocketChannel时,就代表sscKey管理的serverSocketChannel上有accept事件达到了,此时,selector.select()返回的SelectionKey还是第28行对应的sscKey,此时,通过sscKey就能拿到这个新达到的accept事件
key.cancel()
如果有了事件不处理,上面的代码就会一直死循环,因为selector.select()一直会把sscKey给返回
如果拿到事件就是不想处理这个事件,可以cancel
此时key.cancel()实现的效果是,直接把此selectionKey从selector身上拿掉了,也就是说,以后本selector就不再管理此selectionKey对应的channel了,也就是说,以后本selector就不再监控来自这个channel上的任何事件了(这个channel就放养了,无人看管,无人关心了)
事件类型
iter.remove()
当第42行,将accept事件处理以后,就会将sscKey@59a6e353这个selectionKey上的accept事件给去掉
sscKey@59a6e353这个selectionKey此时,还在绿色框中,也就是selector.select()时,还是会将这个selectionKey给拿出来,但是这个selectionKey上此时已经没有accept事件了
第二轮while循环,此时selector.select()返回的集合中有两个selectionKey,一个sscKey和scKey,只不过sscKey上的accept事件已经不存在了,在上一轮while循环被取走了,所以此时在想通过sscKey上取accept事件就取不到了,所以上面代码第42行会返回null,导致代码第43行报了NPE
讲了这么多,我们处理完一个selector.select()返回的集合中的某个selectionKey身上挂载的事件后,就要把这个selectionKey,从绿色框的selectionKey集合中给移除
右边红色框,就是每个selector的红黑树集合,这个保存着所有注册到本selector身上的channel(包括serverSocketChannel和socketChannel)
右边绿色框,就是每次epoll_wait()返回的“有事件到达的selectionKey集合”,只不过每次用户空间把这个集合取过去后,处理完每个selectionKey当前身上挂载的事件后,需要用户空间手动调用iter.remove()把这个selectionKey,从“有事件到达的selectionKey集合”中手动删除
selectKeys中的事件要么处理、要么取消
当客户端连接强制断开时(比如客户端直接宕机了),服务端再通过客户端的channel去read()就会抛异常,会导致服务端线程挂掉,所以服务端需要catch这个IO异常
key.cancel()的应用场景
客户端连接关闭后,会引发一个Read事件,读取这个Read事件会抛出IOExcepiton
所以此时要做的就是,通过调用key.cancel(),取消此selectionKey背后对应的channel,在selector身上的注册,让selector不再管理这个已经死了的客户端channel了
不管是客户端强制断开,还是正常channel.close(),服务端都会受到一个read事件
只不过异常断开时,服务端调用channel.read()会抛出IOException。正常断开时,服务端调用channel.read()会返回-1。
但是不管是客户端连接时强制断开,还是正常channel.close()导致的断开,服务端都要通过key.cancel(),把这个channel对应的SelectionKey从selector的红黑树中删除,也就是,删除这个channel在selector身上的注册行为
selectionKey附件
SelectionKey - Channel - attachment,都是一一对应的
通过这种方式,给每个channel附带一个唯一对应的ByteBuffer
TCP编程都要考虑的:消息边界处理
通过fileSize(固定字节数) + fileBuffer(可变字节数)的形式,来处理消息边界问题。
http也是这种形式,有contentLength请求头
ByteBuffer大小分配
写出内容过多问题-使用write事件(重点)
可以看到发送能力是有限的,当发送缓冲区写满了,第38行的写入就会写不进去了,所以返回的写入字节数是0
这样的实现模式,本身是没有问题的,如果3000w字节的数据,如果没有发送完,那么左侧的epoll处理主线程就会一直卡在上图红框的while循环中,别的channel的话,epoll处理主线程此时就无暇去处理它们。这不符合我们非阻塞IO的设计思想
我们希望是,当发送缓冲区满时,epoll处理主线程就不一直卡在上图红框的while循环中(产生空转,因为只要发送缓冲区满,while循环就会一直空转耗费CPU),而是去处理别的channel的事件
(等上一个channel的发送缓冲区空了,触发一个Write事件,表示发送缓冲区空了,可以写了,此时,epoll处理主线程再对这个channel写入上一轮没有写完的数据)
此时,写事件会覆盖关注的读事件
此时,就表示我们通过scKey这一个SelectionKey,同时关注了Read和Write事件
此后,如果此scKey对应的channel对应的发送缓冲区空了以后,就会触发一个Write的可写事件,然后代码就会进入第47行的if判断中,开始把上一轮没有写完的buffer中的剩余数据,继续去写
这样,就把原来的while循环写,变成了对Write事件的多次触发
大数据buffer写完后,还需要将它从附件处删除,同时取消关注Write事件
可写事件Write的触发机制,就是当发送缓冲区空时,可以写数据了,那么就会触发可写事件,从而epoll.select()就能拿到这个可写事件
通过可写事件-分批多次进行大文件写出
可写事件,只有在要写的数据太多时,才去使用
通过可写事件,来处理channel.write()一次写不完一整个大文件的情况。
那么就可以知道,如果你每次就写个几个字节,几十上百个字节,那么直接通过channel.write(),把数据一次性写出去就好了,根本不需要可写事件Write的相关逻辑参与
可以看到,当这个channel对应的发送缓冲区写满的时候,再通过channel.write()来往channel对应的发送缓冲区中写数据,会直接返回0,也就是写入了0字节。
后续,写入线程就会多次空转,尝试往发送缓冲区中写入数据,那么我们可以通过,写入发送缓冲区写满了以后,就通过selectionKey关注这个channel的write事件,那么当这个channel对应的发送缓冲区又可以写数据时,selector.select()就会探测到一个write可写事件,这个时候,我们的写入线程,再通过channel.write()来往channel对应的发送缓冲区中写数据,就能避免写入线程因为发送换成区满而产生的多次空转问题
那么此时,如果第36行一次没有把全部大文件写完,那么就会多次进入第47行开始的可写事件,也就是相当于,把原来的while循环写,转化为了多次可写事件的处理
因为key身上挂的附件buffer,可能大小有1个G,当我们把这个附件buffer中的数据全部写完了以后,要取消附件buffer的挂载,让JVM去gc回收这篇1个G的大内存,不然一直让它占着1个G的大内存是非常不合适的
另外,我们把1个G的内容写完了以后,还要取消关注可写事件,等到后续又有新的大文件的写需求时,先尝试写一次,如果又没有写完,再让selector去关注这个channel上的可写事件
多路复用器
selector.select()如何退出阻塞
NIO多线程优化
这里实际上,就是boss线程,再往Worker的阻塞队列中,投入了一个Runnable任务,并唤醒在selector.select()处阻塞worker线程
阻塞队列还可以放入Runnable任务
当selector.select()处于阻塞状态时,直接socketChannel.register(selector)来往selector上注册事件监听时,也会被阻塞住
只有先让worker线程,从selector.select()被唤醒,然后worker线程自己从阻塞队列中去task执行
注意,当前阻塞队列中,放的是一个Runnable任务
NIO概念剖析
BIO与NIO
BIO是更加高层次的API,比如它们不会关心到发送缓冲区,接收缓冲区的逻辑。比如,前面的例子,写大量数据时,就会关心发送区慢了,本次就不发了,而是去关注write事件,等下一次write事件达到时,再去发
IO模型
阻塞read()
非阻塞read()
比如有网卡的数据真正达到网卡缓冲区,需要被从网卡缓冲区复制到内核缓冲区时,用户线程调用的read()一样还是会被阻塞住
多路复用器
BIO 同步阻塞、NIO 和 EPOLL都是同步非阻塞
异步IO
异步,一定是非阻塞的。没有异步阻塞的说法
内存映射与零拷贝
mmap+write
使用MappedByteBuffer
内存映射
内存文件映射适用于对大文件的读写。虚拟地址空间有一块区域: “Memory mapped region for shared libraries” ,这段区域就是在内存映射文件的时候将某一段的虚拟地址和文件对象的某一部分建立起映射关系,此时并没有拷贝数据到内存中去,而是当进程代码第一次引用这段代码内的虚拟地址时,触发了缺页异常,这时候OS根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去
只用3次拷贝,减少了1次
rocketmq是mmap+write,kafka是sendFile
sendFile
数据,不在经过用户空间,
零拷贝,指的是不再需要把数据,拷贝到用户空间内存(JVM内存,就是用户空间内存)
不再需要CPU拷贝,只需要DMA去执行拷贝动作
再次对比DirectByteBuffer与HeapByteBuffer
为何IO操作都需要将JVM内存数据拷贝到堆外内存?
- 因为JNI操作的内存空间数据,不能随便被GC从而挪动位置,所以Java程序,不能通过JNI直接分配内存空间来进行代码逻辑的书写,因为可能上一秒通过JNI记录的是固定地址,而GC会导致固定地址内部的数据被挪走到别处
- 但是,Java程序可以通过JNI分配堆外内存,并直接操作堆外内存,堆外内存的地址就是固定的,不会随意被GC动作给挪走
那为什么Java不全部通过JNI操作堆外内存来写代码呢?
- 因为,Java的特色就是能自动进行垃圾回收,而只有堆内内存空间的数据,才能通过JVM垃圾收集器进行自动的垃圾回收
- 所以,如果Java全部使用JNI分配堆外内存,那么就没有办法再使用JVM提供的垃圾收集器进行自动的垃圾回收
- 而JNI操作的堆外内存,才是直面各底层硬件的内存,所有的硬件上的数据读写都要先经过堆外内存
JVM堆内内存,JNI是不能直接访问和操作的。所以,JVM堆内空间要想拿到磁盘中的数据,必须先通过把磁盘数据拿到内核缓冲区中来,然后Java代码再通过read()系统调用,从内核缓冲区中拿数据到JVM堆内来
不能直接通过调用JNI方法,把内核缓冲区中的数据,写入到JVM堆内。只能是磁盘数据先到内核缓冲区,然后用户通过read()系统调用,把内核缓冲区中的数据,再读取JVM堆内
DirectByteBuffer分配的堆外内存,本质是调用操作系统的malloc(),分配的JVM进程的用户堆空间的内存
因为,DirectByteBuffer对应的堆外地址,和内核缓冲区,共用同一个片物理页。所以,我们程序员通过Java代码往DirectByteBuffer对应的堆外地址put了一些数据,就相当于直接写入了内核缓冲区,而且put操作,还不会产生系统调用
普通BIO
但是,JDK还是提供了一种走捷径的方式,通过内存映射mmap,拿到文件映射的address,从而用户可以通过Java代码,直接读写这个address对应的堆外内存空间。
而不用先在JVM堆内用户空间搞一个byte[],然后再把这个byte[]中的内容写入堆外内存空间,或者把堆外内存空间的数据读入byte[]中,从而减少了数据在JVM堆内的拷贝过程
DirectByteBuffer存在的意义(重点)
- 有了mmap内存映射产生的DirectByteBuffer对象,使得Java程序,也有了能直接操作堆外内存地址空间的机会
- 堆外内存空间才是直接面对各个底层硬件的,比如底层的磁盘,或者网卡
- 只有堆外内存空间的数据,才是能直接去往各底层硬件读写的,JVM堆内空间数据,是不能直接往各底层硬件读写的
没有mmap内存映射产生的DirectByteBuffer对象之前
Java程序,想将数据写入到网卡,只能先在Java用户空间生成byte[]并写满数据,然后通过调用write()系统调用先把用户空间的byte[]写入到堆外内存空间,然后通过系统调用flush(),把堆外内存空间的数据,写入到网卡缓冲区
直接内存的开辟示意图
比如定义:DirectByteBuffer dbb = ByteBuffer.allocateDirect(1024);底层是:
public static ByteBuffer allocateDirect(int capacity) {return new DirectByteBuffer(capacity);
}
示意图:
Nio如何管理堆外内存的释放
回收流程
NIO中如何使用虚引用管理堆外内存原理_虚引用 堆外内存-CSDN博客
Netty的异步调用与异步IO模型
Netty的异步指的是调用方式的异步,不是指的IO模型的异步。指的是请求的发送,和响应的接收,分别是不同的IO线程在处理
Netty的IO模型还是基于多路复用器的同步非阻塞IO
视频链接
黑马程序员Netty全套教程, netty深入浅出Java网络编程教程_哔哩哔哩_bilibili