1. 设计模式
1.1 装饰者模式
装饰者(Decorator)和具体组件(ConcreteComponent)都继承自组件(Component),具体组件的方法实现不需要依赖于其它对象,而装饰者组合了一个组件,这样它可以装饰其它装饰者或者具体组件。所谓装饰,就是把这个装饰者套在被装饰者之上,从而动态扩展被装饰者的功能。装饰者的方法有一部分是自己的,这属于它的功能,然后调用被装饰者的方法实现,从而也保留了被装饰者的功能。可以看到,具体组件应当是装饰层次的最低层,因为只有具体组件的方法实现不需要依赖于其它对象。
1.2 IO装饰者模式
以InputStream为例子:
- InputStream 是抽象组件;
- FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作;
-
FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。
FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
2. IO模型
IO 是主存和外部设备 ( 硬盘、终端和网络等 ) 拷贝数据的过程。 IO 是操作系统的底层功能实现,底层通过 I/O 指令进行完成。在本教程中,我们所说的IO指的都是网络IO,有以下几种:
- 阻塞式I/O
- 非阻塞式I/O
- I/O多路复用(select,poll,epoll...)
- 信号驱动式I/O(SIGIO)
- 异步I/O(POSIX的aio_系列函数)
对于这五种IO模型,Java并不是一开始就都全部支持,而是有一个逐步演进的过程:
在JDK1.4之前,Java的IO模型只支持阻塞式IO(Blocking IO),简称为BIO ,在JDK1.4时,支持了I/O多路复用模型,相对于之前的IO模型,这是一个新的模型,所以称之为NIO(New IO),更多的人愿意把NIO理解为None-Blocking IO,即非阻塞IO,在JDK1.7时,对NIO包进行了升级,支持了异步I/O(Asynchronous IO),简称为AIO。
对于一个network IO (以read举例),它会涉及到两个系统对象:一个是调用这个IO的进程,另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
- 阶段1:等待数据准备 (Waiting for the data to be ready)
-
阶段2: 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
用户空间
:常规进程所在区域。 JVM 就是常规进程,驻守于用户空间。用户空间是非特权区域:比如,在该区域执行的代码就不能直接访问硬件设备。
内核空间
:操作系统所在区域。内核代码有特别的权力:它能与设备控制器通讯,控制着用户区域进程的运行状态,等等。最重要的是,所有 I/O 都直接(如这里所述)或间接通过内核空间。
当进程请求 I/O 操作的时候,它执行一个系统调用将控制权移交给内核。底层函数 open( )、 read( )、 write( )和 close( )要做的无非就是建立和执行适当的系统调用。当内核以这种方式被调用,它随即采取任何必要步骤,找到进程所需数据,并把数据传送到用户空间内的指定缓冲区。内核试图对数据进行高速缓存或预读取,因此进程所需数据可能已经在内核空间里了。如果是这样,该数据只需简单地拷贝出来即可。如果数据不在内核空间,则进程被挂起,内核着手把数据读进内存。
2.1 阻塞式IO
以如下代码为例:
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
代码执行流程如下图:
![](https://img.haomeiwen.com/i26379598/8e810bb0331babd2.gif)
服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。
![](https://img.haomeiwen.com/i26379598/1e5e677e9a6b2efa.gif)
这就是传统的BIO,整体流程如下图:
![](https://img.haomeiwen.com/i26379598/356581e2cdc15205.png)
如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。
2.2 非阻塞式IO
为了解决上面BIO的进程阻塞问题,引出了NIO,应用进程执行非阻塞系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。整体流程如下:
![](https://img.haomeiwen.com/i26379598/c128a088440528a6.png)
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error(-1)。 从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次 发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。所以,用户进程第一个阶段不是阻塞的,需要不断的主动询问kernel数据好了没有;第二个阶段依然总是阻塞的。
2.3 IO多路复用
在上面NIO中,每次轮询的去系统调用但是返回-1时,对于系统来说仍然是属于一种资源消耗。在 while 循环里做系统调用,就好比做分布式项目时在 while 里做 RPC请求一样,是非常消息资源的。并且上述两种模型只能是每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程,这样是不可行的,所以就有了IO多路复用技术。
IO多路复用可以让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。Linux中IO复用的实现方式主要有:select模型、poll模型、epoll模型。
select模型
select 是操作系统提供的系统调用函数,通过它我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:
![](https://img.haomeiwen.com/i26379598/7ecef7d810a5b1ca.gif)
细节:
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。
poll模型
poll 也是操作系统提供的系统调用函数。和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。当IO数量大时扫描线性性能下降。
epoll模型
epoll 主要就是针对select的三点细节进行了改进。
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
操作系统提供了三个函数:
第一步,创建一个 epoll 对象
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符。向 epoll 对象中添加要管理的连接
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
第三步,等待其管理的连接上的 IO 事件
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);
其内部原理如下图:
![](https://img.haomeiwen.com/i26379598/3bc045bec5d64b4c.gif)
2.4 信号驱动式IO
上面的 select 虽然不阻塞了,但是他得时刻去查询看看是否有数据已经准备就绪,所以就有了信号驱动式IO,由内核告知数据已准备就绪,然后用户线程再去 read(还是会阻塞)。
![](https://img.haomeiwen.com/i26379598/9ebe34b15abd4d3d.png)
因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 socket 可以产生信号事件有七种。
也就是说不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,所以我们的应用程序无从区分到底是什么事件产生的这个信号,所以我们的应用基本上用不了信号驱动 I/O。
2.5 异步IO
进行 aio_read 系统调用会立即返回,应用进程继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
整体流程如下:
![](https://img.haomeiwen.com/i26379598/85df7bd58c41081b.png)
异步 I/O 其实就是用户线程调用 aio_read ,然后包括将数据从内核拷贝到用户空间那步,所有操作都由内核完成,当内核操作完毕之后,再调用之前设置的回调,此时用户线程就拿着已经拷贝到用户控件的数据可以继续执行后续操作。
在整个过程中,用户线程没有任何阻塞点,这才是真正的非阻塞I/O。但是因为 Linux 对异步 I/O 的支持不足,所以用不了异步 I/O,用的最多的还是IO多路复用。
网友评论