目录
Netty基础------NIO 和 AIO
1、基本概念
2、 Java NIO 核心组件
2.1 缓冲区Buffer
2.2 选择器Selector
2.3 通道Channel
3、NIO实例
4、总结(AIO)
5、补充NIO的三种模型
java.nio全称java non-blocking IO(实际上是 new io),是指JDK 1.4 及以上版本里提供的新api(New IO) ,为所有的原始类型(boolean类型除外)提供缓存支持的数据容器,使用它可以提供非阻塞式的高伸缩性网络。
NIO采用内存映射文件的方式来处理输入输出,NIO将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样访问文件了。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同, NIO支持面向缓冲区(Buffer)的、基于通道(Channel)的IO操作。NIO将以更加高效的方式进行文件的读写操作。
从对比中更加了解NIO:
NIO和BIO的区别:
- 面向流与面向缓冲
Java NIO 和 BIO 之间第一个最大的区别是,BIO 是面向流的,NIO 是面向缓冲区的。
Java BIO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的 数据。
- 阻塞与非阻塞
Java BIO 的各种流是阻塞的。这意味着,当一个线程调用 read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
Java NIO 的非阻塞模式,使一个线程从某通道发送请 求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞, 所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到 某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞 IO 的空闲时间用于在其它 通道上执行 IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
- NIO 的选择器(Selector)
Java NIO 的选择器(Selector)允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然 后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制, 使得一个单独的线程很容易来管理多个通道。
而BIO并没有选择器。
在NIO 中有几个核心对象需要掌握:缓冲区(Buffer)、选择器(Selector)、通道(Channel)。
1、什么是Buffer缓存区
缓冲区Buffer是一块连续的内存块,是 NIO 数据读或写的中转地。
为什么说NIO是基于缓冲区的IO方式呢?
因为,当一个链接建立完成后,IO的数据未必会马上到达,为了当数据到达时能够正确完成IO操作,在BIO(阻塞IO)中,等待IO的线程必须被阻塞,以全天候地执行IO操作。为了解决这种IO方式低效的问题,引入了缓冲区的概念,当数据到达时,可以预先被写入缓冲区,再由缓冲区交给线程,因此线程无需阻塞地等待IO。
缓冲区实际上是一个容器对象,更直接的说,其实就是一个数组,在NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的; 在写入数据时,它也是写入到缓冲区中的;任何时候访问NIO 中的数据,都是将它放到缓冲区中。而在面向流I/O 系统中,所有数据都是直接写入或者直接将数据读取到Stream 对象中。在NIO 中,所有的缓冲区类型都继承于抽象类Buffer,最常用的就是ByteBuffer,对于Java 中的基本类型,基本都有一个具体Buffer 类型与之相对应,它们之间的继承关系如下图所示:
2、缓冲区的基本实现
缓冲区对象本质上是一个数组,但它其实是一个特殊的数组,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况,如果我们使用get()方法从缓冲区获取数据或者使用put()方法把数据写入缓冲区,都会引起缓冲区状态的变化。在缓冲区中,最重要的属性有下面三个,它们一起合作完成对缓冲区内部状态的变化跟踪:
- position:指定下一个将要被写入或者读取的元素索引,它的值由get()/put()方法自动更新,在新创建一个Buffer 对象时,position 被初始化为0。
- limit:指定还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
- capacity:指定了可以存储在缓冲区中的最大数据容量,实际上,它指定了底层数组的大小,或者至少是指定了准许我们使用的底层数组的容量。
在对Buffer进行读/写操作前,我们可以调用Buffer类提供的一些辅助方法来正确设置 position 和 limit 的值,主要有如下几个:
- flip(): 设置 limit 为 position 的值,然后 position 置为0。对Buffer进行读取操作前调用。
- rewind(): 仅仅将 position 置0。一般是在重新读取Buffer数据前调用,比如要读取同一个Buffer的数据写入多个通道时会用到。
- clear(): 回到初始状态,即 limit 等于 capacity,position 置0。重新对Buffer进行写入操作前调用。
- compact(): 将未读取完的数据(position 与 limit 之间的数据)移动到缓冲区开头,并将 position 设置为这段数据末尾的下一个位置。其实就等价于重新向缓冲区中写入了这么一段数据。
来看一幅图:
假如我们创建了一个数组大小为10的缓冲区,那么刚开始时,position指向0,limit和capatity指向10;
当我们进行put进去4个大小的数据后,调用flip方法后,就会变成上图,position和limit之间的数据代表着还没有读,读数据时,position向limit移动。
Selector(选择器)是一个特殊的组件,用于采集各个通道的状态(或者说事件)。我们先将通道注册到选择器,并设置好关心的事件,然后就可以通过调用select()方法,静静地等待事件发生。通道有如下4个事件可供我们监听:
- Accept:有可以接受的连接
- Connect:连接成功
- Read:有数据可读
- Write:可以写入数据了
由于如果用阻塞I/O,需要多线程(浪费内存),如果用非阻塞I/O,需要不断重试(耗费CPU)。Selector的出现解决了这尴尬的问题,非阻塞模式下,通过Selector,我们的线程只为已就绪的通道工作,不用盲目的重试了。比如,当所有通道都没有数据到达时,也就没有Read事件发生,我们的线程会在select()方法处被挂起,从而让出了CPU资源。
传统的Server/Client 模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题,都采用了线程池模型,并设置线程池线程的最大数量,这又带来了新的问题,如果线程池中有200 个线程,而有200 个用户都在进行大文件下载,会导致第201个用户的请求无法及时处理,即便第201 个用户只想请求一个几KB 大小的页面。
NIO 中非阻塞I/O 采用了基于Reactor 模式的工作方式,I/O 调用不会被阻塞,相反是注册感兴趣的特定I/O 事件,如可读数据到达,新的套接字连接等等,在发生特定事件时,系统再通知我们。NIO 中实现非阻塞I/O 的核心对象就是Selector,Selector 就是注册各种I/O 事件地方,而且当那些事件发生时,就是这个对象告诉我们所发生的事件,如下图所示:
从图中可以看出,当有读或写等任何注册的事件发生时,可以从Selector 中获得相应的SelectionKey,同时从SelectionKey 中可以找到发生的事件和该事件所发生的具体的SelectableChannel,以获得客户端发送过来的数据。使用NIO 中非阻塞I/O 编写服务器处理程序,大体上可以分为下面三个步骤:
- 向Selector 对象注册感兴趣的事件。
- 从Selector 中获取感兴趣的事件。
- 根据不同的事件进行相应的处理。
Channel: 数据的源头或者数据的目的地 ,用于向 buffer 提供数据或者读取 buffer 数据 ,buffer 对象的唯一接口,异步 I/O 支持。
Buffer作为IO流中数据的缓冲区,而Channel则作为socket的IO流与Buffer的传输通道。客户端socket与服务端socket之间的IO传输不直接把数据交给CPU使用,而是先经过Channel通道把数据保存到Buffer,然后CPU直接从Buffer区读写数据,一次可以读写更多的内容。使用Buffer提高IO效率的原因(这里与IO流里面的BufferedXXStream、BufferedReader、BufferedWriter提高性能的原理一样):IO的耗时主要花在数据传输的路上,普通的IO是一个字节一个字节地传输,而采用了Buffer的话,通过Buffer封装的方法(比如一次读一行,则以行为单位传输而不是一个字节一次进行传输)就可以实现“一大块字节”的传输。比如:IO就是送快递,普通IO是一个快递跑一趟,采用了Buffer的IO就是一车跑一趟。很明显,buffer效率更高,花在传输路上的时间大大缩短。
面向buffer的通道,一个Channel(通道)代表和某一实体的连接,这个实体可以是文件、网络套接字等。也就是说,通道是Java NIO提供的一座桥梁,用于我们的程序和操作系统底层I/O服务进行交互。通道是一种很基本很抽象的描述,和不同的I/O服务交互,执行不同的I/O操作,实现不一样,因此具体的有FileChannel、SocketChannel,ServerSocketChannel,DatagramChannel等。通道使用起来跟Stream比较像,可以读取数据到Buffer中,也可以把Buffer中的数据写入通道。但是channel是双向的,而stream是单向的。
通道是一个对象,通过它可以读取和写入数据,当然了所有数据都通过Buffer 对象来处理。我们永远不会将字节直接写入通道中,相反是将数据写入包含一个或者多个字节的缓冲区。同样不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。在NIO 中,提供了多种通道对象,而所有的通道对象都实现了Channel 接口。
服务端:
package com.NIO;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
/**
* @author: zps
**/
public class NIOServer {
public static void main(String[] args) throws IOException {
//这个方法就相当于创建了一个ServerSocket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
//创建一个selector 选择器
Selector selector = Selector.open();
//这里是需要注意的地方,默认是不阻塞的,所以需要手动设置
serverSocketChannel.configureBlocking(false);
//在选择器中进行注册
serverSocketChannel.register(selector , SelectionKey.OP_ACCEPT);
while (true){
if(selector.select(1000) == 0){
System.out.println("服务器等待了1s,无连接!");
continue;
}
//通过key获取注册到selector的通道
Set selectionKeys = selector.selectedKeys();
Iterator iterator = selectionKeys.iterator();
while (iterator.hasNext()){
SelectionKey key = iterator.next();
//根据key的类型做相应的处理
if(key.isAcceptable()){
SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("客户端连接成功!");
socketChannel.configureBlocking(false);
//将客户端的通道注册到选择器上,并将状态设置为有数据可读
socketChannel.register(selector , SelectionKey.OP_READ , ByteBuffer.allocate(1024));
//继续监听
key.interestOps(SelectionKey.OP_ACCEPT);
}
if(key.isReadable()){
SocketChannel channel = (SocketChannel) key.channel();
//获取该channel关联的buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
channel.read(buffer);
System.out.println("客户端传来消息:" + new String(buffer.array()));
//当然,下面可以继续设置事件的状态
// key.interestOps(SelectionKey.OP_WRITE);
}
iterator.remove();
}
}
}
}
客户端:
package com.NIO;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;
/**
* @author: zps
**/
public class NIOClient {
public static void main(String[] args) throws IOException {
//这个就相当于传统的创建一个Socket
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
InetSocketAddress inetSocketAddress = new InetSocketAddress("localhost" , 8080);
//连接服务器
if(!socketChannel.connect(inetSocketAddress)){
while (!socketChannel.finishConnect()){
System.out.println("等待连接中。。。。。");
}
}
ByteBuffer buffer = null;
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()){
String msg = scanner.nextLine();
buffer = ByteBuffer.wrap(msg.getBytes());
//发送消息
socketChannel.write(buffer);
buffer.clear();
}
}
}
这里主要是对BIO 和 NIO 和 AIO之间的联系与区别做一些小总结:
进程中的 IO 调用步骤大致可以分为以下四步:
- 进程向操作系统请求数据 ;
- 操作系统把外部数据加载到内核的缓冲区中;
- 操作系统把内核的缓冲区拷贝到进程的缓冲区 ;
- 进程获得数据完成自己的功能 ;
Java I/O 的基本概念:
阻塞/非阻塞,针对的对象为 调用者;
同步/异步,针对的对象为 被调用者,同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方;
BIO
同步阻塞 I/O 模式
底层:服务端应用进程通过调用底层操作系统命令
recvfrom
去接收数据,而由于内核数据没有准备好,应用进程就会阻塞,直到内核准备好数据并将其从内核复制到应用进程的缓冲区中或者发生错误才返回;阻塞体现:若多个客户端依此发送 socket 连接请求到服务器,那在服务端处理的时候,只能在处理完第一个 socket 请求后,才能处理下一个客户端的请求,期间下一个客户端只能挂起等待;
同步体现:此时服务端应用进程阻塞在当前连接请求操作上,当处理完第一个客户端的请求后,再返回结果给客户端;
场景:适用于,连接数少的场景下;
优势:实现上比较简单;
劣势:吞吐量低,耗资源,效率不高;
NIO
同步非阻塞 I/O 模式,多路复用,引入了 channel(通道),selector(选择器),buff(缓冲区) 的概念
底层:服务端应用进程通过调用底层操作系统命令
select
去接收数据,多个进程的 I/O 都可以注册在同一个select
上,当用户进程调用该select
时,select
去监听所有注册好的 I/O,如果所有被监听的 I/O 需要的数据都没有准备好,那么select
调用进程会被阻塞;如果有一个进程的 I/O 数据准备好了,select
就返回可读条件,然后调用recvfrom
把所读数据复制到到应用进程缓冲区。非阻塞体现:一个 select 处理多个客户应用进程的 I/O,如果第一个 I/O 数据没有准备好,那么就去处理第二个客户端的 I/O,依此类推,客户端之间谁的数据先准备好就先处理谁的,不存在第二个要等第一个处理完才能开始处理的情况;
同步的体现:此时服务端应用进程也阻塞在当前连接请求操作,当处理完第一个客户端的请求后,再返回结果给客户端;
场景:适用于连接数目多,但短的场景;
优势:可靠性高,性能也高;
劣势:
- 每个进程
select
能监听的 I/O 数目是有限的,默认是 1024 个,故单机处理请求的能力有限;- 由于采用轮询去监听请求的状态,当请求增多时,轮询过程性能开销就会变多;
- 代码实现上也比较复杂
AIO
异步非阻塞 I/O
底层:底层过程同 NIO,区别在于,AIO 使用的命令是
epoll
,使用事件驱动的方式来代替轮询的方式,当监听的 I/O 准备好了,采用事件驱动(事件回调)的方式通知进程去获取数据非阻塞体现:一个 epoll 监听多个客户应用进程的 I/O,当某个 I/O 准备好时,通过事件驱动的方式告知进程可以处理数据了,不存在第二个要等第一个处理完才能开始处理的情况;
异步的体现:服务端会先将 I/O 处理结果返回给客户端(完成或尚未完成),如果尚未完成,服务端会接着处理,而客户端可以做其他的业务处理,在事件回调中再来处理请求业务;
场景:适用于连接数目多且长的场景;
优势:可靠性高,性能高;
1、Reactor模式思想:分而治之+事件驱动
1)分而治之
一个连接里完整的网络处理过程一般分为accept、read、decode、process、encode、send这几步。
Reactor模式将每个步骤映射为一个Task,服务端线程执行的最小逻辑单元不再是一次完整的网络请求,而是Task,且采用非阻塞方式执行。
2)事件驱动
每个Task对应特定网络事件。当Task准备就绪时,Reactor收到对应的网络事件通知,并将Task分发给绑定了对应网络事件的Handler执行。
3)几个角色
Reactor:负责响应事件,将事件分发给绑定了该事件的Handler处理;
Handler:事件处理器,绑定了某类事件,负责执行对应事件的Task对事件进行处理;
Acceptor:Handler的一种,绑定了connect事件。当客户端发起connect请求时,Reactor会将accept事件分发给Acceptor处理。
2、单线程Reactor
单线程reactor
1)优点:
不需要做并发控制,代码实现简单清晰。
2)缺点:
a)不能利用多核CPU;
b)一个线程需要执行处理所有的accept、read、decode、process、encode、send事件,处理成百上千的链路时性能上无法支撑;
c)一旦reactor线程意外跑飞或者进入死循环,会导致整个系统通信模块不可用。
3、多线程Reactor
多线程reactor
特点:
a)有专门一个reactor线程用于监听服务端ServerSocketChannel,接收客户端的TCP连接请求;
b)网络IO的读/写操作等由一个worker reactor线程池负责,由线程池中的NIO线程负责监听SocketChannel事件,进行消息的读取、解码、编码和发送。
c)一个NIO线程可以同时处理N条链路,但是一个链路只注册在一个NIO线程上处理,防止发生并发操作问题。
4、主从多线程
主从多线程reactor
在绝大多数场景下,Reactor多线程模型都可以满足性能需求;但是在极个别特殊场景中,一个NIO线程负责监听和处理所有的客户端连接可能会存在性能问题。
特点:
a)服务端用于接收客户端连接的不再是个1个单独的reactor线程,而是一个boss reactor线程池;
b)服务端启用多个ServerSocketChannel监听不同端口时,每个ServerSocketChannel的监听工作可以由线程池中的一个NIO线程完成。
参考:https://www.jianshu.com/p/38b56531565d