《Java架构师的第一性原理》23Java基础之IO

发布时间 2023-12-21 13:58:16作者: 沙漏哟

1 简介

Java 非阻塞 IO 和异步 IO

IO多路复用机制详解

《我想进大厂》之网络篇夺命连环12问

1)BIO、NIO、AIO

BIO:面向流(Stream)、同步阻塞IO、单向通道(输入或者输出流)

NIO:面向缓冲区(Buffer)、同步非阻塞IO(轮询状态)、双向通道

三大核心组成部分:缓冲区(Buffer)存储数据、通道(Channel)传输数据、选择器(Selector)轮询IO状态

AIO:异步非阻塞IO

2)Java I/O原理

 

 

 

input:读取外部数据(磁盘/光盘等存储设备)

output:将程序(内存)数据写入到硬盘上

3)java.io.File类

只能访问文件和目录,不能访问内容

4)传统Java BIO(常用Java IO流)

读写数组时本质上都是对字节数组做读取和写出操作,字节数组是IO流读写数据的本质。

文件流(基于文件的操作):文件字节流(FileInputStream、FileOutputStream)、文件字符流(FileInputReader、FileOutputWriter)

字节数组流:ByteArrayInputStream、ByteArrayOutputStream

基本数据类型流:DataInputStream、DataOutputStream

缓冲流(先把数据缓存到内存中,减少IO次数):缓冲字节流(BufferedInputStream、BufferedOutputStream)、缓冲字符流(BufferedInputReader、BufferedOutputWriter)

转换流(字节流与字符流之间的转换):InputStreamReader(字节流->字符流)、OutputStreamWriter(字符流->字节流)

对象流(对象的序列化与反序列化):ObjectInputStream、ObjectOutputStream

随机存取文件流:RandomAccessFile

2 Java NIO 铁三角(缓冲区Buffer 通道Channel 选择器 Selectors)

3 Java AIO

异步 IO 主要是为了控制线程数量,减少过多的线程带来的内存消耗和 CPU 在线程调度上的开销。

在 Unix/Linux 等系统中,JDK 使用了并发包中的线程池来管理任务,具体可以查看 AsynchronousChannelGroup 的源码。

在 Windows 操作系统中,提供了一个叫做 I/O Completion Ports 的方案,通常简称为 IOCP,操作系统负责管理线程池,其性能非常优异,所以在 Windows 中 JDK 直接采用了 IOCP 的支持,使用系统支持,把更多的操作信息暴露给操作系统,也使得操作系统能够对我们的 IO 进行一定程度的优化。

1)Java AIO API

Java异步IO实现API需要关注 AsynchronousSocketChannel,AsynchronousServerSocketChannel 和 AsynchronousFileChannel。

Java异步IO使用提供了两种方式,分别是返回 Future 实例和使用回调函数。

(1)返回 Future 实例

(2)提供 CompletionHandler 回调函数

4 令人迷惑的多路复用和非阻塞IO到底是什么

4.1 零零散散的信息

Memcache使用非阻塞IO复用网络模型。

Redis使用非阻塞I/O多路复用模型。

其中多路I/O复用模型到底是什么?

多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询

一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗)。

4.2 IO多路复用技术

首先说一下,什么是IO多路复用技术。

比如,现在我们模拟一个tcp服务器处理30个客户的socket,如何快速的处理掉这30个请求呢?

在不了解原理的情况下,我们类比一个实例:在课堂上让全班30个人同时做作业,做完后老师检查,30个学生的作业都检查完成才能下课。如何在有限的资源下,以最快的速度下课呢?

  • 第一种:安排一个老师,按顺序逐个检查。先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误。这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。这种方式只需要一个老师,但是耗时时间会比较长。
  • 第二种:安排30个老师,每个老师检查一个学生的作业。 这种类似于为每一个socket创建一个进程或者线程处理连接。这种方式需要30个老师(最消耗资源),但是速度最快。
  • 第三种:安排一个老师,站在讲台上,谁解答完谁举手。这时C、D举手,表示他们作业做完了,老师下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。这种方式可以在最小的资源消耗的情况下,最快的处理完任务。

第三种就是IO复用模型(Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。

此时的socket应该采用非阻塞模式。这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。)

4.3 IO多路复用技术epoll

