Jetty

etty 是 Eclipse 基金会的一个开源项目,和 Tomcat 一样,Jetty 也是一个“HTTP 服务器 + Servlet 容器”,并且 Jetty 和 Tomcat 在架构设计上有不少相似的地方。但同时 Jetty 也有自己的特点,主要是更加小巧,更易于定制化。Jetty 作为一名后起之秀,应用范围也越来越广,比如 Google App Engine 就采用了 Jetty 来作为 Web 容器。

Jetty 整体架构

简单来说,Jetty Server 就是由多个 Connector(连接器)、多个 Handler(处理器),以及一个线程池组成。整体结构请看下面这张图。
notion image
跟 Tomcat 一样,Jetty 也有 HTTP 服务器和 Servlet 容器的功能,因此 Jetty 中的 Connector 组件和 Handler 组件分别来实现这两个功能,而这两个组件工作时所需要的线程资源都直接从一个全局线程池 ThreadPool 中获取。
Jetty Server 可以有多个 Connector 在不同的端口上监听客户请求,而对于请求处理的 Handler 组件,也可以根据具体场景使用不同的 Handler。这样的设计提高了 Jetty 的灵活性,需要支持 Servlet,则可以使用 ServletHandler;需要支持 Session,则再增加一个 SessionHandler。也就是说我们可以不使用 Servlet 或者 Session,只要不配置这个 Handler 就行了。
为了启动和协调上面的核心组件工作,Jetty 提供了一个 Server 类来做这个事情,它负责创建并初始化 Connector、Handler、ThreadPool 组件,然后调用 start 方法启动它们。
我们对比一下 Tomcat 的整体架构图,你会发现 Tomcat 在整体上跟 Jetty 很相似,它们的第一个区别是 Jetty 中没有 Service 的概念,Tomcat 中的 Service 包装了多个连接器和一个容器组件,一个 Tomcat 实例可以配置多个 Service,不同的 Service 通过不同的连接器监听不同的端口;而 Jetty 中 Connector 是被所有 Handler 共享的。
notion image
它们的第二个区别是,在 Tomcat 中每个连接器都有自己的线程池,而在 Jetty 中所有的 Connector 共享一个全局的线程池。
讲完了 Jetty 的整体架构,接下来我来详细分析 Jetty 的 Connector 组件的设计,下一期我将分析 Handler 组件的设计。

Connector 组件

