Tomcat 中经常被提到的一个调优就是修改线程的 I/O 模型。Tomcat 8.5 版本之前,默认情况下使用的是 BIO 线程模型,如果在高负载、高并发的场景下,可以通过设置 NIO 线程模型,来提高系统的网络通信性能。
通过一个性能对比测试来看看在高负载或高并发的情况下,BIO 和 NIO 通信性能(这里用页面请求模拟多 I/O 读写操作的请求):
测试结果:Tomcat 在 I/O 读写操作比较多的情况下,使用 NIO 线程模型有明显的优势。
网络 I/O 模型优化
操作系统内核的网络模型衍生出了五种 I/O 模型,分为阻塞式 I/O、非阻塞式 I/O、I/O 复用、信号驱动式 I/O 和异步 I/O。每一种 I/O 模型的出现,都是基于前一种 I/O 模型的优化升级。
- 阻塞式 I/O:connect 阻塞、accept 阻塞、read、write 阻塞;
- 非阻塞式 I/O:使用 fcntl 可以把以上三种操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个 EWOULDBLOCK 或 EAGAIN 错误,此时进程就不会一直被阻塞;
- I/O 复用:select() 函数、poll() 函数、epoll() 函数;
- 信号驱动式 I/O:信号驱动式 I/O 相比于前三种 I/O 模式,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳;
- 异步 I/O:信号驱动式 I/O 虽然在等待数据就绪时,没有阻塞进程,但在被通知后进行的 I/O 操作还是阻塞的,进程会等待数据从内核空间复制到用户空间中。而异步 I/O 则是实现了真正的非阻塞 I/O。但由于程序的代码复杂度高,调试难度大,且支持异步 I/O 的操作系统比较少见(目前 Linux 暂不支持,而 Windows 已经实现了异步 I/O),所以在实际生产环境中很少用到异步 I/O 模型。
由于信号驱动式 I/O 对 TCP 通信的不支持,以及异步 I/O 在 Linux 操作系统内核中的应用还不大成熟,大部分框架都还是基于 I/O 复用模型实现的网络通信。
NIO 使用 I/O 复用器 Selector 实现非阻塞 I/O,Selector 就是使用了这五种类型中的 I/O 复用模型。Java 中的 Selector 其实就是 select/poll/epoll 的外包类。
零拷贝
每一次 I/O 读写操作都需要完成四次内存拷贝,路径是 I/O 设备 -> 内核空间 -> 用户空间 -> 内核空间 -> 其它 I/O 设备。
Linux 内核中的 mmap 函数可以代替 read、write 的 I/O 读写操作,实现用户空间和内核空间共享一个缓存数据。
在 Java 的 NIO 编程中,则是使用到了 Direct Buffer 来实现内存的零拷贝。Java 直接在 JVM 内存空间之外开辟了一个物理内存空间,这样内核和用户进程都能共享一份缓存数据。
线程模型优化
除了内核对网络 I/O 模型的优化,NIO 在用户层也做了优化升级。NIO 是基于事件驱动模型来实现的 I/O 操作。Reactor 模型三个主要组件:
- 事件接收器 Acceptor:主要负责接收请求连接;
- 事件分离器 Reactor:接收请求后,会将建立的连接注册到分离器中,依赖于循环监听多路复用器 Selector,一旦监听到事件,就会将事件 dispatch 到事件处理器;
- 事件处理器 Handlers:事件处理器主要是完成相关的事件处理,比如读写 I/O 操作。
主从 Reactor 线程模型:现在主流通信框架中的 NIO 通信框架都是基于主从 Reactor 线程模型来实现的。在这个模型中,Acceptor 不再是一个单独的 NIO 线程,而是一个线程池。Acceptor 接收到客户端的 TCP 连接请求,建立连接之后,后续的 I/O 操作将交给 Worker I/O 线程。
基于线程模型的 Tomcat 参数调优
Tomcat 中,BIO、NIO 是基于主从 Reactor 线程模型实现的。可以通过以下几个参数来设置 Acceptor 线程池和 Worker 线程池的配置项。
-
acceptorThreadCount:该参数代表 Acceptor 的线程数量,在请求客户端的数据量非常巨大的情况下,可以适当地调大该线程数量来提高处理请求连接的能力,默认值为 1;
-
maxThreads:专门处理 I/O 操作的 Worker 线程数量,默认是 200,可以根据实际的环境来调整该参数,但不一定越大越好;
-
acceptCount:Tomcat 的 Acceptor 线程是负责从 accept 队列中取出该 connection,然后交给工作线程去执行相关操作,这里的 acceptCount 指的是 accept 队列的大小。 当 Http 关闭 keep alive,在并发量比较大时,可以适当地调大这个值。而在 Http 开启 keep alive 时,因为 Worker 线程数量有限,Worker 线程就可能因长时间被占用,而连接在 accept 队列中等待超时。如果 accept 队列过大,就容易浪费连接;
-
maxConnections:表示有多少个 socket 连接到 Tomcat 上。在 BIO 模式中,一个线程只能处理一个连接,一般 maxConnections 与 maxThreads 的值大小相同;在 NIO 模式中,一个线程同时处理多个连接,maxConnections 应该设置得比 maxThreads 要大的多,默认是 10000。