💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
Java是一门跨平台的语言,其运行时通过Java虚拟机调用操作系统的相关系统函数,也就是说底层都是操作系统的相关程序。因此,我们在学习java I/O时需要对操作系统的I/O进行了解;由于大多时候Java应用程序都运行在Linux系统之上,我们以Linux为学习的基础。 ## 1.1 文件IO 在Linux系统中,所有的输入输出都会当做一个文件进行处理,Socket可以看做是一种特殊的文件。 ### 基本I/O与标准I/O 类Unix系统中常用的I/O函数有read()/write()等,这些被称为不带缓冲的I/O;标准I/O在基本的I/O函数基础上增加了流和缓冲的概念,常用的函数有fopen/getc()/putc()等,标准I/O为了提高读写效率和保护磁盘,使用了页缓存机制。 读文件调用getc()时,操作系统底层会使用read()函数,并从用户空间切换到内核空间,执行系统调用。首先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,经过两次数据拷贝过程,进程才能获取到文件内容。写操作也是一样,用户态写数据时,待发送数据所在的缓冲区处于内核空间,用户态不能直接访问,必须先拷贝至内核空间对应的主存,才能写回磁盘中(延迟写回),因此写入也是需要两次数据拷贝。 **mmap内存映射** mmap是一种内存映射文件的方法,可以将一个文件或者其它对象映射到进程的虚拟地址空间,实现文件磁盘地址和进程虚拟地址空间中某一段地址的一一对映,这样应用程序就可以通过访问进程虚拟内存地地址直接访问到文件或对象。 mmap在操作文件时,首先为用户进程创建新的虚拟内存区域,之后建立文件磁盘地址和虚拟内存相关区域的映射,这期间没有涉及任何的文件拷贝。当进程访问数据时发现内存中并无数据而发起的缺页异常处理,根据已经建立好的映射关系进行一次数据拷贝,将磁盘中的文件数据读取到虚拟地址对应的内存中,供进程使用。 综上所述,常规的文件操作需要通过两次数据拷贝才能从磁盘读取到用户空间所在的内存,而使用mmap操作文件,只需要从磁盘到内存的一次数据拷贝过程。mmap的关键点是实现了用户空间和内核空间的映射,并在此基础上直接进行数据交互,避免了空间不同数据不通的繁琐过程。因此mmap效率更高。但在《Unix网络编程》的14.8 存储映射I/O一节中,作者说明了Linux3.2.0和Solaris中,两种方法的测试结果相反;作者认为是实现方式的差异造成的。 ## 1.2 Linux I/O模型 Linux I/O的模型直接影响了java I/O模型,以下是几种常见的IO模型,在APUE(Unix环境高级编程)中有讲解。 **阻塞模型** 传统的read()和write()会等待数据包到达且复制到应用进程的用户空间缓冲区或发生错误时才会返回,在此期间会一直等待;等待期间进程一直处于空闲状态。 **非阻塞模型** 非阻塞IO模型下,我们发出open/read/write这样的IO操作时,这些操作不会永远阻塞,而是立即返回。对于一个给定的文件描述符,有两种指定非阻塞的方法: 1. 调用open获得描述符时,可指定O_NONBLOCK标志 2. 对于一个已经打开的描述符,可调用fcntl,由该函数打开O_NONBLOCK状态标志 非阻塞模型由于立即返回,后面需要轮询不断的查看读写是否已经就绪,然后才能进行I/O操作。 **异步IO** 关于文件描述符的状态,系统并不会主动告诉我们任何信息,需要不断进行查询(select或poll)。Linux系统中的信号机制提供了一直通知某事情发生的方法。异步IO通知内核某个操作,并让内核在整个操作完成后通知应用程序。 **多路复用** 当我们需要在一个描述符s1上读,然后又在另一个描述符s2上写时,可以连续使用read()和write(),但是我们在s1上进行阻塞读时,会导致进程即使有数据也不能写入到s2中。因此我们需要另一种技术来完成这类操作。 解决方法1:使用两个进程,一个读,一个写,这样会使程序变得复杂,当然也可以使用两个线程,但需要进行同步操作。 解决方法2:仍然使用一个进程,但使用非阻塞IO,打开两个文件时都设置为非阻塞,轮询两个文件描述符,这样会造成CPU资源的浪费 解决方法3:异步IO。信号对每个进程而言只有一个(SIGPOLL或SIGIO),如果该信号对两个描述符都起作用,进行在接到此信号时无法判别是哪个。 还有一种技术就是我们现在要介绍的:I/O多路复用。 ## 1.3 多路复用 使用I/O多路复用时,先构造一张进程感兴趣的文件描述符列表,然后调用一个函数,直到这些描述符中的一个IO已准备好时,该函数才返回。常用的IO多路复用实现有:select() poll()和epoll() **select** select可以指定感兴趣的描述符集合,select函数会遍历当前进程打开的描述符集合,查找就绪事件返回,具体的原理可见http://blog.csdn.net/vonzhoufz/article/details/44490675 **select程序** ``` fd_set set; while(1) { /*设置文件描述符集,先清空,再加入管道1,2*/ /*每次循环都要清空集合,否则不能检测描述符变化 */ FD_ZERO(&set); FD_SET(fd1,&set); FD_SET(fd2,&set); rfd=select(FD_SETSIZE,&set,NULL,NULL,&val); switch(rfd) { case -1: exit(-1);break; case 0:break; default: if(FD_ISSET(fd1,&set)){ //测试fd1是否可读, read(fd1,buf2,sizeof(buf2)); printf("[1]Get msg!\n"); } if(FD_ISSET(fd2,&set)) { read(fd2,buf,sizeof(buf)); printf("[2]Get msg!\n"); } }//switch }//while ``` **select缺点** * 每次调用select,需要把fd_set从用户空间和内核空间之间进行拷贝,fd很多时开销很大 * 每次调用select都要在内核遍历进程的所有fd,fd很多时开销很大,随着fd增长而线性增长 * select支持的文件描述符有限,默认是1024,太小 **poll** ``` for ( ; ; ){ //获取可用描述符的个数 80 nready = poll(clientfds,maxi+1,INFTIM); } ``` poll的实现和select非常相似,只是文件描述符fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构; poll不是为每个条件(读/写/异常)构造一个描述符集,而是构造一个pollfd结构,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件 ``` struct pollfd{ int fd ; short events; //感兴趣的事件 short revents;//发生的事件 } ``` poll和select同样存在一个缺点:包含大量文件描述符的数组被整体复制于用户态和内核的地址空间自己,无论是否就绪,开销随着文件描述符数量增加而增加。 **epoll** ``` for ( ; ; ){ //获取已经准备好的描述符事件 ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1); handle_events(epollfd,events,ret,listenfd,buf); } ``` epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。 而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait: * epoll_create是创建一个epoll句柄 epollfd; * epoll_ctl是注册要监听的事件类型; * epoll_wait则是等待事件的产生。 针对fd_set每次从用户空间和内核空间之间进行拷贝的缺点,epoll在 epoll_ctl函数中,每次在注册fd到epoll_create生成的epoll句柄时,把fd拷贝进内核,而不是在 epoll_wait的时候重复拷贝。因此,epoll保证每个fd只拷贝一次,在循环的epoll_wait不进行拷贝,而select和poll在循环中每次都需要拷贝。 针对select/poll在内核中遍历所有fd,epoll不会每次都将current(用户写select的线程)轮流加入fd对应设备的等待队列,而是在 epoll_ctl时把current挂一遍,并为每个fd指定一个回调函数,设备就绪唤醒等待队列上的线程时,会调用回调函数;回调函数把就绪的fd放入一个就绪链表。 epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd( schedule_timeout() 实现睡一会判断一会) 针对fd大小的限制,epoll没有限制,支持的fd上限时最大可以打开文件的数目,一般远大于2048,如1GB机器可打开的是10万左右 cat /proc/sys/fs/file-max可查看 ## 1.4 Java网络I/O模型 **BIO 阻塞I/O** 服务器每次收到请求后,会启动一个线程处理连接,但是每次建立连接后创建线程,容易造成系统资源耗尽,因为每个线程都需要耗费一定的内存资源,当连接过多时,内存会耗尽。 ``` while(true){ Socket socket= serverSock.accept(); System.out.println("Accept from a client!"); BioHandler handler=new BioHandler(socket); handler.start(); } ``` 在上面的基础上,我们可以引入线程池机制,每次接收到请求,封装为一个任务,提交到线程池处理;如果线程池已满,剩余的任务需要等待或者创建新的线程,这与连接池的配置有关。如果线程池创建新的线程,也会有资源耗尽的缺点;如果等待,意味着如果应答比较缓慢,或者被故障服务器阻塞,之后的请求会一直排队,直到超时。消息的接收方处理缓慢时,不能及时从TCP缓冲区读取数据,造成发送方的窗口大小不断减小,直到为0后不能再发送消息。 ``` pool=new CommonThreadPool<>(); while(true){ Socket socket= serverSock.accept(); System.out.println("Accept from a client!"); pool.execute(new BioHandler(socket)); } ``` **NIO** NIO底层会根据操作系统选择可用的多路复用机制,需要将Channel注册到打开的selector中。 ``` Selector selector=Selector.open(); ServerSocketChannel.open().register(selector, SelectionKey.OP_ACCEPT); while(true){ int size=selector.select();//获取连接 } ``` NIO的优势在于: 1. 客户端发起的连接是异步的,客户端不会被同步阻塞 2. SocketChannel的读写是异步的,没有可读写数据不会同步等待,而是直接返回,IO通信线程可以处理其他事情 3. epoll没有连接句柄的限制(只受制于操作系统的最大句柄数或单个线程的最大句柄数),一个selector线程可以同时处理成千上万个客户端连接,性能不会线性下降。 **Reactor** 在实践中,对于NIO与线程的使用,抽象成了一种Reactor模型。Reactor模型采用分而治之的思想,将服务器处理客户端请求的事件分为两类:I/O事件和非I/O事件;前者需要等待I/O准备就绪,后者可以立即执行,因此分开对着两种事件进行处理。非I/O事件一般包括收到请求后的解码/计算/编码等操作。 Reactor模型分为两个角色: 1.Reactor负责相应IO事件,一旦建立连接,发生给相应的Handler处理。 2.Handler负责非阻塞事件,Handler会将任务提交给业务逻辑线程池,处理具体业务。 Reactor与线程/线程池的组合可以有如下组合: ***单线程Reactor*** 单个线程,所有连接注册到该线程上,适合I/O密集,不适合CPU密集(业务逻辑大量计算)的应用;CPU资源紧张使用单线程可以减少线程切换。 ***Reactor与线程池*** Reactor仅负责I/O,线程池负责其他业务逻辑 ***双Reactor与线程池*** mainReactor负责处理客户端的连接请求,并将accept的连接注册到subReactor的其中一个线程上;subReactor负责处理客户端通道上的数据读写和业务逻辑; 这种方式的好处是,CPU资源紧张时,可通过调整subReactor数量调整线程大小;CPU密集任务时,可以在业务逻辑处理交给线程池处理。 **AIO** NIO2.0引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。具体程序可见 https://github.com/ssj234/JavaStudy_IO/tree/master/IOResearch/src/net/ssj/aio ``` //创建一个异步的服务端通道 asynchronousServerSocketChannel=AsynchronousServerSocketChannel.open(); //监听端口 asynchronousServerSocketChannel.bind(new InetSocketAddress(port)); //接受请求时,只需要注册一个接受到请求后的处理器即可【CompletionHandler】 asynchronousServerSocketChannel.accept(this, new AcceptCompletionHandler()); //AcceptCompletionHandler 实现了CompletionHandler接口,对accept完成后事件进行处理,accept后需要读取客户端请求,并在读完成后调用回掉函数 attachment.asynchronousServerSocketChannel.accept(attachment,this); //分配一个缓冲区,读取输入,交给ReadCompletionHandler处理【CompletionHandler】 ByteBuffer buffer=ByteBuffer.allocate(1024); result.read(buffer,buffer,new ReadCompletionHandler(result)); //ReadCompletionHandler在读完后,向客户发出写请求,也是异步的。 ```