壹影博客.
我在下午4点钟开始想你
Java IO模型详解及Selector底层分析
  • 2024-4-20日
  • 0评论
  • 184围观

Java IO模型详解及Selector底层分析

Java IO模型是指用什么样的通道进行数据的发送和接收,它很大程度上决定了程序通信的性能,简单说就是客户端和服务端进行通讯交互的一种方式,其中BIO、NIO、AIO就是以不同的方式去接收处理客户端发送到服务端的消息不同方案

Java IO模型主要包括三种:BIO(Blocking I/O,阻塞式I/O)、NIO(Non-blocking I/O,非阻塞式I/O)和AIO(Asynchronous I/O,异步I/O)。

本篇文章将介绍各IO模型的特点以及分析示例代码

 一、BIO(Blocking I/O)- 阻塞IO

 描述:在BIO模型中,客户端发送一个请求,服务端会启动一个线程来处理这个请求,然后等待这个请求处理完成后再返回结果给客户端。这个过程中,服务端的线程是被阻塞的,无法处理其他请求。因此,BIO模型在处理大量并发连接时,会消耗大量的系统资源,导致性能下降。

 下面是BIO的传统写法

package com.yyge.bio;

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

/**
 * Bio程序1(阻塞IO) - 传统写法
 * 存在的问题:会造成主线程阻塞 等待消息收发.. ,无法同时处理多个连接
 *
 * 针对改问题的处理方案:开启多线程
 */
public class Socketserver1 {
    //cmd 连接命令
    //telnet localhost 9000  //连接
    //sent hello bio //发送数据

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket =new ServerSocket(9000);
        while (true){
            System.out.println("等待连接...");
            
            //阻塞方法
            Socket accept = serverSocket.accept();
            System.out.println("客户端连接了....");
            handler(accept);
        }
    }

    private static void handler(Socket accept) throws IOException {
        byte[] bytes = new byte[1024];
        System.out.println("准备读取数据");

        //接受客户端数据 阻塞方法,没有数据可读时就阻塞
        int read = accept.getInputStream().read(bytes);
        System.out.println("读取数据完毕...");
        if(read!= -1){
            System.out.println("接收客户端数据:"+new String(bytes,0,read));
        }
        System.out.println("end");
    }
}

经过测试我们可以得知 这种方式 会阻塞主线程,他会阻塞后面的连接,我们可以想到的方案就是开多线程看看能不能解决这个问题

我们将代码做了如下的优化

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

/**
 * Bio程序2(阻塞IO) - 优化后(添加多线程)
 * 优化内容:添加了多线程对连接数据的处理,此时可以同时处理多个新的连接
 * 存在的问题:如果连接的线程过多 比如10万个连接 100万个连接,后台不可能开100万个线程
 * 这样会造型巨大的资源消耗 会导致OOM 内存溢出 C10K问题、C10N问题
 *
 * 针对该问题的处理方法:控制线程数(比如搞一个线程池 控制核心线程数等...),但是如果开线程池的话 会限制程序的并发数
 * 比如搞一个线程池 核心线程数设置为500 ,那么同一时刻只能处理500个线程,限制了并发数量
 * 此外,如果说用户连接 并没有发送信息 那么这个连接依然保持 也会浪费线程资源
 * 试想 如果500个连接 都只是连接 没有发送数据的话 ,那么当前程序将会消耗500个线程的资源
 */
public class Socketserver2 {
    //cmd 连接命令
    //telnet localhost 9000  //连接
    //sent hello bio //发送数据

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket =new ServerSocket(9001);
        while (true){
            System.out.println("等待连接...");

            //阻塞方法
            Socket accept = serverSocket.accept();
            System.out.println("客户端连接了....");
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        handler(accept);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }
            }).start();

        }
    }

    private static void handler(Socket accept) throws IOException {
        byte[] bytes = new byte[1024];
        System.out.println("准备读取数据");

        //接受客户端数据 阻塞方法,没有数据可读时就阻塞
        int read = accept.getInputStream().read(bytes);
        System.out.println("读取数据完毕...");
        if(read!= -1){
            System.out.println("接收客户端数据:"+new String(bytes,0,read));
        }
        System.out.println("end");
    }
}

