Java NIO与多线程Reactor模式

Java1.4开始,提供了新的非阻塞IO操作的API,用来替代Java BIO和网络编程相关的API。Java NIO包括三个核心组件:Buffer缓冲区、Channel通道、Selector选择器。

Buffer缓冲区

缓冲区本质上是一个可以写入数据的内存块(可以是数组),然后可以从中读取数据。此内存块包含在NIO Buffer对象中,该对象提供了一组较方便地使用内存块的方法,相比较直接对数组操作,BufferAPI更容易操作和管理。

Buffer工作原理

Buffer有三个重要属性:

  • capacity容量:作为一个内存块,Buffer有固定的大小,用容量表示
  • position位置:写入模式时,代表当前写入的位置;读取模式时,代表当前读取的位置。也就是用于记录当前位置
  • limit限制:写入模式时,limit等于capacity;读取模式时,limit等于写入的数据量,因此不能读取大于写入量的数据

使用Buffer进行数据写入与读取,需要如下四个步骤:

  1. 调用写入方法,写入数据。写入数据时,position会增加
  2. 调用buffer.flip(),转为读取模式。会将limit设置为position大小,position设为0
  3. 调用读取方法读取数据。读取数据时,position会增加,但不能超过limit
  4. 调用buffer.clear()清除缓冲区数据,转为写入模式。会将position设为0,limit设为capacity大小

buffer.compact()是另一种转为写模式的方法,但只会清除读取过的数据。

ByteBuffer内存类型

ByteBuffer提供了直接内存(direct堆外)和非直接内存(heap堆)两种实现,使用allocateDirect获取堆外内存。
堆外内存的优点:

  • 进行网络IO或者文件IO时,比堆内存少了一次拷贝。(file/socket-----OS-----jvm heap)由于GC对移动堆内存中的对象,在写入file/socket时,jvm实现中会先把数据复制到堆外,再进行写入。
  • 堆外内存在GC范围之外,降低GC压力,并实现了自动管理功能。DirectByteBuffer中又一个Cleaner对象(PhantomReference),Cleaner内GC回收之前,会调用clean方法触发DirectByteBuffer中定义的Deallocator中的run方法,清理内存。

使用堆外内存时,要注意通过虚拟机参数MaxDirectMemorySize限制大小,防止机器内存耗尽。

Channel通道

Java NIO与多线程Reactor模式_第1张图片
传统的BIO中,数据发送接收基于OutputStream和InputStream,网络编程需要用到Socket建立连接与Stream数据读写两组API。Channel的API包含了网络和文件相关的操作:FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel。Channel不仅可以建立连接,同时还实现了数据读写,在一个Channel中可以进行读取和写入操作,而Stream流通常是单向的(input、output)。Channel始终是读取和写入缓冲区Buffer,可以通过非阻塞的方式读写。

SocketChannel

SocketChannel用于建立TCP连接

        //SocketChannel客户端使用方式
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress("localhost", 8080));
        while(!socketChannel.finishConnect()) {
            Thread.yield();
        }
        ByteBuffer byteBuffer = ByteBuffer.wrap("hello".getBytes());
        while(byteBuffer.hasRemaining()) {
            socketChannel.write(byteBuffer);
        }
        int readNum = socketChannel.read(byteBuffer);
        socketChannel.close();

由于是非阻塞的IO:
write方法可能在尚未写入任何数据时就返回,需要循环调用write;
read方法也可能直接返回而不读取任何数据,需要根据int值判断读取的数据量。

ServerSocketChannel

ServerSocketChannel可以监听新的TCP连接通道,用于创建网络服务端

//创建服务端示例
 ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.bind(new InetSocketAddress(8080));
        while(true) {
            SocketChannel socketChannel = ssc.accept();
            if(socketChannel != null) {
                while(socketChannel.isOpen() && socketChannel.read(buffer)!= -1) {
                }
                buffer.flip();
                byte[] content = new byte[buffer.limit()];
                buffer.get(content);
                System.out.println(new String(content));
            }
        }

在非阻塞模式下,accept如果没有连接,会立即返回null,所以必须检查socketChannel是否等于null。在这个示例中,使用的是单线程,虽然是非阻塞的IO模型,但是由于使用while循环一直判断状态,不能同时去处理其他连接请求。为了解决这个问题,需要对服务器端进行改进,可能有的人会想到使用多线程,对于每个请求开一个线程去处理,但是这样的话和BIO有什么区别呢?既然是非阻塞的,我们可以用其他的设计方式,用列表存储所有SocketChannel,没有新连接时,处理IO读写。

private static List socketChannels = new ArrayList<>();

