基础概念
基础概念不了解的同学可以去看上一章,非常重要.
BIO(Blocking IO,第一章阻塞IO模型)
同步阻塞模型,一个客户端连接对应一个处理线程 .在JDK1.4出来之前,我们建立网络连接的时候采用BIO模式,需要先在服务端启动一个ServerSocket,然后在客户端启动Socket来对服务端进行通信.服务端接受到请求后,要指派或新建一个线程去处理客户端的IO请求,直到收到断开连接的指令。
应用场景:
BIO 方式适用于连接数目比较小且固定的架构, 这种方式对服务器资源要求比较高, 但程序简单易理解。
模拟
客户端:用windows上面的telnet localhost 9000可以连接上服务端,再按CTRL+]进入操作界面去模拟客户端连接,send命令发送.
服务端:我们先来看一段Server端的代码,我们监听了服务器的9000端口,然后一个while等待客户端连接,然后读取客户端给我们发的数据,有两个关键点serverSocket.accept(),clientSocket.getInputStream() 这两个是阻塞方法
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
//阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("有客户端连接了。。");
handler(clientSocket);
}
}
private static void handler(Socket clientSocket) throws IOException {
byte[] bytes = new byte[1024];
System.out.println("准备read。。");
//接收客户端的数据,阻塞方法,没有数据可读时就阻塞
int read = clientSocket.getInputStream().read(bytes);
System.out.println("read完毕。。");
if (read != -1) {
System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
}
clientSocket.getOutputStream().write("HelloClient".getBytes());
clientSocket.getOutputStream().flush();
}
}
缺点:
1.单线程,如果我一个客户端连接过来,不做任何读写操作,则线程阻塞,下一个客户端即使连接也做不了任何操作
伪异步 IO/非阻塞:下面我们优化一下这个代码,开启一个线程读取客户端信息,这样我就可以同时处理很多个客户端请求.
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
//阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("有客户端连接了。。");
new Thread(new Runnable() {
@Override
public void run() {
try {
handler(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
缺点:
1、IO代码里read操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,浪费资源,你他么连接了我,又不做读写,搞到一直阻塞.
2、如果连接很多就会创建很多线程,会导致服务器线程太多,压力太大,比如C10K问题,即使用了线程池,线程上下文切换也会消耗资源.
NIO(Non Blocking IO)
服务器实现模式为一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就进行处理,JDK1.4开始引入。
应用场景:
NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂
代码演示
版本1:(第一章非阻塞IO模型)
下述代码服务端是单线程执行,却可以读取所有客户端连接的channel,是不是有点像redis.这是没引入selector的时候,把socket设置为非阻塞,需要我们自己去遍历每一个channel
public class NioServer {
// 保存客户端连接
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException, InterruptedException {
/* 创建NIO ServerSocketChannel,与BIO的serverSocket类似
ServerSocketChannel 是一个可以监听新进来的TCP连接的通道,
就像标准IO中的ServerSocket一样。
通过 ServerSocketChannel.accept() 方法监听新进来的连接。
当 accept()方法返回的时候,它返回一个包含新进来的连接的 SocketChannel。
因此, accept()方法会一直阻塞到有新连接到达.
ServerSocketChannel可以设置成非阻塞模式。在非阻塞模式下,
accept() 方法会立刻返回,如果还没有新进来的连接,返回的将是null。
因此,需要检查返回的SocketChannel是否是null.*/
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
//必须配置为非阻塞才能往selector上注册,否则会报错,selector模式本身就是非阻塞模式
serverSocket.configureBlocking(false);
System.out.println("服务启动成功");
while (true) {
// 非阻塞模式accept方法不会阻塞,否则会阻塞
//NIO非阻塞体现:此处accept方法是阻塞的,但是这里因为是发生了连接事件,所以这个方法会马上执行完,不会阻塞
//处理完连接请求不会继续等待客户端的数据发送
// NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
SocketChannel socketChannel = serverSocket.accept();
if (socketChannel != null) { // 如果有客户端进行连接
System.out.println("连接成功");
// 设置SocketChannel为非阻塞,设置为true会报错
socketChannel.configureBlocking(false);
// 保存客户端连接在List中
channelList.add(socketChannel);
}
// 遍历连接进行数据读取
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()) {
SocketChannel sc = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
// 非阻塞模式read方法不会阻塞,否则会阻塞
//NIO非阻塞体现:首先read方法不会阻塞,其次这种事件响应模型,当调用到read方法时肯定是发生了客户端发送数据的事件
int len = sc.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开,把socket从集合中去掉
iterator.remove();
System.out.println("客户端断开连接");
}
}
}
}
}
缺点:如果连接数太多的话,会有大量的无效遍历channel,假如有10000个连接,其中只有1000个连接有写数据,但是由于其他9000个连接并没有断开,我们还是要每次轮询遍历一万次,其中有十分之九的遍历都是无效的,这显然不是一个让人很满意的状态。那么我们能不能只获取有数据的channel来遍历呢?
版本2:(第一章IO多路复用模型)
NIO引入多路复用器代码示例,我们可以由一个线程去处理我们几十万个客户端连接,并且只对有事件发生的channel进行操作.
这里的多路复用器Selector,其实就是我们基础篇里面的IO多路复用模型,他可以把所有的连接都阻塞到select(poll,epoll,这三个函数功能一致,具体看下一章)函数,当有事件的时候我们才去做操作,除去很多无用功,由系统内核帮我们去完成事件的发现,我们就可以用一个或者少量线程去处理成千上万个连接了.
public class NioSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
//必须配置为非阻塞才能往selector上注册,否则会报错,selector模式本身就是非阻塞模式
serverSocket.configureBlocking(false);
// 打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务启动成功");
while (true) {
// 阻塞等待需要处理的事件发生
selector.select();
// 获取selector中注册的全部事件的 SelectionKey 实例
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历SelectionKey对事件进行处理
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
//NIO非阻塞体现:此处accept方法是阻塞的,但是这里因为是发生了连接事件,所以这个方法会马上执行完,不会阻塞
SocketChannel socketChannel = server.accept();
//设置为true会报错
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(128);
//NIO非阻塞体现:首先read方法不会阻塞,其次这种事件响应模型,当调用到read方法时肯定是发生了客户端发送数据的事件
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();
}
} else if (key.isWritable()) {
SocketChannel sc = (SocketChannel) key.channel();
System.out.println("write事件");
// NIO事件触发是水平触发
// 使用Java的NIO编程的时候,在没有数据可以往外写的时候要取消写事件,
// 在有数据往外写的时候再注册写事件
key.interestOps(SelectionKey.OP_READ);
//sc.close();
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
根据上述代码和下图我们来理解一下NIO工作流程
1.服务端开启ServerSocketChannel监听9000端口
2.新建了一个多路复用器Selector对象,把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
3.selector等待客户端的事件发生
4.突然client1连接上了服务端,客户端socketChannel和服务端建立起了连接(accept事件),这个事件通过linux底层的硬中断感知到了,然后放进rdlist就绪事件列表里面.
5.selector.accpet 感知到了有事件发生(其实就是我们的rdlist有数据了,只遍历rdlist里面的数据),拿一下这个selectKey集合,这个key集合里面有所有和selector关联的channel的一个key,我们可以通过key拿到对应的channel.
6.拿到keyList我们肯定要遍历一下,看一下这些都是什么事件
7.因为客户端连接进来了,发生了accept事件我们根据key拿到了客户端的socketchannel
8.拿到客户端的scoketclient我们就把他关联到我们的selector,并且对他的写事件感兴趣.
9.然后循环回到selector.accpet 阻塞
10.client1发了一条数据给服务端,我们的selector感知到了有写事件发生,然后继续走上述流程
总结:版本一和版本二
为什么我们要交给系统的selelct去帮我们轮询有什么事件发生,而不在我们java程序轮询?
版本一是我们应用程序for循环去轮询,版本二是我们selector调用内核函数select(poll,epoll),select去轮询.
因为java程序轮询最终也是调用系统的函数,造成不必要的额外开销,而直接用系统函数,会把用户态的数据放进去内核态,帮你轮询.
AIO(NIO 2.0)
异步非阻塞, 由操作系统完成后回调通知服务端程序启动线程去处理, 一般适用于连接数较多且连接时间较长的应用
应用场景:
AIO方式适用于连接数目多且连接比较长(重操作)的架构,JDK7 开始支持
AIO代码示例:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;
public class AIOServer {
public static void main(String[] args) throws Exception {
final AsynchronousServerSocketChannel serverChannel =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));
serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
@Override
public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
try {
System.out.println("2--"+Thread.currentThread().getName());
// 再此接收客户端连接,如果不写这行代码后面的客户端连接连不上服务端
serverChannel.accept(attachment, this);
System.out.println(socketChannel.getRemoteAddress());
ByteBuffer buffer = ByteBuffer.allocate(1024);
socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buffer) {
System.out.println("3--"+Thread.currentThread().getName());
buffer.flip();
System.out.println(new String(buffer.array(), 0, result));
socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
}
@Override
public void failed(Throwable exc, ByteBuffer buffer) {
exc.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, Object attachment) {
exc.printStackTrace();
}
});
System.out.println("1--"+Thread.currentThread().getName());
Thread.sleep(Integer.MAX_VALUE);
}
}
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousSocketChannel;
public class AIOClient {
public static void main(String... args) throws Exception {
AsynchronousSocketChannel socketChannel = AsynchronousSocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 9000)).get();
socketChannel.write(ByteBuffer.wrap("HelloServer".getBytes()));
ByteBuffer buffer = ByteBuffer.allocate(512);
Integer len = socketChannel.read(buffer).get();
if (len != -1) {
System.out.println("客户端收到信息:" + new String(buffer.array(), 0, len));
}
}
}
由结果我们可以看到,服务端接收客户端连接,还有读取客户端消息都是不同的线程,不阻塞,异步.
BIO、 NIO、 AIO 对比
为什么Netty使用NIO而不是AIO?
在Linux系统上,AIO的底层实现仍使用Epoll,没有很好实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优化,Linux上AIO还不够成熟。Netty是异步非阻塞框架,Netty在NIO上做了很多异步的封装。