记录一下深入理解io,nio的实现原理,以及io到nio转化的原因

我们都知道io为是阻塞的,nio为非阻塞的,但是这么理解太过于片面,因为这个东西太过于泛化,没有意义。

其实io的阻塞也分为类型,分为连接阻塞和通信阻塞,这么说也太过于抽象,我们先画图说明,然后以实际的代码来进行深入理解。

 通过上图,我们得知阻塞io的连接和通信过程,接下来我们通过代码来验证上图的过程:

首先,我们创建一个服务端IOServerTest类:

/**
 * 测试io阻塞模型
 *
 * 阻塞分为2种,第一种是连接阻塞,第二种是通信阻塞
 *
 * @author shixiongfei
 * @date 2019-12-03
 * @since
 */
public class IOServerTest {

    public static void main(String[] args) throws IOException {
        byte[] bytes = new byte[10];
        /*
        socket,我们称之为套接字,在linux系统中,我们可以将其理解为 ip + port的一个文件
        因为在linux中万物皆文件 对于文件描述符,其实含义就是表示文件内存指针的索引
        linux操作系统分配的文件的内存地址是不会给我们用户暴露的
        (为了linux操作系统的安全性,防止用户能够直接篡改造成系统崩溃)
        如果想要彻底理解需要去查看openjdk源码
         */
        // 执行一些初始化
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress(9876));
        System.out.println("服务端启动成功");
        while (true) {
            // 监听客户端链接,如果无连接则陷入阻塞,这里为连接阻塞
            Socket socket = serverSocket.accept();
            System.out.println("客户端连接成功");

            // 监听客户端发送的数据,如果没有发送则阻塞,这里为通信阻塞
            System.out.println("监听客户端发送的数据");
            int read = socket.getInputStream().read(bytes);
            System.out.println("收到客户端发送的数据");
            System.out.println("输入传送内容: " + new String(bytes));
        }

    }
}

接着,我们创建一个io客户端IOClientTest类:

/**
 * 阻塞io客户端测试类
 *
 * @author shixiongfei
 * @date 2019-12-03
 * @since
 */
public class IOClientTest {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket();
        socket.connect(new InetSocketAddress("127.0.0.1", 9876));
        // 如果这里客户端和服务端建立了连接,但是客户端不输入如何信息,则服务端会陷入读阻塞
        System.out.println("客户端输入:");
        Scanner scanner = new Scanner(System.in);
        String next = scanner.next();
        socket.getOutputStream().write(next.getBytes());
    }
}

1.启动服务端的进程,结果如下:

这里我们验证了第一个过程,accept()监听客户端的连接,无连接到来时陷入阻塞(因为未打印客户端连接成功);

2. 启动客户端的进程,建立于服务端的连接,客户端进程console结果如下:

此时,我没有输入任何东西,我们来看一下服务端的console输出结果:

此时我们发现,客户端和服务端建立了连接,但是服务端居然阻塞了,阻塞的原因是read方法,这里我们验证了read方法会一只等待数据的到来,如果没有,则陷入阻塞(这里的底层原理主要是因为java调用read方法会发起一个系统调用,内部通过JNI去调用read0方法(read0方法使用c或c++写的,用户去调用系统的read函数),会去询问内核是否有数据到来,此时内核在监听客户端的数据(处于阻塞),当有数据到来时,此时操作系统内核的read函数解阻塞,将数据读取到内核空间,然后通知用户空间,数据已经准备就绪,然后会将内核空间的数据拷贝到用户空间(这里又涉及到了线程的上下文切换,后续会以博客的方式来讲解我对上下文切换的理解),然后java程序获取到了客户端的数据,代码接着向下运行)。

客户端输入内容:

服务端console输出内容:

       这便是阻塞io的整体流程,那么阻塞io的缺点是啥呢,我们现在已经知道,io阻塞分为连接阻塞和通信阻塞(读写), 此时我们模拟一个场景,客户端A与服务端S建立了连接,但是客户端A不发送任何数据到服务端S,此时服务端S在read方法中陷入阻塞(此时是线程从用户态陷入到了内核态),在这种情况下,客户端B与服务端S建立连接,这里的连接是建立不上的,因为服务端的主线程在read方法中阻塞了,无法调用accept方法来监听客户端的连接。只有当客户端A发送了数据,服务端才会解阻塞,来监听客户端的连接。

        这便是阻塞io最大的弊病,既然有问题那么我们肯定是有方式去解决的,第一种方案便是采用线程池来调度,这是我们所有人都能理解的,主线程只负责监听客户端的连接,连接成功后,read和write操作完全交由线程池去调度,本身的主线程只对监听进行阻塞和调度。我们来看代码:

/**
 * 测试io阻塞模型
 *
 * 阻塞分为2种,第一种是连接阻塞,第二种是通信阻塞
 *
 * @author shixiongfei
 * @date 2019-12-03
 * @since
 */
public class IOServerTest {

    public static void main(String[] args) throws IOException {

       // 创建线程池, 将连接成功的socket交由线程池来完成调度
            ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
                    .setNameFormat("demo-pool-%d").build();
            ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10,
                    0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(50),
                    namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
        /*
        socket,我们称之为套接字,在linux系统中,我们可以将其理解为 ip + port的一个文件
        因为在linux中万物皆文件 对于文件描述符,其实含义就是表示文件内存指针的索引
        linux操作系统分配的文件的内存地址是不会给我们用户暴露的
        (为了linux操作系统的安全性,防止用户能够直接篡改造成系统崩溃)
        如果想要彻底理解需要去查看openjdk源码
         */
        // 执行一些初始化
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress(9876));
        System.out.println("服务端启动成功");
        while (true) {
            // 监听客户端链接,如果无连接则陷入阻塞,这里为连接阻塞
            Socket socket = serverSocket.accept();
            System.out.println("客户端连接成功");
            executor.execute(new Worker(socket));
        }

    }

    /**
     * 任务类
     */
    static class Worker implements Runnable {

        byte[] bytes = new byte[10];

        private Socket socket;

        public Worker(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            // 监听客户端发送的数据,如果没有发送则阻塞,这里为通信阻塞
            System.out.println("监听客户端发送的数据");
            try {
                int read = socket.getInputStream().read(bytes);
            } catch (IOException e) {
                e.printStackTrace();
            }
            System.out.println("收到客户端发送的数据");
            System.out.println("输入传送内容: " + new String(bytes));
        }
    }

    /**
     * 线程工厂构建器
     */
    static class ThreadFactoryBuilder {

        private String poolName;


        public ThreadFactory build() {
            return new DefaultThreadFactory(poolName);
        }

        public ThreadFactoryBuilder setNameFormat(String nameFormat) {
            this.poolName = nameFormat;
            return this;
        }


    }

    /**
     * 默认的线程工厂
     */
    static class DefaultThreadFactory implements ThreadFactory {

        private String poolName;

        public DefaultThreadFactory(String poolName) {
            this.poolName = poolName;
        }

        @Override
        public Thread newThread(Runnable r) {
            return new Thread(r);
        }
    }
}

我们只修改了服务端的代码,将服务端的读写交由线程池进行管理,实现伪异步,我们启动服务端,同时启动三个客户端,

服务端console输出内容如下:

 我们发现3个客户端都连接成功了,只是3个客户端未发送数据,意味着线程池中的3个线程陷入read阻塞,此时暂时解决了我们阻塞io的瓶颈问题。

那么,我们此时来分析一下我们用这种伪异步的方式有什么缺点,

1. 虽然使用了线程池,但是线程池创建最大线程数是有限制的,而且实际场景中,100个客户端建立了连接,意味着线程池要创建100个线程来同时处理客户端请求数据,那么此时可能只有20个线程实际发送了数据,剩余80个只是连接,不发送任何数据,此时会造成系统内存的巨大消耗,而且随着线程数的增多,线程上下文切换会导致系统性能急剧下降。

