上帝视角拆解 Tomcat 架构设计,在了解整个组件设计思路以后。咱们须要下凡深刻了解每一个组件的细节实现。从远到近,架构给人以宏观思惟,细节展示饱满的美。关注「码哥字节」获取更多硬核,你,准备好了么?
上回「码哥字节」站在上帝视角给你们拆解了 Tomcat 架构设计,分析 Tomcat 如何实现启动、中止,经过设计链接池与容器两大组件完成了一个请求的接受与响应。链接器负责对外交流,处理 socket 链接,容器对内负责,加载 Servlet 以及处理具体 Request 请求与响应。详情点我进入传输门:Tomcat 架构解析到工做借鉴。java
这回,再次拆解,专一 Tomcat 高并发设计之道与性能调优,让你们对整个架构有更高层次的了解与感悟。其中设计的每一个组件思路都是将 Java 面向对象、面向接口、如何封装变与不变,如何根据实际需求抽象不一样组件分工合做,如何设计类实现单一职责,怎么作到将类似功能高内聚低耦合,设计模式运用到极致的学习借鉴。程序员
此次主要涉及到的是 I/O 模型,以及线程池的基础内容。算法
在学习以前,但愿你们积累如下一些技术内容,不少内容「码哥字节」也在历史文章中分享过。你们可爬楼回顾……。但愿你们重视以下几个知识点,在掌握如下知识点再来拆解 Tomcat,就会事半功倍,不然很容易迷失方向不得其法。shell
一块儿来看 Tomcat 如何实现并发链接处理以及任务处理,性能的优化是每个组件都起到对应的做用,如何使用最少的内存,最快的速度执行是咱们的目标。数据库
模板方法模式: 抽象算法流程在抽象类中,封装流程中的变化与不变点。将变化点延迟到子类实现,达到代码复用,开闭原则。apache
观察者模式:针对事件不一样组件有不一样响应机制的需求场景,达到解耦灵活通知下游。编程
责任链模式:将对象链接成一条链,将沿着这条链传递请求。在 Tomcat 中的 Valve 就是该设计模式的运用。设计模式
更多设计模式可查看「码哥字节」以前的设计模式专辑,这里是传送门。数组
Tomcat 实现高并发接收链接,必然涉及到 I/O 模型的运用,了解同步阻塞、异步阻塞、I/O 多路复用,异步非阻塞相关概念以及 Java NIO 包的运用颇有必要。本文也会带你们着重说明 I/O 是如何在 Tomcat 运用实现高并发链接。你们经过本文我相信对 I/O 模型也会有一个深入认识。性能优化
实现高并发,除了总体每一个组件的优雅设计、设计模式的合理、I/O 的运用,还须要线程模型,如何高效的并发编程技巧。在高并发过程当中,不可避免的会出现多个线程对共享变量的访问,须要加锁实现,如何高效的下降锁冲突。所以做为程序员,要有意识的尽可能避免锁的使用,好比可使用原子类 CAS 或者并发集合来代替。若是万不得已须要用到锁,也要尽可能缩小锁的范围和锁的强度。
对于并发相关的基础知识,若是读者感兴趣「码哥字节」后面也给你们安排上,目前也写了部分并发专辑,你们可移步到历史文章或者专辑翻阅,这里是传送门,主要讲解了并发实现的原理、什么是内存可见性,JMM 内存模模型、读写锁等并发知识点。
再次回顾下 Tomcat 总体架构设计,主要设计了 connector 链接器处理 TCP/IP 链接,container 容器做为 Servlet 容器,处理具体的业务请求。对外对内分别抽象两个组件实现拓展。
ProtocolHandler
主要由 Acceptor
以及 SocketProcessor
构成,实现了 TCP/IP 层 的 Socket 读取并转换成 TomcatRequest
和 TomcatResponse
,最后根据 http 或者 ajp 协议获取合适的 Processor
解析为应用层协议,并经过 Adapter 将 TomcatRequest、TomcatResponse 转化成 标准的 ServletRequest、ServletResponse。经过 getAdapter().service(request, response);
将请求传递到 Container 容器。org.apache.catalina.connector.CoyoteAdapter
// Calling the container connector.getService().getContainer().getPipeline().getFirst().invoke( request, response);
这个调用会触发 getPipeline 构成的责任链模式将请求一步步走入容器内部,每一个容器都有一条 Pipeline,经过 First 开始到 Basic 结束并进入容器内部持有的子类容器,最后到 Servlet,这里就是责任链模式的经典运用。具体的源码组件是 Pipeline 构成一条请求链,每个链点由 Valve 组成。「码哥字节」在上一篇Tomcat 架构解析到工做借鉴 已经详细讲解。以下图所示,整个 Tomcat 的架构设计重要组件清晰可见,但愿你们将这个全局架构图深深印在脑海里,掌握全局思路才能更好地分析细节之美。
Connector
和 Engine
的 start
方法。Engine 容器主要就是组合模式将各个容器根据父子关系关联,而且 Container 容器继承了 Lifecycle 实现各个容器的初始化与启动。Lifecycle 定义了 init()、start()、stop()
控制整个容器组件的生命周期实现一键启停。
这里就是一个面向接口、单一职责的设计思想 ,Container 利用组合模式管理容器,LifecycleBase 抽象类继承 Lifecycle 将各大容器生命周期统一管理这里即是,而实现初始化与启动的过程又 LifecycleBase 运用了模板方法设计模式抽象出组件变化与不变的点,将不一样组件的初始化延迟到具体子类实现。而且利用观察者模式发布启动事件解耦。
具体的 init 与 start 流程以下泳道图所示:这是我在阅读源码 debug 所作的笔记,读者朋友们不要怕笔记花费时间长,本身跟着 debug 慢慢记录,相信会有更深的感悟。
init 流程
start 流程
读者朋友根据个人两篇内容,抓住主线组件去 debug,而后跟着该泳道图阅读源码,我相信都会有所收获,而且事半功倍。在读源码的过程当中,切勿进入某个细节,必定要先把各个组件抽象出来,了解每一个组件的职责便可。最后在了解每一个组件的职责与设计哲学以后再深刻理解每一个组件的实现细节,千万不要一开始就想着深刻理解具体一篇叶子。
每一个核心类我在架构设计图以及泳道图都标识出来了,「码哥字节」给你们分享下如何高效阅读源码,以及保持学习兴趣的心得体会。
切勿陷入细节,不看全局:我还没弄清楚森林长啥样,就盯着叶子看 ,看不到全貌和总体设计思路。因此阅读源码学习的时候不要一开始就进入细节,而是宏观看待总体架构设计思想,模块之间的关系。
1.阅读源码以前,须要有必定的技术储备
好比经常使用的设计模式,这个必须掌握,尤为是:模板方法、策略模式、单例、工厂、观察者、动态代理、适配器、责任链、装饰器。你们能够看 「码哥字节」关于设计模式的历史文章,打造好的基础。
2.必须会使用这个框架/类库,精通各类变通用法
魔鬼都在细节中,若是有些用法根本不知道,可能你能看明白代码是什么意思,可是不知道它为何这些写。
3.先去找书,找资料,了解这个软件的总体设计。
从全局的视角去看待,上帝视角理出主要核心架构设计,先森林后树叶。都有哪些模块? 模块之间是怎么关联的?怎么关联的?
可能一会儿理解不了,可是要创建一个总体的概念,就像一个地图,防止你迷航。
在读源码的时候能够时不时看看本身在什么地方。就像「码哥字节」给你们梳理好了 Tomcat 相关架构设计,而后本身再尝试跟着 debug,这样的效率如虎添翼。
4. 搭建系统,把源代码跑起来!
Debug 是很是很是重要的手段, 你想经过只看而不运行就把系统搞清楚,那是根本不可能的!合理运用调用栈(观察调用过程上下文)。
5.笔记
一个很是重要的工做就是记笔记(又是写做!),画出系统的类图(不要依靠 IDE 给你生成的), 记录下主要的函数调用, 方便后续查看。
文档工做极为重要,由于代码太复杂,人的大脑容量也有限,记不住全部的细节。 文档能够帮助你记住关键点, 到时候能够回想起来,迅速地接着往下看。
要否则,你今天看的,可能到明天就忘个差很少了。因此朋友们记得收藏后多翻来看看,尝试把源码下载下来反复调试。
当咱们接到一个功能需求的时候,最重要的就是抽象设计,将功能拆解主要核心组件,而后找到需求的变化与不变点,将类似功能内聚,功能之间若耦合,同时对外支持可拓展,对内关闭修改。努力作到一个需求下来的时候咱们须要合理的抽象能力抽象出不一样组件,而不是一锅端将全部功能糅合在一个类甚至一个方法之中,这样的代码牵一发而动全身,没法拓展,难以维护和阅读。
带着问题咱们来分析 Tomcat 如何设计组件完成链接与容器管理。
看看 Tomcat 如何实现将 Tomcat 启动,而且又是如何接受请求,将请求转发到咱们的 Servlet 中。
主要任务就是建立 Server,并非简单建立,而是解析 server.xml 文件把文件配置的各个组件意义建立出来,接着调用 Server 的 init() 和 start() 方法,启动之旅从这里开始…,同时还要兼顾异常,好比关闭 Tomcat 还须要作到优雅关闭启动过程建立的资源须要释放,Tomcat 则是在 JVM 注册一个「关闭钩子」,源码我都加了注释,省略了部分无关代码。同时经过 await()
监听中止指令关闭 Tomcat。
/** * Start a new server instance. */ public void start() { // 若 server 为空,则解析 server.xml 建立 if (getServer() == null) { load(); } // 建立失败则报错并退出启动 if (getServer() == null) { log.fatal("Cannot start server. Server instance is not configured."); return; } // 开始启动 server try { getServer().start(); } catch (LifecycleException e) { log.fatal(sm.getString("catalina.serverStartFail"), e); try { // 异常则执行 destroy 销毁资源 getServer().destroy(); } catch (LifecycleException e1) { log.debug("destroy() failed for failed Server ", e1); } return; } // 建立并注册 JVM 关闭钩子 if (useShutdownHook) { if (shutdownHook == null) { shutdownHook = new CatalinaShutdownHook(); } Runtime.getRuntime().addShutdownHook(shutdownHook); } // 经过 await 方法监听中止请求 if (await) { await(); stop(); } }
经过「关闭钩子」,就是当 JVM 关闭的时候作一些清理工做,好比说释放线程池,清理一些零时文件,刷新内存数据到磁盘中…...
「关闭钩子」本质就是一个线程,JVM 在中止以前会尝试执行这个线程。咱们来看下 CatalinaShutdownHook 这个钩子到底作了什么。
/** * Shutdown hook which will perform a clean shutdown of Catalina if needed. */ protected class CatalinaShutdownHook extends Thread { @Override public void run() { try { if (getServer() != null) { Catalina.this.stop(); } } catch (Throwable ex) { ... } } /** * 关闭已经建立的 Server 实例 */ public void stop() { try { // Remove the ShutdownHook first so that server.stop() // doesn't get invoked twice if (useShutdownHook) { Runtime.getRuntime().removeShutdownHook(shutdownHook); } } catch (Throwable t) { ...... } // 关闭 Server try { Server s = getServer(); LifecycleState state = s.getState(); // 判断是否已经关闭,如果在关闭中,则不执行任何操做 if (LifecycleState.STOPPING_PREP.compareTo(state) <= 0 && LifecycleState.DESTROYED.compareTo(state) >= 0) { // Nothing to do. stop() was already called } else { s.stop(); s.destroy(); } } catch (LifecycleException e) { log.error("Catalina.stop", e); } }
实际上就是执行了 Server 的 stop 方法,Server 的 stop 方法会释放和清理全部的资源。
来体会下面向接口设计美,看 Tomcat 如何设计组件与接口,抽象 Server 组件,Server 组件须要生命周期管理,因此继承 Lifecycle 实现一键启停。
它的具体实现类是 StandardServer,以下图所示,咱们知道 Lifecycle 主要的方法是组件的 初始化、启动、中止、销毁,和 监听器的管理维护,其实就是观察者模式的设计,当触发不一样事件的时候发布事件给监听器执行不一样业务处理,这里就是如何解耦的设计哲学体现。
而 Server 自生则是负责管理 Service 组件。
接着,咱们再看 Server 组件的具体实现类是 StandardServer 有哪些功能,又跟哪些类关联?
在阅读源码的过程当中,咱们必定要多关注接口与抽象类,接口是组件全局设计的抽象;而抽象类基本上是模板方法模式的运用,主要目的就是抽象整个算法流程,将变化点交给子类,将不变点实现代码复用。
StandardServer 继承了 LifeCycleBase,它的生命周期被统一管理,而且它的子组件是 Service,所以它还须要管理 Service 的生命周期,也就是说在启动时调用 Service 组件的启动方法,在中止时调用它们的中止方法。Server 在内部维护了若干 Service 组件,它是以数组来保存的,那 Server 是如何添加一个 Service 到数组中的呢?
/** * 添加 Service 到定义的数组中 * * @param service The Service to be added */ @Override public void addService(Service service) { service.setServer(this); synchronized (servicesLock) { // 建立一个 services.length + 1 长度的 results 数组 Service results[] = new Service[services.length + 1]; // 将老的数据复制到 results 数组 System.arraycopy(services, 0, results, 0, services.length); results[services.length] = service; services = results; // 启动 Service 组件 if (getState().isAvailable()) { try { service.start(); } catch (LifecycleException e) { // Ignore } } // 观察者模式运用,触发监听事件 support.firePropertyChange("service", null, service); } }
从上面的代码能够知道,并非一开始就分配一个很长的数组,而是在新增过程当中动态拓展长度,这里就是为了节省空间,对于咱们平时开发是否是也要主要空间复杂度带来的内存损耗,追求的就是极致的美。
除此以外,还有一个重要功能,上面 Caralina 的启动方法的最后一行代码就是调用了 Server 的 await 方法。
这个方法主要就是监听中止端口,在 await 方法里会建立一个 Socket 监听 8005 端口,并在一个死循环里接收 Socket 上的链接请求,若是有新的链接到来就创建链接,而后从 Socket 中读取数据;若是读到的数据是中止命令“SHUTDOWN”,就退出循环,进入 stop 流程。
一样是面向接口设计,Service 组件的具体实现类是 StandardService,Service 组件依然是继承 Lifecycle 管理生命周期,这里再也不累赘展现图片关系图。咱们先来看看 Service 接口主要定义的方法以及成员变量。经过接口咱们才能知道核心功能,在阅读源码的时候必定要多关注每一个接口之间的关系,不要急着进入实现类。
public interface Service extends Lifecycle { // ----------主要成员变量 //Service 组件包含的顶层容器 Engine public Engine getContainer(); // 设置 Service 的 Engine 容器 public void setContainer(Engine engine); // 该 Service 所属的 Server 组件 public Server getServer(); // --------------------------------------------------------- Public Methods // 添加 Service 关联的链接器 public void addConnector(Connector connector); public Connector[] findConnectors(); // 自定义线程池 public void addExecutor(Executor ex); // 主要做用就是根据 url 定位到 Service,Mapper 的主要做用就是用于定位一个请求所在的组件处理 Mapper getMapper(); }
接着再来细看 Service 的实现类:
public class StandardService extends LifecycleBase implements Service { // 名字 private String name = null; //Server 实例 private Server server = null; // 链接器数组 protected Connector connectors[] = new Connector[0]; private final Object connectorsLock = new Object(); // 对应的 Engine 容器 private Engine engine = null; // 映射器及其监听器,又是观察者模式的运用 protected final Mapper mapper = new Mapper(); protected final MapperListener mapperListener = new MapperListener(this); }
StandardService 继承了 LifecycleBase 抽象类,抽象类定义了 三个 final 模板方法定义生命周期,每一个方法将变化点定义抽象方法让不一样组件时间本身的流程。这里也是咱们学习的地方,利用模板方法抽象变与不变。
此外 StandardService 中还有一些咱们熟悉的组件,好比 Server、Connector、Engine 和 Mapper。
那为何还有一个 MapperListener?这是由于 Tomcat 支持热部署,当 Web 应用的部署发生变化时,Mapper 中的映射信息也要跟着变化,MapperListener 就是一个监听器,它监听容器的变化,并把信息更新到 Mapper 中,这是典型的观察者模式。下游服务根据多上游服务的动做作出不一样处理,这就是观察者模式的运用场景,实现一个事件多个监听器触发,事件发布者不用调用全部下游,而是经过观察者模式触发达到解耦。
Service 管理了 链接器以及 Engine 顶层容器,因此继续进入它的 startInternal 方法,其实就是 LifecycleBase 模板定义的 抽象方法。看看他是怎么启动每一个组件顺序。
protected void startInternal() throws LifecycleException { //1. 触发启动监听器 setState(LifecycleState.STARTING); //2. 先启动 Engine,Engine 会启动它子容器,由于运用了组合模式,因此每一层容器在会先启动本身的子容器。 if (engine != null) { synchronized (engine) { engine.start(); } } //3. 再启动 Mapper 监听器 mapperListener.start(); //4. 最后启动链接器,链接器会启动它子组件,好比 Endpoint synchronized (connectorsLock) { for (Connector connector: connectors) { if (connector.getState() != LifecycleState.FAILED) { connector.start(); } } } }
Service 先启动了 Engine 组件,再启动 Mapper 监听器,最后才是启动链接器。这很好理解,由于内层组件启动好了才能对外提供服务,才能启动外层的链接器组件。而 Mapper 也依赖容器组件,容器组件启动好了才能监听它们的变化,所以 Mapper 和 MapperListener 在容器组件以后启动。组件中止的顺序跟启动顺序正好相反的,也是基于它们的依赖关系。
做为 Container 的顶层组件,因此 Engine 本质就是一个容器,继承了 ContainerBase ,看到抽象类再次运用了模板方法设计模式。ContainerBase 使用一个 HashMap<String, Container> children = new HashMap<>();
成员变量保存每一个组件的子容器。同时使用 protected final Pipeline pipeline = new StandardPipeline(this);
Pipeline 组成一个管道用于处理链接器传过来的请求,责任链模式构建管道。
public class StandardEngine extends ContainerBase implements Engine { }
Engine 的子容器是 Host,因此 children 保存的就是 Host。
咱们来看看 ContainerBase 作了什么...
public abstract class ContainerBase extends LifecycleMBeanBase implements Container { // 提供了默认初始化逻辑 @Override protected void initInternal() throws LifecycleException { BlockingQueue<Runnable> startStopQueue = new LinkedBlockingQueue<>(); // 建立线程池用于启动或者中止容器 startStopExecutor = new ThreadPoolExecutor( getStartStopThreadsInternal(), getStartStopThreadsInternal(), 10, TimeUnit.SECONDS, startStopQueue, new StartStopThreadFactory(getName() + "-startStop-")); startStopExecutor.allowCoreThreadTimeOut(true); super.initInternal(); } // 容器启动 @Override protected synchronized void startInternal() throws LifecycleException { // 获取子容器并提交到线程池启动 Container children[] = findChildren(); List<Future<Void>> results = new ArrayList<>(); for (Container child : children) { results.add(startStopExecutor.submit(new StartChild(child))); } MultiThrowable multiThrowable = null; // 获取启动结果 for (Future<Void> result : results) { try { result.get(); } catch (Throwable e) { log.error(sm.getString("containerBase.threadedStartFailed"), e); if (multiThrowable == null) { multiThrowable = new MultiThrowable(); } multiThrowable.add(e); } } ...... // 启动 pipeline 管道,用于处理链接器传递过来的请求 if (pipeline instanceof Lifecycle) { ((Lifecycle) pipeline).start(); } // 发布启动事件 setState(LifecycleState.STARTING); // Start our thread threadStart(); } }
继承了 LifecycleMBeanBase 也就是还实现了生命周期的管理,提供了子容器默认的启动方式,同时提供了对子容器的 CRUD 功能。
Engine 在启动 Host 容器就是 使用了 ContainerBase 的 startInternal 方法。Engine 本身还作了什么呢?
咱们看下 构造方法,pipeline 设置了 setBasic,建立了 StandardEngineValve。
/** * Create a new StandardEngine component with the default basic Valve. */ public StandardEngine() { super(); pipeline.setBasic(new StandardEngineValve()); ..... }
容器主要的功能就是处理请求,把请求转发给某一个 Host 子容器来处理,具体是经过 Valve 来实现的。每一个容器组件都有一个 Pipeline 用于组成一个责任链传递请求。而 Pipeline 中有一个基础阀(Basic Valve),而 Engine 容器的基础阀定义以下:
final class StandardEngineValve extends ValveBase { @Override public final void invoke(Request request, Response response) throws IOException, ServletException { // 选择一个合适的 Host 处理请求,经过 Mapper 组件获取到合适的 Host Host host = request.getHost(); if (host == null) { response.sendError (HttpServletResponse.SC_BAD_REQUEST, sm.getString("standardEngine.noHost", request.getServerName())); return; } if (request.isAsyncSupported()) { request.setAsyncSupported(host.getPipeline().isAsyncSupported()); } // 获取 Host 容器的 Pipeline first Valve ,将请求转发到 Host host.getPipeline().getFirst().invoke(request, response); }
这个基础阀实现很是简单,就是把请求转发到 Host 容器。处理请求的 Host 容器对象是从请求中拿到的,请求对象中怎么会有 Host 容器呢?这是由于请求到达 Engine 容器中以前,Mapper 组件已经对请求进行了路由处理,Mapper 组件经过请求的 URL 定位了相应的容器,而且把容器对象保存到了请求对象中。
你们有没有发现,Tomcat 的设计几乎都是面向接口设计,也就是经过接口隔离功能设计其实就是单一职责的体现,每一个接口抽象对象不一样的组件,经过抽象类定义组件的共同执行流程。单一职责四个字的含义其实就是在这里体现出来了。在分析过程当中,咱们看到了观察者模式、模板方法模式、组合模式、责任链模式以及如何抽象组件面向接口设计的设计哲学。
链接器主要功能就是接受 TCP/IP 链接,限制链接数而后读取数据,最后将请求转发到 Container
容器。因此这里必然涉及到 I/O 编程,今天带你们一块儿分析 Tomcat 如何运用 I/O 模型实现高并发的,一块儿进入 I/O 的世界。
I/O 模型主要有 5 种:同步阻塞、同步非阻塞、I/O 多路复用、信号驱动、异步 I/O。是否是很熟悉可是又傻傻分不清他们有何区别?
所谓的I/O 就是计算机内存与外部设备之间拷贝数据的过程。
CPU 是先把外部设备的数据读到内存里,而后再进行处理。请考虑一下这个场景,当程序经过 CPU 向外部设备发出一个读指令时,数据从外部设备拷贝到内存每每须要一段时间,这个时候 CPU 没事干了,程序是主动把 CPU 让给别人?仍是让 CPU 不停地查:数据到了吗,数据到了吗……
这就是 I/O 模型要解决的问题。今天我会先说说各类 I/O 模型的区别,而后重点分析 Tomcat 的 NioEndpoint 组件是如何实现非阻塞 I/O 模型的。
一个网络 I/O 通讯过程,好比网络数据读取,会涉及到两个对象,分别是调用这个 I/O 操做的用户线程和操做系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。
网络读取主要有两个步骤:
同理,将数据发送到网络也是同样的流程,将数据从用户线程复制到内核空间,内核空间将数据复制到网卡发送。
不一样 I/O 模型的区别:实现这两个步骤的方式不同。
用户线程发起read
调用的时候,线程就阻塞了,只能让出 CPU,而内核则等待网卡数据到来,并把数据从网卡拷贝到内核空间,当内核把数据拷贝到用户空间,再把刚刚阻塞的读取用户线程唤醒,两个步骤的线程都是阻塞的。
用户线程一直不停的调用read
方法,若是数据尚未复制到内核空间则返回失败,直到数据到达内核空间。用户线程在等待数据从内核空间复制到用户空间的时间里一直是阻塞的,等数据到达用户空间才被唤醒。循环调用read
方法的时候不阻塞。
用户线程的读取操做被划分为两步:
select
调用,主要就是询问内核数据转备好了没?当内核把数据准备好了就执行第二步。read
调用,在等待内核把数据从内核空间复制到用户空间的时间里,发起 read 线程是阻塞的。为什么叫 I/O 多路复用,核心主要就是:一次 select
调用能够向内核查询多个数据通道(Channel)的状态,所以叫多路复用。
用户线程执行 read 调用的时候会注册一个回调函数, read 调用当即返回,不会阻塞线程,在等待内核将数据准备好之后,再调用刚刚注册的回调函数处理数据,在整个过程当中用户线程一直没有阻塞。
Tomcat 的 NioEndpoit 组件实际上就是实现了 I/O 多路复用模型,正式由于这个并发能力才足够优秀。让咱们一块儿窥探下 Tomcat NioEndpoint 的设计原理。
对于 Java 的多路复用器的使用,无非是两步:
Tomcat 的 NioEndpoint 组件虽然实现比较复杂,但基本原理就是上面两步。咱们先来看看它有哪些组件,它一共包含 LimitLatch、Acceptor、Poller、SocketProcessor 和 Executor 共 5 个组件,它们的工做过程以下图所示:
正是因为使用了 I/O 多路复用,Poller 内部本质就是持有 Java Selector 检测 channel 的 I/O 时间,当数据可读写的时候建立 SocketProcessor 任务丢到线程池执行,也就是少许线程监听读写事件,接着专属的线程池执行读写,提升性能。
为了提升处理能力和并发度, Web 容器一般会把处理请求的工做放在线程池来处理, Tomcat 拓展了 Java 原生的线程池来提高并发需求,在进入 Tomcat 线程池原理以前,咱们先回顾下 Java 线程池原理。
简单的说,Java 线程池里内部维护一个线程数组和一个任务队列,当任务处理不过来的时,就把任务放到队列里慢慢处理。
来窥探线程池核心类的构造函数,咱们须要理解每个参数的做用,才能理解线程池的工做原理。
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { ...... }
allowCoreThreadTimeOut(true)
时,线程池中 corePoolSize 范围内的线程空闲时间达到 keepAliveTime 也将回收。RejectedExecutionHandler
便可。默认的拒绝策略:AbortPolicy
拒绝任务并抛出 RejectedExecutionException
异常;CallerRunsPolicy
提交该任务的线程执行;``来分析下每一个参数之间的关系:
提交新任务的时候,若是线程池数 < corePoolSize,则建立新的线程池执行任务,当线程数 = corePoolSize 时,新的任务就会被放到工做队列 workQueue 中,线程池中的线程尽可能从队列里取任务来执行。
若是任务不少,workQueue 满了,且 当前线程数 < maximumPoolSize 时则临时建立线程执行任务,若是总线程数量超过 maximumPoolSize,则再也不建立线程,而是执行拒绝策略。DiscardPolicy
什么都不作直接丢弃任务;DiscardOldestPolicy
丢弃最旧的未处理程序;
具体执行流程以下图所示:
定制版的 ThreadPoolExecutor,继承了 java.util.concurrent.ThreadPoolExecutor。 对于线程池有两个很关键的参数:
Tomcat 必然须要限定想着两个参数否则在高并发场景下可能致使 CPU 和内存有资源耗尽的风险。继承了 与 java.util.concurrent.ThreadPoolExecutor 相同,但实现的效率更高。
其构造方法以下,跟 Java 官方的一模一样
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler); prestartAllCoreThreads(); }
在 Tomcat 中控制线程池的组件是 StandardThreadExecutor
, 也是实现了生命周期接口,下面是启动线程池的代码
@Override protected void startInternal() throws LifecycleException { // 自定义任务队列 taskqueue = new TaskQueue(maxQueueSize); // 自定义线程工厂 TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority()); // 建立定制版线程池 executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf); executor.setThreadRenewalDelay(threadRenewalDelay); if (prestartminSpareThreads) { executor.prestartAllCoreThreads(); } taskqueue.setParent(executor); // 观察者模式,发布启动事件 setState(LifecycleState.STARTING); }
其中的关键点在于:
除此以外, Tomcat 在官方原有基础上从新定义了本身的线程池处理流程,原生的处理流程上文已经说过。
Tomcat 线程池扩展了原生的 ThreadPoolExecutor,经过重写 execute 方法实现了本身的任务处理逻辑:
最大的差异在于 Tomcat 在线程总数达到最大数时,不是当即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。
代码以下所示:
public void execute(Runnable command, long timeout, TimeUnit unit) { // 记录提交任务数 +1 submittedCount.incrementAndGet(); try { // 调用 java 原生线程池来执行任务,当原生抛出拒绝策略 super.execute(command); } catch (RejectedExecutionException rx) { //总线程数达到 maximumPoolSize,Java 原生会执行拒绝策略 if (super.getQueue() instanceof TaskQueue) { final TaskQueue queue = (TaskQueue)super.getQueue(); try { // 尝试把任务放入队列中 if (!queue.force(command, timeout, unit)) { submittedCount.decrementAndGet(); // 队列仍是满的,插入失败则执行拒绝策略 throw new RejectedExecutionException("Queue capacity is full."); } } catch (InterruptedException x) { submittedCount.decrementAndGet(); throw new RejectedExecutionException(x); } } else { // 提交任务书 -1 submittedCount.decrementAndGet(); throw rx; } } }
Tomcat 线程池是用 submittedCount 来维护已经提交到了线程池,这跟 Tomcat 的定制版的任务队列有关。Tomcat 的任务队列 TaskQueue 扩展了 Java 中的 LinkedBlockingQueue,咱们知道 LinkedBlockingQueue 默认状况下长度是没有限制的,除非给它一个 capacity。所以 Tomcat 给了它一个 capacity,TaskQueue 的构造函数中有个整型的参数 capacity,TaskQueue 将 capacity 传给父类 LinkedBlockingQueue 的构造函数,防止无限添加任务致使内存溢出。并且默认是无限制,就会致使当前线程数达到核心线程数以后,再来任务的话线程池会把任务添加到任务队列,而且老是会成功,这样永远不会有机会建立新线程了。
为了解决这个问题,TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会建立新的线程。
public class TaskQueue extends LinkedBlockingQueue<Runnable> { ... @Override // 线程池调用任务队列的方法时,当前线程数确定已经大于核心线程数了 public boolean offer(Runnable o) { // 若是线程数已经到了最大值,不能建立新线程了,只能把任务添加到任务队列。 if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o); // 执行到这里,代表当前线程数大于核心线程数,而且小于最大线程数。 // 代表是能够建立新线程的,那到底要不要建立呢?分两种状况: //1. 若是已提交的任务数小于当前线程数,表示还有空闲线程,无需建立新线程 if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o); //2. 若是已提交的任务数大于当前线程数,线程不够用了,返回 false 去建立新线程 if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false; // 默认状况下老是把任务添加到任务队列 return super.offer(o); } }
只有当前线程数大于核心线程数、小于最大线程数,而且已提交的任务个数大于当前线程数时,也就是说线程不够用了,可是线程数又没达到极限,才会去建立新的线程。这就是为何 Tomcat 须要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的状况下,让线程池有机会建立新的线程。能够经过设置 maxQueueSize 参数来限制任务队列的长度。
跟 I/O 模型紧密相关的是线程池,线程池的调优就是设置合理的线程池参数。咱们先来看看 Tomcat 线程池中有哪些关键参数:
参数 | 详情 |
---|---|
threadPriority | 线程优先级,默认是 5 |
daemon | 是不是 后台线程,默认 true |
namePrefix | 线程名前缀 |
maxThreads | 最大线程数,默认 200 |
minSpareThreads | 最小线程数(空闲超过必定时间会被回收),默认 25 |
maxIdleTime | 线程最大空闲时间,超过该时间的会被回收,直到只有 minSpareThreads 个。默认是 1 分钟 |
maxQueueSize | 任务队列最大长度 |
prestartAllCoreThreads | 是否在线程池启动的时候就建立 minSpareThreads 个线程,默认是 fasle |
这里面最核心的就是如何肯定 maxThreads 的值,若是这个参数设置小了,Tomcat 会发生线程饥饿,而且请求的处理会在队列中排队等待,致使响应时间变长;若是 maxThreads 参数值过大,一样也会有问题,由于服务器的 CPU 的核数有限,线程数太多会致使线程在 CPU 上来回切换,耗费大量的切换开销。
线程 I/O 时间与 CPU 时间
至此咱们又获得一个线程池个数的计算公式,假设服务器是单核的:
线程池大小 = (线程 I/O 阻塞时间 + 线程 CPU 时间 )/ 线程 CPU 时间
其中:线程 I/O 阻塞时间 + 线程 CPU 时间 = 平均请求处理时间。
JVM 在抛出 java.lang.OutOfMemoryError 时,除了会打印出一行描述信息,还会打印堆栈跟踪,所以咱们能够经过这些信息来找到致使异常的缘由。在寻找缘由前,咱们先来看看有哪些因素会致使 OutOfMemoryError,其中内存泄漏是致使 OutOfMemoryError 的一个比较常见的缘由。
其实调优不少时候都是在找系统瓶颈,假若有个情况:系统响应比较慢,但 CPU 的用率不高,内存有所增长,经过分析 Heap Dump 发现大量请求堆积在线程池的队列中,请问这种状况下应该怎么办呢?多是请求处理时间太长,去排查是否是访问数据库或者外部应用遇到了延迟。
当 JVM 没法在堆中分配对象的会抛出此异常,通常有如下缘由:
jmap -dump:live,format=b,file=filename.bin pid
垃圾收集器持续运行,可是效率很低几乎没有回收内存。好比 Java 进程花费超过 96%的 CPU 时间来进行一次 GC,可是回收的内存少于 3%的 JVM 堆,而且连续 5 次 GC 都是这种状况,就会抛出 OutOfMemoryError。
这个问题 IDE 解决方法就是查看 GC 日志或者生成 Heap Dump,先确认是不是内存溢出,不是的话能够尝试增长堆大小。能够经过以下 JVM 启动参数打印 GC 日志:
-verbose:gc //在控制台输出GC状况 -XX:+PrintGCDetails //在控制台输出详细的GC状况 -Xloggc: filepath //将GC日志输出到指定文件中
好比 可使用 java -verbose:gc -Xloggc:gc.log -XX:+PrintGCDetails -jar xxx.jar
记录 GC 日志,经过 GCViewer 工具查看 GC 日志,用 GCViewer 打开产生的 gc.log 分析垃圾回收状况。
抛出这种异常的缘由是“请求的数组大小超过 JVM 限制”,应用程序尝试分配一个超大的数组。好比程序尝试分配 128M 的数组,可是堆最大 100M,通常这个也是配置问题,有可能 JVM 堆设置过小,也有多是程序的 bug,是否是建立了超大数组。
JVM 元空间的内存在本地内存中分配,可是它的大小受参数 MaxMetaSpaceSize 的限制。当元空间大小超过 MaxMetaSpaceSize 时,JVM 将抛出带有 MetaSpace 字样的 OutOfMemoryError。解决办法是加大 MaxMetaSpaceSize 参数的值。
当本地堆内存分配失败或者本地内存快要耗尽时,Java HotSpot VM 代码会抛出这个异常,VM 会触发“致命错误处理机制”,它会生成“致命错误”日志文件,其中包含崩溃时线程、进程和操做系统的有用信息。若是碰到此类型的 OutOfMemoryError,你须要根据 JVM 抛出的错误信息来进行诊断;或者使用操做系统提供的 DTrace 工具来跟踪系统调用,看看是什么样的程序代码在不断地分配本地内存。
-Xss
决定。这里只是概述场景,对于生产在线排查后续会陆续推出,受限于篇幅再也不展开。关注「码哥字节」给你硬货来啃!
回顾 Tomcat 总结架构设计,详细拆解 Tomcat 如何处理高并发链接设计。而且分享了如何高效阅读开源框架源码思路,设计模式、并发编程基础是重中之重,读者朋友能够翻阅历史「码哥字节」的历史文章学习。
拆解 Tomcat 核心组件,去体会 Tomcat 如何面向接口设计、落实单一职责的设计哲学思想。接着归纳了 链接器涉及到的 I/O 模型,并对不一样的 I/O 模型进行了详解,接着看 Tomcat 如何实现 NIO,如何自定义线程池以及队列实现高并发设计,最后简单分享常见的 OOM 场景以及解决思路,限于篇幅再也不详细展开,关注「码哥字节」后续会分享各类线上故障排查调优思路,敬请期待…...
有任何疑问或者计数探讨能够加我的微信:MageByte1024,一块儿学习进步。
也能够经过公众号菜单加入技术群,里面有阿里、腾讯的大佬。
编写文章不易,若是阅读后以为有用,但愿关注「码哥字节」公众号,点击「分享」、「点赞」、「在看」是最大的鼓励。