JavaIO演进之路
IO基础入门
Linux 网络IO模型简介
linux内核把所有的外部设备都看做一个文件,对一个文件的读写会调用内核提供的系统命令,返回一个file descripter(fd,文件描述符),对一个Socket的读写也会有相应的描述符,称为sockerfd(socket描述符)
根据UNIX对IO模型的分类,UNIX提供了5种I/O模型,分别如下:
阻塞IO模型,最常用的IO模型就是阻塞IO模型,缺省情况下所有的文件操作都是阻塞的,系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才会返回,在此期间会一直等待
非阻塞IO模型,如果缓冲区没有数据就直接返回一个错误,一般都对非阻塞IO轮训这个状态,看是不是有数据到来
I/O复用模型:Linux提供select/poll,进程通过一个或多个fd传递给select/poll调用,阻塞在select状态上,这样select/poll就可以帮我们侦测多个fd是否处于就绪状态。select/poll是轮询扫描fd是否就绪。linux还提供一个epoll系统调用,epoll使用事件驱动的方式代替扫描,因此效率更高。当有fd就绪就立刻回调callback
信号驱动I/O,首先开启套接口信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数 (此系统调用立即返回,进程立即工作,它是非阻塞的)。当数据准备就绪时,就为该进程准备一个SIGIO信号,回调应用程序读取数据并且通知主循环处理数据
异步I/O:告知内核启动某个操作,并让内核在整个操作完成后通知我们
I/O多路复用技术
在I/O编程过程中需要处理多个客户端接入请求,可以利用多线程或者I/O多路复用技术来处理,I/O多路复用通过把多个I/O的阻塞复用到同一个select的阻塞上,进而使系统在单线程的情况下可以处理多个客户端请求,与传统的线程或者多线程相比,I/O多路复用最大的优势使系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程或者线程的运行,降低了系统的维护工作量,节省了系统资源,I/O多路复用的主要应用场景:
- 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字
- 服务器需要同时处理多种网络协议的套接字
目前支持I/O多路复用的系统调用有select、pselect、poll、epoll,在Linux网络编程过程中,很长一段时间都使用select做轮询和网络事件通知,然而select的一些固有缺陷导致了他的应用受到了很大的限制,最终Linux不得不在新的内核版本中寻找select的替代方案,最终选择了epoll。epoll和select的原理比较类似,为了克服select的缺点,epoll做出了很多重大改进,现总结如下:
- 支持一个进程打开的socket描述符fd不受限制,仅受限于操作系统的最大文件句柄数
select最大的缺陷就是单个进程打开的fd是有一定限制的,它由fd——setsize设置,默认是1024,对于那些需哟啊支持上网额TCP连接的大型服务器显然太少了,可以选择修改这个宏然后重新编译内核,不过这会带来网络效率的下降。我们也可以选择多进程的方案(传统的apache)来解决这个问题,不过虽然在linux上创建进程的代价比较小,但是也是不可忽视的。另外,进程之间的数据交换非常麻烦,对于Java来说,由于没有共享内存,需要通过Socket通信或者其他方式进行数据同步,这带来了额外的性能损耗,增加了程序复杂度,所以也不是一种完美的解决方案,值得庆幸的是,epoll并没有这个限制,它所支持的FD上限是操作系统的最大文件句柄数,这个数字远远大于1024.例如,在1G 内存的机器上大约是10万个句柄左右,具体的值可以通过cat/proc/sys/fs/file-max查看,通常情况下跟内存的关系较大
I/O效率不会随着fd数目的增加而线性下降
传统select/poll的另一个致命缺点就是当你有一个很大的socket集合时,由于网络延迟或者链路空闲,任意时刻只有少部分的socker是活跃的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降,epoll不存在这个问题,它只会对活跃的socket进行操作,这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有活跃的socket才会去主动调用callback函数,其他idle状态的socket则不会。在这一点上,epoll实现了一个伪AIO。针对epoll和select的benchmark测试表明,如果所有的socket都处于活跃态--李儒一个告诉LAN环境,epoll并不比select效率高很多,相反如果过多使用epoll_ctl,效率还有稍微降低,但是一点使用idle connection模拟WAN环境,epoll的效率救援在select/poll之上了使用mapp加速内核和用户空间之间的消息传递
epoll的API更简单
包括创建一个epoll描述符,添加监听事件,阻塞等待的监听事件的发生、关闭epoll描述符等等
epoll是Linux系统下用来克服select/poll缺点的方法
JavaNIO历史
Java1.4 NIO1.0 Java1.7NIO2.0
1.0的主要问题是:
- 没有统一的文件属性
- API能力比较弱不能实现目录的级联创建和递归遍历
- 底层存储的一些高级API不能使用
- 所有文件的操作都是同步阻塞调用,不支持异步文件读写
NIO入门
传统的BIO编程
通常有一个独立的Accepter线程负责监听客户端的连接,它接受到客户端连接之后伟每一个客户端创建一个新的线程来进行链路处理。处理完成后通过输出六返回应答给客户端
问题就是缺乏弹性伸缩的能力,当客户端并发访问量增加之后,服务端的线程个数喝客户端并发数成一比一的正比关系,线程数膨胀后,性能将急剧下降
public class TimeServerHandler implements Runnable{
private Socket socket;
public TimeServerHandler(Socket socket){
this.socket = socket;
}
@Override
public void run(){
BufferdReader in = null;
PrintWriter out = null;
try{
in = new BufferedReader(new InputStreamReader(this.socket.getInputStream());
out = new PrintWriter(this.socket.getOutputStream, true);
String currentTime = null;
String body = null;
while(true){
body = in.readLine();
if(body == null) break;
System.out.println("The time server receive order : " + body);
currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date()
}
}catch (Exception e){
if(in != null){
try{
in.close();
}catch(IOException e1){
e1.printStackTrace();
}
}
if(out != null){
out.close();
out = null;
}
if(this.socket != null){
try{
this.socket.close();
}catch(IOException e1){
e1.printStackTrace();
}
this.socket = null;
}
}
}
}
NIO类库介绍
NIO弥补了原来同步阻塞I/O的不足,他在标准Java代码中提供了高速的,面向块的I/O。通过定义包含数据的类,以及通过以块的形式处理这些数据,NIO不哟就那个使用本机代码就可以利用第几优化,这是原来的I/O包无法做到的。
缓冲区Buffer
Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO类库中,所有数据都使用缓冲区来处理的。在读取数据时,它是直接读到缓冲区中的,在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是用过缓冲区进行操作。
缓冲区实质上是一个数组。通常他是一个字节数组ByteBuffer,也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置等信息。
最常用的缓冲区是ByteBuffer,除了ByteBuffer还有其他的一些缓冲区。实际上,每一种Java基本类型(除了Boolean)都对应一种缓冲区:
ByteBuffer、CharBuffer、IntBuffer...
每一个Buffer类都是Buffer接口的一个子实例。除了ByteBuffer,每一个Buffer类都有完全一样的操作,大部分标准I/O操作都使用ByteBuffer,所以他在具有一般缓冲区的操作之外还提供了一些特有的操作,以方便网络读写。
通道Channel
Channel是一个通道,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上流动(一个流必须是InputSteram或者OutputStream的子类)而通道可以用于读写、或者二者同时进行
因为Channel是全双工的,所以它可以比流更好的映射底层操作系统的API。特别是在UNIX网络编程模型中,底层系统的通道都是全双工的,同时支持读写操作。
Channel可以分为两大类:用于网络读写的SelectableChannel和用于文件操作的FileChannel
本书涉及的ServerSocketChannel 和 SocketChannel的子类
多路复用 Selector
多路复用器Select是NIO编程的基础。多路复用器提供选择已经就绪任务的能力。简单来讲,selector会不断轮询注册在其上的Channel,如果某个Channel上面发生读或者写时间,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
一个多路复用器可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现,所以他没有最大连接数的限制,这也就意味着只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端,这确实是个非常大的进步。
NIO服务端步骤
- 打开ServerSocketChannel,用于监听客户端连接,是所有客户端连接的父管道
ServerSocketChannel acceptorSvr = ServerSocketChannel.open();
- 绑定过监听端口,设置连接为非阻塞模式
acceptorSvr.socket().bind(new InetSocketAddress(InetAddress.getByName("IP"), port));
acceptorSvr.configureBlocking(false);
- 创建Reactor线程,创建多路复用器并且启动线程
Selector selector = Selector.open();
New Thread(new ReactorTask()).start());
- 将ServerSocketChannel注册到Reactor线程的多路复用器Selector上,监听ACCEPT事件:
SelectionKey key = accptorSvr.register( selector, SelectionKey.OP_ACCEPT, iohandler);
- 多路复用器在线程run方法的无限循环体内轮询准备就绪的key:
int num = selector.select();
Set sekectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while(it.hasNext()){
SelectionKey key = (SekectionKey)it.next();
//...deal with I/O event...
}
- 多路复用器监听到有新的客户端接入,处理新的接入请求,完成TCP三次握手,建立物理链路
SocketChannel channel = svrChannel.accept();
- 设置客户端链路为非阻塞模式
channel.configureBlocking(false);
channel.socket().setReuseAddress(true);
...
- 将新接入的客户端连接注册到reactor线程的多路复用器上,监听读操作,读取客户端发送的网络消息
SelectionKey key = socketChannel.register( selector, SelectionKey.OP_READ, ioHandler);
- 异步读取客户端请求消息到缓冲区
int readNumber = channel.read(reaceivedBuffer);
- 对ByteBuffer进行编解码,如果有半包消息指针reset,继续读取后续的报文,将解码成功的消息封装成Task,投递到业务线程池中,进行业务逻辑编排
Object message = null;
while(buffer.hashRemain()){
byteBuffer.mark();
Object message = decode(byteBuffer);
if(message == null){
byteBuffer.reset();
break;
}
messageList.add(message);
}
if(!byteBuffer.hasRemain()){
byteBuffer.clear();
}else{
byteBuffer.compact();
}
if(messageList != null & !messageList.isEmpty()){
for(Object messageE : messageList){
handlerTask(messageE);
}
}
- 将POJP对象encode成ByteBuffer,调用SocketChannel的异步write接口,将消息异步发送给客户端
socketChannel.write(buffer);
简单描述NIO过程:
- 把ServerSocketChannel注册到选择器中,绑定accept事件。当触发accept事件时,就可以获取到SocketChannel
- 接收请求参数的多路复用是吧SocketChannel注册到选择器中,绑定read事件,当选择器检测到相应的通道准备好进行读取,就触发read事件,通过相应的SocketChannel读取数据然后处理数据
- 处理完成后,再把SocketChannel绑定到一个新的选择器中,绑定wirte事件,等可以写时,就把数据发送出去。