2. 如果线程数达到了线程池的最大线程数时,以及线程池的任务队列已满,那么接下来的客户端连接便会被抛弃或者抛出异常(这里主要是看线程池的拒绝策略采用哪一种,我们代码中采用的是抛出错误异常的策略),那么会造成客户端发送数据

3. 线程池对线程池的创建和销毁是很耗性能的,而且对于长连接而言,线程池的作用和每个客户端到来创建一个线程的差异并不大。

我们发现了一个问题,只要是服务端对客户端的监听是阻塞的, 服务端对等待客户端的read()是阻塞的,那么采用任何方式来优化都无法达到高并发。我们就会想,如果accept 和read都不是非阻塞的那么问题不久解决了吗,此时nio应运而生。nio又称为non-blocking-io,非阻塞io,他的非阻塞io的意义在于他对服务端监听客户端的连接是可以设置成非阻塞的,等待客户端的数据,也就是read,write()也可以设置成非阻塞的。(我们暂时先不考虑Selector),我们来看一下代码:

首先,我们创建一个NIOServerTest类:

/**
 * 测试nio的服务端
 *
 * @author shixiongfei
 * @date 2019-12-03
 * @since
 */
public class NIOServerTest {

    public static void main(String[] args) throws IOException, InterruptedException {

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(9876));
        // 将服务端的channel设置为非阻塞(这里可以理解为ServerSokect)
        serverSocketChannel.configureBlocking(false);
        while (true) {
            // 因为是非阻塞,所以accept方法不会陷入阻塞
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (Objects.isNull(socketChannel)) {
                TimeUnit.SECONDS.sleep(3);
                // 如果没有监听到连接则输出暂无连接
                System.out.println("暂无连接");
            } else {
                // 将客户端的channel设置为非阻塞,则read方法便不会陷入阻塞(这里可以理解为socket)
                socketChannel.configureBlocking(false);
                System.out.println("连接成功");
                ByteBuffer byteBuffer = ByteBuffer.allocate(10);
                System.out.println("获取客户端的请求数据");
                int read = socketChannel.read(byteBuffer);
                // 如果读到的字节数为0,则代表
                if (read == 0) {
                    System.out.println("未获取到客户端的请求数据");
                } else {
                    byteBuffer.flip();
                    System.out.println("获取客户端的内容:" + new String(byteBuffer.array()));
                }
            }
        }

    }
}

其次我们创建客户端NIOClientTest类(这里需要说明一下,这个客户端也可以采用前面所写的IOClientTest类,这里主要是因为用到了nio,所以希望保持一致):

/**
 * 测试nio客户端
 *
 * @author shixiongfei
 * @date 2019-12-03
 * @since
 */
public class NIOClientTest {

    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 9876));
        while (true) {
            System.out.println("客户端输入内容:");
            Scanner scanner = new Scanner(System.in);
            String next = scanner.next();
            ByteBuffer byteBuffer = ByteBuffer.allocate(next.length());
            byteBuffer.put(next.getBytes());
            byteBuffer.flip();
            socketChannel.write(byteBuffer);
        }
    }
}

ok,代码编写完毕,我们首先启动服务端,服务端console输出内容如下:

 因为此时我们并没有启动客户端,但是这里的accept方法是非阻塞的,所以会一直打印暂无连接;

ok ,我们此时打开客户端连接,客户端连接成功后,服务器端的console输出内容如下:

查看结果我们发现连接建立成功了,但是客户端未发送任何数据,所以在read之后,读到的字节个数为0,所以数据了未获取到客户端的请求数据,我们发现这都是非阻塞的,这就解决了我们io阻塞的缺点。

此时我们在客户端输入内容:

我们来看服务端的console输出内容:

一直输出暂无连接,在这里不知道大家有没有发现问题,因为客户端与服务端建立了连接后,因为都是非阻塞的,服务端对客户端的连接建立成功后,服务端走完了此客户端连接后的业务代码,然后接着轮询查看是否有连接,意思就是服务端并未保存与客户端建立连接后的socketChannel。这样会造成数据丢失,无法实时的查看客户端是否有数据过来,那么我们如何解决呢?

