本文目的:把握IO历史车轮的脉搏,知其然(了解NIO,NIO等),知其所以然(为啥会有NIO,为啥这么设计NIO,这么设计的好处是什么)。
一、核心概念的理解
1、Java I/O模型
贯穿了整个java的各种IO的变革历史,各种方案其实本质上都是围绕着这个目的展开的。
-
同步I/O:
每个请求必须逐个地被处理,一个请求的处理会导致整个流程的暂时等待,这些事件无法并发地执行。用户线程发起I/O请求后需要自己去等待或者自己去轮询内核I/O操作完成后才能继续执行。特征是自己去关注处理结果。
-
异步I/O:
多个请求可以并发执行,一个请求或者任务的执行不会导致整个流程的暂时等待。用户线程发起I/O请求后仍然继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。特征是被动的被告知处理结果。
注:POSIX对同步/异步的官方定义如下,有助于我们精准理解概念:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;
-
阻塞:
某个请求发出后,由于该请求操作需要的条件不满足,请求操作一直阻塞,不会返回,直到条件满足。
阶段1:等待数据就绪。网络 I/O 的情况就是等待远端数据陆续抵达;磁盘I/O的情况就是等待磁盘数据从磁盘上读取到内核态内存中。
阶段2:数据拷贝。出于系统安全,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据拷贝一份到用户态内存中。
-
非阻塞:
请求发出后,若该请求需要的条件不满足,则立即返回一个标志信息告知条件不满足,而不会一直等待。一般需要通过循环判断请求条件是否满足来获取请求结果。
socket设置为 NONBLOCK(非阻塞)就是告诉内核,当所请求的I/O操作无法完成时,不要将线程睡眠,而是返回一个错误码(EWOULDBLOCK) ,这样请求就不会阻塞。
I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。整个I/O 请求的过程中,虽然用户线程每次发起I/O请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的 CPU 的资源。
数据准备好了,从内核拷贝到用户空间。
总结:
阻塞并不等价于同步,而非阻塞并非等价于异步。事实上这两组概念描述的是I/O模型中的两个不同维度;
同步和异步:着重点在于多个任务执行过程中,后发起的任务是否必须等先发起的任务完成之后再进行。而不管先发起的任务请求是阻塞等待完成,还是立即返回通过循环等待请求成功;关心的是事件通知的方式(操作系统是否通知应用程序);
阻塞和非阻塞:重点在于请求的方法是否立即返回(或者说是否在条件不满足时被阻塞),也就是说在此期间,自己/当前线程能否做其他事情(能:非阻塞;不能:阻塞)。
举例说明:
有一个商家的可能要搞优惠促销活动。同步:自己一直主动关心有没有活动。异步:商家一旦搞活动了就会去告诉你。阻塞和非阻塞:在此期间,你能不能做其他事情。组合情况如下:
同步阻塞:自己关注活动,在此期间自己不能做任何其他事情。---BIO
同步非阻塞:自己关注活动,在此期间自己能做其他事情。----NIO
异步阻塞:商家告知活动,在此期间自己不能做任何其他事情。 ---比较鸡肋,可以忽略。
异步非阻塞:商家告知活动,在此期间自己能做其他事情。 ---AIO(又称为:NIO2),AJAX
不同的IO模型对比:
Figure 6.1、 阻塞IO模型
Figure 6.2、非阻塞IO模型
Figure 6.3、 IO多路复用模型
Figure 6.4、 信号驱动式IO
Figure 6.5. 异步IO模型
各种IO模型对比
同步IO和异步IO的区别就在于:前者做IO操作时会将进程阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于同步IO。有人会说,non-blocking IO并没有被block啊。 why?
这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个系统调用。non-blocking IO在执行recvfrom这个system call的时候,如果kernel(内核)的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。
问题:非阻塞 IO和同步IO的区别是什么?
答案:区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而异步IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
2、回顾BIO
Java IO <----> BIO <----> Blocking IO <----> 同步阻塞IO
存在的问题:
-
阻塞的问题
-
多线程的时机问题
-
粗放的模式
代码demo:
public class ServerThreadPool {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3);
RequestHandler requestHandler = new RequestHandler();
try (ServerSocket serverSocket = new ServerSocket(8888)) {
System.out.println("NIOServer has started, listening on port:" + serverSocket.getLocalSocketAddress());
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("Connection from " + clientSocket.getRemoteSocketAddress());
//TODO 此处用多线程明显不合理,因为连接上来了不一定就会有请求过来,应该在ClientHandler的run方法的try成功之后做。
executor.submit(new ClientHandler(clientSocket, requestHandler));
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class ClientHandler implements Runnable{
private final Socket clientSocket;
private final RequestHandler requestHandler;
public ClientHandler(Socket clientSocket, RequestHandler requestHandler) {
this.clientSocket = clientSocket;
this.requestHandler = requestHandler;
}
@Override
public void run() {
try (Scanner input = new Scanner(clientSocket.getInputStream())) {
while (true) {
//阻塞的 客户端如果没有数据发送过来,这儿不会执行
String request = input.nextLine();
if ("quit".equals(request)) {
break;
}
//2、Threads 处理
System.out.println(String.format("From %s : %s", clientSocket.getRemoteSocketAddress(), request));
String response = requestHandler.handle(request);
// String response = "From BIOServer Hello " + request + ".\n";
clientSocket.getOutputStream().write(response.getBytes());
}
} catch (IOException e) {
System.out.println("Caught exception: " + e);
throw new RuntimeException(e);
}
}
}
public class RequestHandler {
public String handle(String request) {
return "From BIOServer Hello " + request + ".\n";
}
}
3、如何优化BIO?
优化的本质是找到瓶颈,优化瓶颈,可以发现至少有三点可以优化:
a. 阻塞变非阻塞,有数据才处理,否则别"占着茅坑不拉shi"啊;
b. 读写要从内核空间到用户空间进行切换,能不能进行共享呢;
c. 现在的读写是分别从socket里获取输入、输出流(单向模式),能不能有双向的呢(减少一倍的开销)。
通过上面的分析,一种新的IO模式(NIO)就呼之欲出了,它的三板斧 (selector、channel、buffer)就是对应上面三点优化。
用大白话讲也就是例如下面这几点(不限于这几点,还有其他很多点)的优化:
- 一旦建立连接,就注册起来
- 发生请求时候再去多线程取出注册信息(得知是哪一个Socket连接)并执行业务处理,延迟并减少了多线程的负担(开发人员不高兴了,为了提升Socket的IO效率,平白无故的带来了是多线程的学习成本和出bug的概率,所以jdk自己又搞了一套NIO来解决这个问题)
- 多线程的时机优化:在入口处多线程来提升轮询注册信息的效率,在处理业务代码用多线程来充分利用多核CPU。
4、救(JDK)命良药之NIO
NIO<----> new IO <----> Non-Blocking IO <----> 同步非阻塞IO
- Selector:注册机制,便于延迟的处理,减轻多线程处理压力(for循环 ----> selector)
- 数据交互优化----Buffer:充当着java IO和OS(操作系统)之间的数据交互的桥梁
- 阻塞IO变成非阻塞IO
- channel:客户端与服务端之间提供了一个通道(多路复用技术)---连接建立的方式和数据交互的方式(注册有若干个socket,windows系统的话底层是使用selector但是有总数的限制还要循环遍历fd_set性能较差是,修改mask值也要遍历寻找性能差一些。Linux/unix底层使用的是epoll性能最高。)
- SelectionKey:想象成Selector去取出注册的ServerSocketChannel【其实是将channel信息封装在该对象中】
总结:
-
Main线程可以同时完成所有的读取和写入操作,则说明是非阻塞的。
-
I/O 多路复用:当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符/套接字描述符,而这些描述符其中的任意一个进入读就绪状态,select()函数就可以返回。
-
缓冲区的逻辑:
创建Heap Buffer: ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
创建Direct Buffer: ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
-
两处线程池:为了充分利用多核处理效率,与之前BIO不得已而为之不一样(代码演示的时候是main线程处理足矣,没有使用多线程)。【Reactor模型】
第一处:处理更多的入口处的selector的轮询操作。
第二处:处理业务代码
实战demo如下:
public class NIOServer {
public static void main(String[] args) throws IOException {
//获取代表服务端的channel(非阻塞的)
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(9999));
System.out.println("NIO NIOServer has started, listening on port:" + serverChannel.getLocalAddress());
//获取注册机并注册进去该socket的连接
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(1024);
RequestHandler requestHandler = new RequestHandler();
//不断监听channel状态的改变 accept read write
while (true) {
/**
* TODO 最后我们注意一下:其实这儿会阻塞(轮询连接的时候)和之前socket.accept()异曲同工;
* 这儿可以搞一个ThreadPool(即BossThreadPool),Reactor模型(bossThreadPool + workerThreadPool)
*/
int select = selector.select();
if (select == 0) {
continue;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
//SelectionKey ServerSocketChannel SocketChannel
SelectionKey key = iterator.next();
if (key.isAcceptable()) {
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = channel.accept();
System.out.println("Connection from " + clientChannel.getRemoteAddress());
clientChannel.configureBlocking(false);
//通过register改变channel要进行的操作
clientChannel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()) {
//new Thread(){...}
SocketChannel channel = (SocketChannel) key.channel();
channel.read(buffer);
String request = new String(buffer.array()).trim();
buffer.clear();
System.out.println(String.format("From %s : %s", channel.getRemoteAddress(), request));
/**
* TODO 假设要处理十几秒 业务代码层面 ---> 可以使用多线程,不是因为IO是阻塞的,只是为了充分利用多核cpu,提升处理IO的效率
* BIO 多线程 1个socket/request 对应 1个thread
* NIO 多线程 单线程就可以对应的处理好很多个request
* 这儿可以搞一个ThreadPool(即workerThreadPool),Reactor模型(bossThreadPool + workerThreadPool)
*/
String response = requestHandler.handle(request);
channel.write(ByteBuffer.wrap(response.getBytes()));
}
iterator.remove();
}
}
}
}
思考题1:基于NIO封装出来的netty框架为何会划分在分布式的范畴里?
答案:分布式的高并发场景下,数据需要交互,原有的BIO不能满足要求,才会用到底层是高性能NIO的RPC通信的Netty框架。
思考题2:select的缺陷是什么?
单个进程能够监视的描述符的数量限制 + 大量连接无读写时无谓的轮询遍历导致效率容易线性下降。
5、NIO的封装者之Netty
“黔无驴,有好事者船载以入。”世上从来不缺少此类聪明绝顶的热心肠的“好事”之人,所以我们只需要站在“好事”之人的肩膀上加以学习和利用。
其实tomcat中也是使用到了多种IO技术,例如早期只支持BIO,后面又支持了NIO,NIO2(AIO)等,因为未显式对外暴露API接口。Tomcat中使用NIO的思路可以参考 4、救(JDK)命良药之NIO 的demo。
上述的NIO的demo中可以看出,写起来还是挺繁琐的,所以定然有好事者去封装NIO以提供更好使的API,其中典型的就是Netty和Mina了。废话不多说,看Netty的demo代码:
public class NettyServer {
private void bind(int port) {
//好多都是模板代码,照抄即可。核心的业务方面的主要在initChannel方法中
NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//服务端辅助启动类,用以降低服务端的开发复杂度
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
//实例化ServerSocketChannel
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
//可以做很多事情,例如编码解码工作,序列化工作,自定义的处理业务的类的工作等等
// pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(Integer.MAX_VALUE, 0, 1111));
// pipeline.addLast("frameEncoder", new LengthFieldPrepender(4));
// pipeline.addLast("encoder", new ObjectEncoder());
// pipeline.addLast("decoder",new ObjectDecoder(Integer.MAX_VALUE, ClassResolvers.softCachingResolver(this.getClass().getClassLoader())));
//业务代码的这个层面就行了 SpringMVC
pipeline.addLast(new MyServerHandler());
}
});
// ChannelFuture:代表异步I/O的结果 开启监听
ChannelFuture f = b.bind(new InetSocketAddress(port)).sync();
f.channel().closeFuture().sync();
} catch (Exception e) {
System.out.println("启动netty服务异常");
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
public static void main(String[] args) {
int port = 5555;
new NettyServer().bind(port);
}
}
public class MyServerHandler extends ChannelInboundHandlerAdapter {
//ChannelHandlerContext用于写数据, msg用于读数据
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//读取服务端发过来的数据
ByteBuf buf = (ByteBuf)msg;
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String message = new String(bytes, "UTF-8");
System.out.println("From server : " + message);
//向客户端写数据
String response = "\nFrom client: ";
ByteBuf buffer = Unpooled.copiedBuffer(response.getBytes());
ctx.write(buffer);//写入缓冲数组
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.flush();//将缓冲区数据写入SocketChannel
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("Exception! exceptionCaught..." + cause);
}
}
参考:
https://blog.csdn.net/baiye_xing/article/details/74331041
https://juejin.im/post/5bd32b84f265da0ac962e7c9
原创不易,请勿转载和抄袭,欢迎随时交流。O(∩_∩)O~