Linux 的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor ( fd,文件描述符)。而对一个socket 的读写也会有相应的描述符,称为socketfd ( socket 描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径,数据区等一些属性)。
根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型,分别如下。
同步
发送一个请求,等待返回,再发送下一个请求,同步可以避免出现死锁,脏读的发生。
异步
发送一个请求,不等待返回,随时可以再发送下一个请求,可以提高效率,保证并发。
阻塞
传统的IO流都是阻塞式的。也就是说,当一个线程调用read()或者write()方法时,该线程将被阻塞,直到有一些数据读读取或者被写入,在此期间,该线程不能执行其他任何任务。在完成网络通信进行IO操作时,由于线程会阻塞,所以服务器端必须为每个客户端都提供一个独立的线程进行处理,当服务器端需要处理大量的客户端时,性能急剧下降。
非阻塞
JavaNIO是非阻塞式的。当线程从某通道进行读写数据时,若没有数据可用时,该线程会去执行其他任务。线程通常将非阻塞IO的空闲时间用于在其他通道上执行IO操作,所以单独的线程可以管理多个输入和输出通道。因此NIO可以让服务器端使用一个或有限几个线程来同时处理连接到服务器端的所有客户端。
BIO(Blocking I O) 同步阻塞模型,一个线程对应一个客户端连接。数据的读取写入必须阻塞在一个线程内等待其完成。举个例子三个服务员等待三个客户给客户添加茶水,茶水充足是三个服务员什么也不做
应用场景:
BIO 方式适用于连接数目比较小且固定的架构, 这种方式对服务器资源要求比较高, 但程序简单易理解。
本文重点介绍NIO,NIO有人称之为New I/O,原因在于它相对于之前的I/O类库是新增的。这是它的官方叫法。但是,由于之前老的IO类库是阻塞IO,New I/O类库的目标就是要让Java支持非阻塞IO,所以,更多的人喜欢称之为非阻塞I/O (Non-block I/O),同步非阻塞I/O更能够体现NIO 的特点。
那么什么叫做同步非阻塞?上面的服务员只需要一个就可以了,一个服务员不断轮训查看顾客的茶水是否充足即可
1 缓冲区buffer
Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO类库中加入 Buffer对象,体现了新库与原I/O的一个重要区别。在面向流的IO中,可以将数据直接写入或者将数据直接读到Stream对象中。
在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作
。
缓冲区实质上是一个数组。通常它是一个字节数组(ByteBuffer ),也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置( limit)等信息。
最常用的缓冲区是ByteBuffer,一个 ByteBuffer提供了一组功能用于操作byte数组。除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种Java基本类型(除了Boolean类型)都对应有一种缓冲区,具体如下。
ByteBuffer:字节缓冲区
CharBuffer:字符缓冲区
ShortBuffer:短整型缓冲区
IntBuffer:整型缓冲区
LongBuffer:长整型缓冲区
FloatBuffer:浮点型缓冲区
DoubleBuffer:双精度浮点型缓冲区
每一个Buffer类都是Buffer接口的一个子实例。除了ByteBuffer,每一个 Buffer类都有完全一样的操作,只是它们所处理的数据类型不一样。因为大多数标准IO操作都使用ByteBuffer,所以它在具有一般缓冲区的操作之外还提供了一些特有的操作,以方便网络读写。
2 通道Channel
Channel是一个通道,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream或者OutputStream的子类),而通道可以用于读、写或者二者同时进行
。
因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。特别是在UNIX网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。
Channel可以分为两大类:用于网络读写的SelectableChannel和用于文件操作的FileChannel。
Channel有四种实现:
FileChannel:是从文件中读取数据。
DatagramChannel:从UDP网络中读取或者写入数据。
SocketChannel:从TCP网络中读取或者写入数据。
ServerSocketChannel:允许你监听来自TCP的连接,就像服务器一样。每一个连接都会有一个SocketChannel产生。
3 多路复用器Selector
它是Java NIO编程的基础,熟练地掌握Selector对于NIO编程至关重要。多路复用器提供选择已经就绪的任务的能力
。简单来讲,Selector会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个 Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
一个多路复用器Selector可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现,所以它并没有最大连接句柄1024/2048的限制
。这也就意味着只需要一个线程负责Selector 的轮询,就可以接入成千上万的客户端,这确实是个非常巨大的进步。
I/O多路复用底层一般用的Linux API(select,poll,epoll)来实现
如果是windows系统,则底层使用WindowsSelectorProvider(select)实现多路复用;如果是linux,则使用epoll
Selector:选择器对象,通道注册、通道监听对象和Selector相关。
SelectorKey:通道监听关键字,通过它来监听通道状态。
监听注册在Selector
socketChannel.register(selector, SelectionKey.OP_READ);
监听的事件有
OP_ACCEPT: 接收就绪,serviceSocketChannel使用的
OP_READ: 读取就绪,socketChannel使用
OP_WRITE: 写入就绪,socketChannel使用
OP_CONNECT: 连接就绪,socketChannel使用
public class NIOServer {
private static int port = 9000;
public static void main(String[] args) throws IOException {
// 打开ServerSocketChannel,监听客户端的连接,是所有客户端连接的父管道
ServerSocketChannel ssc = ServerSocketChannel.open();
// 设置TCP协议连接 三次握手非阻塞模式 绑定监听端口
ssc.configureBlocking(false);
ssc.socket().bind(new InetSocketAddress(port));
// 底层: 通过open()方法找到Selector 开启epoll,为当前socket服务创建epoll服务,epoll_create
Selector selector = Selector.open();
// 注册到selector,等待连接(监听ACCEPT事件)
/** SelectionKey.OP_ACCEPT
* SelectionKey.OP_ACCEPT —— 接收连接就绪事件,表示服务器监听到了客户连接
* SelectionKey.OP_CONNECT —— 连接就绪事件,表示客户与服务器的连接已经建立就绪
* SelectionKey.OP_READ —— 读就绪事件,表示通道中已经有了可读的数据,可以执行读操作
* SelectionKey.OP_WRITE —— 写就绪事件,表示已经可以向通道写数据了(通道目前可以用于写操作)
*/
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
/**
* 轮询事件监听 阻塞的方法
*/
int select = selector.select();
// tcp 数据
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
//删除本次已处理的key,防止下次select重复处理
it.remove();
handle(key);
}
}
}
private static void handle(SelectionKey key) throws IOException {
if (key.isAcceptable()) {
System.out.println("表示:现在有客户端与我建立三次握手;");
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
//多路复用器监测到有新的客服端接入,完成TCP三次握手,建立物理链路
SocketChannel sc = ssc.accept();
// 数据读取通道设置非阻塞的模式
sc.configureBlocking(false);
//将新接入的客服端连接注册到多路复用器,监听读操作,读取客服端发送的网络消息
sc.register(key.selector(), SelectionKey.OP_READ);
} else if (key.isReadable()) {
System.out.println("表示:客户端发送数据给服务器端;");
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
//读取客服端数据缓冲区
int len = sc.read(buffer);
if (len != -1) {
System.out.println(Thread.currentThread().getName() + "读取到客户端发送的数据:" + new String(buffer.array(), 0, len));
}
//将pojo对象encode成bytebuffer,调用socketchannel的异步write接口,将消息发送给客服端
ByteBuffer bufferToWrite = ByteBuffer.wrap("6666".getBytes());
sc.write(bufferToWrite);
}
}
总结:服务器实现模式为一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到 多路复用器selector上(也被称为选择器),多路复用器轮询到连接有IO请求就进行处理。
应用场景:用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂, JDK1.4 开始支持
AIO(NIO 2.0) 异步非阻塞,
由操作系统完成后回调通知服务端程序启动线程去处理
, 一般适用于连接数较多且连接时间较长的应用。是在NIO的基础上进一步封装的。无需一个线程去轮询所有IO操作的状态改变,在相应的状态改变后,系统会通知对应的线程来处理,还用上面的添加茶水的例子,茶杯上放一个传感器,茶水不足时自动通知服务员即可
应用场景:
AIO方式适用于连接数目多且连接比较长(重操作) 的架构,JDK7 开始支持
NIO类库支持非阻塞读和写操作,相比于之前的同步阻塞读和写,它是异步的,因此很多人习惯于称NIO为异步非阻塞I/O,包括很多介绍NIO编程的书籍也沿用了这个说法。从下图来看其实是同步非阻塞的I/O。
以上伪异步I/O是来源于《Netty权威指南》一书,官方并没有这个说法,在JDK NIO编程没有流行之前,为了解决 Tomcat通信线程同步IO导致业务线程被挂住的问题,大家想到了一个办法:在通信线程和业务线程之间做个缓冲区,这个缓冲区用于隔离IO线程和业务线程间的直接访问,这样业务线程就不会被IO线程阻塞。而对于后端的业务侧来说,将消息或者Task放到线程池后就返回了,不再直接访问I/O线程或者进行I/O读写,这样也就不会被同步阻塞了
Netty是一个高性能、异步事件驱动的NIO框架,它提供了对TCP、UDP和文件传输的支持,作为一个异步NIO框架,Netty的所有IO操作都是异步非阻塞的
,通过Future-Listener机制,用户可以方便的主动获取或者通过通知机制获得IO操作结果。
1.传统的NIO 的类库和 API 繁杂, 使用麻烦: 需要熟练掌握Selector、 ServerSocketChannel、 SocketChannel、 ByteBuffer等。
2 需要具备其他的额外技能做铺垫,例如熟悉 Java多线程编程。这是因为NIO编程涉及到Reactor模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的NIO程序。
3 可靠性能力补齐,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等问题,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐的工作量和难度都非常大。
4 JDK NIO的 BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK 1.6版本的update18修复了该问题,但是直到JDK 1.7版本该问题仍旧存在,只不过该BUG发生概率降低了一些而已,它并没有得到根本性解决。
5.Netty 对 JDK 自带的 NIO 的 API 进行了良好的封装,解决了上述问题。且Netty拥有高性能、 高吞吐量,延迟更低,资源消耗减少,最小化不必要的内存复制等优点。
原因:在Linux系统上,AIO的底层实现仍使用EPOLL,与NIO相同,因此在性能上没有明显的优势;Windows的AIO底层实现良好,但是Netty开发人员并没有把Windows作为主要使用平台考虑。
下面我们用netty修改上面NIO的代码
public class NettyServer {
public void bind(int port) throws Exception {
/**
* Netty 抽象出两组线程池BossGroup和WorkerGroup
* BossGroup专门负责接收客户端的连接, WorkerGroup专门负责网络的读写。实际上他们就是reactor线程组
*/
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
//ServerBootstrap 是Netty用于启动NIO服务端的辅助启动类,目的是降低服务端的开发复杂度
ServerBootstrap bootstrap = new ServerBootstrap();
try {
bootstrap.group(bossGroup, workerGroup)
// 设定NioServerSocketChannel 为服务器端
.channel(NioServerSocketChannel.class)
//BACKLOG用于构造服务端套接字ServerSocket对象,标识当服务器请求处理线程全满时,
//用于临时存放已完成三次握手的请求的队列的最大长度。如果未设置或所设置的值小于1,Java将使用默认值50。
.option(ChannelOption.SO_BACKLOG, 100)
// 服务器端监听数据回调Handler
.childHandler(new ChildChannelHandler());
//绑定端口, 同步等待绑定成功;future主要用于异步操作的通知回调
ChannelFuture future = bootstrap.bind(port).sync();
System.out.println("当前服务器端启动成功...");
//阻塞等待服务端监听端口关闭之后推出main函数
future.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
//优雅关闭 线程组,释放相关资源
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 设置异步回调监听
ch.pipeline().addLast(new ServerHandler());
1. 演示LineBasedFrameDecoder编码器
// ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
// ch.pipeline().addLast(new StringDecoder());
//2.设置连接符/分隔符,换行显示
// ByteBuf buf = Unpooled.copiedBuffer("$_".getBytes());
// //DelimiterBasedFrameDecoder:自定义分隔符
// ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, buf));
ByteBuf delimiter = Unpooled.copiedBuffer("$_".getBytes());
ByteBuf delimiter0 = Unpooled.copiedBuffer("A".getBytes());
ByteBuf delimiter1 = Unpooled.copiedBuffer("B".getBytes());
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, delimiter, delimiter0, delimiter1));
}
}
public static void main(String[] args) throws Exception {
int port = 8091;
new NettyServer().bind(port);
System.out.println("NettyServer启动成功..");
}
public class ServerHandler extends SimpleChannelInboundHandler<Object> {
/**
* 服务器接收客户端请求
*
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg)
throws Exception {
//类似于NIO中的byteBuffer,但是更加灵活功能更加强大
ByteBuf buf = (ByteBuf) msg;
byte[] req = new byte[buf.readableBytes()];
buf.readBytes(req);
String body = new String(req, "UTF-8");
System.out.println("服务器端接收到请求,读取到数据 : " + body);
//异步发送应答消息给客户端: 这里并没有把消息直接写入SocketChannel,而是放入发送缓冲数组中
ByteBuf resp = Unpooled.copiedBuffer("www.XXX.com".getBytes());
//异步发送消息到客户端
ctx.writeAndFlush(resp);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//将消息发送队列中的消息写入到SockChannel中发送给对方,write方法只会讲消息发送到缓冲数组中,防止频繁唤醒Slelector进行消息发送
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
//发生异常时,关闭ChannelHandlerContext ,释放相关联的句柄等资源
ctx.close();
}
}
通过对比,相比于传统JDK NIO原生类库的服务端,代码量减少,开发难度也降低很多