我们是不是立马想到了用list集合来存储,然后每次遍历这个集合中的socketChannel中是否有数据到来,如果有,则在控制台数据,没有则跳过。OK,我们开始来改造我们的服务端代码:

/**
 * 测试nio的服务端
 *
 * @author shixiongfei
 * @date 2019-12-03
 * @since
 */
public class NIOServerTest {

    public static void main(String[] args) throws IOException, InterruptedException {

        List<SocketChannel> list = new ArrayList<>(100);

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(9876));
        // 将服务端的channel设置为非阻塞(这里可以理解为ServerSokect)
        serverSocketChannel.configureBlocking(false);
        while (true) {
            // 因为是非阻塞,所以accept方法不会陷入阻塞
            SocketChannel socketChannel = serverSocketChannel.accept();
            if (Objects.isNull(socketChannel)) {
                TimeUnit.SECONDS.sleep(3);
                System.out.println("遍历socketChannel:");
                iteratorList(list);
                if (list.size() == 0) {
                    // 如果没有监听到连接则输出暂无连接
                    System.out.println("暂无连接");
                }
            } else {
                // 将客户端的channel设置为非阻塞,则read方法便不会陷入阻塞(这里可以理解为socket)
                socketChannel.configureBlocking(false);
                // 将socketChannel加入到list集合中
                list.add(socketChannel);
                System.out.println("连接成功");
                ByteBuffer byteBuffer = ByteBuffer.allocate(10);
                System.out.println("获取客户端的请求数据");
                int read = socketChannel.read(byteBuffer);
                // 如果读到的字节数为0,则代表
                if (read == 0) {
                    System.out.println("未获取到客户端的请求数据");
                } else {
                    byteBuffer.flip();
                    System.out.println("获取客户端的内容:" + new String(byteBuffer.array()));
                }
            }
        }

    }

    /**
     * 遍历socketChannel list
     *
     * @param list
     */
    public static void iteratorList(List<SocketChannel> list) {

        if (CollectionUtils.isNotEmpty(list)) {
            list.forEach(socketChannel -> {
                ByteBuffer byteBuffer = ByteBuffer.allocate(20);
                int read = 0;
                try {
                    read = socketChannel.read(byteBuffer);
                } catch (IOException e) {
                    e.printStackTrace();
                }
                // 如果读到的字节数为0,则代表
                if (read == 0) {
                    System.out.println("未获取到客户端的请求数据");
                } else {
                    // 重置 capacity, position limit, mark便于读取
                    System.out.println("获取客户端的内容:" + new String(byteBuffer.array()));
                }
            });

        }
    }
}

此时我们在客户端中输入hello,我们来查看服务端console的输出内容:

 我们发现,成功的打印了出来,这里我们采用自定义的List集合来实现了对socketChannel的轮询,这里其实就是一个自旋。我们这里采用了一个线程来处理多客户端连接的问题。

但是我们再来看看这里面有什么问题,我们知道List集合是在我们的JVM中分配内存的,如果此时有1000个客户端与服务器进行长连接,我们list集合中便会有1000个数据集,每次轮询都会遍历所有的数据集,然后其中可能只有100个向服务器发送了数据,意味着我们浪费了900的数据集的遍历,我们是否对其进行优化,做一个标识,如果存在读的情况,那我就遍历它将它输出,没有就不遍历,这里就不演示了,有小伙伴自己去尝试吧。

但是如果有10000个客户端呢,100000个客户端呢,我们JVM是不是会出现内存溢出,造成系统崩溃,那我们来看看nio是如何实现的,nio中有一个Selector,我们将自身的socketChannel注册到Selector中,Selector会自动帮我们遍历,如果存在读的情况,他会返回一个SelectionKey,然后我们可以通过此对象来获取socketChannel进行一系列的读写操作,那么Selector又是如何实现的呢,这里有涉及到了linux底层中的select, poll, 和epoll的多路复用模式。

近期也在努力的深入和整理学习select ,poll,epoll的底层实现,后续会将此文档完善, 对本篇文章有问题的小伙伴可以积极留言,我会不定期查看,谢谢各位的阅读。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值