Java性能 -- NIO

BIO / NIO

  1. Tomcat 8.5之前,默认使用BIO线程模型,在高并发的场景下,可以设置为NIO线程模型,来提供系统的网络通信性能
  2. 页面请求用于模拟多IO读写操作的请求,Tomcat在IO读写请求比较多的情况下,使用NIO线程模型有明显的优势

网络IO模型优化

网络通信中,最底层的是操作系统内核中的网络IO模型,分别为阻塞式IO非阻塞式IOIO复用信号驱动式IO异步IO

TCP工作流程

  1. 首先,应用程序通过系统调用socket,创建一个套接字,它是系统分配给应用程序的一个文件描述符
  2. 其次,应用程序通过系统调用bind,绑定地址和的端口号,给套接字命名一个名称
  3. 然后,系统调用listen,创建一个队列用于存放客户端进来的连接
  4. 最后,应用程序通过系统调用accept监听客户端的连接请求
  5. 当有一个客户端连接到服务端后,服务端会通过系统调用fork,创建一个子进程
    • 通过系统调用read监听客户端发来的消息,通过系统调用write向客户端返回消息

阻塞式IO

每一个连接创建时,都需要一个用户线程来处理,并且在IO操作没有就绪或者结束时,线程会被挂起,进入阻塞等待状态

connect阻塞

  1. 客户端通过系统调用connect发起TCP连接请求,TCP连接的建立需要完成三次握手
  2. 客户端需要阻塞等待服务端返回的ACK和SYN,服务端需要阻塞等待客户端的ACK

accept阻塞

服务端通过系统调用accept接收客户端请求,如果没有新的客户端连接到达,服务端进程将被挂起,进入阻塞状态

read、write阻塞

Socket连接创建成功后,服务端调用fork创建子进程,调用read等待客户端写入数据,如果没有,子进程被挂起,进入阻塞状态

非阻塞式IO

  1. 使用fcntl把上面的操作都设置为非阻塞,如果没有数据返回,直接返回EWOULDBLOCKEAGAIN错误,进程不会被阻塞
  2. 最传统的非阻塞IO模型:设置一个用户线程对上面的操作进行轮询检查

IO复用

  1. 传统的非阻塞IO模型使用用户线程轮询检查一个IO操作的状态,无法应对大量请求的情况
  2. Linux提供了IO复用函数selectpollepoll
    • 进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上,系统内核去侦测多个读操作是否处于就绪状态

select

  1. 在超时时间内,监听用户感兴趣的文件描述符上的可读可写异常事件的发生
  2. Linux内核将所有外部设备看做文件,对文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd)
  3. select函数监听的文件描述符分为三类:readsetwritesetexceptset(异常事件)
  4. 调用select函数后会阻塞,直到有文件描述符就绪超时,函数返回
  5. 当select函数返回后,可以通过FD_ISSET函数遍历fdset(readset/writeset/exceptset),来找到就绪的文件描述符
1
2
3
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写

poll

  1. 每次调用select函数之前,系统需要把fd从用户态拷贝到内核态(交由内核侦测),带来一定的性能开销
  2. 单个进程监视的fd数量默认为1024(可以修改宏定义或者重新编译内核
  3. 另外fd_set是基于数组实现的,在新增删除fd时,时间复杂度为O(n)(因此fd_set不宜过大)
  4. poll的机制与select类似,本质上差别不大(轮询),只是poll没有最大文件描述符数量的限制
  5. poll和select存在相同的缺点
    • 包含大量文件描述符的数组整体复制用户态内核态的地址空间,无论这些文件描述符是否就绪
    • 系统开销会随着文件描述符的增加而线性增大

epoll

  1. select/poll是顺序扫描fd是否就绪,而且支持的fd数量不宜过大
  2. Linux 2.6提供了epoll调用,epoll使用事件驱动的方式代替轮询扫描fd
  3. epoll事先通过epoll_ctl注册一个文件描述符,将文件描述符存放在内核的一个事件表
    • 该事件表是基于红黑树实现的,在大量IO请求的场景下,其插入和删除的性能比select/poll的数组fd_set要好
    • 因此epoll的性能更好,而且没有fd数量的限制
  4. epoll_ctl函数的参数解析
    • epfd:由epoll_create函数生成的一个epoll专用文件描述符
    • op:操作事件类型
    • fd:关联的文件描述符
    • event:监听的事件类型
  5. 一旦某个文件描述符就绪,操作系统内核会采用类似Callback的回调机制,迅速激活该文件描述符
    • 当进程调用epoll_wait时便得到通知,之后进程将完成相关的IO操作
1
2
3
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)

