This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
<audio id="audio" title="开篇词 | Java程序员如何快速成长" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/3d/4860b25fe8e23a9d659f9b8209f4273d.mp3"></audio>
你好我是李号双很高兴你走进我的“深入拆解Tomcat &amp; Jetty”专栏与我和其他同学一起探讨熟悉又陌生的Tomcat和Jetty。
如果你和我一样选择了Java Web开发这个方向并且正在学习和提高的路上你一定思考过这个问题
**我怎样才能成长为一名高级程序员或者架构师?**
对于这个问题每个人的答案都可能都不太一样我先来讲讲我的经历。十年前我在实习的时候是做嵌入式系统开发用的开发语言是C和C++。出于我个人的兴趣爱好当时我想转Java在学了一段时间的Java后发现Java上手还是挺快的API比较齐全而且也不需要自己来管理内存感觉比C语言高级。毕业后我也顺利地找到了一个Java开发的工作入职后我的工作主要是实现一些小模块很多时候通过代码的复制粘贴再稍微改改就能完成功能这样的状态大概持续了一年。
在这个过程中虽然我对Java语法更加熟悉了也“背”过一些设计模式用过一些Web框架但是我很少有机会将一些Java的高级特性运用到实际项目中因此我对它们的理解也是模糊的。那时候如果让我独立设计一个系统我会感到非常茫然不知道从哪里下手对于Web框架我也只是知道这样用是可以的不知道它背后的原理是什么。并且在我脑子里也没有一张Java Web开发的全景图比如我并不知道浏览器的请求是怎么跟Spring中的代码联系起来的。
后来我分析发现,我的知识体系在广度和深度上都有问题。为了突破这个瓶颈,我当时就想,为什么不站在巨人的肩膀上学习一些优秀的开源系统,看看大牛们是如何思考这些问题的呢。
于是我注意到了像Tomcat和Jetty这样的Web容器觉得它们很神奇只需要把Web应用打成WAR包放到它的目录下启动起来就能通过浏览器来访问了我非常好奇Web容器是如何工作的。此外Tomcat的设计非常经典并且运用了方方面面的Java技术而这些正好是我欠缺的于是我决定选择Tomcat来深入研究。
学习了Tomcat的原理之后我发现Servlet技术是Web开发的原点几乎所有的Java Web框架比如Spring都是基于Servlet的封装Spring应用本身就是一个Servlet而Tomcat和Jetty这样的Web容器负责加载和运行Servlet。你可以通过下面这张图来理解Tomcat和Jetty在Web开发中的位置。
<img src="https://static001.geekbang.org/resource/image/e2/d8/e213f384983f5420884aa085b27eded8.jpg" alt="">
随着学习的深入我还发现Tomcat和Jetty中用到不少Java高级技术比如Java多线程并发编程、Socket网络编程以及反射等等。之前我仅仅只是了解这些技术为了面试也背过一些题但是总感觉“知道”和“会用”之间存在一道鸿沟。通过对Tomcat和Jetty源码的学习我学会了在什么样的场景下去用这些技术这一点至关重要。
还有就是系统设计能力Tomcat和Jetty作为工业级的中间件它们的设计非常优秀比如面向接口编程、组件化、骨架抽象类、一键式启停、对象池技术以及各种设计模式比如模板方法、观察者模式、责任链模式等之后我也开始模仿它们并把这些设计思想运用到实际的工作中。
在理解了Web容器以及JVM的工作原理后我开始解决线上的疑难杂症并且尝试对线上的Tomcat进行调优。性能的提升也是实实在在的成果我也因此得到了同事们的认可。
总之在这个过程中,我逐渐建立起了自己的知识体系,也开始独立设计一个系统,独立解决技术难题,也就是说我渐渐具备了**独当一面**的能力,而这正是高级程序员或者架构师的特质。
概括一下,独当一面的能力,离不开**技术的广度和深度**。
技术的广度体现在你的知识是成体系的,从前端到后端、从应用层面到操作系统、从软件到硬件、从开发、测试、部署到运维…有些领域虽然你不需要挖得很深,但是你必须知道这其中的“门道”。
而技术的深度体现在对于某种技术,你不仅知道怎么用,还知道这项技术如何产生的、它背后的原理是什么,以及它为什么被设计成这样,甚至你还得知道如何去改进它。
但是人的精力是有限的广度和深度该如何权衡呢我建议找准一个点先突破深度而Tomcat和Jetty就是非常好的选择。但同时它们也是比较复杂的具体应该怎么学呢我想通过这个专栏来分享一些我的经验。
首先我们要学习一些基础知识比如操作系统、计算机网络、Java语言面向对象设计、HTTP协议以及Servlet规范等。
接下来我们会学习Tomcat和Jetty的总体架构并从全貌逐步深入到各个组件。在这个过程中我会重点关注组件的工作原理和设计思路比如这个组件为什么设计成这样设计者们当时是怎么考虑这个问题的。然后通过源码的剖析加深你的理解。**更重要的是帮你学会在真实的场景下如何运用Java技术**。
同时我还会通过Jetty与Tomcat的对比比较它们各自的设计特点让你对选型有更深的理解。并且通过思考和总结帮你从中提炼一些通用的设计原则以及实现高性能高并发的思路。
在深入了解Tomcat和Jetty的工作原理之后我会从实战出发带你看看如何监控Tomcat的性能以及怎么从内存、线程池和I/O三个方面进行调优同时我也还会分析和解决一些你在实际工作中可能会碰到的棘手问题。
在这个过程中我还会介绍Tomcat和Jetty支持的Servlet新技术比如WebSocket和异步Servlet等我会重点分析这些新技术是从何而来以及Tomcat和Jetty是如何支持的。这些都是Web技术的最新动向你可以在自己的工作中根据需要选用这些新技术。
总之弄懂了Tomcat和JettyJava Web开发对你来说就已经毫无“秘密”可言。并且你能体会到大神们是如何设计Tomcat和Jetty的体会他们如何思考问题、如何写代码。比如怎样设计服务端程序的I/O和线程模型、怎样写高性能高并发程序、Spring的IoC容器为什么设计成这个样子、设计一个中间件或者框架有哪些套路等…这些都能快速增加你的经验值。
成长的道路没有捷径不仅需要上进心和耐心还要保持对知识的好奇心。如果你也想在技术和视野上有所突破拥有独当一面的能力从Tomcat和Jetty入手是一个非常好的选择我也邀请你与我一起探究Tomcat和Jetty的设计精髓一起收获经验、享受成长。
最后如果你正在Java Web开发这条路上向着架构师的方向狂奔欢迎你给我留言讲讲你所付出的努力、遇到了哪些问题或者写写你对这个专栏的期待期待与你交流。

View File

@@ -0,0 +1,86 @@
<audio id="audio" title="01 | Web容器学习路径" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/d3/34cf9f358085b505f803d4bfdd2c0cd3.mp3"></audio>
你好,我是李号双。在开篇词里我提到要成长为一名高级程序员或者架构师,我们需要提高自己知识的广度和深度。你可以先突破深度,再以点带面拓展广度,因此我建议通过深入学习一些优秀的开源系统来达到突破深度的目的。
我会跟你一起在这个专栏里深入学习Web容器Tomcat和Jetty而作为专栏更新的第1篇文章我想和你谈谈什么是Web容器以及怎么学习Web容器。根据我的经验在学习一门技术之前想一想这两个问题往往可以达到事半功倍的效果。
## Web容器是什么
让我们先来简单回顾一下Web技术的发展历史可以帮助你理解Web容器的由来。
早期的Web应用主要用于浏览新闻等静态页面HTTP服务器比如Apache、Nginx向浏览器返回静态HTML浏览器负责解析HTML将结果呈现给用户。
随着互联网的发展我们已经不满足于仅仅浏览静态页面还希望通过一些交互操作来获取动态结果因此也就需要一些扩展机制能够让HTTP服务器调用服务端程序。
于是Sun公司推出了Servlet技术。你可以把Servlet简单理解为运行在服务端的Java小程序但是Servlet没有main方法不能独立运行因此必须把它部署到Servlet容器中由容器来实例化并调用Servlet。
而Tomcat和Jetty就是一个Servlet容器。为了方便使用它们也具有HTTP服务器的功能因此**Tomcat或者Jetty就是一个“HTTP服务器 + Servlet容器”我们也叫它们Web容器。**
其他应用服务器比如JBoss和WebLogic它们不仅仅有Servlet容器的功能也包含EJB容器是完整的Java EE应用服务器。从这个角度看Tomcat和Jetty算是一个轻量级的应用服务器。
在微服务架构日渐流行的今天开发人员更喜欢稳定的、轻量级的应用服务器并且应用程序用内嵌的方式来运行Servlet容器也逐渐流行起来。之所以选择轻量级是因为在微服务架构下我们把一个大而全的单体应用拆分成一个个功能单一的微服务在这个过程中服务的数量必然要增加但为了减少资源的消耗并且降低部署的成本我们希望运行服务的Web容器也是轻量级的Web容器本身应该消耗较少的内存和CPU资源并且由应用本身来启动一个嵌入式的Web容器而不是通过Web容器来部署和启动应用这样可以降低应用部署的复杂度。
因此轻量级的Tomcat和Jetty就是一个很好的选择并且Tomcat它本身也是Spring Boot默认的嵌入式Servlet容器。最新版本Tomcat和Jetty都支持Servlet 4.0规范。
读到这里我想你应该对Web容器有了基本的认识可以结合平时工作再去细细体会一下。如果你对HTTP协议和Servlet依然是一头雾水不用担心在预习模块中我还会和你聊聊你应该掌握的HTTP协议和Servlet的相关知识帮你打好学习的基础。
## Web容器该怎么学
Java Web技术发展日新月异各种框架也是百花齐放。在从事Java Web开发相关的工作时面对这些眼花缭乱的技术时你是否会感到一丝迷茫可能有些初学者不知道从哪里开始我身边还有些已经进入了这个行业并且有了一定Java基础的人对于系统设计的体会可能还不够深刻编程的时候还停留在完成功能的层次。这样不仅业务上难有突破对于个人成长也很不利。
为了打破这个瓶颈就需要我们在深度上多下功夫找准一个点深挖下去彻底理解它的原理和设计精髓。并且在深入学习Tomcat和Jetty这样的Web容器之前你还需要掌握一定的基础知识这样才能达到事半功倍的效果。
下面我列举一些在学习Web容器之前需要掌握的关键点我建议你在学习专栏的同时再去复习一下这些基础知识。你可以把这些基础知识当作成为架构师的必经之路在专栏以外也要花时间深入进去。当然为了让你更好地理解专栏每期所讲的内容重点的基础知识我也会在文章里帮你再梳理一遍。
**操作系统基础**
Java语言其实是对操作系统API的封装上层应用包括Web容器都是通过操作系统来工作的因此掌握相关的操作系统原理是我们深刻理解Web容器的基础。
对于Web容器来说操作系统方面你应该掌握它的工作原理比如什么是进程、什么是内核、什么是内核空间和用户空间、进程间通信的方式、进程和线程的区别、线程同步的方式、什么是虚拟内存、内存分配的过程、什么是I/O、什么是I/O模型、阻塞与非阻塞的区别、同步与异步的区别、网络通信的原理、OSI七层网络模型以及TCP/IP、UDP和HTTP协议。
总之一句话基础扎实了你学什么都快。关于操作系统的学习我推荐你读一读《UNIX环境高级编程》这本经典书籍。
**Java语言基础**
Java的基础知识包括Java基本语法、面向对象设计的概念封装、继承、多态、接口、抽象类等、Java集合的使用、Java I/O体系、异常处理、基本的多线程并发编程包括线程同步、原子类、线程池、并发容器的使用和原理、Java网络编程I/O模型BIO、NIO、AIO的原理和相应的Java API、Java注解以及Java反射的原理等。
此外你还需要了解一些JVM的基本知识比如JVM的类加载机制、JVM内存模型、JVM内存空间分布、JVM内存和本地内存的区别以及JVM GC的原理等。
这方面我推荐的经典书籍有[《Java核心技术》](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2fnx3ed6fpk3c)、[《Java编程思想》](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F3f0ddticdedfc)、[《Java并发编程实战》](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2758xqdzr6uuw)和[《深入理解Java虚拟机JVM高级特性与最佳实践》](time://mall?url=http%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F36a92yq65q4x4)等。
**Java Web开发基础**
具备了一定的操作系统和Java基础接下来就可以开始学习Java Web开发你可以开始学习一些通用的设计原则和设计模式。这个阶段的核心任务就是了解Web的工作原理**同时提高你的设计能力**注重代码的质量。我的建议是可以从学习Servlet和Servlet容器开始。我见过不少同学跳过这个阶段直接学Web框架这样做的话结果会事倍功半。
为什么这么说呢Web框架的本质是开发者在使用某种语言编写Web应用时总结出的一些经验和设计思路。很多Web框架都是从实际的Web项目抽取出来的其目的是用于简化Web应用程序开发。
我以Spring框架为例给你讲讲Web框架是怎么产生的。Web应用程序的开发主要是完成两方面的工作。
<li>
设计并实现类,包括定义类与类之间的关系,以及实现类的方法,方法对数据的操作就是具体的业务逻辑。
</li>
<li>
类设计好之后,需要创建这些类的实例并根据类与类的关系把它们组装在一起,这样类的实例才能一起协作完成业务功能。
</li>
就好比制造一辆汽车汽车是由零件组装而成的。第一步是画出各种零件的图纸以及定义零件之间的接口。第二步把把图纸交给工厂去生产零件并组装在一起。因此对于Web应用开发来说第一步工作是具体业务逻辑的实现每个应用都不一样。而第二步工作相对来说比较通用和标准化工厂拿到零件的图纸就知道怎么生产零件并按照零件之间的接口把它们组装起来因此这个工作就被抽取出来交给Spring框架来做。
Spring又是用容器来完成这个工作的的容器负责创建、组装和销毁这些类的实例而应用只需要通过配置文件或者注解来告诉Spring类与类之间的关系。但是容器的概念不是Spring发明的最开始来源于Servlet容器并且Servlet容器也是通过配置文件来加载Servlet的。你会发现它们的“元神”是相似的在Web应用的开发中有一些本质的东西是不变的而很多“元神”就藏在“老祖宗”那里藏在Servlet容器的设计里。
Spring框架就是对Servlet的封装Spring应用本身就是一个Servlet而Servlet容器是管理和运行Servlet的因此我们需要先理解Servlet和Servlet容器是怎样工作的才能更好地理解Spring。
## 本期精华
今天我谈了什么是Web容器以及该如何学习Web容器。在深入学习之前你需要掌握一些操作系统、Java和Web的基础知识。我希望你在学习专栏的过程中多温习一下这些基础知识有扎实的基础再结合专栏深入学习Web容器就比较容易了。
等你深刻理解了Web容器的工作原理和设计精髓以后你就可以把学到的知识扩展到其他领域你会发现它们的本质都是相通的这个时候你可以站在更高的角度来学习和审视各种Web框架。虽然Web框架的更新比较快但是抓住了框架的本质在学习的过程中往往会更得心应手。
不知道你有没有遇到过这样的场景,当你在看一个框架的技术细节时,会突然恍然大悟:对啊,就是应该这么设计!如果你有这种感觉,说明你的知识储备起到了作用,你对框架的运用也会更加自如。
## 课后思考
请你分享一下你对Web容器的理解或者你在学习、使用Web容器时遇到了哪些问题
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="02 | HTTP协议必知必会" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/93/d8f55a07283b9d1553f64efaa4a5c793.mp3"></audio>
在开始学习Web容器之前我想先问你一个问题HTTP和HTML有什么区别
为什么我会问这个问题你可以把它当作一个入门测试检测一下自己的对HTTP协议的理解。因为Tomcat和Jetty本身就是一个“HTTP服务器 + Servlet容器”如果你想深入理解Tomcat和Jetty的工作原理我认为理解HTTP协议的工作原理是学习的基础。
如果你对这个问题还稍有迟疑那么请跟我一起来回顾一下HTTP协议吧。
## HTTP的本质
HTTP协议是浏览器与服务器之间的数据传送协议。作为应用层协议HTTP是基于TCP/IP协议来传递数据的HTML文件、图片、查询结果等HTTP协议不涉及数据包Packet传输主要规定了客户端和服务器之间的通信格式。
下面我通过一个例子来告诉你HTTP的本质是什么。
假如浏览器需要从远程HTTP服务器获取一个HTML文本在这个过程中浏览器实际上要做两件事情。
<li>
与服务器建立Socket连接。
</li>
<li>
生成**请求数据**并通过Socket发送出去。
</li>
第一步比较容易理解,浏览器从地址栏获取用户输入的网址和端口,去连接远端的服务器,这样就能通信了。
我们重点来看第二步,这个请求数据到底长什么样呢?都请求些什么内容呢?或者换句话说,浏览器需要告诉服务端什么信息呢?
首先最基本的是你要让服务端知道你的意图你是想获取内容还是提交内容其次你需要告诉服务端你想要哪个内容。那么要把这些信息以一种什么样的格式放到请求里去呢这就是HTTP协议要解决的问题。也就是说**HTTP协议的本质就是一种浏览器与服务器之间约定好的通信格式**。那浏览器与服务器之间具体是怎么工作的呢?
## HTTP工作原理
请你来看下面这张图我们过一遍一次HTTP的请求过程。
<img src="https://static001.geekbang.org/resource/image/f5/ca/f5bd0c7840160d5a121c191e7e54b4ca.jpg" alt="">
从图上你可以看到,这个过程是:
1.用户通过浏览器进行了一个操作,比如输入网址并回车,或者是点击链接,接着浏览器获取了这个事件。
2.浏览器向服务端发出TCP连接请求。
3.服务程序接受浏览器的连接请求并经过TCP三次握手建立连接。
4.浏览器将请求数据打包成一个HTTP协议格式的数据包。
5.浏览器将该数据包推入网络,数据包经过网络传输,最终达到端服务程序。
6.服务端程序拿到这个数据包后同样以HTTP协议格式解包获取到客户端的意图。
7.得知客户端意图后进行处理,比如提供静态文件或者调用服务端程序获得动态结果。
8.服务器将响应结果可能是HTML或者图片等按照HTTP协议格式打包。
9.服务器将响应数据包推入网络,数据包经过网络传输最终达到到浏览器。
10.浏览器拿到数据包后以HTTP协议的格式解包然后解析数据假设这里的数据是HTML。
11.浏览器将HTML文件展示在页面上。
那我们想要探究的Tomcat和Jetty作为一个HTTP服务器在这个过程中都做了些什么事情呢主要是接受连接、解析请求数据、处理请求和发送响应这几个步骤。这里请你注意可能有成千上万的浏览器同时请求同一个HTTP服务器因此Tomcat和Jetty为了提高服务的能力和并发度往往会将自己要做的几个事情并行化具体来说就是使用多线程的技术。这也是专栏所关注的一个重点我在后面会进行专门讲解。
## HTTP请求响应实例
你有没有注意到在浏览器和HTTP服务器之间通信的过程中首先要将数据打包成HTTP协议的格式那HTTP协议的数据包具体长什么样呢这里我以极客时间的登陆请求为例用户在登陆页面输入用户名和密码点击登陆后浏览器发出了这样的HTTP请求
<img src="https://static001.geekbang.org/resource/image/f5/14/f58bf57649ec9eb35eb24e0679bb2514.png" alt="">
你可以看到HTTP请求数据由三部分组成分别是**请求行、请求报头、请求正文**。当这个HTTP请求数据到达Tomcat后Tomcat会把HTTP请求数据字节流解析成一个Request对象这个Request对象封装了HTTP所有的请求信息。接着Tomcat把这个Request对象交给Web应用去处理处理完后得到一个Response对象Tomcat会把这个Response对象转成HTTP格式的响应数据并发送给浏览器。
我们再来看看HTTP响应的格式HTTP的响应也是由三部分组成分别是**状态行、响应报头、报文主体**。同样,我还以极客时间登陆请求的响应为例。
<img src="https://static001.geekbang.org/resource/image/84/b7/84f4fe4c411dfb9fd83a1d53cf2915b7.png" alt="">
具体的HTTP协议格式你可以去网上搜索我就不再赘述了。为了更好地帮助你理解HTTP服务器比如Tomcat的工作原理接下来我想谈一谈Cookie跟Session的原理。
## Cookie和Session
我们知道HTTP协议有个特点是无状态请求与请求之间是没有关系的。这样会出现一个很尴尬的问题Web应用不知道你是谁。比如你登陆淘宝后在购物车中添加了三件商品刷新一下网页这时系统提示你仍然处于未登录的状态购物车也空了很显然这种情况是不可接受的。因此HTTP协议需要一种技术让请求与请求之间建立起联系并且服务器需要知道这个请求来自哪个用户于是Cookie技术出现了。
**1. Cookie技术**
Cookie是HTTP报文的一个请求头Web应用可以将用户的标识信息或者其他一些信息用户名等存储在Cookie中。用户经过验证之后每次HTTP请求报文中都包含Cookie这样服务器读取这个Cookie请求头就知道用户是谁了。**Cookie本质上就是一份存储在用户本地的文件里面包含了每次请求中都需要传递的信息**。
**2. Session技术**
由于Cookie以明文的方式存储在本地而Cookie中往往带有用户信息这样就造成了非常大的安全隐患。而Session的出现解决了这个问题**Session可以理解为服务器端开辟的存储空间里面保存了用户的状态**用户信息以Session的形式存储在服务端。当用户请求到来时服务端可以把用户的请求和用户的Session对应起来。那么Session是怎么和请求对应起来的呢答案是通过Cookie浏览器在Cookie中填充了一个Session ID之类的字段用来标识请求。
具体工作过程是这样的服务器在创建Session的同时会为该Session生成唯一的Session ID当浏览器再次发送请求的时候会将这个Session ID带上服务器接受到请求之后就会依据Session ID找到相应的Session找到Session后就可以在Session中获取或者添加内容了。而这些内容只会保存在服务器中发到客户端的只有Session ID这样相对安全也节省了网络流量因为不需要在Cookie中存储大量用户信息。
**3. Session创建与存储**
那么Session在何时何地创建呢当然还是在服务器端程序运行的过程中创建的不同语言实现的应用程序有不同的创建Session的方法。在Java中是Web应用程序在调用HttpServletRequest的getSession方法时由Web容器比如Tomcat创建的。那HttpServletRequest又是什么呢别着急我们下一期再聊。
Tomcat的Session管理器提供了多种持久化方案来存储Session通常会采用高性能的存储方式比如Redis并且通过集群部署的方式防止单点故障从而提升高可用。同时Session有过期时间因此Tomcat会开启后台线程定期的轮询如果Session过期了就将Session失效。
## 本期精华
HTTP协议和其他应用层协议一样本质上是一种通信格式。回到文章开头我问你的问题其实答案很简单HTTP是通信的方式HTML才是通信的目的就好比HTTP是信封信封里面的信HTML才是内容但是没有信封信也没办法寄出去。HTTP协议就是浏览器与服务器之间的沟通语言具体交互过程是请求、处理和响应。
由于HTTP是无状态的协议为了识别请求是哪个用户发过来的出现了Cookie和Session技术。Cookie本质上就是一份存储在用户本地的文件里面包含了每次请求中都需要传递的信息Session可以理解为服务器端开辟的存储空间里面保存的信息用于保持状态。作为Web容器Tomcat负责创建和管理Session并提供了多种持久化方案来存储Session。
## 课后思考
在HTTP/1.0时期每次HTTP请求都会创建一个新的TCP连接请求完成后之后这个TCP连接就会被关闭。这种通信模式的效率不高所以在HTTP/1.1中引入了HTTP长连接的概念使用长连接的HTTP协议会在响应头加入Connection:keep-alive。这样当浏览器完成一次请求后浏览器和服务器之间的TCP连接不会关闭再次访问这个服务器上的网页时浏览器会继续使用这一条已经建立的连接也就是说两个请求可能共用一个TCP连接。
今天留给你的思考题是我在上面提到HTTP的特点是无状态的多个请求之间是没有关系的这是不是矛盾了
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,104 @@
<audio id="audio" title="03 | 你应该知道的Servlet规范和Servlet容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/aa/b57e22e43a21499972eb20ad00177caa.mp3"></audio>
通过专栏上一期的学习我们知道浏览器发给服务端的是一个HTTP格式的请求HTTP服务器收到这个请求后需要调用服务端程序来处理所谓的服务端程序就是你写的Java类一般来说不同的请求需要由不同的Java类来处理。
那么问题来了HTTP服务器怎么知道要调用哪个Java类的哪个方法呢。最直接的做法是在HTTP服务器代码里写一大堆if else逻辑判断如果是A请求就调X类的M1方法如果是B请求就调Y类的M2方法。但这样做明显有问题因为HTTP服务器的代码跟业务逻辑耦合在一起了如果新加一个业务方法还要改HTTP服务器的代码。
那该怎么解决这个问题呢我们知道面向接口编程是解决耦合问题的法宝于是有一伙人就定义了一个接口各种业务类都必须实现这个接口这个接口就叫Servlet接口有时我们也把实现了Servlet接口的业务类叫作Servlet。
但是这里还有一个问题对于特定的请求HTTP服务器如何知道由哪个Servlet来处理呢Servlet又是由谁来实例化呢显然HTTP服务器不适合做这个工作否则又和业务类耦合了。
于是还是那伙人又发明了Servlet容器Servlet容器用来加载和管理业务类。HTTP服务器不直接跟业务类打交道而是把请求交给Servlet容器去处理Servlet容器会将请求转发到具体的Servlet如果这个Servlet还没创建就加载并实例化这个Servlet然后调用这个Servlet的接口方法。因此Servlet接口其实是**Servlet容器跟具体业务类之间的接口**。下面我们通过一张图来加深理解。
<img src="https://static001.geekbang.org/resource/image/df/01/dfe304d3336f29d833b97f2cfe8d7801.jpg" alt="">
图的左边表示HTTP服务器直接调用具体业务类它们是紧耦合的。再看图的右边HTTP服务器不直接调用业务类而是把请求交给容器来处理容器通过Servlet接口调用业务类。因此Servlet接口和Servlet容器的出现达到了HTTP服务器与业务类解耦的目的。
而Servlet接口和Servlet容器这一整套规范叫作Servlet规范。Tomcat和Jetty都按照Servlet规范的要求实现了Servlet容器同时它们也具有HTTP服务器的功能。作为Java程序员如果我们要实现新的业务功能只需要实现一个Servlet并把它注册到TomcatServlet容器剩下的事情就由Tomcat帮我们处理了。
接下来我们来看看Servlet接口具体是怎么定义的以及Servlet规范又有哪些要重点关注的地方呢
## Servlet接口
Servlet接口定义了下面五个方法
```
public interface Servlet {
void init(ServletConfig config) throws ServletException;
ServletConfig getServletConfig();
void service(ServletRequest req, ServletResponse resthrows ServletException, IOException;
String getServletInfo();
void destroy();
}
```
其中最重要是的service方法具体业务类在这个方法里实现处理逻辑。这个方法有两个参数ServletRequest和ServletResponse。ServletRequest用来封装请求信息ServletResponse用来封装响应信息因此**本质上这两个类是对通信协议的封装。**
比如HTTP协议中的请求和响应就是对应了HttpServletRequest和HttpServletResponse这两个类。你可以通过HttpServletRequest来获取所有请求相关的信息包括请求路径、Cookie、HTTP头、请求参数等。此外我在专栏上一期提到过我们还可以通过HttpServletRequest来创建和获取Session。而HttpServletResponse是用来封装HTTP响应的。
你可以看到接口中还有两个跟生命周期有关的方法init和destroy这是一个比较贴心的设计Servlet容器在加载Servlet类的时候会调用init方法在卸载的时候会调用destroy方法。我们可能会在init方法里初始化一些资源并在destroy方法里释放这些资源比如Spring MVC中的DispatcherServlet就是在init方法里创建了自己的Spring容器。
你还会注意到ServletConfig这个类ServletConfig的作用就是封装Servlet的初始化参数。你可以在`web.xml`给Servlet配置参数并在程序里通过getServletConfig方法拿到这些参数。
我们知道有接口一般就有抽象类抽象类用来实现接口和封装通用的逻辑因此Servlet规范提供了GenericServlet抽象类我们可以通过扩展它来实现Servlet。虽然Servlet规范并不在乎通信协议是什么但是大多数的Servlet都是在HTTP环境中处理的因此Servet规范还提供了HttpServlet来继承GenericServlet并且加入了HTTP特性。这样我们通过继承HttpServlet类来实现自己的Servlet只需要重写两个方法doGet和doPost。
## Servlet容器
我在前面提到为了解耦HTTP服务器不直接调用Servlet而是把请求交给Servlet容器来处理那Servlet容器又是怎么工作的呢接下来我会介绍Servlet容器大体的工作流程一起来聊聊我们非常关心的两个话题**Web应用的目录格式是什么样的以及我该怎样扩展和定制化Servlet容器的功能**。
**工作流程**
当客户请求某个资源时HTTP服务器会用一个ServletRequest对象把客户的请求信息封装起来然后调用Servlet容器的service方法Servlet容器拿到请求后根据请求的URL和Servlet的映射关系找到相应的Servlet如果Servlet还没有被加载就用反射机制创建这个Servlet并调用Servlet的init方法来完成初始化接着调用Servlet的service方法来处理请求把ServletResponse对象返回给HTTP服务器HTTP服务器会把响应发送给客户端。同样我通过一张图来帮助你理解。
<img src="https://static001.geekbang.org/resource/image/b7/15/b70723c89b4ed0bccaf073c84e08e115.jpg" alt="">
**Web应用**
Servlet容器会实例化和调用Servlet那Servlet是怎么注册到Servlet容器中的呢一般来说我们是以Web应用程序的方式来部署Servlet的而根据Servlet规范Web应用程序有一定的目录结构在这个目录下分别放置了Servlet的类文件、配置文件以及静态资源Servlet容器通过读取配置文件就能找到并加载Servlet。Web应用的目录结构大概是下面这样的
```
| - MyWebApp
| - WEB-INF/web.xml -- 配置文件用来配置Servlet等
| - WEB-INF/lib/ -- 存放Web应用所需各种JAR包
| - WEB-INF/classes/ -- 存放你的应用类比如Servlet类
| - META-INF/ -- 目录存放工程的一些信息
```
Servlet规范里定义了**ServletContext**这个接口来对应一个Web应用。Web应用部署好后Servlet容器在启动时会加载Web应用并为每个Web应用创建唯一的ServletContext对象。你可以把ServletContext看成是一个全局对象一个Web应用可能有多个Servlet这些Servlet可以通过全局的ServletContext来共享数据这些数据包括Web应用的初始化参数、Web应用目录下的文件资源等。由于ServletContext持有所有Servlet实例你还可以通过它来实现Servlet请求的转发。
**扩展机制**
不知道你有没有发现引入了Servlet规范后你不需要关心Socket网络通信、不需要关心HTTP协议也不需要关心你的业务类是如何被实例化和调用的因为这些都被Servlet规范标准化了你只要关心怎么实现的你的业务逻辑。这对于程序员来说是件好事但也有不方便的一面。所谓规范就是说大家都要遵守就会千篇一律但是如果这个规范不能满足你的业务的个性化需求就有问题了因此设计一个规范或者一个中间件要充分考虑到可扩展性。Servlet规范提供了两种扩展机制**Filter**和**Listener**。
**Filter**是过滤器这个接口允许你对请求和响应做一些统一的定制化处理比如你可以根据请求的频率来限制访问或者根据国家地区的不同来修改响应内容。过滤器的工作原理是这样的Web应用部署完成后Servlet容器需要实例化Filter并把Filter链接成一个FilterChain。当请求进来时获取第一个Filter并调用doFilter方法doFilter方法负责调用这个FilterChain中的下一个Filter。
**Listener**是监听器这是另一种扩展机制。当Web应用在Servlet容器中运行时Servlet容器内部会不断的发生各种事件如Web应用的启动和停止、用户请求到达等。 Servlet容器提供了一些默认的监听器来监听这些事件当事件发生时Servlet容器会负责调用监听器的方法。当然你可以定义自己的监听器去监听你感兴趣的事件将监听器配置在`web.xml`中。比如Spring就实现了自己的监听器来监听ServletContext的启动事件目的是当Servlet容器启动时创建并初始化全局的Spring容器。
到这里相信你对Servlet容器的工作原理有了深入的了解只有理解了这些原理我们才能更好的理解Tomcat和Jetty因为它们都是Servlet容器的具体实现。后面我还会详细谈到Tomcat和Jetty是如何设计和实现Servlet容器的虽然它们的实现方法各有特点但是都遵守了Servlet规范因此你的Web应用可以在这两个Servlet容器中方便的切换。
## 本期精华
今天我们学习了什么是Servlet回顾一下Servlet本质上是一个接口实现了Servlet接口的业务类也叫Servlet。Servlet接口其实是Servlet容器跟具体Servlet业务类之间的接口。Servlet接口跟Servlet容器这一整套规范叫作Servlet规范而Servlet规范使得程序员可以专注业务逻辑的开发同时Servlet规范也给开发者提供了扩展的机制Filter和Listener。
最后我给你总结一下Filter和Listener的本质区别
<li>
**Filter是干预过程的**,它是过程的一部分,是基于过程行为的。
</li>
<li>
**Listener是基于状态的**,任何行为改变同一个状态,触发的事件是一致的。
</li>
## 课后思考
Servlet容器与Spring容器有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,250 @@
<audio id="audio" title="04 | 实战纯手工打造和运行一个Servlet" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/2e/bf8e5c8f4c1c003b36f013012072d32e.mp3"></audio>
作为Java程序员我们可能已经习惯了使用IDE和Web框架进行开发IDE帮我们做了编译、打包的工作而Spring框架在背后帮我们实现了Servlet接口并把Servlet注册到了Web容器这样我们可能很少有机会接触到一些底层本质的东西比如怎么开发一个Servlet如何编译Servlet如何在Web容器中跑起来
今天我们就抛弃IDE、拒绝框架自己纯手工编写一个Servlet并在Tomcat中运行起来。一方面进一步加深对Servlet的理解另一方面还可以熟悉一下Tomcat的基本功能使用。
主要的步骤有:
1.下载并安装Tomcat。<br>
2.编写一个继承HttpServlet的Java类。<br>
3.将Java类文件编译成Class文件。<br>
4.建立Web应用的目录结构并配置`web.xml`<br>
5.部署Web应用。<br>
6.启动Tomcat。<br>
7.浏览器访问验证结果。<br>
8.查看Tomcat日志。
下面你可以跟我一起一步步操作来完成整个过程。Servlet 3.0规范支持用注解的方式来部署Servlet不需要在`web.xml`里配置最后我会演示怎么用注解的方式来部署Servlet。
**1. 下载并安装Tomcat**
最新版本的Tomcat可以直接在[官网](https://tomcat.apache.org/download-90.cgi)上下载根据你的操作系统下载相应的版本这里我使用的是Mac系统下载完成后直接解压解压后的目录结构如下。
<img src="https://static001.geekbang.org/resource/image/0f/d6/0f9c064d26fec3e620f494caabbab8d6.png" alt="">
下面简单介绍一下这些目录:
/bin存放Windows或Linux平台上启动和关闭Tomcat的脚本文件。<br>
/conf存放Tomcat的各种全局配置文件其中最重要的是`server.xml`<br>
/lib存放Tomcat以及所有Web应用都可以访问的JAR文件。<br>
/logs存放Tomcat执行时产生的日志文件。<br>
/work存放JSP编译后产生的Class文件。<br>
/webappsTomcat的Web应用目录默认情况下把Web应用放在这个目录下。
**2. 编写一个继承HttpServlet的Java类**
我在专栏上一期提到,`javax.servlet`包提供了实现Servlet接口的GenericServlet抽象类。这是一个比较方便的类可以通过扩展它来创建Servlet。但是大多数的Servlet都在HTTP环境中处理请求因此Servlet规范还提供了HttpServlet来扩展GenericServlet并且加入了HTTP特性。我们通过继承HttpServlet类来实现自己的Servlet只需要重写两个方法doGet和doPost。
因此今天我们创建一个Java类去继承HttpServlet类并重写doGet和doPost方法。首先新建一个名为`MyServlet.java`的文件,敲入下面这些代码:
```
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class MyServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println(&quot;MyServlet 在处理get请求...&quot;);
PrintWriter out = response.getWriter();
response.setContentType(&quot;text/html;charset=utf-8&quot;);
out.println(&quot;&lt;strong&gt;My Servlet!&lt;/strong&gt;&lt;br&gt;&quot;);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println(&quot;MyServlet 在处理post请求...&quot;);
PrintWriter out = response.getWriter();
response.setContentType(&quot;text/html;charset=utf-8&quot;);
out.println(&quot;&lt;strong&gt;My Servlet!&lt;/strong&gt;&lt;br&gt;&quot;);
}
}
```
这个Servlet完成的功能很简单分别在doGet和doPost方法体里返回一段简单的HTML。
**3. 将Java文件编译成Class文件**
下一步我们需要把`MyServlet.java`文件编译成Class文件。你需要先安装JDK这里我使用的是JDK 10。接着你需要把Tomcat lib目录下的`servlet-api.jar`拷贝到当前目录下,这是因为`servlet-api.jar`中定义了Servlet接口而我们的Servlet类实现了Servlet接口因此编译Servlet类需要这个JAR包。接着我们执行编译命令
```
javac -cp ./servlet-api.jar MyServlet.java
```
编译成功后,你会在当前目录下找到一个叫`MyServlet.class`的文件。
**4. 建立Web应用的目录结构**
我们在上一期学到Servlet是放到Web应用部署到Tomcat的而Web应用具有一定的目录结构所有我们按照要求建立Web应用文件夹名字叫MyWebApp然后在这个目录下建立子文件夹像下面这样
```
MyWebApp/WEB-INF/web.xml
MyWebApp/WEB-INF/classes/MyServlet.class
```
然后在`web.xml`中配置Servlet内容如下
```
&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;web-app xmlns=&quot;http://xmlns.jcp.org/xml/ns/javaee&quot;
xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
xsi:schemaLocation=&quot;http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd&quot;
version=&quot;4.0&quot;
metadata-complete=&quot;true&quot;&gt;
&lt;description&gt; Servlet Example. &lt;/description&gt;
&lt;display-name&gt; MyServlet Example &lt;/display-name&gt;
&lt;request-character-encoding&gt;UTF-8&lt;/request-character-encoding&gt;
&lt;servlet&gt;
&lt;servlet-name&gt;myServlet&lt;/servlet-name&gt;
&lt;servlet-class&gt;MyServlet&lt;/servlet-class&gt;
&lt;/servlet&gt;
&lt;servlet-mapping&gt;
&lt;servlet-name&gt;myServlet&lt;/servlet-name&gt;
&lt;url-pattern&gt;/myservlet&lt;/url-pattern&gt;
&lt;/servlet-mapping&gt;
&lt;/web-app&gt;
```
你可以看到在`web.xml`配置了Servlet的名字和具体的类以及这个Servlet对应的URL路径。请你注意**servlet和servlet-mapping这两个标签里的servlet-name要保持一致。**
**5. 部署Web应用**
Tomcat应用的部署非常简单将这个目录MyWebApp拷贝到Tomcat的安装目录下的webapps目录即可。
**6. 启动Tomcat**
找到Tomcat安装目录下的bin目录根据操作系统的不同执行相应的启动脚本。如果是Windows系统执行`startup.bat`.如果是Linux系统则执行`startup.sh`
**7. 浏览访问验证结果**
在浏览器里访问这个URL`http://localhost:8080/MyWebApp/myservlet`,你会看到:
```
My Servlet!
```
这里需要注意访问URL路径中的MyWebApp是Web应用的名字`myservlet`是在`web.xml`里配置的Servlet的路径。
**8. 查看Tomcat日志**
打开Tomcat的日志目录也就是Tomcat安装目录下的logs目录。Tomcat的日志信息分为两类 :一是运行日志,它主要记录运行过程中的一些信息,尤其是一些异常错误日志信息 二是访问日志它记录访问的时间、IP地址、访问的路径等相关信息。
这里简要介绍各个文件的含义。
- `catalina.***.log`
主要是记录Tomcat启动过程的信息在这个文件可以看到启动的JVM参数以及操作系统等日志信息。
- `catalina.out`
`catalina.out`是Tomcat的标准输出stdout和标准错误stderr这是在Tomcat的启动脚本里指定的如果没有修改的话stdout和stderr会重定向到这里。所以在这个文件里可以看到我们在`MyServlet.java`程序里打印出来的信息:
>
MyServlet在处理get请求…
- `localhost.**.log`
主要记录Web应用在初始化过程中遇到的未处理的异常会被Tomcat捕获而输出这个日志文件。
- `localhost_access_log.**.txt`
存放访问Tomcat的请求日志包括IP地址以及请求的路径、时间、请求协议以及状态码等信息。
- `manager.***.log/host-manager.***.log`
存放Tomcat自带的Manager项目的日志信息。
**用注解的方式部署Servlet**
为了演示用注解的方式来部署Servlet我们首先修改Java代码给Servlet类加上**@WebServlet**注解,修改后的代码如下。
```
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@WebServlet(&quot;/myAnnotationServlet&quot;)
public class AnnotationServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println(&quot;AnnotationServlet 在处理get请求...&quot;);
PrintWriter out = response.getWriter();
response.setContentType(&quot;text/html; charset=utf-8&quot;);
out.println(&quot;&lt;strong&gt;Annotation Servlet!&lt;/strong&gt;&lt;br&gt;&quot;);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println(&quot;AnnotationServlet 在处理post请求...&quot;);
PrintWriter out = response.getWriter();
response.setContentType(&quot;text/html; charset=utf-8&quot;);
out.println(&quot;&lt;strong&gt;Annotation Servlet!&lt;/strong&gt;&lt;br&gt;&quot;);
}
}
```
这段代码里最关键的就是这个注解它表明两层意思第一层意思是AnnotationServlet这个Java类是一个Servlet第二层意思是这个Servlet对应的URL路径是myAnnotationServlet。
```
@WebServlet(&quot;/myAnnotationServlet&quot;)
```
创建好Java类以后同样经过编译并放到MyWebApp的class目录下。这里要注意的是你**需要删除原来的web.xml**,因为我们不需要`web.xml`来配置Servlet了。然后重启Tomcat接下来我们验证一下这个新的AnnotationServlet有没有部署成功。在浏览器里输入`http://localhost:8080/MyWebApp/myAnnotationServlet`,得到结果:
```
Annotation Servlet!
```
这说明我们的AnnotationServlet部署成功了。可以通过注解完成`web.xml`所有的配置功能包括Servlet初始化参数以及配置Filter和Listener等。
## 本期精华
通过今天的学习和实践相信你掌握了如何通过扩展HttpServlet来实现自己的Servlet知道了如何编译Servlet、如何通过`web.xml`来部署Servlet同时还练习了如何启动Tomcat、如何查看Tomcat的各种日志并且还掌握了如何通过注解的方式来部署Servlet。我相信通过专栏前面文章的学习加上今天的练习实践一定会加深你对Servlet工作原理的理解。之所以我设置今天的实战练习是希望你知道IDE和Web框架在背后为我们做了哪些事情这对于我们排查问题非常重要因为只有我们明白了IDE和框架在背后做的事情一旦出现问题的时候我们才能判断它们做得对不对否则可能开发环境里的一个小问题就会折腾我们半天。
## 课后思考
我在Servlet类里同时实现了doGet方法和doPost方法从浏览器的网址访问默认访问的是doGet方法今天的课后思考题是如何访问这个doPost方法。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="14 | NioEndpoint组件Tomcat如何实现非阻塞I/O" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/41/208714cb04bf6bf86c069ae181087741.mp3"></audio>
UNIX系统下的I/O模型有5种同步阻塞I/O、同步非阻塞I/O、I/O多路复用、信号驱动I/O和异步I/O。这些名词我们好像都似曾相识但这些I/O通信模型有什么区别同步和阻塞似乎是一回事到底有什么不同等一下在这之前你是不是应该问自己一个终极问题什么是I/O为什么需要这些I/O模型
所谓的**I/O就是计算机内存与外部设备之间拷贝数据的过程**。我们知道CPU访问内存的速度远远高于外部设备因此CPU是先把外部设备的数据读到内存里然后再进行处理。请考虑一下这个场景当你的程序通过CPU向外部设备发出一个读指令时数据从外部设备拷贝到内存往往需要一段时间这个时候CPU没事干了你的程序是主动把CPU让给别人还是让CPU不停地查数据到了吗数据到了吗……
这就是I/O模型要解决的问题。今天我会先说说各种I/O模型的区别然后重点分析Tomcat的NioEndpoint组件是如何实现非阻塞I/O模型的。
## Java I/O模型
对于一个网络I/O通信过程比如网络数据读取会涉及两个对象一个是调用这个I/O操作的用户线程另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间用户线程不能直接访问内核空间。
当用户线程发起I/O操作后网络数据读取操作会经历两个步骤
- **用户线程等待内核将数据从网卡拷贝到内核空间。**
- **内核将数据从内核空间拷贝到用户空间。**
各种I/O模型的区别就是它们实现这两个步骤的方式是不一样的。
**同步阻塞I/O**用户线程发起read调用后就阻塞了让出CPU。内核等待网卡数据到来把数据从网卡拷贝到内核空间接着把数据拷贝到用户空间再把用户线程叫醒。
<img src="https://static001.geekbang.org/resource/image/99/de/9925741240414d45a3480e976a9eb5de.jpg" alt="">
**同步非阻塞I/O**用户线程不断的发起read调用数据没到内核空间时每次都返回失败直到数据到了内核空间这一次read调用后在等待数据从内核空间拷贝到用户空间这段时间里线程还是阻塞的等数据到了用户空间再把线程叫醒。
<img src="https://static001.geekbang.org/resource/image/f6/b9/f609702b40d7fa873f049e472d1819b9.jpg" alt="">
**I/O多路复用**用户线程的读取操作分成两步了线程先发起select调用目的是问内核数据准备好了吗等内核把数据准备好了用户线程再发起read调用。在等待数据从内核空间拷贝到用户空间这段时间里线程还是阻塞的。那为什么叫I/O多路复用呢因为一次select调用可以向内核查多个数据通道Channel的状态所以叫多路复用。
<img src="https://static001.geekbang.org/resource/image/cd/99/cd2f30b47a690c0fe3b0332203dd3e99.jpg" alt="">
**异步I/O**用户线程发起read调用的同时注册一个回调函数read立即返回等内核将数据准备好后再调用指定的回调函数完成处理。在这个过程中用户线程一直没有阻塞。
<img src="https://static001.geekbang.org/resource/image/aa/c3/aacd28f7f9719ceeb2f649db1a6c06c3.jpg" alt="">
## NioEndpoint组件
Tomcat的NioEndpoint组件实现了I/O多路复用模型接下来我会介绍NioEndpoint的实现原理下一期我会介绍Tomcat如何实现异步I/O模型。
**总体工作流程**
我们知道对于Java的多路复用器的使用无非是两步
<li>
创建一个Selector在它身上注册各种感兴趣的事件然后调用select方法等待感兴趣的事情发生。
</li>
<li>
感兴趣的事情发生了比如可以读了这时便创建一个新的线程从Channel中读数据。
</li>
Tomcat的NioEndpoint组件虽然实现比较复杂但基本原理就是上面两步。我们先来看看它有哪些组件它一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件它们的工作过程如下图所示。
<img src="https://static001.geekbang.org/resource/image/c4/65/c4bbda75005dd5e8519c2bc439359465.jpg" alt="">
LimitLatch是连接控制器它负责控制最大连接数NIO模式下默认是10000达到这个阈值后连接请求被拒绝。
Acceptor跑在一个单独的线程里它在一个死循环里调用accept方法来接收新连接一旦有新的连接请求到来accept方法返回一个Channel对象接着把Channel对象交给Poller去处理。
Poller的本质是一个Selector也跑在单独线程里。Poller在内部维护一个Channel数组它在一个死循环里不断检测Channel的数据就绪状态一旦有Channel可读就生成一个SocketProcessor任务对象扔给Executor去处理。
Executor就是线程池负责运行SocketProcessor任务类SocketProcessor的run方法会调用Http11Processor来读取和解析请求数据。我们知道Http11Processor是应用层协议的封装它会调用容器获得响应再把响应通过Channel写出。
接下来我详细介绍一下各组件的设计特点。
**LimitLatch**
LimitLatch用来控制连接个数当连接数到达最大时阻塞线程直到后续组件处理完一个连接后将连接数减1。请你注意到达最大连接数后操作系统底层还是会接收客户端连接但用户层已经不再接收。LimitLatch的核心代码如下
```
public class LimitLatch {
private class Sync extends AbstractQueuedSynchronizer {
@Override
protected int tryAcquireShared() {
long newCount = count.incrementAndGet();
if (newCount &gt; limit) {
count.decrementAndGet();
return -1;
} else {
return 1;
}
}
@Override
protected boolean tryReleaseShared(int arg) {
count.decrementAndGet();
return true;
}
}
private final Sync sync;
private final AtomicLong count;
private volatile long limit;
//线程调用这个方法来获得接收新连接的许可,线程可能被阻塞
public void countUpOrAwait() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//调用这个方法来释放一个连接许可,那么前面阻塞的线程可能被唤醒
public long countDown() {
sync.releaseShared(0);
long result = getCount();
return result;
}
}
```
从上面的代码我们看到LimitLatch内步定义了内部类Sync而Sync扩展了AQSAQS是Java并发包中的一个核心类它在内部维护一个状态和一个线程队列可以用来**控制线程什么时候挂起,什么时候唤醒**。我们可以扩展它来实现自己的同步器实际上Java并发包里的锁和条件变量等等都是通过AQS来实现的而这里的LimitLatch也不例外。
理解上面的代码时有两个要点:
<li>
用户线程通过调用LimitLatch的countUpOrAwait方法来拿到锁如果暂时无法获取这个线程会被阻塞到AQS的队列中。那AQS怎么知道是阻塞还是不阻塞用户线程呢其实这是由AQS的使用者来决定的也就是内部类Sync来决定的因为Sync类重写了AQS的**tryAcquireShared()方法**。它的实现逻辑是如果当前连接数count小于limit线程能获取锁返回1否则返回-1。
</li>
<li>
如何用户线程被阻塞到了AQS的队列那什么时候唤醒呢同样是由Sync内部类决定Sync重写了AQS的**tryReleaseShared()方法**,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样前面阻塞的线程将会被唤醒。
</li>
其实你会发现AQS就是一个骨架抽象类它帮我们搭了个架子用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由你来决定。我们还注意到当前线程数被定义成原子变量AtomicLong而limit变量用volatile关键字来修饰这些并发编程的实际运用。
**Acceptor**
Acceptor实现了Runnable接口因此可以跑在单独线程里。一个端口号只能对应一个ServerSocketChannel因此这个ServerSocketChannel是在多个Acceptor线程之间共享的它是Endpoint的属性由Endpoint完成初始化和端口绑定。初始化过程如下
```
serverSock = ServerSocketChannel.open();
serverSock.socket().bind(addr,getAcceptCount());
serverSock.configureBlocking(true);
```
从上面的初始化代码我们可以看到两个关键信息:
<li>
bind方法的第二个参数表示操作系统的等待队列长度我在上面提到当应用层面的连接数到达最大值时操作系统可以继续接收连接那么操作系统能继续接收的最大连接数就是这个队列长度可以通过acceptCount参数配置默认是100。
</li>
<li>
ServerSocketChannel被设置成阻塞模式也就是说它是以阻塞的方式接收连接的。
</li>
ServerSocketChannel通过accept()接受新的连接accept()方法返回获得SocketChannel对象然后将SocketChannel对象封装在一个PollerEvent对象中并将PollerEvent对象压入Poller的Queue里这是个典型的“生产者-消费者”模式Acceptor与Poller线程之间通过Queue通信。
**Poller**
Poller本质是一个Selector它内部维护一个Queue这个Queue定义如下
```
private final SynchronizedQueue&lt;PollerEvent&gt; events = new SynchronizedQueue&lt;&gt;();
```
SynchronizedQueue的方法比如offer、poll、size和clear方法都使用了synchronized关键字进行修饰用来保证同一时刻只有一个Acceptor线程对Queue进行读写。同时有多个Poller线程在运行每个Poller线程都有自己的Queue。每个Poller线程可能同时被多个Acceptor线程调用来注册PollerEvent。同样Poller的个数可以通过pollers参数配置。
Poller不断的通过内部的Selector对象向内核查询Channel的状态一旦可读就生成任务类SocketProcessor交给Executor去处理。Poller的另一个重要任务是循环遍历检查自己所管理的SocketChannel是否已经超时如果有超时就关闭这个SocketChannel。
**SocketProcessor**
我们知道Poller会创建SocketProcessor任务类交给线程池处理而SocketProcessor实现了Runnable接口用来定义Executor中线程所执行的任务主要就是调用Http11Processor组件来处理请求。Http11Processor读取Channel的数据来生成ServletRequest对象这里请你注意
Http11Processor并不是直接读取Channel的。这是因为Tomcat支持同步非阻塞I/O模型和异步I/O模型在Java API中相应的Channel类也是不一样的比如有AsynchronousSocketChannel和SocketChannel为了对Http11Processor屏蔽这些差异Tomcat设计了一个包装类叫作SocketWrapperHttp11Processor只调用SocketWrapper的方法去读写数据。
**Executor**
Executor是Tomcat定制版的线程池它负责创建真正干活的工作线程干什么活呢就是执行SocketProcessor的run方法也就是解析请求并通过容器来处理请求最终会调用到我们的Servlet。后面我会用专门的篇幅介绍Tomcat怎么扩展和使用Java原生的线程池。
## 高并发思路
在弄清楚NioEndpoint的实现原理后我们来考虑一个重要的问题怎么把这个过程做到高并发呢
高并发就是能快速地处理大量的请求需要合理设计线程模型让CPU忙起来尽量不要让线程阻塞因为一阻塞CPU就闲下来了。另外就是有多少任务就用相应规模的线程数去处理。我们注意到NioEndpoint要完成三件事情接收连接、检测I/O事件以及处理请求那么最核心的就是把这三件事情分开用不同规模的线程数去处理比如用专门的线程组去跑Acceptor并且Acceptor的个数可以配置用专门的线程组去跑PollerPoller的个数也可以配置最后具体任务的执行也由专门的线程池来处理也可以配置线程池的大小。
## 本期精华
I/O模型是为了解决内存和外部设备速度差异的问题。我们平时说的**阻塞或非阻塞**是指应用程序在**发起I/O操作时是立即返回还是等待**。而**同步和异步**,是指应用程序在与内核通信时,**数据从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发。**
在Tomcat中Endpoint组件的主要工作就是处理I/O而NioEndpoint利用Java NIO API实现了多路复用I/O模型。其中关键的一点是读写数据的线程自己不会阻塞在I/O等待上而是把这个工作交给Selector。同时Tomcat在这个过程中运用到了很多Java并发编程技术比如AQS、原子类、并发容器线程池等都值得我们去细细品味。
## 课后思考
Tomcat的NioEndpoint组件的名字中有NIONIO是非阻塞的意思似乎说的是同步非阻塞I/O模型但是NioEndpoint又是调用Java的的Selector来实现的我们知道Selector指的是I/O多路复用器也就是我们说的I/O多路复用模型这不是矛盾了吗
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="15 | Nio2Endpoint组件Tomcat如何实现异步I/O" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/5e/37b60903f90f52371892e37c6d724b5e.mp3"></audio>
我在专栏上一期里提到了5种I/O模型相应的Java提供了BIO、NIO和NIO.2这些API来实现这些I/O模型。BIO是我们最熟悉的同步阻塞NIO是同步非阻塞那NIO.2又是什么呢NIO已经足够好了为什么还要NIO.2呢?
NIO和NIO.2最大的区别是,一个是同步一个是异步。我在上期提到过,异步最大的特点是,应用程序不需要自己去**触发**数据从内核空间到用户空间的**拷贝**。
为什么是应用程序去“触发”数据的拷贝,而不是直接从内核拷贝数据呢?这是因为应用程序是不能访问内核空间的,因此数据拷贝肯定是由内核来做,关键是谁来触发这个动作。
是内核主动将数据拷贝到用户空间并通知应用程序。还是等待应用程序通过Selector来查询当数据就绪后应用程序再发起一个read调用这时内核再把数据从内核空间拷贝到用户空间。
需要注意的是,数据从内核空间拷贝到用户空间这段时间,应用程序还是阻塞的。所以你会看到异步的效率是高于同步的,因为异步模式下应用程序始终不会被阻塞。下面我以网络数据读取为例,来说明异步模式的工作过程。
首先应用程序在调用read API的同时告诉内核两件事情数据准备好了以后拷贝到哪个Buffer以及调用哪个回调函数去处理这些数据。
之后内核接到这个read指令后等待网卡数据到达数据到了后产生硬件中断内核在中断程序里把数据从网卡拷贝到内核空间接着做TCP/IP协议层面的数据解包和重组再把数据拷贝到应用程序指定的Buffer最后调用应用程序指定的回调函数。
你可能通过下面这张图来回顾一下同步与异步的区别:
<img src="https://static001.geekbang.org/resource/image/75/0d/75e622d376aaba976f87bd5a2642060d.jpg" alt="">
我们可以看到在异步模式下应用程序当了“甩手掌柜”内核则忙前忙后但最大限度提高了I/O通信的效率。Windows的IOCP和Linux内核2.6的AIO都提供了异步I/O的支持Java的NIO.2 API就是对操作系统异步I/O API的封装。
## Java NIO.2回顾
今天我们会重点关注Tomcat是如何实现异步I/O模型的但在这之前我们先来简单回顾下如何用Java的NIO.2 API来编写一个服务端程序。
```
public class Nio2Server {
void listen(){
//1.创建一个线程池
ExecutorService es = Executors.newCachedThreadPool();
//2.创建异步通道群组
AsynchronousChannelGroup tg = AsynchronousChannelGroup.withCachedThreadPool(es, 1);
//3.创建服务端异步通道
AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open(tg);
//4.绑定监听端口
assc.bind(new InetSocketAddress(8080));
//5. 监听连接,传入回调类处理连接请求
assc.accept(this, new AcceptHandler());
}
}
```
上面的代码主要做了5件事情
1. 创建一个线程池,这个线程池用来执行来自内核的回调请求。
1. 创建一个AsynchronousChannelGroup并绑定一个线程池。
1. 创建AsynchronousServerSocketChannel并绑定到AsynchronousChannelGroup。
1. 绑定一个监听端口。
1. 调用accept方法开始监听连接请求同时传入一个回调类去处理连接请求。请你注意accept方法的第一个参数是this对象就是Nio2Server对象本身我在下文还会讲为什么要传入这个参数。
你可能会问为什么需要创建一个线程池呢其实在异步I/O模型里应用程序不知道数据在什么时候到达因此向内核注册回调函数当数据到达时内核就会调用这个回调函数。同时为了提高处理速度会提供一个线程池给内核使用这样不会耽误内核线程的工作内核只需要把工作交给线程池就立即返回了。
我们再来看看处理连接的回调类AcceptHandler是什么样的。
```
//AcceptHandler类实现了CompletionHandler接口的completed方法。它还有两个模板参数第一个是异步通道第二个就是Nio2Server本身
public class AcceptHandler implements CompletionHandler&lt;AsynchronousSocketChannel, Nio2Server&gt; {
//具体处理连接请求的就是completed方法它有两个参数第一个是异步通道第二个就是上面传入的NioServer对象
@Override
public void completed(AsynchronousSocketChannel asc, Nio2Server attachment) {
//调用accept方法继续接收其他客户端的请求
attachment.assc.accept(attachment, this);
//1. 先分配好Buffer告诉内核数据拷贝到哪里去
ByteBuffer buf = ByteBuffer.allocate(1024);
//2. 调用read函数读取数据除了把buf作为参数传入还传入读回调类
channel.read(buf, buf, new ReadHandler(asc));
}
```
我们看到它实现了CompletionHandler接口下面我们先来看看CompletionHandler接口的定义。
```
public interface CompletionHandler&lt;V,A&gt; {
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
}
```
**CompletionHandler接口有两个模板参数V和A分别表示I/O调用的返回值和附件类**。比如accept的返回值就是AsynchronousSocketChannel而附件类由用户自己决定在accept的调用中我们传入了一个Nio2Server。因此AcceptHandler带有了两个模板参数AsynchronousSocketChannel和Nio2Server。
CompletionHandler有两个方法completed和failed分别在I/O操作成功和失败时调用。completed方法有两个参数其实就是前面说的两个模板参数。也就是说Java的NIO.2在调用回调方法时会把返回值和附件类当作参数传给NIO.2的使用者。
下面我们再来看看处理读的回调类ReadHandler长什么样子。
```
public class ReadHandler implements CompletionHandler&lt;Integer, ByteBuffer&gt; {
//读取到消息后的处理
@Override
public void completed(Integer result, ByteBuffer attachment) {
//attachment就是数据调用flip操作其实就是把读的位置移动最前面
attachment.flip();
//读取数据
...
}
void failed(Throwable exc, A attachment){
...
}
}
```
read调用的返回值是一个整型数所以我们回调方法里的第一个参数就是一个整型表示有多少数据被读取到了Buffer中。第二个参数是一个ByteBuffer这是因为我们在调用read方法时把用来存放数据的ByteBuffer当作附件类传进去了所以在回调方法里有ByteBuffer类型的参数我们直接从这个ByteBuffer里获取数据。
## Nio2Endpoint
掌握了Java NIO.2 API的使用以及服务端程序的工作原理之后再来理解Tomcat的异步I/O实现就不难了。我们先通过一张图来看看Nio2Endpoint有哪些组件。
<img src="https://static001.geekbang.org/resource/image/be/e0/be7da29404bda751fc3aa263fb909de0.jpg" alt="">
从图上看总体工作流程跟NioEndpoint是相似的。
LimitLatch是连接控制器它负责控制最大连接数。
Nio2Acceptor扩展了Acceptor用异步I/O的方式来接收连接跑在一个单独的线程里也是一个线程组。Nio2Acceptor接收新的连接后得到一个AsynchronousSocketChannelNio2Acceptor把AsynchronousSocketChannel封装成一个Nio2SocketWrapper并创建一个SocketProcessor任务类交给线程池处理并且SocketProcessor持有Nio2SocketWrapper对象。
Executor在执行SocketProcessor时SocketProcessor的run方法会调用Http11Processor来处理请求Http11Processor会通过Nio2SocketWrapper读取和解析请求数据请求经过容器处理后再把响应通过Nio2SocketWrapper写出。
需要你注意Nio2Endpoint跟NioEndpoint的一个明显不同点是**Nio2Endpoint中没有Poller组件也就是没有Selector。这是为什么呢因为在异步I/O模式下Selector的工作交给内核来做了。**
接下来我详细介绍一下Nio2Endpoint各组件的设计。
**Nio2Acceptor**
和NioEndpint一样Nio2Endpoint的基本思路是用LimitLatch组件来控制连接数但是Nio2Acceptor的监听连接的过程不是在一个死循环里不断地调accept方法而是通过回调函数来完成的。我们来看看它的连接监听方法
```
serverSock.accept(null, this);
```
其实就是调用了accept方法注意它的第二个参数是this表明Nio2Acceptor自己就是处理连接的回调类因此Nio2Acceptor实现了CompletionHandler接口。那么它是如何实现CompletionHandler接口的呢
```
protected class Nio2Acceptor extends Acceptor&lt;AsynchronousSocketChannel&gt;
implements CompletionHandler&lt;AsynchronousSocketChannel, Void&gt; {
@Override
public void completed(AsynchronousSocketChannel socket,
Void attachment) {
if (isRunning() &amp;&amp; !isPaused()) {
if (getMaxConnections() == -1) {
//如果没有连接限制,继续接收新的连接
serverSock.accept(null, this);
} else {
//如果有连接限制就在线程池里跑run方法run方法会检查连接数
getExecutor().execute(this);
}
//处理请求
if (!setSocketOptions(socket)) {
closeSocket(socket);
}
}
}
```
可以看到CompletionHandler的两个模板参数分别是AsynchronousServerSocketChannel和Void我在前面说过第一个参数就是accept方法的返回值第二个参数是附件类由用户自己决定这里为Void。completed方法的处理逻辑比较简单
- 如果没有连接限制继续在本线程中调用accept方法接收新的连接。
- 如果有连接限制就在线程池里跑run方法去接收新的连接。那为什么要跑run方法呢因为在run方法里会检查连接数当连接达到最大数时线程可能会被LimitLatch阻塞。为什么要放在线程池里跑呢这是因为如果放在当前线程里执行completed方法可能被阻塞会导致这个回调方法一直不返回。
接着completed方法会调用setSocketOptions方法在这个方法里会创建Nio2SocketWrapper和SocketProcessor并交给线程池处理。
**Nio2SocketWrapper**
Nio2SocketWrapper的主要作用是封装Channel并提供接口给Http11Processor读写数据。讲到这里你是不是有个疑问Http11Processor是不能阻塞等待数据的按照异步I/O的套路Http11Processor在调用Nio2SocketWrapper的read方法时需要注册回调类read调用会立即返回问题是立即返回后Http11Processor还没有读到数据怎么办呢这个请求的处理不就失败了吗
为了解决这个问题Http11Processor是通过2次read调用来完成数据读取操作的。
- 第一次read调用连接刚刚建立好后Acceptor创建SocketProcessor任务类交给线程池去处理Http11Processor在处理请求的过程中会调用Nio2SocketWrapper的read方法发出第一次读请求同时注册了回调类readCompletionHandler因为数据没读到Http11Processor把当前的Nio2SocketWrapper标记为数据不完整。**接着SocketProcessor线程被回收Http11Processor并没有阻塞等待数据**。这里请注意Http11Processor维护了一个Nio2SocketWrapper列表也就是维护了连接的状态。
- 第二次read调用当数据到达后内核已经把数据拷贝到Http11Processor指定的Buffer里同时回调类readCompletionHandler被调用在这个回调处理方法里会**重新创建一个新的SocketProcessor任务来继续处理这个连接**而这个新的SocketProcessor任务类持有原来那个Nio2SocketWrapper这一次Http11Processor可以通过Nio2SocketWrapper读取数据了因为数据已经到了应用层的Buffer。
这个回调类readCompletionHandler的源码如下最关键的一点是**Nio2SocketWrapper是作为附件类来传递的**,这样在回调函数里能拿到所有的上下文。
```
this.readCompletionHandler = new CompletionHandler&lt;Integer, SocketWrapperBase&lt;Nio2Channel&gt;&gt;() {
public void completed(Integer nBytes, SocketWrapperBase&lt;Nio2Channel&gt; attachment) {
...
//通过附件类SocketWrapper拿到所有的上下文
Nio2SocketWrapper.this.getEndpoint().processSocket(attachment, SocketEvent.OPEN_READ, false);
}
public void failed(Throwable exc, SocketWrapperBase&lt;Nio2Channel&gt; attachment) {
...
}
}
```
## 本期精华
在异步I/O模型里内核做了很多事情它把数据准备好并拷贝到用户空间再通知应用程序去处理也就是调用应用程序注册的回调函数。Java在操作系统 异步IO API的基础上进行了封装提供了Java NIO.2 API而Tomcat的异步I/O模型就是基于Java NIO.2 实现的。
由于NIO和NIO.2的API接口和使用方法完全不同可以想象一个系统中如果已经支持同步I/O要再支持异步I/O改动是比较大的很有可能不得不重新设计组件之间的接口。但是Tomcat通过充分的抽象比如SocketWrapper对Channel的封装再加上Http11Processor的两次read调用巧妙地解决了这个问题使得协议处理器Http11Processor和I/O通信处理器Endpoint之间的接口保持不变。
## 课后思考
我在文章开头介绍Java NIO.2的使用时提到过要创建一个线程池来处理异步I/O的回调那么这个线程池跟Tomcat的工作线程池Executor是同一个吗如果不是它们有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="16 | AprEndpoint组件Tomcat APR提高I/O性能的秘密" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/18/e4df61f44281499c5da7938e1a470e18.mp3"></audio>
我们在使用Tomcat时会在启动日志里看到这样的提示信息
>
The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: ***
这句话的意思就是推荐你去安装APR库可以提高系统性能。那什么是APR呢
APRApache Portable Runtime Libraries是Apache可移植运行时库它是用C语言实现的其目的是向上层应用程序提供一个跨平台的操作系统接口库。Tomcat可以用它来处理包括文件和网络I/O从而提升性能。我在专栏前面提到过Tomcat支持的连接器有NIO、NIO.2和APR。跟NioEndpoint一样AprEndpoint也实现了非阻塞I/O它们的区别是NioEndpoint通过调用Java的NIO API来实现非阻塞I/O而AprEndpoint是通过JNI调用APR本地库而实现非阻塞I/O的。
那同样是非阻塞I/O为什么Tomcat会提示使用APR本地库的性能会更好呢这是因为在某些场景下比如需要频繁与操作系统进行交互Socket网络通信就是这样一个场景特别是如果你的Web应用使用了TLS来加密传输我们知道TLS协议在握手过程中有多次网络交互在这种情况下Java跟C语言程序相比还是有一定的差距而这正是APR的强项。
Tomcat本身是Java编写的为了调用C语言编写的APR需要通过JNI方式来调用。JNIJava Native Interface 是JDK提供的一个编程接口它允许Java程序调用其他语言编写的程序或者代码库其实JDK本身的实现也大量用到JNI技术来调用本地C程序库。
在今天这一期文章首先我会讲AprEndpoint组件的工作过程接着我会在原理的基础上分析APR提升性能的一些秘密。在今天的学习过程中会涉及到一些操作系统的底层原理毫无疑问掌握这些底层知识对于提高你的内功非常有帮助。
## AprEndpoint工作过程
下面我还是通过一张图来帮你理解AprEndpoint的工作过程。
<img src="https://static001.geekbang.org/resource/image/37/93/37117f9fd6ed5523a331ac566a906893.jpg" alt="">
你会发现它跟NioEndpoint的图很像从左到右有LimitLatch、Acceptor、Poller、SocketProcessor和Http11Processor只是Acceptor和Poller的实现和NioEndpoint不同。接下来我分别来讲讲这两个组件。
**Acceptor**
Accpetor的功能就是监听连接接收并建立连接。它的本质就是调用了四个操作系统APISocket、Bind、Listen和Accept。那Java语言如何直接调用C语言API呢答案就是通过JNI。具体来说就是两步先封装一个Java类在里面定义一堆用**native关键字**修饰的方法,像下面这样。
```
public class Socket {
...
//用native修饰这个方法表明这个函数是C语言实现
public static native long create(int family, int type,
int protocol, long cont)
public static native int bind(long sock, long sa);
public static native int listen(long sock, int backlog);
public static native long accept(long sock)
}
```
接着用C代码实现这些方法比如Bind函数就是这样实现的
```
//注意函数的名字要符合JNI规范的要求
JNIEXPORT jint JNICALL
Java_org_apache_tomcat_jni_Socket_bind(JNIEnv *e, jlong sock,jlong sa)
{
jint rv = APR_SUCCESS;
tcn_socket_t *s = (tcn_socket_t *sock;
apr_sockaddr_t *a = (apr_sockaddr_t *) sa;
//调用APR库自己实现的bind函数
rv = (jint)apr_socket_bind(s-&gt;sock, a);
return rv;
}
```
专栏里我就不展开JNI的细节了你可以[扩展阅读](http://jnicookbook.owsiak.org/contents/)获得更多信息和例子。我们要注意的是函数名字要符合JNI的规范以及Java和C语言如何互相传递参数比如在C语言有指针Java没有指针的概念所以在Java中用long类型来表示指针。AprEndpoint的Acceptor组件就是调用了APR实现的四个API。
**Poller**
Acceptor接收到一个新的Socket连接后按照NioEndpoint的实现它会把这个Socket交给Poller去查询I/O事件。AprEndpoint也是这样做的不过AprEndpoint的Poller并不是调用Java NIO里的Selector来查询Socket的状态而是通过JNI调用APR中的poll方法而APR又是调用了操作系统的epoll API来实现的。
这里有个特别的地方是在AprEndpoint中我们可以配置一个叫`deferAccept`的参数它对应的是TCP协议中的`TCP_DEFER_ACCEPT`设置这个参数后当TCP客户端有新的连接请求到达时TCP服务端先不建立连接而是再等等直到客户端有请求数据发过来时再建立连接。这样的好处是服务端不需要用Selector去反复查询请求数据是否就绪。
这是一种TCP协议层的优化不是每个操作系统内核都支持因为Java作为一种跨平台语言需要屏蔽各种操作系统的差异因此并没有把这个参数提供给用户但是对于APR来说它的目的就是尽可能提升性能因此它向用户暴露了这个参数。
## APR提升性能的秘密
APR连接器之所以能提高Tomcat的性能除了APR本身是C程序库之外还有哪些提速的秘密呢
**JVM堆 VS 本地内存**
我们知道Java的类实例一般在JVM堆上分配而Java是通过JNI调用C代码来实现Socket通信的那么C代码在运行过程中需要的内存又是从哪里分配的呢C代码能否直接操作Java堆
为了回答这些问题我先来说说JVM和用户进程的关系。如果你想运行一个Java类文件可以用下面的Java命令来执行。
```
java my.class
```
这个命令行中的`java`其实是**一个可执行程序这个程序会创建JVM来加载和运行你的Java类**。操作系统会创建一个进程来执行这个`java`可执行程序而每个进程都有自己的虚拟地址空间JVM用到的内存包括堆、栈和方法区就是从进程的虚拟地址空间上分配的。请你注意的是JVM内存只是进程空间的一部分除此之外进程空间内还有代码段、数据段、内存映射区、内核空间等。从JVM的角度看JVM内存之外的部分叫作本地内存C程序代码在运行过程中用到的内存就是本地内存中分配的。下面我们通过一张图来理解一下。
<img src="https://static001.geekbang.org/resource/image/83/80/839bfab2636634d47477cbd0920b5980.jpg" alt="">
Tomcat的Endpoint组件在接收网络数据时需要预先分配好一块Buffer所谓的Buffer就是字节数组`byte[]`Java通过JNI调用把这块Buffer的地址传给C代码C代码通过操作系统API读取Socket并把数据填充到这块Buffer。Java NIO API提供了两种Buffer来接收数据HeapByteBuffer和DirectByteBuffer下面的代码演示了如何创建两种Buffer。
```
//分配HeapByteBuffer
ByteBuffer buf = ByteBuffer.allocate(1024);
//分配DirectByteBuffer
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
```
创建好Buffer后直接传给Channel的read或者write函数最终这块Buffer会通过JNI调用传递给C程序。
```
//将buf作为read函数的参数
int bytesRead = socketChannel.read(buf);
```
那HeapByteBuffer和DirectByteBuffer有什么区别呢HeapByteBuffer对象本身在JVM堆上分配并且它持有的字节数组`byte[]`也是在JVM堆上分配。但是如果用**HeapByteBuffer**来接收网络数据,**需要把数据从内核先拷贝到一个临时的本地内存再从临时本地内存拷贝到JVM堆**而不是直接从内核拷贝到JVM堆上。这是为什么呢这是因为数据从内核拷贝到JVM堆的过程中JVM可能会发生GCGC过程中对象可能会被移动也就是说JVM堆上的字节数组可能会被移动这样的话Buffer地址就失效了。如果这中间经过本地内存中转从本地内存到JVM堆的拷贝过程中JVM可以保证不做GC。
如果使用HeapByteBuffer你会发现JVM堆和内核之间多了一层中转而DirectByteBuffer用来解决这个问题DirectByteBuffer对象本身在JVM堆上但是它持有的字节数组不是从JVM堆上分配的而是从本地内存分配的。DirectByteBuffer对象中有个long类型字段address记录着本地内存的地址这样在接收数据的时候直接把这个本地内存地址传递给C程序C程序会将网络数据从内核拷贝到这个本地内存JVM可以直接读取这个本地内存这种方式比HeapByteBuffer少了一次拷贝因此一般来说它的速度会比HeapByteBuffer快好几倍。你可以通过上面的图加深理解。
Tomcat中的AprEndpoint就是通过DirectByteBuffer来接收数据的而NioEndpoint和Nio2Endpoint是通过HeapByteBuffer来接收数据的。你可能会问NioEndpoint和Nio2Endpoint为什么不用DirectByteBuffer呢这是因为本地内存不好管理发生内存泄漏难以定位从稳定性考虑NioEndpoint和Nio2Endpoint没有去冒这个险。
**sendfile**
我们再来考虑另一个网络通信的场景也就是静态文件的处理。浏览器通过Tomcat来获取一个HTML文件而Tomcat的处理逻辑无非是两步
1. 从磁盘读取HTML到内存。
1. 将这段内存的内容通过Socket发送出去。
但是在传统方式下,有很多次的内存拷贝:
- 读取文件时,首先是内核把文件内容读取到内核缓冲区。
- 如果使用HeapByteBuffer文件数据从内核到JVM堆内存需要经过本地内存中转。
- 同样在将文件内容推入网络时从JVM堆到内核缓冲区需要经过本地内存中转。
- 最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。
从下面的图你会发现这个过程有6次内存拷贝并且read和write等系统调用将导致进程从用户态到内核态的切换会耗费大量的CPU和内存资源。
<img src="https://static001.geekbang.org/resource/image/2b/0e/2b902479c36647142ccd413320b3900e.jpg" alt="">
而Tomcat的AprEndpoint通过操作系统层面的sendfile特性解决了这个问题sendfile系统调用方式非常简洁。
```
sendfile(socket, file, len);
```
它带有两个关键参数Socket和文件句柄。将文件从磁盘写入Socket的过程只有两步
第一步:将文件内容读取到内核缓冲区。
第二步数据并没有从内核缓冲区复制到Socket关联的缓冲区只有记录数据位置和长度的描述符被添加到Socket缓冲区中接着把数据直接从内核缓冲区传递给网卡。这个过程你可以看下面的图。
<img src="https://static001.geekbang.org/resource/image/19/00/193df268fccb59a09195810e34080a00.jpg" alt="">
## 本期精华
对于一些需要频繁与操作系统进行交互的场景比如网络通信Java的效率没有C语言高特别是TLS协议握手过程中需要多次网络交互这种情况下使用APR本地库能够显著提升性能。
除此之外APR提升性能的秘密还有通过DirectByteBuffer避免了JVM堆与本地内存之间的内存拷贝通过sendfile特性避免了内核与应用之间的内存拷贝以及用户态和内核态的切换。其实很多高性能网络通信组件比如Netty都是通过DirectByteBuffer来收发网络数据的。由于本地内存难于管理Netty采用了本地内存池技术感兴趣的同学可以深入了解一下。
## 课后思考
为什么不同的操作系统比如Linux和Windows都有自己的Java虚拟机
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="17 | Executor组件Tomcat如何扩展Java线程池" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/90/c82a203e38173d87153b211bfe1d9990.mp3"></audio>
在开发中我们经常会碰到“池”的概念比如数据库连接池、内存池、线程池、常量池等。为什么需要“池”呢程序运行的本质就是通过使用系统资源CPU、内存、网络、磁盘等来完成信息的处理比如在JVM中创建一个对象实例需要消耗CPU和内存资源如果你的程序需要频繁创建大量的对象并且这些对象的存活时间短就意味着需要进行频繁销毁那么很有可能这部分代码会成为性能的瓶颈。
而“池”就是用来解决这个问题的简单来说对象池就是把用过的对象保存起来等下一次需要这种对象的时候直接从对象池中拿出来重复使用避免频繁地创建和销毁。在Java中万物皆对象线程也是一个对象Java线程是对操作系统线程的封装创建Java线程也需要消耗系统资源因此就有了线程池。JDK中提供了线程池的默认实现我们也可以通过扩展Java原生线程池来实现自己的线程池。
同样为了提高处理能力和并发度Web容器一般会把处理请求的工作放到线程池里来执行Tomcat扩展了原生的Java线程池来满足Web容器高并发的需求下面我们就来学习一下Java线程池的原理以及Tomcat是如何扩展Java线程池的。
## Java线程池
简单的说Java线程池里内部维护一个线程数组和一个任务队列当任务处理不过来的时就把任务放到队列里慢慢处理。
**ThreadPoolExecutor**
我们先来看看Java线程池核心类ThreadPoolExecutor的构造函数你需要知道ThreadPoolExecutor是如何使用这些参数的这是理解Java线程工作原理的关键。
```
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue&lt;Runnable&gt; workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
```
每次提交任务时,如果线程数还没达到核心线程数**corePoolSize**,线程池就创建新线程来执行。当线程数达到**corePoolSize**后,新增的任务就放到工作队列**workQueue**里,而线程池中的线程则努力地从**workQueue**里拉活来干也就是调用poll方法来获取任务。
如果任务很多,并且**workQueue**是个有界队列,队列可能会满,此时线程池就会紧急创建新的临时线程来救场,如果总的线程数达到了最大线程数**maximumPoolSize**,则不能再创建新的临时线程了,转而执行拒绝策略**handler**,比如抛出异常或者由调用者线程来执行任务等。
如果高峰过去了线程池比较闲了怎么办临时线程使用poll**keepAliveTime, unit**方法从工作队列中拉活干请注意poll方法设置了超时时间如果超时了仍然两手空空没拉到活表明它太闲了这个线程会被销毁回收。
那还有一个参数**threadFactory**是用来做什么的呢?通过它你可以扩展原生的线程工厂,比如给创建出来的线程取个有意义的名字。
**FixedThreadPool/CachedThreadPool**
Java提供了一些默认的线程池实现比如FixedThreadPool和CachedThreadPool它们的本质就是给ThreadPoolExecutor设置了不同的参数是定制版的ThreadPoolExecutor。
```
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue&lt;Runnable&gt;());
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue&lt;Runnable&gt;());
}
```
从上面的代码你可以看到:
- **FixedThreadPool有固定长度nThreads的线程数组**,忙不过来时会把任务放到无限长的队列里,这是因为**LinkedBlockingQueue默认是一个无界队列**。
- **CachedThreadPool的maximumPoolSize参数值是`Integer.MAX_VALUE`**,因此它对线程个数不做限制,忙不过来时无限创建临时线程,闲下来时再回收。它的任务队列是**SynchronousQueue**表明队列长度为0。
## Tomcat线程池
跟FixedThreadPool/CachedThreadPool一样Tomcat的线程池也是一个定制版的ThreadPoolExecutor。
**定制版的ThreadPoolExecutor**
通过比较FixedThreadPool和CachedThreadPool我们发现它们传给ThreadPoolExecutor的参数有两个关键点
- 是否限制线程个数。
- 是否限制队列长度。
对于Tomcat来说这两个资源都需要限制也就是说要对高并发进行控制否则CPU和内存有资源耗尽的风险。因此Tomcat传入的参数是这样的
```
//定制版的任务队列
taskqueue = new TaskQueue(maxQueueSize);
//定制版的线程工厂
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
//定制版的线程池
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
```
你可以看到其中的两个关键点:
- Tomcat有自己的定制版任务队列和线程工厂并且可以限制任务队列的长度它的最大长度是maxQueueSize。
- Tomcat对线程数也有限制设置了核心线程数minSpareThreads和最大线程池数maxThreads
除了资源限制以外Tomcat线程池还定制自己的任务处理流程。我们知道Java原生线程池的任务处理逻辑比较简单
1. 前corePoolSize个任务时来一个任务就创建一个新线程。
1. 后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
1. 如果总线程数达到maximumPoolSize**执行拒绝策略。**
Tomcat线程池扩展了原生的ThreadPoolExecutor通过重写execute方法实现了自己的任务处理逻辑
1. 前corePoolSize个任务时来一个任务就创建一个新线程。
1. 再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
1. 如果总线程数达到maximumPoolSize**则继续尝试把任务添加到任务队列中去。**
1. **如果缓冲队列也满了,插入失败,执行拒绝策略。**
观察Tomcat线程池和Java原生线程池的区别其实就是在第3步Tomcat在线程总数达到最大数时不是立即执行拒绝策略而是再尝试向任务队列添加任务添加失败后再执行拒绝策略。那具体如何实现呢其实很简单我们来看一下Tomcat线程池的execute方法的核心代码。
```
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
...
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
//调用Java原生线程池的execute去执行任务
super.execute(command);
} catch (RejectedExecutionException rx) {
//如果总线程数达到maximumPoolSizeJava原生线程池执行拒绝策略
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
//继续尝试把任务放到任务队列中去
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
//如果缓冲队列也满了,插入失败,执行拒绝策略。
throw new RejectedExecutionException(&quot;...&quot;);
}
}
}
}
}
```
从这个方法你可以看到Tomcat线程池的execute方法会调用Java原生线程池的execute去执行任务如果总线程数达到maximumPoolSizeJava原生线程池的execute方法会抛出RejectedExecutionException异常但是这个异常会被Tomcat线程池的execute方法捕获到并继续尝试把这个任务放到任务队列中去如果任务队列也满了再执行拒绝策略。
**定制版的任务队列**
细心的你有没有发现在Tomcat线程池的execute方法最开始有这么一行
```
submittedCount.incrementAndGet();
```
这行代码的意思把submittedCount这个原子变量加一并且在任务执行失败抛出拒绝异常时将这个原子变量减一
```
submittedCount.decrementAndGet();
```
其实Tomcat线程池是用这个变量submittedCount来维护已经提交到了线程池但是还没有执行完的任务个数。Tomcat为什么要维护这个变量呢这跟Tomcat的定制版的任务队列有关。Tomcat的任务队列TaskQueue扩展了Java中的LinkedBlockingQueue我们知道LinkedBlockingQueue默认情况下长度是没有限制的除非给它一个capacity。因此Tomcat给了它一个capacityTaskQueue的构造函数中有个整型的参数capacityTaskQueue将capacity传给父类LinkedBlockingQueue的构造函数。
```
public class TaskQueue extends LinkedBlockingQueue&lt;Runnable&gt; {
public TaskQueue(int capacity) {
super(capacity);
}
...
}
```
这个capacity参数是通过Tomcat的maxQueueSize参数来设置的但问题是默认情况下maxQueueSize的值是`Integer.MAX_VALUE`,等于没有限制,这样就带来一个问题:当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。
为了解决这个问题TaskQueue重写了LinkedBlockingQueue的offer方法在合适的时机返回false返回false表示任务添加失败这时线程池会创建新的线程。那什么是合适的时机呢请看下面offer方法的核心源码
```
public class TaskQueue extends LinkedBlockingQueue&lt;Runnable&gt; {
...
@Override
//线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
public boolean offer(Runnable o) {
//如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
if (parent.getPoolSize() == parent.getMaximumPoolSize())
return super.offer(o);
//执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
//表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
if (parent.getSubmittedCount()&lt;=(parent.getPoolSize()))
return super.offer(o);
//2. 如果已提交的任务数大于当前线程数线程不够用了返回false去创建新线程
if (parent.getPoolSize()&lt;parent.getMaximumPoolSize())
return false;
//默认情况下总是把任务添加到任务队列
return super.offer(o);
}
}
```
从上面的代码我们看到只有当前线程数大于核心线程数、小于最大线程数并且已提交的任务个数大于当前线程数时也就是说线程不够用了但是线程数又没达到极限才会去创建新的线程。这就是为什么Tomcat需要维护已提交任务数这个变量它的目的就是**在任务队列的长度无限制的情况下,让线程池有机会创建新的线程**。
当然默认情况下Tomcat的任务队列是没有限制的你可以通过设置maxQueueSize参数来限制任务队列的长度。
## 本期精华
池化的目的是为了避免频繁地创建和销毁对象减少对系统资源的消耗。Java提供了默认的线程池实现我们也可以扩展Java原生的线程池来实现定制自己的线程池Tomcat就是这么做的。Tomcat扩展了Java线程池的核心类ThreadPoolExecutor并重写了它的execute方法定制了自己的任务处理流程。同时Tomcat还实现了定制版的任务队列重写了offer方法使得在任务队列长度无限制的情况下线程池仍然有机会创建新的线程。
## 课后思考
请你再仔细看看Tomcat的定制版任务队列TaskQueue的offer方法它多次调用了getPoolSize方法但是这个方法是有锁的锁会引起线程上下文切换而损耗性能请问这段代码可以如何优化呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="18 | 新特性Tomcat如何支持WebSocket" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/80/3f4d60e4fe811d966731ce2d0783c580.mp3"></audio>
我们知道HTTP协议是“请求-响应”模式,浏览器必须先发请求给服务器,服务器才会响应这个请求。也就是说,服务器不会主动发送数据给浏览器。
对于实时性要求比较的高的应用比如在线游戏、股票基金实时报价和在线协同编辑等浏览器需要实时显示服务器上最新的数据因此出现了Ajax和Comet技术。Ajax本质上还是轮询而Comet是在HTTP长连接的基础上做了一些hack但是它们的实时性不高另外频繁的请求会给服务器带来压力也会浪费网络流量和带宽。于是HTML5推出了WebSocket标准使得浏览器和服务器之间任何一方都可以主动发消息给对方这样服务器有新数据时可以主动推送给浏览器。
今天我会介绍WebSocket的工作原理以及作为服务器端的Tomcat是如何支持WebSocket的。更重要的是希望你在学完之后可以灵活地选用WebSocket技术来解决实际工作中的问题。
## WebSocket工作原理
WebSocket的名字里带有Socket那Socket是什么呢网络上的两个程序通过一个双向链路进行通信这个双向链路的一端称为一个Socket。一个Socket对应一个IP地址和端口号应用程序通常通过Socket向网络发出请求或者应答网络请求。Socket不是协议它其实是对TCP/IP协议层抽象出来的API。
但WebSocket不是一套API跟HTTP协议一样WebSocket也是一个应用层协议。为了跟现有的HTTP协议保持兼容它通过HTTP协议进行一次握手握手之后数据就直接从TCP层的Socket传输就与HTTP协议无关了。浏览器发给服务端的请求会带上跟WebSocket有关的请求头比如`Connection: Upgrade``Upgrade: websocket`
<img src="https://static001.geekbang.org/resource/image/ee/20/eebd74d6f1cbdf6d765adac12ebaed20.jpg" alt="">
如果服务器支持WebSocket同样会在HTTP响应里加上WebSocket相关的HTTP头部。
<img src="https://static001.geekbang.org/resource/image/14/2e/14776cea5251c30c73df754dfbd45a2e.jpg" alt="">
这样WebSocket连接就建立好了接下来WebSocket的数据传输会以frame形式传输会将一条消息分为几个frame按照先后顺序传输出去。这样做的好处有
- 大数据的传输可以分片传输,不用考虑数据大小的问题。
- 和HTTP的chunk一样可以边生成数据边传输提高传输效率。
## Tomcat如何支持WebSocket
在讲Tomcat如何支持WebSocket之前我们先来开发一个简单的聊天室程序需求是用户可以通过浏览器加入聊天室、发送消息聊天室的其他人都可以收到消息。
**WebSocket聊天室程序**
浏览器端JavaScript核心代码如下
```
var Chat = {};
Chat.socket = null;
Chat.connect = (function(host) {
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
//如果支持则创建WebSocket JS类
Chat.socket = new WebSocket(host);
} else if ('MozWebSocket' in window) {
Chat.socket = new MozWebSocket(host);
} else {
Console.log('WebSocket is not supported by this browser.');
return;
}
//回调函数当和服务器的WebSocket连接建立起来后浏览器会回调这个方法
Chat.socket.onopen = function () {
Console.log('Info: WebSocket connection opened.');
document.getElementById('chat').onkeydown = function(event) {
if (event.keyCode == 13) {
Chat.sendMessage();
}
};
};
//回调函数当和服务器的WebSocket连接关闭后浏览器会回调这个方法
Chat.socket.onclose = function () {
document.getElementById('chat').onkeydown = null;
Console.log('Info: WebSocket closed.');
};
//回调函数,当服务器有新消息发送到浏览器,浏览器会回调这个方法
Chat.socket.onmessage = function (message) {
Console.log(message.data);
};
});
```
上面的代码实现逻辑比较清晰就是创建一个WebSocket JavaScript对象然后实现了几个回调方法onopen、onclose和onmessage。当连接建立、关闭和有新消息时浏览器会负责调用这些回调方法。我们再来看服务器端Tomcat的实现代码
```
//Tomcat端的实现类加上@ServerEndpoint注解里面的value是URL路径
@ServerEndpoint(value = &quot;/websocket/chat&quot;)
public class ChatEndpoint {
private static final String GUEST_PREFIX = &quot;Guest&quot;;
//记录当前有多少个用户加入到了聊天室它是static全局变量。为了多线程安全使用原子变量AtomicInteger
private static final AtomicInteger connectionIds = new AtomicInteger(0);
//每个用户用一个CharAnnotation实例来维护请你注意它是一个全局的static变量所以用到了线程安全的CopyOnWriteArraySet
private static final Set&lt;ChatEndpoint&gt; connections =
new CopyOnWriteArraySet&lt;&gt;();
private final String nickname;
private Session session;
public ChatEndpoint() {
nickname = GUEST_PREFIX + connectionIds.getAndIncrement();
}
//新连接到达时Tomcat会创建一个Session并回调这个函数
@OnOpen
public void start(Session session) {
this.session = session;
connections.add(this);
String message = String.format(&quot;* %s %s&quot;, nickname, &quot;has joined.&quot;);
broadcast(message);
}
//浏览器关闭连接时Tomcat会回调这个函数
@OnClose
public void end() {
connections.remove(this);
String message = String.format(&quot;* %s %s&quot;,
nickname, &quot;has disconnected.&quot;);
broadcast(message);
}
//浏览器发送消息到服务器时Tomcat会回调这个函数
@OnMessage
public void incoming(String message) {
// Never trust the client
String filteredMessage = String.format(&quot;%s: %s&quot;,
nickname, HTMLFilter.filter(message.toString()));
broadcast(filteredMessage);
}
//WebSocket连接出错时Tomcat会回调这个函数
@OnError
public void onError(Throwable t) throws Throwable {
log.error(&quot;Chat Error: &quot; + t.toString(), t);
}
//向聊天室中的每个用户广播消息
private static void broadcast(String msg) {
for (ChatAnnotation client : connections) {
try {
synchronized (client) {
client.session.getBasicRemote().sendText(msg);
}
} catch (IOException e) {
...
}
}
}
}
```
根据Java WebSocket规范的规定Java WebSocket应用程序由一系列的WebSocket Endpoint组成。**Endpoint是一个Java对象代表WebSocket连接的一端就好像处理HTTP请求的Servlet一样你可以把它看作是处理WebSocket消息的接口**。跟Servlet不同的地方在于Tomcat会给每一个WebSocket连接创建一个Endpoint实例。你可以通过两种方式定义和实现Endpoint。
第一种方法是编程式的就是编写一个Java类继承`javax.websocket.Endpoint`并实现它的onOpen、onClose和onError方法。这些方法跟Endpoint的生命周期有关Tomcat负责管理Endpoint的生命周期并调用这些方法。并且当浏览器连接到一个Endpoint时Tomcat会给这个连接创建一个唯一的Session`javax.websocket.Session`。Session在WebSocket连接握手成功之后创建并在连接关闭时销毁。当触发Endpoint各个生命周期事件时Tomcat会将当前Session作为参数传给Endpoint的回调方法因此一个Endpoint实例对应一个Session我们通过在Session中添加MessageHandler消息处理器来接收消息MessageHandler中定义了onMessage方法。**在这里Session的本质是对Socket的封装Endpoint通过它与浏览器通信。**
第二种定义Endpoint的方法是注解式的也就是上面的聊天室程序例子中用到的方式即实现一个业务类并给它添加WebSocket相关的注解。首先我们注意到`@ServerEndpoint(value = "/websocket/chat")`注解它表明当前业务类ChatEndpoint是一个实现了WebSocket规范的Endpoint并且注解的value值表明ChatEndpoint映射的URL是`/websocket/chat`。我们还看到ChatEndpoint类中有`@OnOpen``@OnClose``@OnError`和在`@OnMessage`注解的方法,从名字你就知道它们的功能是什么。
对于程序员来说其实我们只需要专注具体的Endpoint的实现比如在上面聊天室的例子中为了方便向所有人群发消息ChatEndpoint在内部使用了一个全局静态的集合CopyOnWriteArraySet来维护所有的ChatEndpoint实例因为每一个ChatEndpoint实例对应一个WebSocket连接也就是代表了一个加入聊天室的用户。**当某个ChatEndpoint实例收到来自浏览器的消息时这个ChatEndpoint会向集合中其他ChatEndpoint实例背后的WebSocket连接推送消息。**
那么这个过程中Tomcat主要做了哪些事情呢简单来说就是两件事情**Endpoint加载和WebSocket请求处理**。下面我分别来详细说说Tomcat是如何做这两件事情的。
**WebSocket加载**
Tomcat的WebSocket加载是通过SCI机制完成的。SCI全称ServletContainerInitializer是Servlet 3.0规范中定义的用来**接收Web应用启动事件的接口**。那为什么要监听Servlet容器的启动事件呢因为这样我们有机会在Web应用启动时做一些初始化工作比如WebSocket需要扫描和加载Endpoint类。SCI的使用也比较简单将实现ServletContainerInitializer接口的类增加HandlesTypes注解并且在注解内指定的一系列类和接口集合。比如Tomcat为了扫描和加载Endpoint而定义的SCI类如下
```
@HandlesTypes({ServerEndpoint.class, ServerApplicationConfig.class, Endpoint.class})
public class WsSci implements ServletContainerInitializer {
public void onStartup(Set&lt;Class&lt;?&gt;&gt; clazzes, ServletContext ctx) throws ServletException {
...
}
}
```
一旦定义好了SCITomcat在启动阶段扫描类时会将HandlesTypes注解中指定的类都扫描出来作为SCI的onStartup方法的参数并调用SCI的onStartup方法。注意到WsSci的HandlesTypes注解中定义了`ServerEndpoint.class``ServerApplicationConfig.class``Endpoint.class`因此在Tomcat的启动阶段会将这些类的类实例注意不是对象实例传递给WsSci的onStartup方法。那么WsSci的onStartup方法又做了什么事呢
它会构造一个WebSocketContainer实例你可以把WebSocketContainer理解成一个专门处理WebSocket请求的**Endpoint容器**。也就是说Tomcat会把扫描到的Endpoint子类和添加了注解`@ServerEndpoint`的类注册到这个容器中并且这个容器还维护了URL到Endpoint的映射关系这样通过请求URL就能找到具体的Endpoint来处理WebSocket请求。
**WebSocket请求处理**
在讲WebSocket请求处理之前我们先来回顾一下Tomcat连接器的组件图。
<img src="https://static001.geekbang.org/resource/image/ea/ed/ea924a53eb834e4b07fce6a559fc37ed.jpg" alt="">
你可以看到Tomcat用ProtocolHandler组件屏蔽应用层协议的差异其中ProtocolHandler中有两个关键组件Endpoint和Processor。需要注意这里的Endpoint跟上文提到的WebSocket中的Endpoint完全是两回事连接器中的Endpoint组件用来处理I/O通信。WebSocket本质就是一个应用层协议因此不能用HttpProcessor来处理WebSocket请求而要用专门Processor来处理而在Tomcat中这样的Processor叫作UpgradeProcessor。
为什么叫UpgradeProcessor呢这是因为Tomcat是将HTTP协议升级成WebSocket协议的我们知道WebSocket是通过HTTP协议来进行握手的因此当WebSocket的握手请求到来时HttpProtocolHandler首先接收到这个请求在处理这个HTTP请求时Tomcat通过一个特殊的Filter判断该当前HTTP请求是否是一个WebSocket Upgrade请求即包含`Upgrade: websocket`的HTTP头信息如果是则在HTTP响应里添加WebSocket相关的响应头信息并进行协议升级。具体来说就是用UpgradeProtocolHandler替换当前的HttpProtocolHandler相应的把当前Socket的Processor替换成UpgradeProcessor同时Tomcat会创建WebSocket Session实例和Endpoint实例并跟当前的WebSocket连接一一对应起来。这个WebSocket连接不会立即关闭并且在请求处理中不再使用原有的HttpProcessor而是用专门的UpgradeProcessorUpgradeProcessor最终会调用相应的Endpoint实例来处理请求。下面我们通过一张图来理解一下。
<img src="https://static001.geekbang.org/resource/image/90/65/90892eab8ab21dac9dda65eed3aa5c65.jpg" alt="">
你可以看到Tomcat对WebSocket请求的处理没有经过Servlet容器而是通过UpgradeProcessor组件直接把请求发到ServerEndpoint实例并且Tomcat的WebSocket实现不需要关注具体I/O模型的细节从而实现了与具体I/O方式的解耦。
## 本期精华
WebSocket技术实现了Tomcat与浏览器的双向通信Tomcat可以主动向浏览器推送数据可以用来实现对数据实时性要求比较高的应用。这需要浏览器和Web服务器同时支持WebSocket标准Tomcat启动时通过SCI技术来扫描和加载WebSocket的处理类ServerEndpoint并且建立起了URL到ServerEndpoint的映射关系。
当第一个WebSocket请求到达时Tomcat将HTTP协议升级成WebSocket协议并将该Socket连接的Processor替换成UpgradeProcessor。这个Socket不会立即关闭对接下来的请求Tomcat通过UpgradeProcessor直接调用相应的ServerEndpoint来处理。
今天我讲了可以通过两种方式来开发WebSocket应用一种是继承`javax.websocket.Endpoint`另一种通过WebSocket相关的注解。其实你还可以通过Spring来实现WebSocket应用有兴趣的话你可以去研究一下Spring WebSocket的原理。
## 课后思考
今天我举的聊天室的例子实现的是群发消息,如果要向某个特定用户发送消息,应该怎么做呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,237 @@
<audio id="audio" title="19 | 比较Jetty的线程策略EatWhatYouKill" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/bb/f9af6500d765605b7560dd8961bae7bb.mp3"></audio>
我在前面的专栏里介绍了Jetty的总体架构设计简单回顾一下Jetty总体上是由一系列Connector、一系列Handler和一个ThreadPool组成它们的关系如下图所示
<img src="https://static001.geekbang.org/resource/image/9b/41/9b0e08e109f41b1c02b9f324c0a71241.jpg" alt="">
相比较Tomcat的连接器Jetty的Connector在设计上有自己的特点。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;
//&quot;Selector更新任务&quot;队列
private Deque&lt;SelectorUpdate&gt; _updates = new ArrayDeque&lt;&gt;();
private Deque&lt;SelectorUpdate&gt; _updateable = new ArrayDeque&lt;&gt;();
...
}
```
这些成员变量中其他的都好理解就是“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 &amp; SelectionKey.OP_READ) != 0;
boolean flushable = (readyOps &amp; 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事件处理完效率明显不高。通过图来理解图中绿色表示生产一个任务蓝色表示执行这个任务。
<img src="https://static001.geekbang.org/resource/image/23/3e/2394d237e9f7de107bfca736ffd71f3e.jpg" alt="">
- ProduceExecuteConsume任务生产者开启新线程来运行任务这是典型的I/O事件侦测和处理用不同的线程来处理缺点是不能利用CPU缓存并且线程切换成本高。同样我们通过一张图来理解图中的棕色表示线程切换。
<img src="https://static001.geekbang.org/resource/image/7e/6d/7e50ce9ec1bff55bbec777e79271066d.png" alt="">
- ExecuteProduceConsume任务生产者自己运行任务但是该策略可能会新建一个新线程以继续生产和执行任务。这种策略也被称为“吃掉你杀的猎物”它来自狩猎伦理认为一个人不应该杀死他不吃掉的东西对应线程来说不应该生成自己不打算运行的任务。它的优点是能利用CPU缓存但是潜在的问题是如果处理I/O事件的业务代码执行时间过长会导致线程大量阻塞和线程饥饿。
<img src="https://static001.geekbang.org/resource/image/43/b4/43c2dadaf5c323edf057a90ff06a71b4.png" alt="">
- 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&lt;SelectionKey&gt; _keys = Collections.emptySet();
private Iterator&lt;SelectionKey&gt; _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就绪事件。
## 本期精华
多线程虽然是提高并发的法宝但并不是说线程越多越好CPU缓存以及线程上下文切换的开销也是需要考虑的。Jetty巧妙设计了EatWhatYouKill的线程策略尽量用同一个线程侦测I/O事件和处理I/O事件充分利用了CPU缓存并减少了线程切换的开销。
## 课后思考
文章提到ManagedSelector的使用者不能直接向它注册I/O事件而是需要向ManagedSelector提交一个SelectorUpdate事件ManagedSelector将这些事件Queue起来由自己来统一处理这样做有什么好处呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,201 @@
<audio id="audio" title="20 | 总结Tomcat和Jetty中的对象池技术" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/d4/748b53277374b2ea9f9ce5b4805341d4.mp3"></audio>
Java对象特别是一个比较大、比较复杂的Java对象它们的创建、初始化和GC都需要耗费CPU和内存资源为了减少这些开销Tomcat和Jetty都使用了对象池技术。所谓的对象池技术就是说一个Java对象用完之后把它保存起来之后再拿出来重复使用省去了对象创建、初始化和GC的过程。对象池技术是典型的以**空间换时间**的思路。
由于维护对象池本身也需要资源的开销不是所有场景都适合用对象池。如果你的Java对象数量很多并且存在的时间比较短对象本身又比较大比较复杂对象初始化的成本比较高这样的场景就适合用对象池技术。比如Tomcat和Jetty处理HTTP请求的场景就符合这个特征请求的数量很多为了处理单个请求需要创建不少的复杂对象比如Tomcat连接器中SocketWrapper和SocketProcessor而且一般来说请求处理的时间比较短一旦请求处理完毕这些对象就需要被销毁因此这个场景适合对象池技术。
## Tomcat的SynchronizedStack
Tomcat用SynchronizedStack类来实现对象池下面我贴出它的关键代码来帮助你理解。
```
public class SynchronizedStack&lt;T&gt; {
//内部维护一个对象数组,用数组实现栈的功能
private Object[] stack;
//这个方法用来归还对象用synchronized进行线程同步
public synchronized boolean push(T obj) {
index++;
if (index == size) {
if (limit == -1 || size &lt; 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 &amp;&amp; newSize &gt; 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&lt;=0)//ByteBuffer的最小长度
minSize=0;
if (increment&lt;=0)
increment=1024;//默认以1024递增
if (maxSize&lt;=0)
maxSize=64*1024;//ByteBuffer的最大长度默认是64K
//ByteBuffer的最小长度必须小于增量
if (minSize&gt;=increment)
throw new IllegalArgumentException(&quot;minSize &gt;= increment&quot;);
//最大长度必须是增量的整数倍
if ((maxSize%increment)!=0 || increment&gt;=maxSize)
throw new IllegalArgumentException(&quot;increment must be a divisor of maxSize&quot;);
_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&lt;_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&lt;ByteBuffer&gt; _queue = new ConcurrentLinkedDeque&lt;&gt;();
```
你可以通过下面的图再来理解一下:
<img src="https://static001.geekbang.org/resource/image/85/79/852834815eda15e82888ec18a81b5879.png" alt="">
而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都用到了对象池技术这是因为处理一次HTTP请求的时间比较短但是这个过程中又需要创建大量复杂对象。
对象池技术可以减少频繁创建和销毁对象带来的成本实现对象的缓存和复用。如果你的系统需要频繁的创建和销毁对象并且对象的创建代价比较大这种情况下一般来说你会观察到GC的压力比较大占用CPU率比较高这个时候你就可以考虑使用对象池了。
还有一种情况是你需要对资源的使用做限制,比如数据库连接,不能无限制地创建数据库连接,因此就有了数据库连接池,你也可以考虑把一些关键的资源池化,对它们进行统一管理,防止滥用。
## 课后思考
请你想想在实际工作中,有哪些场景可以用“池化”技术来优化。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="21 | 总结Tomcat和Jetty的高性能、高并发之道" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/52/87cb1dc01eef875e851955ba5a84b652.mp3"></audio>
高性能程序就是高效的利用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系统调用。
## 池化、零拷贝
关于池化和零拷贝,我在专栏前面已经详细讲了它们的原理,你可以回过头看看[专栏第20期](http://time.geekbang.org/column/article/103197)和[第16期](http://time.geekbang.org/column/article/101201)。其实池化的本质就是用内存换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 &gt; 0 &amp;&amp; isRunning())
{
//获取当前已经启动的线程数,如果已经够了就不需要启动了
int threads = _threadsStarted.get();
if (threads &gt;= _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 + &quot;-&quot; + 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&lt;LifecycleListener&gt; lifecycleListeners = new CopyOnWriteArrayList&lt;&gt;();
...
}
```
**volatile关键字的使用**
再拿Tomcat中的LifecycleBase作为例子它里面的生命状态就是用volatile关键字修饰的。volatile的目的是为了保证一个线程修改了变量另一个线程能够读到这种变化。对于生命状态来说需要在各个线程中保持是最新的值因此采用了volatile修饰。
```
public abstract class LifecycleBase implements Lifecycle {
//当前组件的生命状态用volatile修饰
private volatile LifecycleState state = LifecycleState.NEW;
}
```
## 本期精华
高性能程序能够高效的利用系统资源首先就是减少资源浪费比如要减少线程的阻塞因为阻塞会导致资源闲置和线程上下文切换Tomcat和Jetty通过合理的I/O模型和线程模型减少了线程的阻塞。
另外系统调用会导致用户态和内核态切换的过程Tomcat和Jetty通过缓存和延迟解析尽量减少系统调用另外还通过零拷贝技术避免多余的数据拷贝。
高效的利用资源还包括另一层含义那就是我们在系统设计的过程中经常会用一种资源换取另一种资源比如Tomcat和Jetty中使用的对象池技术就是用内存换取CPU将数据压缩后再传输就是用CPU换网络。
除此之外高效的并发编程也很重要多线程虽然可以提高并发度也带来了锁的开销因此我们在实际编程过程中要尽量避免使用锁比如可以用原子变量和CAS操作来代替锁。如果实在避免不了用锁也要尽量减少锁的范围和强度比如可以用细粒度的对象锁或者低强度的读写锁。Tomcat和Jetty的代码也很好的实践了这一理念。
## 课后思考
今天的文章提到我们要有意识尽量避免系统调用那你知道有哪些Java API会导致系统调用吗
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,56 @@
<audio id="audio" title="22 | 热点问题答疑2内核如何阻塞与唤醒进程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/14/eac0a1c2a0b89788f2dd4e71f30bf314.mp3"></audio>
在专栏的第三个模块我们学习了Tomcat连接器组件的设计**其中最重要的是各种I/O模型及其实现**。而I/O模型跟操作系统密切相关要彻底理解这些原理我们首先需要弄清楚什么是进程和线程什么是虚拟内存和物理内存什么是用户空间和内核空间线程的阻塞到底意味着什么内核又是如何唤醒用户线程的等等这些问题。可以说掌握这些底层的知识对于你学习Tomcat和Jetty的原理乃至其他各种后端架构都至关重要这些知识可以说是后端开发的“基石”。
在专栏的留言中我也发现很多同学反馈对这些底层的概念很模糊,那今天作为模块的答疑篇,我就来跟你聊聊这些问题。
## 进程和线程
我们先从Linux的进程谈起操作系统要运行一个可执行程序首先要将程序文件加载到内存然后CPU去读取和执行程序指令而一个进程就是“一次程序的运行过程”内核会给每一个进程创建一个名为`task_struct`的数据结构,而内核也是一段程序,系统启动时就被加载到内存中了。
进程在运行过程中要访问内存而物理内存是有限的比如16GB那怎么把有限的内存分给不同的进程使用呢跟CPU的分时共享一样内存也是共享的Linux给每个进程虚拟出一块很大的地址空间比如32位机器上进程的虚拟内存地址空间是4GB从0x00000000到0xFFFFFFFF。但这4GB并不是真实的物理内存而是进程访问到了某个虚拟地址如果这个地址还没有对应的物理内存页就会产生缺页中断分配物理内存MMU内存管理单元会将虚拟地址与物理内存页的映射关系保存在页表中再次访问这个虚拟地址就能找到相应的物理内存页。每个进程的这4GB虚拟地址空间分布如下图所示
<img src="https://static001.geekbang.org/resource/image/d7/86/d78cd0faf850c4efdbe00c63659e0f86.png" alt="">
进程的虚拟地址空间总体分为用户空间和内核空间低地址上的3GB属于用户空间高地址的1GB是内核空间这是基于安全上的考虑用户程序只能访问用户空间内核程序可以访问整个进程空间并且只有内核可以直接访问各种硬件资源比如磁盘和网卡。那用户程序需要访问这些硬件资源该怎么办呢答案是通过系统调用系统调用可以理解为内核实现的函数比如应用程序要通过网卡接收数据会调用Socket的read函数
```
ssize_t read(int fd,void *buf,size_t nbyte)
```
CPU在执行系统调用的过程中会从用户态切换到内核态CPU在用户态下执行用户程序使用的是用户空间的栈访问用户空间的内存当CPU切换到内核态后执行内核代码使用的是内核空间上的栈。
从上面这张图我们看到用户空间从低到高依次是代码区、数据区、堆、共享库与mmap内存映射区、栈、环境变量。其中堆向高地址增长栈向低地址增长。
请注意用户空间上还有一个共享库和mmap映射区Linux提供了内存映射函数mmap 它可将文件内容映射到这个内存区域用户通过读写这段内存从而实现对文件的读取和修改无需通过read/write系统调用来读写文件省去了用户空间和内核空间之间的数据拷贝Java的MappedByteBuffer就是通过它来实现的用户程序用到的系统共享库也是通过mmap映射到了这个区域。
我在开始提到的`task_struct`结构体本身是分配在内核空间,它的`vm_struct`成员变量保存了各内存区域的起始和终止地址,此外`task_struct`中还保存了进程的其他信息比如进程号、打开的文件、创建的Socket以及CPU运行上下文等。
在Linux中线程是一个轻量级的进程轻量级说的是线程只是一个CPU调度单元因此线程有自己的`task_struct`结构体和运行栈区但是线程的其他资源都是跟父进程共用的比如虚拟地址空间、打开的文件和Socket等。
## 阻塞与唤醒
我们知道当用户线程发起一个阻塞式的read调用数据未就绪时线程就会阻塞那阻塞具体是如何实现的呢
Linux内核将线程当作一个进程进行CPU调度内核维护了一个可运行的进程队列所有处于`TASK_RUNNING`状态的进程都会被放入运行队列中,本质是用双向链表将`task_struct`链接起来排队使用CPU时间片时间片用完重新调度CPU。所谓调度就是在可运行进程列表中选择一个进程再从CPU列表中选择一个可用的CPU将进程的上下文恢复到这个CPU的寄存器中然后执行进程上下文指定的下一条指令。
<img src="https://static001.geekbang.org/resource/image/b6/e8/b6794ae547bccdf71c0f6ea4e93012e8.png" alt="">
而阻塞的本质就是将进程的`task_struct`移出运行队列,添加到等待队列,并且将进程的状态的置为`TASK_UNINTERRUPTIBLE`或者`TASK_INTERRUPTIBLE`重新触发一次CPU调度让出CPU。
那线程怎么唤醒呢线程在加入到等待队列的同时向内核注册了一个回调函数告诉内核我在等待这个Socket上的数据如果数据到了就唤醒我。这样当网卡接收到数据时产生硬件中断内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的`task_struct`从等待队列移到运行队列,并且将`task_struct`的状态置为`TASK_RUNNING`这样进程就有机会重新获得CPU时间片。
这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。
<img src="https://static001.geekbang.org/resource/image/2e/b8/2e27945eee139201de846e6a58c031b8.png" alt="">
当read系统调用返回时CPU又从内核态切换到用户态继续执行read调用的下一行代码并且能从用户空间上的Buffer读到数据了。
## 小结
今天我们谈到了一次Socket read系统调用的过程首先CPU在用户态执行应用程序的代码访问进程虚拟地址空间的用户空间read系统调用时CPU从用户态切换到内核态执行内核代码内核检测到Socket上的数据未就绪时将进程的`task_struct`结构体从运行队列中移到等待队列并触发一次CPU调度这时进程会让出CPU当网卡数据到达时内核将数据从内核空间拷贝到用户空间的Buffer接着将进程的`task_struct`结构体重新移到运行队列这样进程就有机会重新获得CPU时间片系统调用返回CPU又从内核态切换到用户态访问用户空间的数据。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,177 @@
<audio id="audio" title="05 | Tomcat系统架构 连接器是如何设计的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/bf/d1e496ba509e03ccb784ec2b940929bf.mp3"></audio>
在面试时我们可能经常被问到你做的XX项目的架构是如何设计的请讲一下实现的思路。对于面试官来说可以通过你对复杂系统设计的理解了解你的技术水平以及处理复杂问题的思路。
今天咱们就来一步一步分析Tomcat的设计思路看看Tomcat的设计者们当时是怎么回答这个问题的。一方面我们可以学到Tomcat的总体架构学会从宏观上怎么去设计一个复杂系统怎么设计顶层模块以及模块之间的关系另一方面也为我们深入学习Tomcat的工作原理打下基础。
## Tomcat总体架构
我们知道如果要设计一个系统首先是要了解需求。通过专栏前面的文章我们已经了解了Tomcat要实现2个核心功能
<li>
处理Socket连接负责网络字节流与Request和Response对象的转化。
</li>
<li>
加载和管理Servlet以及具体处理Request请求。
</li>
**因此Tomcat设计了两个核心组件连接器Connector和容器Container来分别做这两件事情。连接器负责对外交流容器负责内部处理。**
所以连接器和容器可以说是Tomcat架构里最重要的两部分需要你花些精力理解清楚。这两部分内容我会分成两期今天我来分析连接器是如何设计的下一期我会介绍容器的设计。
在开始讲连接器前我先铺垫一下Tomcat支持的多种I/O模型和应用层协议。
Tomcat支持的I/O模型有
<li>
NIO非阻塞I/O采用Java NIO类库实现。
</li>
<li>
NIO.2异步I/O采用JDK 7最新的NIO.2类库实现。
</li>
<li>
APR采用Apache可移植运行库实现是C/C++编写的本地库。
</li>
Tomcat支持的应用层协议有
<li>
HTTP/1.1这是大部分Web应用采用的访问协议。
</li>
<li>
AJP用于和Web服务器集成如Apache
</li>
<li>
HTTP/2HTTP 2.0大幅度的提升了Web性能。
</li>
Tomcat为了实现支持多种I/O模型和应用层协议一个容器可能对接多个连接器就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务需要把它们组装起来才能工作组装后这个整体叫作Service组件。这里请你注意Service本身没有做什么重要的事情只是在连接器和容器外面多包了一层把它们组装在一起。Tomcat内可能有多个Service这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
到此我们得到这样一张关系图:
<img src="https://static001.geekbang.org/resource/image/ee/d6/ee880033c5ae38125fa91fb3c4f8cad6.jpg" alt="">
从图上你可以看到最顶层是Server这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
## 连接器
连接器对Servlet容器屏蔽了协议及I/O模型等的区别无论是HTTP还是AJP在容器中获取到的都是一个标准的ServletRequest对象。
我们可以把连接器的功能需求进一步细化,比如:
<li>
监听网络端口。
</li>
<li>
接受网络连接请求。
</li>
<li>
读取网络请求字节流。
</li>
<li>
根据具体应用层协议HTTP/AJP解析字节流生成统一的Tomcat Request对象。
</li>
<li>
将Tomcat Request对象转成标准的ServletRequest。
</li>
<li>
调用Servlet容器得到ServletResponse。
</li>
<li>
将ServletResponse转成Tomcat Response对象。
</li>
<li>
将Tomcat Response转成网络字节流。
</li>
<li>
将响应字节流写回给浏览器。
</li>
需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?优秀的模块化设计应该考虑**高内聚、低耦合**。
<li>
**高内聚**是指相关度比较高的功能要尽可能集中,不要分散。
</li>
<li>
**低耦合**是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。
</li>
通过分析连接器的详细功能列表我们发现连接器需要完成3个**高内聚**的功能:
<li>
网络通信。
</li>
<li>
应用层协议解析。
</li>
<li>
Tomcat Request/Response与ServletRequest/ServletResponse的转化。
</li>
因此Tomcat的设计者设计了3个组件来实现这3个功能分别是Endpoint、Processor和Adapter。
组件之间通过抽象接口交互。这样做还有一个好处是**封装变化。**这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。
网络通信的I/O模型是变化的可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。
但是整体的处理逻辑是不变的Endpoint负责提供字节流给ProcessorProcessor负责提供Tomcat Request对象给AdapterAdapter负责提供ServletRequest对象给容器。
如果要支持新的I/O方案、新的应用层协议只需要实现相关的具体子类上层通用的处理逻辑是不变的。
由于I/O模型和应用层协议可以自由组合比如NIO + HTTP或者NIO.2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如Http11NioProtocol和AjpNioProtocol。
除了这些变化点系统也存在一些相对稳定的部分因此Tomcat设计了一系列抽象基类来**封装这些稳定的部分**抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类比如AbstractAjpProtocol和AbstractHttp11Protocol具体协议的实现类扩展了协议层抽象基类。下面我整理一下它们的继承关系。
<img src="https://static001.geekbang.org/resource/image/13/55/13850ee56c3f09cbabe9892e84502155.jpg" alt="">
通过上面的图你可以清晰地看到它们的继承和层次关系这样设计的目的是尽量将稳定的部分放到抽象基类同时每一种I/O模型和协议的组合都有相应的具体实现类我们在使用时可以自由选择。
小结一下连接器模块用三个核心组件Endpoint、Processor和Adapter来分别做三件事情其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件它们的关系如下图所示。
<img src="https://static001.geekbang.org/resource/image/6e/ce/6eeaeb93839adcb4e76c15ee93f545ce.jpg" alt="">
下面我来详细介绍这两个顶层组件ProtocolHandler和Adapter。
**ProtocolHandler组件**
由上文我们知道连接器用ProtocolHandler来处理网络连接和应用层协议包含了2个重要部件Endpoint和Processor下面我来详细介绍它们的工作原理。
- Endpoint
Endpoint是通信端点即通信监听的接口是具体的Socket接收和发送处理器是对传输层的抽象因此Endpoint是用来实现TCP/IP协议的。
Endpoint是一个接口对应的抽象实现类是AbstractEndpoint而AbstractEndpoint的具体子类比如在NioEndpoint和Nio2Endpoint中有两个重要的子组件Acceptor和SocketProcessor。
其中Acceptor用于监听Socket连接请求。SocketProcessor用于处理接收到的Socket请求它实现Runnable接口在run方法里调用协议处理组件Processor进行处理。为了提高处理能力SocketProcessor被提交到线程池来执行。而这个线程池叫作执行器Executor)我在后面的专栏会详细介绍Tomcat如何扩展原生的Java线程池。
- Processor
如果说Endpoint是用来实现TCP/IP协议的那么Processor用来实现HTTP协议Processor接收来自Endpoint的Socket读取字节流解析成Tomcat Request和Response对象并通过Adapter将其提交到容器处理Processor是对应用层协议的抽象。
Processor是一个接口定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装没有对方法进行实现。具体的实现有AjpProcessor、Http11Processor等这些具体实现类实现了特定协议的解析方法和请求处理方式。
我们再来看看连接器的组件图:
<img src="https://static001.geekbang.org/resource/image/30/cf/309cae2e132210489d327cf55b284dcf.jpg" alt="">
从图中我们看到Endpoint接收到Socket连接后生成一个SocketProcessor任务提交到线程池去处理SocketProcessor的run方法会调用Processor组件去解析应用层协议Processor通过解析生成Request对象后会调用Adapter的Service方法。
到这里我们学习了ProtocolHandler的总体架构和工作原理关于Endpoint的详细设计后面我还会专门介绍Endpoint是如何最大限度地利用Java NIO的非阻塞以及NIO.2的异步特性,来实现高并发。
**Adapter组件**
我在前面说过由于协议不同客户端发过来的请求信息也不尽相同Tomcat定义了自己的Request类来“存放”这些请求信息。ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest也就意味着不能用Tomcat Request作为参数来调用容器。Tomcat设计者的解决方案是引入CoyoteAdapter这是适配器模式的经典运用连接器调用CoyoteAdapter的sevice方法传入的是Tomcat Request对象CoyoteAdapter负责将Tomcat Request转成ServletRequest再调用容器的service方法。
## 本期精华
Tomcat的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流容器负责内部处理。连接器用ProtocolHandler接口来封装通信协议和I/O模型的差异ProtocolHandler内部又分为Endpoint和Processor模块Endpoint负责底层Socket通信Processor负责应用层协议解析。连接器通过适配器Adapter调用容器。
通过对Tomcat整体架构的学习我们可以得到一些设计复杂系统的基本思路。首先要分析需求根据高内聚低耦合的原则确定子模块然后找出子模块中的变化点和不变点用接口和抽象基类去封装不变点在抽象基类中定义模板方法让子类自行实现抽象方法也就是具体子类去实现变化点。
## 课后思考
回忆一下你在工作中曾经独立设计过的系统,或者你碰到过的设计类面试题,结合今天专栏的内容,你有没有一些新的思路?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,139 @@
<audio id="audio" title="06 | Tomcat系统架构聊聊多层容器的设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/a8/932fcee9871bd36583c78261ee0832a8.mp3"></audio>
专栏上一期我们学完了连接器的设计今天我们一起来看一下Tomcat的容器设计。先复习一下上期我讲到了Tomcat有两个核心组件连接器和容器其中连接器负责外部交流容器负责内部处理。具体来说就是连接器处理Socket通信和应用层协议的解析得到Servlet请求而容器则负责处理Servlet请求。我们通过下面这张图来回忆一下。
<img src="https://static001.geekbang.org/resource/image/ee/d6/ee880033c5ae38125fa91fb3c4f8cad6.jpg" alt="">
容器顾名思义就是用来装载东西的器具在Tomcat里容器就是用来装载Servlet的。那Tomcat的Servlet容器是如何设计的呢
## 容器的层次结构
Tomcat设计了4种容器分别是Engine、Host、Context和Wrapper。这4种容器不是平行关系而是父子关系。下面我画了一张图帮你理解它们的关系。
<img src="https://static001.geekbang.org/resource/image/cc/ed/cc968a11925591df558da0e7393f06ed.jpg" alt="">
你可能会问,为什么要设计成这么多层次的容器,这不是增加了复杂度吗?其实这背后的考虑是,**Tomcat通过一种分层的架构使得Servlet容器具有很好的灵活性。**
Context表示一个Web应用程序Wrapper表示一个Servlet一个Web应用程序中可能会有多个ServletHost代表的是一个虚拟主机或者说一个站点可以给Tomcat配置多个虚拟主机地址而一个虚拟主机下可以部署多个Web应用程序Engine表示引擎用来管理多个虚拟站点一个Service最多只能有一个Engine。
你可以再通过Tomcat的`server.xml`配置文件来加深对Tomcat容器的理解。Tomcat采用了组件化的设计它的构成组件都是可配置的其中最外层的是Server其他组件按照一定的格式要求配置在这个顶层容器中。
<img src="https://static001.geekbang.org/resource/image/82/66/82b3f97aab5152dd5fe74e947db2a266.jpg" alt="">
那么Tomcat是怎么管理这些容器的呢你会发现这些容器具有父子关系形成一个树形结构你可能马上就想到了设计模式中的组合模式。没错Tomcat就是用组合模式来管理这些容器的。具体实现方法是所有容器组件都实现了Container接口因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。这里单容器对象指的是最底层的Wrapper组合容器对象指的是上面的Context、Host或者Engine。Container接口定义如下
```
public interface Container extends Lifecycle {
public void setName(String name);
public Container getParent();
public void setParent(Container container);
public void addChild(Container child);
public void removeChild(Container child);
public Container findChild(String name);
}
```
正如我们期望的那样我们在上面的接口看到了getParent、setParent、addChild和removeChild等方法。你可能还注意到Container接口扩展了Lifecycle接口Lifecycle接口用来统一管理各组件的生命周期后面我也用专门的篇幅去详细介绍。
## 请求定位Servlet的过程
你可能好奇设计了这么多层次的容器Tomcat是怎么确定请求是由哪个Wrapper容器里的Servlet来处理的呢答案是Tomcat是用Mapper组件来完成这个任务的。
Mapper组件的功能就是将用户请求的URL定位到一个Servlet它的工作原理是Mapper组件里保存了Web应用的配置信息其实就是**容器组件与访问路径的映射关系**比如Host容器里配置的域名、Context容器里的Web应用路径以及Wrapper容器里Servlet映射的路径你可以想象这些配置信息就是一个多层次的Map。
当一个请求到来时Mapper组件通过解析请求URL里的域名和路径再到自己保存的Map里去查找就能定位到一个Servlet。请你注意一个请求URL最后只会定位到一个Wrapper容器也就是一个Servlet。
读到这里你可能感到有些抽象,接下来我通过一个例子来解释这个定位的过程。
假如有一个网购系统有面向网站管理人员的后台管理系统还有面向终端客户的在线购物系统。这两个系统跑在同一个Tomcat上为了隔离它们的访问域名配置了两个虚拟域名`manage.shopping.com``user.shopping.com`,网站管理人员通过`manage.shopping.com`域名访问Tomcat去管理用户和商品而用户管理和商品管理是两个单独的Web应用。终端客户通过`user.shopping.com`域名去搜索商品和下订单搜索功能和订单管理也是两个独立的Web应用。
针对这样的部署Tomcat会创建一个Service组件和一个Engine容器组件在Engine容器下创建两个Host子容器在每个Host容器下创建两个Context子容器。由于一个Web应用通常有多个ServletTomcat还会在每个Context容器里创建多个Wrapper子容器。每个容器都有对应的访问路径你可以通过下面这张图来帮助你理解。
<img src="https://static001.geekbang.org/resource/image/be/96/be22494588ca4f79358347468cd62496.jpg" alt="">
假如有用户访问一个URL比如图中的`http://user.shopping.com:8080/order/buy`Tomcat如何将这个URL定位到一个Servlet呢
**首先根据协议和端口号选定Service和Engine。**
我们知道Tomcat的每个连接器都监听不同的端口比如Tomcat默认的HTTP连接器监听8080端口、默认的AJP连接器监听8009端口。上面例子中的URL访问的是8080端口因此这个请求会被HTTP连接器接收而一个连接器是属于一个Service组件的这样Service组件就确定了。我们还知道一个Service组件里除了有多个连接器还有一个容器组件具体来说就是一个Engine容器因此Service确定了也就意味着Engine也确定了。
**然后根据域名选定Host。**
Service和Engine确定后Mapper组件通过URL中的域名去查找相应的Host容器比如例子中的URL访问的域名是`user.shopping.com`因此Mapper会找到Host2这个容器。
**之后根据URL路径找到Context组件。**
Host确定以后Mapper根据URL的路径来匹配相应的Web应用的路径比如例子中访问的是`/order`因此找到了Context4这个Context容器。
**最后根据URL路径找到WrapperServlet。**
Context确定后Mapper再根据`web.xml`中配置的Servlet映射路径来找到具体的Wrapper和Servlet。
看到这里我想你应该已经了解了什么是容器以及Tomcat如何通过一层一层的父子容器找到某个Servlet来处理请求。需要注意的是并不是说只有Servlet才会去处理请求实际上这个查找路径上的父子容器都会对请求做一些处理。我在上一期说过连接器中的Adapter会调用容器的Service方法来执行Servlet最先拿到请求的是Engine容器Engine容器对请求做一些处理后会把请求传给自己子容器Host继续处理依次类推最后这个请求会传给Wrapper容器Wrapper会调用最终的Servlet来处理。那么这个调用过程具体是怎么实现的呢答案是使用Pipeline-Valve管道。
Pipeline-Valve是责任链模式责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处理每个处理者负责做自己相应的处理处理完之后将再调用下一个处理者继续处理。
Valve表示一个处理点比如权限认证和记录日志。如果你还不太理解的话可以来看看Valve和Pipeline接口中的关键方法。
```
public interface Valve {
public Valve getNext();
public void setNext(Valve valve);
public void invoke(Request request, Response response)
}
```
由于Valve是一个处理点因此invoke方法就是来处理请求的。注意到Valve中有getNext和setNext方法因此我们大概可以猜到有一个链表将Valve链起来了。请你继续看Pipeline接口
```
public interface Pipeline extends Contained {
public void addValve(Valve valve);
public Valve getBasic();
public void setBasic(Valve valve);
public Valve getFirst();
}
```
没错Pipeline中有addValve方法。Pipeline中维护了Valve链表Valve可以插入到Pipeline中对请求做某些处理。我们还发现Pipeline中没有invoke方法因为整个调用链的触发是Valve来完成的Valve完成自己的处理后调用`getNext.invoke`来触发下一个Valve调用。
每一个容器都有一个Pipeline对象只要触发这个Pipeline的第一个Valve这个容器里Pipeline中的Valve就都会被调用到。但是不同容器的Pipeline是怎么链式触发的呢比如Engine中Pipeline需要调用下层容器Host中的Pipeline。
这是因为Pipeline中还有个getBasic方法。这个BasicValve处于Valve链表的末端它是Pipeline中必不可少的一个Valve负责调用下层容器的Pipeline里的第一个Valve。我还是通过一张图来解释。
<img src="https://static001.geekbang.org/resource/image/b0/ca/b014ecce1f64b771bd58da62c05162ca.jpg" alt="">
整个调用过程由连接器中的Adapter触发的它会调用Engine的第一个Valve
```
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
```
Wrapper容器的最后一个Valve会创建一个Filter链并调用doFilter方法最终会调到Servlet的service方法。
你可能会问前面我们不是讲到了Filter似乎也有相似的功能那Valve和Filter有什么区别吗它们的区别是
<li>
Valve是Tomcat的私有机制与Tomcat的基础架构/API是紧耦合的。Servlet API是公有的标准所有的Web容器包括Jetty都支持Filter机制。
</li>
<li>
另一个重要的区别是Valve工作在Web容器级别拦截所有应用的请求而Servlet Filter工作在应用级别只能拦截某个Web应用的所有请求。如果想做整个Web容器的拦截器必须通过Valve来实现。
</li>
## 本期精华
今天我们学习了Tomcat容器的层次结构、根据请求定位Servlet的过程以及请求在容器中的调用过程。Tomcat设计了多层容器是为了灵活性的考虑灵活性具体体现在一个Tomcat实例Server可以有多个Service每个Service通过多个连接器监听不同的端口而一个Service又可以支持多个虚拟主机。一个URL网址可以用不同的主机名、不同的端口和不同的路径来访问特定的Servlet实例。
请求的链式调用是基于Pipeline-Valve责任链来完成的这样的设计使得系统具有良好的可扩展性如果需要扩展容器本身的功能只需要增加相应的Valve即可。
## 课后思考
Tomcat内的Context组件跟Servlet规范中的ServletContext接口有什么区别跟Spring中的ApplicationContext又有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="07 | Tomcat如何实现一键式启停" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/dd/54966edd8fee48887fcf1015a61dffdd.mp3"></audio>
通过前面的学习相信你对Tomcat的架构已经有所了解知道了Tomcat都有哪些组件组件之间是什么样的关系以及Tomcat是怎么处理一个HTTP请求的。下面我们通过一张简化的类图来回顾一下从图上你可以看到各种组件的层次关系图中的虚线表示一个请求在Tomcat中流转的过程。
<img src="https://static001.geekbang.org/resource/image/12/9b/12ad9ddc3ff73e0aacf2276bcfafae9b.png" alt="">
上面这张图描述了组件之间的静态关系如果想让一个系统能够对外提供服务我们需要创建、组装并启动这些组件在服务停止的时候我们还需要释放资源销毁这些组件因此这是一个动态的过程。也就是说Tomcat需要动态地管理这些组件的生命周期。
在我们实际的工作中,如果你需要设计一个比较大的系统或者框架时,你同样也需要考虑这几个问题:如何统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组件?如何做到组件启动和停止不遗漏、不重复?
今天我们就来解决上面的问题,在这之前,先来看看组件之间的关系。如果你仔细分析过这些组件,可以发现它们具有两层关系。
<li>
第一层关系是组件有大有小大组件管理小组件比如Server管理ServiceService又管理连接器和容器。
</li>
<li>
第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件调用内层组件完成业务功能。也就是说,**请求的处理过程是由外层组件来驱动的。**
</li>
这两层关系决定了系统在创建组件时应该遵循一定的顺序。
<li>
第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
</li>
<li>
第二个原则是先创建内层组件,再创建外层组件,内层组件需要被“注入”到外层组件。
</li>
因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。不知道你注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于后期的功能扩展。
为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启动”那样的效果。
## 一键式启停Lifecycle接口
我在前面说到过,设计就是要找到系统的变化点和不变点。这里的不变点就是每个组件都要经历创建、初始化、启动这几个过程,这些状态以及状态的转化是不变的。而变化点是每个具体组件的初始化方法,也就是启动方法是不一样的。
因此我们把不变点抽象出来成为一个接口这个接口跟生命周期有关叫作Lifecycle。Lifecycle接口里应该定义这么几个方法init、start、stop和destroy每个具体的组件去实现这些方法。
理所当然在父组件的init方法里需要创建子组件并调用子组件的init方法。同样在父组件的start方法里也需要调用子组件的start方法因此调用者可以无差别的调用各组件的init方法和start方法这就是**组合模式**的使用并且只要调用最顶层组件也就是Server组件的init和start方法整个Tomcat就被启动起来了。下面是Lifecycle接口的定义。
<img src="https://static001.geekbang.org/resource/image/a1/5c/a1fcba6105f4235486bdba350d58bb5c.png" alt="">
## 可扩展性Lifecycle事件
我们再来考虑另一个问题那就是系统的可扩展性。因为各个组件init和start方法的具体实现是复杂多变的比如在Host容器的启动方法里需要扫描webapps目录下的Web应用创建相应的Context容器如果将来需要增加新的逻辑直接修改start方法这样会违反开闭原则那如何解决这个问题呢开闭原则说的是为了扩展系统的功能你不能直接修改系统中已有的类但是你可以定义新的类。
我们注意到组件的init和start调用是由它的父组件的状态变化触发的上层组件的初始化会触发子组件的初始化上层组件的启动会触发子组件的启动因此我们把组件的生命周期定义成一个个状态把状态的转变看作是一个事件。而事件是有监听器的在监听器里可以实现一些逻辑并且监听器也可以方便的添加和删除这就是典型的**观察者模式**。
具体来说就是在Lifecycle接口里加入两个方法添加监听器和删除监听器。除此之外我们还需要定义一个Enum来表示组件有哪些状态以及处在什么状态会触发什么样的事件。因此Lifecycle接口和LifecycleState就定义成了下面这样。
<img src="https://static001.geekbang.org/resource/image/dd/c0/dd0ce38fdff06dcc6d40714f39fc4ec0.png" alt="">
从图上你可以看到组件的生命周期有NEW、INITIALIZING、INITIALIZED、STARTING_PREP、STARTING、STARTED等而一旦组件到达相应的状态就触发相应的事件比如NEW状态表示组件刚刚被实例化而当init方法被调用时状态就变成INITIALIZING状态这个时候就会触发BEFORE_INIT_EVENT事件如果有监听器在监听这个事件它的方法就会被调用。
## 重用性LifecycleBase抽象基类
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。
比如宝马和荣威的底盘和骨架其实是一样的,只是发动机和内饰等配套是不一样的。底盘和骨架就是基类,宝马和荣威就是子类。仅仅有底盘和骨架还不是一辆真正意义上的车,只能算是半成品,因此在底盘和骨架上会留出一些安装接口,比如安装发动机的接口、安装座椅的接口,这些就是抽象方法。宝马或者荣威上安装的发动机和座椅是不一样的,也就是具体子类对抽象方法有不同的实现。
回到Lifecycle接口Tomcat定义一个基类LifecycleBase来实现Lifecycle接口把一些公共的逻辑放到基类中去比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等而子类就负责实现自己的初始化、启动和停止等方法。为了避免跟基类中的方法同名我们把具体子类的实现方法改个名字在后面加上Internal叫initInternal、startInternal等。我们再来看引入了基类LifecycleBase后的类图
<img src="https://static001.geekbang.org/resource/image/67/d9/6704bf8a3e10e1d4cfb35ba11e6de5d9.png" alt="">
从图上可以看到LifecycleBase实现了Lifecycle接口中所有的方法还定义了相应的抽象方法交给具体子类去实现这是典型的**模板设计模式**。
我们还是看一看代码可以帮你加深理解下面是LifecycleBase的init方法实现。
```
@Override
public final synchronized void init() throws LifecycleException {
//1. 状态检查
if (!state.equals(LifecycleState.NEW)) {
invalidTransition(Lifecycle.BEFORE_INIT_EVENT);
}
try {
//2.触发INITIALIZING事件的监听器
setStateInternal(LifecycleState.INITIALIZING, null, false);
//3.调用具体子类的初始化方法
initInternal();
//4. 触发INITIALIZED事件的监听器
setStateInternal(LifecycleState.INITIALIZED, null, false);
} catch (Throwable t) {
...
}
}
```
这个方法逻辑比较清楚,主要完成了四步:
第一步检查状态的合法性比如当前状态必须是NEW然后才能进行初始化。
第二步触发INITIALIZING事件的监听器
```
setStateInternal(LifecycleState.INITIALIZING, null, false);
```
在这个setStateInternal方法里会调用监听器的业务方法。
第三步调用具体子类实现的抽象方法initInternal方法。我在前面提到过为了实现一键式启动具体组件在实现initInternal方法时又会调用它的子组件的init方法。
第四步子组件初始化后触发INITIALIZED事件的监听器相应监听器的业务方法就会被调用。
```
setStateInternal(LifecycleState.INITIALIZED, null, false);
```
总之LifecycleBase调用了抽象方法来实现骨架逻辑。讲到这里 你可能好奇LifecycleBase负责触发事件并调用监听器的方法那是什么时候、谁把监听器注册进来的呢
分为两种情况:
<li>
Tomcat自定义了一些监听器这些监听器是父组件在创建子组件的过程中注册到子组件的。比如MemoryLeakTrackingListener监听器用来检测Context容器中的内存泄漏这个监听器是Host容器在创建Context容器时注册到Context中的。
</li>
<li>
我们还可以在`server.xml`中定义自己的监听器Tomcat在启动时会解析`server.xml`,创建监听器并注册到容器组件。
</li>
## 生周期管理总体类图
通过上面的学习我相信你对Tomcat组件的生命周期的管理有了深入的理解我们再来看一张总体类图继续加深印象。
<img src="https://static001.geekbang.org/resource/image/de/90/de55ad3475e714acbf883713ee077690.png" alt="">
这里请你注意图中的StandardServer、StandardService等是Server和Service组件的具体实现类它们都继承了LifecycleBase。
StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类因为它们都是容器所以继承了ContainerBase抽象基类而ContainerBase实现了Container接口也继承了LifecycleBase类它们的生命周期管理接口和功能接口是分开的这也符合设计中**接口分离的原则**。
## 本期精华
Tomcat为了实现一键式启停以及优雅的生命周期管理并考虑到了可扩展性和可重用性将面向对象思想和设计模式发挥到了极致分别运用了**组合模式、观察者模式、骨架抽象类和模板方法**。
如果你需要维护一堆具有父子关系的实体,可以考虑使用组合模式。
观察者模式听起来“高大上”,其实就是当一个事件发生后,需要执行一连串更新操作。传统的实现方式是在事件响应代码里直接加更新逻辑,当更新逻辑加多了之后,代码会变得臃肿,并且这种方式是紧耦合的、侵入式的。而观察者模式实现了低耦合、非侵入式的通知与更新机制。
而模板方法在抽象基类中经常用到,用来实现通用逻辑。
## 课后思考
从文中最后的类图上你会看到所有的容器组件都扩展了ContainerBase跟LifecycleBase一样ContainerBase也是一个骨架抽象类请你思考一下各容器组件有哪些“共同的逻辑”需要ContainerBase由来实现呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,267 @@
<audio id="audio" title="08 | Tomcat的“高层们”都负责做什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/66/50a5b1e7769fe9d4e37d84dc695b0666.mp3"></audio>
使用过Tomcat的同学都知道我们可以通过Tomcat的`/bin`目录下的脚本`startup.sh`来启动Tomcat那你是否知道我们执行了这个脚本后发生了什么呢你可以通过下面这张流程图来了解一下。
<img src="https://static001.geekbang.org/resource/image/57/4d/578edfe9c06856324084ee193243694d.png" alt="">
1.Tomcat本质上是一个Java程序因此`startup.sh`脚本会启动一个JVM来运行Tomcat的启动类Bootstrap。
2.Bootstrap的主要任务是初始化Tomcat的类加载器并且创建Catalina。关于Tomcat为什么需要自己的类加载器我会在专栏后面详细介绍。
3.Catalina是一个启动类它通过解析`server.xml`、创建相应的组件并调用Server的start方法。
4.Server组件的职责就是管理Service组件它会负责调用Service的start方法。
5.Service组件的职责就是管理连接器和顶层容器Engine因此它会调用连接器和Engine的start方法。
这样Tomcat的启动就算完成了。下面我来详细介绍一下上面这个启动过程中提到的几个非常关键的启动类和组件。
你可以把Bootstrap看作是上帝它初始化了类加载器也就是创造万物的工具。
如果我们把Tomcat比作是一家公司那么Catalina应该是公司创始人因为Catalina负责组建团队也就是创建Server以及它的子组件。
Server是公司的CEO负责管理多个事业群每个事业群就是一个Service。
Service是事业群总经理它管理两个职能部门一个是对外的市场部也就是连接器组件另一个是对内的研发部也就是容器组件。
Engine则是研发部经理因为Engine是最顶层的容器组件。
你可以看到这些启动类或者组件不处理具体请求它们的任务主要是“管理”管理下层组件的生命周期并且给下层组件分配任务也就是把请求路由到负责“干活儿”的组件。因此我把它们比作Tomcat的“高层”。
今天我们就来看看这些“高层”的实现细节目的是让我们逐步理解Tomcat的工作原理。另一方面软件系统中往往都有一些起管理作用的组件你可以学习和借鉴Tomcat是如何实现这些组件的。
## Catalina
Catalina的主要任务就是创建Server它不是直接new一个Server实例就完事了而是需要解析`server.xml`,把在`server.xml`里配置的各种组件一一创建出来接着调用Server组件的init方法和start方法这样整个Tomcat就启动起来了。作为“管理者”Catalina还需要处理各种“异常”情况比如当我们通过“Ctrl + C”关闭Tomcat时Tomcat将如何优雅的停止并且清理资源呢因此Catalina在JVM中注册一个“关闭钩子”。
```
public void start() {
//1. 如果持有的Server实例为空就解析server.xml创建出来
if (getServer() == null) {
load();
}
//2. 如果创建失败,报错退出
if (getServer() == null) {
log.fatal(sm.getString(&quot;catalina.noServer&quot;));
return;
}
//3.启动Server
try {
getServer().start();
} catch (LifecycleException e) {
return;
}
//创建并注册关闭钩子
if (useShutdownHook) {
if (shutdownHook == null) {
shutdownHook = new CatalinaShutdownHook();
}
Runtime.getRuntime().addShutdownHook(shutdownHook);
}
//用await方法监听停止请求
if (await) {
await();
stop();
}
}
```
那什么是“关闭钩子”它又是做什么的呢如果我们需要在JVM关闭时做一些清理工作比如将缓存数据刷到磁盘上或者清理一些临时文件可以向JVM注册一个“关闭钩子”。“关闭钩子”其实就是一个线程JVM在停止之前会尝试执行这个线程的run方法。下面我们来看看Tomcat的“关闭钩子”CatalinaShutdownHook做了些什么。
```
protected class CatalinaShutdownHook extends Thread {
@Override
public void run() {
try {
if (getServer() != null) {
Catalina.this.stop();
}
} catch (Throwable ex) {
...
}
}
}
```
从这段代码中你可以看到Tomcat的“关闭钩子”实际上就执行了Server的stop方法Server的stop方法会释放和清理所有的资源。
## Server组件
Server组件的具体实现类是StandardServer我们来看下StandardServer具体实现了哪些功能。Server继承了LifecycleBase它的生命周期被统一管理并且它的子组件是Service因此它还需要管理Service的生命周期也就是说在启动时调用Service组件的启动方法在停止时调用它们的停止方法。Server在内部维护了若干Service组件它是以数组来保存的那Server是如何添加一个Service到数组中的呢
```
@Override
public void addService(Service service) {
service.setServer(this);
synchronized (servicesLock) {
//创建一个长度+1的新数组
Service results[] = new Service[services.length + 1];
//将老的数据复制过去
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(&quot;service&quot;, null, service);
}
}
```
从上面的代码你能看到它并没有一开始就分配一个很长的数组而是在添加的过程中动态地扩展数组长度当添加一个新的Service实例时会创建一个新数组并把原来数组内容复制到新数组这样做的目的其实是为了节省内存空间。
除此之外Server组件还有一个重要的任务是启动一个Socket来监听停止端口这就是为什么你能通过shutdown命令来关闭Tomcat。不知道你留意到没有上面Catalina的启动方法的最后一行代码就是调用了Server的await方法。
在await方法里会创建一个Socket监听8005端口并在一个死循环里接收Socket上的连接请求如果有新的连接到来就建立连接然后从Socket中读取数据如果读到的数据是停止命令“SHUTDOWN”就退出循环进入stop流程。
## Service组件
Service组件的具体实现类是StandardService我们先来看看它的定义以及关键的成员变量。
```
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抽象类此外StandardService中还有一些我们熟悉的组件比如Server、Connector、Engine和Mapper。
那为什么还有一个MapperListener这是因为Tomcat支持热部署当Web应用的部署发生变化时Mapper中的映射信息也要跟着变化MapperListener就是一个监听器它监听容器的变化并把信息更新到Mapper中这是典型的观察者模式。
作为“管理”角色的组件最重要的是维护其他组件的生命周期。此外在启动各种组件时要注意它们的依赖关系也就是说要注意启动的顺序。我们来看看Service启动方法
```
protected void startInternal() throws LifecycleException {
//1. 触发启动监听器
setState(LifecycleState.STARTING);
//2. 先启动EngineEngine会启动它子容器
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在容器组件之后启动。组件停止的顺序跟启动顺序正好相反的也是基于它们的依赖关系。
## Engine组件
最后我们再来看看顶层的容器组件Engine具体是如何实现的。Engine本质是一个容器因此它继承了ContainerBase基类并且实现了Engine接口。
```
public class StandardEngine extends ContainerBase implements Engine {
}
```
我们知道Engine的子容器是Host所以它持有了一个Host容器的数组这些功能都被抽象到了ContainerBase中ContainerBase中有这样一个数据结构
```
protected final HashMap&lt;String, Container&gt; children = new HashMap&lt;&gt;();
```
ContainerBase用HashMap保存了它的子容器并且ContainerBase还实现了子容器的“增删改查”甚至连子组件的启动和停止都提供了默认实现比如ContainerBase会用专门的线程池来启动子容器。
```
for (int i = 0; i &lt; children.length; i++) {
results.add(startStopExecutor.submit(new StartChild(children[i])));
}
```
所以Engine在启动Host子容器时就直接重用了这个方法。
那Engine自己做了什么呢我们知道容器组件最重要的功能是处理请求而Engine容器对请求的“处理”其实就是把请求转发给某一个Host子容器来处理具体是通过Valve来实现的。
通过专栏前面的学习我们知道每一个容器组件都有一个Pipeline而Pipeline中有一个基础阀Basic Valve而Engine容器的基础阀定义如下
```
final class StandardEngineValve extends ValveBase {
public final void invoke(Request request, Response response)
throws IOException, ServletException {
//拿到请求中的Host容器
Host host = request.getHost();
if (host == null) {
return;
}
// 调用Host容器中的Pipeline中的第一个Valve
host.getPipeline().getFirst().invoke(request, response);
}
}
```
这个基础阀实现非常简单就是把请求转发到Host容器。你可能好奇从代码中可以看到处理请求的Host容器对象是从请求中拿到的请求对象中怎么会有Host容器呢这是因为请求到达Engine容器中之前Mapper组件已经对请求进行了路由处理Mapper组件通过请求的URL定位了相应的容器并且把容器对象保存到了请求对象中。
## 本期精华
今天我们学习了Tomcat启动过程具体是由启动类和“高层”组件来完成的它们都承担着“管理”的角色负责将子组件创建出来并把它们拼装在一起同时也掌握子组件的“生杀大权”。
所以当我们在设计这样的组件时,需要考虑两个方面:
首先要选用合适的数据结构来保存子组件比如Server用数组来保存Service组件并且采取动态扩容的方式这是因为数组结构简单占用内存小再比如ContainerBase用HashMap来保存子容器虽然Map占用内存会多一点但是可以通过Map来快速的查找子容器。因此在实际的工作中我们也需要根据具体的场景和需求来选用合适的数据结构。
其次还需要根据子组件依赖关系来决定它们的启动和停止顺序,以及如何优雅的停止,防止异常情况下的资源泄漏。这正是“管理者”应该考虑的事情。
## 课后思考
Server组件的在启动连接器和容器时都分别加了锁这是为什么呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="09 | 比较Jetty架构特点之Connector组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/aa/86daf3559544dcc83b1de4e4e4c650aa.mp3"></audio>
经过专栏前面几期的学习相信你对Tomcat的整体架构和工作原理有了基本了解。但是Servlet容器并非只有Tomcat一家还有别的架构设计思路吗今天我们就来看看Jetty的设计特点。
Jetty是Eclipse基金会的一个开源项目和Tomcat一样Jetty也是一个“HTTP服务器 + Servlet容器”并且Jetty和Tomcat在架构设计上有不少相似的地方。但同时Jetty也有自己的特点主要是更加小巧更易于定制化。Jetty作为一名后起之秀应用范围也越来越广比如Google App Engine就采用了Jetty来作为Web容器。Jetty和Tomcat各有特点所以今天我会和你重点聊聊Jetty在哪些地方跟Tomcat不同。通过比较它们的差异一方面希望可以继续加深你对Web容器架构设计的理解另一方面也让你更清楚它们的设计区别并根据它们的特点来选用这两款Web容器。
## 鸟瞰Jetty整体架构
简单来说Jetty Server就是由多个Connector连接器、多个Handler处理器以及一个线程池组成。整体结构请看下面这张图。
<img src="https://static001.geekbang.org/resource/image/95/b6/95b908af86695af107fd3877a02190b6.jpg" alt="">
跟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共享的。
<img src="https://static001.geekbang.org/resource/image/7b/f2/7b100cb1c7399a366157b97b77f042f2.jpg" alt="">
它们的第二个区别是在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回顾**
关于Java NIO编程如果你还不太熟悉可以先学习这一[系列文章](http://ifeve.com/java-nio-all/)。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&lt;SelectionKey&gt; 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 &lt; _acceptors.length; i++)
{
Acceptor a = new Acceptor(i);
getExecutor().execute(a);
}
```
Acceptor是ServerConnector中的一个内部类同时也是一个RunnableAcceptor线程是通过getExecutor得到的线程池来执行的前面提到这是一个全局的线程池。
Acceptor通过阻塞的方式来接受连接这一点跟Tomcat也是一样的。
```
public void accept(int acceptorID) throws IOException
{
ServerSocketChannel serverChannel = _acceptChannel;
if (serverChannel != null &amp;&amp; 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交给ManagedSelectorManagedSelector在处理这个任务主要做了两步
第一步调用Selector的register方法把Channel注册到Selector上拿到一个SelectionKey。
```
_key = _channel.register(selector, SelectionKey.OP_ACCEPT, this);
```
第二步创建一个EndPoint和Connection并跟这个SelectionKeyChannel绑在一起
```
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的工作流程。
<img src="https://static001.geekbang.org/resource/image/b5/83/b526a90be6ee4c3e45c94e122c9c1e83.jpg" alt="">
1.Acceptor监听连接请求当有连接请求到达时就接受连接一个连接对应一个ChannelAcceptor将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组件去处理。
## 本期精华
Jetty Server就是由多个Connector、多个Handler以及一个线程池组成在设计上简洁明了。
Jetty的Connector只支持NIO模型跟Tomcat的NioEndpoint组件一样它也是通过Java的NIO API实现的。我们知道Java NIO编程有三个关键组件Channel、Buffer和Selector而核心是Selector。为了方便使用Jetty在原生Selector组件的基础上做了一些封装实现了ManagedSelector组件。
在线程模型设计上Tomcat的NioEndpoint跟Jetty的Connector是相似的都是用一个Acceptor数组监听连接用一个Selector数组侦测I/O事件用一个线程池执行请求。它们的不同点在于Jetty使用了一个全局的线程池所有的线程资源都是从线程池来分配。
Jetty Connector设计中的一大特点是使用了回调函数来模拟异步I/O比如Connection向EndPoint注册了一堆回调函数。它的本质**将函数当作一个参数来传递**,告诉对方,你准备好了就调这个回调函数。
## 课后思考
Jetty的Connector主要完成了三件事件接收连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来做这三件事情。今天的思考题是为什么要把这些组件跑在不同的线程里呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,123 @@
<audio id="audio" title="10 | 比较Jetty架构特点之Handler组件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/88/7247d03fdc73836019655db81219ba88.mp3"></audio>
在专栏上一期我们学习了Jetty的整体架构。先来回顾一下Jetty 就是由多个Connector连接器、多个Handler处理器以及一个线程池组成整体结构图如下。
<img src="https://static001.geekbang.org/resource/image/66/6a/66e55e89fd621c0eba6321471da2016a.png" alt="">
上一期我们分析了Jetty Connector组件的设计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和ServletResponse两个参数。除此之外这个接口中还有setServer和getServer方法因为任何一个Handler都需要关联一个Server组件也就是说Handler需要被Server组件来管理。一般来说Handler会加载一些资源到内存因此通过设置destroy方法来销毁。
**Handler继承关系**
Handler只是一个接口完成具体功能的还是它的子类。那么Handler有哪些子类呢它们的继承关系又是怎样的这些子类是如何实现Servlet容器功能的呢
Jetty中定义了一些默认Handler类并且这些Handler类之间的继承关系比较复杂我们先通过一个全景图来了解一下。为了避免让你感到不适我对类图进行了简化。
<img src="https://static001.geekbang.org/resource/image/3a/64/3a7b3fbf16bb79594ec23620507c5c64.png" alt="">
从图上你可以看到Handler的种类和层次关系还是比较复杂的
Handler接口之下有抽象类AbstractHandler这一点并不意外因为有接口一般就有抽象实现类。
在AbstractHandler之下有AbstractHandlerContainer为什么需要这个类呢这其实是个过渡为了实现链式调用一个Handler内部必然要有其他Handler的引用所以这个类的名字里才有Container意思就是这样的Handler里包含了其他Handler的引用。
理解了上面的AbstractHandlerContainer我们就能理解它的两个子类了HandlerWrapper和HandlerCollection。简单来说就是HandlerWrapper和HandlerCollection都是Handler但是这些Handler里还包括其他Handler的引用。不同的是HandlerWrapper只包含一个其他Handler的引用而HandlerCollection中有一个Handler数组的引用。
<img src="https://static001.geekbang.org/resource/image/f8/c1/f89a1e88a78a7e6860d69af3572467c1.png" alt="">
接着来看左边的HandlerWrapper它有两个子类Server和ScopedHandler。Server比较好理解它本身是Handler模块的入口必然要将请求传递给其他Handler来处理为了触发其他Handler的调用所以它是一个HandlerWrapper。
再看ScopedHandler它也是一个比较重要的Handler实现了“具有上下文信息”的责任链调用。为什么我要强调“具有上下文信息”呢那是因为Servlet规范规定Servlet在执行过程中是有上下文的。那么这些Handler在执行过程中如何访问这个上下文呢这个上下文又存在什么地方呢答案就是通过ScopedHandler来实现的。
而ScopedHandler有一堆的子类这些子类就是用来实现Servlet规范的比如ServletHandler、ContextHandler、SessionHandler、ServletContextHandler和WebAppContext。接下来我会详细介绍它们但我们先把总体类图看完。
请看类图的右边跟HandlerWrapper对等的还有HandlerCollectionHandlerCollection其实维护了一个Handler数组。你可能会问为什么要发明一个这样的Handler这是因为Jetty可能需要同时支持多个Web应用如果每个Web应用有一个Handler入口那么多个Web应用的Handler就成了一个数组比如Server中就有一个HandlerCollectionServer会根据用户请求的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应用。
```
//新建一个WebAppContextWebAppContext是一个Handler
WebAppContext webapp = new WebAppContext();
webapp.setContextPath(&quot;/mywebapp&quot;);
webapp.setWar(&quot;mywebapp.war&quot;);
//将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来实现Servlet规范中规定的功能而**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。我们通过一张图来理解一下。
<img src="https://static001.geekbang.org/resource/image/5f/c1/5f1404567deec36ac68c36e44bb06cc1.jpg" alt="">
通过对比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 Server就是由多个Connector、多个Handler以及一个线程池组成。
Jetty的Handler设计是它的一大特色Jetty本质就是一个Handler管理器Jetty本身就提供了一些默认Handler来实现Servlet容器的功能你也可以定义自己的Handler来添加到Jetty中这体现了“**微内核 + 插件**”的设计思想。
## 课后思考
通过今天的学习我们知道各种Handler都会对请求做一些处理再将请求传给下一个Handler而Servlet也是用来处理请求的那Handler跟Servlet有什么区别呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,54 @@
<audio id="audio" title="11 | 总结从Tomcat和Jetty中提炼组件化设计规范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/42/6ea42a9860fc77edb204200e5fd49742.mp3"></audio>
在当今的互联网时代,我们每个人获取信息的机会基本上都是平等的,但是为什么有些人对信息理解得更深,并且有自己独到的见解呢?我认为是因为他们养成了思考和总结的好习惯。当我们学习一门技术的时候,如果可以勤于思考、善于总结,可以帮助我们看到现象背后更本质的东西,让我们在成长之路上更快“脱颖而出”。
我们经常谈敏捷、快速迭代和重构这些都是为了应对需求的快速变化也因此我们在开始设计一个系统时就要考虑可扩展性。那究竟该怎样设计才能适应变化呢或者要设计成什么样后面才能以最小的成本进行重构呢今天我来总结一些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的用户可以通过配置文件或者注解的方式来组装BeanBean与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的生命周期过程
<img src="https://static001.geekbang.org/resource/image/7f/3d/7f87b5f06cef33af6266ae7f6dcf203d.png" alt="">
## 组件的骨架抽象类和模板模式
具体到组件的设计的与实现Tomcat和Jetty都大量采用了骨架抽象类和模板模式。比如说Tomcat中ProtocolHandler接口ProtocolHandler有抽象基类AbstractProtocol它实现了协议处理层的骨架和通用逻辑而具体协议也有抽象基类比如HttpProtocol和AjpProtocol。对于Jetty来说Handler接口之下有AbstractHandlerConnector接口之下有AbstractConnector这些抽象骨架类实现了一些通用逻辑并且会定义一些抽象方法这些抽象方法由子类实现抽象骨架类调用抽象方法来实现骨架逻辑。
这是一个通用的设计规范不管是Web容器还是Spring甚至JDK本身都到处使用这种设计比如Java集合中的AbstractSet、AbstractMap等。 值得一提的是从Java 8开始允许接口有default方法这样我们可以把抽象骨架类的通用逻辑放到接口中去。
## 本期精华
今天我总结了Tomcat和Jetty的组件化设计我们可以通过搭积木的方式来定制化自己的Web容器。Web容器为了支持这种组件化设计遵循了一些规范比如面向接口编程用“管理者”去组装这些组件用反射的方式动态的创建组件、统一管理组件的生命周期并且给组件生命状态的变化提供了扩展点组件的具体实现一般遵循骨架抽象类和模板模式。
通过今天的学习你会发现Tomcat和Jetty有很多共同点并且Spring框架的设计也有不少相似的的地方这正好说明了Web开发中有一些本质的东西是相通的只要你深入理解了一个技术也就是在一个点上突破了深度再扩展广度就不是难事。并且我建议在学习一门技术的时候可以回想一下之前学过的东西是不是有相似的地方有什么不同的地方通过对比理解它们的本质这样我们才能真正掌握这些技术背后的精髓。
## 课后思考
在我们的实际项目中,可能经常遇到改变需求,那如果采用组件化设计,当需求更改时是不是会有一些帮助呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,124 @@
<audio id="audio" title="12 | 实战优化并提高Tomcat启动速度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/28/1cd234a8fa1e16e291ed04a55a944928.mp3"></audio>
到目前为止我们学习了Tomcat和Jetty的整体架构还知道了Tomcat是如何启动起来的今天我们来聊一个比较轻松的话题如何优化并提高Tomcat的启动速度。
我们在使用Tomcat时可能会碰到启动比较慢的问题比如我们的系统发布新版本上线时可能需要重启服务这个时候我们希望Tomcat能快速启动起来提供服务。其实关于如何让Tomcat启动变快官方网站有专门的[文章](https://wiki.apache.org/tomcat/HowTo/FasterStartUp)来介绍这个话题。下面我也针对Tomcat 8.5和9.0版本,给出几条非常明确的建议,可以现学现用。
## 清理你的Tomcat
**1. 清理不必要的Web应用**
首先我们要做的是删除掉webapps文件夹下不需要的工程一般是host-manager、example、doc等这些默认的工程可能还有以前添加的但现在用不着的工程最好把这些全都删除掉。如果你看过Tomcat的启动日志可以发现每次启动Tomcat都会重新布署这些工程。
**2. 清理XML配置文件**
我们知道Tomcat在启动的时候会解析所有的XML配置文件但XML解析的代价可不小因此我们要尽量保持配置文件的简洁需要解析的东西越少速度自然就会越快。
**3. 清理JAR文件**
我们还可以删除所有不需要的JAR文件。JVM的类加载器在加载类时需要查找每一个JAR文件去找到所需要的类。如果删除了不需要的JAR文件查找的速度就会快一些。这里请注意**Web应用中的lib目录下不应该出现Servlet API或者Tomcat自身的JAR**这些JAR由Tomcat负责提供。如果你是使用Maven来构建你的应用对Servlet API的依赖应该指定为`&lt;scope&gt;provided&lt;/scope&gt;`
**4. 清理其他文件**
及时清理日志删除logs文件夹下不需要的日志文件。同样还有work文件夹下的catalina文件夹它其实是Tomcat把JSP转换为Class文件的工作目录。有时候我们也许会遇到修改了代码重启了Tomcat但是仍没效果这时候便可以删除掉这个文件夹Tomcat下次启动的时候会重新生成。
## 禁止Tomcat TLD扫描
Tomcat为了支持JSP在应用启动的时候会扫描JAR包里面的TLD文件加载里面定义的标签库所以在Tomcat的启动日志里你可能会碰到这种提示
>
At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time.
Tomcat的意思是我扫描了你Web应用下的JAR包发现JAR包里没有TLD文件。我建议配置一下Tomcat不要去扫描这些JAR包这样可以提高Tomcat的启动速度并节省JSP编译时间。
那如何配置不去扫描这些JAR包呢这里分两种情况
- 如果你的项目没有使用JSP作为Web页面模板而是使用Velocity之类的模板引擎你完全可以把TLD扫描禁止掉。方法是找到Tomcat的`conf/`目录下的`context.xml`文件在这个文件里Context标签下加上**JarScanner**和**JarScanFilter**子标签,像下面这样。
<img src="https://static001.geekbang.org/resource/image/b9/6e/b9c09507c546c6ff349270cd992ff66e.jpg" alt="">
- 如果你的项目使用了JSP作为Web页面模块意味着TLD扫描无法避免但是我们可以通过配置来告诉Tomcat只扫描那些包含TLD文件的JAR包。方法是找到Tomcat的`conf/`目录下的`catalina.properties`文件在这个文件里的jarsToSkip配置项中加上你的JAR包。
```
tomcat.util.scan.StandardJarScanFilter.jarsToSkip=xxx.jar
```
## 关闭WebSocket支持
Tomcat会扫描WebSocket注解的API实现比如`@ServerEndpoint`注解的类。我们知道注解扫描一般是比较慢的如果不需要使用WebSocket就可以关闭它。具体方法是找到Tomcat的`conf/`目录下的`context.xml`文件给Context标签加一个**containerSciFilter**的属性,像下面这样。
<img src="https://static001.geekbang.org/resource/image/fb/fc/fb25c3f5e44521ec47046fafec11e0fc.jpg" alt="">
更进一步如果你不需要WebSocket这个功能你可以把Tomcat lib目录下的`websocket-api.jar``tomcat-websocket.jar`这两个JAR文件删除掉进一步提高性能。
## 关闭JSP支持
跟关闭WebSocket一样如果你不需要使用JSP可以通过类似方法关闭JSP功能像下面这样。
<img src="https://static001.geekbang.org/resource/image/0e/c4/0ef2d6f508babe62960909c6c881c4c4.jpg" alt="">
我们发现关闭JSP用的也是**containerSciFilter**属性如果你想把WebSocket和JSP都关闭那就这样配置
<img src="https://static001.geekbang.org/resource/image/eb/5d/ebd41a1156f28e92257f4c0130a8125d.jpg" alt="">
## 禁止Servlet注解扫描
Servlet 3.0引入了注解ServletTomcat为了支持这个特性会在Web应用启动时扫描你的类文件因此如果你没有使用Servlet注解这个功能可以告诉Tomcat不要去扫描Servlet注解。具体配置方法是在你的Web应用的`web.xml`文件中,设置`&lt;web-app&gt;`元素的属性`metadata-complete="true"`,像下面这样。
<img src="https://static001.geekbang.org/resource/image/9b/60/9b54b6eebfe23017f4e90e7a16c97760.jpg" alt="">
`metadata-complete`的意思是,`web.xml`里配置的Servlet是完整的不需要再去库类中找Servlet的定义。
## 配置Web-Fragment扫描
Servlet 3.0还引入了“Web模块部署描述符片段”的`web-fragment.xml`,这是一个部署描述文件,可以完成`web.xml`的配置功能。而这个`web-fragment.xml`文件必须存放在JAR文件的`META-INF`目录下而JAR包通常放在`WEB-INF/lib`目录下因此Tomcat需要对JAR文件进行扫描才能支持这个功能。
你可以通过配置`web.xml`里面的`&lt;absolute-ordering&gt;`元素直接指定了哪些JAR包需要扫描`web fragment`,如果`&lt;absolute-ordering/&gt;`元素是空的, 则表示不需要扫描,像下面这样。
<img src="https://static001.geekbang.org/resource/image/ff/8f/ff715ef5e61959bb8a17abd15681fc8f.jpg" alt="">
## 随机数熵源优化
这是一个比较有名的问题。Tomcat 7以上的版本依赖Java的SecureRandom类来生成随机数比如Session ID。而JVM 默认使用阻塞式熵源(`/dev/random` 在某些情况下就会导致Tomcat启动变慢。当阻塞时间较长时 你会看到这样一条警告日志:
>
<p>`&lt;DATE&gt;` org.apache.catalina.util.SessionIdGenerator createSecureRandom<br>
INFO: Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [8152] milliseconds.</p>
这其中的原理我就不展开了,你可以阅读[资料](https://stackoverflow.com/questions/28201794/slow-startup-on-tomcat-7-0-57-because-of-securerandom)获得更多信息。解决方案是通过设置让JVM使用非阻塞式的熵源。
我们可以设置JVM的参数
```
-Djava.security.egd=file:/dev/./urandom
```
或者是设置`java.security`文件,位于`$JAVA_HOME/jre/lib/security`目录之下: `securerandom.source=file:/dev/./urandom`
这里请你注意,`/dev/./urandom`中间有个`./`的原因是Oracle JRE中的BugJava 8里面的 SecureRandom类已经修正这个Bug。 阻塞式的熵源(`/dev/random`)安全性较高, 非阻塞式的熵源(`/dev/./urandom`)安全性会低一些,因为如果你对随机数的要求比较高, 可以考虑使用硬件方式生成熵源。
## 并行启动多个Web应用
Tomcat启动的时候默认情况下Web应用都是一个一个启动的等所有Web应用全部启动完成Tomcat才算启动完毕。如果在一个Tomcat下你有多个Web应用为了优化启动速度你可以配置多个应用程序并行启动可以通过修改`server.xml`中Host元素的startStopThreads属性来完成。startStopThreads的值表示你想用多少个线程来启动你的Web应用如果设成0表示你要并行启动Web应用像下面这样的配置。
<img src="https://static001.geekbang.org/resource/image/50/ed/50e0c10a38c5e4a5e7a28ca1885698ed.jpg" alt="">
这里需要注意的是Engine元素里也配置了这个参数这意味着如果你的Tomcat配置了多个Host虚拟主机Tomcat会以并行的方式启动多个Host。
## 本期精华
今天我讲了不少提高优化Tomcat启动速度的小贴士现在你就可以把它们用在项目中了。不管是在开发环境还是生产环境你都可以打开Tomcat的启动日志看看目前你们的应用启动需要多长时间然后尝试去调优再看看Tomcat的启动速度快了多少。
如果你是用嵌入式的方式运行Tomcat比如Spring Boot你也可以通过Spring Boot的方式去修改Tomcat的参数调优的原理都是一样的。
## 课后思考
在Tomcat启动速度优化上你都遇到了哪些问题或者你还有自己的“独门秘籍”欢迎把它们分享给我和其他同学。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,76 @@
<audio id="audio" title="13 | 热点问题答疑1如何学习源码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/a2/beea7e78875c4dd8e30fc1ee337e72a2.mp3"></audio>
不知道你有没有留意到,不少高端开发岗位在招聘要求里往往会写这么一条:研究过框架和中间件源码的优先考虑。这是因为一切秘密都藏在源码之中,阅读源码会让我们对框架或者中间件的理解更加深刻。有时候即使你阅读了大量原理性的文档,但如果不看源码,可能仍然会觉得还没有理解透。另外如果你能深入源码,招聘者从侧面也能感觉到你的学习热情和探索精神。
今天我们就来聊聊源码学习这个话题。对于Java后端开发来说有不少经典的开源框架和中间件下面我帮你按照后端的分层架构整理出来供你参考。
- **服务接入层**反向代理NginxAPI网关Node.js。
- **业务逻辑层**Web容器Tomcat、Jetty应用层框架Spring、Spring MVC和Spring BootORM框架MyBatis
- **数据缓存层**内存数据库Redis消息中间件Kafka。
- **数据存储层**关系型数据库MySQL非关系型数据库MongoDB文件存储HDFS搜索分析引擎Elasticsearch。
这其中每一层都要支持水平扩展和高可用比如业务层普遍采用微服务架构微服务之间需要互相调用于是就出现了RPC框架Spring Cloud和Dubbo。
除此之外还有两个非常重要的基础组件Netty和ZooKeeper其中Netty用于网络通信ZooKeeper用于分布式协调。其实很多中间件都用到了这两个基础组件并且ZooKeeper的网络通信模块也是通过Netty来实现的。
而这些框架或者中间件并不是凭空产生的,它们是在互联网的演化过程中,为了解决各种具体业务的痛点,一点一点积累进化而来的。很多时候我们把这些“零件”按照成熟的模式组装在一起,就能搭建出一个互联网后台系统。一般来说大厂都会对这些框架或者中间件进行改造,或者完全靠自己来实现。这就对后台程序员提出了更高的要求。
那这么多中间件和框架从哪里入手呢先学哪个后学哪个呢我觉得可以先学一些你熟悉的或者相对来说比较简单的树立起信心后再学复杂的。比如可以先学Tomcat、Jetty和Spring核心容器弄懂了这些以后再扩展到Spring的其他组件。
在这个过程中,我们就会积累一些通用的技术,比如网络编程、多线程、反射和类加载技术等,这些通用的技术在不少中间件和框架中会用到。
先说**网络通信**在分布式环境下信息要在各个实体之间流动到处都是网络通信的场景比如浏览器要将HTTP请求发给Web容器一个微服务要调用另一个微服务Web应用读写缓存服务器、消息队列或者数据库等都需要网络通信。
尽管网络通信的场景很多,但无外乎都要考虑这么几个问题:
- I/O模型同步还是异步是阻塞还是非阻塞
- 通信协议是二进制gRPC还是文本HTTP
- 数据怎么序列化是JSON还是Protocol Buffer
此外服务端的线程模型也是一个重点。我们知道多线程可以把要做的事情“并行化”提高并发度和吞吐量但是线程可能会阻塞一旦阻塞线程资源就闲置了并且会有线程上下文切换的开销浪费CPU资源。而有些任务执行会发生阻塞有些则不会阻塞因此线程模型就是要决定哪几件事情放到一个线程来做哪几件事情放到另一个线程来做并设置合理的线程数量目的就是要让CPU忙起来并且不是白忙活也就是不做无用功。
我们知道服务端处理一个网络连接的过程是:
accept、select、read、decode、process、encode、send。
一般来说服务端程序有几个角色Acceptor、Selector和Processor。
- Acceptor负责接收新连接也就是accept
- Selector负责检测连接上的I/O事件也就是select
- Processor负责数据读写、编解码和业务处理也就是read、decode、process、encode、send。
Acceptor在接收连接时可能会阻塞为了不耽误其他工作一般跑在单独的线程里而Selector在侦测I/O事件时也可能阻塞但是它一次可以检测多个Channel连接其实就是用阻塞它一个来换取大量业务线程的不阻塞那Selector检测I/O事件到了是用同一个线程来执行Processor还是另一个线程来执行呢不同的场景又有相应的策略。
比如Netty通过EventLoop将Selector和Processor跑在同一个线程。一个EventLoop绑定了一个线程并且持有一个Selector。而Processor的处理过程被封装成一个个任务一个EventLoop负责处理多个Channel上的所有任务而一个Channel只能由一个EventLoop来处理这就保证了任务执行的线程安全并且用同一个线程来侦测I/O事件和读写数据可以充分利用CPU缓存。我们通过一张图来理解一下
<img src="https://static001.geekbang.org/resource/image/f6/f4/f6741dc09985d5e08934ad77cd8c96f4.png" alt="">
请你注意这要求Processor中的任务能在短时间完成否则会阻塞这个EventLoop上其他Channel的处理。因此在Netty中可以设置业务处理和I/O处理的时间比率超过这个比率则将任务扔到专门的业务线程池来执行这一点跟Jetty的EatWhatYouKill线程策略有异曲同工之妙。
而Kafka把Selector和Processor跑在不同的线程里因为Kafka的业务逻辑大多涉及与磁盘读写处理时间不确定所以Kafka有专门的业务处理线程池来运行Processor。与此类似Tomcat也采用了这样的策略同样我们还是通过一张图来理解一下。
<img src="https://static001.geekbang.org/resource/image/74/e4/74f7742142740ae55bcda997a73a37e4.png" alt="">
我们再来看看**Java反射机制**几乎所有的框架都用到了反射和类加载技术这是为了保证框架的通用性需要根据配置文件在运行时加载不同的类并调用其方法。比如Web容器Tomcat和Jetty通过反射来加载Servlet、Filter和Listener而Spring的两大核心功能IOC和AOP都用到了反射技术再比如MyBatis将数据从数据库读出后也是通过反射机制来创建Java对象并设置对象的值。
因此你会发现通过学习一个中间件熟悉了这些通用的技术以后再学习其他的中间件或者框架就容易多了。比如学透了Tomcat的I/O线程模型以及高并发高性能设计思路再学Netty的源码就轻车熟路了Tomcat的组件化设计和类加载机制理解透彻了再学Spring容器的源码就会轻松很多。
接下来我再来聊聊具体如何学习源码,有很多同学在专栏里问这个问题,我在专栏的留言中也提到过,但我觉得有必要展开详细讲讲我是如何学习源码的。
学习的第一步首先我们要弄清楚中间件的核心功能是什么我以专栏所讲的Tomcat为例。Tomcat的核心功能是HTTP服务器和Servlet容器因此就抓住请求处理这条线通过什么样的方式接收连接接收到连接后以什么样的方式来读取数据读到数据后怎么解析数据HTTP协议请求数据解析出来后怎么调用Servlet容器Servlet容器又怎么调到Spring中的业务代码。
为了完成这些功能Tomcat中有一些起骨架作用的核心类其他类都是在这个骨架上进行扩展或补充细节来实现。因此在学习前期就要紧紧抓住这些类先不要深入到其他细节你可以先画出一张骨架类图。
<img src="https://static001.geekbang.org/resource/image/12/9b/12ad9ddc3ff73e0aacf2276bcfafae9b.png" alt="">
在此之后我们还需要将源码跑起来打打断点看看变量的值和调用栈。我建议用内嵌式的方式来启动和调试Tomcat体会一下Spring Boot是如何使用Tomcat的这里有[示例源码](https://github.com/heroku/devcenter-embedded-tomcat)。在源码阅读过程中要充分利用IDE的功能比如通过快捷键查找某个接口的所有实现类、查找某个类或者函数在哪些地方被用到。
我们还要带着问题去学习源码比如你想弄清楚Tomcat如何启停、类加载器是如何设计的、Spring Boot是如何启动Tomcat的、Jetty是如何通过Handler链实现高度定制化的如果要你来设计这些功能会怎么做呢带着这些问题去分析相关的源码效率会更高同时你在寻找答案的过程中也会碰到更多问题等你把这些问题都弄清楚了你获得的不仅仅是知识更重要的是你会树立起攻克难关的信心。同时我还建议在你弄清楚一些细节后要及时记录下来画画流程图或者类图再加上一些关键备注以防遗忘。
当然在这个过程中,你还可以看看产品的官方文档,熟悉一下大概的设计思路。在遇到难题时,你还可以看看网上的博客,参考一下别人的分析。但最终还是需要你自己去实践和摸索,因为网上的分析也不一定对,只有你自己看了源码后才能真正理解它,印象才更加深刻。
今天说了这么多,就是想告诉你如果理解透彻一两个中间件,有了一定的积累,这时再来学一个新的系统,往往你只需要瞧上几眼,就能明白它所用的架构,而且你会自然联想到系统存在哪些角色,以及角色之间的关系,包括静态的依赖关系和动态的协作关系,甚至你会不由自主带着审视的眼光,来发现一些可以改进的地方。如果你现在就是这样的状态,那么恭喜你,你的技术水平已经成长到一个新的层面了。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,168 @@
<audio id="audio" title="31 | Logger组件Tomcat的日志框架及实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/7b/06f1a6e8d3084c0929d5d807504b9c7b.mp3"></audio>
每一个系统都有一些通用的模块比如日志模块、异常处理模块、工具类等对于Tomcat来说比较重要的通用模块有日志、Session管理和集群管理。从今天开始我会分三期来介绍通用模块今天这一期先来讲日志模块。
日志模块作为一个通用的功能在系统里通常会使用第三方的日志框架。Java的日志框架有很多比如JULJava Util Logging、Log4j、Logback、Log4j2、Tinylog等。除此之外还有JCLApache Commons Logging和SLF4J这样的“门面日志”。下面是SLF4J与日志框架Logback、Log4j的关系图
<img src="https://static001.geekbang.org/resource/image/a2/2b/a263790cd53456273d50ab754063a72b.png" alt="">
我先来解释一下什么是“门面日志”。“门面日志”利用了设计模式中的门面模式思想对外提供一套通用的日志记录的API而不提供具体的日志输出服务如果要实现日志输出需要集成其他的日志框架比如Log4j、Logback、Log4j2等。
这种门面模式的好处在于记录日志的API和日志输出的服务分离开代码里面只需要关注记录日志的API通过SLF4J指定的接口记录日志而日志输出通过引入JAR包的方式即可指定其他的日志框架。当我们需要改变系统的日志输出服务时不用修改代码只需要改变引入日志输出框架JAR包。
今天我们就来看看Tomcat的日志模块是如何实现的。默认情况下Tomcat使用自身的JULI作为Tomcat内部的日志处理系统。JULI的日志门面采用了JCL而JULI的具体实现是构建在Java原生的日志系统`java.util.logging`之上的所以在看JULI的日志系统之前我先简单介绍一下Java的日志系统。
## Java日志系统
Java的日志包在`java.util.logging`路径下,包含了几个比较重要的组件,我们通过一张图来理解一下:
<img src="https://static001.geekbang.org/resource/image/5a/1d/5aae1574eb4e0c33da06484011bb121d.png" alt="">
从图上我们看到这样几个重要的组件:
- Logger用来记录日志的类。
- Handler规定了日志的输出方式如控制台输出、写入文件。
- Level定义了日志的不同等级。
- Formatter将日志信息格式化比如纯文本、XML。
我们可以通过下面的代码来使用这些组件:
```
public static void main(String[] args) {
Logger logger = Logger.getLogger(&quot;com.mycompany.myapp&quot;);
logger.setLevel(Level.FINE);
logger.setUseParentHandlers(false);
Handler hd = new ConsoleHandler();
hd.setLevel(Level.FINE);
logger.addHandler(hd);
logger.info(&quot;start log&quot;);
}
```
## JULI
JULI对日志的处理方式与Java自带的基本一致但是Tomcat中可以包含多个应用而每个应用的日志系统应该相互独立。Java的原生日志系统是每个JVM有一份日志的配置文件这不符合Tomcat多应用的场景所以JULI重新实现了一些日志接口。
**DirectJDKLog**
Log的基础实现类是DirectJDKLog这个类相对简单就包装了一下Java的Logger类。但是它也在原来的基础上进行了一些修改比如修改默认的格式化方式。
**LogFactory**
Log使用了工厂模式来向外提供实例LogFactory是一个单例可以通过SeviceLoader为Log提供自定义的实现版本如果没有配置就默认使用DirectJDKLog。
```
private LogFactory() {
// 通过ServiceLoader尝试加载Log的实现类
ServiceLoader&lt;Log&gt; logLoader = ServiceLoader.load(Log.class);
Constructor&lt;? extends Log&gt; m=null;
for (Log log: logLoader) {
Class&lt;? extends Log&gt; c=log.getClass();
try {
m=c.getConstructor(String.class);
break;
}
catch (NoSuchMethodException | SecurityException e) {
throw new Error(e);
}
}
//如何没有定义Log的实现类discoveredLogConstructor为null
discoveredLogConstructor = m;
}
```
下面的代码是LogFactory的getInstance方法
```
public Log getInstance(String name) throws LogConfigurationException {
//如果discoveredLogConstructor为null也就没有定义Log类默认用DirectJDKLog
if (discoveredLogConstructor == null) {
return DirectJDKLog.getInstance(name);
}
try {
return discoveredLogConstructor.newInstance(name);
} catch (ReflectiveOperationException | IllegalArgumentException e) {
throw new LogConfigurationException(e);
}
}
```
**Handler**
在JULI中就自定义了两个HandlerFileHandler和AsyncFileHandler。FileHandler可以简单地理解为一个在特定位置写文件的工具类有一些写操作常用的方法如open、write(publish)、close、flush等使用了读写锁。其中的日志信息通过Formatter来格式化。
AsyncFileHandler继承自FileHandler实现了异步的写操作。其中缓存存储是通过阻塞双端队列LinkedBlockingDeque来实现的。当应用要通过这个Handler来记录一条消息时消息会先被存储到队列中而在后台会有一个专门的线程来处理队列中的消息取出的消息会通过父类的publish方法写入相应文件内。这样就可以在大量日志需要写入的时候起到缓冲作用防止都阻塞在写日志这个动作上。需要注意的是我们可以为阻塞双端队列设置不同的模式在不同模式下对新进入的消息有不同的处理方式有些模式下会直接丢弃一些日志
```
OVERFLOW_DROP_LAST丢弃栈顶的元素
OVERFLOW_DROP_FIRSH丢弃栈底的元素
OVERFLOW_DROP_FLUSH等待一定时间并重试不会丢失元素
OVERFLOW_DROP_CURRENT丢弃放入的元素
```
**Formatter**
Formatter通过一个format方法将日志记录LogRecord转化成格式化的字符串JULI提供了三个新的Formatter。
- OnlineFormatter基本与Java自带的SimpleFormatter格式相同不过把所有内容都写到了一行中。
- VerbatimFormatter只记录了日志信息没有任何额外的信息。
- JdkLoggerFormatter格式化了一个轻量级的日志信息。
**日志配置**
Tomcat的日志配置文件为Tomcat文件夹下`conf/logging.properties`。我来拆解一下这个配置文件首先可以看到各种Handler的配置
```
handlers = 1catalina.org.apache.juli.AsyncFileHandler, 2localhost.org.apache.juli.AsyncFileHandler, 3manager.org.apache.juli.AsyncFileHandler, 4host-manager.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
.handlers = 1catalina.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
```
`1catalina.org.apache.juli.AsyncFileHandler`为例数字是为了区分同一个类的不同实例catalina、localhost、manager和host-manager是Tomcat用来区分不同系统日志的标志后面的字符串表示了Handler具体类型如果要添加Tomcat服务器的自定义Handler需要在字符串里添加。
接下来是每个Handler设置日志等级、目录和文件前缀自定义的Handler也要在这里配置详细信息:
```
1catalina.org.apache.juli.AsyncFileHandler.level = FINE
1catalina.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs
1catalina.org.apache.juli.AsyncFileHandler.prefix = catalina.
1catalina.org.apache.juli.AsyncFileHandler.maxDays = 90
1catalina.org.apache.juli.AsyncFileHandler.encoding = UTF-8
```
## Tomcat + SLF4J + Logback
在今天文章开头我提到SLF4J和JCL都是日志门面那它们有什么区别呢它们的区别主要体现在日志服务类的绑定机制上。JCL采用运行时动态绑定的机制在运行时动态寻找和加载日志框架实现。
SLF4J日志输出服务绑定则相对简单很多在编译时就静态绑定日志框架只需要提前引入需要的日志框架。另外Logback可以说Log4j的进化版在性能和可用性方面都有所提升。你可以参考官网上这篇[文章](https://logback.qos.ch/reasonsToSwitch.html)来了解Logback的优势。
基于此我们来实战一下如何将Tomcat默认的日志框架切换成为“SLF4J + Logback”。具体的步骤是
1.根据你的Tomcat版本从[这里](https://github.com/tomcat-slf4j-logback/tomcat-slf4j-logback/releases)下载所需要文件。解压后你会看到一个类似于Tomcat目录结构的文件夹。<br>
2.替换或拷贝下列这些文件到Tomcat的安装目录
<img src="https://static001.geekbang.org/resource/image/09/17/09c024a49b055a86c961ea4a3beb6717.jpg" alt="">
3.删除`&lt;Tomcat&gt;/conf/logging.properties`<br>
4.启动Tomcat
## 本期精华
今天我们谈了日志框架与日志门面的区别以及Tomcat的日志模块是如何实现的。默认情况下Tomcat的日志模板叫作JULIJULI的日志门面采用了JCL而具体实现是基于Java默认的日志框架Java Util LoggingTomcat在Java Util Logging基础上进行了改造使得它自身的日志框架不会影响Web应用并且可以分模板配置日志的输出文件和格式。最后我分享了如何将Tomcat的日志模块切换到时下流行的“SLF4J + Logback”希望对你有所帮助。
## 课后思考
Tomcat独立部署时各种日志都输出到了相应的日志文件假如Spring Boot以内嵌式的方式运行Tomcat这种情况下Tomcat的日志都输出到哪里去了
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,299 @@
<audio id="audio" title="32 | Manager组件Tomcat的Session管理机制解析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/7e/80ea367a5d5d2b6f99bac315a3d1147e.mp3"></audio>
我们可以通过Request对象的getSession方法来获取Session并通过Session对象来读取和写入属性值。而Session的管理是由Web容器来完成的主要是对Session的创建和销毁除此之外Web容器还需要将Session状态的变化通知给监听者。
当然Session管理还可以交给Spring来做好处是与特定的Web容器解耦Spring Session的核心原理是通过Filter拦截Servlet请求将标准的ServletRequest包装一下换成Spring的Request对象这样当我们调用Request对象的getSession方法时Spring在背后为我们创建和管理Session。
那么Tomcat的Session管理机制我们还需要了解吗我觉得还是有必要因为只有了解这些原理我们才能更好的理解Spring Session以及Spring Session为什么设计成这样。今天我们就从Session的创建、Session的清理以及Session的事件通知这几个方面来了解Tomcat的Session管理机制。
## Session的创建
Tomcat中主要由每个Context容器内的一个Manager对象来管理Session。默认实现类为StandardManager。下面我们通过它的接口来了解一下StandardManager的功能
```
public interface Manager {
public Context getContext();
public void setContext(Context context);
public SessionIdGenerator getSessionIdGenerator();
public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator);
public long getSessionCounter();
public void setSessionCounter(long sessionCounter);
public int getMaxActive();
public void setMaxActive(int maxActive);
public int getActiveSessions();
public long getExpiredSessions();
public void setExpiredSessions(long expiredSessions);
public int getRejectedSessions();
public int getSessionMaxAliveTime();
public void setSessionMaxAliveTime(int sessionMaxAliveTime);
public int getSessionAverageAliveTime();
public int getSessionCreateRate();
public int getSessionExpireRate();
public void add(Session session);
public void changeSessionId(Session session);
public void changeSessionId(Session session, String newId);
public Session createEmptySession();
public Session createSession(String sessionId);
public Session findSession(String id) throws IOException;
public Session[] findSessions();
public void load() throws ClassNotFoundException, IOException;
public void remove(Session session);
public void remove(Session session, boolean update);
public void addPropertyChangeListener(PropertyChangeListener listener)
public void removePropertyChangeListener(PropertyChangeListener listener);
public void unload() throws IOException;
public void backgroundProcess();
public boolean willAttributeDistribute(String name, Object value);
}
```
不出意外我们在接口中看到了添加和删除Session的方法另外还有load和unload方法它们的作用是分别是将Session持久化到存储介质和从存储介质加载Session。
当我们调用`HttpServletRequest.getSession(true)`这个参数true的意思是“如果当前请求还没有Session就创建一个新的”。那Tomcat在背后为我们做了些什么呢
HttpServletRequest是一个接口Tomcat实现了这个接口具体实现类是`org.apache.catalina.connector.Request`
但这并不是我们拿到的RequestTomcat为了避免把一些实现细节暴露出来还有基于安全上的考虑定义了Request的包装类叫作RequestFacade我们可以通过代码来理解一下
```
public class Request implements HttpServletRequest {}
```
```
public class RequestFacade implements HttpServletRequest {
protected Request request = null;
public HttpSession getSession(boolean create) {
return request.getSession(create);
}
}
```
因此我们拿到的Request类其实是RequestFacadeRequestFacade的getSession方法调用的是Request类的getSession方法我们继续来看Session具体是如何创建的
```
Context context = getContext();
if (context == null) {
return null;
}
Manager manager = context.getManager();
if (manager == null) {
return null;
}
session = manager.createSession(sessionId);
session.access();
```
从上面的代码可以看出Request对象中持有Context容器对象而Context容器持有Session管理器Manager这样通过Context组件就能拿到Manager组件最后由Manager组件来创建Session。
因此最后还是到了StandardManagerStandardManager的父类叫ManagerBase这个createSession方法定义在ManagerBase中StandardManager直接重用这个方法。
接着我们来看ManagerBase的createSession是如何实现的
```
@Override
public Session createSession(String sessionId) {
//首先判断Session数量是不是到了最大值最大Session数可以通过参数设置
if ((maxActiveSessions &gt;= 0) &amp;&amp;
(getActiveSessions() &gt;= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(
sm.getString(&quot;managerBase.createSession.ise&quot;),
maxActiveSessions);
}
// 重用或者创建一个新的Session对象请注意在Tomcat中就是StandardSession
// 它是HttpSession的具体实现类而HttpSession是Servlet规范中定义的接口
Session session = createEmptySession();
// 初始化新Session的值
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
session.setId(id);// 这里会将Session添加到ConcurrentHashMap中
sessionCounter++;
//将创建时间添加到LinkedList中并且把最先添加的时间移除
//主要还是方便清理过期Session
SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return session
}
```
到此我们明白了Session是如何创建出来的创建出来后Session会被保存到一个ConcurrentHashMap中
```
protected Map&lt;String, Session&gt; sessions = new ConcurrentHashMap&lt;&gt;();
```
请注意Session的具体实现类是StandardSessionStandardSession同时实现了`javax.servlet.http.HttpSession``org.apache.catalina.Session`接口并且对程序员暴露的是StandardSessionFacade外观类保证了StandardSession的安全避免了程序员调用其内部方法进行不当操作。StandardSession的核心成员变量如下
```
public class StandardSession implements HttpSession, Session, Serializable {
protected ConcurrentMap&lt;String, Object&gt; attributes = new ConcurrentHashMap&lt;&gt;();
protected long creationTime = 0L;
protected transient volatile boolean expiring = false;
protected transient StandardSessionFacade facade = null;
protected String id = null;
protected volatile long lastAccessedTime = creationTime;
protected transient ArrayList&lt;SessionListener&gt; listeners = new ArrayList&lt;&gt;();
protected transient Manager manager = null;
protected volatile int maxInactiveInterval = -1;
protected volatile boolean isNew = false;
protected volatile boolean isValid = false;
protected transient Map&lt;String, Object&gt; notes = new Hashtable&lt;&gt;();
protected transient Principal principal = null;
}
```
## Session的清理
我们再来看看Tomcat是如何清理过期的Session。在Tomcat[热加载和热部署](https://time.geekbang.org/column/article/104423)的文章里我讲到容器组件会开启一个ContainerBackgroundProcessor后台线程调用自己以及子容器的backgroundProcess进行一些后台逻辑的处理和Lifecycle一样这个动作也是具有传递性的也就是说子容器还会把这个动作传递给自己的子容器。你可以参考下图来理解这个过程。
<img src="https://static001.geekbang.org/resource/image/3b/eb/3b2dfa635469c0fe7e3a17e2517c53eb.jpg" alt="">
其中父容器会遍历所有的子容器并调用其backgroundProcess方法而StandardContext重写了该方法它会调用StandardManager的backgroundProcess进而完成Session的清理工作下面是StandardManager的backgroundProcess方法的代码
```
public void backgroundProcess() {
// processExpiresFrequency 默认值为6而backgroundProcess默认每隔10s调用一次也就是说除了任务执行的耗时每隔 60s 执行一次
count = (count + 1) % processExpiresFrequency;
if (count == 0) // 默认每隔 60s 执行一次 Session 清理
processExpires();
}
/**
* 单线程处理,不存在线程安全问题
*/
public void processExpires() {
// 获取所有的 Session
Session sessions[] = findSessions();
int expireHere = 0 ;
for (int i = 0; i &lt; sessions.length; i++) {
// Session 的过期是在isValid()方法里处理的
if (sessions[i]!=null &amp;&amp; !sessions[i].isValid()) {
expireHere++;
}
}
}
```
backgroundProcess由Tomcat后台线程调用默认是每隔10秒调用一次但是Session的清理动作不能太频繁因为需要遍历Session列表会耗费CPU资源所以在backgroundProcess方法中做了取模处理backgroundProcess调用6次才执行一次Session清理也就是说Session清理每60秒执行一次。
## Session事件通知
按照Servlet规范在Session的生命周期过程中要将事件通知监听者Servlet规范定义了Session的监听器接口
```
public interface HttpSessionListener extends EventListener {
//Session创建时调用
public default void sessionCreated(HttpSessionEvent se) {
}
//Session销毁时调用
public default void sessionDestroyed(HttpSessionEvent se) {
}
}
```
注意到这两个方法的参数都是HttpSessionEvent所以Tomcat需要先创建HttpSessionEvent对象然后遍历Context内部的LifecycleListener并且判断是否为HttpSessionListener实例如果是的话则调用HttpSessionListener的sessionCreated方法进行事件通知。这些事情都是在Session的setId方法中完成的
```
session.setId(id);
@Override
public void setId(String id, boolean notify) {
//如果这个id已经存在先从Manager中删除
if ((this.id != null) &amp;&amp; (manager != null))
manager.remove(this);
this.id = id;
//添加新的Session
if (manager != null)
manager.add(this);
//这里面完成了HttpSessionListener事件通知
if (notify) {
tellNew();
}
}
```
从代码我们看到setId方法调用了tellNew方法那tellNew又是如何实现的呢
```
public void tellNew() {
// 通知org.apache.catalina.SessionListener
fireSessionEvent(Session.SESSION_CREATED_EVENT, null);
// 获取Context内部的LifecycleListener并判断是否为HttpSessionListener
Context context = manager.getContext();
Object listeners[] = context.getApplicationLifecycleListeners();
if (listeners != null &amp;&amp; listeners.length &gt; 0) {
//创建HttpSessionEvent
HttpSessionEvent event = new HttpSessionEvent(getSession());
for (int i = 0; i &lt; listeners.length; i++) {
//判断是否是HttpSessionListener
if (!(listeners[i] instanceof HttpSessionListener))
continue;
HttpSessionListener listener = (HttpSessionListener) listeners[i];
//注意这是容器内部事件
context.fireContainerEvent(&quot;beforeSessionCreated&quot;, listener);
//触发Session Created 事件
listener.sessionCreated(event);
//注意这也是容器内部事件
context.fireContainerEvent(&quot;afterSessionCreated&quot;, listener);
}
}
}
```
上面代码的逻辑是先通过StandardContext将HttpSessionListener类型的Listener取出然后依次调用它们的sessionCreated方法。
## 本期精华
今天我们从Request谈到了Session的创建、销毁和事件通知里面涉及不少相关的类下面我画了一张图帮你理解和消化一下这些类的关系
<img src="https://static001.geekbang.org/resource/image/11/cf/11493762a465c27152dbb4aa4b563ecf.jpg" alt="">
Servlet规范中定义了HttpServletRequest和HttpSession接口Tomcat实现了这些接口但具体实现细节并没有暴露给开发者因此定义了两个包装类RequestFacade和StandardSessionFacade。
Tomcat是通过Manager来管理Session的默认实现是StandardManager。StandardContext持有StandardManager的实例并存放了HttpSessionListener集合Session在创建和销毁时会通知监听器。
## 课后思考
TCP连接的过期时间和Session的过期时间有什么区别
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="33 | Cluster组件Tomcat的集群通信原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/ad/4cc3977b530ed4e0f67c38e639cc38ad.mp3"></audio>
为了支持水平扩展和高可用Tomcat提供了集群部署的能力但与此同时也带来了分布式系统的一个通用问题那就是如何在集群中的多个节点之间保持数据的一致性比如会话Session信息。
要实现这一点基本上有两种方式一种是把所有Session数据放到一台服务器或者一个数据库中集群中的所有节点通过访问这台Session服务器来获取数据。另一种方式就是在集群中的节点间进行Session数据的同步拷贝这里又分为两种策略第一种是将一个节点的Session拷贝到集群中其他所有节点第二种是只将一个节点上的Session数据拷贝到另一个备份节点。
对于Tomcat的Session管理来说这两种方式都支持。今天我们就来看看第二种方式的实现原理也就是Tomcat集群通信的原理和配置方法最后通过官网上的一个例子来了解下Tomcat集群到底是如何工作的。
## 集群通信原理
要实现集群通信首先要知道集群中都有哪些成员。Tomcat是通过**组播**Multicast来实现的。那什么是组播呢为了理解组播我先来说说什么是“单播”。网络节点之间的通信就好像是人们之间的对话一样一个人对另外一个人说话此时信息的接收和传递只在两个节点之间进行比如你在收发电子邮件、浏览网页时使用的就是单播也就是我们熟悉的“点对点通信”。
如果一台主机需要将同一个消息发送多个主机逐个传输,效率就会比较低,于是就出现组播技术。组播是**一台主机向指定的一组主机发送数据报包**组播通信的过程是这样的每一个Tomcat节点在启动时和运行时都会周期性默认500毫秒发送组播心跳包同一个集群内的节点都在相同的**组播地址**和**端口**监听这些信息在一定的时间内默认3秒不发送**组播报文**的节点就会被认为已经崩溃了,会从集群中删去。因此通过组播,集群中每个成员都能维护一个集群成员列表。
## 集群通信配置
有了集群成员的列表集群中的节点就能通过TCP连接向其他节点传输Session数据。Tomcat通过SimpleTcpCluster类来进行会话复制In-Memory Replication。要开启集群功能只需要将`server.xml`里的这一行的注释去掉就行:
<img src="https://static001.geekbang.org/resource/image/45/89/45760766a99cad8a1e7001beae6d5589.png" alt="">
变成这样:
<img src="https://static001.geekbang.org/resource/image/99/bb/99d7bd2cebfd19dfd4d702351a4450bb.png" alt="">
虽然只是简单的一行配置但这一行配置等同于下面这样的配置也就是说Tomcat给我们设置了很多默认参数这些参数都跟集群通信有关。
```
&lt;!--
SimpleTcpCluster是用来复制Session的组件。复制Session有同步和异步两种方式
同步模式下向浏览器的发送响应数据前需要先将Session拷贝到其他节点完
异步模式下无需等待Session拷贝完成就可响应。异步模式更高效但是同步模式
可靠性更高。
同步异步模式由channelSendOptions参数控制默认值是8为异步模式4是同步模式。
在异步模式下,可以通过加上&quot;拷贝确认&quot;Acknowledge来提高可靠性此时
channelSendOptions设为10
--&gt;
&lt;Cluster className=&quot;org.apache.catalina.ha.tcp.SimpleTcpCluster&quot;
channelSendOptions=&quot;8&quot;&gt;
&lt;!--
Manager决定如何管理集群的Session信息。
Tomcat提供了两种ManagerBackupManager和DeltaManager。
BackupManager集群下的某一节点的Session将复制到一个备份节点。
DeltaManager 集群下某一节点的Session将复制到所有其他节点。
DeltaManager是Tomcat默认的集群Manager。
expireSessionsOnShutdown设置为true时一个节点关闭时
将导致集群下的所有Session失效
notifyListenersOnReplication集群下节点间的Session复制、
删除操作是否通知session listeners
maxInactiveInterval集群下Session的有效时间(单位:s)。
maxInactiveInterval内未活动的Session将被Tomcat回收。
默认值为1800(30min)
--&gt;
&lt;Manager className=&quot;org.apache.catalina.ha.session.DeltaManager&quot;
expireSessionsOnShutdown=&quot;false&quot;
notifyListenersOnReplication=&quot;true&quot;/&gt;
&lt;!--
Channel是Tomcat节点之间进行通讯的工具。
Channel包括5个组件Membership、Receiver、Sender、
Transport、Interceptor
--&gt;
&lt;Channel className=&quot;org.apache.catalina.tribes.group.GroupChannel&quot;&gt;
&lt;!--
Membership维护集群的可用节点列表。它可以检查到新增的节点
也可以检查没有心跳的节点
className指定Membership使用的类
address组播地址
port组播端口
frequency发送心跳(向组播地址发送UDP数据包)的时间间隔(单位:ms)。
dropTimeMembership在dropTime(单位:ms)内未收到某一节点的心跳,
则将该节点从可用节点列表删除。默认值为3000。
--&gt;
&lt;Membership className=&quot;org.apache.catalina.tribes.membership.
McastService&quot;
address=&quot;228.0.0.4&quot;
port=&quot;45564&quot;
frequency=&quot;500&quot;
dropTime=&quot;3000&quot;/&gt;
&lt;!--
Receiver用于各个节点接收其他节点发送的数据。
接收器分为两种BioReceiver(阻塞式)、NioReceiver(非阻塞式)
className指定Receiver使用的类
address接收消息的地址
port接收消息的端口
autoBind端口的变化区间如果port为4000autoBind为100
接收器将在4000-4099间取一个端口进行监听。
selectorTimeoutNioReceiver内Selector轮询的超时时间
maxThreads线程池的最大线程数
--&gt;
&lt;Receiver className=&quot;org.apache.catalina.tribes.transport.nio.
NioReceiver&quot;
address=&quot;auto&quot;
port=&quot;4000&quot;
autoBind=&quot;100&quot;
selectorTimeout=&quot;5000&quot;
maxThreads=&quot;6&quot;/&gt;
&lt;!--
Sender用于向其他节点发送数据Sender内嵌了Transport组件
Transport真正负责发送消息。
--&gt;
&lt;Sender className=&quot;org.apache.catalina.tribes.transport.
ReplicationTransmitter&quot;&gt;
&lt;!--
Transport分为两种bio.PooledMultiSender(阻塞式)
和nio.PooledParallelSender(非阻塞式)PooledParallelSender
是从tcp连接池中获取连接可以实现并行发送即集群中的节点可以
同时向其他所有节点发送数据而互不影响。
--&gt;
&lt;Transport className=&quot;org.apache.catalina.tribes.
transport.nio.PooledParallelSender&quot;/&gt;
&lt;/Sender&gt;
&lt;!--
Interceptor : Cluster的拦截器
TcpFailureDetectorTcpFailureDetector可以拦截到某个节点关闭
的信息并尝试通过TCP连接到此节点以确保此节点真正关闭从而更新集
群可用节点列表
--&gt;
&lt;Interceptor className=&quot;org.apache.catalina.tribes.group.
interceptors.TcpFailureDetector&quot;/&gt;
&lt;!--
MessageDispatchInterceptor查看Cluster组件发送消息的
方式是否设置为Channel.SEND_OPTIONS_ASYNCHRONOUS如果是
MessageDispatchInterceptor先将等待发送的消息进行排队
然后将排好队的消息转给Sender。
--&gt;
&lt;Interceptor className=&quot;org.apache.catalina.tribes.group.
interceptors.MessageDispatchInterceptor&quot;/&gt;
&lt;/Channel&gt;
&lt;!--
Valve : Tomcat的拦截器
ReplicationValve在处理请求前后打日志过滤不涉及Session变化的请求。
--&gt;
&lt;Valve className=&quot;org.apache.catalina.ha.tcp.ReplicationValve&quot;
filter=&quot;&quot;/&gt;
&lt;Valve className=&quot;org.apache.catalina.ha.session.
JvmRouteBinderValve&quot;/&gt;
&lt;!--
Deployer用于集群的farm功能监控应用中文件的更新以保证集群中所有节点
应用的一致性如某个用户上传文件到集群中某个节点的应用程序目录下Deployer
会监测到这一操作并把文件拷贝到集群中其他节点相同应用的对应目录下以保持
所有应用的一致,这是一个相当强大的功能。
--&gt;
&lt;Deployer className=&quot;org.apache.catalina.ha.deploy.FarmWarDeployer&quot;
tempDir=&quot;/tmp/war-temp/&quot;
deployDir=&quot;/tmp/war-deploy/&quot;
watchDir=&quot;/tmp/war-listen/&quot;
watchEnabled=&quot;false&quot;/&gt;
&lt;!--
ClusterListener : 监听器监听Cluster组件接收的消息
使用DeltaManager时Cluster接收的信息通过ClusterSessionListener
传递给DeltaManager从而更新自己的Session列表。
--&gt;
&lt;ClusterListener className=&quot;org.apache.catalina.ha.session.
ClusterSessionListener&quot;/&gt;
&lt;/Cluster&gt;
```
从上面的的参数列表可以看到默认情况下Session管理组件DeltaManager会在节点之间拷贝SessionDeltaManager采用的一种all-to-all的工作方式即集群中的节点会把Session数据向所有其他节点拷贝而不管其他节点是否部署了当前应用。当集群节点数比较少时比如少于4个这种all-to-all的方式是不错的选择但是当集群中的节点数量比较多时数据拷贝的开销成指数级增长这种情况下可以考虑BackupManagerBackupManager只向一个备份节点拷贝数据。
在大体了解了Tomcat集群实现模型后就可以对集群作出更优化的配置了。Tomcat推荐了一套配置使用了比DeltaManager更高效的BackupManager并且通过ReplicationValve设置了请求过滤。
这里还请注意在一台服务器部署多个节点时需要修改Receiver的侦听端口另外为了在节点间高效地拷贝数据所有Tomcat节点最好采用相同的配置具体配置如下
```
&lt;Cluster className=&quot;org.apache.catalina.ha.tcp.SimpleTcpCluster&quot;
channelSendOptions=&quot;6&quot;&gt;
&lt;Manager className=&quot;org.apache.catalina.ha.session.BackupManager&quot;
expireSessionsOnShutdown=&quot;false&quot;
notifyListenersOnReplication=&quot;true&quot;
mapSendOptions=&quot;6&quot;/&gt;
&lt;Channel className=&quot;org.apache.catalina.tribes.group.
GroupChannel&quot;&gt;
&lt;Membership className=&quot;org.apache.catalina.tribes.membership.
McastService&quot;
address=&quot;228.0.0.4&quot;
port=&quot;45564&quot;
frequency=&quot;500&quot;
dropTime=&quot;3000&quot;/&gt;
&lt;Receiver className=&quot;org.apache.catalina.tribes.transport.nio.
NioReceiver&quot;
address=&quot;auto&quot;
port=&quot;5000&quot;
selectorTimeout=&quot;100&quot;
maxThreads=&quot;6&quot;/&gt;
&lt;Sender className=&quot;org.apache.catalina.tribes.transport.
ReplicationTransmitter&quot;&gt;
&lt;Transport className=&quot;org.apache.catalina.tribes.transport.
nio.PooledParallelSender&quot;/&gt;
&lt;/Sender&gt;
&lt;Interceptor className=&quot;org.apache.catalina.tribes.group.
interceptors.TcpFailureDetector&quot;/&gt;
&lt;Interceptor className=&quot;org.apache.catalina.tribes.group.
interceptors.MessageDispatchInterceptor&quot;/&gt;
&lt;Interceptor className=&quot;org.apache.catalina.tribes.group.
interceptors.ThroughputInterceptor&quot;/&gt;
&lt;/Channel&gt;
&lt;Valve className=&quot;org.apache.catalina.ha.tcp.ReplicationValve&quot;
filter=&quot;.*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\
.htm|.*\.html|.*\.css|.*\.txt&quot;/&gt;
&lt;Deployer className=&quot;org.apache.catalina.ha.deploy.FarmWarDeployer&quot;
tempDir=&quot;/tmp/war-temp/&quot;
deployDir=&quot;/tmp/war-deploy/&quot;
watchDir=&quot;/tmp/war-listen/&quot;
watchEnabled=&quot;false&quot;/&gt;
&lt;ClusterListener className=&quot;org.apache.catalina.ha.session.
ClusterSessionListener&quot;/&gt;
&lt;/Cluster&gt;
```
## 集群工作过程
Tomcat的官网给出了一个例子来说明Tomcat集群模式下是如何工作的以及Tomcat集群是如何实现高可用的。比如集群由Tomcat A和Tomcat B两个Tomcat实例组成按照时间先后顺序发生了如下事件
**1. Tomcat A启动**
Tomcat A启动过程中当Host对象被创建时一个Cluster组件默认是SimpleTcpCluster被关联到这个Host对象。当某个应用在`web.xml`中设置了Distributable时Tomcat将为此应用的上下文环境创建一个DeltaManager。SimpleTcpCluster启动Membership服务和Replication服务。
**2. Tomcat B启动在Tomcat A之后启动**
首先Tomcat B会执行和Tomcat A一样的操作然后SimpleTcpCluster会建立一个由Tomcat A和Tomcat B组成的Membership。接着Tomcat B向集群中的Tomcat A请求Session数据如果Tomcat A没有响应Tomcat B的拷贝请求Tomcat B会在60秒后time out。在Session数据拷贝完成之前Tomcat B不会接收浏览器的请求。
**3. Tomcat A接收HTTP请求创建Session 1**
Tomcat A响应客户请求在把结果发送回客户端之前ReplicationValve会拦截当前请求如果Filter中配置了不需拦截的请求类型这一步就不会进行默认配置下拦截所有请求如果发现当前请求更新了Session就调用Replication服务建立TCP连接将Session拷贝到Membership列表中的其他节点即Tomcat B。在拷贝时所有保存在当前Session中的可序列化的对象都会被拷贝而不仅仅是发生更新的部分。
**4. Tomcat A崩溃**
当Tomcat A崩溃时Tomcat B会被告知Tomcat A已从集群中退出然后Tomcat B就会把Tomcat A从自己的Membership列表中删除。并且Tomcat B的Session更新时不再往Tomcat A拷贝同时负载均衡器会把后续的HTTP请求全部转发给Tomcat B。在此过程中所有的Session数据不会丢失。
**5. Tomcat B接收Tomcat A的请求**
Tomcat B正常响应本应该发往Tomcat A的请求因为Tomcat B保存了Tomcat A的所有Session数据。
**6. Tomcat A重新启动**
Tomcat A按步骤1、2操作启动加入集群并从Tomcat B拷贝所有Session数据拷贝完成后开始接收请求。
**7. Tomcat A接收请求Session 1被用户注销**
Tomcat继续接收发往Tomcat A的请求Session 1设置为失效。请注意这里的失效并非因为Tomcat A处于非活动状态超过设置的时间而是应用程序执行了注销的操作比如用户登出而引起的Session失效。这时Tomcat A向Tomcat B发送一个Session 1 Expired的消息Tomcat B收到消息后也会把Session 1设置为失效。
**8. Tomcat B接收到一个新请求创建Session 2**
同理这个新的Session也会被拷贝到Tomcat A。
**9. Tomcat A上的Session 2过期**
因超时原因引起的Session失效Tomcat A无需通知Tomcat BTomcat B同样知道Session 2已经超时。因此对于Tomcat集群有一点非常重要**所有节点的操作系统时间必须一致**。不然会出现某个节点Session已过期而在另一节点此Session仍处于活动状态的现象。
## 本期精华
今天我谈了Tomcat的集群工作原理和配置方式还通过官网上的一个例子说明了Tomcat集群的工作过程。Tomcat集群对Session的拷贝支持两种方式DeltaManager和BackupManager。
当集群中节点比较少时可以采用DeltaManager因为Session数据在集群中各个节点都有备份任何一个节点崩溃都不会对整体造成影响可靠性比较高。
当集群中节点数比较多时可以采用BackupManager这是因为一个节点的Session只会拷贝到另一个节点数据拷贝的开销比较少同时只要这两个节点不同时崩溃Session数据就不会丢失。
## 课后思考
在Tomcat官方推荐的配置里ReplicationValve被配置成下面这样
```
&lt;Valve className=&quot;org.apache.catalina.ha.tcp.ReplicationValve&quot;
filter=&quot;.*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\
.htm|.*\.html|.*\.css|.*\.txt&quot;/&gt;
```
你是否注意到filter的值是一些JS文件或者图片等这是为什么呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,29 @@
<audio id="audio" title="特别放送 | 如何持续保持对学习的兴趣?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/ef/e9eba83a465fad6c5b225273c26228ef.mp3"></audio>
你好我是李号双。今天我们抛开技术本身的内容来聊聊专栏或者一门新技术的学习方法我也分享一下自己是如何啃下Tomcat和Jetty源码的。
专栏如今已经更新完了五个模块我们学习了Tomcat和Jetty的整体架构、连接器、容器和通用组件这些内容可以说是Tomcat和Jetty的设计核心。在日常工作的使用中我们使用到了Tomcat和Jetty提供的功能我希望通过学习专栏还能帮你了解这些功能是如何实现的以及Tomcat和Jetty在设计时都考虑了哪些地方。
所以在学习专栏时你不妨思考这样一个问题假如让你来设计并实现一个Web容器你会怎么做呢如何合理设计顶层模块如何考虑方方面面的需求比如最基本的功能需求是加载和运行Web程序最重要的非功能需求是高性能、高并发。你可以顺着这两条线先思考下你会怎么做然后再回过头来看看Tomcat和Jetty是如何做到的。这样的学习方法其实就在有意识地训练自己独立设计一个系统的能力不管是对于学习这个专栏还是其他技术带着问题再去学习都会有所帮助。
说完关于专栏的学习方法下面我必须要鼓励一下坚持学习到现在的你。专栏从第三模块开始开始讲解连接器、容器和通用组件的设计和原理有些内容可能比较偏向底层确实难度比较大如果对底层源码不熟悉或者不感兴趣学习起来会有些痛苦。但是我之所以设计了这部分内容就是希望能够揭开Tomcat和Jetty的内部细节因为任何一个优秀的中间件之所以可以让用户使用比较容易其内部一定都是很复杂的。这也从侧面传递出一个信号美好的东西都是有代价的需要也值得我们去付出时间和精力。
我和你一样我们都身处IT行业这个行业技术更新迭代非常快因此我们需要以一个开放的心态持续学习。而学习恰恰又是一个反人性的过程甚至是比较痛苦的尤其是有些技术框架本身比较庞大设计得非常复杂我们在学习初期很容易遇到“挫折感”一些技术点怎么想也想不明白往往也会有放弃的想法。我同样经历过这个过程**我的经验是找到适合自己的学习方法非常重要,同样关键的是要保持学习的兴趣和动力**。
举个我学习Spring框架的例子记得当时我在接触Spring框架的时候一开始就钻进一个模块开始啃起了源代码。由于Spring框架本身比较庞杂分很多模块当时给我最直观的感受就是看不懂我不明白代码为什么要这么写为什么设计得这么“绕”。这里面的问题是首先**我还没弄清楚森林长什么样子,就盯着树叶看**很可能是盲人摸象看不到全貌和整体的设计思路。第二个问题是我还没学会用Spring就开始研究它是如何设计的结果可想而知也遇到了挫折。后来我逐渐总结出一些学习新技术的小经验在学习一门技术的时候一定要先看清它的全貌我推荐先看官方文档看看都有哪些模块、整体上是如何设计的。接着我们先不要直接看源码而是要动手跑一跑官网上的例子或者用这个框架实现一个小系统关键是要学会怎么使用。只有在这个基础上才能深入到特定模块去研究设计思路或者深入到某一模块源码之中。这样在学习的过程中按照一定的顺序一步一步来就能够即时获得**成就感**,有了成就感你才会更加专注,才会愿意花更多时间和精力去深入研究。因此要保持学习的兴趣,我觉得有两个方面比较重要:
第一个是我们需要**带着明确的目标去学习**。比如某些知识点是面试的热点那学习目标就是彻底理解和掌握它当被问到相关问题时你的回答能够使得面试官对你刮目相看有时候往往凭着某一个亮点就能影响最后的录用结果。再比如你想掌握一门新技术来解决工作上的问题那你的学习目标应该是不但要掌握清楚原理还要能真正的将新技术合理运用到实际工作中解决实际问题产生实际效果。我们学习了Tomcat和Jetty的责任链模式是不是在实际项目中的一些场景下就可以用到这种设计呢再比如学习了调优方法是不是可以在生产环境里解决性能问题呢总之技术需要变现才有学习动力。
第二个是**一定要动手实践**。只有动手实践才会让我们对技术有最直观的感受。有时候我们听别人讲经验和理论感觉似乎懂了但是过一段时间便又忘记了。如果我们动手实践了特别是在这个过程中碰到了一些问题通过网上查找资料或者跟同事讨论解决了问题这便是你积累的宝贵经验想忘记都难。另外适当的动手实践能够树立起信心培养起兴趣这跟玩游戏上瘾有点类似通过打怪升级一点点积累起成就感。比如学习了Tomcat的线程池实现我们就可以自己写一个定制版的线程池学习了Tomcat的类加载器我们也可以自己动手写一个类加载器。
专栏更新到现在,内容最难的部分已经结束,在后面的实战调优模块,我在设计内容时都安排了实战环节。毕竟调优本身就是一个很贴近实际场景的话题,应该基于特定场景,去解决某个性能问题,而不是为了调优而调优。所以这部分内容也更贴近实际工作场景,你可以尝试用我前面讲的方法,带着问题学习后面的专栏。
调优的过程中需要一些知识储备比如我们需要掌握操作系统、JVM以及网络通信的原理这些原理在专栏前面的文章也讲到过。虽然涉及很多原理也很复杂并不是说要面面俱到我们也不太容易深入到每个细节所以最关键的是要弄懂相关参数的含义比如JVM内存的参数、GC的参数、Linux内核的相关参数等。
除此之外,调优的过程还需要借助大量的工具,包括性能监控工具、日志分析工具、网络抓包工具和流量压测工具等,熟练使用这些工具也是每一个后端程序员必须掌握的看家本领,因此在实战环节,我也设计了一些场景来带你熟悉这些工具。
说了那么多,就是希望你保持对学习的热情,树立明确的目标,再加上亲自动手实践。专栏学习到现在这个阶段,是时候开始动手实践了,希望你每天都能积累一点,每天都能有所进步。
最后欢迎你在留言区分享一下你学习一门新技术的方法和心得,与我和其他同学一起讨论。

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="34 | JVM GC原理及调优的基本思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/18/d86ba52c6bbb5e4d5e369cea04ae4118.mp3"></audio>
和Web应用程序一样Tomcat作为一个Java程序也跑在JVM中因此如果我们要对Tomcat进行调优需要先了解JVM调优的原理。而对于JVM调优来说主要是JVM垃圾收集的优化一般来说是因为有问题才需要优化所以对于JVM GC来说如果你观察到Tomcat进程的CPU使用率比较高并且在GC日志中发现GC次数比较频繁、GC停顿时间长这表明你需要对GC进行优化了。
在对GC调优的过程中我们不仅需要知道GC的原理更重要的是要熟练使用各种监控和分析工具具备GC调优的实战能力。CMS和G1是时下使用率比较高的两款垃圾收集器从Java 9开始采用G1作为默认垃圾收集器而G1的目标也是逐步取代CMS。所以今天我们先来简单回顾一下两种垃圾收集器CMS和G1的区别接着通过一个例子帮你提高GC调优的实战能力。
## CMS vs G1
CMS收集器将Java堆分为**年轻代**Young或**年老代**Old。这主要是因为有研究表明超过90的对象在第一次GC时就被回收掉但是少数对象往往会存活较长的时间。
CMS还将年轻代内存空间分为**幸存者空间**Survivor和**伊甸园空间**Eden。新的对象始终在Eden空间上创建。一旦一个对象在一次垃圾收集后还幸存就会被移动到幸存者空间。当一个对象在多次垃圾收集之后还存活时它会移动到年老代。这样做的目的是在年轻代和年老代采用不同的收集算法以达到较高的收集效率比如在年轻代采用复制-整理算法,在年老代采用标记-清理算法。因此CMS将Java堆分成如下区域
<img src="https://static001.geekbang.org/resource/image/8a/7a/8a4e63a4dc5c7f1c0ba19afd748aee7a.png" alt="">
与CMS相比G1收集器有两大特点
- G1可以并发完成大部分GC的工作这期间不会“Stop-The-World”。
- G1使用**非连续空间**这使G1能够有效地处理非常大的堆。此外G1可以同时收集年轻代和年老代。G1并没有将Java堆分成三个空间Eden、Survivor和Old而是将堆分成许多通常是几百个非常小的区域。这些区域是固定大小的默认情况下大约为2MB。每个区域都分配给一个空间。 G1收集器的Java堆如下图所示
<img src="https://static001.geekbang.org/resource/image/14/9e/14fed64d57fc1e56bdcd472440444d9e.png" alt="">
图上的U表示“未分配”区域。G1将堆拆分成小的区域一个最大的好处是可以做局部区域的垃圾回收而不需要每次都回收整个区域比如年轻代和年老代这样回收的停顿时间会比较短。具体的收集过程是
- 将所有存活的对象将从**收集的区域**复制到**未分配的区域**比如收集的区域是Eden空间把Eden中的存活对象复制到未分配区域这个未分配区域就成了Survivor空间。理想情况下如果一个区域全是垃圾意味着一个存活的对象都没有则可以直接将该区域声明为“未分配”。
- 为了优化收集时间G1总是优先选择垃圾最多的区域从而最大限度地减少后续分配和释放堆空间所需的工作量。这也是G1收集器名字的由来——Garbage-First。
## GC调优原则
GC是有代价的因此我们调优的根本原则是**每一次GC都回收尽可能多的对象**也就是减少无用功。因此我们在做具体调优的时候针对CMS和G1两种垃圾收集器分别有一些相应的策略。
**CMS收集器**
对于CMS收集器来说最重要的是**合理地设置年轻代和年老代的大小**。年轻代太小的话会导致频繁的Minor GC并且很有可能存活期短的对象也不能被回收GC的效率就不高。而年老代太小的话容纳不下从年轻代过来的新对象会频繁触发单线程Full GC导致较长时间的GC暂停影响Web应用的响应时间。
**G1收集器**
对于G1收集器来说我不推荐直接设置年轻代的大小这一点跟CMS收集器不一样这是因为G1收集器会根据算法动态决定年轻代和年老代的大小。因此对于G1收集器我们需要关心的是Java堆的总大小`-Xmx`)。
此外G1还有一个较关键的参数是`-XX:MaxGCPauseMillis = n`这个参数是用来限制最大的GC暂停时间目的是尽量不影响请求处理的响应时间。G1将根据先前收集的信息以及检测到的垃圾量估计它可以立即收集的最大区域数量从而尽量保证GC时间不会超出这个限制。因此G1相对来说更加“智能”使用起来更加简单。
## 内存调优实战
下面我通过一个例子实战一下Java堆设置得过小导致频繁的GC我们将通过GC日志分析工具来观察GC活动并定位问题。
1.首先我们建立一个Spring Boot程序作为我们的调优对象代码如下
```
@RestController
public class GcTestController {
private Queue&lt;Greeting&gt; objCache = new ConcurrentLinkedDeque&lt;&gt;();
@RequestMapping(&quot;/greeting&quot;)
public Greeting greeting() {
Greeting greeting = new Greeting(&quot;Hello World!&quot;);
if (objCache.size() &gt;= 200000) {
objCache.clear();
} else {
objCache.add(greeting);
}
return greeting;
}
}
@Data
@AllArgsConstructor
class Greeting {
private String message;
}
```
上面的代码就是创建了一个对象池当对象池中的对象数到达200000时才清空一次用来模拟年老代对象。
2.用下面的命令启动测试程序:
```
java -Xmx32m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar
```
我给程序设置的堆的大小为32MB目的是能让我们看到Full GC。除此之外我还打开了verbosegc日志请注意这里我使用的版本是Java 12默认的垃圾收集器是G1。
3.使用JMeter压测工具向程序发送测试请求访问的路径是`/greeting`
<img src="https://static001.geekbang.org/resource/image/bd/85/bd3a55b83f85b3c6a050cbe7aa288485.png" alt="">
4.使用GCViewer工具打开GC日志我们可以看到这样的图
<img src="https://static001.geekbang.org/resource/image/7a/a2/7aab9535570082e1dd19c158012e05a2.png" alt="">
我来解释一下这张图:
- 图中上部的蓝线表示已使用堆的大小我们看到它周期的上下震荡这是我们的对象池要扩展到200000才会清空。
- 图底部的绿线表示年轻代GC活动从图上看到当堆的使用率上去了会触发频繁的GC活动。
- 图中的竖线表示Full GC从图上看到伴随着Full GC蓝线会下降这说明Full GC收集了年老代中的对象。
基于上面的分析我们可以得出一个结论那就是Java堆的大小不够。我来解释一下为什么得出这个结论
- GC活动频繁年轻代GC绿色线和年老代GC黑色线都比较密集。这说明内存空间不够也就是Java堆的大小不够。
- Java的堆中对象在GC之后能够被回收说明不是内存泄漏。
我们通过GCViewer还发现累计GC暂停时间有55.57秒,如下图所示:
<img src="https://static001.geekbang.org/resource/image/2a/06/2a0dddc7e9fc5c61339e5d515c449806.png" alt="">
因此我们的解决方案是调大Java堆的大小像下面这样
```
java -Xmx2048m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar
```
生成的新的GC log分析图如下
<img src="https://static001.geekbang.org/resource/image/30/99/3027354c1ae0b359dab025c53b297599.png" alt="">
你可以看到没有发生Full GC并且年轻代GC也没有那么频繁了并且累计GC暂停时间只有3.05秒。
<img src="https://static001.geekbang.org/resource/image/9f/1b/9f1b3655cebf6e8f40148dfa6d6c111b.png" alt="">
## 本期精华
今天我们首先回顾了CMS和G1两种垃圾收集器背后的设计思路以及它们的区别接着分析了GC调优的总体原则。
对于CMS来说我们要合理设置年轻代和年老代的大小。你可能会问该如何确定它们的大小呢这是一个迭代的过程可以先采用JVM的默认值然后通过压测分析GC日志。
如果我们看年轻代的内存使用率处在高位导致频繁的Minor GC而频繁GC的效率又不高说明对象没那么快能被回收这时年轻代可以适当调大一点。
如果我们看年老代的内存使用率处在高位导致频繁的Full GC这样分两种情况如果每次Full GC后年老代的内存占用率没有下来可以怀疑是内存泄漏如果Full GC后年老代的内存占用率下来了说明不是内存泄漏我们要考虑调大年老代。
对于G1收集器来说我们可以适当调大Java堆因为G1收集器采用了局部区域收集策略单次垃圾收集的时间可控可以管理较大的Java堆。
## 课后思考
如果把年轻代和年老代都设置得很大,会有什么问题?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="35 | 如何监控Tomcat的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/51/a0fe8c6f6ec246d1b3e9ba089e64ca51.mp3"></audio>
专栏上一期我们分析了JVM GC的基本原理以及监控和分析工具今天我们接着来聊如何监控Tomcat的各种指标因为只有我们掌握了这些指标和信息才能对Tomcat内部发生的事情一目了然让我们明白系统的瓶颈在哪里进而做出调优的决策。
在今天的文章里我们首先来看看到底都需要监控Tomcat哪些关键指标接着来具体学习如何通过JConsole来监控它们。如果系统没有暴露JMX接口我们还可以通过命令行来查看Tomcat的性能指标。
Web应用的响应时间是我们关注的一个重点最后我们通过一个实战案例来看看Web应用的下游服务响应时间比较长的情况下Tomcat的各项指标是什么样子的。
## Tomcat的关键指标
Tomcat的关键指标有**吞吐量、响应时间、错误数、线程池、CPU以及JVM内存**。
我来简单介绍一下这些指标背后的意义。其中前三个指标是我们最关心的业务指标Tomcat作为服务器就是要能够又快有好地处理请求因此吞吐量要大、响应时间要短并且错误数要少。
而后面三个指标是跟系统资源有关的当某个资源出现瓶颈就会影响前面的业务指标比如线程池中的线程数量不足会影响吞吐量和响应时间但是线程数太多会耗费大量CPU也会影响吞吐量当内存不足时会触发频繁地GC耗费CPU最后也会反映到业务指标上来。
那如何监控这些指标呢Tomcat可以通过JMX将上述指标暴露出来的。JMXJava Management Extensions即Java管理扩展是一个为应用程序、设备、系统等植入监控管理功能的框架。JMX使用管理MBean来监控业务资源这些MBean在JMX MBean服务器上注册代表JVM中运行的应用程序或服务。每个MBean都有一个属性列表。JMX客户端可以连接到MBean Server来读写MBean的属性值。你可以通过下面这张图来理解一下JMX的工作原理
<img src="https://static001.geekbang.org/resource/image/71/6b/714fa2e12380122599be077c10375a6b.png" alt="">
Tomcat定义了一系列MBean来对外暴露系统状态接下来我们来看看如何通过JConsole来监控这些指标。
## 通过JConsole监控Tomcat
首先我们需要开启JMX的远程监听端口具体来说就是设置若干JVM参数。我们可以在Tomcat的bin目录下新建一个名为`setenv.sh`的文件(或者`setenv.bat`,根据你的操作系统类型),然后输入下面的内容:
```
export JAVA_OPTS=&quot;${JAVA_OPTS} -Dcom.sun.management.jmxremote&quot;
export JAVA_OPTS=&quot;${JAVA_OPTS} -Dcom.sun.management.jmxremote.port=9001&quot;
export JAVA_OPTS=&quot;${JAVA_OPTS} -Djava.rmi.server.hostname=x.x.x.x&quot;
export JAVA_OPTS=&quot;${JAVA_OPTS} -Dcom.sun.management.jmxremote.ssl=false&quot;
export JAVA_OPTS=&quot;${JAVA_OPTS} -Dcom.sun.management.jmxremote.authenticate=false&quot;
```
重启Tomcat这样JMX的监听端口9001就开启了接下来通过JConsole来连接这个端口。
```
jconsole x.x.x.x:9001
```
我们可以看到JConsole的主界面
<img src="https://static001.geekbang.org/resource/image/80/d7/80f14c4bd4eead05f4d84937ed7726d7.png" alt="">
前面我提到的需要监控的关键指标有**吞吐量、响应时间、错误数、线程池、CPU以及JVM内存**接下来我们就来看看怎么在JConsole上找到这些指标。
**吞吐量、响应时间、错误数**
在MBeans标签页下选择GlobalRequestProcessor这里有Tomcat请求处理的统计信息。你会看到Tomcat中的各种连接器展开“http-nio-8080”你会看到这个连接器上的统计信息其中maxTime表示最长的响应时间processingTime表示平均响应时间requestCount表示吞吐量errorCount就是错误数。
<img src="https://static001.geekbang.org/resource/image/ff/9c/ff0a9163fbeeed8eb84ed89c3f71799c.png" alt="">
**线程池**
选择“线程”标签页可以看到当前Tomcat进程中有多少线程如下图所示
<img src="https://static001.geekbang.org/resource/image/78/b8/78858a3264107e1f1a0b6a78d43113b8.png" alt="">
图的左下方是线程列表右边是线程的运行栈这些都是非常有用的信息。如果大量线程阻塞通过观察线程栈能看到线程阻塞在哪个函数有可能是I/O等待或者是死锁。
**CPU**
在主界面可以找到CPU使用率指标请注意这里的CPU使用率指的是Tomcat进程占用的CPU不是主机总的CPU使用率。
<img src="https://static001.geekbang.org/resource/image/1b/9f/1bb88cc3d5f2b9a80377a29b0b80e19f.png" alt="">
**JVM内存**
选择“内存”标签页你能看到Tomcat进程的JVM内存使用情况。
<img src="https://static001.geekbang.org/resource/image/f2/02/f22eca547ca76eb5edba03b39082fa02.png" alt="">
你还可以查看JVM各内存区域的使用情况大的层面分堆区和非堆区。堆区里有分为Eden、Survivor和Old。选择“VM Summary”标签可以看到虚拟机内的详细信息。
<img src="https://static001.geekbang.org/resource/image/cc/a0/cc91bb5b7b8d8b0b46dad946617b01a0.png" alt="">
## 命令行查看Tomcat指标
极端情况下如果Web应用占用过多CPU或者内存又或者程序中发生了死锁导致Web应用对外没有响应监控系统上看不到数据这个时候需要我们登陆到目标机器通过命令行来查看各种指标。
1.首先我们通过ps命令找到Tomcat进程拿到进程ID。
<img src="https://static001.geekbang.org/resource/image/84/9a/8477832ebe079cf92e3cd58766754e9a.png" alt="">
2.接着查看进程状态的大致信息,通过`cat/proc/&lt;pid&gt;/status`命令:
<img src="https://static001.geekbang.org/resource/image/d8/b3/d812ac93be2ac882e689f77e5d8e12b3.png" alt="">
3.监控进程的CPU和内存资源使用情况
<img src="https://static001.geekbang.org/resource/image/6e/d6/6e7e0730e92e3d8846f37ea8a14973d6.png" alt="">
4.查看Tomcat的网络连接比如Tomcat在8080端口上监听连接请求通过下面的命令查看连接列表
<img src="https://static001.geekbang.org/resource/image/14/61/14ba365d585f0ec79543efc1a9b32961.png" alt="">
你还可以分别统计处在“已连接”状态和“TIME_WAIT”状态的连接数
<img src="https://static001.geekbang.org/resource/image/aa/2c/aaab0fb1156bb8ec92ee6c02b149cf2c.jpg" alt="">
5.通过ifstat来查看网络流量大致可以看出Tomcat当前的请求数和负载状况。
<img src="https://static001.geekbang.org/resource/image/67/b2/67a9a29b8bf071152ccc1bc108adc4b2.png" alt="">
## 实战案例
在这个实战案例中我们会创建一个Web应用根据传入的参数latency来休眠相应的秒数目的是模拟当前的Web应用在访问下游服务时遇到的延迟。然后用JMeter来压测这个服务通过JConsole来观察Tomcat的各项指标分析和定位问题。
主要的步骤有:
1.创建一个Spring Boot程序加入下面代码所示的一个RestController
```
@RestController
public class DownStreamLatency {
@RequestMapping(&quot;/greeting/latency/{seconds}&quot;)
public Greeting greeting(@PathVariable long seconds) {
try {
Thread.sleep(seconds * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Greeting greeting = new Greeting(&quot;Hello World!&quot;);
return greeting;
}
}
```
从上面的代码我们看到程序会读取URL传过来的seconds参数先休眠相应的秒数再返回请求。这样做的目的是客户端压测工具能够控制服务端的延迟。
为了方便观察Tomcat的线程数跟延迟之间的关系还需要加大Tomcat的最大线程数我们可以在`application.properties`文件中加入这样一行:
```
server.tomcat.max-threads=1000server.tomcat.max-threads=1000
```
2.启动JMeter开始压测这里我们将压测的线程数设置为100
<img src="https://static001.geekbang.org/resource/image/89/51/895c7c986d0c86b0a869a604c9c6af51.png" alt="">
请你注意的是我们还需要将客户端的Timeout设置为1000毫秒这是因为JMeter的测试线程在收到响应之前不会发出下一次请求这就意味我们没法按照固定的吞吐量向服务端加压。而加了Timeout以后JMeter会有固定的吞吐量向Tomcat发送请求。
<img src="https://static001.geekbang.org/resource/image/3e/a5/3e275c10132680995ac83460f29a1aa5.png" alt="">
3.开启测试这里分三个阶段第一个阶段将服务端休眠时间设为2秒然后暂停一段时间。第二和第三阶段分别将休眠时间设置成4秒和6秒。
<img src="https://static001.geekbang.org/resource/image/8b/0d/8b071d99cbf8875c138f14bd17f0ed0d.png" alt="">
4.最后我们通过JConsole来观察结果
<img src="https://static001.geekbang.org/resource/image/dd/b3/ddb8609042469ee73ddc3fb68f676eb3.png" alt="">
下面我们从线程数、内存和CPU这三个指标来分析Tomcat的性能问题。
- 首先看线程数在第一阶段时间之前线程数大概是40第一阶段压测开始后线程数增长到250。为什么是250呢这是因为JMeter每秒会发出100个请求每一个请求休眠2秒因此Tomcat需要200个工作线程来干活此外Tomcat还有一些其他线程用来处理网络通信和后台任务所以总数是250左右。第一阶段压测暂停后线程数又下降到40这是因为线程池会回收空闲线程。第二阶段测试开始后线程数涨到了420这是因为每个请求休眠了4秒同理我们看到第三阶段测试的线程数是620。
- 我们再来看CPU在三个阶段的测试中CPU的峰值始终比较稳定这是因为JMeter控制了总体的吞吐量因为服务端用来处理这些请求所需要消耗的CPU基本也是一样的。
- 各测试阶段的内存使用量略有增加,这是因为线程数增加了,创建线程也需要消耗内存。
从上面的测试结果我们可以得出一个结论对于一个Web应用来说下游服务的延迟越大Tomcat所需要的线程数越多但是CPU保持稳定。所以如果你在实际工作碰到线程数飙升但是CPU没有增加的情况这个时候你需要怀疑你的Web应用所依赖的下游服务是不是出了问题响应时间是否变长了。
## 本期精华
今天我们学习了Tomcat中的关键的性能指标以及如何监控这些指标主要有**吞吐量、响应时间、错误数、线程池、CPU以及JVM内存。**
在实际工作中我们需要通过观察这些指标来诊断系统遇到的性能问题找到性能瓶颈。如果我们监控到CPU上升这时我们可以看看吞吐量是不是也上升了如果是那说明正常如果不是的话可以看看GC的活动如果GC活动频繁并且内存居高不下基本可以断定是内存泄漏。
## 课后思考
请问工作中你如何监控Web应用的健康状态遇到性能问题的时候是如何做问题定位的呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="36 | Tomcat I/O和线程池的并发调优" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/1c/aa656aaf89a325e2350892a86abbe11c.mp3"></audio>
上一期我们谈到了如何监控Tomcat的性能指标在这个基础上今天我们接着聊如何对Tomcat进行调优。
Tomcat的调优涉及I/O模型和线程池调优、JVM内存调优以及网络优化等今天我们来聊聊I/O模型和线程池调优由于Web应用程序跑在Tomcat的工作线程中因此Web应用对请求的处理时间也直接影响Tomcat整体的性能而Tomcat和Web应用在运行过程中所用到的资源都来自于操作系统因此调优需要将服务端看作是一个整体来考虑。
所谓的I/O调优指的是选择NIO、NIO.2还是APR而线程池调优指的是给Tomcat的线程池设置合适的参数使得Tomcat能够又快又好地处理请求。
## I/O模型的选择
I/O调优实际上是连接器类型的选择一般情况下默认都是NIO在绝大多数情况下都是够用的除非你的Web应用用到了TLS加密传输而且对性能要求极高这个时候可以考虑APR因为APR通过OpenSSL来处理TLS握手和加/解密。OpenSSL本身用C语言实现它还对TLS通信做了优化所以性能比Java要高。
那你可能会问那什么时候考虑选择NIO.2我的建议是如果你的Tomcat跑在Windows平台上并且HTTP请求的数据量比较大可以考虑NIO.2这是因为Windows从操作系统层面实现了真正意义上的异步I/O如果传输的数据量比较大异步I/O的效果就能显现出来。
如果你的Tomcat跑在Linux平台上建议使用NIO这是因为Linux内核没有很完善地支持异步I/O模型因此JVM并没有采用原生的Linux异步I/O而是在应用层面通过epoll模拟了异步I/O模型只是Java NIO的使用者感觉不到而已。因此可以这样理解在Linux平台上Java NIO和Java NIO.2底层都是通过epoll来实现的但是Java NIO更加简单高效。
## 线程池调优
跟I/O模型紧密相关的是线程池线程池的调优就是设置合理的线程池参数。我们先来看看Tomcat线程池中有哪些关键参数
<img src="https://static001.geekbang.org/resource/image/a4/e2/a48106c3893862fc826e7b7ffaa461e2.jpg" alt="">
这里面最核心的就是如何确定maxThreads的值如果这个参数设置小了Tomcat会发生线程饥饿并且请求的处理会在队列中排队等待导致响应时间变长如果maxThreads参数值过大同样也会有问题因为服务器的CPU的核数有限线程数太多会导致线程在CPU上来回切换耗费大量的切换开销。
那maxThreads设置成多少才算是合适呢为了理解清楚这个问题我们先来看看什么是利特尔法则Littles Law
**利特尔法则**
>
系统中的请求数 = 请求的到达速率 × 每个请求处理时间
其实这个公式很好理解,我举个我们身边的例子:我们去超市购物结账需要排队,但是你是如何估算一个队列有多长呢?队列中如果每个人都买很多东西,那么结账的时间就越长,队列也会越长;同理,短时间一下有很多人来收银台结账,队列也会变长。因此队列的长度等于新人加入队列的频率乘以平均每个人处理的时间。
**计算出了队列的长度,那么我们就创建相应数量的线程来处理请求,这样既能以最快的速度处理完所有请求,同时又没有额外的线程资源闲置和浪费。**
假设一个单核服务器在接收请求:
- 如果每秒10个请求到达平均处理一个请求需要1秒那么服务器任何时候都有10个请求在处理即需要10个线程。
- 如果每秒10个请求到达平均处理一个请求需要2秒那么服务器在每个时刻都有20个请求在处理因此需要20个线程。
- 如果每秒10000个请求到达平均处理一个请求需要1秒那么服务器在每个时刻都有10000个请求在处理因此需要10000个线程。
因此可以总结出一个公式:
**线程池大小 = 每秒请求数 × 平均请求处理时间**
这是理想的情况也就是说线程一直在忙着干活没有被阻塞在I/O等待上。实际上任务在执行中线程不可避免会发生阻塞比如阻塞在I/O等待上等待数据库或者下游服务的数据返回虽然通过非阻塞I/O模型可以减少线程的等待但是数据在用户空间和内核空间拷贝过程中线程还是阻塞的。线程一阻塞就会让出CPU线程闲置下来就好像工作人员不可能24小时不间断地处理客户的请求解决办法就是增加工作人员的数量一个人去休息另一个人再顶上。对应到线程池就是增加线程数量因此I/O密集型应用需要设置更多的线程。
**线程I/O时间与CPU时间**
至此我们又得到一个线程池个数的计算公式,假设服务器是单核的:
**线程池大小 = 线程I/O阻塞时间 + 线程CPU时间 / 线程CPU时间**
其中线程I/O阻塞时间 + 线程CPU时间 = 平均请求处理时间
对比一下两个公式,你会发现,**平均请求处理时间**在两个公式里都出现了,这说明请求时间越长,需要更多的线程是毫无疑问的。
不同的是第一个公式是用**每秒请求数**来乘以请求处理时间;而第二个公式用**请求处理时间**来除以**线程CPU时间**请注意CPU时间是小于请求处理时间的。
虽然这两个公式是从不同的角度来看待问题的,但都是理想情况,都有一定的前提条件。
1. 请求处理时间越长需要的线程数越多但前提是CPU核数要足够如果一个CPU来支撑10000 TPS并发创建10000个线程显然不合理会造成大量线程上下文切换。
1. 请求处理过程中I/O等待时间越长需要的线程数越多前提是CUP时间和I/O时间的比率要计算的足够准确。
1. 请求进来的速率越快需要的线程数越多前提是CPU核数也要跟上。
## 实际场景下如何确定线程数
那么在实际情况下,线程池的个数如何确定呢?这是一个迭代的过程,先用上面两个公式大概算出理想的线程数,再反复压测调整,从而达到最优。
一般来说如果系统的TPS要求足够大用第一个公式算出来的线程数往往会比公式二算出来的要大。我建议选取这两个值中间更靠近公式二的值。也就是先设置一个较小的线程数然后进行压测当达到系统极限时错误数增加或者响应时间大幅增加再逐步加大线程数当增加到某个值再增加线程数也无济于事甚至TPS反而下降那这个值可以认为是最佳线程数。
线程池中其他的参数最好就用默认值能不改就不改除非在压测的过程发现了瓶颈。如果发现了问题就需要调整比如maxQueueSize如果大量任务来不及处理都堆积在maxQueueSize中会导致内存耗尽这个时候就需要给maxQueueSize设一个限制。当然这是一个比较极端的情况了。
再比如minSpareThreads参数默认是25个线程如果你发现系统在闲的时候用不到25个线程就可以调小一点如果系统在大部分时间都比较忙线程池中的线程总是远远多于25个这个时候你就可以把这个参数调大一点因为这样线程池就不需要反复地创建和销毁线程了。
## 本期精华
今天我们学习了I/O调优也就是如何选择连接器的类型以及在选择过程中有哪些需要注意的地方。
后面还聊到Tomcat线程池的各种参数其中最重要的参数是最大线程数maxThreads。理论上我们可以通过利特尔法则或者CPU时间与I/O时间的比率计算出一个理想值这个值只具有指导意义因为它受到各种资源的限制实际场景中我们需要在理想值的基础上进行压测来获得最佳线程数。
## 课后思考
其实调优很多时候都是在找系统瓶颈假如有个状况系统响应比较慢但CPU的用率不高内存有所增加通过分析Heap Dump发现大量请求堆积在线程池的队列中请问这种情况下应该怎么办呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,169 @@
<audio id="audio" title="37 | Tomcat内存溢出的原因分析及调优" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/09/6c9f55f61bef8b7996c62c960bc66b09.mp3"></audio>
作为Java程序员我们几乎都会碰到java.lang.OutOfMemoryError异常但是你知道有哪些原因可能导致JVM抛出OutOfMemoryError异常吗
JVM在抛出java.lang.OutOfMemoryError时除了会打印出一行描述信息还会打印堆栈跟踪因此我们可以通过这些信息来找到导致异常的原因。在寻找原因前我们先来看看有哪些因素会导致OutOfMemoryError其中内存泄漏是导致OutOfMemoryError的一个比较常见的原因最后我们通过一个实战案例来定位内存泄漏。
## 内存溢出场景及方案
**java.lang.OutOfMemoryError: Java heap space**
JVM无法在堆中分配对象时会抛出这个异常导致这个异常的原因可能有三种
1. 内存泄漏。Java应用程序一直持有Java对象的引用导致对象无法被GC回收比如对象池和内存池中的对象无法被GC回收。
1. 配置问题。有可能是我们通过JVM参数指定的堆大小或者未指定的默认大小对于应用程序来说是不够的。解决办法是通过JVM参数加大堆的大小。
1. finalize方法的过度使用。如果我们想在Java类实例被GC之前执行一些逻辑比如清理对象持有的资源可以在Java类中定义finalize方法这样JVM GC不会立即回收这些对象实例而是将对象实例添加到一个叫“`java.lang.ref.Finalizer.ReferenceQueue`”的队列中执行对象的finalize方法之后才会回收这些对象。Finalizer线程会和主线程竞争CPU资源但由于优先级低所以处理速度跟不上主线程创建对象的速度因此ReferenceQueue队列中的对象就越来越多最终会抛出OutOfMemoryError。解决办法是尽量不要给Java类定义finalize方法。
**java.lang.OutOfMemoryError: GC overhead limit exceeded**
出现这种OutOfMemoryError的原因是垃圾收集器一直在运行但是GC效率很低比如Java进程花费超过98的CPU时间来进行一次GC但是回收的内存少于2的JVM堆并且连续5次GC都是这种情况就会抛出OutOfMemoryError。
解决办法是查看GC日志或者生成Heap Dump确认一下是不是内存泄漏如果不是内存泄漏可以考虑增加Java堆的大小。当然你还可以通过参数配置来告诉JVM无论如何也不要抛出这个异常方法是配置`-XX:-UseGCOverheadLimit`但是我并不推荐这么做因为这只是延迟了OutOfMemoryError的出现。
**java.lang.OutOfMemoryError: Requested array size exceeds VM limit**
从错误消息我们也能猜到抛出这种异常的原因是“请求的数组大小超过JVM限制”应用程序尝试分配一个超大的数组。比如应用程序尝试分配512MB的数组但最大堆大小为256MB则将抛出OutOfMemoryError并且请求的数组大小超过VM限制。
通常这也是一个配置问题JVM堆太小或者是应用程序的一个Bug比如程序错误地计算了数组的大小导致尝试创建一个大小为1GB的数组。
**java.lang.OutOfMemoryError: MetaSpace**
如果JVM的元空间用尽则会抛出这个异常。我们知道JVM元空间的内存在本地内存中分配但是它的大小受参数MaxMetaSpaceSize的限制。当元空间大小超过MaxMetaSpaceSize时JVM将抛出带有MetaSpace字样的OutOfMemoryError。解决办法是加大MaxMetaSpaceSize参数的值。
**java.lang.OutOfMemoryError: Request size bytes for reason. Out of swap space**
当本地堆内存分配失败或者本地内存快要耗尽时Java HotSpot VM代码会抛出这个异常VM会触发“致命错误处理机制”它会生成“致命错误”日志文件其中包含崩溃时线程、进程和操作系统的有用信息。如果碰到此类型的OutOfMemoryError你需要根据JVM抛出的错误信息来进行诊断或者使用操作系统提供的DTrace工具来跟踪系统调用看看是什么样的程序代码在不断地分配本地内存。
**java.lang.OutOfMemoryError: Unable to create native threads**
抛出这个异常的过程大概是这样的:
1. Java程序向JVM请求创建一个新的Java线程。
1. JVM本地代码Native Code代理该请求通过调用操作系统API去创建一个操作系统级别的线程Native Thread。
1. 操作系统尝试创建一个新的Native Thread需要同时分配一些内存给该线程每一个Native Thread都有一个线程栈线程栈的大小由JVM参数`-Xss`决定。
1. 由于各种原因,操作系统创建新的线程可能会失败,下面会详细谈到。
1. JVM抛出“java.lang.OutOfMemoryError: Unable to create new native thread”错误。
因此关键在于第四步线程创建失败JVM就会抛出OutOfMemoryError那具体有哪些因素会导致线程创建失败呢
1.内存大小限制我前面提到Java创建一个线程需要消耗一定的栈空间并通过`-Xss`参数指定。请你注意的是栈空间如果过小可能会导致StackOverflowError尤其是在递归调用的情况下但是栈空间过大会占用过多内存而对于一个32位Java应用来说用户进程空间是4GB内核占用1GB那么用户空间就剩下3GB因此它能创建的线程数大致可以通过这个公式算出来
```
Max memory3GB = [-Xmx] + [-XX:MaxMetaSpaceSize] + number_of_threads * [-Xss]
```
不过对于64位的应用由于虚拟进程空间近乎无限大因此不会因为线程栈过大而耗尽虚拟地址空间。但是请你注意64位的Java进程能分配的最大内存数仍然受物理内存大小的限制。
**2. ulimit限制**在Linux下执行`ulimit -a`你会看到ulimit对各种资源的限制。
<img src="https://static001.geekbang.org/resource/image/4d/4b/4d79133fc4f8795e44ea3a48a7293a4b.png" alt="">
其中的“max user processes”就是一个进程能创建的最大线程数我们可以修改这个参数
<img src="https://static001.geekbang.org/resource/image/73/77/736fbbb5fadb35f9ae0cc47182938a77.png" alt="">
**3. 参数`sys.kernel.threads-max`限制**。这个参数限制操作系统全局的线程数,通过下面的命令可以查看它的值。
<img src="https://static001.geekbang.org/resource/image/2e/58/2e9f281f4f0d89be1983314713a70258.png" alt="">
这表明当前系统能创建的总的线程是63752。当然我们调整这个参数具体办法是
`/etc/sysctl.conf`配置文件中,加入`sys.kernel.threads-max = 999999`
**4. 参数`sys.kernel.pid_max`限制**这个参数表示系统全局的PID号数值的限制每一个线程都有IDID的值超过这个数线程就会创建失败。跟`sys.kernel.threads-max`参数一样,我们也可以将`sys.kernel.pid_max`调大,方法是在`/etc/sysctl.conf`配置文件中,加入`sys.kernel.pid_max = 999999`
对于线程创建失败的OutOfMemoryError除了调整各种参数我们还需要从程序本身找找原因看看是否真的需要这么多线程有可能是程序的Bug导致创建过多的线程。
## 内存泄漏定位实战
我们先创建一个Web应用不断地new新对象放到一个List中来模拟Web应用中的内存泄漏。然后通过各种工具来观察GC的行为最后通过生成Heap Dump来找到泄漏点。
内存泄漏模拟程序比较简单创建一个Spring Boot应用定义如下所示的类
```
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
import java.util.List;
@Component
public class MemLeaker {
private List&lt;Object&gt; objs = new LinkedList&lt;&gt;();
@Scheduled(fixedRate = 1000)
public void run() {
for (int i = 0; i &lt; 50000; i++) {
objs.add(new Object());
}
}
}
```
这个程序做的事情就是每隔1秒向一个List中添加50000个对象。接下来运行并通过工具观察它的GC行为
1.运行程序并打开verbosegc将GC的日志输出到gc.log文件中。
```
java -verbose:gc -Xloggc:gc.log -XX:+PrintGCDetails -jar mem-0.0.1-SNAPSHOT.jar
```
2.使用`jstat`命令观察GC的过程
```
jstat -gc 94223 2000 1000
```
94223是程序的进程ID2000表示每隔2秒执行一次1000表示持续执行1000次。下面是命令的输出
<img src="https://static001.geekbang.org/resource/image/6f/c2/6fe71c885aa0917d6aaf634cdb1b19c2.png" alt="">
其中每一列的含义是:
- S0C第一个Survivor区总的大小
- S1C第二个Survivor区总的大小
- S0U第一个Survivor区已使用内存的大小
- S1U第二个Survivor区已使用内存的大小。
后面的列相信从名字你也能猜出是什么意思了其中E代表EdenO代表OldM代表MetadataYGC表示Minor GC的总时间YGCT表示Minor GC的次数FGC表示Full GC。
通过这个工具你能大概看到各个内存区域的大小、已经GC的次数和所花的时间。verbosegc参数对程序的影响比较小因此很适合在生产环境现场使用。
3.通过GCViewer工具查看GC日志用GCViewer打开第一步产生的gc.log会看到这样的图
<img src="https://static001.geekbang.org/resource/image/d2/3b/d281cba5576304f0c1407aa16b01353b.png" alt="">
图中红色的线表示年老代占用的内存你会看到它一直在增加而黑色的竖线表示一次Full GC。你可以看到后期JVM在频繁地Full GC但是年老代的内存并没有降下来这是典型的内存泄漏的特征。
除了内存泄漏我们还可以通过GCViewer来观察Minor GC和Full GC的频次已及每次的内存回收量。
4.为了找到内存泄漏点我们通过jmap工具生成Heap Dump
```
jmap -dump:live,format=b,file=94223.bin 94223
```
5.用Eclipse Memory Analyzer打开Dump文件通过内存泄漏分析得到这样一个分析报告
<img src="https://static001.geekbang.org/resource/image/5b/4d/5b64390e688a0f49c19b110106ee334d.png" alt="">
从报告中可以看到JVM内存中有一个长度为4000万的List至此我们也就找到了泄漏点。
## 本期精华
今天我讲解了常见的OutOfMemoryError的场景以及解决办法我们在实际工作中要根据具体的错误信息去分析背后的原因尤其是Java堆内存不够时需要生成Heap Dump来分析看是不是内存泄漏排除内存泄漏之后我们再调整各种JVM参数否则根本的问题原因没有解决的话调整JVM参数也无济于事。
## 课后思考
请你分享一下平时在工作中遇到了什么样的OutOfMemoryError以及你是怎么解决的。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,114 @@
<audio id="audio" title="38 | Tomcat拒绝连接原因分析及网络优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/9b/808812b8479ad8a3404366ca2540469b.mp3"></audio>
专栏上一期我们分析各种JVM OutOfMemory错误的原因和解决办法今天我们来看看网络通信中可能会碰到的各种错误。网络通信方面的错误和异常也是我们在实际工作中经常碰到的需要理解异常背后的原理才能更快更精准地定位问题从而找到解决办法。
下面我会先讲讲Java Socket网络编程常见的异常有哪些然后通过一个实验来重现其中的Connection reset异常并且通过配置Tomcat的参数来解决这个问题。
## 常见异常
**java.net.SocketTimeoutException**
指超时错误。超时分为**连接超时**和**读取超时**连接超时是指在调用Socket.connect方法的时候超时而读取超时是调用Socket.read方法时超时。请你注意的是连接超时往往是由于网络不稳定造成的但是读取超时不一定是网络延迟造成的很有可能是下游服务的响应时间过长。
**java.net.BindException: Address already in use: JVM_Bind**
指端口被占用。当服务器端调用new ServerSocket(port)或者Socket.bind函数时如果端口已经被占用就会抛出这个异常。我们可以用`netstat an`命令来查看端口被谁占用了,换一个没有被占用的端口就能解决。
**java.net.ConnectException: Connection refused: connect**
指连接被拒绝。当客户端调用new Socket(ip, port)或者Socket.connect函数时可能会抛出这个异常。原因是指定IP地址的机器没有找到或者是机器存在但这个机器上没有开启指定的监听端口。
解决办法是从客户端机器ping一下服务端IP假如ping不通可以看看IP是不是写错了假如能ping通需要确认服务端的服务是不是崩溃了。
**java.net.SocketException: Socket is closed**
指连接已关闭。出现这个异常的原因是通信的一方主动关闭了Socket连接调用了Socket的close方法接着又对Socket连接进行了读写操作这时操作系统会报“Socket连接已关闭”的错误。
**java.net.SocketException: Connection reset/Connect reset by peer: Socket write error**
指连接被重置。这里有两种情况分别对应两种错误第一种情况是通信的一方已经将Socket关闭可能是主动关闭或者是因为异常退出这时如果通信的另一方还在写数据就会触发这个异常Connect reset by peer如果对方还在尝试从TCP连接中读数据则会抛出Connection reset异常。
为了避免这些异常发生,在编写网络通信程序时要确保:
- 程序退出前要主动关闭所有的网络连接。
- 检测通信的另一方的关闭连接操作,当发现另一方关闭连接后自己也要关闭该连接。
**java.net.SocketException: Broken pipe**
指通信管道已坏。发生这个异常的场景是通信的一方在收到“Connect reset by peer: Socket write error”后如果再继续写数据则会抛出Broken pipe异常解决方法同上。
**java.net.SocketException: Too many open files**
指进程打开文件句柄数超过限制。当并发用户数比较大时服务器可能会报这个异常。这是因为每创建一个Socket连接就需要一个文件句柄此外服务端程序在处理请求时可能也需要打开一些文件。
你可以通过`lsof -p pid`命令查看进程打开了哪些文件是不是有资源泄露也就是说进程打开的这些文件本应该被关闭但由于程序的Bug而没有被关闭。
如果没有资源泄露,可以通过设置增加最大文件句柄数。具体方法是通过`ulimit -a`来查看系统目前资源限制,通过`ulimit -n 10240`修改最大文件数。
## Tomcat网络参数
接下来我们看看Tomcat两个比较关键的参数maxConnections和acceptCount。在解释这个参数之前先简单回顾下TCP连接的建立过程客户端向服务端发送SYN包服务端回复SYNACK同时将这个处于SYN_RECV状态的连接保存到**半连接队列**。客户端返回ACK包完成三次握手服务端将ESTABLISHED状态的连接移入**accept队列**等待应用程序Tomcat调用accept方法将连接取走。这里涉及两个队列
- **半连接队列**保存SYN_RECV状态的连接。队列长度由`net.ipv4.tcp_max_syn_backlog`设置。
- **accept队列**保存ESTABLISHED状态的连接。队列长度为`min(net.core.somaxconnbacklog)`。其中backlog是我们创建ServerSocket时指定的参数最终会传递给listen方法
```
int listen(int sockfd, int backlog);
```
如果我们设置的backlog大于`net.core.somaxconn`accept队列的长度将被设置为`net.core.somaxconn`而这个backlog参数就是Tomcat中的**acceptCount**参数默认值是100但请注意`net.core.somaxconn`的默认值是128。你可以想象在高并发情况下当Tomcat来不及处理新的连接时这些连接都被堆积在accept队列中而**acceptCount**参数可以控制accept队列的长度超过这个长度时内核会向客户端发送RST这样客户端会触发上文提到的“Connection reset”异常。
而Tomcat中的**maxConnections**是指Tomcat在任意时刻接收和处理的最大连接数。当Tomcat接收的连接数达到maxConnections时Acceptor线程不会再从accept队列中取走连接这时accept队列中的连接会越积越多。
maxConnections的默认值与连接器类型有关NIO的默认值是10000APR默认是8192。
所以你会发现Tomcat的最大并发连接数等于**maxConnections + acceptCount**。如果acceptCount设置得过大请求等待时间会比较长如果acceptCount设置过小高并发情况下客户端会立即触发Connection reset异常。
## Tomcat网络调优实战
接下来我们通过一个直观的例子来加深对上面两个参数的理解。我们先重现流量高峰时accept队列堆积的情况这样会导致客户端触发“Connection reset”异常然后通过调整参数解决这个问题。主要步骤有
1.下载和安装压测工具[JMeter](http://jmeter.apache.org/download_jmeter.cgi)。解压后打开,我们需要创建一个测试计划、一个线程组、一个请求和,如下图所示。
**测试计划**
<img src="https://static001.geekbang.org/resource/image/a6/4d/a6ad806b55cab54098f2b179c2cf874d.png" alt="">
**线程组**线程数这里设置为1000模拟大流量
<img src="https://static001.geekbang.org/resource/image/59/3a/590569c1b516d10af6e6ca9ee99f6a3a.png" alt="">
**请求**请求的路径是Tomcat自带的例子程序
<img src="https://static001.geekbang.org/resource/image/9e/3b/9efa851e885448e457b4883c8927ac3b.png" alt="">
2.启动Tomcat。
3.开启JMeter测试在View Results Tree中会看到大量失败的请求请求的响应里有“Connection reset”异常也就是前面提到的当accept队列溢出时服务端的内核发送了RST给客户端使得客户端抛出了这个异常。
<img src="https://static001.geekbang.org/resource/image/26/9c/267b3808cdc2673418f9b3ac44a59b9c.png" alt="">
4.修改内核参数,在`/etc/sysctl.conf`中增加一行`net.core.somaxconn=2048`,然后执行命令`sysctl -p`
5.修改Tomcat参数acceptCount为2048重启Tomcat。
<img src="https://static001.geekbang.org/resource/image/d1/0b/d12ea2188bddf803b62613fd59d8af0b.png" alt="">
6.再次启动JMeter测试这一次所有的请求会成功也看不到异常了。我们可以通过下面的命令看到系统中ESTABLISHED的连接数增大了这是因为我们加大了accept队列的长度。
<img src="https://static001.geekbang.org/resource/image/c6/e6/c6f610d4311c433a149ea9d3d4b5ade6.png" alt="">
## 本期精华
在Socket网络通信过程中我们不可避免地会碰到各种Java异常了解这些异常产生的原因非常关键通过这些信息我们大概知道问题出在哪里如果一时找不到问题代码我们还可以通过网络抓包工具来分析数据包。
在这个基础上我们还分析了Tomcat中两个比较重要的参数acceptCount和maxConnections。acceptCount用来控制内核的TCP连接队列长度maxConnections用于控制Tomcat层面的最大连接数。在实战环节我们通过调整acceptCount和相关的内核参数`somaxconn`,增加了系统的并发度。
## 课后思考
在上面的实验中,我们通过`netstat`命令发现有大量的TCP连接处在TIME_WAIT状态请问这是为什么它可能会带来什么样的问题呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,136 @@
<audio id="audio" title="39 | Tomcat进程占用CPU过高怎么办" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/b5/9fe8938a0e92a040a20aad313d1f28b5.mp3"></audio>
在性能优化这个主题里前面我们聊过了Tomcat的内存问题和网络相关的问题接下来我们看一下CPU的问题。CPU资源经常会成为系统性能的一个瓶颈这其中的原因是多方面的可能是内存泄露导致频繁GC进而引起CPU使用率过高又可能是代码中的Bug创建了大量的线程导致CPU上下文切换开销。
今天我们就来聊聊Tomcat进程的CPU使用率过高怎么办以及怎样一步一步找到问题的根因。
## “Java进程CPU使用率高”的解决思路是什么
通常我们所说的CPU使用率过高这里面其实隐含着一个用来比较高与低的基准值比如JVM在峰值负载下的平均CPU利用率为40如果CPU使用率飙到80%就可以被认为是不正常的。
典型的JVM进程包含多个Java线程其中一些在等待工作另一些则正在执行任务。在单个Java程序的情况下线程数可以非常低而对于处理大量并发事务的互联网后台来说线程数可能会比较高。
对于CPU的问题最重要的是要找到是**哪些线程在消耗CPU**通过线程栈定位到问题代码如果没有找到个别线程的CPU使用率特别高我们要怀疑到是不是线程上下文切换导致了CPU使用率过高。下面我们通过一个实例来学习CPU问题定位的过程。
## 定位高CPU使用率的线程和代码
1.写一个模拟程序来模拟CPU使用率过高的问题这个程序会在线程池中创建4096个线程。代码如下
```
@SpringBootApplication
@EnableScheduling
public class DemoApplication {
//创建线程池其中有4096个线程。
private ExecutorService executor = Executors.newFixedThreadPool(4096);
//全局变量,访问它需要加锁。
private int count;
//以固定的速率向线程池中加入任务
@Scheduled(fixedRate = 10)
public void lockContention() {
IntStream.range(0, 1000000)
.forEach(i -&gt; executor.submit(this::incrementSync));
}
//具体任务就是将count数加一
private synchronized void incrementSync() {
count = (count + 1) % 10000000;
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
```
2.在Linux环境下启动程序
```
java -Xss256k -jar demo-0.0.1-SNAPSHOT.jar
```
请注意这里我将线程栈大小指定为256KB。对于测试程序来说操作系统默认值8192KB过大因为我们需要创建4096个线程。
3.使用top命令我们看到Java进程的CPU使用率达到了262.3%注意到进程ID是4361。
<img src="https://static001.geekbang.org/resource/image/e0/50/e0db4c399cbbf83924a505a9cd619150.png" alt="">
4.接着我们用更精细化的top命令查看这个Java进程中各线程使用CPU的情况
```
#top -H -p 4361
```
<img src="https://static001.geekbang.org/resource/image/4a/8d/4a52b5335daf5bfe0b60128a1c13558d.png" alt="">
从图上我们可以看到有个叫“scheduling-1”的线程占用了较多的CPU达到了42.5%。因此下一步我们要找出这个线程在做什么事情。
5.为了找出线程在做什么事情我们需要用jstack命令生成线程快照具体方法是
```
jstack 4361
```
jstack的输出比较大你可以将输出写入文件
```
jstack 4361 &gt; 4361.log
```
然后我们打开4361.log定位到第4步中找到的名为“scheduling-1”的线程发现它的线程栈如下
<img src="https://static001.geekbang.org/resource/image/da/8e/dae7a6f02563051a1d4dd3752d9f5e8e.png" alt="">
从线程栈中我们看到了`AbstractExecutorService.submit`这个函数调用说明它是Spring Boot启动的周期性任务线程向线程池中提交任务这个线程消耗了大量CPU。
## 进一步分析上下文切换开销
一般来说通过上面的过程我们就能定位到大量消耗CPU的线程以及有问题的代码比如死循环。但是对于这个实例的问题你是否发现这样一个情况Java进程占用的CPU是262.3% 而“scheduling-1”线程只占用了42.5%的CPU那还有将近220%的CPU被谁占用了呢
不知道你注意到没有我们在第4步用`top -H -p 4361`命令看到的线程列表中还有许多名为“pool-1-thread-x”的线程它们单个的CPU使用率不高但是似乎数量比较多。你可能已经猜到这些就是线程池中干活的线程。那剩下的220%的CPU是不是被这些线程消耗了呢
要弄清楚这个问题我们还需要看jstack的输出结果主要是看这些线程池中的线程是不是真的在干活还是在“休息”呢
<img src="https://static001.geekbang.org/resource/image/68/bf/68bb91e5c1405940b470c08851d13cbf.png" alt="">
通过上面的图我们发现这些“pool-1-thread-x”线程基本都处于WAITING的状态那什么是WAITING状态呢或者说Java线程都有哪些状态呢你可以通过下面的图来理解一下
<img src="https://static001.geekbang.org/resource/image/0e/43/0e2336814a4b9fc39bcdf991949a7e43.png" alt="">
从图上我们看到“Blocking”和“Waiting”是两个不同的状态我们要注意它们的区别
- Blocking指的是一个线程因为等待临界区的锁Lock或者synchronized关键字而被阻塞的状态请你注意的是处于这个状态的线程**还没有拿到锁。**
- Waiting指的是一个线程拿到了锁但是需要等待其他线程执行某些操作。比如调用了Object.wait、Thread.join或者LockSupport.park方法时进入Waiting状态。**前提是这个线程已经拿到锁了**并且在进入Waiting状态前操作系统层面会自动释放锁当等待条件满足外部调用了Object.notify或者LockSupport.unpark方法线程会重新竞争锁成功获得锁后才能进入到Runnable状态继续执行。
回到我们的“pool-1-thread-x”线程这些线程都处在“Waiting”状态从线程栈我们看到这些线程“等待”在getTask方法调用上线程尝试从线程池的队列中取任务但是队列为空所以通过LockSupport.park调用进到了“Waiting”状态。那“pool-1-thread-x”线程有多少个呢通过下面这个命令来统计一下结果是4096正好跟线程池中的线程数相等。
<img src="https://static001.geekbang.org/resource/image/f7/3d/f7b4611b87a8bd65fa25a2c4c7228b3d.png" alt="">
你可能好奇了那剩下的220%的CPU到底被谁消耗了呢分析到这里我们应该怀疑CPU的上下文切换开销了因为我们看到Java进程中的线程数比较多。下面我们通过vmstat命令来查看一下操作系统层面的线程上下文切换活动
<img src="https://static001.geekbang.org/resource/image/07/c4/07cccbe33337df20a2544947281c71c4.png" alt="">
如果你还不太熟悉vmstat可以在[这里](https://linux.die.net/man/8/vmstat)学习如何使用vmstat和查看结果。其中cs那一栏表示线程上下文切换次数in表示CPU中断次数我们发现这两个数字非常高基本证实了我们的猜测线程上下文切切换消耗了大量CPU。那么问题来了具体是哪个进程导致的呢
我们停止Spring Boot测试程序再次运行vmstat命令会看到in和cs都大幅下降了这样就证实了引起线程上下文切换开销的Java进程正是4361。
<img src="https://static001.geekbang.org/resource/image/5f/fa/5f0a5dadc0659da607fd6e5f0c96dffa.png" alt="">
## 本期精华
当我们遇到CPU过高的问题时首先要定位是哪个进程的导致的之后可以通过`top -H -p pid`命令定位到具体的线程。其次还要通jstack查看线程的状态看看线程的个数或者线程的状态如果线程数过多可以怀疑是线程上下文切换的开销我们可以通过vmstat和pidstat这两个工具进行确认。
## 课后思考
哪些情况可能导致程序中的线程数失控,产生大量线程呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="40 | 谈谈Jetty性能调优的思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/41/1d672e12e7055ecef1acb50198278f41.mp3"></audio>
关于Tomcat的性能调优前面我主要谈了工作经常会遇到的有关JVM GC、监控、I/O和线程池以及CPU的问题定位和调优今天我们来看看Jetty有哪些调优的思路。
关于Jetty的性能调优官网上给出了一些很好的建议分为操作系统层面和Jetty本身的调优我们将分别来看一看它们具体是怎么做的最后再通过一个实战案例来学习一下如何确定Jetty的最佳线程数。
## 操作系统层面调优
对于Linux操作系统调优来说我们需要加大一些默认的限制值这些参数主要可以在`/etc/security/limits.conf`中或通过`sysctl`命令进行配置其实这些配置对于Tomcat来说也是适用的下面我来详细介绍一下这些参数。
**TCP缓冲区大小**
TCP的发送和接收缓冲区最好加大到16MB可以通过下面的命令配置
```
sysctl -w net.core.rmem_max = 16777216
sysctl -w net.core.wmem_max = 16777216
sysctl -w net.ipv4.tcp_rmem =“4096 87380 16777216”
sysctl -w net.ipv4.tcp_wmem =“4096 16384 16777216”
```
**TCP队列大小**
`net.core.somaxconn`控制TCP连接队列的大小默认值为128在高并发情况下明显不够用会出现拒绝连接的错误。但是这个值也不能调得过高因为过多积压的TCP连接会消耗服务端的资源并且会造成请求处理的延迟给用户带来不好的体验。因此我建议适当调大推荐设置为4096。
```
sysctl -w net.core.somaxconn = 4096
```
`net.core.netdev_max_backlog`用来控制Java程序传入数据包队列的大小可以适当调大。
```
sysctl -w net.core.netdev_max_backlog = 16384
sysctl -w net.ipv4.tcp_max_syn_backlog = 8192
sysctl -w net.ipv4.tcp_syncookies = 1
```
**端口**
如果Web应用程序作为客户端向远程服务器建立了很多TCP连接可能会出现TCP端口不足的情况。因此最好增加使用的端口范围并允许在TIME_WAIT中重用套接字
```
sysctl -w net.ipv4.ip_local_port_range =“1024 65535”
sysctl -w net.ipv4.tcp_tw_recycle = 1
```
**文件句柄数**
高负载服务器的文件句柄数很容易耗尽,这是因为系统默认值通常比较低,我们可以在`/etc/security/limits.conf`中为特定用户增加文件句柄数:
```
用户名 hard nofile 40000
用户名 soft nofile 40000
```
**拥塞控制**
Linux内核支持可插拔的拥塞控制算法如果要获取内核可用的拥塞控制算法列表可以通过下面的命令
```
sysctl net.ipv4.tcp_available_congestion_control
```
这里我推荐将拥塞控制算法设置为cubic
```
sysctl -w net.ipv4.tcp_congestion_control = cubic
```
## Jetty本身的调优
Jetty本身的调优主要是设置不同类型的线程的数量包括Acceptor和Thread Pool。
**Acceptors**
Acceptor的个数accepts应该设置为大于等于1并且小于等于CPU核数。
**Thread Pool**
限制Jetty的任务队列非常重要。默认情况下队列是无限的因此如果在高负载下超过Web应用的处理能力Jetty将在队列上积压大量待处理的请求。并且即使负载高峰过去了Jetty也不能正常响应新的请求这是因为仍然有很多请求在队列等着被处理。
因此对于一个高可靠性的系统我们应该通过使用有界队列立即拒绝过多的请求也叫快速失败。那队列的长度设置成多大呢应该根据Web应用的处理速度而定。比如如果Web应用每秒可以处理100个请求当负载高峰到来我们允许一个请求可以在队列积压60秒那么我们就可以把队列长度设置为60 × 100 = 6000。如果设置得太低Jetty将很快拒绝请求无法处理正常的高峰负载以下是配置示例
```
&lt;Configure id=&quot;Server&quot; class=&quot;org.eclipse.jetty.server.Server&quot;&gt;
&lt;Set name=&quot;ThreadPool&quot;&gt;
&lt;New class=&quot;org.eclipse.jetty.util.thread.QueuedThreadPool&quot;&gt;
&lt;!-- specify a bounded queue --&gt;
&lt;Arg&gt;
&lt;New class=&quot;java.util.concurrent.ArrayBlockingQueue&quot;&gt;
&lt;Arg type=&quot;int&quot;&gt;6000&lt;/Arg&gt;
&lt;/New&gt;
&lt;/Arg&gt;
&lt;Set name=&quot;minThreads&quot;&gt;10&lt;/Set&gt;
&lt;Set name=&quot;maxThreads&quot;&gt;200&lt;/Set&gt;
&lt;Set name=&quot;detailedDump&quot;&gt;false&lt;/Set&gt;
&lt;/New&gt;
&lt;/Set&gt;
&lt;/Configure&gt;
```
那如何配置Jetty的线程池中的线程数呢跟Tomcat一样你可以根据实际压测如果I/O越密集线程阻塞越严重那么线程数就可以配置多一些。通常情况增加线程数需要更多的内存因此内存的最大值也要跟着调整所以一般来说Jetty的最大线程数应该在50到500之间。
## Jetty性能测试
接下来我们通过一个实验来测试一下Jetty的性能。我们可以在[这里](https://repo1.maven.org/maven2/org/eclipse/jetty/aggregate/jetty-all/9.4.19.v20190610/jetty-all-9.4.19.v20190610-uber.jar)下载Jetty的JAR包。
<img src="https://static001.geekbang.org/resource/image/2d/c9/2d78b4d2b8eca5912ed5899aa57b73c9.png" alt="">
第二步我们创建一个Handler这个Handler用来向客户端返回“Hello World”并实现一个main方法根据传入的参数创建相应数量的线程池。
```
public class HelloWorld extends AbstractHandler {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
response.setContentType(&quot;text/html; charset=utf-8&quot;);
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println(&quot;&lt;h1&gt;Hello World&lt;/h1&gt;&quot;);
baseRequest.setHandled(true);
}
public static void main(String[] args) throws Exception {
//根据传入的参数控制线程池中最大线程数的大小
int maxThreads = Integer.parseInt(args[0]);
System.out.println(&quot;maxThreads:&quot; + maxThreads);
//创建线程池
QueuedThreadPool threadPool = new QueuedThreadPool();
threadPool.setMaxThreads(maxThreads);
Server server = new Server(threadPool);
ServerConnector http = new ServerConnector(server,
new HttpConnectionFactory(new HttpConfiguration()));
http.setPort(8000);
server.addConnector(http);
server.start();
server.join();
}
}
```
第三步我们编译这个Handler得到HelloWorld.class。
```
javac -cp jetty.jar HelloWorld.java
```
第四步启动Jetty server并且指定最大线程数为4。
```
java -cp .:jetty.jar HelloWorld 4
```
第五步启动压测工具Apache Bench。关于Apache Bench的使用你可以参考[这里](https://httpd.apache.org/docs/current/programs/ab.html)。
```
ab -n 200000 -c 100 http://localhost:8000/
```
上面命令的意思是向Jetty server发出20万个请求开启100个线程同时发送。
经过多次压测测试结果稳定以后在Linux 4核机器上得到的结果是这样的
<img src="https://static001.geekbang.org/resource/image/33/04/33b63aca5bc1e92864c01d1b5c11f804.png" alt="">
从上面的测试结果我们可以看到20万个请求在9.99秒内处理完成RPS达到了20020。 不知道你是否好奇为什么我把最大线程数设置为4呢是不是有点小
别着急接下来我们就试着逐步加大最大线程数直到找到最佳值。下面这个表格显示了在其他条件不变的情况下只调整线程数对RPS的影响。
<img src="https://static001.geekbang.org/resource/image/78/db/782ab4f927e8e27b762f7fcb48c48cdb.jpg" alt="">
我们发现一个有意思的现象线程数从4增加到6RPS确实增加了。但是线程数从6开始继续增加RPS不但没有跟着上升反而下降了而且线程数越多RPS越低。
发生这个现象的原因是测试机器的CPU只有4核而我们测试的程序做得事情比较简单没有I/O阻塞属于CPU密集型程序。对于这种程序最大线程数可以设置为比CPU核心稍微大一点点。那具体设置成多少是最佳值呢我们需要根据实验里的步骤反复测试。你可以看到在我们这个实验中当最大线程数为6也就CPU核数的1.5倍时,性能达到最佳。
## 本期精华
今天我们首先学习了Jetty调优的基本思路主要分为操作系统级别的调优和Jetty本身的调优其中操作系统级别也适用于Tomcat。接着我们通过一个实例来寻找Jetty的最佳线程数在测试中我们发现对于CPU密集型应用将最大线程数设置CPU核数的1.5倍是最佳的。因此,在我们的实际工作中,切勿将线程池直接设置得很大,因为程序所需要的线程数可能会比我们想象的要小。
## 课后思考
我在今天文章前面提到Jetty的最大线程数应该在50到500之间。但是我们的实验中测试发现最大线程数为6时最佳这是不是矛盾了
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,74 @@
<audio id="audio" title="41 | 热点问题答疑4 Tomcat和Jetty有哪些不同" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/04/0ae0712b99d5bcc08dbf1dcb43cec604.mp3"></audio>
作为专栏最后一个模块的答疑文章我想是时候总结一下Tomcat和Jetty的区别了。专栏里也有同学给我留言询问有关Tomcat和Jetty在系统选型时需要考虑的地方今天我也会通过一个实战案例来比较一下Tomcat和Jetty在实际场景下的表现帮你在做选型时有更深的理解。
我先来概括一下Tomcat和Jetty两者最大的区别。大体来说Tomcat的核心竞争力是**成熟稳定**因为它经过了多年的市场考验应用也相当广泛对于比较复杂的企业级应用支持得更加全面。也因为如此Tomcat在整体结构上比Jetty更加复杂功能扩展方面可能不如Jetty那么方便。
而Jetty比较年轻设计上更加**简洁小巧**配置也比较简单功能也支持方便地扩展和裁剪比如我们可以把Jetty的SessionHandler去掉以节省内存资源因此Jetty还可以运行在小型的嵌入式设备中比如手机和机顶盒。当然我们也可以自己开发一个Handler加入Handler链中用来扩展Jetty的功能。值得一提的是Hadoop和Solr都嵌入了Jetty作为Web服务器。
从设计的角度来看Tomcat的架构基于一种多级容器的模式这些容器组件具有父子关系所有组件依附于这个骨架而且这个骨架是不变的我们在扩展Tomcat的功能时也需要基于这个骨架因此Tomcat在设计上相对来说比较复杂。当然Tomcat也提供了较好的扩展机制比如我们可以自定义一个Valve但相对来说学习成本还是比较大的。而Jetty采用Handler责任链模式。由于Handler之间的关系比较松散Jetty提供HandlerCollection可以帮助开发者方便地构建一个Handler链同时也提供了ScopeHandler帮助开发者控制Handler链的访问顺序。关于这部分内容你可以回忆一下专栏里讲的回溯方式的责任链模式。
说了一堆理论你可能觉得还是有点抽象接下来我们通过一个实例来压测一下Tomcat和Jetty看看在同等流量压力下Tomcat和Jetty分别表现如何。需要说明的是通常我们从吞吐量、延迟和错误率这三个方面来比较结果。
测试的计划是这样的,我们还是用[专栏第36期](http://time.geekbang.org/column/article/112271)中的Spring Boot应用程序。首先用Spring Boot默认的Tomcat作为内嵌式Web容器经过一轮压测后将内嵌式的Web容器换成Jetty再做一轮测试然后比较结果。为了方便观察各种指标我在本地开发机器上做这个实验。
我们会在每个请求的处理过程中休眠1秒适当地模拟Web应用的I/O等待时间。JMeter客户端的线程数为100压测持续10分钟。在JMeter中创建一个Summary Report在这个页面上可以看到各种统计指标。
<img src="https://static001.geekbang.org/resource/image/88/b6/888ff5dc207d7bc746663c2be2d3dbb6.png" alt="">
第一步压测Tomcat。启动Spring Boot程序和JMeter持续10分钟以下是测试结果结果分为两部分
**吞吐量、延迟和错误率**
<img src="https://static001.geekbang.org/resource/image/eb/c9/eb8f119a6106da2ada200a86436df8c9.png" alt="">
**资源使用情况**
<img src="https://static001.geekbang.org/resource/image/8a/c6/8ad606fd374a375ccedae71c5eaadcc6.png" alt="">
第二步我们将Spring Boot的Web容器替换成Jetty具体步骤是在pom.xml文件中的spring-boot-starter-web依赖修改下面这样
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
&lt;exclusions&gt;
&lt;exclusion&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-tomcat&lt;/artifactId&gt;
&lt;/exclusion&gt;
&lt;/exclusions&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-jetty&lt;/artifactId&gt;
&lt;/dependency&gt;
```
编译打包启动Spring Boot再启动JMeter压测以下是测试结果
**吞吐量、延迟和错误率**
<img src="https://static001.geekbang.org/resource/image/16/c6/1613eca65656b08e467eae63238252c6.png" alt="">
**资源使用情况**
<img src="https://static001.geekbang.org/resource/image/60/9f/6039f999094dd390bb7a60ec63c5b19f.png" alt="">
下面我们通过一个表格来对比Tomcat和Jetty
<img src="https://static001.geekbang.org/resource/image/82/4f/824b67dbdb1cd7205427ab67a3ab864f.jpg" alt="">
从表格中的数据我们可以看到:
- **Jetty在吞吐量和响应速度方面稍有优势并且Jetty消耗的线程和内存资源明显比Tomcat要少这也恰好说明了Jetty在设计上更加小巧和轻量级的特点。**
- **但是Jetty有2.45%的错误率而Tomcat没有任何错误并且我经过多次测试都是这个结果。因此我们可以认为Tomcat比Jetty更加成熟和稳定。**
当然由于测试场景的限制以上数据并不能完全反映Tomcat和Jetty的真实能力。但是它可以在我们做选型的时候提供一些参考如果系统的目标是资源消耗尽量少并且对稳定性要求没有那么高可以选择轻量级的Jetty如果你的系统是比较关键的企业级应用建议还是选择Tomcat比较稳妥。
最后用一句话总结Tomcat和Jetty的区别**Tomcat好比是一位工作多年比较成熟的工程师轻易不会出错、不会掉链子但是他有自己的想法不会轻易做出改变。而Jetty更像是一位年轻的后起之秀脑子转得很快可塑性也很强但有时候也会犯一点小错误。**
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="23 | Host容器Tomcat如何实现热部署和热加载" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a7/73/a7049437f86d62aeb23a8c93d15f4473.mp3"></audio>
从这一期我们开始学习Tomcat的容器模块来聊一聊各容器组件实现的功能主要有热部署热加载、类加载机制以及Servlet规范的实现。最后还会谈到Spring Boot是如何与Web容器进行交互的。
今天我们首先来看热部署和热加载。要在运行的过程中升级Web应用如果你不想重启系统实现的方式有两种热加载和热部署。
那如何实现热部署和热加载呢?它们跟类加载机制有关,具体来说就是:
- 热加载的实现方式是Web容器启动一个后台线程定期检测类文件的变化如果有变化就重新加载类在这个过程中不会清空Session ,一般用在开发环境。
- 热部署原理类似也是由后台线程定时检测Web应用的变化但它会重新加载整个Web应用。这种方式会清空Session比热加载更加干净、彻底一般用在生产环境。
今天我们来学习一下Tomcat是如何用后台线程来实现热加载和热部署的。Tomcat通过开启后台线程使得各个层次的容器组件都有机会完成一些周期性任务。我们在实际工作中往往也需要执行一些周期性的任务比如监控程序周期性拉取系统的健康状态就可以借鉴这种设计。
## Tomcat的后台线程
要说开启后台线程做周期性的任务有经验的同学马上会想到线程池中的ScheduledThreadPoolExecutor它除了具有线程池的功能还能够执行周期性的任务。Tomcat就是通过它来开启后台线程的
```
bgFuture = exec.scheduleWithFixedDelay(
new ContainerBackgroundProcessor(),//要执行的Runnable
backgroundProcessorDelay, //第一次执行延迟多久
backgroundProcessorDelay, //之后每次执行间隔多久
TimeUnit.SECONDS); //时间单位
```
上面的代码调用了scheduleWithFixedDelay方法传入了四个参数第一个参数就是要周期性执行的任务类ContainerBackgroundProcessor它是一个Runnable同时也是ContainerBase的内部类ContainerBase是所有容器组件的基类我们来回忆一下容器组件有哪些有Engine、Host、Context和Wrapper等它们具有父子关系。
**ContainerBackgroundProcessor实现**
我们接来看ContainerBackgroundProcessor具体是如何实现的。
```
protected class ContainerBackgroundProcessor implements Runnable {
@Override
public void run() {
//请注意这里传入的参数是&quot;宿主类&quot;的实例
processChildren(ContainerBase.this);
}
protected void processChildren(Container container) {
try {
//1. 调用当前容器的backgroundProcess方法。
container.backgroundProcess();
//2. 遍历所有的子容器递归调用processChildren
//这样当前容器的子孙都会被处理
Container[] children = container.findChildren();
for (int i = 0; i &lt; children.length; i++) {
//这里请你注意容器基类有个变量叫做backgroundProcessorDelay如果大于0表明子容器有自己的后台线程无需父容器来调用它的processChildren方法。
if (children[i].getBackgroundProcessorDelay() &lt;= 0) {
processChildren(children[i]);
}
}
} catch (Throwable t) { ... }
```
上面的代码逻辑也是比较清晰的首先ContainerBackgroundProcessor是一个Runnable它需要实现run方法它的run很简单就是调用了processChildren方法。这里有个小技巧它把“宿主类”也就是**ContainerBase的类实例当成参数传给了run方法**。
而在processChildren方法里就做了两步调用当前容器的backgroundProcess方法以及递归调用子孙的backgroundProcess方法。请你注意backgroundProcess是Container接口中的方法也就是说所有类型的容器都可以实现这个方法在这个方法里完成需要周期性执行的任务。
这样的设计意味着什么呢我们只需要在顶层容器也就是Engine容器中启动一个后台线程那么这个线程**不但会执行Engine容器的周期性任务它还会执行所有子容器的周期性任务**。
**backgroundProcess方法**
上述代码都是在基类ContainerBase中实现的那具体容器类需要做什么呢其实很简单如果有周期性任务要执行就实现backgroundProcess方法如果没有就重用基类ContainerBase的方法。ContainerBase的backgroundProcess方法实现如下
```
public void backgroundProcess() {
//1.执行容器中Cluster组件的周期性任务
Cluster cluster = getClusterInternal();
if (cluster != null) {
cluster.backgroundProcess();
}
//2.执行容器中Realm组件的周期性任务
Realm realm = getRealmInternal();
if (realm != null) {
realm.backgroundProcess();
}
//3.执行容器中Valve组件的周期性任务
Valve current = pipeline.getFirst();
while (current != null) {
current.backgroundProcess();
current = current.getNext();
}
//4. 触发容器的&quot;周期事件&quot;Host容器的监听器HostConfig就靠它来调用
fireLifecycleEvent(Lifecycle.PERIODIC_EVENT, null);
}
```
从上面的代码可以看到不仅每个容器可以有周期性任务每个容器中的其他通用组件比如跟集群管理有关的Cluster组件、跟安全管理有关的Realm组件都可以有自己的周期性任务。
我在前面的专栏里提到过容器之间的链式调用是通过Pipeline-Valve机制来实现的从上面的代码你可以看到容器中的Valve也可以有周期性任务并且被ContainerBase统一处理。
请你特别注意的是在backgroundProcess方法的最后还触发了容器的“周期事件”。我们知道容器的生命周期事件有初始化、启动和停止等那“周期事件”又是什么呢它跟生命周期事件一样是一种扩展机制你可以这样理解
又一段时间过去了,容器还活着,你想做点什么吗?如果你想做点什么,就创建一个监听器来监听这个“周期事件”,事件到了我负责调用你的方法。
总之有了ContainerBase中的后台线程和backgroundProcess方法各种子容器和通用组件不需要各自弄一个后台线程来处理周期性任务这样的设计显得优雅和整洁。
## Tomcat热加载
有了ContainerBase的周期性任务处理“框架”作为具体容器子类只需要实现自己的周期性任务就行。而Tomcat的热加载就是在Context容器中实现的。Context容器的backgroundProcess方法是这样实现的
```
public void backgroundProcess() {
//WebappLoader周期性的检查WEB-INF/classes和WEB-INF/lib目录下的类文件
Loader loader = getLoader();
if (loader != null) {
loader.backgroundProcess();
}
//Session管理器周期性的检查是否有过期的Session
Manager manager = getManager();
if (manager != null) {
manager.backgroundProcess();
}
//周期性的检查静态资源是否有变化
WebResourceRoot resources = getResources();
if (resources != null) {
resources.backgroundProcess();
}
//调用父类ContainerBase的backgroundProcess方法
super.backgroundProcess();
}
```
从上面的代码我们看到Context容器通过WebappLoader来检查类文件是否有更新通过Session管理器来检查是否有Session过期并且通过资源管理器来检查静态资源是否有更新最后还调用了父类ContainerBase的backgroundProcess方法。
这里我们要重点关注WebappLoader是如何实现热加载的它主要是调用了Context容器的reload方法而Context的reload方法比较复杂总结起来主要完成了下面这些任务
1. 停止和销毁Context容器及其所有子容器子容器其实就是Wrapper也就是说Wrapper里面Servlet实例也被销毁了。
1. 停止和销毁Context容器关联的Listener和Filter。
1. 停止和销毁Context下的Pipeline和各种Valve。
1. 停止和销毁Context的类加载器以及类加载器加载的类文件资源。
1. 启动Context容器在这个过程中会重新创建前面四步被销毁的资源。
在这个过程中类加载器发挥着关键作用。一个Context容器对应一个类加载器类加载器在销毁的过程中会把它加载的所有类也全部销毁。Context容器在启动过程中会创建一个新的类加载器来加载新的类文件。
在Context的reload方法里并没有调用Session管理器的destroy方法也就是说这个Context关联的Session是没有销毁的。你还需要注意的是Tomcat的热加载默认是关闭的你需要在conf目录下的context.xml文件中设置reloadable参数来开启这个功能像下面这样
```
&lt;Context reloadable=&quot;true&quot;/&gt;
```
## Tomcat热部署
我们再来看看热部署热部署跟热加载的本质区别是热部署会重新部署Web应用原来的Context对象会整个被销毁掉因此这个Context所关联的一切资源都会被销毁包括Session。
那么Tomcat热部署又是由哪个容器来实现的呢应该不是由Context因为热部署过程中Context容器被销毁了那么这个重担就落在Host身上了因为它是Context的父容器。
跟Context不一样Host容器并没有在backgroundProcess方法中实现周期性检测的任务而是通过监听器HostConfig来实现的HostConfig就是前面提到的“周期事件”的监听器那“周期事件”达到时HostConfig会做什么事呢
```
public void lifecycleEvent(LifecycleEvent event) {
// 执行check方法。
if (event.getType().equals(Lifecycle.PERIODIC_EVENT)) {
check();
}
}
```
它执行了check方法我们接着来看check方法里做了什么。
```
protected void check() {
if (host.getAutoDeploy()) {
// 检查这个Host下所有已经部署的Web应用
DeployedApplication[] apps =
deployed.values().toArray(new DeployedApplication[0]);
for (int i = 0; i &lt; apps.length; i++) {
//检查Web应用目录是否有变化
checkResources(apps[i], false);
}
//执行部署
deployApps();
}
}
```
其实HostConfig会检查webapps目录下的所有Web应用
- 如果原来Web应用目录被删掉了就把相应Context容器整个销毁掉。
- 是否有新的Web应用目录放进来了或者有新的WAR包放进来了就部署相应的Web应用。
因此HostConfig做的事情都是比较“宏观”的它不会去检查具体类文件或者资源文件是否有变化而是检查Web应用目录级别的变化。
## 本期精华
今天我们学习Tomcat的热加载和热部署它们的目的都是在不重启Tomcat的情况下实现Web应用的更新。
热加载的粒度比较小主要是针对类文件的更新通过创建新的类加载器来实现重新加载。而热部署是针对整个Web应用的Tomcat会将原来的Context对象整个销毁掉再重新创建Context容器对象。
热加载和热部署的实现都离不开后台线程的周期性检查Tomcat在基类ContainerBase中统一实现了后台线程的处理逻辑并在顶层容器Engine启动后台线程这样子容器组件甚至各种通用组件都不需要自己去创建后台线程这样的设计显得优雅整洁。
## 课后思考
为什么Host容器不通过重写backgroundProcess方法来实现热部署呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,216 @@
<audio id="audio" title="24 | Context容器Tomcat如何打破双亲委托机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/95/80efe60469187d94ab5d63c83cc21195.mp3"></audio>
相信我们平时在工作中都遇到过ClassNotFound异常这个异常表示JVM在尝试加载某个类的时候失败了。想要解决这个问题首先你需要知道什么是类加载JVM是如何加载类的以及为什么会出现ClassNotFound异常弄懂上面这些问题之后我们接着要思考Tomcat作为Web容器它是如何加载和管理Web应用下的Servlet呢
Tomcat正是通过Context组件来加载管理Web应用的所以今天我会详细分析Tomcat的类加载机制。但在这之前我们有必要预习一下JVM的类加载机制我会先回答一下一开始抛出来的问题接着再谈谈Tomcat的类加载器如何打破Java的双亲委托机制。
## JVM的类加载器
Java的类加载就是把字节码格式“.class”文件加载到JVM的**方法区**并在JVM的**堆区**建立一个`java.lang.Class`对象的实例用来封装Java类相关的数据和方法。那Class对象又是什么呢你可以把它理解成业务类的模板JVM根据这个模板来创建具体业务类对象实例。
JVM并不是在启动时就把所有的“.class”文件都加载一遍而是程序在运行过程中用到了这个类才去加载。JVM类加载是由类加载器来完成的JDK提供一个抽象类ClassLoader这个抽象类中定义了三个关键方法理解清楚它们的作用和关系非常重要。
```
public abstract class ClassLoader {
//每个类加载器都有个父加载器
private final ClassLoader parent;
public Class&lt;?&gt; loadClass(String name) {
//查找一下这个类是不是已经加载过了
Class&lt;?&gt; c = findLoadedClass(name);
//如果没有加载过
if( c == null ){
//先委托给父加载器去加载,注意这是个递归调用
if (parent != null) {
c = parent.loadClass(name);
}else {
// 如果父加载器为空查找Bootstrap加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
}
// 如果父加载器没加载成功调用自己的findClass去加载
if (c == null) {
c = findClass(name);
}
return c
}
protected Class&lt;?&gt; findClass(String name){
//1. 根据传入的类名name到在特定目录下去寻找类文件把.class文件读入内存
...
//2. 调用defineClass将字节数组转成Class对象
return defineClass(buf, off, len)
}
// 将字节码数组解析成一个Class对象用native方法实现
protected final Class&lt;?&gt; defineClass(byte[] b, int off, int len){
...
}
}
```
从上面的代码我们可以得到几个关键信息:
- JVM的类加载器是分层次的它们有父子关系每个类加载器都持有一个parent字段指向父加载器。
- defineClass是个工具方法它的职责是调用native方法把Java类的字节码解析成一个Class对象所谓的native方法就是由C语言实现的方法Java通过JNI机制调用。
- findClass方法的主要职责就是找到“.class”文件可能来自文件系统或者网络找到后把“.class”文件读到内存得到字节码数组然后调用defineClass方法得到Class对象。
- loadClass是个public方法说明它才是对外提供服务的接口具体实现也比较清晰首先检查这个类是不是已经被加载过了如果加载过了直接返回否则交给父加载器去加载。请你注意这是一个递归调用也就是说子加载器持有父加载器的引用当一个类加载器需要加载一个Java类时会先委托父加载器去加载然后父加载器在自己的加载路径中搜索Java类当父加载器在自己的加载范围内找不到时才会交还给子加载器加载这就是双亲委托机制。
JDK中有哪些默认的类加载器它们的本质区别是什么为什么需要双亲委托机制JDK中有3个类加载器另外你也可以自定义类加载器它们的关系如下图所示。
<img src="https://static001.geekbang.org/resource/image/43/90/43ebbd8e7d24467d2182969fb0496d90.png" alt="">
- BootstrapClassLoader是启动类加载器由C语言实现用来加载JVM启动时所需要的核心类比如`rt.jar``resources.jar`等。
- ExtClassLoader是扩展类加载器用来加载`\jre\lib\ext`目录下JAR包。
- AppClassLoader是系统类加载器用来加载classpath下的类应用程序默认用它来加载类。
- 自定义类加载器,用来加载自定义路径下的类。
这些类加载器的工作原理是一样的区别是它们的加载路径不同也就是说findClass这个方法查找的路径不同。双亲委托机制是为了保证一个Java类在JVM中是唯一的假如你不小心写了一个与JRE核心类同名的类比如Object类双亲委托机制能保证加载的是JRE里的那个Object类而不是你写的Object类。这是因为AppClassLoader在加载你的Object类时会委托给ExtClassLoader去加载而ExtClassLoader又会委托给BootstrapClassLoaderBootstrapClassLoader发现自己已经加载过了Object类会直接返回不会去加载你写的Object类。
这里请你注意类加载器的父子关系不是通过继承来实现的比如AppClassLoader并不是ExtClassLoader的子类而是说AppClassLoader的parent成员变量指向ExtClassLoader对象。同样的道理如果你要自定义类加载器不去继承AppClassLoader而是继承ClassLoader抽象类再重写findClass和loadClass方法即可Tomcat就是通过自定义类加载器来实现自己的类加载逻辑。不知道你发现没有如果你要打破双亲委托机制就需要重写loadClass方法因为loadClass的默认实现就是双亲委托机制。
## Tomcat的类加载器
Tomcat的自定义类加载器WebAppClassLoader打破了双亲委托机制它**首先自己尝试去加载某个类,如果找不到再代理给父类加载器**其目的是优先加载Web应用自己定义的类。具体实现就是重写ClassLoader的两个方法findClass和loadClass。
**findClass方法**
我们先来看看findClass方法的实现为了方便理解和阅读我去掉了一些细节
```
public Class&lt;?&gt; findClass(String name) throws ClassNotFoundException {
...
Class&lt;?&gt; clazz = null;
try {
//1. 先在Web应用目录下查找类
clazz = findClassInternal(name);
} catch (RuntimeException e) {
throw e;
}
if (clazz == null) {
try {
//2. 如果在本地目录没有找到,交给父加载器去查找
clazz = super.findClass(name);
} catch (RuntimeException e) {
throw e;
}
//3. 如果父类也没找到抛出ClassNotFoundException
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
```
在findClass方法里主要有三个步骤
1. 先在Web应用本地目录下查找要加载的类。
1. 如果没有找到交给父加载器去查找它的父加载器就是上面提到的系统类加载器AppClassLoader。
1. 如何父加载器也没找到这个类抛出ClassNotFound异常。
**loadClass方法**
接着我们再来看Tomcat类加载器的loadClass方法的实现同样我也去掉了一些细节
```
public Class&lt;?&gt; loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class&lt;?&gt; clazz = null;
//1. 先在本地cache查找该类是否已经加载过
clazz = findLoadedClass0(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
//2. 从系统类加载器的cache中查找是否加载过
clazz = findLoadedClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
// 3. 尝试用ExtClassLoader类加载器类加载为什么
ClassLoader javaseLoader = getJavaseClassLoader();
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 4. 尝试在本地目录搜索class并加载
try {
clazz = findClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 5. 尝试用系统类加载器(也就是AppClassLoader)来加载
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
//6. 上述过程都加载失败,抛出异常
throw new ClassNotFoundException(name);
}
```
loadClass方法稍微复杂一点主要有六个步骤
1. 先在本地Cache查找该类是否已经加载过也就是说Tomcat的类加载器是否已经加载过这个类。
1. 如果Tomcat类加载器没有加载过这个类再看看系统类加载器是否加载过。
1. 如果都没有,就让**ExtClassLoader**去加载,这一步比较关键,目的**防止Web应用自己的类覆盖JRE的核心类**。因为Tomcat需要打破双亲委托机制假如Web应用里自定义了一个叫Object的类如果先加载这个Object类就会覆盖JRE里面的那个Object类这就是为什么Tomcat的类加载器会优先尝试用ExtClassLoader去加载因为ExtClassLoader会委托给BootstrapClassLoader去加载BootstrapClassLoader发现自己已经加载了Object类直接返回给Tomcat的类加载器这样Tomcat的类加载器就不会去加载Web应用下的Object类了也就避免了覆盖JRE核心类的问题。
1. 如果ExtClassLoader加载器加载失败也就是说JRE核心类中没有这类那么就在本地Web应用目录下查找并加载。
1. 如果本地目录下没有这个类说明不是Web应用自己定义的类那么由系统类加载器去加载。这里请你注意Web应用是通过`Class.forName`调用交给系统类加载器的,因为`Class.forName`的默认加载器就是系统类加载器。
1. 如果上述加载过程全部失败抛出ClassNotFound异常。
从上面的过程我们可以看到Tomcat的类加载器打破了双亲委托机制没有一上来就直接委托给父加载器而是先在本地目录下加载为了避免本地目录下的类覆盖JRE的核心类先尝试用JVM扩展类加载器ExtClassLoader去加载。那为什么不先用系统类加载器AppClassLoader去加载很显然如果是这样的话那就变成双亲委托机制了这就是Tomcat类加载器的巧妙之处。
## 本期精华
今天我介绍了JVM的类加载器原理和源码剖析以及Tomcat的类加载器是如何打破双亲委托机制的目的是为了优先加载Web应用目录下的类然后再加载其他目录下的类这也是Servlet规范的推荐做法。
要打破双亲委托机制需要继承ClassLoader抽象类并且需要重写它的loadClass方法因为ClassLoader的默认实现就是双亲委托。
## 课后思考
如果你并不想打破双亲委托机制但是又想定义自己的类加载器来加载特定目录下的类你需要重写findClass和loadClass方法中的哪一个还是两个都要重写
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="25 | Context容器Tomcat如何隔离Web应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/ce/c3dbdc4cb28ef833a956e29578e436ce.mp3"></audio>
我在专栏上一期提到Tomcat通过自定义类加载器WebAppClassLoader打破了双亲委托机制具体来说就是重写了JVM的类加载器ClassLoader的findClass方法和loadClass方法这样做的目的是优先加载Web应用目录下的类。除此之外你觉得Tomcat的类加载器还需要完成哪些需求呢或者说在设计上还需要考虑哪些方面
我们知道Tomcat作为Servlet容器它负责加载我们的Servlet类此外它还负责加载Servlet所依赖的JAR包。并且Tomcat本身也是一个Java程序因此它需要加载自己的类和依赖的JAR包。首先让我们思考这一下这几个问题
1. 假如我们在Tomcat中运行了两个Web应用程序两个Web应用中有同名的Servlet但是功能不同Tomcat需要同时加载和管理这两个同名的Servlet类保证它们不会冲突因此Web应用之间的类需要隔离。
1. 假如两个Web应用都依赖同一个第三方的JAR包比如Spring那Spring的JAR包被加载到内存后Tomcat要保证这两个Web应用能够共享也就是说Spring的JAR包只被加载一次否则随着依赖的第三方JAR包增多JVM的内存会膨胀。
1. 跟JVM一样我们需要隔离Tomcat本身的类和Web应用的类。
在了解了Tomcat的类加载器在设计时要考虑的这些问题以后今天我们主要来学习一下Tomcat是如何通过设计多层次的类加载器来解决这些问题的。
## Tomcat类加载器的层次结构
为了解决这些问题Tomcat设计了类加载器的层次结构它们的关系如下图所示。下面我来详细解释为什么要设计这些类加载器告诉你它们是怎么解决上面这些问题的。
<img src="https://static001.geekbang.org/resource/image/62/23/6260716096c77cb89a375e4ac3572923.png" alt="">
我们先来看**第1个问题**假如我们使用JVM默认AppClassLoader来加载Web应用AppClassLoader只能加载一个Servlet类在加载第二个同名Servlet类时AppClassLoader会返回第一个Servlet类的Class实例这是因为在AppClassLoader看来同名的Servlet类只被加载一次。
因此Tomcat的解决方案是自定义一个类加载器WebAppClassLoader 并且给每个Web应用创建一个类加载器实例。我们知道Context容器组件对应一个Web应用因此每个Context容器负责创建和维护一个WebAppClassLoader加载器实例。这背后的原理是**不同的加载器实例加载的类被认为是不同的类**即使它们的类名相同。这就相当于在Java虚拟机内部创建了一个个相互隔离的Java类空间每一个Web应用都有自己的类空间Web应用之间通过各自的类加载器互相隔离。
**SharedClassLoader**
我们再来看**第2个问题**本质需求是两个Web应用之间怎么共享库类并且不能重复加载相同的类。我们知道在双亲委托机制里各个子加载器都可以通过父加载器去加载类那么把需要共享的类放到父加载器的加载路径下不就行了吗应用程序也正是通过这种方式共享JRE的核心类。因此Tomcat的设计者又加了一个类加载器SharedClassLoader作为WebAppClassLoader的父加载器专门来加载Web应用之间共享的类。如果WebAppClassLoader自己没有加载到某个类就会委托父加载器SharedClassLoader去加载这个类SharedClassLoader会在指定目录下加载共享类之后返回给WebAppClassLoader这样共享的问题就解决了。
**CatalinaClassLoader**
我们来看**第3个问题**如何隔离Tomcat本身的类和Web应用的类我们知道要共享可以通过父子关系要隔离那就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的它们可能拥有同一个父加载器但是两个兄弟类加载器加载的类是隔离的。基于此Tomcat又设计一个类加载器CatalinaClassLoader专门来加载Tomcat自身的类。这样设计有个问题那Tomcat和各Web应用之间需要共享一些类时该怎么办呢
**CommonClassLoader**
老办法还是再增加一个CommonClassLoader作为CatalinaClassLoader和SharedClassLoader的父加载器。CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader 使用而CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离。WebAppClassLoader可以使用SharedClassLoader加载到的类但各个WebAppClassLoader实例之间相互隔离。
## Spring的加载问题
在JVM的实现中有一条隐含的规则默认情况下如果一个类由类加载器A加载那么这个类的依赖类也是由相同的类加载器加载。比如Spring作为一个Bean工厂它需要创建业务类的实例并且在创建业务类实例之前需要加载这些类。Spring是通过调用`Class.forName`来加载业务类的我们来看一下forName的源码
```
public static Class&lt;?&gt; forName(String className) {
Class&lt;?&gt; caller = Reflection.getCallerClass();
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
```
可以看到在forName的函数里会用调用者也就是Spring的加载器去加载业务类。
我在前面提到Web应用之间共享的JAR包可以交给SharedClassLoader来加载从而避免重复加载。Spring作为共享的第三方JAR包它本身是由SharedClassLoader来加载的Spring又要去加载业务类按照前面那条规则加载Spring的类加载器也会用来加载业务类但是业务类在Web应用目录下不在SharedClassLoader的加载路径下这该怎么办呢
于是线程上下文加载器登场了它其实是一种类加载器传递机制。为什么叫作“线程上下文加载器”呢因为这个类加载器保存在线程私有数据里只要是同一个线程一旦设置了线程上下文加载器在线程后续执行过程中就能把这个类加载器取出来用。因此Tomcat为每个Web应用创建一个WebAppClassLoader类加载器并在启动Web应用的线程里设置线程上下文加载器这样Spring在启动时就将线程上下文加载器取出来用来加载Bean。Spring取线程上下文加载的代码如下
```
cl = Thread.currentThread().getContextClassLoader();
```
## 本期精华
今天我介绍了JVM的类加载器原理并剖析了源码以及Tomcat的类加载器的设计。重点需要你理解的是Tomcat的Context组件为每个Web应用创建一个WebAppClassLoader类加载器由于**不同类加载器实例加载的类是互相隔离的**因此达到了隔离Web应用的目的同时通过CommonClassLoader等父加载器来共享第三方JAR包。而共享的第三方JAR包怎么加载特定Web应用的类呢可以通过设置线程上下文加载器来解决。而作为Java程序员我们应该牢记的是
- 每个Web应用自己的Java类文件和依赖的JAR包分别放在`WEB-INF/classes``WEB-INF/lib`目录下面。
- 多个应用共享的Java类文件和JAR包分别放在Web容器指定的共享目录下。
- 当出现ClassNotFound错误时应该检查你的类加载器是否正确。
线程上下文加载器不仅仅可以用在Tomcat和Spring类加载的场景里核心框架类需要加载具体实现类时都可以用到它比如我们熟悉的JDBC就是通过上下文类加载器来加载不同的数据库驱动的感兴趣的话可以深入了解一下。
## 课后思考
在StandardContext的启动方法里会将当前线程的上下文加载器设置为WebAppClassLoader。
```
originalClassLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(webApplicationClassLoader);
```
在启动方法结束的时候,还会恢复线程的上下文加载器:
```
Thread.currentThread().setContextClassLoader(originalClassLoader);
```
这是为什么呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,210 @@
<audio id="audio" title="26 | Context容器Tomcat如何实现Servlet规范" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/34/9bab34cddb058c3054405af038809e34.mp3"></audio>
我们知道Servlet容器最重要的任务就是创建Servlet的实例并且调用Servlet在前面两期我谈到了Tomcat如何定义自己的类加载器来加载Servlet但加载Servlet的类不等于创建Servlet的实例类加载只是第一步类加载好了才能创建类的实例也就是说Tomcat先加载Servlet的类然后在Java堆上创建了一个Servlet实例。
一个Web应用里往往有多个Servlet而在Tomcat中一个Web应用对应一个Context容器也就是说一个Context容器需要管理多个Servlet实例。但Context容器并不直接持有Servlet实例而是通过子容器Wrapper来管理Servlet你可以把Wrapper容器看作是Servlet的包装。
那为什么需要Wrapper呢Context容器直接维护一个Servlet数组不就行了吗这是因为Servlet不仅仅是一个类实例它还有相关的配置信息比如它的URL映射、它的初始化参数因此设计出了一个包装器把Servlet本身和它相关的数据包起来没错这就是面向对象的思想。
那管理好Servlet就完事大吉了吗别忘了Servlet还有两个兄弟Listener和Filter它们也是Servlet规范中的重要成员因此Tomcat也需要创建它们的实例也需要在合适的时机去调用它们的方法。
说了那么多下面我们就来聊一聊Tomcat是如何做到上面这些事的。
## Servlet管理
前面提到Tomcat是用Wrapper容器来管理Servlet的那Wrapper容器具体长什么样子呢我们先来看看它里面有哪些关键的成员变量
```
protected volatile Servlet instance = null;
```
毫无悬念它拥有一个Servlet实例并且Wrapper通过loadServlet方法来实例化Servlet。为了方便你阅读我简化了代码
```
public synchronized Servlet loadServlet() throws ServletException {
Servlet servlet;
//1. 创建一个Servlet实例
servlet = (Servlet) instanceManager.newInstance(servletClass);
//2.调用了Servlet的init方法这是Servlet规范要求的
initServlet(servlet);
return servlet;
}
```
其实loadServlet主要做了两件事创建Servlet的实例并且调用Servlet的init方法因为这是Servlet规范要求的。
那接下来的问题是什么时候会调到这个loadServlet方法呢为了加快系统的启动速度我们往往会采取资源延迟加载的策略Tomcat也不例外默认情况下Tomcat在启动时不会加载你的Servlet除非你把Servlet的`loadOnStartup`参数设置为`true`
这里还需要你注意的是虽然Tomcat在启动时不会创建Servlet实例但是会创建Wrapper容器就好比尽管枪里面还没有子弹先把枪造出来。那子弹什么时候造呢是真正需要开枪的时候也就是说有请求来访问某个Servlet时这个Servlet的实例才会被创建。
那Servlet是被谁调用的呢我们回忆一下专栏前面提到过Tomcat的Pipeline-Valve机制每个容器组件都有自己的Pipeline每个Pipeline中有一个Valve链并且每个容器组件有一个BasicValve基础阀。Wrapper作为一个容器组件它也有自己的Pipeline和BasicValveWrapper的BasicValve叫**StandardWrapperValve**。
你可以想到当请求到来时Context容器的BasicValve会调用Wrapper容器中Pipeline中的第一个Valve然后会调用到StandardWrapperValve。我们先来看看它的invoke方法是如何实现的同样为了方便你阅读我简化了代码
```
public final void invoke(Request request, Response response) {
//1.实例化Servlet
servlet = wrapper.allocate();
//2.给当前请求创建一个Filter链
ApplicationFilterChain filterChain =
ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
//3. 调用这个Filter链Filter链中的最后一个Filter会调用Servlet
filterChain.doFilter(request.getRequest(), response.getResponse());
}
```
StandardWrapperValve的invoke方法比较复杂去掉其他异常处理的一些细节本质上就是三步
- 第一步创建Servlet实例
- 第二步给当前请求创建一个Filter链
- 第三步调用这个Filter链。
你可能会问为什么需要给每个请求创建一个Filter链这是因为每个请求的请求路径都不一样而Filter都有相应的路径映射因此不是所有的Filter都需要来处理当前的请求我们需要根据请求的路径来选择特定的一些Filter来处理。
第二个问题是为什么没有看到调到Servlet的service方法这是因为Filter链的doFilter方法会负责调用Servlet具体来说就是Filter链中的最后一个Filter会负责调用Servlet。
接下来我们来看Filter的实现原理。
## Filter管理
我们知道跟Servlet一样Filter也可以在`web.xml`文件里进行配置不同的是Filter的作用域是整个Web应用因此Filter的实例是在Context容器中进行管理的Context容器用Map集合来保存Filter。
```
private Map&lt;String, FilterDef&gt; filterDefs = new HashMap&lt;&gt;();
```
那上面提到的Filter链又是什么呢Filter链的存活期很短它是跟每个请求对应的。一个新的请求来了就动态创建一个Filter链请求处理完了Filter链也就被回收了。理解它的原理也非常关键我们还是来看看源码
```
public final class ApplicationFilterChain implements FilterChain {
//Filter链中有Filter数组这个好理解
private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0];
//Filter链中的当前的调用位置
private int pos = 0;
//总共有多少了Filter
private int n = 0;
//每个Filter链对应一个Servlet也就是它要调用的Servlet
private Servlet servlet = null;
public void doFilter(ServletRequest req, ServletResponse res) {
internalDoFilter(request,response);
}
private void internalDoFilter(ServletRequest req,
ServletResponse res){
// 每个Filter链在内部维护了一个Filter数组
if (pos &lt; n) {
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
filter.doFilter(request, response, this);
return;
}
servlet.service(request, response);
}
```
从ApplicationFilterChain的源码我们可以看到几个关键信息
1. Filter链中除了有Filter对象的数组还有一个整数变量pos这个变量用来记录当前被调用的Filter在数组中的位置。
1. Filter链中有个Servlet实例这个好理解因为上面提到了每个Filter链最后都会调到一个Servlet。
1. Filter链本身也实现了doFilter方法直接调用了一个内部方法internalDoFilter。
1. internalDoFilter方法的实现比较有意思它做了一个判断如果当前Filter的位置小于Filter数组的长度也就是说Filter还没调完就从Filter数组拿下一个Filter调用它的doFilter方法。否则意味着所有Filter都调到了就调用Servlet的service方法。
但问题是方法体里没看到循环谁在不停地调用Filter链的doFilter方法呢Filter是怎么依次调到的呢
答案是**Filter本身的doFilter方法会调用Filter链的doFilter方法**,我们还是来看看代码就明白了:
```
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain){
...
//调用Filter的方法
chain.doFilter(request, response);
}
```
注意Filter的doFilter方法有个关键参数FilterChain就是Filter链。并且每个Filter在实现doFilter时必须要调用Filter链的doFilter方法而Filter链中保存当前Filter的位置会调用下一个Filter的doFilter方法这样链式调用就完成了。
Filter链跟Tomcat的Pipeline-Valve本质都是责任链模式但是在具体实现上稍有不同你可以细细体会一下。
## Listener管理
我们接着聊Servlet规范里Listener。跟Filter一样Listener也是一种扩展机制你可以监听容器内部发生的事件主要有两类事件
- 第一类是生命状态的变化比如Context容器启动和停止、Session的创建和销毁。
- 第二类是属性的变化比如Context容器某个属性值变了、Session的某个属性值变了以及新的请求来了等。
我们可以在`web.xml`配置或者通过注解的方式来添加监听器在监听器里实现我们的业务逻辑。对于Tomcat来说它需要读取配置文件拿到监听器类的名字实例化这些类并且在合适的时机调用这些监听器的方法。
Tomcat是通过Context容器来管理这些监听器的。Context容器将两类事件分开来管理分别用不同的集合来存放不同类型事件的监听器
```
//监听属性值变化的监听器
private List&lt;Object&gt; applicationEventListenersList = new CopyOnWriteArrayList&lt;&gt;();
//监听生命事件的监听器
private Object applicationLifecycleListenersObjects[] = new Object[0];
```
剩下的事情就是触发监听器了比如在Context容器的启动方法里就触发了所有的ServletContextListener
```
//1.拿到所有的生命周期监听器
Object instances[] = getApplicationLifecycleListeners();
for (int i = 0; i &lt; instances.length; i++) {
//2. 判断Listener的类型是不是ServletContextListener
if (!(instances[i] instanceof ServletContextListener))
continue;
//3.触发Listener的方法
ServletContextListener lr = (ServletContextListener) instances[i];
lr.contextInitialized(event);
}
```
需要注意的是这里的ServletContextListener接口是一种留给用户的扩展机制用户可以实现这个接口来定义自己的监听器监听Context容器的启停事件。Spring就是这么做的。ServletContextListener跟Tomcat自己的生命周期事件LifecycleListener是不同的。LifecycleListener定义在生命周期管理组件中由基类LifecycleBase统一管理。
## 本期精华
Servlet规范中最重要的就是Servlet、Filter和Listener“三兄弟”。Web容器最重要的职能就是把它们创建出来并在适当的时候调用它们的方法。
Tomcat通过Wrapper容器来管理ServletWrapper包装了Servlet本身以及相应的参数这体现了面向对象中“封装”的设计原则。
Tomcat会给**每个请求生成一个Filter链**Filter链中的最后一个Filter会负责调用Servlet的service方法。
对于Listener来说我们可以定制自己的监听器来监听Tomcat内部发生的各种事件包括Web应用级别的、Session级别的和请求级别的。Tomcat中的Context容器统一维护了这些监听器并负责触发。
最后小结一下这3期内容Context组件通过自定义类加载器来加载Web应用并实现了Servlet规范直接跟Web应用打交道是一个核心的容器组件。也因此我用了很重的篇幅去讲解它也非常建议你花点时间阅读一下它的源码。
## 课后思考
Context容器分别用了CopyOnWriteArrayList和对象数组来存储两种不同的监听器为什么要这样设计你可以思考一下背后的原因。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,210 @@
<audio id="audio" title="27 | 新特性Tomcat如何支持异步Servlet" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/fe/2fe0ba6d4f94b033c41f112a237045fe.mp3"></audio>
通过专栏前面的学习我们知道当一个新的请求到达时Tomcat和Jetty会从线程池里拿出一个线程来处理请求这个线程会调用你的Web应用Web应用在处理请求的过程中Tomcat线程会一直阻塞直到Web应用处理完毕才能再输出响应最后Tomcat才回收这个线程。
我们来思考这样一个问题假如你的Web应用需要较长的时间来处理请求比如数据库查询或者等待下游的服务调用返回那么Tomcat线程一直不回收会占用系统资源在极端情况下会导致“线程饥饿”也就是说Tomcat和Jetty没有更多的线程来处理新的请求。
那该如何解决这个问题呢方案是Servlet 3.0中引入的异步Servlet。主要是在Web应用里启动一个单独的线程来执行这些比较耗时的请求而Tomcat线程立即返回不再等待Web应用将请求处理完这样Tomcat线程可以立即被回收到线程池用来响应其他请求降低了系统的资源消耗同时还能提高系统的吞吐量。
今天我们就来学习一下如何开发一个异步Servlet以及异步Servlet的工作原理也就是Tomcat是如何支持异步Servlet的让你彻底理解它的来龙去脉。
## 异步Servlet示例
我们先通过一个简单的示例来了解一下异步Servlet的实现。
```
@WebServlet(urlPatterns = {&quot;/async&quot;}, asyncSupported = true)
public class AsyncServlet extends HttpServlet {
//Web应用线程池用来处理异步Servlet
ExecutorService executor = Executors.newSingleThreadExecutor();
public void service(HttpServletRequest req, HttpServletResponse resp) {
//1. 调用startAsync或者异步上下文
final AsyncContext ctx = req.startAsync();
//用线程池来执行耗时操作
executor.execute(new Runnable() {
@Override
public void run() {
//在这里做耗时的操作
try {
ctx.getResponse().getWriter().println(&quot;Handling Async Servlet&quot;);
} catch (IOException e) {}
//3. 异步Servlet处理完了调用异步上下文的complete方法
ctx.complete();
}
});
}
}
```
上面的代码有三个要点:
1. 通过注解的方式来注册Servlet除了@WebServlet注解,还需要加上`asyncSupported=true`的属性表明当前的Servlet是一个异步Servlet。
1. Web应用程序需要调用Request对象的startAsync方法来拿到一个异步上下文AsyncContext。这个上下文保存了请求和响应对象。
1. Web应用需要开启一个新线程来处理耗时的操作处理完成后需要调用AsyncContext的complete方法。目的是告诉Tomcat请求已经处理完成。
这里请你注意虽然异步Servlet允许用更长的时间来处理请求但是也有超时限制的默认是30秒如果30秒内请求还没处理完Tomcat会触发超时机制向浏览器返回超时错误如果这个时候你的Web应用再调用`ctx.complete`方法会得到一个IllegalStateException异常。
## 异步Servlet原理
通过上面的例子相信你对Servlet的异步实现有了基本的理解。要理解Tomcat在这个过程都做了什么事情关键就是要弄清楚`req.startAsync`方法和`ctx.complete`方法都做了什么。
**startAsync方法**
startAsync方法其实就是创建了一个异步上下文AsyncContext对象AsyncContext对象的作用是保存请求的中间信息比如Request和Response对象等上下文信息。你来思考一下为什么需要保存这些信息呢
这是因为Tomcat的工作线程在`request.startAsync`调用之后就直接结束回到线程池中了线程本身不会保存任何信息。也就是说一个请求到服务端执行到一半你的Web应用正在处理这个时候Tomcat的工作线程没了这就需要有个缓存能够保存原始的Request和Response对象而这个缓存就是AsyncContext。
有了AsyncContext你的Web应用通过它拿到Request和Response对象拿到Request对象后就可以读取请求信息请求处理完了还需要通过Response对象将HTTP响应发送给浏览器。
除了创建AsyncContext对象startAsync还需要完成一个关键任务那就是告诉Tomcat当前的Servlet处理方法返回时不要把响应发到浏览器因为这个时候响应还没生成呢并且不能把Request对象和Response对象销毁因为后面Web应用还要用呢。
在Tomcat中负责flush响应数据的是CoyoteAdapter它还会销毁Request对象和Response对象因此需要通过某种机制通知CoyoteAdapter具体来说是通过下面这行代码
```
this.request.getCoyoteRequest().action(ActionCode.ASYNC_START, this);
```
你可以把它理解为一个Callback在这个action方法里设置了Request对象的状态设置它为一个异步Servlet请求。
我们知道连接器是调用CoyoteAdapter的service方法来处理请求的而CoyoteAdapter会调用容器的service方法当容器的service方法返回时CoyoteAdapter判断当前的请求是不是异步Servlet请求如果是就不会销毁Request和Response对象也不会把响应信息发到浏览器。你可以通过下面的代码理解一下这是CoyoteAdapter的service方法我对它进行了简化
```
public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) {
//调用容器的service方法处理请求
connector.getService().getContainer().getPipeline().
getFirst().invoke(request, response);
//如果是异步Servlet请求仅仅设置一个标志
//否则说明是同步Servlet请求就将响应数据刷到浏览器
if (request.isAsync()) {
async = true;
} else {
request.finishRequest();
response.finishResponse();
}
//如果不是异步Servlet请求就销毁Request对象和Response对象
if (!async) {
request.recycle();
response.recycle();
}
}
```
接下来当CoyoteAdapter的service方法返回到ProtocolHandler组件时ProtocolHandler判断返回值如果当前请求是一个异步Servlet请求它会把当前Socket的协议处理者Processor缓存起来将SocketWrapper对象和相应的Processor存到一个Map数据结构里。
```
private final Map&lt;S,Processor&gt; connections = new ConcurrentHashMap&lt;&gt;();
```
之所以要缓存是因为这个请求接下来还要接着处理还是由原来的Processor来处理通过SocketWrapper就能从Map里找到相应的Processor。
**complete方法**
接着我们再来看关键的`ctx.complete`方法当请求处理完成时Web应用调用这个方法。那么这个方法做了些什么事情呢最重要的就是把响应数据发送到浏览器。
这件事情不能由Web应用线程来做也就是说`ctx.complete`方法不能直接把响应数据发送到浏览器因为这件事情应该由Tomcat线程来做但具体怎么做呢
我们知道连接器中的Endpoint组件检测到有请求数据达到时会创建一个SocketProcessor对象交给线程池去处理因此Endpoint的通信处理和具体请求处理在两个线程里运行。
在异步Servlet的场景里Web应用通过调用`ctx.complete`方法时也可以生成一个新的SocketProcessor任务类交给线程池处理。对于异步Servlet请求来说相应的Socket和协议处理组件Processor都被缓存起来了并且这些对象都可以通过Request对象拿到。
讲到这里,你可能已经猜到`ctx.complete`是如何实现的了:
```
public void complete() {
//检查状态合法性,我们先忽略这句
check();
//调用Request对象的action方法其实就是通知连接器这个异步请求处理完了
request.getCoyoteRequest().action(ActionCode.ASYNC_COMPLETE, null);
}
```
我们可以看到complete方法调用了Request对象的action方法。而在action方法里则是调用了Processor的processSocketEvent方法并且传入了操作码OPEN_READ。
```
case ASYNC_COMPLETE: {
clearDispatches();
if (asyncStateMachine.asyncComplete()) {
processSocketEvent(SocketEvent.OPEN_READ, true);
}
break;
}
```
我们接着看processSocketEvent方法它调用SocketWrapper的processSocket方法
```
protected void processSocketEvent(SocketEvent event, boolean dispatch) {
SocketWrapperBase&lt;?&gt; socketWrapper = getSocketWrapper();
if (socketWrapper != null) {
socketWrapper.processSocket(event, dispatch);
}
}
```
而SocketWrapper的processSocket方法会创建SocketProcessor任务类并通过Tomcat线程池来处理
```
public boolean processSocket(SocketWrapperBase&lt;S&gt; socketWrapper,
SocketEvent event, boolean dispatch) {
if (socketWrapper == null) {
return false;
}
SocketProcessorBase&lt;S&gt; sc = processorCache.pop();
if (sc == null) {
sc = createSocketProcessor(socketWrapper, event);
} else {
sc.reset(socketWrapper, event);
}
//线程池运行
Executor executor = getExecutor();
if (dispatch &amp;&amp; executor != null) {
executor.execute(sc);
} else {
sc.run();
}
}
```
请你注意createSocketProcessor函数的第二个参数是SocketEvent这里我们传入的是OPEN_READ。通过这个参数我们就能控制SocketProcessor的行为因为我们不需要再把请求发送到容器进行处理只需要向浏览器端发送数据并且重新在这个Socket上监听新的请求就行了。
最后我通过一张在帮你理解一下整个过程:
<img src="https://static001.geekbang.org/resource/image/d2/ae/d2d96b7450dff9735989005958fa13ae.png" alt="">
## 本期精华
非阻塞I/O模型可以利用很少的线程处理大量的连接提高了并发度本质就是通过一个Selector线程查询多个Socket的I/O事件减少了线程的阻塞等待。
同样异步Servlet机制也是减少了线程的阻塞等待将Tomcat线程和业务线程分开Tomca线程不再等待业务代码的执行。
那什么样的场景适合异步Servlet呢适合的场景有很多最主要的还是根据你的实际情况如果你拿不准是否适合异步Servlet就看一条如果你发现Tomcat的线程不够了大量线程阻塞在等待Web应用的处理上而Web应用又没有优化的空间了确实需要长时间处理这个时候你不妨尝试一下异步Servlet。
## 课后思考
异步Servlet将Tomcat线程和Web应用线程分开体现了隔离的思想也就是把不同的业务处理所使用的资源隔离开使得它们互不干扰尤其是低优先级的业务不能影响高优先级的业务。你可以思考一下在你的Web应用内部是不是也可以运用这种设计思想呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,260 @@
<audio id="audio" title="28 | 新特性Spring Boot如何使用内嵌式的Tomcat和Jetty" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/ad/96e044ff5b42bc3a553b64e5d41873ad.mp3"></audio>
为了方便开发和部署Spring Boot在内部启动了一个嵌入式的Web容器。我们知道Tomcat和Jetty是组件化的设计要启动Tomcat或者Jetty其实就是启动这些组件。在Tomcat独立部署的模式下我们通过startup脚本来启动TomcatTomcat中的Bootstrap和Catalina会负责初始化类加载器并解析`server.xml`和启动这些组件。
在内嵌式的模式下Bootstrap和Catalina的工作就由Spring Boot来做了Spring Boot调用了Tomcat和Jetty的API来启动这些组件。那Spring Boot具体是怎么做的呢而作为程序员我们如何向Spring Boot中的Tomcat注册Servlet或者Filter呢我们又如何定制内嵌式的Tomcat今天我们就来聊聊这些话题。
## Spring Boot中Web容器相关的接口
既然要支持多种Web容器Spring Boot对内嵌式Web容器进行了抽象定义了**WebServer**接口:
```
public interface WebServer {
void start() throws WebServerException;
void stop() throws WebServerException;
int getPort();
}
```
各种Web容器比如Tomcat和Jetty需要去实现这个接口。
Spring Boot还定义了一个工厂**ServletWebServerFactory**来创建Web容器返回的对象就是上面提到的WebServer。
```
public interface ServletWebServerFactory {
WebServer getWebServer(ServletContextInitializer... initializers);
}
```
可以看到getWebServer有个参数类型是**ServletContextInitializer**。它表示ServletContext的初始化器用于ServletContext中的一些配置
```
public interface ServletContextInitializer {
void onStartup(ServletContext servletContext) throws ServletException;
}
```
这里请注意上面提到的getWebServer方法会调用ServletContextInitializer的onStartup方法也就是说如果你想在Servlet容器启动时做一些事情比如注册你自己的Servlet可以实现一个ServletContextInitializer在Web容器启动时Spring Boot会把所有实现了ServletContextInitializer接口的类收集起来统一调它们的onStartup方法。
为了支持对内嵌式Web容器的定制化Spring Boot还定义了**WebServerFactoryCustomizerBeanPostProcessor**接口它是一个BeanPostProcessor它在postProcessBeforeInitialization过程中去寻找Spring容器中WebServerFactoryCustomizer类型的Bean并依次调用WebServerFactoryCustomizer接口的customize方法做一些定制化。
```
public interface WebServerFactoryCustomizer&lt;T extends WebServerFactory&gt; {
void customize(T factory);
}
```
## 内嵌式Web容器的创建和启动
铺垫了这些接口我们再来看看Spring Boot是如何实例化和启动一个Web容器的。我们知道Spring的核心是一个ApplicationContext它的抽象实现类AbstractApplicationContext实现了著名的**refresh**方法它用来新建或者刷新一个ApplicationContext在refresh方法中会调用onRefresh方法AbstractApplicationContext的子类可以重写这个onRefresh方法来实现特定Context的刷新逻辑因此ServletWebServerApplicationContext就是通过重写onRefresh方法来创建内嵌式的Web容器具体创建过程是这样的
```
@Override
protected void onRefresh() {
super.onRefresh();
try {
//重写onRefresh方法调用createWebServer创建和启动Tomcat
createWebServer();
}
catch (Throwable ex) {
}
}
//createWebServer的具体实现
private void createWebServer() {
//这里WebServer是Spring Boot抽象出来的接口具体实现类就是不同的Web容器
WebServer webServer = this.webServer;
ServletContext servletContext = this.getServletContext();
//如果Web容器还没创建
if (webServer == null &amp;&amp; servletContext == null) {
//通过Web容器工厂来创建
ServletWebServerFactory factory = this.getWebServerFactory();
//注意传入了一个&quot;SelfInitializer&quot;
this.webServer = factory.getWebServer(new ServletContextInitializer[]{this.getSelfInitializer()});
} else if (servletContext != null) {
try {
this.getSelfInitializer().onStartup(servletContext);
} catch (ServletException var4) {
...
}
}
this.initPropertySources();
}
```
再来看看getWebServer具体做了什么以Tomcat为例主要调用Tomcat的API去创建各种组件
```
public WebServer getWebServer(ServletContextInitializer... initializers) {
//1.实例化一个Tomcat可以理解为Server组件。
Tomcat tomcat = new Tomcat();
//2. 创建一个临时目录
File baseDir = this.baseDirectory != null ? this.baseDirectory : this.createTempDir(&quot;tomcat&quot;);
tomcat.setBaseDir(baseDir.getAbsolutePath());
//3.初始化各种组件
Connector connector = new Connector(this.protocol);
tomcat.getService().addConnector(connector);
this.customizeConnector(connector);
tomcat.setConnector(connector);
tomcat.getHost().setAutoDeploy(false);
this.configureEngine(tomcat.getEngine());
//4. 创建定制版的&quot;Context&quot;组件。
this.prepareContext(tomcat.getHost(), initializers);
return this.getTomcatWebServer(tomcat);
}
```
你可能好奇prepareContext方法是做什么的呢这里的Context是指**Tomcat中的Context组件**为了方便控制Context组件的行为Spring Boot定义了自己的TomcatEmbeddedContext它扩展了Tomcat的StandardContext
```
class TomcatEmbeddedContext extends StandardContext {}
```
## 注册Servlet的三种方式
**1. Servlet注解**
在Spring Boot启动类上加上@ServletComponentScan注解后,使用@WebServlet@WebFilter@WebListener标记的Servlet、Filter、Listener就可以自动注册到Servlet容器中无需其他代码我们通过下面的代码示例来理解一下。
```
@SpringBootApplication
@ServletComponentScan
public class xxxApplication
{}
```
```
@WebServlet(&quot;/hello&quot;)
public class HelloServlet extends HttpServlet {}
```
在Web应用的入口类上加上@ServletComponentScan并且在Servlet类上加上@WebServlet这样Spring Boot会负责将Servlet注册到内嵌的Tomcat中。
**2. ServletRegistrationBean**
同时Spring Boot也提供了ServletRegistrationBean、FilterRegistrationBean和ServletListenerRegistrationBean这三个类分别用来注册Servlet、Filter、Listener。假如要注册一个Servlet可以这样做
```
@Bean
public ServletRegistrationBean servletRegistrationBean() {
return new ServletRegistrationBean(new HelloServlet(),&quot;/hello&quot;);
}
```
这段代码实现的方法返回一个ServletRegistrationBean并将它当作Bean注册到Spring中因此你需要把这段代码放到Spring Boot自动扫描的目录中或者放到@Configuration标识的类中
**3. 动态注册**
你还可以创建一个类去实现前面提到的ServletContextInitializer接口并把它注册为一个BeanSpring Boot会负责调用这个接口的onStartup方法。
```
@Component
public class MyServletRegister implements ServletContextInitializer {
@Override
public void onStartup(ServletContext servletContext) {
//Servlet 3.0规范新的API
ServletRegistration myServlet = servletContext
.addServlet(&quot;HelloServlet&quot;, HelloServlet.class);
myServlet.addMapping(&quot;/hello&quot;);
myServlet.setInitParameter(&quot;name&quot;, &quot;Hello Servlet&quot;);
}
}
```
这里请注意两点:
- ServletRegistrationBean其实也是通过ServletContextInitializer来实现的它实现了ServletContextInitializer接口。
- 注意到onStartup方法的参数是我们熟悉的ServletContext可以通过调用它的addServlet方法来动态注册新的Servlet这是Servlet 3.0以后才有的功能。
## Web容器的定制
我们再来考虑一个问题那就是如何在Spring Boot中定制Web容器。在Spring Boot 2.0中我们可以通过两种方式来定制Web容器。
**第一种方式**是通过通用的Web容器工厂ConfigurableServletWebServerFactory来定制一些Web容器通用的参数
```
@Component
public class MyGeneralCustomizer implements
WebServerFactoryCustomizer&lt;ConfigurableServletWebServerFactory&gt; {
public void customize(ConfigurableServletWebServerFactory factory) {
factory.setPort(8081);
factory.setContextPath(&quot;/hello&quot;);
}
}
```
**第二种方式**是通过特定Web容器的工厂比如TomcatServletWebServerFactory来进一步定制。下面的例子里我们给Tomcat增加一个Valve这个Valve的功能是向请求头里添加traceid用于分布式追踪。TraceValve的定义如下
```
class TraceValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
request.getCoyoteRequest().getMimeHeaders().
addValue(&quot;traceid&quot;).setString(&quot;1234xxxxabcd&quot;);
Valve next = getNext();
if (null == next) {
return;
}
next.invoke(request, response);
}
}
```
跟第一种方式类似,再添加一个定制器,代码如下:
```
@Component
public class MyTomcatCustomizer implements
WebServerFactoryCustomizer&lt;TomcatServletWebServerFactory&gt; {
@Override
public void customize(TomcatServletWebServerFactory factory) {
factory.setPort(8081);
factory.setContextPath(&quot;/hello&quot;);
factory.addEngineValves(new TraceValve() );
}
}
```
## 本期精华
今天我们学习了Spring Boot如何利用Web容器的API来启动Web容器、如何向Web容器注册Servlet以及如何定制化Web容器除了给Web容器配置参数还可以增加或者修改Web容器本身的组件。
## 课后思考
我在文章中提到通过ServletContextInitializer接口可以向Web容器注册Servlet那ServletContextInitializer跟Tomcat中的ServletContainerInitializer有什么区别和联系呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,339 @@
<audio id="audio" title="29 | 比较Jetty如何实现具有上下文信息的责任链" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/87/c4ff4e83056c1e75f919bf3ccef48687.mp3"></audio>
我们知道Tomcat和Jetty的核心功能是处理请求并且请求的处理者不止一个因此Tomcat和Jetty都实现了责任链模式其中Tomcat是通过Pipeline-Valve来实现的而Jetty是通过HandlerWrapper来实现的。HandlerWrapper中保存了下一个Handler的引用将各Handler组成一个链表像下面这样
WebAppContext -&gt; SessionHandler -&gt; SecurityHandler -&gt; ServletHandler
这样链中的Handler从头到尾能被依次调用除此之外Jetty还实现了“回溯”的链式调用那就是从头到尾依次链式调用Handler的**方法A**,完成后再回到头节点,再进行一次链式调用,只不过这一次调用另一个**方法B**。你可能会问一次链式调用不就够了吗为什么还要回过头再调一次呢这是因为一次请求到达时Jetty需要先调用各Handler的初始化方法之后再调用各Handler的请求处理方法并且初始化必须在请求处理之前完成。
而Jetty是通过ScopedHandler来做到这一点的那ScopedHandler跟HandlerWrapper有什么关系呢ScopedHandler是HandlerWrapper的子类我们还是通过一张图来回顾一下各种Handler的继承关系
<img src="https://static001.geekbang.org/resource/image/68/50/68f3668cc7b179b5311d1bb5cb3cf350.jpg" alt="">
从图上我们看到ScopedHandler是Jetty非常核心的一个Handler跟Servlet规范相关的Handler比如ContextHandler、SessionHandler、ServletHandler、WebappContext等都直接或间接地继承了ScopedHandler。
今天我就分析一下ScopedHandler是如何实现“回溯”的链式调用的。
## HandlerWrapper
为了方便理解我们先来回顾一下HandlerWrapper的源码
```
public class HandlerWrapper extends AbstractHandlerContainer
{
protected Handler _handler;
@Override
public void handle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
Handler handler=_handler;
if (handler!=null)
handler.handle(target,baseRequest, request, response);
}
}
```
从代码可以看到它持有下一个Handler的引用并且会在handle方法里调用下一个Handler。
## ScopedHandler
ScopedHandler的父类是HandlerWrapperScopedHandler重写了handle方法在HandlerWrapper的handle方法的基础上引入了doScope方法。
```
public final void handle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
if (isStarted())
{
if (_outerScope==null)
doScope(target,baseRequest,request, response);
else
doHandle(target,baseRequest,request, response);
}
}
```
上面的代码中是根据`_outerScope`是否为null来判断是使用doScope还是doHandle方法。那`_outScope`又是什么呢?`_outScope`是ScopedHandler引入的一个辅助变量此外还有一个`_nextScope`变量。
```
protected ScopedHandler _outerScope;
protected ScopedHandler _nextScope;
private static final ThreadLocal&lt;ScopedHandler&gt; __outerScope= new ThreadLocal&lt;ScopedHandler&gt;();
```
我们看到`__outerScope`是一个ThreadLocal变量ThreadLocal表示线程的私有数据跟特定线程绑定。需要注意的是`__outerScope`实际上保存了一个ScopedHandler。
下面通过我通过一个例子来说明`_outScope``_nextScope`的含义。我们知道ScopedHandler继承自HandlerWrapper所以也是可以形成Handler链的Jetty的源码注释中给出了下面这样一个例子
```
ScopedHandler scopedA;
ScopedHandler scopedB;
HandlerWrapper wrapperX;
ScopedHandler scopedC;
scopedA.setHandler(scopedB);
scopedB.setHandler(wrapperX);
wrapperX.setHandler(scopedC)
```
经过上面的设置之后形成的Handler链是这样的
<img src="https://static001.geekbang.org/resource/image/5f/2a/5f18bc5677f9216a9126413db4f4b22a.png" alt="">
上面的过程只是设置了`_handler`变量,那`_outScope``_nextScope`需要设置成什么样呢?为了方便你理解,我们先来看最后的效果图:
<img src="https://static001.geekbang.org/resource/image/21/4c/21a2e99691804f64d13d62ab9b3f924c.png" alt="">
从上图我们看到scopedA的`_nextScope=scopedB`scopedB的`_nextScope=scopedC`为什么scopedB的`_nextScope`不是WrapperX呢因为WrapperX不是一个ScopedHandler。scopedC的`_nextScope`是null因为它是链尾没有下一个节点。因此我们得出一个结论`_nextScope`**指向下一个Scoped节点**的引用由于WrapperX不是Scoped节点它没有`_outScope``_nextScope`变量。
注意到scopedA的`_outerScope`是nullscopedB和scopedC的`_outScope`都是指向scopedA`_outScope`**指向的是当前Handler链的头节点**,头节点本身`_outScope`为null。
弄清楚了`_outScope``_nextScope`的含义下一个问题就是对于一个ScopedHandler对象如何设置这两个值以及在何时设置这两个值。答案是在组件启动的时候下面是ScopedHandler中的doStart方法源码
```
@Override
protected void doStart() throws Exception
{
try
{
//请注意_outScope是一个实例变量而__outerScope是一个全局变量。先读取全局的线程私有变量__outerScope到_outerScope中
_outerScope=__outerScope.get();
//如果全局的__outerScope还没有被赋值说明执行doStart方法的是头节点
if (_outerScope==null)
//handler链的头节点将自己的引用填充到__outerScope
__outerScope.set(this);
//调用父类HandlerWrapper的doStart方法
super.doStart();
//各Handler将自己的_nextScope指向下一个ScopedHandler
_nextScope= getChildHandlerByClass(ScopedHandler.class);
}
finally
{
if (_outerScope==null)
__outerScope.set(null);
}
}
```
你可能会问,为什么要设计这样一个全局的`__outerScope`这是因为这个变量不能通过方法参数在Handler链中进行传递但是在形成链的过程中又需要用到它。
你可以想象当scopedA调用start方法时会把自己填充到`__scopeHandler`接着scopedA调用`super.doStart`。由于scopedA是一个HandlerWrapper类型并且它持有的`_handler`引用指向的是scopedB所以`super.doStart`实际上会调用scopedB的start方法。
这个方法里同样会执行scopedB的doStart方法不过这次`__outerScope.get`方法返回的不是null而是scopedA的引用所以scopedB的`_outScope`被设置为scopedA。
接着`super.dostart`会进入到scopedC也会将scopedC的`_outScope`指向scopedA。到了scopedC执行doStart方法时它的`_handler`属性为null因为它是Handler链的最后一个所以它的`super.doStart`会直接返回。接着继续执行scopedC的doStart方法的下一行代码
```
_nextScope=(ScopedHandler)getChildHandlerByClass(ScopedHandler.class)
```
对于HandlerWrapper来说getChildHandlerByClass返回的就是其包装的`_handler`对象这里返回的就是null。所以scopedC的`_nextScope`为null这段方法结束返回后继续执行scopedB中的doStart中同样执行这句代码
```
_nextScope=(ScopedHandler)getChildHandlerByClass(ScopedHandler.class)
```
因为scopedB的`_handler`引用指向的是scopedC所以getChildHandlerByClass返回的结果就是scopedC的引用即scopedB的`_nextScope`指向scopedC。
同理scopedA的`_nextScope`会指向scopedB。scopedA的doStart方法返回之后`_outScope`为null。请注意执行到这里只有scopedA的`_outScope`为null所以doStart中finally部分的逻辑被触发这个线程的ThreadLocal变量又被设置为null。
```
finally
{
if (_outerScope==null)
__outerScope.set(null);
}
```
你可能会问,费这么大劲设置`_outScope``_nextScope`的值到底有什么用?如果你觉得上面的过程比较复杂,可以跳过这个过程,直接通过图来理解`_outScope``_nextScope`的值而这样设置的目的是用来控制doScope方法和doHandle方法的调用顺序。
实际上在ScopedHandler中对于doScope和doHandle方法是没有具体实现的但是提供了nextHandle和nextScope两个方法下面是它们的源码
```
public void doScope(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
nextScope(target,baseRequest,request,response);
}
public final void nextScope(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
if (_nextScope!=null)
_nextScope.doScope(target,baseRequest,request, response);
else if (_outerScope!=null)
_outerScope.doHandle(target,baseRequest,request, response);
else
doHandle(target,baseRequest,request, response);
}
public abstract void doHandle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException;
public final void nextHandle(String target,
final Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
if (_nextScope!=null &amp;&amp; _nextScope==_handler)
_nextScope.doHandle(target,baseRequest,request, response);
else if (_handler!=null)
super.handle(target,baseRequest,request,response);
}
```
从nextHandle和nextScope方法大致上可以猜到doScope和doHandle的调用流程。我通过一个调用栈来帮助你理解
```
A.handle(...)
A.doScope(...)
B.doScope(...)
C.doScope(...)
A.doHandle(...)
B.doHandle(...)
X.handle(...)
C.handle(...)
C.doHandle(...)
```
因此通过设置`_outScope``_nextScope`的值并且在代码中判断这些值并采取相应的动作目的就是让ScopedHandler链上的**doScope方法在doHandle、handle方法之前执行**。并且不同ScopedHandler的doScope都是按照它在链上的先后顺序执行的doHandle和handle方法也是如此。
这样ScopedHandler帮我们把调用框架搭好了它的子类只需要实现doScope和doHandle方法。比如在doScope方法里做一些初始化工作在doHanlde方法处理请求。
## ContextHandler
接下来我们来看看ScopedHandler的子类ContextHandler是如何实现doScope和doHandle方法的。ContextHandler可以理解为Tomcat中的Context组件对应一个Web应用它的功能是给Servlet的执行维护一个上下文环境并且将请求转发到相应的Servlet。那什么是Servlet执行的上下文我们通过ContextHandler的构造函数来了解一下
```
private ContextHandler(Context context, HandlerContainer parent, String contextPath)
{
//_scontext就是Servlet规范中的ServletContext
_scontext = context == null?new Context():context;
//Web应用的初始化参数
_initParams = new HashMap&lt;String, String&gt;();
...
}
```
我们看到ContextHandler维护了ServletContext和Web应用的初始化参数。那ContextHandler的doScope方法做了些什么呢我们看看它的关键代码
```
public void doScope(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
...
//1.修正请求的URL去掉多余的'/',或者加上'/'
if (_compactPath)
target = URIUtil.compactPath(target);
if (!checkContext(target,baseRequest,response))
return;
if (target.length() &gt; _contextPath.length())
{
if (_contextPath.length() &gt; 1)
target = target.substring(_contextPath.length());
pathInfo = target;
}
else if (_contextPath.length() == 1)
{
target = URIUtil.SLASH;
pathInfo = URIUtil.SLASH;
}
else
{
target = URIUtil.SLASH;
pathInfo = null;
}
//2.设置当前Web应用的类加载器
if (_classLoader != null)
{
current_thread = Thread.currentThread();
old_classloader = current_thread.getContextClassLoader();
current_thread.setContextClassLoader(_classLoader);
}
//3. 调用nextScope
nextScope(target,baseRequest,request,response);
...
}
```
从代码我们看到在doScope方法里主要是做了一些请求的修正、类加载器的设置并调用nextScope请你注意nextScope调用是由父类ScopedHandler实现的。接着我们来ContextHandler的doHandle方法
```
public void doHandle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
final DispatcherType dispatch = baseRequest.getDispatcherType();
final boolean new_context = baseRequest.takeNewContext();
try
{
//请求的初始化工作,主要是为请求添加ServletRequestAttributeListener监听器,并将&quot;开始处理一个新请求&quot;这个事件通知ServletRequestListener
if (new_context)
requestInitialized(baseRequest,request);
...
//继续调用下一个Handler下一个Handler可能是ServletHandler、SessionHandler ...
nextHandle(target,baseRequest,request,response);
}
finally
{
//同样一个Servlet请求处理完毕也要通知相应的监听器
if (new_context)
requestDestroyed(baseRequest,request);
}
}
```
从上面的代码我们看到ContextHandler在doHandle方法里分别完成了相应的请求处理工作。
## 本期精华
今天我们分析了Jetty中ScopedHandler的实现原理剖析了如何实现链式调用的“回溯”。主要是确定了doScope和doHandle的调用顺序doScope依次调用完以后再依次调用doHandle它的子类比如ContextHandler只需要实现doScope和doHandle方法而不需要关心它们被调用的顺序。
这背后的原理是ScopedHandler通过递归的方式来设置`_outScope``_nextScope`两个变量然后通过判断这些值来控制调用的顺序。递归是计算机编程的一个重要的概念在各种面试题中也经常出现如果你能读懂Jetty中的这部分代码毫无疑问你已经掌握了递归的精髓。
另外我们进行层层递归调用中需要用到一些变量比如ScopedHandler中的`__outerScope`它保存了Handler链中的头节点但是它不是递归方法的参数那参数怎么传递过去呢一种可能的办法是设置一个全局变量各Handler都能访问到这个变量。但这样会有线程安全的问题因此ScopedHandler通过线程私有数据ThreadLocal来保存变量这样既达到了传递变量的目的又没有线程安全的问题。
## 课后思考
ScopedHandler的doStart方法最后一步是将线程私有变量`__outerScope`设置成null为什么需要这样做呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="30 | 热点问题答疑3Spring框架中的设计模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/99/5dc1c344dde037893983e93ae74caf99.mp3"></audio>
在构思这个专栏的时候回想当时我是如何研究Tomcat和Jetty源码的除了理解它们的实现之外也从中学到了很多架构和设计的理念其中很重要的就是对设计模式的运用让我收获到不少经验。而且这些经验通过自己消化和吸收是可以把它应用到实际工作中去的。
在专栏的热点问题答疑第三篇我想跟你分享一些我对设计模式的理解。有关Tomcat和Jetty所运用的设计模式我在专栏里已经有所介绍今天想跟你分享一下Spring框架里的设计模式。Spring的核心功能是IOC容器以及AOP面向切面编程同样也是很多Web后端工程师每天都要打交道的框架相信你一定可以从中吸收到一些设计方面的精髓帮助你提升设计能力。
## 简单工厂模式
我们来考虑这样一个场景当A对象需要调用B对象的方法时我们需要在A中new一个B的实例我们把这种方式叫作硬编码耦合它的缺点是一旦需求发生变化比如需要使用C类来代替B时就要改写A类的方法。假如应用中有1000个类以硬编码的方式耦合了B那改起来就费劲了。于是简单工厂模式就登场了简单工厂模式又叫静态工厂方法其实质是由一个工厂类根据传入的参数动态决定应该创建哪一个产品类。
Spring中的BeanFactory就是简单工厂模式的体现BeanFactory是Spring IOC容器中的一个核心接口它的定义如下
```
public interface BeanFactory {
Object getBean(String name) throws BeansException;
&lt;T&gt; T getBean(String name, Class&lt;T&gt; requiredType);
Object getBean(String name, Object... args);
&lt;T&gt; T getBean(Class&lt;T&gt; requiredType);
&lt;T&gt; T getBean(Class&lt;T&gt; requiredType, Object... args);
boolean containsBean(String name);
boolean isSingleton(String name);
boolea isPrototype(String name);
boolean isTypeMatch(String name, ResolvableType typeToMatch);
boolean isTypeMatch(String name, Class&lt;?&gt; typeToMatch);
Class&lt;?&gt; getType(String name);
String[] getAliases(String name);
}
```
我们可以通过它的具体实现类比如ClassPathXmlApplicationContext来获取Bean
```
BeanFactory bf = new ClassPathXmlApplicationContext(&quot;spring.xml&quot;);
User userBean = (User) bf.getBean(&quot;userBean&quot;);
```
从上面代码可以看到使用者不需要自己来new对象而是通过工厂类的方法getBean来获取对象实例这是典型的简单工厂模式只不过Spring是用反射机制来创建Bean的。
## 工厂方法模式
工厂方法模式说白了其实就是简单工厂模式的一种升级或者说是进一步抽象,它可以应用于更加复杂的场景,灵活性也更高。在简单工厂中,由工厂类进行所有的逻辑判断、实例创建;如果不想在工厂类中进行判断,可以为不同的产品提供不同的工厂,不同的工厂生产不同的产品,每一个工厂都只对应一个相应的对象,这就是工厂方法模式。
Spring中的FactoryBean就是这种思想的体现FactoryBean可以理解为工厂Bean先来看看它的定义
```
public interface FactoryBean&lt;T&gt; {
T getObject()
Class&lt;?&gt; getObjectType();
boolean isSingleton();
}
```
我们定义一个类UserFactoryBean来实现FactoryBean接口主要是在getObject方法里new一个User对象。这样我们通过getBean(id) 获得的是该工厂所产生的User的实例而不是UserFactoryBean本身的实例像下面这样
```
BeanFactory bf = new ClassPathXmlApplicationContext(&quot;user.xml&quot;);
User userBean = (User) bf.getBean(&quot;userFactoryBean&quot;);
```
## 单例模式
单例模式是指一个类在整个系统运行过程中只允许产生一个实例。在Spring中Bean可以被定义为两种模式Prototype多例和Singleton单例Spring Bean默认是单例模式。那Spring是如何实现单例模式的呢答案是通过单例注册表的方式具体来说就是使用了HashMap。请注意为了方便你阅读我对代码进行了简化
```
public class DefaultSingletonBeanRegistry {
//使用了线程安全容器ConcurrentHashMap保存各种单实例对象
private final Map&lt;String, Object&gt; singletonObjects = new ConcurrentHashMap&lt;String, Object&gt;;
protected Object getSingleton(String beanName) {
//先到HashMap中拿Object
Object singletonObject = singletonObjects.get(beanName);
//如果没拿到通过反射创建一个对象实例并添加到HashMap中
if (singletonObject == null) {
singletonObjects.put(beanName,
Class.forName(beanName).newInstance());
}
//返回对象实例
return singletonObjects.get(beanName);
}
}
```
上面的代码逻辑比较清晰先到HashMap去拿单实例对象没拿到就创建一个添加到HashMap。
## 代理模式
所谓代理,是指它与被代理对象实现了相同的接口,客户端必须通过代理才能与被代理的目标类进行交互,而代理一般在交互的过程中(交互前后),进行某些特定的处理,比如在调用这个方法前做前置处理,调用这个方法后做后置处理。代理模式中有下面几种角色:
- **抽象接口**:定义目标类及代理类的共同接口,这样在任何可以使用目标对象的地方都可以使用代理对象。
- **目标对象** 定义了代理对象所代表的目标对象,专注于业务功能的实现。
- **代理对象** 代理对象内部含有目标对象的引用,收到客户端的调用请求时,代理对象通常不会直接调用目标对象的方法,而是在调用之前和之后实现一些额外的逻辑。
代理模式的好处是,可以在目标对象业务功能的基础上添加一些公共的逻辑,比如我们想给目标对象加入日志、权限管理和事务控制等功能,我们就可以使用代理类来完成,而没必要修改目标类,从而使得目标类保持稳定。这其实是开闭原则的体现,不要随意去修改别人已经写好的代码或者方法。
代理又分为静态代理和动态代理两种方式。静态代理需要定义接口被代理对象目标对象与代理对象Proxy)一起实现相同的接口,我们通过一个例子来理解一下:
```
//抽象接口
public interface IStudentDao {
void save();
}
//目标对象
public class StudentDao implements IStudentDao {
public void save() {
System.out.println(&quot;保存成功&quot;);
}
}
//代理对象
public class StudentDaoProxy implements IStudentDao{
//持有目标对象的引用
private IStudentDao target;
public StudentDaoProxy(IStudentDao target){
this.target = target;
}
//在目标功能对象方法的前后加入事务控制
public void save() {
System.out.println(&quot;开始事务&quot;);
target.save();//执行目标对象的方法
System.out.println(&quot;提交事务&quot;);
}
}
public static void main(String[] args) {
//创建目标对象
StudentDao target = new StudentDao();
//创建代理对象,把目标对象传给代理对象,建立代理关系
StudentDaoProxy proxy = new StudentDaoProxy(target);
//执行的是代理的方法
proxy.save();
}
```
而Spring的AOP采用的是动态代理的方式而动态代理就是指代理类在程序运行时由JVM动态创建。在上面静态代理的例子中代理类StudentDaoProxy是我们自己定义好的在程序运行之前就已经编译完成。而动态代理代理类并不是在Java代码中定义的而是在运行时根据我们在Java代码中的“指示”动态生成的。那我们怎么“指示”JDK去动态地生成代理类呢
在Java的`java.lang.reflect`包里提供了一个Proxy类和一个InvocationHandler接口通过这个类和这个接口可以生成动态代理对象。具体来说有如下步骤
1.定义一个InvocationHandler类将需要扩展的逻辑集中放到这个类中比如下面的例子模拟了添加事务控制的逻辑。
```
public class MyInvocationHandler implements InvocationHandler {
private Object obj;
public MyInvocationHandler(Object obj){
this.obj=obj;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.println(&quot;开始事务&quot;);
Object result = method.invoke(obj, args);
System.out.println(&quot;开始事务&quot;);
return result;
}
}
```
2.使用Proxy的newProxyInstance方法动态的创建代理对象
```
public static void main(String[] args) {
//创建目标对象StudentDao
IStudentDao stuDAO = new StudentDao();
//创建MyInvocationHandler对象
InvocationHandler handler = new MyInvocationHandler(stuDAO);
//使用Proxy.newProxyInstance动态的创建代理对象stuProxy
IStudentDao stuProxy = (IStudentDao)
Proxy.newProxyInstance(stuDAO.getClass().getClassLoader(), stuDAO.getClass().getInterfaces(), handler);
//动用代理对象的方法
stuProxy.save();
}
```
上面的代码实现和静态代理一样的功能,相比于静态代理,动态代理的优势在于可以很方便地对代理类的函数进行统一的处理,而不用修改每个代理类中的方法。
Spring实现了通过动态代理对类进行方法级别的切面增强我来解释一下这句话其实就是动态生成目标对象的代理类并在代理类的方法中设置拦截器通过执行拦截器中的逻辑增强了代理方法的功能从而实现AOP。
## 本期精华
今天我和你聊了Spring中的设计模式我记得我刚毕业那会儿拿到一个任务时我首先考虑的是怎么把功能实现了从不考虑设计的问题因此写出来的代码就显得比较稚嫩。后来随着经验的积累我会有意识地去思考这个场景是不是用个设计模式会更高大上呢以后重构起来是不是会更轻松呢慢慢我也就形成一个习惯那就是用优雅的方式去实现一个系统这也是每个程序员需要经历的过程。
今天我们学习了Spring的两大核心功能IOC和AOP中用到的一些设计模式主要有简单工厂模式、工厂方法模式、单例模式和代理模式。而代理模式又分为静态代理和动态代理。JDK提供实现动态代理的机制除此之外还可以通过CGLIB来实现有兴趣的同学可以理解一下它的原理。
## 课后思考
注意到在newProxyInstance方法中传入了目标类的加载器、目标类实现的接口以及MyInvocationHandler三个参数就能得到一个动态代理对象请你思考一下newProxyInstance方法是如何实现的。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@@ -0,0 +1,27 @@
<audio id="audio" title="结束语 | 静下心来,品味经典" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/e1/8022f5d42e7d3a8cb7f993edd62921e1.mp3"></audio>
从专栏上线发布到现在,不知不觉三个月时间过去了,感谢你的一路陪伴,今天到了说再见的时候,我想简单回顾一下专栏的内容,并且聊聊我的一些感受。
Tomcat和Jetty发展这么多年已经比较成熟稳定。这些年技术发展迭代速度又很快在一个“追新求快”的时代Tomcat和Jetty作为Java Web开发的必备工具似乎变成了“熟悉的陌生人”。对于很多新同学来说虽然有些Tomcat和Jetty的知识点在面试中会碰到但从侧面来说Tomcat和Jetty似乎没有那么“火”那是不是说如今就没有必要深入学习Tomcat和Jetty了呢只要会用就行呢要回答这个问题我先讲讲为什么我选择这个主题来写专栏吧。我写这个专栏的初心还是希望我们可以**静下心来,细细品味经典的开源作品**从而进一步提升我们的“内功”。“内功”这个词有些抽象具体来说就是学习大牛们如何设计、架构一个中间件软件系统并且让这些经验可以为自己所用。作为一名IT从业者我认为我们很有必要深入思考一下这些大牛为什么能够创造出这些优秀的作品并且能引领技术的发展呢。
不知道你发现没有,美好的事物往往是整洁而优雅的。但这并不等于简单,而是要将复杂的系统分解成一个个小模块,并且各个模块的职责划分也要清晰合理。与此相反的是凌乱无序,比如你看到一堆互相纠缠在一起的电线,可能会感到不适。
同样的道理,当我们在设计一个软件系统时,追求的目标也应该是整洁和优雅。我觉得首先需要合理划分功能模块,主要是分清楚“**变与不变**”的边界因为变化往往会给系统实现带来混乱因此需要将“变”的因素控制、隔离起来。如果你发现一个软件系统里有大量if else语句、大量的重复代码、大量的相互依赖那么这个系统多半还有提高的空间所以分清楚“变与不变”十分重要。
从宏观上看中间件实现的功能基本上是稳定不变的它们往往会实现一些协议和规范比如Tomcat作为一个“HTTP服务器 + Servlet容器”它向开发人员屏蔽应用层协议和网络通信细节我们拿到的是一个标准的Request和Response对象而具体业务逻辑则作为变化点交给我们来实现。
从微观上来看Tomcat内部也隔离了变化点和不变点比如Tomcat和Jetty都采用了基于组件化的设计其目的就是为了实现“搭积木式”的高度定制化而组件的生命周期管理有一些共性被提取出来成为接口和抽象类而具体子类实现变化点。
其实当下流行的微服务也是这个思路,首先按照功能将单体应用拆成微服务,拆分的过程中要注意从众多微服务中提取一些共性,而这些共性就会成为一些核心的基础服务,或者成为一些通用库。
设计模式往往是封装变化的一把利器我在专栏里也谈到不少Tomcat和Jetty所采用的设计模式合理地运用设计模式能让我们的代码看起来优雅且整洁。
除此之外我们在编写程序时应该时刻考虑到高性能尤其是开发基础的中间件系统在大数据量、高并发情况下可能一行代码的改动会带来明显的性能提升。高效意味着合理的数据存储和流动方式换句话说其实就是合理地运用数据结构和算法举个最简单的例子在某个场景是选择数组还是链表。如果你深入了解过Tomcat你会发现在许多实际场景中Tomcat都会有针对性的选择所以对于一些常见的数据结构和算法虽然我们不需要深入到实现细节但是一定要知道在什么场景下用哪个。
此外写高性能程序还意味着你需要掌握操作系统底层原理并且深入到JVM底层的实现细节比如我们调用了一个Java APIJVM和操作系统在背后为我们做了什么呢挖得更深一点我们对程序的理解也就更深刻也许就是因为深入的这一小步能够让我们在竞争中脱颖而出。
不知不觉我从Tomcat和Jetty的学习谈到了如何优雅地设计一个复杂的系统。由点及面你可以把Tomcat和Jetty当作一个支点从我们身边“熟悉又陌生”的Tomcat和Jetty入手不光掌握它们的使用更能从它们的源码中汲取经验提升自己的系统设计能力。学习这件事千万不能浮躁很难做到一口吃成大胖子最重要的是需要静下心慢慢体会和思考。我看到不少同学的留言从提问的内容我能感受到你们的好奇心和思考有些问题我也还要去查阅源码才能回答上来在这个过程中我自己也主动或被动的学到不少东西所以说多和同行们交流也非常有必要。
学习永远在路上,最后祝我们一起进步!
[<img src="https://static001.geekbang.org/resource/image/d0/3d/d0c77baab97a0a372af5eedd8344613d.jpg" alt="">](http://jinshuju.net/f/VJkUch)

View File

@@ -0,0 +1,10 @@
你好,我是李号双。
到这里《深入拆解Tomcat&amp;Jetty》这门课程已经全部结束了。我给你准备了一个结课小测试来帮助你检验自己的学习效果。
这套测试题共有 20 道题目包括11道单选题和9道多选题满分 100 分,系统自动评分。
还等什么,点击下面按钮开始测试吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=114&amp;exam_id=245)