public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.bind(new InetSocketAddress(8080));
        while(true) {
            SocketChannel socketChannel = ssc.accept();
            if(socketChannel != null) {
                socketChannel.configureBlocking(false);
                System.out.println("接收到新的连接:" + socketChannel.getRemoteAddress());
                socketChannels.add(socketChannel);
            }else {
                Iterator iterator = socketChannels.iterator();
                while(iterator.hasNext()){
                    //TODO 读取/响应
                }
            }
        }
}

通过这种方式,实现了一个线程可以处理多个请求。但是这种方式不管有没有数据,每次都要对整个SockerChannel列表进行轮询检查,这在高并发的场景下是十分低效的,因此Java提供了Selector选择器,也称为多路复用器。

Selector选择器

Selector可以检查多个NIO通道,并确定哪些通道已经准备好进行读取或写入,实现单个线程对多个通道的管理。Selector基于事件驱动机制:非阻塞的网络通道下,通过Selector注册感兴趣的事件类型,当有事件发生时,Selector会收到通知(由操作系统多路复用机制支持,虽然名称是selector,但是用的可能是epoll模型)。使用Selector后。监听方式由监听Channel变成了监听事件,极大地提高了效率。
Selector可以监听四种类型的事件,对应SelectionKey的四个常量:
OP_CONNECT:连接
OP_ACCEPT:准备就绪
OP_READ:可读
OP_WRITE:可写

        //创建一个Selector
        Selector selector = Selector.open();

        //创建服务端,并且注册到Selector上
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(8080));
        serverSocketChannel.configureBlocking(false);
        //ServerSocketChannel只支持ACCEPT事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("服务器启动成功");

        while(true){
            //不再轮询Channel,select有阻塞效果,直到有感兴趣的事件发生才返回
            selector.select();
            Iterator iterator = selector.selectedKeys().iterator();
            while(iterator.hasNext()){
                SelectionKey selectionKey = iterator.next();
                iterator.remove();
                //关注read和accept
                if(selectionKey.isAcceptable()){
                    //将客户端连接注册到selector,感兴趣事件为read
                    SocketChannel socketChannel = ((ServerSocketChannel)selectionKey.channel()).accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("收到新连接:" + socketChannel.getRemoteAddress());
                }
                if(selectionKey.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    while (socketChannel.isOpen() && socketChannel.read(buffer) != -1){
                        //TODO d读取/响应
                    }
                    buffer.flip();
                    byte[] content = new byte[buffer.limit()];
                    buffer.get(content);
                    System.out.println("接收到数据来自:" + socketChannel.getRemoteAddress());
                    System.out.println(new String(content));
                    selectionKey.cancel();
                }
            }
        }

通过selector的3个方法select(阻塞选择,直到有关心的事件发生时退出阻塞),selectNow(不阻塞选择),select(long)(指定超时选择,超时到达或者有关心事件发生时退出阻塞),来获取关心事件的发生。其执行步骤分为以下3步:
1、将存在于canceled-key set中的key从所有的key set中移除,撤销注册的channel,清空canceled-key set。
2、底层操作系统检查是否有关心的事件发生,当有关心的事件发生时,首先检查channel的key是否已经存在于selected-key set中,如果不存在,则将其加入到selected-key set中去,同时修改key的ready-operation set来表明当前ready的操作,而以前存在于ready-operation set中的信息会被删除。如果对应的key已经存在于selected-key set中,这直接修改其ready-operation set来表明当前ready的操作,删除原来ready-operation set中的信息。
3、如果在第二步中有加入到canceled-key set中的key,在这一步会执行第一步的操作。

selector自身是线程安全的,而他的key set却不是。在一次选择发生的过程中,对于key的关心事件的修改要等到下一次select的时候才会生效。 另外,key和其代表的channel有可能在任何时候被cancel和close。因此存在于key set中的key并不代表其key是有效的,也不代表其channel是open的。如果key有可能被其他的线程取消或关闭channel,程序必须小心的同步检查这些条件。

Reactor

在实际生产环境中,高并发、海量连接的情况下,使用一个线程处理所有的请求是不现实的。单个线程不仅资源利用率低,没有充分发挥服务器多核的特性,而且效率不高,无法及时处理请求。Doug Lea 在《Scalable IO in Java》中提出了一种NIO与多线程结合的方案,Reactor模式。
Java NIO与多线程Reactor模式_第2张图片
单Reactor模式:Reactor接收请求,分发给线程池处理请求。单Reactor模式下,将网络处理和耗时业务处理分开,一定程度上提升了效率。但是网络IO部分的Reactor仍然是单线程的,IO处理仍然会成为性能瓶颈。
Java NIO与多线程Reactor模式_第3张图片
多Reactor模式:将Reactor分为mainReactor和subReactor,mainReactor负责处理连接请求,注册给subReactor,subReactor可以是多个,让subReactor处理IO事件,业务处理仍然放在单独的线程里执行。

BIO与NIO比对

Java NIO与多线程Reactor模式_第4张图片

你可能感兴趣的:(Java并发编程)