文章目录
IO模型就是说用什么样的通道进行数据的发送和接收,首先要明确一点:IO是操作系统与其他网络进行数据交互,JDK底层并没有实现IO,而是对操作系统内核函数做的一个封装,IO代码进入底层其实都是native形式的。Java共支持3种网络编程IO模式:BIO,NIO,AIO。下文进行介绍
1. BIO
BIO(Blocking IO) 又称同步阻塞IO,一个客户端由一个线程来进行处理,伪代码如下:
可以看到服务端的线程阻塞在两个地方
- 接受客户端请求的
accept()
函数 - 读取数据的
read()
函数
其中读取数据的read()
函数需要经过内核态 - 用户态的切换,read()
的阻塞主要发生在
- 数据从网卡拷贝到内核态
- 数据从内核态拷贝到用户态
这就是传统的阻塞IO,如果客户端一直占用连接不发送数据,那么服务端将一直阻塞在read()
函数上,无法接受其他客户端请求!为了解决这种阻塞,可以每次都创建一个新的进程或线程,去调用 read
函数,这样采用多线程的方式就不用阻塞在原客户端的 read
请求上。
不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read
函数仍然是阻塞的。所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read
函数。
BIO代码示例
public class SocketServer {
public static void main(String[] args) throws IOException {
//创建socket连接,端口为9000
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
//阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("有客户端连接了。。");
//单线程连接,性能不好,下面开启多线程
//handler(clientSocket);
//开启多线程
new Thread(new Runnable() {
@Override
public void run() {
try {
handler(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
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();
}
}
上边是BIO连接的示例代码,启动后可以通过telnet与 localhost 9000 建立连接,并发送字符串信息123,测试结果如下:
测试成功,但BIO现在已经用的不多了,因为它在大并发下有几个致命的缺点:
- 如果BIO使用单线程接受连接,则会阻塞其他连接,效率较低。
- 如果使用多线程虽然减弱了单线程带来的影响,但当有大并发进来时,会导致服务器线程太多,压力太大而崩溃。
- 就算使用线程池,也只能同时允许有限个数的线程进行连接,如果并发量远大于线程池设置的数量,还是与单线程无异
- 操作系统里read函数操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,就是说只占用连接,不发送数据,则会浪费资源。比如线程池中500个连接,只有100个是频繁读写的连接,其他占着茅坑不拉屎,浪费资源!
- 另外多线程也会有线程切换带来的消耗
综上所述,BIO方式已经不适用于如下的大并发场景,仅适用于连接数目比较小且固定的架构。这种方式对服务器资源要求比较高,但BIO程序简单易理解。
2. NIO
2.1 操作系统的非阻塞read函数
上边说到,用户层无论是否采用多线程,操作系统的read
函数本质上还是阻塞的,需要从操作系统层面找到一个非阻塞的 read
函数。操作系统提供了这样的功能,只需要在调用 read
前,将文件描述符设置为非阻塞即可(serverSocket.configureBlocking(false);
)。
这个 read
函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。流程如下:
注意:
- 数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。
- 当数据已到达内核缓冲区,此时调用
read
函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。
整体流程如下图
2.2 NIO 多路复用
操作系统的read
函数解决了读数据时,数据在 网卡 - 内核态 的阻塞问题。但面临多个客户端连接时,如果为每个客户端创建一个线程,服务器端的线程资源还是很容易被耗光。
面对这种问题,有个聪明的办法就是可以每 accept
一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read
方法,这样,我们就成功用一个线程处理了多个客户端连接。
但这种做法看起来像多路复用(多个连接都用一个线程处理了嘛),其实不然,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read
返回 -1 时仍然是一次浪费资源的系统调用(用户态 - 内核态)。真正的多路复用其实是 把一批文件描述符的集合通过一次系统调用传给内核,只在内核态去遍历,用户态仅处理内核态的遍历结果,这样就减少了 用户态- 内核态的请求次数,从根本上解决了这个问题
NIO的多路复用底层主要用的是Linux 内核函数(select,poll,epoll)来实现的。windows不支持epoll实现,windows底层是基于winsock2的select函数实现的(不开源)。三种内核模型的区别如下所示!
select | poll | epoll(jdk 1.5及以上) | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现数组 | 数组 | 链表 | 哈希表 |
IO效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当有IO事件就绪,系统注册的回调函数就会被调用,时间复杂度O(1) |
最大连接 | 有上限(1024个) | 无上限 | 无上限 |
NIO 有三大核心组件:
- Buffer(缓冲区):buffer 底层就是个数组
- Channel(通道):channel 类似于流,每个 channel 对应一个 buffer缓冲区
- Selector(多路复用器):channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
注意:NIO 的 Buffer 和 channel 都是既可以读也可以写,NIO的多路复用示意图如下:
应用场景:
- NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂
2.3 select模型
select
是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理
select
每次都会轮询所有的sockchannel
看下哪个channel
有读写事件,有的话就处理,没有就继续遍历,select和poll模型作为NIO的早期实现,存在一定弊端。下面是一段代码,简单表述一下他们的弊端!
第一个版本:NIO的早期版本模拟代码实现(select)
public class NioServer {
// 保存客户端连接
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel,与BIO的serverSocket类似
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞, 配置为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中
channelList.add(socketChannel);
}
// 遍历连接进行数据读取
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()) {
SocketChannel sc = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
// 非阻塞模式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("客户端断开连接");
}
}
}
}
}
从上述代码可以看到,NIO使用一个mian线程 + 一个数组 解决了BIO的痛点。具体解决方式如下
- 首先设置服务端连接的阻塞方式为false,代表非阻塞方式
- 非阻塞式接受客户端连接,意味着这段代码一直在轮询的跑,不会阻塞。
- 如果有客户端连接进来,就把这个连接放入list集合中
- 后续遍历list集合,使用内核非阻塞式读取数据
(read函数)
- 读取完成再次轮询跑代码
测试如下:
这种方式虽然解决了BIO的部分痛点,但并不是很完美。因为select
模型的底层实现、io效率、最大连接数在面对高并发时还存在一定弊端!
select
调用需要传入客户端连接的数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(epoll
模型可优化为不复制)select
在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(epoll
模型内核层可优化为异步事件通知)select
仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(epoll
模型可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
2.4 poll模型
poll
也是操作系统提供的系统调用函数。它和 select
的主要区别就是,去掉了 select
只能监听 1024
个文件描述符的限制。
2.5 epoll模型
JDK1.5开始引入了epoll基于事件响应机制来优化NIO。epoll 主要就是针对select
模型的三点弊端进行了改进:
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。基于事件响应的,类似于观察者模式!
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
具体,操作系统提供了这三个函数。
-
第一步,创建一个 epoll 句柄
int epoll_create(int size);
-
第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event);
-
第三步,类似发起了 select() 调用
int epoll_wait( int epfd, struct epoll_event *events, int max events, int timeout);
内部原理如下:
NIO第二个版本:使用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为非阻塞
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();
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(128);
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();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
在使用epoll模型之后,对简单版本的NIO做了优化处理,可以理解为在第一个版本的NIO上,又增加了一个就绪事件列表集合,这个集合中存放着有事件响应的连接,然后开启一个线程去监听这个集合,有元素的话就进行处理。
总结:
- 一切的开始,都起源于这个
read
函数是操作系统提供的,而且是阻塞的,我们叫它阻塞 IO
。为了破这个局,程序员在用户态通过多线程来防止主线程卡死。 - 后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的
read
函数,这样程序员就可以在用户态用一个线程内通过while
遍历,完成多个文件描述符的读取(如果read
函数未就绪直接返回-1),这就是非阻塞 IO
。 - 但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是
IO 多路复用
。 - 多路复用快的原因在于:把遍历操作强加到内核中,使得原来的
while
循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。 - 多路复用有三个函数,最开始是
select
,然后又发明了poll
解决了select
文件描述符的限制,然后又发明了epoll
解决select
的三个不足。
2.6 深入hotpost源码分析epoll模型
NIO第二个版本的代码中,与简单版本最主要的代码区别在以下三行代码
// 1. 打开Selector处理Channel,即创建epoll ,创建多路复用器
Selector selector = Selector.open();
// 2. 把ServerSocketChannel链接注册到selector多路复用器上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
// 3. 阻塞等待需要处理的事件发生
selector.select();
这三行代码内部是优化的核心,下面一一介绍!
2.3.1. Selector selector = Selector.open();
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
========================= SelectorProvider.provider() =======================
public static SelectorProvider provider() {
synchronized (lock) {
if (provider != null)
return provider;
return AccessController.doPrivileged(
new PrivilegedAction<SelectorProvider>() {
public SelectorProvider run() {
if (loadProviderFromProperty())
return provider;
if (loadProviderAsService())
return provider;
// 我们在Windows环境下,默认创建的是WindowsSelectorProvider,如下所示
provider = sun.nio.ch.DefaultSelectorProvider.create();
return provider;
}
});
}
}
======================= 默认创建的是WindowsSelectorProvider =======================
public static SelectorProvider create() {
return new WindowsSelectorProvider();
}
进入open方法源码中,可以看到创建的是WindowsSelectorProvider,而项目发布一般都是在linux上,所以需要进入hotport源码中看一下Linux平台的实现。这中现象也是JVM的跨平台性的体现。进入hotpost源码发现如果是linux环境,则创建EpollSelectorProvider。也发现Linux上实现是基于Epoll模型!
所以 SelectorProvider.provider().openSelector() 其实是EpollSelectorProvider.openSelector(),代码如下所示!
可以看到 EPollSelectorImpl 是具体的实现,那么在EPollSelectorImpl实例化时做了什么事情呢?
在这个 EPollArray 包装类中调用了一个 epoll_create 的本地方法,创建了一个epoll对象,并返回一个非负数作为文件描述符,用于对epoll接口的所有后续调用。这个方法属于linux操作系统内核中的方法。至此Selector.open()方法解释完毕,总的来说就是创建了一个linux内核层面的Epoll对象!
2.3.2. serverSocket.register(selector, SelectionKey.OP_ACCEPT)
查看这个方法的源码,同样要使用LInux下的平台中的EpollSelectorProvider的register方法,核心代码如下:
这个方法是把当前连接注册到第一步EPollSelectorImpl实例化时生成的EPollArray的集合中,此时并没有事件驱动。直到 selector.select()方法调用linux内核函数epoll_ctl
2.3.3. selector.select()
这个select方法内部会调用doselect()方法,这个方法同样要使用LInux下的平台中的EpollSelectorProvider
在这个poll方法中,分别调用了Linux核心函数 epoll_ctl 和 epoll_wait,下面分别解释其含义
- epoll_ctl: 负责真正去关联socketChannel 和 epoll实例(selector)的关系。如果客户端连接(socketChannel)有发送事件,就会把存在于EPollArray中的这个连接放入epoll实例中的就绪事件列表(rdlist)中去。
其中这个放入就绪事件列表的操作是怎么做的呢?
放入操作并非是java去做,每当服务端收到客户端的连接,且该链接有响应事件时,就是使用操作系统的硬中断功能把该连接放入就绪事件列表中去。后续遍历就绪事件列表中的元素即可,减少了无效遍历。 - epoll_wait:监听就绪事件列表中的元素,如果没有数据则阻塞。如果有数据,则把数据放入selectedKeys中,供java代码获取元素,响应事件!
2.7 NIO与redis
Redis就是典型的基于epoll的NIO线程模型(nginx也是),epoll实例收集所有事件(连接与读写事件),把有数据交互的连接放入就绪事件列表中,由一个服务端线程连续处理所有就绪事件列表中的命令。
在ae_epoll.c源码中也发现有 epoll_create、epoll_ctl 和 epoll_wait,原理与上文讲的类似
问题一:为什么 redis 不建议用 bigkey?
bigkey的big体现在单个value值很大,一般认为超过10KB就是bigkey。由于redis底层用的是NIO,多路复用一个线程,如果存在bigkey的话,这个bigkey就会占用这个线程较大的时间,导致其他连接的数据交互阻塞,所以不建议使用bigkey。注意:这里说的阻塞并不是 异步非阻塞的阻塞。
3. AIO
AIO自JDK1.7以后才开始支持,是异步非阻塞的,客户端与服务端的连接(accept)、数据读写(read、write)不再由main线程去执行,而是开辟一个回调函数,当客户端与服务端建立连接时,把这个客户端的连接传入回调函数中,由服务端启动一个子线程去处理,这就完成了异步操作!适用于连接数较多且连接时间较长的应用。
AIO与BIO、NIO的不同之处在于:
- AIO是 异步非阻塞模型
- NIO是 同步非阻塞模型
- BIO是 同步阻塞模型
AIO代码示例
public class AIOServer {
public static void main(String[] args) throws Exception {
//创建服务端
final AsynchronousServerSocketChannel serverChannel =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));
//使用CompletionHandler异步处理客户端连接
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);
//使用CompletionHandler异步读取数据
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);
}
}
AIO作为异步非阻塞模型,理论上来说应该被广泛使用,但大多数公司并没有使用AIO,而是使用了netty,为什么?
- 首先AIO得底层实现仍使用Epoll,并没有很好的实现异步,在性能上对比NIO没有太大优势
- 其次AIO的代码逻辑比较复杂,且Linux上AIO还不够成熟
- Netty在NIO上做了很多异步的封装,是异步非阻塞框架
4. BIO、NIO、AIO的对比
一个关于同步异步与阻塞非阻塞的段子:
老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
- 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻
- 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
- 老张把响水壶放到火上,立等水开。(异步阻塞)老张觉得这样傻等意义不大
- 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)老张觉得自己聪明了。
所谓同步异步,只是对于水壶而言。
- 普通水壶,同步
- 响水壶,异步。
- 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。
同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。
所谓阻塞非阻塞,仅仅对于老张而言。
- 立等的老张,阻塞
- 看电视的老张,非阻塞。