欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

一文读懂NIO

程序员文章站 2022-03-11 09:01:46
前言NIO看了好多的博客、视频,都学不明白,最多只是单纯的记住了代码而已,时间一久就忘得一干二净,更不用说学习更牛逼的Netty,Nginx,Redis的底层原理了。所以这篇博客从底层讲起,从操作系统上的BIO到Non-blocking IO到Java中的NewIO,只有真正理解了原理,才能学会NIO。操作系统知识首先要有一点操作系统的前置知识。系统调用的概念都知道操作系统中分为用户态(可以理解为应用程序所在的空间)和内核态(可以理解为操作系统内核所在的空间)。用户态是不能直接访问内核态的,要访...

前言

NIO看了好多的博客、视频,都学不明白,最多只是单纯的记住了代码而已,时间一久就忘得一干二净,更不用说学习更牛逼的Netty,Nginx,Redis的底层原理了。
所以这篇博客从底层讲起,从操作系统上的BIO到Non-blocking IO到Java中的NewIO,只有真正理解了原理,才能学会NIO。

操作系统知识

首先要有一点操作系统的前置知识。

系统调用的概念

都知道操作系统中分为用户态(可以理解为应用程序所在的空间)和内核态(可以理解为操作系统内核所在的空间)。用户态是不能直接访问内核态的,要访问内核态需通过系统调用的方式。系统调用可以简单的理解为内核态提供的的一些api,用户态没有权限访问一些资源,只能通过调系统调用来切换为内核态,让内核态访问。
在切换为内核态时,cpu要将当前运行程序的一些缓存数据和当前指令等保存到程序所在内存空间中(即保护现场),内核态切换回用户态时要去把这一部分数据从内核态拷贝回来(即恢复现场)。很直观的一个结论:频繁的用户态内核态切换,一定会影响性能
此外还有个时钟中断的概念,假设只有一个cpu,但现代操作系统仍然可以运行多个进程(线程),这是因为每隔一段时间cpu就收到一个时钟中断、表示当前需要切换进程(线程)运行了,宏观的感觉就是1颗cpu上并行运行了多个程序,实际上在微观每个时间点还是只能运行单个程序的,进程(线程)之间的切换也需要保护/恢复现场,也是会影响性能

strace命令

strace命令常用于跟踪用户态向内核态发起的系统调用,并记录在一个文件里面。我们可以用它来跟踪java程序,并在对应的文件里找到当前java程序到底向内核态发起了什么样的系统调用。

man命令

man命令是查询linux帮助文档,比如man 2 xxx表示查询xxx的系统调用的详情(系统调用的代号为2)。
以上2个命令具体详解百度,这里只需要大概知道它们的作用就行了。

socket

我的理解socket就是对一个网络连接的抽象(或者说操作系统定义的规范?),比如服务器153.1.1.1:8081和客户端142.1.1.1:8088建立了一个连接,这一对ip+port就可以被称为一个scoket。
服务器的ip+port和客户端的ip+port,有任意一个不同都是不同的socket。

fd

fd即file description(文件描述符),Linux中一切皆文件,一个socket也会在操作系统中以一个fd文件的形式保存下来。
socket和fd的详情也可以百度,这里只需知道大概作用,直接看后文的实际操作就行。

BIO

首先写一个BIO的程序,然后用strace命令跟踪,看这个程序它在底层到底做了啥。
一个最简单的BIO程序

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @author MasterYee
 * @Description:
 * @date: 2020/7/15
 */