如上的代码 我们用多线程解决了阻塞的问题 但是这个BIO还是存在很多问题,我在注释里面写的也比较清楚了

 * 存在的问题:如果连接的线程过多 比如10万个连接 100万个连接,后台不可能开100万个线程
 * 这样会造型巨大的资源消耗 会导致OOM 内存溢出 C10K问题、C10N问题
 *
 * 针对该问题的处理方法:控制线程数(比如搞一个线程池 控制核心线程数等...),但是如果开线程池的话 会限制程序的并发数
 * 比如搞一个线程池 核心线程数设置为500 ,那么同一时刻只能处理500个线程,限制了并发数量
 * 此外,如果说用户连接 并没有发送信息 那么这个连接依然保持 也会浪费线程资源
 * 试想 如果500个连接 都只是连接 没有发送数据的话 ,那么当前程序将会消耗500个线程的资源

NIO是时候登场了...

二、NIO(Non-blocking I/O)- 非阻塞IO

描述:NIO是Java 1.4引入的新I/O模型,它支持非阻塞的I/O操作。在NIO模型中,客户端发送一个请求,服务端会启动一个线程来处理这个请求,但不会等待这个请求处理完成就返回结果给客户端。客户端可以继续发送其他请求,服务端也会继续启动其他线程来处理这些请求。这样,服务端就可以同时处理多个请求,大大提高了系统的并发处理能力。NIO模型主要使用了Channel、Buffer和Selector等核心组件,简单说就是可以在一个线程同时建立多个连接

 直接看看NIO-低级版本的代码吧

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

/**
 * NIO程序(非阻塞):简单的原始使用
 * NIO程序由于是非阻塞IO那么他只需要一个线程就可以处理多个连接
 *
 * 存在的问题:如果服务器已经有10万个连接建立,则数组内就会有10万条连接
 * 我们每一次遍历都要遍历10万次,比较消耗性能
 * 相当于每一次处理其中几个连接 我就要遍历10万次
 * 还有就是 没有业务处理 有10万连接 ,那么程序就会一直遍历十万次...
 *
 * 针对该问题的处理方法:再去搞一个集合 每一次遍历另一个集合 这个新的集合里面只存放有消息收发的连接
 * 极大的提高了性能
 */
public class NioServer1 {
    //保存客户端连接
    static List<SocketChannel> channels=new ArrayList<>();

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

        //创建NIO ServerSocketChannel
        ServerSocketChannel serverSocket=ServerSocketChannel.open();

        //绑定服务并设置服务端口号
        serverSocket.socket().bind(new InetSocketAddress(6000));

        //设置服务为非阻塞 如何是true的话 会退化为bio程序
        serverSocket.configureBlocking(false);
        System.out.println("服务启动成功");

        while (true){
            //非阻塞模式accept方法不会阻塞,否则会阻塞
            //NIO的非阻塞是有操作系统内部实现的,底层调用了linux内核的accept函数
            SocketChannel socketChannel = serverSocket.accept();
            if(socketChannel!=null){ //如果有客户端进行连接
                System.out.println("连接成功");

                //设置SocketChannel为非阻塞
                socketChannel.configureBlocking(false);

                //保存客户端连接在List中;
                channels.add(socketChannel);
            }

            //遍历连接进行数据的读取 读取每一个连接中是否有接收到消息,以及检查连接的状态
            Iterator<SocketChannel> iterator= channels.listIterator();
            while (iterator.hasNext()){
                SocketChannel next = iterator.next();
                ByteBuffer byteBuffer=ByteBuffer.allocate(6);

                //非阻塞模式read方法不会阻塞
                int len = next.read(byteBuffer);

                //如果有数据,把数据打印出来
                if(len>0){
                    System.out.println("接收到消息:"+new String(byteBuffer.array()));
                }else if(len == -1){ //如果客户端断开连接,把socket从集合中移出掉
                    iterator.remove();
                    System.out.println("客户端断开连接");
                }

            }
        }
    }
}

