我所理解的IO
要说IO
基本上所有人都用过,但是每每提起NIO
,BIO
,AIO
这些概念,总是觉得模糊又清晰。下面简单的总结下:
首先,抛开Java NIO
的概念,从操作系统的层面上说说几种IO
BIO(Blocking IO)
在操作系统刚开始的时候,使用的是传统的BIO
。由于服务端(Server
)并不知道什么时候会有客户端进行连接,同时,当建立连接后,数据的准备也需要一段时间,比如需要查询数据库,然后执行运算操作等等才能返回数据等。因此,IO
采用了最简单的操作:阻塞
。
然后阻塞带来一个坏处他会使得其他操作无法继续进行,因为这个线程被阻塞到这里了,因此一般BIO
都需要新开线程。
一个BIO Server
如下(已省略try-catch):
public static void main(String[] args) throws Exception {
//初始化客户端
ServerSocket serverSocket = new ServerSocket(8000);
// 建立线程监听请求
new Thread(() -> {
while (true) {
//监听客户端连接,阻塞
Socket socket = serverSocket.accept();
//成功监听连接后,创建线程处理此连接
new Thread(() -> {
int len;
byte[] data = new byte[1024];
InputStream inputStream = socket.getInputStream();
//读取数据,阻塞
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
}).start();
}
}).start();
}
可以看到,上面阻塞的地方有两个:
- 由于不知道客户端多久会来建立连接,因此只能阻塞等待。
- 由于不知道客户端数据多久能准备好,因此只能阻塞等待。
因此监听连接使用一个线程,每当连接一个客户端再创建一个新的线程。
BIO
的好处就是使用简单,按照正常的处理逻辑即可使用,但是坏处也比较明显,那就是每个来一个客户端连接,就需要创建一个线程,当线程过多的时候,系统的上下文开销会非常大。
NIO(Non-blocking IO)
为了解决BIO
带来的问题,于是出现了NIO
,NIO
顾名思义就是非阻塞IO
,在Linux
操作系统中,可以通过参数设置socket
为非阻塞IO
.
当IO
为非阻塞之后,调用线程就不会被阻塞从而可以执行其他的操作。然后再次带来的一个问题就是线程不知道数据什么时候能准备好,因此需要不断的去轮询数据是否准备完成。
因此NIO
的优缺点如下:
优点 : 不会阻塞线程,线程可以在数据准备期间执行其他操作。
缺点:
- 循环主动轮询将将大幅度推高CPU占用率。
- 由于需要主动轮询数据是否已经准备完成,可能导致数据早已准备完成,但是没有来得及查询,因此会降低系统的吞吐量。
多路复用IO(IO multiplexing)
虽然NIO
能够不阻塞线程,但是会降低吞吐量,这是万万不能接受的,因此出现了多路复用IO
,多路复用IO
在NIO
的基础上,修改了模型,NIO
的问题就在于需要调用者主动去轮询数据是否已经准备完成,但是调用者并不知道数据多久准备完毕。于是操作系统提供了API
让专门的一个线程(Selector
)去监听所有的连接,如果有数据准备好,就读取,如果没有数据,Selector
就会阻塞等待。
理解多路复用模型可以按照观察者来进行理解,前面说明NIO
虽然非阻塞,但是可以不知道什么时候数据能准备好,而多路复用则是通过操作系统提供一个API
,可以通过调用这个API
获取当前Socket
下所有连接的数据的准备情况,从而达到数据准备好,就立即通知业务线程进行处理的效果。从这里也可以看出多路复用名称的由来:多路连接复用一个或多个Selector
进行查询通知。
Java
的NIO
则是使用的多路复用模型
AIO(Asynchronous IO)
AIO
是真正意义的异步IO
,AIO
完全不会阻塞,当数据拷贝到应用程序中(后面会详细解释),才会通知,但是AIO
使用比较少,在Linux
内核2.6版本才开始引入,只用知道有这个IO
模型即可。
内核态,用户态
大概理解了IO
的几个模型之后,接下来深入理解下IO
.
在操作系统中,为了保证系统的安全,以及达到进程不能相互影响,操作系统将操作权限分为了用户态和内核态。用户态想要调用与操作系统相关的方法时,只能通过内核代码去执行,内核代码在检查这次访问合法后,才会进行执行。通过这样的方式, 可以防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络等,提高了系统的安全性。
Linux
模型如下,可以看到,应用程序想要访问内核的东西,只能通过系统调用来实现,系统调用作为一个隔离层,来实现用户态主动切换到内核态的一种主要方式,用户态切换到内核态还有另外两种方式便是异常和中断。
Data Copy
为什么要说内核态和用户态,是因为在一次IO
中,会涉及到好几次Data Copy
。
操作系统
前面说过,使用NIO
调用IO
就不会阻塞,这里的不会阻塞并不是完全不会阻塞。由于数据基本都都是在磁盘或者网卡这类“内核”中,当应用程序需要从磁盘或网卡读取数据的时候,会涉及到以下两个步骤:
- 将数据由网卡或磁盘拷贝到内核中,这一步阻塞时间是最久的,因为磁盘要查找,网卡要等网络传输
- 将内核的数据拷贝到应用程序中
几乎所有的IO
,都需要经过这两步,包括前面说的BIO
,NIO
,多路复用。他们的阻塞情况如下图:
- BIO
BIO
两个阶段都会阻塞。 -
NIO
NIO
如下,在数据准备阶段,也就是从网卡/磁盘中读取数据到内核阶段是非阻塞的,但是从内核读取数据到应用中依然是阻塞的。 -
多路复用
多路复用和
NIO
又不太像,由于多路复用需要调用操作系统API
进行监听,因此使用Selector
也是阻塞的,不过这个过程一般会单独启动一个线程进行操作,但是这个过程依然涉及到用户态到内核态的切换。当有数据准备好(也就是数据已经成功拷贝到内核中)之后,会通知Selector
,然后应用程序需要从内核拷贝数据到应用程序中,这个过程是阻塞的。 -
AIO
AIO
全程异步,当用户发起IO
请求后,只需将应用程序接收数据的地址传给操作系统,操作系统在完成两次Copy
之后,才会通知应用程序,应用程序接收到通知之后便可以直接使用数据。
JVM
前面说了操作系统IO
的copy data
的次数,接下来再说说Java
层面的。
对于JVM
来说,由于存在GC
,不管是标记-整理
还是复制算法,都会涉及到移动对象。因此,如果我们直接将JVM
中的对象的地址传给操作系统进行拷贝数据,则如果发生GC
,会导致出错。同时由于JVM
规范并不要求byte[]
必须是连续的内存空间,但是操作系统在IO
拷贝数据的时候,要求接受的内存需要是连续的,因此对于JVM
的IO
会先将数据从内核态拷贝到DirectByteBuffer
,然后再从DirectByteBuffer
拷贝到JVM
中,这里又涉及到一次Copy
DirectByteBuffer
从上面可以看出来,对于JVM
的IO
来说,会涉及到3次data copy
,网卡/磁盘 -> 内核 -> DirectByteBuffer
-> Java程序
。
因此,如果我们不需要操作数据,仅仅是转发数据的话,可以只用使用堆外内存作为中转站,以节省一次data copy
但是,如果我们需要操作数据,比如解析,编码,过滤等,还是不得不将数据读取到JVM
内存中进行操作。
Zero Copy
说了这么多阻塞与非阻塞,使用NIO
能够使得系统在高并发的情况下依然保持很高的吞吐,但是还有没有更好的提高性能的方式呢?
前面说过,使用JVM IO
数据,需要进行3次拷贝,如果有什么方法能够减少一些拷贝次数,那就更好了。
MemoryMap
由于磁盘->内核->应用程序 导致有两次数据拷贝,因此操作系统提供了内存映射(MemoryMap
),通过内存映射,数据会先通过 DMA 被复制到操作系统内核的缓冲区中去。接着,应用程序会开辟一块内存与此内核缓冲区进行映射,这样,操作系统内核和应用程序存储空间就不需要数据复制操作,从而减少了一次从操作系统内核到用户态之间的数据拷贝。
注意:MMAP 的过程中,操作系统是首先建立一个用户程序虚拟地址到文件地址映射,此时仅仅是建立了映射,当程序调用
MMAP
读取数据的时候,CPU
会对此映射的地址发起访问,此时会导致操作系统进行缺页异常,异常过程中,会调用文件系统将一页或多页的数据加载到操作系统的内核BUFFER
中,从而使得你可以获取到数据。个人的理解:
mmap()
高效的原因:减少了一次内核态到用户态的文件拷贝这里
mmap
映射和普通的read()/write()
一样最终读取的依然是操作系统内核的pagecache
,只是mmap()
是直接读写的pagecache
,而没有经过二次拷贝,因此mmap()
还是享受的有操作系统预读的特点,同时由于mmap
只是将数据写到内存中,因此如果系统直接断电,则可能出现丢失数据的风险。
Java
的MappedByteBuffer
是一个抽象类,它的具体的实现类为:DirectByteBuffer
由前面的内容,我们可以知道如果不需要对数据进行修改的话,DirectByteBuffer
可以省略一次到Java
对象的拷贝。因此使用MappedByteBuffer
可以做到只有一次copy
mmap之所以快,是因为建立了页到用户进程的虚地址空间映射,以读取文件为例,避免了页从内核态拷贝到用户态。
Zore Copy
同时,对于很多应用程序来说,比如网络文件下载,应用程序只是做了一次从磁盘到网卡的中转站。然而这样一次中转,最少却要经过4次拷贝。
如图所示。可以看出来,其实这种中转站是很没有必要的,数据从内核态切换到用户态,再从用户态切换到内核态。
操作提供提供了API: sendFile
,可以直接将磁盘的描述信息发送到socket buffer
,然后通过DMA
的方式将数据从磁盘传输到Socket Buffer
,使得CPU
不用拷贝任何数据,从而实现了Zero Copy
Java
中提供的Zero Copy API
为transferTo()
如图所示,应用程序只用传输少量的数据位置和长度信息的描述符到内核Buffer
即可,其他的数据操作交由DMA来完成,从而实现了解放CPU
从这里也可以看出来,零拷贝不是说完全没有数据拷贝,而是说不需要
CPU
参与数据传输,CPU
零次拷贝
对比MemoryMap
和tansferTo
的区别:
MemoryMap
的作用在于将磁盘空间映射到应用程序,一般都用于应用程序需要读取数据进行处理的情况下,通过MemoryMap
读取文件,可以减少一次内核到应用程序的拷贝,提高性能。但是如果通过MemoryMap
进行中转站式的网络文件传输,则还是需要一次CPU
参与的数据传输,如下图:-
sendFile
则是用来解决上图所存在的问题的,在网络系统中,如果需要进行文件到网络的传输时,为了减少最后一次CPU
的数据拷贝,则可以通过sendFile
来完成。
对于MemoryMap
和sendFile
使用的最直观的来说就是Kafka
的使用,对于Kafka Broker
来说,当接收到producer
发送的消息时,直接通过MemoryMap
来进行落盘,而需要将消息发送给Consumer
时,则通过sendFile
来完成Zero Copy
,极大的提高了系统性能。
总结
从IO
模型到数据拷贝,都是为了提高IO
的效率,网络IO通过优化IO
模型实现,文件IO
则通过减少CPU Copy
实现。
当需要开发网络服务器的时候,为了解决IO阻塞带来的线程过多的问题,我们可以通过IO多路复用来减少线程数据,从而减少上下文切换,提高系统的吞吐率。
当涉及到文件IO的时候,由于系统的安全性设计,传统的IO存在多次数据拷贝的情况,因此在我们需要读取文件进行修改的时候,可以通过MemoryMap
来减少数据从内核空间拷贝到用户空间的开销,当需要做文件传输的时候,可以通过sendFile
来消除CPU
的数据拷贝的开销
参考链接:
https://www.cnblogs.com/whj233/articles/10073629.html
Java NIO direct buffer的优势在哪儿? – 知乎