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差不多
发表评论