上面是NIO的基本使用,虽然他一个线程就可以处理多个连接但是,他是将连接存到数组的,每一次操作都要去遍历数组,相当麻烦, 如果服务器已经有10万个连接建立,则数组内就会有10万条连接,我们每一次遍历都要遍历10万次,比较消耗性能,相当于每一次处理其中几个连接 我就要遍历10万次,还有就是 没有业务处理 有10万连接 ,那么程序就会一直遍历十万次... 

那么有没有更好的解决方案呢? NIO提供了一个多路复用器 在这个多路复用器的内部帮我们维护了另外的数组 每一次遍历不用遍历原有数组....,代码如下

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

/**
 * NIO程序(非阻塞) - selector:多路复用器 -- 在传统NIO应用程序上的优化
 * 当前程序的优点:带多路复用器的NIO程序 是在一个线程内通过操作系统层面的监听来实现事件的触发
 * 解决了 普通NIO程序遍历集合完成收发的问题,提高了系统的性能
 *
 * 在JAVA层面 - 当有多个连接与服务端连接时 只有其中一个连接于服务端进行收发交互则selector只会出发这一个连接
 * 而不会遍历所有的连接
 * 在JDK的源码层面 - 底层是包装了一个数组 系统会把有收发消息的连接丢到新的数组(也就是我们注册的一些连接)(类似于Java的集合)
 * 但是在创建数组前 会调用JDK本地方法中C源码(EPollArrayWrapper.c)中的 epoll_create()方法
 * 该方法 调用了操作系统的一个内核函数
 *
 * 我们可以在Linux中通过 # man epoll_create 来查阅该条命令的大致含义
 * 大致含义是:在底层创建了一个类似于像C语言结构体一样的集合 这个结构体可以放很多数据 ,以后的事件之类的就会存放到这个结构体内部
 *
 * 当前程序依然存在的问题:如果一个线程处理业务时间非常久比较耗时的话,由于是在一个线程执行 也可能会造成其他的连接消息收发受阻塞的情况
 * 当前这种实现是redis实现的NIO模型 因为Redis是单线程的 但是在java这种可以使用多线程处理业务的情况,还有待优化的空间
 *
 * 解决方案:如果用多线程的话来实现NIO程序 可以直接使用Netty Netty在NIO的实现上做了大量的优化
 */
public class NioServer2 {

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

        //创建NIO ServerSocketChannel
        ServerSocketChannel serverSocket=ServerSocketChannel.open();

        //绑定服务并设置服务端口号
        serverSocket.socket().bind(new InetSocketAddress(6001));

        //设置服务为非阻塞 如何是true的话 会退化为bio程序
        serverSocket.configureBlocking(false);

        //打开selector处理Channel,即创建epoll
        Selector selector = Selector.open();

        //把ServerSocketChannel 注册到selector上,并且selector对客户端accept连接操作
        //SelectionKey.OP_ACCEPT --连接事件
        //这一步在JDK底层的C部分就是通过 把这个Channel装到了C层面的一个数组中
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务启动成功");

        while (true){
            //阻塞等待需要处理的事件发生
            //他会监听注册的上面的所有的Channel 内部的事件变化,IO事件的变化 如读、写事件等
            selector.select(); //这个方法会阻塞等待 某一个事件的发生 在serverSocket.register中第二个参数 配置的事件类型,如果没有事件发生会在此阻塞等待

            //获取selector中注册的全部事件的selectorKey实例
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            while (iterator.hasNext()){
                SelectionKey key = iterator.next();

                //如果是OP_ACCEPT事件 则进行连接获取和事件的注册
                if(key.isAcceptable()){
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false); //设置非阻塞

                    socketChannel.register(selector,SelectionKey.OP_READ);
                    System.out.println("客户端连接成功");
                }else if(key.isReadable()){ //如果是OP_READ事件 则进行读取和打印
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(6);
                    int len = socketChannel.read(byteBuffer);

                    //如果有数据就打印出来
                    if(len>0){
                        System.out.println("接收到消息:"+new String(byteBuffer.array()));
                    }else if(len == -1){ //如果客户端断开连接,把socket从集合中移出掉
                        System.out.println("客户端断开连接");
                        socketChannel.close(); //关闭socketChannel
                    }
                }

                //从事件集合中删除本次处理的key,防止下次select重复处理
                iterator.remove();
            }
        }
    }
}