io多路复用简单来说就是一个线程处理多个网络请求。
我们知道epoll in 的事件触发是可读了,这个比较好理解,比如一个连接过来,或者一个数据发送过来了,那么in事件就触发了,那么out事件是如何触发的呢?缓冲区可写(有空的区域),就可以触发。
epoll有两种模式LT(水平触发)和ET(边缘触发)。
  • LT模式下,主要缓冲区数据一次没有处理完,那么下次epoll_wait返回时,还会返回这个句柄;
  • ET模式下,缓冲区数据处理一次就结束,下次是不会再通知了,只在第一次返回.所以在ET模式下,一般是通过while循环,一次性读完全部数据.epoll默认使用的是LT。
socket的缓冲区已经满了,此时无法继续send。此时异步程序的正确处理流程是调用epoll_wait。
当socket缓冲区中的数据被对方接收之后,缓冲区就会有空闲空间可以继续往里面写数据,此时epoll_wait就会返回这个socket的EPOLLOUT事件,获得这个事件时,你就可以继续往socket中写出数据。
redis的epoll使用的是默认的LT模式,只要写缓冲区可写时,就会不断的触发可写事件,为了避免一直触发可写事件,redis是在有数据可写的时候注册写事件,写完之后就取消写事件的注册。
 
epoll内部数据结构为红黑树和链表,红黑树保存了所有socket和监听的事件信息,链表保存的是就绪的socket信息,就是那些就绪socket已经帮你整理好了。
那么,这个准备就绪list链表是怎么维护的呢?
当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。
如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。
执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。
读写事件的注册与删除
当一个新的连接建立后,redis会创建一个redisClient对象,然后为这个socket向epoll注册一个读事件,直到RedisClient对象销毁时才删除读事件,当redis读到一个完整的命令并解析完成后,就会为socket向epoll注册写事件,将回复信息发给client之后,就会从epoll删除刚注册的写事件,下个命令来了之后又会重复这个增删写事件的动作。
所以每个socket向epoll注册销毁一次读事件,多次注册销毁写事件,这样做的目的:在我没什么可写的情况下你就别叫我写了,我知道什么时候可写 

4.4 Redis线程模型

4.4.1 redis单线程是怎么做到高性能的呢?

以前我一直在想一个问题:如果一个redis命令很长,redis接收处理这个命令就要100毫秒,那么别的命令会不会延迟100毫秒呢?后续命令处理会不会像消息队列一样积压呢?
答案:不会。
上面我们已经说了epoll的原理,它不是让我们一次处理完一个命令后,再去处理另一个命令,epoll是帮我们一次接收多个命令的部分数据(如果命令很短则是完整的数据),每个socket都有一个缓冲区,写满了就不能写了,需要读出来后才能继续往里面写,redis为每个client分配了一个变长缓冲区,从socket中读出后存在缓冲区中,当接收到一个完整的命令,就解析并执行这个命令,然后把缓冲区后面的数据往前移动,反复利用这块内存,当这块内存超过一定值后就会释放,在需要的时候重新分配一块内存
也就是说epoll的水平触发模式将一个较长的命令请求分成了多次接收,一次能接收多个命令的请求,天生就只支持高并发的,加上redis会将耗时的命令会分多次处理,保证了我们的读写操作都很快。
综述单线程高性能的原因:
  • 1:纯内存操作本来就很快
  • 2:redis使用epoll支持io多路复用,天生支持高并发请求
  • 3:redis将耗时的操作分多次处理,保证每次处理的时间都很短,保证了读写性能,如果数据很长的话处理时间就会变长,所以redis不建议保存太长的数据
还有redis6.0实现了多线程的功能,性能至少翻倍,那你还要问题单线程为什么性能高吗?而且还是在数据的接收解析和数据的发送使用多线程的情况下,性能就至少翻倍了。可能是为了保证代码的简洁性,作者不愿意使用多线程,为了提升性能用了多线程,也是部分功能使用多线程,操作redis数据库的逻辑还是单线程,如果数据是写少读多的情况下,采用多线程读写锁性能会不会提升很多呢?
所以redis一开始采用单线程的原因:
  • 1:代码简洁又简单 
  • 2:性能已经很好了
  • 3:性能不够我再搞多线程吗

