写入和读出的问题
任何情况下,对一个区域来说都是写入,读出。IO流的read是流的方法,对流来说是读出,而不是对内存来说是读入。同理,bytebuffer也是,调用的channel的read方法对channel来说是读出,而不是针对bytebuffer是读入,对bytebuffer来说是写入。
注意:BIO,NIO,AIO都是网络编程,不是文件编程
NIO可以工作在阻塞和非阻塞模式下
NIO三大组件
(Channel):channel 有一点类似于 stream,它就是读写数据的**双向通道**,**可以从 channel 将数据读入 buffer,也可以将 buffer 的数据写入 channel。而之前的 stream 要么是输入,要么是输出。
(Buffer):用余连接channel的缓冲区,用来存储发送数据和存储接收数据。
selector:selector 的作用就是配合一个线程来管理多个 channel(fileChannel因为是阻塞式的,所以无法使用selector),获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,当一个channel中没有执行任务时,可以去执行其他channel中的任务。适合连接数多,但流量较少的场景
ByteBuffer
ByteBuffer和channel的基本读写流程
使用方式
- 向 buffer 写入数据,例如调用 filechannel.read(buffer),Filechannel的read方法类似BIO中的read方法(只不过IO的buffer是我们在内存new的,这里的buffer是系统提供的),判断-1,把buffer写满。
- 调用 flip() 切换至读模式
- flip会使得buffer中的limit变为position,position变为0
- 从 buffer 读取数据,例如调用 buffer.get(),把buffer都读出来
- 调用 clear() 或者compact()切换至写模式
- 调用clear()方法时position=0,limit变为capacity
- 调用compact()方法时,会将缓冲区中的未读数据压缩到缓冲区前面
- 重复以上步骤
ByteBuffer内部结构
capacity
position
limit
开始
写入,写完后position+1
flip,position = 0 , limit 指向最后一个元素的下一个位置,capacity不变
读出,position会+1
clear,position = 0, capacity = limit
compact
compact会把未读完的数据向前压缩,然后切换到写模式,数据前移后,原位置的值并未清零,写时会覆盖之前的值,compact方法position不在首位。
byteBuffer的方法
创建allocate
buffer读入数据
调用 channel 的 read 方法
调用 buffer 自己的 put 方法
buffer 写出数据
调用 channel 的 write 方法
调用 buffer 自己的 get 方法,读取缓冲区中的一个值
重复读取
调用 rewind 方法将 position 重新置为 0
调用 get(int i) 方法获取索引 i 的内容,它不会移动读指针
调用mark 和 reset:
mark 是在读取时,在当前position做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置
clear
如果buffer内还有数据,他们处于“被遗忘”状态,下次进行写操作时会覆盖这些数据
Channel
channel在NIO中相当于BIO中的Stream,有read,write,close等方法。
filechannel
filechannel用于文件和内存之间的传输,本地传输,属于NIO的内容,但不能和selector一起用,因为只能非阻塞模式的channel才能配和selector。
创建
必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法
- 通过 FileInputStream 获取的 channel 只能读出
- 通过 FileOutputStream 获取的 channel 只能写入
- 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定
读取
通过read方法将数据写入到ByteBuffer中,Filechannel的read方法类似BIO中的read方法(只不过IO的buffer是我们在内存new的,这里的buffer是系统提供的)。
read方法的返回值表示读到了多少字节,若读到了文件末尾则返回-1
写入
因为channel也是有大小的,所以 write 方法并不能保证一次将 buffer 中的内容全部写入 channel。必须需要按照以下规则进行写入
关闭
通道需要close,一般情况通过try-fanally进行关闭
大小
使用 size 方法获取文件的大小
channel之间的数据传输(只有FileChannel有)
transferTo/transferFrom方法:一次只能传输2G的内容,底层使用了sendFile零拷贝技术
SocketChannel和ServerSocketChannel
SocketChannel相当于BIO中的socket,ServerSocketChannel相当于BIO中的ServerSocket,只不过BIO中得获得流来read/write,NIO中SocketChannel/ServerSocketChannel直接就能read/write
创建
open方法
读取
read方法,
read方法的返回值表示读到了多少字节,若读到了文件末尾则返回-1,如果是非阻塞模式返回0
写入
write方法
注意:限于网络传输能力,SocketChannel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件
关闭
close
大小
size
accept方法:ServerSocketChannel
connect方法:SocketChannel
configureBlocking方法:ServerSocketChannel和SocketChannel都有,非阻塞模式
NIO在不使用selector时工作模式
NIO在阻塞模式
NIO在阻塞模式下其阻塞原理和BIO socket网络编程很像,
阻塞模式下,相关方法都会导致线程陷入阻塞:
- ServerSocketChannel.accept 会在没有连接建立时让服务器线程一直等待
- SocketChannel.read 会在通道中没有数据可读时让服务器线程一直等待当前客户端,而无法处理其他客户端的请求,因为read阻塞无法执行accept。
- write也会阻塞,参考阻塞IO模型
单线程下,多个客户端之间相互影响,几乎不能正常工作,需要多线程支持,
但多线程下,有新的问题,体现在以下方面
1、32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致内存占用过大,并且线程太多,反而会因为频繁上下文切换导致性能降低
2、可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多客户端连接,而线程池线程不够会导致长时间未响应,因此不适合长连接,只适合短连接
NIO在非阻塞模式下
服务器使用ServerSocketChannel和SocketChannel调用configureBlocking(false)开启非阻塞模式,此时accept方法不会一直等待客户端连接,如果没有客户端连接,返回socketchannel为null,继续往下运行。read方法也不会一直等待客户端发送数据,如果没有数据,执行到read时返回0,继续往下运行。
缺点是CPU一直在运行,耗费CPU资源。
Selector
创建:open方法
获取发生的事件:select方法,注意select方法在没有获取到所有channel的事件的情况下才让该线程等待,陷入阻塞,而非只针对某个channel。但是如果事件不处理那么select方法还是能获取到即还是不会陷入阻塞,要么处理要么取消key.cancel。
单线程多路复用(这里的非阻塞实际上是半阻塞,select方法)
单线程多路复用
服务器中单线程可以配合 Selector 完成对多个 Channel 读写连接事件的监控,这称之为多路复用,能够保证
有可连接事件时才去连接
有可读事件才去读取
有可写事件才去写入
什么事件都没有才进入阻塞等待
▲之前阻塞模型中单线程只能一直等待客户端连接,做不了其他事,一旦连接上,又必须一直等待当前客户端发送信息,也做不了其他事。多路复用保证没有客户端来连接,可以处理其他连接上的客户端的读写数据,没有其他客户端来读写数据,可以保证能让新来的客户端连接上。
▲注意:NIO不完全等于多路复用,NIO提供了多路复用的必要条件非阻塞。除了多路复用,NIO还可以实现普通阻塞和普通非阻塞
创建
Selector selector = Selector.open();
设置非阻塞
channel.configureBlocking(false);只有非阻塞模式的channel才能注册selector。
注册 Channel
SelectionKey key = channel.register(selector, 绑定事件);
SelectionKey相当于channel的管理者。
绑定的事件类型可以有
connect - 客户端连接成功时触发
accept - 服务器端成功接受连接时触发
read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
监听 Channel 事件
可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少 channel 发生了事件
方法1,阻塞直到绑定事件发生int count = selector.select();
方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms)int count = selector.select(long timeout);
方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件int count = selector.selectNow();
获取 Channel 事件
selector.selectedKeys,返回发生事件的selectionKey的集合
遍历 Channel事件
遍历集合得到每个发生事件的SelectionKey
判断并处理事件
判断这个key发生了哪种事件,这个key反向获取到对应的channel,进而执行accept,read,write等方法。
移除selectionkey
itorator.remove()
处理accept事件
服务器端创建selector,设置非阻塞,注册serversocketchannel,绑定ACCEPT事件,select监听事件,获取key集合,遍历,判断是否是ACCEPT事件并反向获取channel(其实serversocketchannel只有一个)。
处理read事件
注意:如果在服务器处理read事件中,客户端断开连接,要保证把事件取消cancel。
NIO多线程多路复用单个worker
分两组选择器
单线程Boss配一个selector,专门处理 accept 事件
创建 cpu 核心数的worker线程,每个线程配一个selector,轮流处理 read 事件
产生一个问题:
产生一个问题:worker的select监控和把socketchannel注册到worker上两个步骤在两个不同的线程中,如果单纯移动代码前后不能完全保证按照先注册再监控的顺序。如果顺序不对,先监控,后面无法注册,导致select一直处于阻塞中接收不到数据。
问题的本质上是要按照selector的创建,设置,注册,监控等顺序来,不能先监控再注册,这样无法注册成功。
解决办法
1、使用队列
2、在worker类中这么做,上面两句代码在一个线程,select在一个线程,顺序会有三种情况
①先wakeup,再register,在select
②先wakeup,再select,再register
③先select,再wakeup,再register
三种方法都能保证要么register→select在第一轮循环中就成功,要么register在第一轮成功→select在第二轮循环中成功,因为select阻塞后wakeup,进入第二轮循环。
NIO多线程多路复用多个worker
多路复用+负载均衡,保证多个客户端连接的时候轮流使用selector
零拷贝
所谓的【零拷贝】,并不是真正无拷贝,而是不会拷贝重复数据到 jvm 用户缓冲区中,第三种第四种都叫零拷贝
DMA:直接内存拷贝,不使用CPU拷贝
传统BIO读写数据
- java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,其间也不会使用 cpu
DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO - 从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 cpu 会参与拷贝,无法利用 DMA
- 调用 write 方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,cpu 会参与拷贝
- 接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的
- 用户态与内核态的切换发生了 4 次,这个操作比较重量级
- 数据拷贝了共 4 次
NIO的优化mmap内存映射
- 采用mmap内存映射映射到用户缓冲区。
- 使用 mmap 的目的是将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲(user buffer)的过程
- 用户态与内核态的切换发生了 4 次
- 数据拷贝了共3 次
sendFile优化
底层过程如下,java 代码中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据。
- 用户态与内核态的切换发生了 2 次
- 数据拷贝了共3 次
sendFile最终优化
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗。
- 用户态与内核态的切换发生了 2 次
- 数据拷贝了共2 次