目录
前言
高并发量引起的问题。
一个使用传统阻塞I/O的系统,如果还是使用传统的一个请求对应一个线程这种模式,一旦有高并发的大量请求,就会有如下问题:
- 线程不够用, 就算使用了线程池复用线程也无济于事;
- 阻塞I/O模式下,会有大量的线程被阻塞,一直在等待数据,这个时候的线程被挂起,只能干等,CPU利用率很低,换句话说,系统的吞吐量差;
- 如果网络I/O堵塞或者有网络抖动或者网络故障等,线程的阻塞时间可能很长。整个系统也变的不可靠;
什么是NIO
java.nio全称java non-blocking IO(实际上是 new io),是指JDK 1.4 及以上版本里提供的新api(New IO) ,为所有的原始类型(boolean类型除外)提供缓存支持的数据容器,使用它可以提供非阻塞式的高伸缩性网络。
HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。
下面是从知乎 https://zhuanlan.zhihu.com/p/62260286 抄过来的一段话。
要搞清楚什么是NIO,要先搞清楚什么是BIO,即阻塞IO,看一段代码:
ServerSocket serverSocket = new ServerSocket(port);
OutputStream os = null;
InputStream is = null;
try {
Socket socket = serverSocket.accept();
is = socket.getInputStream();
byte[] b = new byte[1024];
int n = is.read(b);
os = socket.getOutputStream();
os.write(b, 0, n);
}catch (Exception e){
}finally {
is.close();
os.close();
}
上面的代码中有两处阻塞的地方,一个是accept函数会调用到一个native方法accept0(nativefd, isaa);直到建立新的tcp连接。另外一处是read函数在没有消息的时候会一直阻塞,直到接收到新的消息。java在1.4以前没有NIO的时候处理网络消息的办法就只能是每次有一个新的连接,开启一个新的线程,或者从线程池中取出一个线程,这个线程执行的逻辑是使用一个while循环来不断接收消息,接收到消息以后处理消息或者加入消息队列交给其他线程处理。即:
new Thread(()->{
while (true){
is = socket.getInputStream();
byte[] b = new byte[1024];
int len = is.read(b);
//向客户端发送反馈内容
os = socket.getOutputStream();
os.write(b, 0, len);
}
});
现在来到了我们的NIO,NIO在Linux上使用了epoll这个系统调用,epoll能够做到已注册的连接在消息到来的时候主动通知调度器,将消息加入消息队列。这个调度器就是java NIO的Selector,Selector的select设定一个超时时间,获取消息队列中的消息然后分发给工作线程异步进行解码等操作。
NIO将上面的代码分给了至少三个线程去完成,一个处理连接的线程,一个消息调度线程,以及至少一个工作线程。NIO降低了任务的粒度并且避免阻塞io中大量的线程阻塞占用过多的内存。实际代码中,工作线程们不能直接处理占用过多cpu时间的操作,应当将处理好的数据放入消息队列或者直接交给异步线程池来处理,否则会阻塞后续消息的接收,又回到了阻塞io的问题上。
NIO,即非阻塞io最重要的其实就是解决了read函数的阻塞问题,然后分离读消息与处理消息(类似与业务中专门使用sql处理线程池异步处理sql相关的处理)减少大量的线程占用。
IO和NIO的区别
原有的 IO 是面向流的、阻塞的,NIO 则是面向块的、非阻塞的。
怎么理解IO是面向流的、阻塞的?
java1.4以前的io模型,一连接对一个线程。
原始的IO是面向流的,不存在缓存的概念。Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区
Java IO的各种流是阻塞的,这意味着当一个线程调用read或 write方法时,该线程被阻塞,直到有一些数据被读取,或数据完全写入,该线程在此期间不能再干任何事情了。
怎么理解NIO是面向块的、非阻塞的?
NIO是面向缓冲区的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性。
Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个。
NIO的核心实现
在标准IO API中,你可以操作字节流和字符流,但在新IO中,你可以操作通道和缓冲,数据总是从通道被读取到缓冲中或者从缓冲写入到通道中。
NIO核心API Channel, Buffer, Selector 。
通道Channel
NIO的通道类似于流,但有些区别如下:
- 通道可以同时进行读写,而流只能读或者只能写;
- 通道可以实现异步读写数据;
- 通道可以从缓冲读数据,也可以写数据到缓冲:;
可以从通道读取数据到缓冲区,也可以把缓冲区的数据写到通道中。
缓存Buffer
缓冲区本质上是一个可以写入数据的内存块,然后可以再次读取,该对象提供了一组方法,可以更轻松地使用内存块,使用缓冲区读取和写入数据通常遵循以下四个步骤:
- 写数据到缓冲区;
- 调用buffer.flip()方法;
- 从缓冲区中读取数据;
- 调用buffer.clear()或buffer.compat()方法;
当向buffer写入数据时,buffer会记录下写了多少数据,一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式,在读模式下可以读取之前写入到buffer的所有数据,一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。
Buffer在与Channel交互时,需要一些标志:
buffer的大小/容量 - Capacity
作为一个内存块,Buffer有一个固定的大小值,用参数capacity表示。
当前读/写的位置 - Position
当写数据到缓冲时,position表示当前待写入的位置,position最大可为capacity – 1;当从缓冲读取数据时,position表示从当前位置读取。
信息末尾的位置 - limit
在写模式下,缓冲区的limit表示你最多能往Buffer里写多少数据; 写模式下,limit等于Buffer的capacity,意味着你还能从缓冲区获取多少数据。
下图展示了buffer中三个关键属性capacity,position以及limit在读写模式中的说明:
缓冲区常用的操作
向缓冲区写数据:
- 从Channel写到Buffer;
- 通过Buffer的put方法写到Buffer中;
int bytesRead = inChannel.read(buf); //read into buffer.
buf.put(127);
从缓冲区读取数据:
- 从Buffer中读取数据到Channel;
- 通过Buffer的get方法从Buffer中读取数据;
//read from buffer into channel.
int bytesWritten = inChannel.write(buf);
byte aByte = buf.get();
flip方法:
flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
rewind()方法:
Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。
clear方法 vs compact方法:
clear()方法会清空整个缓冲区。
compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
mark()与reset()方法:
通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。例如:
buffer.mark();
//call buffer.get() a couple of times, e.g. during parsing.
buffer.reset(); //set position back to mark.
Scatter/Gather :
分散(scatter)从Channel中读取是指在读操作时将读取的数据写入多个buffer中。因此,Channel将从Channel中读取的数据“分散(scatter)”到多个Buffer中。
聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel。
ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
// channel.read(bufferArray);
// channel.write(bufferArray);
Selector
一个Selector组件,可以监测多个NIO channel,看看读或者写事件是否就绪。多个NIO channel 以事件的方式注册到同一个Selector,从而达到用一个线程处理多个请求成为可能。
要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新连接进来,数据接收等。
一个thread对应多个channel,一个channel处理一个请求。
当你调用Selector的select()或者 selectNow() 方法它只会返回有数据读取的SelectableChannel的实例。