4.4.2 redis单线程是怎么同时处理文件事件和时间事件

文件事件主要是网络I/O的读写,请求的接收和回复。时间事件就是单次/多次执行的定时器,如主从复制、定时删除过期数据、字典rehash等。
redis所有核心功能都是跑在主线程中的,像aof文件落盘操作是在子线程中执行的,那么在高并发情况下它是怎么做到高性能的呢?
由于这两种事件在同一个线程中执行,就会出现互相影响的问题,如时间事件到了还在等待/执行文件事件,或者文件事件已经就绪却在执行时间事件,这就是单线程的缺点,所以在实现上要将这些影响降到最低。那么redis是怎么实现的呢?
定时执行的时间事件保存在一个链表中,由于链表中任务没有按照执行时间排序,所以每次需要扫描单链表,找到最近需要执行的任务,时间复杂度是O(N),redis敢这么实现就是因为这个链表很短,大部分定时任务都是在serverCron方法中被调用。从现在开始到最近需要执行的任务的开始时间,时长定位T,这段时间就是属于文件事件的处理时间,以epoll为例,执行epoll_wait最多等待的时长为T,如果有就绪任务epoll会返回所有就绪的网络任务,存在一个数组中,这时我们知道了所有就绪的socket和对应的事件(读、写、错误、挂断),然后就可以接收数据,解析,执行对应的命令函数。
如果最近要执行的定时任务时间已经过了,那么epoll就不会阻塞,直接返回已经就绪的网络事件,即不等待。
总之单线程,定时事件和网络事件还是会互相影响的,正在处理定时事件网络任务来了,正在处理网络事件定时任务的时间到了。所以redis必须保证每个任务的处理时间不能太长。 

4.4.3 redis的文件实践分发器

Redis基于Reactor模式开发了自己的网络事件处理器,被称为文件事件处理器,由套接字、I/O多路复用程序、文件事件分派器(dispatcher),事件处理器四部分组成。

 

1)I/O多路复用程序、文件事件分派器

I/O多路复用程序会同时监听多个套接字,当被监听的套接字准备好执行accept、read、write、close等操作时,与操作相对应的文件事件就会产生,I/O多路复用程序会将所有产生事件的套接字都压入一个队列,然后以有序地每次仅一个套接字的方式传送给文件事件分派器,文件事件分派器接收到套接字后会根据套接字产生的事件类型调用对应的事件处理器。

2)事件的处理器

 

 

(1)连接应答处理器:

当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的AEREADABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候,套接字就会产生AEREADABLE事件,引发连接应答处理器执行,并执行相应的套接字应答操作。

(2)命令请求处理器:

当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的AEREADABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生AEREADABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作;

在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的AE_READABLE事件关联命令请求处理器。

(3)命令回复处理器:

当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AEWRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AEWRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。

当命令发送完毕后,服务器会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联。

  • 注意1:只有当上一个套接字产生的事件被所关联的事件处理器执行完毕,I/O多路复用程序才会继续向文件事件分派器传送下一个套接字,所以对每个命令的执行时间是有要求的,如果某个命令执行过长,会造成其他命令的阻塞。所以慎用O(n)命令,Redis是面向快速执行场景的数据库。
  • 注意2:命令的并发性。Redis是单线程处理命令,命令会被逐个被执行,假如有3个客户端命令同时执行,执行顺序是不确定的,但能确定不会有两条命令被同时执行,所以两条incr命令无论怎么执行最终结果都是2。

3)客户端与redis通信过程

  • 1、假设一个Redis服务器正在运作,那么这个服务器的监听套接字的 AE_READABLE 事件应该正处于监听状态之下, 而该事件所对应的处理器为连接应答处理器。
  • 2、如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生 AEREADABLE事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的 AEREADABLE事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。
  • 3、之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生 AE_READABLE 事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给相关程序去执行。
  • 4、执行命令将产生相应的命令回复, 为了将这些命令回复传送回客户端, 服务器会将客户端套接字的 AEWRITABLE 事件与命令回复处理器进行关联。当客户端尝试读取命令回复的时候, 客户端套接字将产生 AEWRITABLE 事件, 触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后, 服务器就会解除客户端套接字的 AE_WRITABLE 事件与命令回复处理器之间的关联。 

