Tomcat是怎样使用NIO的
Tomcat的结构
一个Tomcat实例为一个Server
。
Server
中,包含有很多个Service
实例。一个Service
可以理解为Tomcat目录下的一个运行中的war包。所以嵌入式启动的Tomcat只有一个Service
。
每一个Service
包含若干Connector
实例和一个唯一的Container
实例。其中Connector
负责网络通信,Container
负责对通信报文的处理。
Tomcat的Connector
标准实现是Coyote
,而Container
的标准实现是Catalina
。
Coyote
的构造器参数只有一个,就是协议的类名,例如org.apache.coyote.http11.Http11Nio2Protocol
。每一个协议类同时指定了IO机制(NIO,NIO2,APR)和协议类型(HTTP/1.1,AJP)。
Catalina
里面有多个容器类型:Engine
、Host
、Context
等。三者为嵌套关系。
Enigne
负责处理Service
的所有请求,每个Service
里面只能有一个Enigne
。Host
代表虚拟主机,关联于一个特定的域名。一个Engine
里面可以有多个Host
,每一个Engine
里必须有且只有一个Host
对应于defaultHost
。Context
容器关联于一个servlet context
。
这次的主角是Coyote
。
Coyote的协议类
如前所述,每一个协议类同时指定了IO机制(NIO,NIO2,APR)和协议类型(HTTP/1.1,AJP)。
Coyote
的三种IO机制分别是指:
- NIO,指的是使用随JDK1.4发布的
java.nio.*
包下的组件开发出的Reactor多路复用通信模式。 - NIO2,指的是使用随JDK1.7发布的
java.nio.*
包下的组件开发出的Proactor异步通信模式。 - APR,是指使用Apache Portable Runtime组件开发出的JNI组件实现的非阻塞通信模式。
其中APR模式使用的是C/C++实现的套接字,NIO和NIO2使用的是Java实现的套接字。所以要想使用APR模式,必须首先在环境中安装APR组件。
Coyote
支持两种末端协议:HTTP/1.1,AJP。AJP是一种二进制协议,它将HTTP协议中常见的字段,例如Content-type
,使用数字来代替,从而实现高效的通信。但是对于不常见的字段,还是使用字符串,所以AJP是有可能退化成为HTTP协议的。该不该使用AJP协议应当根据业务场景来测试后决定。
Coyote
也支持HTTP/2,WebSocket协议,但这两者需要首先使用上面两种末端协议建立连接后,根据特定条件转换为目标协议。HTTP/2协议的连接建立可以参考这篇博文。
Coyote NIO的调用链
上图是HTTP/1.1协议的NIO/NIO2的整体调用链示意图。
可以看出Coyote NIO大量使用模版模式,整体框架使用接口、抽象类组织。
Acceptor
是一个线程实例,只用来接受套接字。如果已经建立的套接字达到了连接最大限制,就会等待。AbstractEndpoint
是底层通信框架的抽象类,NIO和NIO2的区别就在这个类,两者分别对应于NioEndpoint
和NioEndpoint
。AbstractProtocol
就是前面说的协议类的抽象类,其中AbstractEndpoint
和Processor
均为其实例属性。参考下方Http11Nio2Protocol
类图。Processor
是所有协议处理器的统一抽象,而HTTP/1.1协议和AJP协议的处理器均直接继承了AbstractProcessor
抽象类。Processor
完成协议处理后,就将报文提交给了Container
,交互接口是Adapter
。也就是说用户如过不想用Tomcat的Catalina
容器,可以自行实现Adapter接口。不过Tomcat的Connector
类默认的Adapter
实现就是CoyoteAdapter
,而且是硬编码,所以还得实现Connector
子类覆盖相关方法。
NIO vs NIO2
NIOEndpoint
的实现
NIOEndpoint
使用Java NIO来实现,是一种Reactor模式。
Java NIO需要在用户态维护多路复用器Selector
。业务线程在Selector
上注册相应套接字上希望发生的事件(可读、可写等等)。Selector
是对操作系统的多路复用器的封装,也就是说这个“兴趣”实际上注册到了内核态。Selector
会阻塞式等候内核通知,一旦有事件可用,就会检查注册在自己上的所有套接字句柄,如果相应的注册事件发生了,就会通知注册方执行下一步动作。Selector
的阻塞式等候导致其必须占用一个线程来运行。
在NIOEndpoint
里使用Poller
线程对象来实现Selector
的阻塞式等候。一个NIOEndpoint
对象中最多有2个Poller
线程对象。当客户端的请求套接字完成接受后,Poller
就会调用register
方法,将NioChannel
对象和对应的NioSocketWrapper
对象绑定在同一个PollerEvent
对象里。NioChannel
对象就是对套接字的封装,NioSocketWrapper
对象使用NioSelectPool
实现了对报文的阻塞式接受和回写。当Poller
发现感兴趣的事件发生时,会调用processKey
方法,将NioSocketWrapper
对象注入到SocketProcessor
私有类中,在线程池中调用AbstractProtocol
处理套接字。
NIO2Endpoint
的实现
NIO2Endpoint
使用Java NIO2来实现,是一种Proactor模式。
尽管NIO2的AsynchronousServerSocketChannel
也支持使用CompletionHandler
回调接口来异步实现接受套接字请求,但是Tomcat NIO2还是使用阻塞式的Future
对象来接收请求。这估计是出于和NIO兼容复用代码的考虑。
Tomcat NIO2主要是用AsynchronousChannelGroup
完成异步调用。AsynchronousServerSocketChannel
首先阻塞式的接受客户端请求,得到AsynchronousSocketChannel
套接字对象,然后注入到一个Nio2Channel
对象里。这个Nio2Channel
对象也被注入到Nio2SocketWrapper
对象里面,再将Nio2SocketWrapper
对象注入到SocketProcessor
私有类对象里面,然后在线程池中调用AbstractProtocol
处理套接字。
在Tomcat NIO2中,Nio2SocketWrapper
中的报文接受与回写也是阻塞式的。
对比一下
NIO2明显比NIO的结构要简单。这其实得归功于Proactor模式。Proactor模式屏蔽了事件细节,用户只需要明确事件和回调函数就可以了。Proactor框架可以自动触发回调函数。
而NIO的Reactor模式,需要用户自行处理事件发生之后的所有事情(Poller
线程)。
不过,NIO2虽然逻辑简单,但是其背后的Proactor的实现依赖于具体的操作系统环境。Windows内核提供了真正的Proactor API,而Linux的网络IO依然是Epoll。所以Linux环境下的NIO2实际上是在JDK内部对Epoll的封装。
因此说,NIO2和NIO的性能孰优孰劣,是需要测试才能明确的。
一点心得
Tomcat在处理请求的过程中,大量使用对象池缓存开销较大的对象,例如ByteBuffer
、byte[]
等。这个在实际运行中,可以感受到很明显的优化效果。我在Chrome的Postman插件中测试的时候发现,一般新启动的Tomcat在处理第一个请求的响应时间一般在200+ms,但是在处理第二个请求的时候,响应时间立刻缩短到了两位数。
这一点确实很值得学习。
参考资料
- 详解 Tomcat 配置文件 server.xml
- Apache Tomcat 9 Configuration Reference
- 异步通道 API
- 两种高性能I/O设计模式(Reactor/Proactor)的比较
- 聊聊Tomcat中的NIO2通道
附:如何嵌入式启动Tomcat
一开始研究Tomcat,发现嵌入式启动搞不定。有可能是因为我的Tomcat 9版本太新的缘故,网上找了很多嵌入式启动的代码都是无效的。后来在Spring Boot找到了代码才解决了这个问题。原来这些代码都没有设置Connector。
下面是我使用的调试代码。
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.apache.coyote.http2.Http2Protocol;
import org.apache.tomcat.util.net.Acceptor;
import org.apache.tomcat.util.net.Nio2Endpoint;
import org.apache.tomcat.util.net.NioEndpoint;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Writer;
public class App {
public static class HelloWorld extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
public void doGet(HttpServletRequest req, HttpServletResponse res)
throws IOException {
Writer w = res.getWriter();
w.write("Hello, Tomcat!");
w.flush();
w.close();
}
}
public static void main(String[] args) throws LifecycleException {
Tomcat tomcat = new Tomcat();
String mWorkingDir = System.getProperty("java.io.tmpdir");
tomcat.setBaseDir(mWorkingDir);
Connector connector = new Connector("org.apache.coyote.http11.Http11Nio2Protocol");
connector.setPort(8080);
tomcat.setConnector(connector);
Context ctx = tomcat.addContext("/1", null);
Tomcat.addServlet(ctx, "hi", new HelloWorld());
ctx.addServletMappingDecoded("/hi", "hi", false);
tomcat.start();
tomcat.getServer().await();
}
}
访问http://localhost:8080/1/hi
应该能够得到响应Hello, Tomcat!
。