int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)

信号驱动式IO

  1. 信号驱动式IO类似于观察者模式内核是观察者信号回调是通知
  2. 用户进程发起一个IO请求操作,通过系统调用sigaction,给对应的Socket注册一个信号回调
    • 此时不阻塞用户进程,用户进行继续工作
  3. 内核数据就绪时,操作系统内核该进程生成一个SIGIO信号,通过信号回调通知进程进行相关IO操作
  4. 相比于前三种IO模型,在等待数据就绪时进程不被阻塞,主循环可以继续工作,性能更佳
  5. 但对于TCP来说,信号驱动式IO几乎没有被使用
    • 因为SIGIO信号是一种UNIX信号没有附加信息
    • 如果一个信号源有多种产生信号的原因,信号接收者无法确定实际发生了什么
    • 而TCP Socket生产的信号事件有七种之多,进程收到SIGIO信号后也根本没法处理
  6. 而对于UDP来说,信号驱动式IO已经有所应用,例如NTP服务器
    • 因为UDP只有一个数据请求事件
    • 正常情况下,UDP进程只要捕获到SIGIO信号,就调用recvfrom读取到达的数据报
    • 异常情况下,就返回一个异常错误

异步IO

  1. 虽然信号驱动式IO在等待数据就绪时,不会阻塞进程,但在被通知后进行的IO操作还是阻塞的
    • 进程会等待数据从内核空间复制到用户空间
  2. 异步IO实现了真正的非阻塞IO
    • 用户进程发起一个IO请求操作,系统会告知内核启动某个操作,并让内核在整个操作完成后通知用户进程
    • 整个操作包括:等待数据就绪数据从内核空间复制到用户空间
  3. Linux不支持异步IO,Windows支持异步IO,因此生产环境中很少用到异步IO模型

Java NIO

Selector

Java NIO使用了IO复用器Selector实现非阻塞IO,Selector使用的是IO复用模型Selector是select/poll/epoll的外包类

SelectionKey

Socket通信中的connect、accept、read/write是阻塞操作,分别对应SelectionKey的四个监听事件

服务端编程

  1. 首先,创建ServerSocketChannel,用于监听客户端连接
  2. 然后,创建Selector,将ServerSocketChannel注册到Selector,应用程序会通过Selector来轮询注册在其上的Channel
    • 当发现有一个或多个Channel处于就绪状态,返回就绪的监听事件,最后进行相关的IO操作
  3. 在创建Selector时,应用程序会根据操作系统版本选择使用哪种IO复用函数
    • JDK 1.5 + Linux 2.6 -> epoll
    • 由于信号驱动式IO对TCP通信不支持,以及Linux不支持异步IO,因此大部分框架还是基于IO复用模型实现网络通信
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 功能:向每个接入的客户端发送Hello字符串

// 创建ServerSocketChannel,配置为非阻塞模式
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
// 绑定监听
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
// 创建单独的IO线程,用于轮询多路复用器Selector
Selector selector = Selector.open();
// 创建Selector,将之前创建的serverSocketChannel注册到Selector上,监听OP_ACCEPT
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 轮询就绪的Channel
while (true) {
try {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
for (Iterator<SelectionKey> it = keys.iterator(); it.hasNext(); ) {
SelectionKey key = it.next();
it.remove();
try {
if (key.isAcceptable()) {
// 新的客户端接入
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
// 将客户端的Channel注册到Selector上,监听OP_WRITE
client.register(selector, SelectionKey.OP_WRITE);
} else if (key.isWritable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.wrap("Hello".getBytes());
client.write(buffer);
key.cancel();
}
} catch (IOException e) {
key.cancel();
try {
key.channel().close();
} catch (IOException ignored) {
}
}
}
} catch (IOException e) {
break;
}
}

零拷贝

  1. IO复用模型中,执行读写IO操作依然是阻塞的,并且存在多次内存拷贝上下文切换,增加性能开销
  2. 零拷贝是一种避免多次内存复制的技术,用来优化读写IO操作
  3. 在网络编程中,通常由read、write来完成一次IO读写操作,每次IO读写操作都需要完成4次内存拷贝
    • 路径:IO设备 -> 内核空间 -> 用户空间 -> 内核空间 -> 其他IO设备

