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里面有多个容器类型:EngineHostContext等。三者为嵌套关系。

  1. Enigne负责处理Service的所有请求,每个Service里面只能有一个Enigne
  2. Host代表虚拟主机,关联于一个特定的域名。一个Engine里面可以有多个Host,每一个Engine里必须有且只有一个Host对应于defaultHost
  3. Context容器关联于一个servlet context

这次的主角是Coyote

Coyote的协议类

如前所述,每一个协议类同时指定了IO机制(NIO,NIO2,APR)和协议类型(HTTP/1.1,AJP)。

Coyote的三种IO机制分别是指:

  1. NIO,指的是使用随JDK1.4发布的java.nio.*包下的组件开发出的Reactor多路复用通信模式。
  2. NIO2,指的是使用随JDK1.7发布的java.nio.*包下的组件开发出的Proactor异步通信模式。
  3. 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的调用链

Http11ProcessorsocketsocketAcceptorAcceptorAbstractEndpointAbstractEndpointAbstractProtocolAbstractProtocolProcessorProcessorHttp11InputBufferHttp11InputBufferHttp11OutputBufferHttp11OutputBufferCoyoteAdapterCoyoteAdapter客户端请求接受请求套接字可读确定应用层协议后,将报文交给合适的协议处理器完成报文解析Catalina处理请求回传响应

上图是HTTP/1.1协议的NIO/NIO2的整体调用链示意图。

可以看出Coyote NIO大量使用模版模式,整体框架使用接口、抽象类组织。

  • Acceptor是一个线程实例,只用来接受套接字。如果已经建立的套接字达到了连接最大限制,就会等待。
  • AbstractEndpoint是底层通信框架的抽象类,NIO和NIO2的区别就在这个类,两者分别对应于NioEndpointNioEndpoint
  • AbstractProtocol就是前面说的协议类的抽象类,其中AbstractEndpointProcessor均为其实例属性。参考下方Http11Nio2Protocol类图。
  • Processor是所有协议处理器的统一抽象,而HTTP/1.1协议和AJP协议的处理器均直接继承了AbstractProcessor抽象类。
  • Processor完成协议处理后,就将报文提交给了Container,交互接口是Adapter。也就是说用户如过不想用Tomcat的Catalina容器,可以自行实现Adapter接口。不过Tomcat的Connector类默认的Adapter实现就是CoyoteAdapter,而且是硬编码,所以还得实现Connector子类覆盖相关方法。
AbstractProtocolAbstractHttp11ProtocolHttp11Nio2ProtocolHttp11ProcessorNio2Endpoint

NIO vs NIO2

NIOEndpoint的实现

NIOEndpoint使用Java NIO来实现,是一种Reactor模式。

NioEndpointNioSelectorPoolwrite(...)read(...)Pollerrun()register(NioChannel socket)processKey(...)PollerEventrun()SocketProcessordoRun()NioSocketWrapperfillReadBuffer(...)doWrite(...)NioChannel

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模式。

Nio2EndpointNio2SocketWrapperSocketProcessorNio2Channel

尽管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在处理请求的过程中,大量使用对象池缓存开销较大的对象,例如ByteBufferbyte[]等。这个在实际运行中,可以感受到很明显的优化效果。我在Chrome的Postman插件中测试的时候发现,一般新启动的Tomcat在处理第一个请求的响应时间一般在200+ms,但是在处理第二个请求的时候,响应时间立刻缩短到了两位数。

这一点确实很值得学习。

参考资料

附:如何嵌入式启动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!