4.8 进程与线程

  • 单进程多线程模型:MySQL、Memcached、Oracle(Windows版本);
  • 多进程模型:Oracle(Linux版本);
  • Nginx有两类进程,一类称为Master进程(相当于管理进程),另一类称为Worker进程(实际工作进程)。

启动方式有两种:

(1)单进程启动:此时系统中仅有一个进程,该进程既充当Master进程的角色,也充当Worker进程的角色。

(2)多进程启动:此时系统有且仅有一个Master进程,至少有一个Worker进程工作。

(3)Master进程主要进行一些全局性的初始化工作和管理Worker的工作;事件处理是在Worker中进行的。

5 为什么DB连接管理一般不采用IO多路复用

这是一个非常好的问题。IO多路复用被视为是非常好的性能助力器。但是一般我们在使用DB时,还是经常性采用c3p0,tomcat connection pool等技术来与DB连接,哪怕整个程序已经变成以Netty为核心。这到底是为什么?

首先纠正一个常见的误解。IO多路复用听上去好像是多个数据可以共享一个IO(socket连接),实际上并非如此。IO多路复用不是指多个服务共享一个连接,而仅仅是指多个连接的管理可以在同一进程。在网络服务中,IO多路复用起的作用是一次性把多个连接的事件通知业务代码处理。至于这些事件的处理方式,到底是业务代码循环着处理、丢到队列里,还是交给线程池处理,由业务代码决定。

对于使用DB的程序来讲,不管使用多路复用,还是连接池,都要维护一组网络连接,支持并发的查询。

为什么并发查询一定要使用多个连接才能完成呢?因为DB一般是使用连接作为Session管理的基本单元。在一个连接中,SQL语句的执行必须是串行、同步的。这是由于对于每一个Session,DB都要维护一组状态来支持查询,比如事务隔离级别,当前Session的变量等。只有单Session内串行执行,才能维护查询的正确性(试想一下一组sql在不断的增减变量,然后这组sql乱序执行会发生什么)。维护这些状态需要耗费内存,同时也会消耗CPU和磁盘IO。这样,限制对DB的连接数,就是在限制对DB资源的消耗。

因此,对DB来说,关键是要限制连接的数目。这个要求无论是DB连接池还是NIO的连接管理都能做到。

这样问题就绕回来了,为什么DB连接不能放到IO多路复用里一并执行吗?为啥大家都用连接池?

答案是,可以用IO多路复用——但是使用JDBC不行。JDBC是一个出现了近20年的标准,它的设计核心是BIO(因为199X年时还没有别的IO可以用):调用者在通过JDBC时执行比如query这样的API,在没有执行完成之前,整个调用线程被卡住。而类似于Mysql Connector/J这样的driver完备的实现了这套语义。

当然如果DB Client的协议的连接处理和解析稍微改一下:

  1. 将IO模式调整为Non-Blocking,这样就可以挂到IO多路复用的内核上(select、epoll、kqueue……)

  2. 在Non-Blocking实现的基础之上实现数据库协议的编码和解析

就可以实现用IO多路复用来访问DB。实际上很多其他语言/框架里都是这么干的。比如Nodejs,see https://github.com/sidorares/node-mysql2;或者Vert.X 的db客户端(https://github.com/mauricio/postgresql-async,不要在意这个名字,它实际上同时支持mysql和postgres)。只不过对于IO多路复用,数据库官方似乎都没做这种支持——他们只支持JDBC、ODBC等等这些标准协议。

那么为什么基于IO多路复用的实现不能成为默认的,官方的,而要成为偏门呢?