Linux mmap

  1. Linux内核中的mmap函数可以代替read、write的IO读写操作,实现用户空间和内核空间共享一个缓存数据
  2. mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址
    • 不管是用户空间还是内核空间都是虚拟地址,最终都要映射到物理内存地址
  3. 这种方式避免了内核空间与用户空间的数据交换
  4. IO复用的epoll函数也是利用了mmap函数减少了内存拷贝

Java NIO

  1. Java NIO可以使用Direct Buffer来实现内存的零拷贝
  2. Java直接在JVM内存之外开辟一个物理内存空间,这样内核用户进程都能共享一份缓存数据

线程模型优化

  1. 一方面内核网络IO模型做了优化,另一方面NIO用户层也做了优化
  2. NIO是基于事件驱动模型来实现IO操作
  3. Reactor模型是同步IO事件处理的一种常见模型
    • 将IO事件注册到多路复用器上,一旦有IO事件触发,多路复用器会将事件分发事件处理器中,执行就绪的IO事件操作

Reactor模型的组件

  1. 事件接收器Acceptor
    • 主要负责接收请求连接
  2. 事件分离器Reactor
    • 接收请求后,会将建立的连接注册到分离器中,依赖于循环监听多路复用器Selector
    • 一旦监听到事件,就会将事件分发到事件处理器
  3. 事件处理器Handler
    • 事件处理器主要完成相关的事件处理,比如读写IO操作

单线程Reactor

  1. 最开始NIO是基于单线程实现的,所有的IO操作都在一个NIO线程上完成
  2. 由于NIO是非阻塞IO,理论上一个线程可以完成所有IO操作
  3. 但NIO并没有真正实现非阻塞IO,因为读写IO操作时用户进程还是处于阻塞状态
  4. 在高并发场景下会存在性能瓶颈,一个NIO线程也无法支撑C10K

多线程Reactor

  1. 为了解决单线程Reactor在高并发场景下的性能瓶颈,后来采用了线程池
  2. TomcatNetty中都使用了一个Acceptor线程来监听连接请求事件
    • 当连接成功后,会将建立的连接注册到多路复用器中,一旦监听到事件,将交给Worker线程池来负责处理
    • 在大多数情况下,这种线程模型可以满足性能要求,但如果连接的客户端很多,一个Acceptor线程也会存在性能瓶颈

主从Reactor

  1. 现在主流通信框架中的NIO通信框架都是基于主从Reactor线程模型来实现的
  2. 主从Reactor:Acceptor不再是一个单独的NIO线程,而是一个线程池
    • Acceptor接收到客户端的TCP连接请求,建立连接后,后续的IO操作将交给Worker线程处理

Tomcat

原理

  1. 在Tomcat中,BIO和NIO是基于主从Reactor线程模型实现的
  2. BIO中,Tomcat中的Acceptor只负责监听新的连接,一旦连接建立,监听到IO操作,就会交给Worker线程处理
  3. NIO中,Tomcat新增一个Poller线程池
    • Acceptor监听到连接后,不是直接使用Worker线程处理请求,而是先将请求发送给Poller缓冲队列
    • 在Poller中,维护了一个Selector对象,当Poller从缓冲队列中取出连接后,注册到该Selector中
    • 然后,通过遍历Selector,找出其中就绪的IO操作,并交给Worker线程处理

配置参数

  1. acceptorThreadCount
    • Acceptor的线程数量,默认1
  2. maxThreads
    • 专门处理IO操作的Worker线程数量,默认200(不一定越大越好)
  3. acceptCount
    • Acceptor线程负责从accept队列中取出连接,然后交给Worker线程处理
    • acceptCount指的是accept队列的大小
    • 当HTTP关闭Keep Alive,并发量会增大,可以适当调大该值
    • 当HTTP开启Keep Alive,而Worker线程数量有限,并且有可能被长时间占用,连接在accept队列中等待超时
      • 如果accept队列过大,很容易造成连接浪费
  4. maxConnections
    • 表示可以有多少个Socket连接到Tomcat上,默认10000
    • BIO模式中,一个线程只能处理一个连接,一般maxThreads与maxConnections的值相同
    • NIO模式中,一个线程可以同时处理多个连接,maxThreads应该比maxConnections大很多
0%