public class BIOTest {
    public static void main(String[] args) throws IOException {
        // new 一个ServerSocket  表示起了一个服务 绑定8081的端口
        ServerSocket serverSocket = new ServerSocket(8081);
        System.out.println("BIO Server start...");
        // 死循环的作用是不断的accept 也就可以建立多个连接了
        while (true){
            // 阻塞式的等待客户端连接 当有客户端连接时 返回一个socket
            Socket socket = serverSocket.accept();
            // 新建一个线程去读取数据
            // 因为read方法也是阻塞的 如果在同一个线程里做accept和read 那么会导致
            // 新来一个连接后 程序阻塞在等待新连接发数据上了 而此时再来一个新连接也会被阻塞
            // 不能用lambda表达式,因为要用jdk1.4运行
            new Thread(new Runnable() {
                public void run() {
                    // 保存请求数据的byte
                    byte[] request = new byte[1024];
                    try {
                        // 阻塞式的等待客户端发来数据
                        socket.getInputStream().read(request);
                        System.out.println("client send data...");
                        System.out.println(new String(request));
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

运行起来以后,程序阻塞在了accept上;浏览器输入127.0.0.1:8081相当于建立了一个socket连接,程序打印对应的一些数据
一文读懂NIO
放在Linux环境下,用jdk1.4的版本编译后
用strace命令跟踪运行

strace -ff -o out /usr/java/j2sdk1.4.2_18/bin/java BIOTest

上面这段代码表示用strace跟踪1.4的这个java程序,将每个线程映射成一个out开头的文件并保存到磁盘,我们可以通过对应线程的out文件来查看当前线程调了哪些系统调用
一文读懂NIO
启动另一个窗口,并查看第一个out文件(即主线程的out文件)
一文读懂NIO
可以用man 2命令来看这些系统调用的意思。这里直接解释了。

2565行:新建一个socket服务端,对应的fd为3
2567行:将这个3这个fd绑定到8081端口上
2568行:监听3这个fd(也就是说监听8081端口)
最下面一行accept:参数3表示尝试接收3这个fd上的新连接,只有一半表示阻塞在接收这个新连接上了(此时还没有新连接建立)

用nc命令访问8081端口,相当于模拟一个客户端与8081建立连接

nc 127.0.0.1 8081

可以看到当前程序继续运行
一文读懂NIO
tail -f 之前的out文件
一文读懂NIO

可以看到accept多了后半段,并返回了一个新的fd 7 这个fd就是代表着当前客户端与服务端建立的一个socket连接
还能看到最下面是一个新的阻塞住的accept,说明当前线程经过了一次循环后继续accept新的客户端

此时会多出一个out文件,因为Java代码在建立了新连接后会起一个新的线程去read,查看这个out文件
一文读懂NIO

调recv这个系统调用请求从参数7这个fd中(对应的就是新建立的scoket连接)获取数据
只有一半说明当前线程也阻塞在这个系统调用上了

BIO的弊端

  1. 起多个线程处理,浪费空间
  2. 由前文结论线程之间的切换调度一定会损耗性能
    解决方案
    BIO的弊端就是因为其阻塞的性质导致的。因此只要有一种accep和recv能非阻塞的获取连接/数据的话,就能解决问题。
    所幸的是,操作系统是支持的,这样就诞生了NIO(这里指的是操作系统层面的nonblocking IO)

NIO

先说操作系统层面的Nonblocking IO(非阻塞IO)
man 2 socket,可以发现这个系统调用有一个参数设置非阻塞
一文读懂NIO
Java中的NIO,实际上是基于操作系统的NonBlocking IO新诞生的一个New IO,新增了一些新的类
代码展示Java中的NIO

在这里插入代码片

设置非阻塞后,accept和read没有值时返回null,有新连接建立时accept返回新连接的fd,有数据时read返回数据
由程序去循环判断是否有数据,然后再新建连接/读取数据

NIO 的弊端

  1. 对所有已建立连接的fd,循环调用read方法,假设建立了1w个连接只有一个连接有数据,那么浪费了9999次循环,且每一次循环都是发起一次系统调用的过程,由前文结论这是非常消耗资源的。
    解决方案
    既然性能问题是由于循环发起系统调用导致用户态内核态一直循环切换,那么只要避免这种情况就可以,因此诞生了多路复用器(select poll epoll这些都是)

多路复用器

多路复用器的意思就是一次性传多个fd进内核态,由本来的一个用户态遍历这些fds发起系统调用转变为:一次系统调用传多个fds,在内核态里遍历,最后内核态返回有数据的fds给用户态。多路的意思就是:原本传了一个fd,后面传了一堆fds,对应单路——>多路。

select poll

多路复用器中,selectpoll又是一类。
poll相当于一个方法将传入的fds遍历一遍,找出发生了对应事件的fds,再返回给用户态。
用户态再遍历返回的fds,建立连接或者读取数据。这样用户态就不用陷入无意义的循环中
select 和poll基本没有区别,唯一一个可能是select有一个fds数量的限制(默认1024)
select poll的弊端

  1. 但select 和 poll依然有不足之处:每建立一个新的连接,新产生了一个fd,都要把全量的fds作为poll的参数传进去
    解决方案
    在内核态中开辟一个新的空间保存已传入的fds,每次新建一个连接,只需要将新的fd传入这个空间,并设置相关事件。内核态去遍历这些fds,将发生了事件的fds保存到另一块内核空间中,用户态只需调某个系统调用去另一个内核空间中拿发生了事件的fds。
    其实这就是epoll的原理
epoll

epoll有3个相关的系统调用,分别用man 2 查看可知其意思。

epoll_create 创建一个epoll空间,会返回这个空间对应的epfd
epoll_ctl(epfd,op,fd,event)参数op为add时表示将fd加入epfd对应的内核空间中,当发生了event时会被拷贝至另一内核空间
epoll_wait(epfd,accept) 相当于从epfd对应的另一内核空间拿发生了event事件的fds

同步IO模型

不管是BIO,普通的NIO,还是基于多路复用的NIO,本质上都是用户态自己去读取数据/建立连接的。在多路复用器模型中内核态也只负责告诉用户态连接的状态而已,这种IO模型一律称为同步IO模型

NIO基于Selector的代码

基于Selector的NIO代码实际上就是基于事件的一种写法,操作系统将发生了某种事件的连接通知给用户态,用户态根据事件来进行相应处理。
Java中的Selector实际上就是对应的操作系统中多路复用器(Java对底层api进行了封装,在不同的操作系统上可能有不同的实现,底层可能是select poll 也可能是epoll)

在这里插入代码片

(ps:这部分nio包括之后的细节有空再补,写了5500字了真累)

AIO

简单介绍下AIO
前文说到不管是BIO还是NIO,都是同步IO,因为都是程序自己去读取数据的。
JDK7之后诞生了异步非阻塞式的IO,即AIO
和NIO类似,一样需要向多路复用器注册事件,事件发生后通知应用程序取数据,但IO过程发生在OS中,而不是用户态中,AIO只需要从Buffer中拿操作系统已经取好的数据。这种方式就是异步的。

本文地址:https://blog.csdn.net/qq_34548229/article/details/107359632