对于数据库开发者来说。这种用法在整体的用户里占有量非常小,所以也许不值当的花大力气。只需要把协议写清楚(比如https://dev.mysql.com/doc/internals/en/client-server-protocol.html),就可以做实现。那么社区的有兴趣的人自然就可以去做。

另外一个原因是体系的支持。简单来讲,如果没有一个大的Reactive的运行环境,IO多路复用的使用会非常受限。

IO多路复用之所以能成立,是需要整个程序要有一个IO多路复用的驱动代码——就是select那句调用——等待事件来临,一个blocking的API。整个程序必须以这个驱动代码为核心。这样就对整个代码的结构产生重大的影响。这种影响是没法用简单的接口抽象的。

Java Web容器之所以可以使用NIO是因为NIO可以被封装到容器内部。Web容器对外暴露的还是传统的多线程形式的Java EE接口。

如果DB和Web容器同时使用NIO,那么调用的DB连接库与必须与容器有一个约定描述DB的连接管理如何接入Web容器的NIO的驱动代码。在Java这个大环境下,不同人,不同的容器写的代码不同;又或者,不使用任何常见的容器,而是自己用NIO去封装一个。这样是无法形成代码上的约定的。那么多个独立的组件就不能很好的共享NIO的驱动代码。

上面这个用法假设整个程序应该共享一个NIO驱动代码。那么Web和DB可不可以各用各的呢?也是可以的,但是为了保证这两个NIO驱动代码不会相互block,最好要分开两个线程。这样一来就会打破一般Web服务一个请求处理用一个线程的一般做法,会让程序边的更复杂——你的业务代码和DB查询之间必须做跨线程数据交换。

相反,连接池的实现就相对独立的多,也简单的多。外界只要配好DB URL,用户名密码和连接池的容量参数,就可以做到自行管理连接。

而Nodejs和Vert.X是完全不同的。他们本质就是Reactive的。他们的NIO的驱动方式是其运行时的基础——所有要在这个基础上开发的代码都必须遵守同样的NIO+异步开发规范,使用同一个NIO的驱动。这样DB与NIO的协作就不成问题了。

最后,有大量场景是需要BIO的DB查询支持的。批处理数据分析代码都是这样的场景。这样的程序写成NIO就会得不偿失——代码不容易懂,也没有任何效率上的优势。类似于Nodejs这样的运行时在此场景下,反而要利用async或等价的语法来让代码看起来是同步的,这样才容易写。

总结一下。DB访问一般采用连接池这种现象是生态造成的。历史上的BIO+连接池的做法经过多年的发展,已经解决了主要的问题。在Java的大环境下,这个方案是非常靠谱的,成熟的。而基于IO多路复用的方式尽管在性能上可能有优势,但是其对整个程序的代码结构要求过多,过于复杂。当然,如果有特定的需要,希望使用IO多路复用管理DB连接,是完全可行的。

6 几个IO又来了

6.1 说说BIO/NIO/AIO的区别?

BIO:同步阻塞IO,每一个客户端连接,服务端都会对应一个处理线程,对于没有分配到处理线程的连接就会被阻塞或者拒绝。相当于是一个连接一个线程

NIO:同步非阻塞IO,基于Reactor模型,客户端和channel进行通信,channel可以进行读写操作,通过多路复用器selector来轮询注册在其上的channel,而后再进行IO操作。这样的话,在进行IO操作的时候再用一个线程去处理就可以了,也就是一个请求一个线程

 

 

AIO:异步非阻塞IO,相比NIO更进一步,完全由操作系统来完成请求的处理,然后通知服务端开启线程去进行处理,因此是一个有效请求一个线程

6.2 那么你怎么理解同步和阻塞? 

首先,可以认为一个IO操作包含两个部分:

  1. 发起IO请求
  2. 实际的IO读写操作

同步和异步在于第二个,实际的IO读写操作,如果操作系统帮你完成了再通知你,那就是异步,否则都叫做同步。

阻塞和非阻塞在于第一个,发起IO请求,对于NIO来说通过channel发起IO操作请求后,其实就返回了,所以是非阻塞。

6.3 谈一下你对Reactor模型的理解?

Reactor模型包含两个组件:

  1. Reactor:负责查询、响应IO事件,当检测到IO事件时,分发给Handlers处理。
  2. Handler:与IO事件绑定,负责IO事件的处理。

它包含几种实现方式:

单线程Reactor

这个模式reactor和handler在一个线程中,如果某个handler阻塞的话,会导致其他所有的handler无法执行,而且无法充分利用多核的性能。

单Reactor多线程

由于decode、compute、encode的操作并非IO的操作,多线程Reactor的思路就是充分发挥多核的特性,同时把非IO的操作剥离开。

但是,单个Reactor承担了所有的事件监听、响应工作,如果连接过多,还是可能存在性能问题。

 

多Reactor多线程

为了解决单Reactor的性能问题,就产生了多Reactor的模式。其中mainReactor建立连接,多个subReactor则负责数据读写。