三、selector多路复用器底层实现原理

selector 底层创建的是通过 sun.nio.ch.DefaultselpctorProvider.create();
进行创建的 在这个create方法内部 会调用到
jdk1.8.0_171\jre\lib\rt.jar!\sun\nio\ch\DefaultSelectorProvider.class中的create()

在这个方法内 不同的操作系统 new的对象不同
# \sun\nio\ch\WindowsSelectorProvider.class
win系统是 new WindowsSelectorProvider()

# \sun\nio\ch\EpollSelectorProvider.class
Linux系统是 new EpollSelectorProvider()

# \sun\nio\ch\DevPollSelectorProvider.class
SunOS系统是 new DevPollSelectorProvider()

这个取决你下载的是什么系统的JDK win的只有WindowsSelectorProvider
....

说白了 这个对象的功能在底层是基于不同操作系统的命令所实现的

由于selector在JDK的源码层面 - 底层是包装了一个数组 会把有收发消息的连接丢到新的数组(类似于Java的集合)
但是在创建数组前 会调用JDK本地方法中C源码(EPollArrayWrapper.c)中的 epoll_create()方法
该方法 调用了操作系统的一个内核函数epoll_create()

我们可以在Linux中通过 # man epoll_create 来查阅该条命令的大致含义
大致含义是:在底层创建了一个类似于像C语言结构体一样的集合 这个结构体可以放很多数据 ,以后的事件之类的就会存放到这个结构体内部

** selector内部有两个集合 一个放所有注册过来的Channel、另外一个函数是事件集合的函数,如果Channel有事件收发 则会放另外的一个集合去

我们在使用selector的时候有三个方法与JDK底层C层面的源码关系如下图关系

描述 底层创建数组 添加数组 epoll_ctl:监听绑定的事件
epoll_waite 等待文件描述符epfd上的事件,也就是监听事件集合函数列表里面的事件发生
事件集合:是通过操作系统的中断程序,收发事件的感知回调 添加到事件集合里面去

Java程序是没有办法直接跟客户端进行交互的,必须通过操作系统的一些函数做数据收发

本P上述总结 这个selector多路复用器底层是调用了操作系统内核函数的 epoll_create、epoll_ctl、epoll_waite来实现的

 常见面试题:

面试题:select、poll、epoll有什么区别?
这几个都是实现Java层NIO的底层操作系统的函数
select函数 -- 早期JDK实现NIO调用操作系统底层的内核函数 函数特点:在底层会遍历集合 所有的Channel全部遍历一遍来对客户端的消息收发做感知 底层实现是数组 最大连接是有上限的
poll函数(JDK1.4使用) -- 早期JDK实现NIO调用操作系统底层的内核函数 函数特点:底层也会遍历集合 所有的Channel全部遍历一遍来客户端的消息收发做感知 底层是通过链表实现的 最大连接无上限
epoll函数(JDK1.5及之后) -- JDK1.4以后实现NIO调用操作系统底层的内核函数 函数特点:底层是通过回调的方式 来进行感知的 底层实现用的哈希表 最大连接无上限

面试题:Redis是单线程为什么性能还能这么高?
1.基于内存的存储
2.底层实现用到了EPOLL事件轮询模型 redis启动的时候 会调用epoll_create(1024) 创建一个存放Channel的结构体..原理于JAVA的selector差不多

发表评论