跟 Tomcat 一样,Connector 的主要功能是对 I/O 模型和应用层协议的封装。
I/O 模型方面,最新的 Jetty 9 版本只支持 NIO,因此 Jetty 的 Connector 设计有明显的 Java NIO 通信模型的痕迹。至于应用层协议方面,跟 Tomcat 的 Processor 一样,Jetty 抽象出了 Connection 组件来封装应用层协议的差异。
Java NIO 早已成为程序员的必备技能,并且也经常出现在面试题中。接下来我们一起来看看 Jetty 是如何实现 NIO 模型的,以及它是怎么Java NIO 的。
Java NIO 的核心组件是 Channel、Buffer 和 Selector。Channel 表示一个连接,可以理解为一个 Socket,通过它可以读取和写入数据,但是并不能直接操作数据,需要通过 Buffer 来中转。
Selector 可以用来检测 Channel 上的 I/O 事件,比如读就绪、写就绪、连接就绪,一个 Selector 可以同时处理多个 Channel,因此单个线程可以监听多个 Channel,这样会大量减少线程上下文切换的开销。下面我们通过一个典型的服务端 NIO 程序来回顾一下如何使用这些组件。
首先,创建服务端 Channel,绑定监听端口并把 Channel 设置为非阻塞方式。
ServerSocketChannel server = ServerSocketChannel.open(); server.socket().bind(new InetSocketAddress(port)); server.configureBlocking(false);
然后,创建 Selector,并在 Selector 中注册 Channel 感兴趣的事件 OP_ACCEPT,告诉 Selector 如果客户端有新的连接请求到这个端口就通知我。
Selector selector = Selector.open(); server.register(selector, SelectionKey.OP_ACCEPT);
接下来,Selector 会在一个死循环里不断地调用 select() 去查询 I/O 状态,select() 会返回一个 SelectionKey 列表,Selector 会遍历这个列表,看看是否有“客户”感兴趣的事件,如果有,就采取相应的动作。
比如下面这个例子,如果有新的连接请求,就会建立一个新的连接。连接建立后,再注册 Channel 的可读事件到 Selector 中,告诉 Selector 我对这个 Channel 上是否有新的数据到达感兴趣。
while (true) { selector.select();// 查询 I/O 事件 for (Iterator<SelectionKey> i = selector.selectedKeys().iterator(); i.hasNext();) { SelectionKey key = i.next(); i.remove(); if (key.isAcceptable()) { // 建立一个新连接 SocketChannel client = server.accept(); client.configureBlocking(false); // 连接建立后,告诉 Selector,我现在对 I/O 可读事件感兴趣 client.register(selector, SelectionKey.OP_READ); } } }
简单回顾完服务端 NIO 编程之后,你会发现服务端在 I/O 通信上主要完成了三件事情:监听连接、I/O 事件查询以及数据读写。因此 Jetty 设计了Acceptor、SelectorManager 和 Connection 来分别做这三件事情,下面我分别来说说这三个组件。

Acceptor

顾名思义,Acceptor 用于接受请求,跟 Tomcat 一样,Jetty 也有独立的 Acceptor 线程组用于处理连接请求。在 Connector 的实现类 ServerConnector 中,有一个_acceptors的数组,在 Connector 启动的时候, 会根据_acceptors数组的长度创建对应数量的 Acceptor,而 Acceptor 的个数可以配置。
for (int i = 0; i < _acceptors.length; i++) { Acceptor a = new Acceptor(i); getExecutor().execute(a); }
Acceptor 是 ServerConnector 中的一个内部类,同时也是一个 Runnable,Acceptor 线程是通过 getExecutor() 得到的线程池来执行的,前面提到这是一个全局的线程池。
Acceptor 通过阻塞的方式来接受连接,这一点跟 Tomcat 也是一样的。
public void accept(int acceptorID) throws IOException { ServerSocketChannel serverChannel = _acceptChannel; if (serverChannel != null && serverChannel.isOpen()) { // 这里是阻塞的 SocketChannel channel = serverChannel.accept(); // 执行到这里时说明有请求进来了 accepted(channel); } }
接受连接成功后会调用 accepted() 函数,accepted() 函数中会将 SocketChannel 设置为非阻塞模式,然后交给 Selector 去处理,因此这也就到了 Selector 的地界了。
private void accepted(SocketChannel channel) throws IOException { channel.configureBlocking(false); Socket socket = channel.socket(); configure(socket); // _manager 是 SelectorManager 实例,里面管理了所有的 Selector 实例 _manager.accept(channel); }
SelectorManager
Jetty 的 Selector 由 SelectorManager 类管理,而被管理的 Selector 叫作 ManagedSelector。
SelectorManager 内部有一个 ManagedSelector 数组,真正干活的是 ManagedSelector。咱们接着上面分析,看看在 SelectorManager 在 accept 方法里做了什么。
public void accept(SelectableChannel channel, Object attachment) { // 选择一个 ManagedSelector 来处理 Channel final ManagedSelector selector = chooseSelector(); // 提交一个任务 Accept 给 ManagedSelector selector.submit(selector.new Accept(channel, attachment)); }
SelectorManager 从本身的 Selector 数组中选择一个 Selector 来处理这个 Channel,并创建一个任务 Accept 交给 ManagedSelector,ManagedSelector 在处理这个任务主要做了两步:
第一步,调用 Selector 的 register 方法把 Channel 注册到 Selector 上,拿到一个 SelectionKey。
_key = _channel.register(selector, SelectionKey.OP_ACCEPT, this);
第二步,创建一个 EndPoint 和 Connection,并跟这个 SelectionKey(Channel)绑在一起:
private void createEndPoint(SelectableChannel channel, SelectionKey selectionKey) throws IOException { //1. 创建 Endpoint EndPoint endPoint = _selectorManager.newEndPoint(channel, this, selectionKey); //2. 创建 Connection Connection connection = _selectorManager.newConnection(channel, endPoint, selectionKey.attachment()); //3. 把 Endpoint、Connection 和 SelectionKey 绑在一起 endPoint.setConnection(connection); selectionKey.attach(endPoint); }
上面这两个过程是什么意思呢?打个比方,你到餐厅吃饭,先点菜(注册 I/O 事件),服务员(ManagedSelector)给你一个单子(SelectionKey),等菜做好了(I/O 事件到了),服务员根据单子就知道是哪桌点了这个菜,于是喊一嗓子某某桌的菜做好了(调用了绑定在 SelectionKey 上的 EndPoint 的方法)。
这里需要你特别注意的是,ManagedSelector 并没有调用直接 EndPoint 的方法去处理数据,而是通过调用 EndPoint 的方法返回一个 Runnable,然后把这个 Runnable 扔给线程池执行,所以你能猜到,这个 Runnable 才会去真正读数据和处理请求。

Connection

这个 Runnable 是 EndPoint 的一个内部类,它会调用 Connection 的回调方法来处理请求。Jetty 的 Connection 组件类比就是 Tomcat 的 Processor,负责具体协议的解析,得到 Request 对象,并调用 Handler 容器进行处理。下面我简单介绍一下它的具体实现类 HttpConnection 对请求和响应的处理过程。
请求处理:HttpConnection 并不会主动向 EndPoint 读取数据,而是向在 EndPoint 中注册一堆回调方法:
getEndPoint().fillInterested(_readCallback);
这段代码就是告诉 EndPoint,数据到了你就调我这些回调方法 _readCallback 吧,有点异步 I/O 的感觉,也就是说 Jetty 在应用层面模拟了异步 I/O 模型。
而在回调方法 _readCallback 里,会调用 EndPoint 的接口去读数据,读完后让 HTTP 解析器去解析字节流,HTTP 解析器会将解析后的数据,包括请求行、请求头相关信息存到 Request 对象里。
响应处理:Connection 调用 Handler 进行业务处理,Handler 会通过 Response 对象来操作响应流,向流里面写入数据,HttpConnection 再通过 EndPoint 把数据写到 Channel,这样一次响应就完成了。
到此你应该了解了 Connector 的工作原理,下面我画张图再来回顾一下 Connector 的工作流程。
notion image
1.Acceptor 监听连接请求,当有连接请求到达时就接受连接,一个连接对应一个 Channel,Acceptor 将 Channel 交给 ManagedSelector 来处理。
2.ManagedSelector 把 Channel 注册到 Selector 上,并创建一个 EndPoint 和 Connection 跟这个 Channel 绑定,接着就不断地检测 I/O 事件。
3.I/O 事件到了就调用 EndPoint 的方法拿到一个 Runnable,并扔给线程池执行。
4. 线程池中调度某个线程执行 Runnable。
5.Runnable 执行时,调用回调函数,这个回调函数是 Connection 注册到 EndPoint 中的。
6. 回调函数内部实现,其实就是调用 EndPoint 的接口方法来读数据。
7.Connection 解析读到的数据,生成请求对象并交给 Handler 组件去处理。

Handler组件

Connector 会将 Servlet 请求交给 Handler 去处理,那 Handler 又是如何处理请求的呢?
Jetty 的 Handler 在设计上非常有意思,可以说是 Jetty 的灵魂,Jetty 通过 Handler 实现了高度可定制化,

Handler 是什么

Handler 就是一个接口,它有一堆实现类,Jetty 的 Connector 组件调用这些接口来处理 Servlet 请求,我们先来看看这个接口定义成什么样子。
public interface Handler extends LifeCycle, Destroyable { // 处理请求的方法 public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException; // 每个 Handler 都关联一个 Server 组件,被 Server 管理 public void setServer(Server server); public Server getServer(); // 销毁方法相关的资源 public void destroy(); }
你会看到 Handler 接口的定义非常简洁,主要就是用 handle 方法用来处理请求,跟 Tomcat 容器组件的 service 方法一样,它有 ServletRequest 和 ServeletResponse 两个参数。除此之外,这个接口中还有 setServer 和 getServer 方法,因为任何一个 Handler 都需要关联一个 Server 组件,也就是说 Handler 需要被 Server 组件来管理。一般来说 Handler 会加载一些资源到内存,因此通过设置 destroy 方法来销毁。

Handler 继承关系

Handler 只是一个接口,完成具体功能的还是它的子类。那么 Handler 有哪些子类呢?它们的继承关系又是怎样的?这些子类是如何实现 Servlet 容器功能的呢?
Jetty 中定义了一些默认 Handler 类,并且这些 Handler 类之间的继承关系比较复杂,我们先通过一个全景图来了解一下。为了避免让你感到不适,我对类图进行了简化。
notion image
从图上你可以看到,Handler 的种类和层次关系还是比较复杂的:
Handler 接口之下有抽象类 AbstractHandler,这一点并不意外,因为有接口一般就有抽象实现类。
在 AbstractHandler 之下有 AbstractHandlerContainer,为什么需要这个类呢?这其实是个过渡,为了实现链式调用,一个 Handler 内部必然要有其他 Handler 的引用,所以这个类的名字里才有 Container,意思就是这样的 Handler 里包含了其他 Handler 的引用。
理解了上面的 AbstractHandlerContainer,我们就能理解它的两个子类了:HandlerWrapper 和 HandlerCollection。简单来说就是,HandlerWrapper 和 HandlerCollection 都是 Handler,但是这些 Handler 里还包括其他 Handler 的引用。不同的是,HandlerWrapper 只包含一个其他 Handler 的引用,而 HandlerCollection 中有一个 Handler 数组的引用。
notion image
接着来看左边的 HandlerWrapper,它有两个子类:Server 和 ScopedHandler。Server 比较好理解,它本身是 Handler 模块的入口,必然要将请求传递给其他 Handler 来处理,为了触发其他 Handler 的调用,所以它是一个 HandlerWrapper。
再看 ScopedHandler,它也是一个比较重要的 Handler,实现了“具有上下文信息”的责任链调用。为什么我要强调“具有上下文信息”呢?那是因为 Servlet 规范规定 Servlet 在执行过程中是有上下文的。那么这些 Handler 在执行过程中如何访问这个上下文呢?这个上下文又存在什么地方呢?答案就是通过 ScopedHandler 来实现的。
而 ScopedHandler 有一堆的子类,这些子类就是用来实现 Servlet 规范的,比如 ServletHandler、ContextHandler、SessionHandler、ServletContextHandler 和 WebAppContext。接下来我会详细介绍它们,但我们先把总体类图看完。
请看类图的右边,跟 HandlerWapper 对等的还有 HandlerCollection,HandlerCollection 其实维护了一个 Handler 数组。你可能会问,为什么要发明一个这样的 Handler?这是因为 Jetty 可能需要同时支持多个 Web 应用,如果每个 Web 应用有一个 Handler 入口,那么多个 Web 应用的 Handler 就成了一个数组,比如 Server 中就有一个 HandlerCollection,Server 会根据用户请求的 URL 从数组中选取相应的 Handler 来处理,就是选择特定的 Web 应用来处理请求。
Handler 的类型
虽然从类图上看 Handler 有很多,但是本质上这些 Handler 分成三种类型:
  • 第一种是协调 Handler,这种 Handler 负责将请求路由到一组 Handler 中去,比如上图中的 HandlerCollection,它内部持有一个 Handler 数组,当请求到来时,它负责将请求转发到数组中的某一个 Handler。
  • 第二种是过滤器 Handler,这种 Handler 自己会处理请求,处理完了后再把请求转发到下一个 Handler,比如图上的 HandlerWrapper,它内部持有下一个 Handler 的引用。需要注意的是,所有继承了 HandlerWrapper 的 Handler 都具有了过滤器 Handler 的特征,比如 ContextHandler、SessionHandler 和 WebAppContext 等。
  • 第三种是内容 Handler,说白了就是这些 Handler 会真正调用 Servlet 来处理请求,生成响应的内容,比如 ServletHandler。如果浏览器请求的是一个静态资源,也有相应的 ResourceHandler 来处理这个请求,返回静态页面。

如何实现 Servlet 规范

上文提到,ServletHandler、ContextHandler 以及 WebAppContext 等,它们实现了 Servlet 规范,那具体是怎么实现的呢?为了帮助你理解,在这之前,我们还是来看看如何使用 Jetty 来启动一个 Web 应用。
// 新建一个 WebAppContext,WebAppContext 是一个 Handler WebAppContext webapp = new WebAppContext(); webapp.setContextPath("/mywebapp"); webapp.setWar("mywebapp.war"); // 将 Handler 添加到 Server 中去 server.setHandler(webapp); // 启动 Server server.start(); server.join();
上面的过程主要分为两步:
第一步创建一个 WebAppContext,接着设置一些参数到这个 Handler 中,就是告诉 WebAppContext 你的 WAR 包放在哪,Web 应用的访问路径是什么。
第二步就是把新创建的 WebAppContext 添加到 Server 中,然后启动 Server。
WebAppContext 对应一个 Web 应用。我们回忆一下 Servlet 规范中有 Context、Servlet、Filter、Listener 和 Session 等,Jetty 要支持 Servlet 规范,就需要有相应的 Handler 来分别实现这些功能。因此,Jetty 设计了 3 个组件:ContextHandler、ServletHandler 和 SessionHandler 来实现 Servle 规范中规定的功能,而WebAppContext 本身就是一个 ContextHandler,另外它还负责管理 ServletHandler 和 SessionHandler。
我们再来看一下什么是 ContextHandler。ContextHandler 会创建并初始化 Servlet 规范里的 ServletContext 对象,同时 ContextHandler 还包含了一组能够让你的 Web 应用运行起来的 Handler,可以这样理解,Context 本身也是一种 Handler,它里面包含了其他的 Handler,这些 Handler 能处理某个特定 URL 下的请求。比如,ContextHandler 包含了一个或者多个 ServletHandler。
再来看 ServletHandler,它实现了 Servlet 规范中的 Servlet、Filter 和 Listener 的功能。ServletHandler 依赖 FilterHolder、ServletHolder、ServletMapping、FilterMapping 这四大组件。FilterHolder 和 ServletHolder 分别是 Filter 和 Servlet 的包装类,每一个 Servlet 与路径的映射会被封装成 ServletMapping,而 Filter 与拦截 URL 的映射会被封装成 FilterMapping。
SessionHandler 从名字就知道它的功能,用来管理 Session。除此之外 WebAppContext 还有一些通用功能的 Handler,比如 SecurityHandler 和 GzipHandler,同样从名字可以知道这些 Handler 的功能分别是安全控制和压缩 / 解压缩。
WebAppContext 会将这些 Handler 构建成一个执行链,通过这个链会最终调用到我们的业务 Servlet。我们通过一张图来理解一下。
notion image
通过对比 Tomcat 的架构图,你可以看到,Jetty 的 Handler 组件和 Tomcat 中的容器组件是大致是对等的概念,Jetty 中的 WebAppContext 相当于 Tomcat 的 Context 组件,都是对应一个 Web 应用;而 Jetty 中的 ServletHandler 对应 Tomcat 中的 Wrapper 组件,它负责初始化和调用 Servlet,并实现了 Filter 的功能。
对于一些通用组件,比如安全和解压缩,在 Jetty 中都被做成了 Handler,这是 Jetty Handler 架构的特点。
因此对于 Jetty 来说,请求处理模块就被抽象成 Handler,不管是实现了 Servlet 规范的 Handler,还是实现通用功能的 Handler,比如安全、解压缩等,我们可以任意添加或者裁剪这些“功能模块”,从而实现高度的可定制化。
Jetty 的 Handler 设计是它的一大特色,Jetty 本质就是一个 Handler 管理器,Jetty 本身就提供了一些默认 Handler 来实现 Servlet 容器的功能,你也可以定义自己的 Handler 来添加到 Jetty 中,这体现了“微内核 + 插件”的设计思想。

从Tomcat和Jetty中提炼组件化设计规范

在当今的互联网时代,我们每个人获取信息的机会基本上都是平等的,但是为什么有些人对信息理解得更深,并且有自己独到的见解呢?我认为是因为他们养成了思考和总结的好习惯。当我们学习一门技术的时候,如果可以勤于思考、善于总结,可以帮助我们看到现象背后更本质的东西,让我们在成长之路上更快“脱颖而出”。
我们经常谈敏捷、快速迭代和重构,这些都是为了应对需求的快速变化,也因此我们在开始设计一个系统时就要考虑可扩展性。那究竟该怎样设计才能适应变化呢?或者要设计成什么样后面才能以最小的成本进行重构呢?今天我来总结一些 Tomcat 和 Jetty 组件化的设计思想,或许从中我们可以得到一些启发。

组件化及可配置

Tomcat 和 Jetty 的整体架构都是基于组件的,你可以通过 XML 文件或者代码的方式来配置这些组件,比如我们可以在 server.xml 配置 Tomcat 的连接器以及容器组件。相应的,你也可以在 Jetty.xml 文件里组装 Jetty 的 Connector 组件,以及各种 Handler 组件。也就是说,Tomcat 和 Jetty 提供了一堆积木,怎么搭建这些积木由你来决定,你可以根据自己的需要灵活选择组件来搭建你的 Web 容器,并且也可以自定义组件,这样的设计为 Web 容器提供了深度可定制化。
那 Web 容器如何实现这种组件化设计呢?我认为有两个要点:
  • 第一个是面向接口编程。我们需要对系统的功能按照“高内聚、低耦合”的原则进行拆分,每个组件都有相应的接口,组件之间通过接口通信,这样就可以方便地替换组件了。比如我们可以选择不同连接器类型,只要这些连接器组件实现同一个接口就行。
  • 第二个是 Web 容器提供一个载体把组件组装在一起工作。组件的工作无非就是处理请求,因此容器通过责任链模式把请求依次交给组件去处理。对于用户来说,我只需要告诉 Web 容器由哪些组件来处理请求。把组件组织起来需要一个“管理者”,这就是为什么 Tomcat 和 Jetty 都有一个 Server 的概念,Server 就是组件的载体,Server 里包含了连接器组件和容器组件;容器还需要把请求交给各个子容器组件去处理,Tomcat 和 Jetty 都是责任链模式来实现的。
用户通过配置来组装组件,跟 Spring 中 Bean 的依赖注入相似。Spring 的用户可以通过配置文件或者注解的方式来组装 Bean,Bean 与 Bean 的依赖关系完全由用户自己来定义。这一点与 Web 容器不同,Web 容器中组件与组件之间的关系是固定的,比如 Tomcat 中 Engine 组件下有 Host 组件、Host 组件下有 Context 组件等,但你不能在 Host 组件里“注入”一个 Wrapper 组件,这是由于 Web 容器本身的功能来决定的。

组件的创建

由于组件是可以配置的,Web 容器在启动之前并不知道要创建哪些组件,也就是说,不能通过硬编码的方式来实例化这些组件,而是需要通过反射机制来动态地创建。具体来说,Web 容器不是通过 new 方法来实例化组件对象的,而是通过 Class.forName 来创建组件。无论哪种方式,在实例化一个类之前,Web 容器需要把组件类加载到 JVM,这就涉及一个类加载的问题,Web 容器设计了自己类加载器,我会在专栏后面的文章详细介绍 Tomcat 的类加载器。
Spring 也是通过反射机制来动态地实例化 Bean,那么它用到的类加载器是从哪里来的呢?Web 容器给每个 Web 应用创建了一个类加载器,Spring 用到的类加载器是 Web 容器传给它的。

组件的生命周期管理

不同类型的组件具有父子层次关系,父组件处理请求后再把请求传递给某个子组件。你可能会感到疑惑,Jetty 的中 Handler 不是一条链吗,看上去像是平行关系?其实不然,Jetty 中的 Handler 也是分层次的,比如 WebAppContext 中包含 ServletHandler 和 SessionHandler。因此你也可以把 ContextHandler 和它所包含的 Handler 看作是父子关系。
而 Tomcat 通过容器的概念,把小容器放到大容器来实现父子关系,其实它们的本质都是一样的。这其实涉及如何统一管理这些组件,如何做到一键式启停。
Tomcat 和 Jetty 都采用了类似的办法来管理组件的生命周期,主要有两个要点,一是父组件负责子组件的创建、启停和销毁。这样只要启动最上层组件,整个 Web 容器就被启动起来了,也就实现了一键式启停;二是 Tomcat 和 Jetty 都定义了组件的生命周期状态,并且把组件状态的转变定义成一个事件,一个组件的状态变化会触发子组件的变化,比如 Host 容器的启动事件里会触发 Web 应用的扫描和加载,最终会在 Host 容器下创建相应的 Context 容器,而 Context 组件的启动事件又会触发 Servlet 的扫描,进而创建 Wrapper 组件。那么如何实现这种联动呢?答案是观察者模式。具体来说就是创建监听器去监听容器的状态变化,在监听器的方法里去实现相应的动作,这些监听器其实是组件生命周期过程中的“扩展点”。
Spring 也采用了类似的设计,Spring 给 Bean 生命周期状态提供了很多的“扩展点”。这些扩展点被定义成一个个接口,只要你的 Bean 实现了这些接口,Spring 就会负责调用这些接口,这样做的目的就是,当 Bean 的创建、初始化和销毁这些控制权交给 Spring 后,Spring 让你有机会在 Bean 的整个生命周期中执行你的逻辑。下面我通过一张图帮你理解 Spring Bean 的生命周期过程:
notion image

组件的骨架抽象类和模板模式

具体到组件的设计的与实现,Tomcat 和 Jetty 都大量采用了骨架抽象类和模板模式。比如说 Tomcat 中 ProtocolHandler 接口,ProtocolHandler 有抽象基类 AbstractProtocol,它实现了协议处理层的骨架和通用逻辑,而具体协议也有抽象基类,比如 HttpProtocol 和 AjpProtocol。对于 Jetty 来说,Handler 接口之下有 AbstractHandler,Connector 接口之下有 AbstractorConnector,这些抽象骨架类实现了一些通用逻辑,并且会定义一些抽象方法,这些抽象方法由子类实现,抽象骨架类调用抽象方法来实现骨架逻辑。
这是一个通用的设计规范,不管是 Web 容器还是 Spring,甚至 JDK 本身都到处使用这种设计,比如 Java 集合中的 AbstractSet、AbstractMap 等。 值得一提的是,从 Java 8 开始允许接口有 default 方法,这样我们可以把抽象骨架类的通用逻辑放到接口中去。
Tomcat 和 Jetty 有很多共同点,并且 Spring 框架的设计也有不少相似的的地方,这正好说明了 Web 开发中有一些本质的东西是相通的,只要你深入理解了一个技术,也就是在一个点上突破了深度,再扩展广度就不是难事。

Jetty的线程策略EatWhatYouKill

Jetty 的 Connector 支持 NIO 通信模型,我们知道NIO 模型中的主角就是 Selector,Jetty 在 Java 原生 Selector 的基础上封装了自己的 Selector,叫作 ManagedSelector。ManagedSelector 在线程策略方面做了大胆尝试,将 I/O 事件的侦测和处理放到同一个线程来处理,充分利用了 CPU 缓存并减少了线程上下文切换的开销。
具体的数字是,根据 Jetty 的官方测试,这种名为“EatWhatYouKill”的线程策略将吞吐量提高了 8 倍。

Selector 编程的一般思路

常规的 NIO 编程思路是,将 I/O 事件的侦测和请求的处理分别用不同的线程处理。具体过程是:
启动一个线程,在一个死循环里不断地调用 select 方法,检测 Channel 的 I/O 状态,一旦 I/O 事件达到,比如数据就绪,就把该 I/O 事件以及一些数据包装成一个 Runnable,将 Runnable 放到新线程中去处理。
在这个过程中按照职责划分,有两个线程在干活,一个是 I/O 事件检测线程,另一个是 I/O 事件处理线程。我们仔细思考一下这两者的关系,其实它们是生产者和消费者的关系。I/O 事件侦测线程作为生产者,负责“生产”I/O 事件,也就是负责接活儿的老板;I/O 处理线程是消费者,它“消费”并处理 I/O 事件,就是干苦力的员工。把这两个工作用不同的线程来处理,好处是它们互不干扰和阻塞对方。

Jetty 中的 Selector 编程

然而世事无绝对,将 I/O 事件检测和业务处理这两种工作分开的思路也有缺点。当 Selector 检测读就绪事件时,数据已经被拷贝到内核中的缓存了,同时 CPU 的缓存中也有这些数据了,我们知道 CPU 本身的缓存比内存快多了,这时当应用程序去读取这些数据时,如果用另一个线程去读,很有可能这个读线程使用另一个 CPU 核,而不是之前那个检测数据就绪的 CPU 核,这样 CPU 缓存中的数据就用不上了,并且线程切换也需要开销。
因此 Jetty 的 Connector 做了一个大胆尝试,那就是用把 I/O 事件的生产和消费放到同一个线程来处理,如果这两个任务由同一个线程来执行,如果执行过程中线程不阻塞,操作系统会用同一个 CPU 核来执行这两个任务,这样就能利用 CPU 缓存了。那具体是如何做的呢,我们还是来详细分析一下 Connector 中的 ManagedSelector 组件。

ManagedSelector

ManagedSelector 的本质就是一个 Selector,负责 I/O 事件的检测和分发。为了方便使用,Jetty 在 Java 原生的 Selector 上做了一些扩展,就变成了 ManagedSelector,我们先来看看它有哪些成员变量:
public class ManagedSelector extends ContainerLifeCycle implements Dumpable { // 原子变量,表明当前的 ManagedSelector 是否已经启动 private final AtomicBoolean _started = new AtomicBoolean(false); // 表明是否阻塞在 select 调用上 private boolean _selecting = false; // 管理器的引用,SelectorManager 管理若干 ManagedSelector 的生命周期 private final SelectorManager _selectorManager; //ManagedSelector 不止一个,为它们每人分配一个 id private final int _id; // 关键的执行策略,生产者和消费者是否在同一个线程处理由它决定 private final ExecutionStrategy _strategy; //Java 原生的 Selector private Selector _selector; //"Selector 更新任务 " 队列 private Deque<SelectorUpdate> _updates = new ArrayDeque<>(); private Deque<SelectorUpdate> _updateable = new ArrayDeque<>(); ... }
这些成员变量中其他的都好理解,就是“Selector 更新任务”队列_updates和执行策略_strategy可能不是很直观。

SelectorUpdate 接口

为什么需要一个“Selector 更新任务”队列呢,对于 Selector 的用户来说,我们对 Selector 的操作无非是将 Channel 注册到 Selector 或者告诉 Selector 我对什么 I/O 事件感兴趣,那么这些操作其实就是对 Selector 状态的更新,Jetty 把这些操作抽象成 SelectorUpdate 接口。
/** * A selector update to be done when the selector has been woken. */ public interface SelectorUpdate { void update(Selector selector); }
这意味着如果你不能直接操作 ManageSelector 中的 Selector,而是需要向 ManagedSelector 提交一个任务类,这个类需要实现 SelectorUpdate 接口 update 方法,在 update 方法里定义你想要对 ManagedSelector 做的操作。
比如 Connector 中 Endpoint 组件对读就绪事件感兴趣,它就向 ManagedSelector 提交了一个内部任务类 ManagedSelector.SelectorUpdate:
_selector.submit(_updateKeyAction);
这个_updateKeyAction就是一个 SelectorUpdate 实例,它的 update 方法实现如下:
private final ManagedSelector.SelectorUpdate _updateKeyAction = new ManagedSelector.SelectorUpdate() { @Override public void update(Selector selector) { // 这里的 updateKey 其实就是调用了 SelectionKey.interestOps(OP_READ); updateKey(); } };
我们看到在 update 方法里,调用了 SelectionKey 类的 interestOps 方法,传入的参数是OP_READ,意思是现在我对这个 Channel 上的读就绪事件感兴趣了。
那谁来负责执行这些 update 方法呢,答案是 ManagedSelector 自己,它在一个死循环里拉取这些 SelectorUpdate 任务类逐个执行。

Selectable 接口

那 I/O 事件到达时,ManagedSelector 怎么知道应该调哪个函数来处理呢?其实也是通过一个任务类接口,这个接口就是 Selectable,它返回一个 Runnable,这个 Runnable 其实就是 I/O 事件就绪时相应的处理逻辑。
public interface Selectable { // 当某一个 Channel 的 I/O 事件就绪后,ManagedSelector 会调用的回调函数 Runnable onSelected(); // 当所有事件处理完了之后 ManagedSelector 会调的回调函数,我们先忽略。 void updateKey(); }
ManagedSelector 在检测到某个 Channel 上的 I/O 事件就绪时,也就是说这个 Channel 被选中了,ManagedSelector 调用这个 Channel 所绑定的附件类的 onSelected 方法来拿到一个 Runnable。
这句话有点绕,其实就是 ManagedSelector 的使用者,比如 Endpoint 组件在向 ManagedSelector 注册读就绪事件时,同时也要告诉 ManagedSelector 在事件就绪时执行什么任务,具体来说就是传入一个附件类,这个附件类需要实现 Selectable 接口。ManagedSelector 通过调用这个 onSelected 拿到一个 Runnable,然后把 Runnable 扔给线程池去执行。
那 Endpoint 的 onSelected 是如何实现的呢?
@Override public Runnable onSelected() { int readyOps = _key.readyOps(); boolean fillable = (readyOps & SelectionKey.OP_READ) != 0; boolean flushable = (readyOps & SelectionKey.OP_WRITE) != 0; // return task to complete the job Runnable task= fillable ? (flushable ? _runCompleteWriteFillable : _runFillable) : (flushable ? _runCompleteWrite : null); return task; }
上面的代码逻辑很简单,就是读事件到了就读,写事件到了就写。

ExecutionStrategy

铺垫了这么多,终于要上主菜了。前面我主要介绍了 ManagedSelector 的使用者如何跟 ManagedSelector 交互,也就是如何注册 Channel 以及 I/O 事件,提供什么样的处理类来处理 I/O 事件,接下来我们来看看 ManagedSelector 是如何统一管理和维护用户注册的 Channel 集合。再回到今天开始的讨论,ManagedSelector 将 I/O 事件的生产和消费看作是生产者消费者模式,为了充分利用 CPU 缓存,生产和消费尽量放到同一个线程处理,那这是如何实现的呢?Jetty 定义了 ExecutionStrategy 接口:
public interface ExecutionStrategy { // 只在 HTTP2 中用到,简单起见,我们先忽略这个方法。 public void dispatch(); // 实现具体执行策略,任务生产出来后可能由当前线程执行,也可能由新线程来执行 public void produce(); // 任务的生产委托给 Producer 内部接口, public interface Producer { // 生产一个 Runnable(任务) Runnable produce(); } }
我们看到 ExecutionStrategy 接口比较简单,它将具体任务的生产委托内部接口 Producer,而在自己的 produce 方法里来实现具体执行逻辑,也就是生产出来的任务要么由当前线程执行,要么放到新线程中执行。Jetty 提供了一些具体策略实现类:ProduceConsume、ProduceExecuteConsume、ExecuteProduceConsume 和 EatWhatYouKill。它们的区别是:
  • ProduceConsume:任务生产者自己依次生产和执行任务,对应到 NIO 通信模型就是用一个线程来侦测和处理一个 ManagedSelector 上所有的 I/O 事件,后面的 I/O 事件要等待前面的 I/O 事件处理完,效率明显不高。通过图来理解,图中绿色表示生产一个任务,蓝色表示执行这个任务。
    • notion image
  • ProduceExecuteConsume:任务生产者开启新线程来运行任务,这是典型的 I/O 事件侦测和处理用不同的线程来处理,缺点是不能利用 CPU 缓存,并且线程切换成本高。同样我们通过一张图来理解,图中的棕色表示线程切换。
    • notion image
  • ExecuteProduceConsume:任务生产者自己运行任务,但是该策略可能会新建一个新线程以继续生产和执行任务。这种策略也被称为“吃掉你杀的猎物”,它来自狩猎伦理,认为一个人不应该杀死他不吃掉的东西,对应线程来说,不应该生成自己不打算运行的任务。它的优点是能利用 CPU 缓存,但是潜在的问题是如果处理 I/O 事件的业务代码执行时间过长,会导致线程大量阻塞和线程饥饿。
    • notion image
  • EatWhatYouKill:这是 Jetty 对 ExecuteProduceConsume 策略的改良,在线程池线程充足的情况下等同于 ExecuteProduceConsume;当系统比较忙线程不够时,切换成 ProduceExecuteConsume 策略。
    • 为什么要这么做呢,原因是 ExecuteProduceConsume 是在同一线程执行 I/O 事件的生产和消费,它使用的线程来自 Jetty 全局的线程池,这些线程有可能被业务代码阻塞,如果阻塞得多了,全局线程池中的线程自然就不够用了,最坏的情况是连 I/O 事件的侦测都没有线程可用了,会导致 Connector 拒绝浏览器请求。
      于是 Jetty 做了一个优化,在低线程情况下,就执行 ProduceExecuteConsume 策略,I/O 侦测用专门的线程处理,I/O 事件的处理扔给线程池处理,其实就是放到线程池的队列里慢慢处理。
分析了这几种线程策略,我们再来看看 Jetty 是如何实现 ExecutionStrategy 接口的。答案其实就是实现 produce 接口生产任务,一旦任务生产出来,ExecutionStrategy 会负责执行这个任务。
private class SelectorProducer implements ExecutionStrategy.Producer { private Set<SelectionKey> _keys = Collections.emptySet(); private Iterator<SelectionKey> _cursor = Collections.emptyIterator(); @Override public Runnable produce() { while (true) { // 如何 Channel 集合中有 I/O 事件就绪,调用前面提到的 Selectable 接口获取 Runnable, 直接返回给 ExecutionStrategy 去处理 Runnable task = processSelected(); if (task != null) return task; // 如果没有 I/O 事件就绪,就干点杂活,看看有没有客户提交了更新 Selector 的任务,就是上面提到的 SelectorUpdate 任务类。 processUpdates(); updateKeys(); // 继续执行 select 方法,侦测 I/O 就绪事件 if (!select()) return null; } } }
SelectorProducer 是 ManagedSelector 的内部类,SelectorProducer 实现了 ExecutionStrategy 中的 Producer 接口中的 produce 方法,需要向 ExecutionStrategy 返回一个 Runnable。在这个方法里 SelectorProducer 主要干了三件事情
  1. 如果 Channel 集合中有 I/O 事件就绪,调用前面提到的 Selectable 接口获取 Runnable,直接返回给 ExecutionStrategy 去处理。
  1. 如果没有 I/O 事件就绪,就干点杂活,看看有没有客户提交了更新 Selector 上事件注册的任务,也就是上面提到的 SelectorUpdate 任务类。
  1. 干完杂活继续执行 select 方法,侦测 I/O 就绪事件。

Tomcat和Jetty中的对象池技术

 
Java 对象,特别是一个比较大、比较复杂的 Java 对象,它们的创建、初始化和 GC 都需要耗费 CPU 和内存资源,为了减少这些开销,Tomcat 和 Jetty 都使用了对象池技术。所谓的对象池技术,就是说一个 Java 对象用完之后把它保存起来,之后再拿出来重复使用,省去了对象创建、初始化和 GC 的过程。对象池技术是典型的以空间换时间的思路。
由于维护对象池本身也需要资源的开销,不是所有场景都适合用对象池。如果你的 Java 对象数量很多并且存在的时间比较短,对象本身又比较大比较复杂,对象初始化的成本比较高,这样的场景就适合用对象池技术。比如 Tomcat 和 Jetty 处理 HTTP 请求的场景就符合这个特征,请求的数量很多,为了处理单个请求需要创建不少的复杂对象(比如 Tomcat 连接器中 SocketWrapper 和 SocketProcessor),而且一般来说请求处理的时间比较短,一旦请求处理完毕,这些对象就需要被销毁,因此这个场景适合对象池技术。

Tomcat 的 SynchronizedStack

Tomcat 用 SynchronizedStack 类来实现对象池,下面我贴出它的关键代码来帮助你理解。
public class SynchronizedStack<T> { // 内部维护一个对象数组, 用数组实现栈的功能 private Object[] stack; // 这个方法用来归还对象,用 synchronized 进行线程同步 public synchronized boolean push(T obj) { index++; if (index == size) { if (limit == -1 || size < limit) { expand();// 对象不够用了,扩展对象数组 } else { index--; return false; } } stack[index] = obj; return true; } // 这个方法用来获取对象 public synchronized T pop() { if (index == -1) { return null; } T result = (T) stack[index]; stack[index--] = null; return result; } // 扩展对象数组长度,以 2 倍大小扩展 private void expand() { int newSize = size * 2; if (limit != -1 && newSize > limit) { newSize = limit; } // 扩展策略是创建一个数组长度为原来两倍的新数组 Object[] newStack = new Object[newSize]; // 将老数组对象引用复制到新数组 System.arraycopy(stack, 0, newStack, 0, size); // 将 stack 指向新数组,老数组可以被 GC 掉了 stack = newStack; size = newSize; } }
这个代码逻辑比较清晰,主要是 SynchronizedStack 内部维护了一个对象数组,并且用数组来实现栈的接口:push 和 pop 方法,这两个方法分别用来归还对象和获取对象。你可能好奇为什么 Tomcat 使用一个看起来比较简单的 SynchronizedStack 来做对象容器,为什么不使用高级一点的并发容器比如 ConcurrentLinkedQueue 呢?
这是因为 SynchronizedStack 用数组而不是链表来维护对象,可以减少结点维护的内存开销,并且它本身只支持扩容不支持缩容,也就是说数组对象在使用过程中不会被重新赋值,也就不会被 GC。这样设计的目的是用最低的内存和 GC 的代价来实现无界容器,同时 Tomcat 的最大同时请求数是有限制的,因此不需要担心对象的数量会无限膨胀。

Jetty 的 ByteBufferPool

我们再来看 Jetty 中的对象池 ByteBufferPool,它本质是一个 ByteBuffer 对象池。当 Jetty 在进行网络数据读写时,不需要每次都在 JVM 堆上分配一块新的 Buffer,只需在 ByteBuffer 对象池里拿到一块预先分配好的 Buffer,这样就避免了频繁的分配内存和释放内存。这种设计你同样可以在高性能通信中间件比如 Mina 和 Netty 中看到。ByteBufferPool 是一个接口:
public interface ByteBufferPool { public ByteBuffer acquire(int size, boolean direct); public void release(ByteBuffer buffer); }
接口中的两个方法:acquire 和 release 分别用来分配和释放内存,并且你可以通过 acquire 方法的 direct 参数来指定 buffer 是从 JVM 堆上分配还是从本地内存分配。ArrayByteBufferPool 是 ByteBufferPool 的实现类,我们先来看看它的成员变量和构造函数:
public class ArrayByteBufferPool implements ByteBufferPool { private final int _min;// 最小 size 的 Buffer 长度 private final int _maxQueue;//Queue 最大长度 // 用不同的 Bucket(桶) 来持有不同 size 的 ByteBuffer 对象, 同一个桶中的 ByteBuffer size 是一样的 private final ByteBufferPool.Bucket[] _direct; private final ByteBufferPool.Bucket[] _indirect; //ByteBuffer 的 size 增量 private final int _inc; public ArrayByteBufferPool(int minSize, int increment, int maxSize, int maxQueue) { // 检查参数值并设置默认值 if (minSize<=0)//ByteBuffer 的最小长度 minSize=0; if (increment<=0) increment=1024;// 默认以 1024 递增 if (maxSize<=0) maxSize=64*1024;//ByteBuffer 的最大长度默认是 64K //ByteBuffer 的最小长度必须小于增量 if (minSize>=increment) throw new IllegalArgumentException("minSize >= increment"); // 最大长度必须是增量的整数倍 if ((maxSize%increment)!=0 || increment>=maxSize) throw new IllegalArgumentException("increment must be a divisor of maxSize"); _min=minSize; _inc=increment; // 创建 maxSize/increment 个桶, 包含直接内存的与 heap 的 _direct=new ByteBufferPool.Bucket[maxSize/increment]; _indirect=new ByteBufferPool.Bucket[maxSize/increment]; _maxQueue=maxQueue; int size=0; for (int i=0;i<_direct.length;i++) { size+=_inc; _direct[i]=new ByteBufferPool.Bucket(this,size,_maxQueue); _indirect[i]=new ByteBufferPool.Bucket(this,size,_maxQueue); } } }
从上面的代码我们看到,ByteBufferPool 是用不同的桶(Bucket)来管理不同长度的 ByteBuffer,因为我们可能需要分配一块 1024 字节的 Buffer,也可能需要一块 64K 字节的 Buffer。而桶的内部用一个 ConcurrentLinkedDeque 来放置 ByteBuffer 对象的引用。
private final Deque<ByteBuffer> _queue = new ConcurrentLinkedDeque<>();
你可以通过下面的图再来理解一下:
notion image
而 Buffer 的分配和释放过程,就是找到相应的桶,并对桶中的 Deque 做出队和入队的操作,而不是直接向 JVM 堆申请和释放内存。
// 分配 Buffer public ByteBuffer acquire(int size, boolean direct) { // 找到对应的桶,没有的话创建一个桶 ByteBufferPool.Bucket bucket = bucketFor(size,direct); if (bucket==null) return newByteBuffer(size,direct); // 这里其实调用了 Deque 的 poll 方法 return bucket.acquire(direct); } // 释放 Buffer public void release(ByteBuffer buffer) { if (buffer!=null) { // 找到对应的桶 ByteBufferPool.Bucket bucket = bucketFor(buffer.capacity(),buffer.isDirect()); // 这里调用了 Deque 的 offerFirst 方法 if (bucket!=null) bucket.release(buffer); } }

对象池的思考

对象池作为全局资源,高并发环境中多个线程可能同时需要获取对象池中的对象,因此多个线程在争抢对象时会因为锁竞争而阻塞, 因此使用对象池有线程同步的开销,而不使用对象池则有创建和销毁对象的开销。
对于对象池本身的设计来说,需要尽量做到无锁化,比如 Jetty 就使用了 ConcurrentLinkedDeque。如果你的内存足够大,可以考虑用线程本地(ThreadLocal)对象池,这样每个线程都有自己的对象池,线程之间互不干扰。
为了防止对象池的无限膨胀,必须要对池的大小做限制。对象池太小发挥不了作用,对象池太大的话可能有空闲对象,这些空闲对象会一直占用内存,造成内存浪费。这里你需要根据实际情况做一个平衡,因此对象池本身除了应该有自动扩容的功能,还需要考虑自动缩容。
所有的池化技术,包括缓存,都会面临内存泄露的问题,原因是对象池或者缓存的本质是一个 Java 集合类,比如 List 和 Stack,这个集合类持有缓存对象的引用,只要集合类不被 GC,缓存对象也不会被 GC。维持大量的对象也比较占用内存空间,所以必要时我们需要主动清理这些对象。
以 Java 的线程池 ThreadPoolExecutor 为例,它提供了 allowCoreThreadTimeOut 和 setKeepAliveTime 两种方法,可以在超时后销毁线程,我们在实际项目中也可以参考这个策略。
另外在使用对象池时,我这里还有一些小贴士供你参考:
  • 对象在用完后,需要调用对象池的方法将对象归还给对象池。
  • 对象池中的对象在再次使用时需要重置,否则会产生脏对象,脏对象可能持有上次使用的引用,导致内存泄漏等问题,并且如果脏对象下一次使用时没有被清理,程序在运行过程中会发生意想不到的问题。
  • 对象一旦归还给对象池,使用者就不能对它做任何操作了。
  • 向对象池请求对象时有可能出现的阻塞、异常或者返回 null 值,这些都需要我们做一些额外的处理,来确保程序的正常运行。

Tomcat和Jetty的高性能、高并发之道

高性能程序就是高效的利用 CPU、内存、网络和磁盘等资源,在短时间内处理大量的请求。那如何衡量“短时间和大量”呢?其实就是两个关键指标:响应时间和每秒事务处理量(TPS)。
那什么是资源的高效利用呢? 我觉得有两个原则:
  1. 减少资源浪费。比如尽量避免线程阻塞,因为一阻塞就会发生线程上下文切换,就需要耗费 CPU 资源;再比如网络通信时数据从内核空间拷贝到 Java 堆内存,需要通过本地内存中转。
  1. 当某种资源成为瓶颈时,用另一种资源来换取。比如缓存和对象池技术就是用内存换 CPU;数据压缩后再传输就是用 CPU 换网络。
Tomcat 和 Jetty 中用到了大量的高性能、高并发的设计,我总结了几点:I/O 和线程模型、减少系统调用、池化、零拷贝、高效的并发编程。

I/O 和线程模型

I/O 模型的本质就是为了缓解 CPU 和外设之间的速度差。当线程发起 I/O 请求时,比如读写网络数据,网卡数据还没准备好,这个线程就会被阻塞,让出 CPU,也就是说发生了线程切换。而线程切换是无用功,并且线程被阻塞后,它持有内存资源并没有释放,阻塞的线程越多,消耗的内存就越大,因此 I/O 模型的目标就是尽量减少线程阻塞。Tomcat 和 Jetty 都已经抛弃了传统的同步阻塞 I/O,采用了非阻塞 I/O 或者异步 I/O,目的是业务线程不需要阻塞在 I/O 等待上。
除了 I/O 模型,线程模型也是影响性能和并发的关键点。Tomcat 和 Jetty 的总体处理原则是:
  • 连接请求由专门的 Acceptor 线程组处理。
  • I/O 事件侦测也由专门的 Selector 线程组来处理。
  • 具体的协议解析和业务处理可能交给线程池(Tomcat),或者交给 Selector 线程来处理(Jetty)。
将这些事情分开的好处是解耦,并且可以根据实际情况合理设置各部分的线程数。这里请你注意,线程数并不是越多越好,因为 CPU 核的个数有限,线程太多也处理不过来,会导致大量的线程上下文切换。

减少系统调用

其实系统调用是非常耗资源的一个过程,涉及 CPU 从用户态切换到内核态的过程,因此我们在编写程序的时候要有意识尽量避免系统调用。比如在 Tomcat 和 Jetty 中,系统调用最多的就是网络通信操作了,一个 Channel 上的 write 就是系统调用,为了降低系统调用的次数,最直接的方法就是使用缓冲,当输出数据达到一定的大小才 flush 缓冲区。Tomcat 和 Jetty 的 Channel 都带有输入输出缓冲区。
还有值得一提的是,Tomcat 和 Jetty 在解析 HTTP 协议数据时, 都采取了延迟解析的策略,HTTP 的请求体(HTTP Body)直到用的时候才解析。也就是说,当 Tomcat 调用 Servlet 的 service 方法时,只是读取了和解析了 HTTP 请求头,并没有读取 HTTP 请求体。
直到你的 Web 应用程序调用了 ServletRequest 对象的 getInputStream 方法或者 getParameter 方法时,Tomcat 才会去读取和解析 HTTP 请求体中的数据;这意味着如果你的应用程序没有调用上面那两个方法,HTTP 请求体的数据就不会被读取和解析,这样就省掉了一次 I/O 系统调用。

池化、零拷贝

关于池化和零拷贝,其实池化的本质就是用内存换 CPU;而零拷贝就是不做无用功,减少资源浪费。

高效的并发编程

我们知道并发的过程中为了同步多个线程对共享变量的访问,需要加锁来实现。而锁的开销是比较大的,拿锁的过程本身就是个系统调用,如果锁没拿到线程会阻塞,又会发生线程上下文切换,尤其是大量线程同时竞争一把锁时,会浪费大量的系统资源。因此作为程序员,要有意识的尽量避免锁的使用,比如可以使用原子类 CAS 或者并发集合来代替。如果万不得已需要用到锁,也要尽量缩小锁的范围和锁的强度。接下来我们来看看 Tomcat 和 Jetty 如何做到高效的并发编程的。

缩小锁的范围

缩小锁的范围,其实就是不直接在方法上加 synchronized,而是使用细粒度的对象锁。
protected void startInternal() throws LifecycleException { setState(LifecycleState.STARTING); // 锁 engine 成员变量 if (engine != null) { synchronized (engine) { engine.start(); } } // 锁 executors 成员变量 synchronized (executors) { for (Executor executor: executors) { executor.start(); } } mapperListener.start(); // 锁 connectors 成员变量 synchronized (connectorsLock) { for (Connector connector: connectors) { // If it has already failed, don't try and start it if (connector.getState() != LifecycleState.FAILED) { connector.start(); } } } }
比如上面的代码是 Tomcat 的 StandardService 组件的启动方法,这个启动方法要启动三种子组件:engine、executors 和 connectors。它没有直接在方法上加锁,而是用了三把细粒度的锁,来分别用来锁三个成员变量。如果直接在方法上加 synchronized,多个线程执行到这个方法时需要排队;而在对象级别上加 synchronized,多个线程可以并行执行这个方法,只是在访问某个成员变量时才需要排队。

用原子变量和 CAS 取代锁

下面的代码是 Jetty 线程池的启动方法,它的主要功能就是根据传入的参数启动相应个数的线程。
private boolean startThreads(int threadsToStart) { while (threadsToStart > 0 && isRunning()) { // 获取当前已经启动的线程数,如果已经够了就不需要启动了 int threads = _threadsStarted.get(); if (threads >= _maxThreads) return false; // 用 CAS 方法将线程数加一,请注意执行失败走 continue,继续尝试 if (!_threadsStarted.compareAndSet(threads, threads + 1)) continue; boolean started = false; try { Thread thread = newThread(_runnable); thread.setDaemon(isDaemon()); thread.setPriority(getThreadsPriority()); thread.setName(_name + "-" + thread.getId()); _threads.add(thread);//_threads 并发集合 _lastShrink.set(System.nanoTime());//_lastShrink 是原子变量 thread.start(); started = true; --threadsToStart; } finally { // 如果最终线程启动失败,还需要把线程数减一 if (!started) _threadsStarted.decrementAndGet(); } } return true; }
你可以看到整个函数的实现是一个while 循环,并且是无锁的。_threadsStarted表示当前线程池已经启动了多少个线程,它是一个原子变量 AtomicInteger,首先通过它的 get 方法拿到值,如果线程数已经达到最大值,直接返回。否则尝试用 CAS 操作将_threadsStarted的值加一,如果成功了意味着没有其他线程在改这个值,当前线程可以继续往下执行;否则走 continue 分支,也就是继续重试,直到成功为止。在这里当然你也可以使用锁来实现,但是我们的目的是无锁化。

并发容器的使用

CopyOnWriteArrayList 适用于读多写少的场景,比如 Tomcat 用它来“存放”事件监听器,这是因为监听器一般在初始化过程中确定后就基本不会改变,当事件触发时需要遍历这个监听器列表,所以这个场景符合读多写少的特征。
public abstract class LifecycleBase implements Lifecycle { // 事件监听器集合 private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>(); ... }

volatile 关键字的使用

再拿 Tomcat 中的 LifecycleBase 作为例子,它里面的生命状态就是用 volatile 关键字修饰的。volatile 的目的是为了保证一个线程修改了变量,另一个线程能够读到这种变化。对于生命状态来说,需要在各个线程中保持是最新的值,因此采用了 volatile 修饰。
public abstract class LifecycleBase implements Lifecycle { // 当前组件的生命状态,用 volatile 修饰 private volatile LifecycleState state = LifecycleState.NEW; }