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!。