first commit
This commit is contained in:
59
专栏/深入拆解TomcatJetty/00开篇词Java程序员如何快速成长?.md
Normal file
59
专栏/深入拆解TomcatJetty/00开篇词Java程序员如何快速成长?.md
Normal file
@ -0,0 +1,59 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 Java程序员如何快速成长?
|
||||
你好,我是李号双,很高兴你走进我的“深入拆解Tomcat & 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开发中的位置。
|
||||
|
||||
|
||||
|
||||
随着学习的深入,我还发现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和Jetty,Java Web开发对你来说就已经毫无“秘密”可言。并且你能体会到大神们是如何设计Tomcat和Jetty的,体会他们如何思考问题、如何写代码。比如怎样设计服务端程序的I/O和线程模型、怎样写高性能高并发程序、Spring的IoC容器为什么设计成这个样子、设计一个中间件或者框架有哪些套路等…这些都能快速增加你的经验值。
|
||||
|
||||
成长的道路没有捷径,不仅需要上进心和耐心,还要保持对知识的好奇心。如果你也想在技术和视野上有所突破,拥有独当一面的能力,从Tomcat和Jetty入手是一个非常好的选择,我也邀请你与我一起探究Tomcat和Jetty的设计精髓,一起收获经验、享受成长。
|
||||
|
||||
最后,如果你正在Java Web开发这条路上向着架构师的方向狂奔,欢迎你给我留言,讲讲你所付出的努力、遇到了哪些问题,或者写写你对这个专栏的期待,期待与你交流。
|
||||
|
||||
|
||||
|
||||
|
91
专栏/深入拆解TomcatJetty/01Web容器学习路径.md
Normal file
91
专栏/深入拆解TomcatJetty/01Web容器学习路径.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 Web容器学习路径
|
||||
你好,我是李号双。在开篇词里我提到要成长为一名高级程序员或者架构师,我们需要提高自己知识的广度和深度。你可以先突破深度,再以点带面拓展广度,因此我建议通过深入学习一些优秀的开源系统来达到突破深度的目的。
|
||||
|
||||
我会跟你一起在这个专栏里深入学习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核心技术》、《Java编程思想》、《Java并发编程实战》和《深入理解Java虚拟机:JVM高级特性与最佳实践》等。
|
||||
|
||||
Java Web开发基础
|
||||
|
||||
具备了一定的操作系统和Java基础,接下来就可以开始学习Java Web开发,你可以开始学习一些通用的设计原则和设计模式。这个阶段的核心任务就是了解Web的工作原理,同时提高你的设计能力,注重代码的质量。我的建议是可以从学习Servlet和Servlet容器开始。我见过不少同学跳过这个阶段直接学Web框架,这样做的话结果会事倍功半。
|
||||
|
||||
为什么这么说呢?Web框架的本质是,开发者在使用某种语言编写Web应用时,总结出的一些经验和设计思路。很多Web框架都是从实际的Web项目抽取出来的,其目的是用于简化Web应用程序开发。
|
||||
|
||||
我以Spring框架为例,给你讲讲Web框架是怎么产生的。Web应用程序的开发主要是完成两方面的工作。
|
||||
|
||||
|
||||
设计并实现类,包括定义类与类之间的关系,以及实现类的方法,方法对数据的操作就是具体的业务逻辑。
|
||||
|
||||
类设计好之后,需要创建这些类的实例并根据类与类的关系把它们组装在一起,这样类的实例才能一起协作完成业务功能。
|
||||
|
||||
|
||||
就好比制造一辆汽车,汽车是由零件组装而成的。第一步是画出各种零件的图纸,以及定义零件之间的接口。第二步把把图纸交给工厂去生产零件并组装在一起。因此对于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容器时遇到了哪些问题?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
115
专栏/深入拆解TomcatJetty/02HTTP协议必知必会.md
Normal file
115
专栏/深入拆解TomcatJetty/02HTTP协议必知必会.md
Normal file
@ -0,0 +1,115 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 HTTP协议必知必会
|
||||
在开始学习Web容器之前,我想先问你一个问题:HTTP和HTML有什么区别?
|
||||
|
||||
为什么我会问这个问题?你可以把它当作一个入门测试,检测一下自己的对HTTP协议的理解。因为Tomcat和Jetty本身就是一个“HTTP服务器 + Servlet容器”,如果你想深入理解Tomcat和Jetty的工作原理,我认为理解HTTP协议的工作原理是学习的基础。
|
||||
|
||||
如果你对这个问题还稍有迟疑,那么请跟我一起来回顾一下HTTP协议吧。
|
||||
|
||||
HTTP的本质
|
||||
|
||||
HTTP协议是浏览器与服务器之间的数据传送协议。作为应用层协议,HTTP是基于TCP/IP协议来传递数据的(HTML文件、图片、查询结果等),HTTP协议不涉及数据包(Packet)传输,主要规定了客户端和服务器之间的通信格式。
|
||||
|
||||
下面我通过一个例子来告诉你HTTP的本质是什么。
|
||||
|
||||
假如浏览器需要从远程HTTP服务器获取一个HTML文本,在这个过程中,浏览器实际上要做两件事情。
|
||||
|
||||
|
||||
与服务器建立Socket连接。
|
||||
|
||||
生成请求数据并通过Socket发送出去。
|
||||
|
||||
|
||||
第一步比较容易理解,浏览器从地址栏获取用户输入的网址和端口,去连接远端的服务器,这样就能通信了。
|
||||
|
||||
我们重点来看第二步,这个请求数据到底长什么样呢?都请求些什么内容呢?或者换句话说,浏览器需要告诉服务端什么信息呢?
|
||||
|
||||
首先最基本的是,你要让服务端知道你的意图,你是想获取内容还是提交内容;其次你需要告诉服务端你想要哪个内容。那么要把这些信息以一种什么样的格式放到请求里去呢?这就是HTTP协议要解决的问题。也就是说,HTTP协议的本质就是一种浏览器与服务器之间约定好的通信格式。那浏览器与服务器之间具体是怎么工作的呢?
|
||||
|
||||
HTTP工作原理
|
||||
|
||||
请你来看下面这张图,我们过一遍一次HTTP的请求过程。
|
||||
|
||||
|
||||
|
||||
从图上你可以看到,这个过程是:
|
||||
|
||||
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请求:
|
||||
|
||||
|
||||
|
||||
你可以看到,HTTP请求数据由三部分组成,分别是请求行、请求报头、请求正文。当这个HTTP请求数据到达Tomcat后,Tomcat会把HTTP请求数据字节流解析成一个Request对象,这个Request对象封装了HTTP所有的请求信息。接着Tomcat把这个Request对象交给Web应用去处理,处理完后得到一个Response对象,Tomcat会把这个Response对象转成HTTP格式的响应数据并发送给浏览器。
|
||||
|
||||
我们再来看看HTTP响应的格式,HTTP的响应也是由三部分组成,分别是状态行、响应报头、报文主体。同样,我还以极客时间登陆请求的响应为例。
|
||||
|
||||
|
||||
|
||||
具体的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的特点是无状态的,多个请求之间是没有关系的,这是不是矛盾了?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
105
专栏/深入拆解TomcatJetty/03你应该知道的Servlet规范和Servlet容器.md
Normal file
105
专栏/深入拆解TomcatJetty/03你应该知道的Servlet规范和Servlet容器.md
Normal file
@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 你应该知道的Servlet规范和Servlet容器
|
||||
通过专栏上一期的学习我们知道,浏览器发给服务端的是一个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容器跟具体业务类之间的接口。下面我们通过一张图来加深理解。
|
||||
|
||||
|
||||
|
||||
图的左边表示HTTP服务器直接调用具体业务类,它们是紧耦合的。再看图的右边,HTTP服务器不直接调用业务类,而是把请求交给容器来处理,容器通过Servlet接口调用业务类。因此Servlet接口和Servlet容器的出现,达到了HTTP服务器与业务类解耦的目的。
|
||||
|
||||
而Servlet接口和Servlet容器这一整套规范叫作Servlet规范。Tomcat和Jetty都按照Servlet规范的要求实现了Servlet容器,同时它们也具有HTTP服务器的功能。作为Java程序员,如果我们要实现新的业务功能,只需要实现一个Servlet,并把它注册到Tomcat(Servlet容器)中,剩下的事情就由Tomcat帮我们处理了。
|
||||
|
||||
接下来我们来看看Servlet接口具体是怎么定义的,以及Servlet规范又有哪些要重点关注的地方呢?
|
||||
|
||||
Servlet接口
|
||||
|
||||
Servlet接口定义了下面五个方法:
|
||||
|
||||
public interface Servlet {
|
||||
void init(ServletConfig config) throws ServletException;
|
||||
|
||||
ServletConfig getServletConfig();
|
||||
|
||||
void service(ServletRequest req, ServletResponse res)throws 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服务器会把响应发送给客户端。同样我通过一张图来帮助你理解。
|
||||
|
||||
|
||||
|
||||
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的本质区别:
|
||||
|
||||
|
||||
Filter是干预过程的,它是过程的一部分,是基于过程行为的。
|
||||
|
||||
Listener是基于状态的,任何行为改变同一个状态,触发的事件是一致的。
|
||||
|
||||
|
||||
课后思考
|
||||
|
||||
Servlet容器与Spring容器有什么关系?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
250
专栏/深入拆解TomcatJetty/04实战:纯手工打造和运行一个Servlet.md
Normal file
250
专栏/深入拆解TomcatJetty/04实战:纯手工打造和运行一个Servlet.md
Normal file
@ -0,0 +1,250 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 实战:纯手工打造和运行一个Servlet
|
||||
作为Java程序员,我们可能已经习惯了使用IDE和Web框架进行开发,IDE帮我们做了编译、打包的工作,而Spring框架在背后帮我们实现了Servlet接口,并把Servlet注册到了Web容器,这样我们可能很少有机会接触到一些底层本质的东西,比如怎么开发一个Servlet?如何编译Servlet?如何在Web容器中跑起来?
|
||||
|
||||
今天我们就抛弃IDE、拒绝框架,自己纯手工编写一个Servlet,并在Tomcat中运行起来。一方面进一步加深对Servlet的理解;另一方面,还可以熟悉一下Tomcat的基本功能使用。
|
||||
|
||||
主要的步骤有:
|
||||
|
||||
1.下载并安装Tomcat。-
|
||||
2.编写一个继承HttpServlet的Java类。-
|
||||
3.将Java类文件编译成Class文件。-
|
||||
4.建立Web应用的目录结构,并配置web.xml。-
|
||||
5.部署Web应用。-
|
||||
6.启动Tomcat。-
|
||||
7.浏览器访问验证结果。-
|
||||
8.查看Tomcat日志。
|
||||
|
||||
下面你可以跟我一起一步步操作来完成整个过程。Servlet 3.0规范支持用注解的方式来部署Servlet,不需要在web.xml里配置,最后我会演示怎么用注解的方式来部署Servlet。
|
||||
|
||||
1. 下载并安装Tomcat
|
||||
|
||||
最新版本的Tomcat可以直接在官网上下载,根据你的操作系统下载相应的版本,这里我使用的是Mac系统,下载完成后直接解压,解压后的目录结构如下。
|
||||
|
||||
|
||||
|
||||
下面简单介绍一下这些目录:
|
||||
|
||||
/bin:存放Windows或Linux平台上启动和关闭Tomcat的脚本文件。-
|
||||
/conf:存放Tomcat的各种全局配置文件,其中最重要的是server.xml。-
|
||||
/lib:存放Tomcat以及所有Web应用都可以访问的JAR文件。-
|
||||
/logs:存放Tomcat执行时产生的日志文件。-
|
||||
/work:存放JSP编译后产生的Class文件。-
|
||||
/webapps:Tomcat的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("MyServlet 在处理get()请求...");
|
||||
PrintWriter out = response.getWriter();
|
||||
response.setContentType("text/html;charset=utf-8");
|
||||
out.println("<strong>My Servlet!</strong><br>");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
|
||||
System.out.println("MyServlet 在处理post()请求...");
|
||||
PrintWriter out = response.getWriter();
|
||||
response.setContentType("text/html;charset=utf-8");
|
||||
out.println("<strong>My Servlet!</strong><br>");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这个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,内容如下:
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
|
||||
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
|
||||
version="4.0"
|
||||
metadata-complete="true">
|
||||
|
||||
<description> Servlet Example. </description>
|
||||
<display-name> MyServlet Example </display-name>
|
||||
<request-character-encoding>UTF-8</request-character-encoding>
|
||||
|
||||
<servlet>
|
||||
<servlet-name>myServlet</servlet-name>
|
||||
<servlet-class>MyServlet</servlet-class>
|
||||
</servlet>
|
||||
|
||||
<servlet-mapping>
|
||||
<servlet-name>myServlet</servlet-name>
|
||||
<url-pattern>/myservlet</url-pattern>
|
||||
</servlet-mapping>
|
||||
|
||||
</web-app>
|
||||
|
||||
|
||||
你可以看到在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("/myAnnotationServlet")
|
||||
public class AnnotationServlet extends HttpServlet {
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
|
||||
System.out.println("AnnotationServlet 在处理get请求...");
|
||||
PrintWriter out = response.getWriter();
|
||||
response.setContentType("text/html; charset=utf-8");
|
||||
out.println("<strong>Annotation Servlet!</strong><br>");
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest request, HttpServletResponse response)
|
||||
throws ServletException, IOException {
|
||||
|
||||
System.out.println("AnnotationServlet 在处理post请求...");
|
||||
PrintWriter out = response.getWriter();
|
||||
response.setContentType("text/html; charset=utf-8");
|
||||
out.println("<strong>Annotation Servlet!</strong><br>");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这段代码里最关键的就是这个注解,它表明两层意思:第一层意思是AnnotationServlet这个Java类是一个Servlet,第二层意思是这个Servlet对应的URL路径是myAnnotationServlet。
|
||||
|
||||
@WebServlet("/myAnnotationServlet")
|
||||
|
||||
|
||||
创建好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方法。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
173
专栏/深入拆解TomcatJetty/05Tomcat系统架构(上): 连接器是如何设计的?.md
Normal file
173
专栏/深入拆解TomcatJetty/05Tomcat系统架构(上): 连接器是如何设计的?.md
Normal file
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 Tomcat系统架构(上): 连接器是如何设计的?
|
||||
05 Tomcat系统架构(上): 连接器是如何设计的?
|
||||
|
||||
在面试时我们可能经常被问到:你做的XX项目的架构是如何设计的,请讲一下实现的思路。对于面试官来说,可以通过你对复杂系统设计的理解,了解你的技术水平以及处理复杂问题的思路。
|
||||
|
||||
今天咱们就来一步一步分析Tomcat的设计思路,看看Tomcat的设计者们当时是怎么回答这个问题的。一方面我们可以学到Tomcat的总体架构,学会从宏观上怎么去设计一个复杂系统,怎么设计顶层模块,以及模块之间的关系;另一方面也为我们深入学习Tomcat的工作原理打下基础。
|
||||
|
||||
Tomcat总体架构
|
||||
|
||||
我们知道如果要设计一个系统,首先是要了解需求。通过专栏前面的文章,我们已经了解了Tomcat要实现2个核心功能:
|
||||
|
||||
|
||||
处理Socket连接,负责网络字节流与Request和Response对象的转化。
|
||||
|
||||
加载和管理Servlet,以及具体处理Request请求。
|
||||
|
||||
|
||||
因此Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。
|
||||
|
||||
所以连接器和容器可以说是Tomcat架构里最重要的两部分,需要你花些精力理解清楚。这两部分内容我会分成两期,今天我来分析连接器是如何设计的,下一期我会介绍容器的设计。
|
||||
|
||||
在开始讲连接器前,我先铺垫一下Tomcat支持的多种I/O模型和应用层协议。
|
||||
|
||||
Tomcat支持的I/O模型有:
|
||||
|
||||
|
||||
NIO:非阻塞I/O,采用Java NIO类库实现。
|
||||
|
||||
NIO.2:异步I/O,采用JDK 7最新的NIO.2类库实现。
|
||||
|
||||
APR:采用Apache可移植运行库实现,是C/C++编写的本地库。
|
||||
|
||||
|
||||
Tomcat支持的应用层协议有:
|
||||
|
||||
|
||||
HTTP/1.1:这是大部分Web应用采用的访问协议。
|
||||
|
||||
AJP:用于和Web服务器集成(如Apache)。
|
||||
|
||||
HTTP/2:HTTP 2.0大幅度的提升了Web性能。
|
||||
|
||||
|
||||
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。这里请你注意,Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。Tomcat内可能有多个Service,这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
|
||||
|
||||
到此我们得到这样一张关系图:
|
||||
|
||||
|
||||
|
||||
从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
|
||||
|
||||
连接器
|
||||
|
||||
连接器对Servlet容器屏蔽了协议及I/O模型等的区别,无论是HTTP还是AJP,在容器中获取到的都是一个标准的ServletRequest对象。
|
||||
|
||||
我们可以把连接器的功能需求进一步细化,比如:
|
||||
|
||||
|
||||
监听网络端口。
|
||||
|
||||
接受网络连接请求。
|
||||
|
||||
读取网络请求字节流。
|
||||
|
||||
根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的Tomcat Request对象。
|
||||
|
||||
将Tomcat Request对象转成标准的ServletRequest。
|
||||
|
||||
调用Servlet容器,得到ServletResponse。
|
||||
|
||||
将ServletResponse转成Tomcat Response对象。
|
||||
|
||||
将Tomcat Response转成网络字节流。
|
||||
|
||||
将响应字节流写回给浏览器。
|
||||
|
||||
|
||||
需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?优秀的模块化设计应该考虑高内聚、低耦合。
|
||||
|
||||
|
||||
高内聚是指相关度比较高的功能要尽可能集中,不要分散。
|
||||
|
||||
低耦合是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。
|
||||
|
||||
|
||||
通过分析连接器的详细功能列表,我们发现连接器需要完成3个高内聚的功能:
|
||||
|
||||
|
||||
网络通信。
|
||||
|
||||
应用层协议解析。
|
||||
|
||||
Tomcat Request/Response与ServletRequest/ServletResponse的转化。
|
||||
|
||||
|
||||
因此Tomcat的设计者设计了3个组件来实现这3个功能,分别是Endpoint、Processor和Adapter。
|
||||
|
||||
组件之间通过抽象接口交互。这样做还有一个好处是封装变化。这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。
|
||||
|
||||
网络通信的I/O模型是变化的,可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的,可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。
|
||||
|
||||
但是整体的处理逻辑是不变的,Endpoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adapter,Adapter负责提供ServletRequest对象给容器。
|
||||
|
||||
如果要支持新的I/O方案、新的应用层协议,只需要实现相关的具体子类,上层通用的处理逻辑是不变的。
|
||||
|
||||
由于I/O模型和应用层协议可以自由组合,比如NIO + HTTP或者NIO.2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol和AjpNioProtocol。
|
||||
|
||||
除了这些变化点,系统也存在一些相对稳定的部分,因此Tomcat设计了一系列抽象基类来封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。下面我整理一下它们的继承关系。
|
||||
|
||||
|
||||
|
||||
通过上面的图,你可以清晰地看到它们的继承和层次关系,这样设计的目的是尽量将稳定的部分放到抽象基类,同时每一种I/O模型和协议的组合都有相应的具体实现类,我们在使用时可以自由选择。
|
||||
|
||||
小结一下,连接器模块用三个核心组件:Endpoint、Processor和Adapter来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。
|
||||
|
||||
|
||||
|
||||
下面我来详细介绍这两个顶层组件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等,这些具体实现类实现了特定协议的解析方法和请求处理方式。
|
||||
|
||||
我们再来看看连接器的组件图:
|
||||
|
||||
|
||||
|
||||
从图中我们看到,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整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。
|
||||
|
||||
课后思考
|
||||
|
||||
回忆一下你在工作中曾经独立设计过的系统,或者你碰到过的设计类面试题,结合今天专栏的内容,你有没有一些新的思路?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
136
专栏/深入拆解TomcatJetty/06Tomcat系统架构(下):聊聊多层容器的设计.md
Normal file
136
专栏/深入拆解TomcatJetty/06Tomcat系统架构(下):聊聊多层容器的设计.md
Normal file
@ -0,0 +1,136 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 Tomcat系统架构(下):聊聊多层容器的设计
|
||||
专栏上一期我们学完了连接器的设计,今天我们一起来看一下Tomcat的容器设计。先复习一下,上期我讲到了Tomcat有两个核心组件:连接器和容器,其中连接器负责外部交流,容器负责内部处理。具体来说就是,连接器处理Socket通信和应用层协议的解析,得到Servlet请求;而容器则负责处理Servlet请求。我们通过下面这张图来回忆一下。
|
||||
|
||||
|
||||
|
||||
容器,顾名思义就是用来装载东西的器具,在Tomcat里,容器就是用来装载Servlet的。那Tomcat的Servlet容器是如何设计的呢?
|
||||
|
||||
容器的层次结构
|
||||
|
||||
Tomcat设计了4种容器,分别是Engine、Host、Context和Wrapper。这4种容器不是平行关系,而是父子关系。下面我画了一张图帮你理解它们的关系。
|
||||
|
||||
|
||||
|
||||
你可能会问,为什么要设计成这么多层次的容器,这不是增加了复杂度吗?其实这背后的考虑是,Tomcat通过一种分层的架构,使得Servlet容器具有很好的灵活性。
|
||||
|
||||
Context表示一个Web应用程序;Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序;Engine表示引擎,用来管理多个虚拟站点,一个Service最多只能有一个Engine。
|
||||
|
||||
你可以再通过Tomcat的server.xml配置文件来加深对Tomcat容器的理解。Tomcat采用了组件化的设计,它的构成组件都是可配置的,其中最外层的是Server,其他组件按照一定的格式要求配置在这个顶层容器中。
|
||||
|
||||
|
||||
|
||||
那么,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应用通常有多个Servlet,Tomcat还会在每个Context容器里创建多个Wrapper子容器。每个容器都有对应的访问路径,你可以通过下面这张图来帮助你理解。
|
||||
|
||||
|
||||
|
||||
假如有用户访问一个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路径找到Wrapper(Servlet)。
|
||||
|
||||
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。我还是通过一张图来解释。
|
||||
|
||||
|
||||
|
||||
整个调用过程由连接器中的Adapter触发的,它会调用Engine的第一个Valve:
|
||||
|
||||
// Calling the container
|
||||
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
|
||||
|
||||
|
||||
Wrapper容器的最后一个Valve会创建一个Filter链,并调用doFilter方法,最终会调到Servlet的service方法。
|
||||
|
||||
你可能会问,前面我们不是讲到了Filter,似乎也有相似的功能,那Valve和Filter有什么区别吗?它们的区别是:
|
||||
|
||||
|
||||
Valve是Tomcat的私有机制,与Tomcat的基础架构/API是紧耦合的。Servlet API是公有的标准,所有的Web容器包括Jetty都支持Filter机制。
|
||||
|
||||
另一个重要的区别是Valve工作在Web容器级别,拦截所有应用的请求;而Servlet Filter工作在应用级别,只能拦截某个Web应用的所有请求。如果想做整个Web容器的拦截器,必须通过Valve来实现。
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
今天我们学习了Tomcat容器的层次结构、根据请求定位Servlet的过程,以及请求在容器中的调用过程。Tomcat设计了多层容器是为了灵活性的考虑,灵活性具体体现在一个Tomcat实例(Server)可以有多个Service,每个Service通过多个连接器监听不同的端口,而一个Service又可以支持多个虚拟主机。一个URL网址可以用不同的主机名、不同的端口和不同的路径来访问特定的Servlet实例。
|
||||
|
||||
请求的链式调用是基于Pipeline-Valve责任链来完成的,这样的设计使得系统具有良好的可扩展性,如果需要扩展容器本身的功能,只需要增加相应的Valve即可。
|
||||
|
||||
课后思考
|
||||
|
||||
Tomcat内的Context组件跟Servlet规范中的ServletContext接口有什么区别?跟Spring中的ApplicationContext又有什么关系?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
151
专栏/深入拆解TomcatJetty/07Tomcat如何实现一键式启停?.md
Normal file
151
专栏/深入拆解TomcatJetty/07Tomcat如何实现一键式启停?.md
Normal file
@ -0,0 +1,151 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 Tomcat如何实现一键式启停?
|
||||
通过前面的学习,相信你对Tomcat的架构已经有所了解,知道了Tomcat都有哪些组件,组件之间是什么样的关系,以及Tomcat是怎么处理一个HTTP请求的。下面我们通过一张简化的类图来回顾一下,从图上你可以看到各种组件的层次关系,图中的虚线表示一个请求在Tomcat中流转的过程。
|
||||
|
||||
|
||||
|
||||
上面这张图描述了组件之间的静态关系,如果想让一个系统能够对外提供服务,我们需要创建、组装并启动这些组件;在服务停止的时候,我们还需要释放资源,销毁这些组件,因此这是一个动态的过程。也就是说,Tomcat需要动态地管理这些组件的生命周期。
|
||||
|
||||
在我们实际的工作中,如果你需要设计一个比较大的系统或者框架时,你同样也需要考虑这几个问题:如何统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组件?如何做到组件启动和停止不遗漏、不重复?
|
||||
|
||||
今天我们就来解决上面的问题,在这之前,先来看看组件之间的关系。如果你仔细分析过这些组件,可以发现它们具有两层关系。
|
||||
|
||||
|
||||
第一层关系是组件有大有小,大组件管理小组件,比如Server管理Service,Service又管理连接器和容器。
|
||||
|
||||
第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件调用内层组件完成业务功能。也就是说,请求的处理过程是由外层组件来驱动的。
|
||||
|
||||
|
||||
这两层关系决定了系统在创建组件时应该遵循一定的顺序。
|
||||
|
||||
|
||||
第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
|
||||
|
||||
第二个原则是先创建内层组件,再创建外层组件,内层组件需要被“注入”到外层组件。
|
||||
|
||||
|
||||
因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。不知道你注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于后期的功能扩展。
|
||||
|
||||
为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启动”那样的效果。
|
||||
|
||||
一键式启停:Lifecycle接口
|
||||
|
||||
我在前面说到过,设计就是要找到系统的变化点和不变点。这里的不变点就是每个组件都要经历创建、初始化、启动这几个过程,这些状态以及状态的转化是不变的。而变化点是每个具体组件的初始化方法,也就是启动方法是不一样的。
|
||||
|
||||
因此,我们把不变点抽象出来成为一个接口,这个接口跟生命周期有关,叫作Lifecycle。Lifecycle接口里应该定义这么几个方法:init、start、stop和destroy,每个具体的组件去实现这些方法。
|
||||
|
||||
理所当然,在父组件的init方法里需要创建子组件并调用子组件的init方法。同样,在父组件的start方法里也需要调用子组件的start方法,因此调用者可以无差别的调用各组件的init方法和start方法,这就是组合模式的使用,并且只要调用最顶层组件,也就是Server组件的init和start方法,整个Tomcat就被启动起来了。下面是Lifecycle接口的定义。
|
||||
|
||||
|
||||
|
||||
可扩展性:Lifecycle事件
|
||||
|
||||
我们再来考虑另一个问题,那就是系统的可扩展性。因为各个组件init和start方法的具体实现是复杂多变的,比如在Host容器的启动方法里需要扫描webapps目录下的Web应用,创建相应的Context容器,如果将来需要增加新的逻辑,直接修改start方法?这样会违反开闭原则,那如何解决这个问题呢?开闭原则说的是为了扩展系统的功能,你不能直接修改系统中已有的类,但是你可以定义新的类。
|
||||
|
||||
我们注意到,组件的init和start调用是由它的父组件的状态变化触发的,上层组件的初始化会触发子组件的初始化,上层组件的启动会触发子组件的启动,因此我们把组件的生命周期定义成一个个状态,把状态的转变看作是一个事件。而事件是有监听器的,在监听器里可以实现一些逻辑,并且监听器也可以方便的添加和删除,这就是典型的观察者模式。
|
||||
|
||||
具体来说就是在Lifecycle接口里加入两个方法:添加监听器和删除监听器。除此之外,我们还需要定义一个Enum来表示组件有哪些状态,以及处在什么状态会触发什么样的事件。因此Lifecycle接口和LifecycleState就定义成了下面这样。
|
||||
|
||||
|
||||
|
||||
从图上你可以看到,组件的生命周期有NEW、INITIALIZING、INITIALIZED、STARTING_PREP、STARTING、STARTED等,而一旦组件到达相应的状态就触发相应的事件,比如NEW状态表示组件刚刚被实例化;而当init方法被调用时,状态就变成INITIALIZING状态,这个时候,就会触发BEFORE_INIT_EVENT事件,如果有监听器在监听这个事件,它的方法就会被调用。
|
||||
|
||||
重用性:LifecycleBase抽象基类
|
||||
|
||||
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
|
||||
|
||||
而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。
|
||||
|
||||
比如宝马和荣威的底盘和骨架其实是一样的,只是发动机和内饰等配套是不一样的。底盘和骨架就是基类,宝马和荣威就是子类。仅仅有底盘和骨架还不是一辆真正意义上的车,只能算是半成品,因此在底盘和骨架上会留出一些安装接口,比如安装发动机的接口、安装座椅的接口,这些就是抽象方法。宝马或者荣威上安装的发动机和座椅是不一样的,也就是具体子类对抽象方法有不同的实现。
|
||||
|
||||
回到Lifecycle接口,Tomcat定义一个基类LifecycleBase来实现Lifecycle接口,把一些公共的逻辑放到基类中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的初始化、启动和停止等方法。为了避免跟基类中的方法同名,我们把具体子类的实现方法改个名字,在后面加上Internal,叫initInternal、startInternal等。我们再来看引入了基类LifecycleBase后的类图:
|
||||
|
||||
|
||||
|
||||
从图上可以看到,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负责触发事件,并调用监听器的方法,那是什么时候、谁把监听器注册进来的呢?
|
||||
|
||||
分为两种情况:
|
||||
|
||||
|
||||
Tomcat自定义了一些监听器,这些监听器是父组件在创建子组件的过程中注册到子组件的。比如MemoryLeakTrackingListener监听器,用来检测Context容器中的内存泄漏,这个监听器是Host容器在创建Context容器时注册到Context中的。
|
||||
|
||||
我们还可以在server.xml中定义自己的监听器,Tomcat在启动时会解析server.xml,创建监听器并注册到容器组件。
|
||||
|
||||
|
||||
生周期管理总体类图
|
||||
|
||||
通过上面的学习,我相信你对Tomcat组件的生命周期的管理有了深入的理解,我们再来看一张总体类图继续加深印象。
|
||||
|
||||
|
||||
|
||||
这里请你注意,图中的StandardServer、StandardService等是Server和Service组件的具体实现类,它们都继承了LifecycleBase。
|
||||
|
||||
StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类,因为它们都是容器,所以继承了ContainerBase抽象基类,而ContainerBase实现了Container接口,也继承了LifecycleBase类,它们的生命周期管理接口和功能接口是分开的,这也符合设计中接口分离的原则。
|
||||
|
||||
本期精华
|
||||
|
||||
Tomcat为了实现一键式启停以及优雅的生命周期管理,并考虑到了可扩展性和可重用性,将面向对象思想和设计模式发挥到了极致,分别运用了组合模式、观察者模式、骨架抽象类和模板方法。
|
||||
|
||||
如果你需要维护一堆具有父子关系的实体,可以考虑使用组合模式。
|
||||
|
||||
观察者模式听起来“高大上”,其实就是当一个事件发生后,需要执行一连串更新操作。传统的实现方式是在事件响应代码里直接加更新逻辑,当更新逻辑加多了之后,代码会变得臃肿,并且这种方式是紧耦合的、侵入式的。而观察者模式实现了低耦合、非侵入式的通知与更新机制。
|
||||
|
||||
而模板方法在抽象基类中经常用到,用来实现通用逻辑。
|
||||
|
||||
课后思考
|
||||
|
||||
从文中最后的类图上你会看到所有的容器组件都扩展了ContainerBase,跟LifecycleBase一样,ContainerBase也是一个骨架抽象类,请你思考一下,各容器组件有哪些“共同的逻辑”需要ContainerBase由来实现呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
255
专栏/深入拆解TomcatJetty/08Tomcat的“高层们”都负责做什么?.md
Normal file
255
专栏/深入拆解TomcatJetty/08Tomcat的“高层们”都负责做什么?.md
Normal file
@ -0,0 +1,255 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 Tomcat的“高层们”都负责做什么?
|
||||
使用过Tomcat的同学都知道,我们可以通过Tomcat的/bin目录下的脚本startup.sh来启动Tomcat,那你是否知道我们执行了这个脚本后发生了什么呢?你可以通过下面这张流程图来了解一下。
|
||||
|
||||
|
||||
|
||||
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("catalina.noServer"));
|
||||
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("service", 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. 先启动Engine,Engine会启动它子容器
|
||||
if (engine != null) {
|
||||
synchronized (engine) {
|
||||
engine.start();
|
||||
}
|
||||
}
|
||||
|
||||
//3. 再启动Mapper监听器
|
||||
mapperListener.start();
|
||||
|
||||
//4.最后启动连接器,连接器会启动它子组件,比如Endpoint
|
||||
synchronized (connectorsLock) {
|
||||
for (Connector connector: connectors) {
|
||||
if (connector.getState() != LifecycleState.FAILED) {
|
||||
connector.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从启动方法可以看到,Service先启动了Engine组件,再启动Mapper监听器,最后才是启动连接器。这很好理解,因为内层组件启动好了才能对外提供服务,才能启动外层的连接器组件。而Mapper也依赖容器组件,容器组件启动好了才能监听它们的变化,因此Mapper和MapperListener在容器组件之后启动。组件停止的顺序跟启动顺序正好相反的,也是基于它们的依赖关系。
|
||||
|
||||
Engine组件
|
||||
|
||||
最后我们再来看看顶层的容器组件Engine具体是如何实现的。Engine本质是一个容器,因此它继承了ContainerBase基类,并且实现了Engine接口。
|
||||
|
||||
public class StandardEngine extends ContainerBase implements Engine {
|
||||
}
|
||||
|
||||
|
||||
我们知道,Engine的子容器是Host,所以它持有了一个Host容器的数组,这些功能都被抽象到了ContainerBase中,ContainerBase中有这样一个数据结构:
|
||||
|
||||
protected final HashMap<String, Container> children = new HashMap<>();
|
||||
|
||||
|
||||
ContainerBase用HashMap保存了它的子容器,并且ContainerBase还实现了子容器的“增删改查”,甚至连子组件的启动和停止都提供了默认实现,比如ContainerBase会用专门的线程池来启动子容器。
|
||||
|
||||
for (int i = 0; i < 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组件的在启动连接器和容器时,都分别加了锁,这是为什么呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
212
专栏/深入拆解TomcatJetty/09比较:Jetty架构特点之Connector组件.md
Normal file
212
专栏/深入拆解TomcatJetty/09比较:Jetty架构特点之Connector组件.md
Normal file
@ -0,0 +1,212 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 比较:Jetty架构特点之Connector组件
|
||||
经过专栏前面几期的学习,相信你对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(处理器),以及一个线程池组成。整体结构请看下面这张图。
|
||||
|
||||
|
||||
|
||||
跟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共享的。
|
||||
|
||||
|
||||
|
||||
它们的第二个区别是,在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编程,如果你还不太熟悉,可以先学习这一系列文章。Java NIO的核心组件是Channel、Buffer和Selector。Channel表示一个连接,可以理解为一个Socket,通过它可以读取和写入数据,但是并不能直接操作数据,需要通过Buffer来中转。
|
||||
|
||||
Selector可以用来检测Channel上的I/O事件,比如读就绪、写就绪、连接就绪,一个Selector可以同时处理多个Channel,因此单个线程可以监听多个Channel,这样会大量减少线程上下文切换的开销。下面我们通过一个典型的服务端NIO程序来回顾一下如何使用这些组件。
|
||||
|
||||
首先,创建服务端Channel,绑定监听端口并把Channel设置为非阻塞方式。
|
||||
|
||||
ServerSocketChannel server = ServerSocketChannel.open();
|
||||
server.socket().bind(new InetSocketAddress(port));
|
||||
server.configureBlocking(false);
|
||||
|
||||
|
||||
然后,创建Selector,并在Selector中注册Channel感兴趣的事件OP_ACCEPT,告诉Selector如果客户端有新的连接请求到这个端口就通知我。
|
||||
|
||||
Selector selector = Selector.open();
|
||||
server.register(selector, SelectionKey.OP_ACCEPT);
|
||||
|
||||
|
||||
接下来,Selector会在一个死循环里不断地调用select去查询I/O状态,select会返回一个SelectionKey列表,Selector会遍历这个列表,看看是否有“客户”感兴趣的事件,如果有,就采取相应的动作。
|
||||
|
||||
比如下面这个例子,如果有新的连接请求,就会建立一个新的连接。连接建立后,再注册Channel的可读事件到Selector中,告诉Selector我对这个Channel上是否有新的数据到达感兴趣。
|
||||
|
||||
while (true) {
|
||||
selector.select();//查询I/O事件
|
||||
for (Iterator<SelectionKey> i = selector.selectedKeys().iterator(); i.hasNext();) {
|
||||
SelectionKey key = i.next();
|
||||
i.remove();
|
||||
|
||||
if (key.isAcceptable()) {
|
||||
// 建立一个新连接
|
||||
SocketChannel client = server.accept();
|
||||
client.configureBlocking(false);
|
||||
|
||||
//连接建立后,告诉Selector,我现在对I/O可读事件感兴趣
|
||||
client.register(selector, SelectionKey.OP_READ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
简单回顾完服务端NIO编程之后,你会发现服务端在I/O通信上主要完成了三件事情:监听连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来分别做这三件事情,下面我分别来说说这三个组件。
|
||||
|
||||
Acceptor
|
||||
|
||||
顾名思义,Acceptor用于接受请求,跟Tomcat一样,Jetty也有独立的Acceptor线程组用于处理连接请求。在Connector的实现类ServerConnector中,有一个_acceptors的数组,在Connector启动的时候, 会根据_acceptors数组的长度创建对应数量的Acceptor,而Acceptor的个数可以配置。
|
||||
|
||||
for (int i = 0; i < _acceptors.length; i++)
|
||||
{
|
||||
Acceptor a = new Acceptor(i);
|
||||
getExecutor().execute(a);
|
||||
}
|
||||
|
||||
|
||||
Acceptor是ServerConnector中的一个内部类,同时也是一个Runnable,Acceptor线程是通过getExecutor得到的线程池来执行的,前面提到这是一个全局的线程池。
|
||||
|
||||
Acceptor通过阻塞的方式来接受连接,这一点跟Tomcat也是一样的。
|
||||
|
||||
public void accept(int acceptorID) throws IOException
|
||||
{
|
||||
ServerSocketChannel serverChannel = _acceptChannel;
|
||||
if (serverChannel != null && serverChannel.isOpen())
|
||||
{
|
||||
// 这里是阻塞的
|
||||
SocketChannel channel = serverChannel.accept();
|
||||
// 执行到这里时说明有请求进来了
|
||||
accepted(channel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
接受连接成功后会调用accepted函数,accepted函数中会将SocketChannel设置为非阻塞模式,然后交给Selector去处理,因此这也就到了Selector的地界了。
|
||||
|
||||
private void accepted(SocketChannel channel) throws IOException
|
||||
{
|
||||
channel.configureBlocking(false);
|
||||
Socket socket = channel.socket();
|
||||
configure(socket);
|
||||
// _manager是SelectorManager实例,里面管理了所有的Selector实例
|
||||
_manager.accept(channel);
|
||||
}
|
||||
|
||||
|
||||
SelectorManager
|
||||
|
||||
Jetty的Selector由SelectorManager类管理,而被管理的Selector叫作ManagedSelector。SelectorManager内部有一个ManagedSelector数组,真正干活的是ManagedSelector。咱们接着上面分析,看看在SelectorManager在accept方法里做了什么。
|
||||
|
||||
public void accept(SelectableChannel channel, Object attachment)
|
||||
{
|
||||
//选择一个ManagedSelector来处理Channel
|
||||
final ManagedSelector selector = chooseSelector();
|
||||
//提交一个任务Accept给ManagedSelector
|
||||
selector.submit(selector.new Accept(channel, attachment));
|
||||
}
|
||||
|
||||
|
||||
SelectorManager从本身的Selector数组中选择一个Selector来处理这个Channel,并创建一个任务Accept交给ManagedSelector,ManagedSelector在处理这个任务主要做了两步:
|
||||
|
||||
第一步,调用Selector的register方法把Channel注册到Selector上,拿到一个SelectionKey。
|
||||
|
||||
_key = _channel.register(selector, SelectionKey.OP_ACCEPT, this);
|
||||
|
||||
|
||||
第二步,创建一个EndPoint和Connection,并跟这个SelectionKey(Channel)绑在一起:
|
||||
|
||||
private void createEndPoint(SelectableChannel channel, SelectionKey selectionKey) throws IOException
|
||||
{
|
||||
//1. 创建EndPoint
|
||||
EndPoint endPoint = _selectorManager.newEndPoint(channel, this, selectionKey);
|
||||
|
||||
//2. 创建Connection
|
||||
Connection connection = _selectorManager.newConnection(channel, endPoint, selectionKey.attachment());
|
||||
|
||||
//3. 把EndPoint、Connection和SelectionKey绑在一起
|
||||
endPoint.setConnection(connection);
|
||||
selectionKey.attach(endPoint);
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面这两个过程是什么意思呢?打个比方,你到餐厅吃饭,先点菜(注册I/O事件),服务员(ManagedSelector)给你一个单子(SelectionKey),等菜做好了(I/O事件到了),服务员根据单子就知道是哪桌点了这个菜,于是喊一嗓子某某桌的菜做好了(调用了绑定在SelectionKey上的EndPoint的方法)。
|
||||
|
||||
这里需要你特别注意的是,ManagedSelector并没有调用直接EndPoint的方法去处理数据,而是通过调用EndPoint的方法返回一个Runnable,然后把这个Runnable扔给线程池执行,所以你能猜到,这个Runnable才会去真正读数据和处理请求。
|
||||
|
||||
Connection
|
||||
|
||||
这个Runnable是EndPoint的一个内部类,它会调用Connection的回调方法来处理请求。Jetty的Connection组件类比就是Tomcat的Processor,负责具体协议的解析,得到Request对象,并调用Handler容器进行处理。下面我简单介绍一下它的具体实现类HttpConnection对请求和响应的处理过程。
|
||||
|
||||
请求处理:HttpConnection并不会主动向EndPoint读取数据,而是向在EndPoint中注册一堆回调方法:
|
||||
|
||||
getEndPoint().fillInterested(_readCallback);
|
||||
|
||||
|
||||
这段代码就是告诉EndPoint,数据到了你就调我这些回调方法_readCallback吧,有点异步I/O的感觉,也就是说Jetty在应用层面模拟了异步I/O模型。
|
||||
|
||||
而在回调方法_readCallback里,会调用EndPoint的接口去读数据,读完后让HTTP解析器去解析字节流,HTTP解析器会将解析后的数据,包括请求行、请求头相关信息存到Request对象里。
|
||||
|
||||
响应处理:Connection调用Handler进行业务处理,Handler会通过Response对象来操作响应流,向流里面写入数据,HttpConnection再通过EndPoint把数据写到Channel,这样一次响应就完成了。
|
||||
|
||||
到此你应该了解了Connector的工作原理,下面我画张图再来回顾一下Connector的工作流程。
|
||||
|
||||
|
||||
|
||||
1.Acceptor监听连接请求,当有连接请求到达时就接受连接,一个连接对应一个Channel,Acceptor将Channel交给ManagedSelector来处理。
|
||||
|
||||
2.ManagedSelector把Channel注册到Selector上,并创建一个EndPoint和Connection跟这个Channel绑定,接着就不断地检测I/O事件。
|
||||
|
||||
3.I/O事件到了就调用EndPoint的方法拿到一个Runnable,并扔给线程池执行。
|
||||
|
||||
4.线程池中调度某个线程执行Runnable。
|
||||
|
||||
5.Runnable执行时,调用回调函数,这个回调函数是Connection注册到EndPoint中的。
|
||||
|
||||
6.回调函数内部实现,其实就是调用EndPoint的接口方法来读数据。
|
||||
|
||||
7.Connection解析读到的数据,生成请求对象并交给Handler组件去处理。
|
||||
|
||||
本期精华
|
||||
|
||||
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来做这三件事情。今天的思考题是,为什么要把这些组件跑在不同的线程里呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
127
专栏/深入拆解TomcatJetty/10比较:Jetty架构特点之Handler组件.md
Normal file
127
专栏/深入拆解TomcatJetty/10比较:Jetty架构特点之Handler组件.md
Normal file
@ -0,0 +1,127 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 比较:Jetty架构特点之Handler组件
|
||||
在专栏上一期,我们学习了Jetty的整体架构。先来回顾一下,Jetty 就是由多个Connector(连接器)、多个Handler(处理器),以及一个线程池组成,整体结构图如下。
|
||||
|
||||
|
||||
|
||||
上一期我们分析了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类之间的继承关系比较复杂,我们先通过一个全景图来了解一下。为了避免让你感到不适,我对类图进行了简化。
|
||||
|
||||
|
||||
|
||||
从图上你可以看到,Handler的种类和层次关系还是比较复杂的:
|
||||
|
||||
Handler接口之下有抽象类AbstractHandler,这一点并不意外,因为有接口一般就有抽象实现类。
|
||||
|
||||
在AbstractHandler之下有AbstractHandlerContainer,为什么需要这个类呢?这其实是个过渡,为了实现链式调用,一个Handler内部必然要有其他Handler的引用,所以这个类的名字里才有Container,意思就是这样的Handler里包含了其他Handler的引用。
|
||||
|
||||
理解了上面的AbstractHandlerContainer,我们就能理解它的两个子类了:HandlerWrapper和HandlerCollection。简单来说就是,HandlerWrapper和HandlerCollection都是Handler,但是这些Handler里还包括其他Handler的引用。不同的是,HandlerWrapper只包含一个其他Handler的引用,而HandlerCollection中有一个Handler数组的引用。
|
||||
|
||||
|
||||
|
||||
接着来看左边的HandlerWrapper,它有两个子类:Server和ScopedHandler。Server比较好理解,它本身是Handler模块的入口,必然要将请求传递给其他Handler来处理,为了触发其他Handler的调用,所以它是一个HandlerWrapper。
|
||||
|
||||
再看ScopedHandler,它也是一个比较重要的Handler,实现了“具有上下文信息”的责任链调用。为什么我要强调“具有上下文信息”呢?那是因为Servlet规范规定Servlet在执行过程中是有上下文的。那么这些Handler在执行过程中如何访问这个上下文呢?这个上下文又存在什么地方呢?答案就是通过ScopedHandler来实现的。
|
||||
|
||||
而ScopedHandler有一堆的子类,这些子类就是用来实现Servlet规范的,比如ServletHandler、ContextHandler、SessionHandler、ServletContextHandler和WebAppContext。接下来我会详细介绍它们,但我们先把总体类图看完。
|
||||
|
||||
请看类图的右边,跟HandlerWrapper对等的还有HandlerCollection,HandlerCollection其实维护了一个Handler数组。你可能会问,为什么要发明一个这样的Handler?这是因为Jetty可能需要同时支持多个Web应用,如果每个Web应用有一个Handler入口,那么多个Web应用的Handler就成了一个数组,比如Server中就有一个HandlerCollection,Server会根据用户请求的URL从数组中选取相应的Handler来处理,就是选择特定的Web应用来处理请求。
|
||||
|
||||
Handler的类型
|
||||
|
||||
虽然从类图上看Handler有很多,但是本质上这些Handler分成三种类型:
|
||||
|
||||
|
||||
第一种是协调Handler,这种Handler负责将请求路由到一组Handler中去,比如上图中的HandlerCollection,它内部持有一个Handler数组,当请求到来时,它负责将请求转发到数组中的某一个Handler。
|
||||
第二种是过滤器Handler,这种Handler自己会处理请求,处理完了后再把请求转发到下一个Handler,比如图上的HandlerWrapper,它内部持有下一个Handler的引用。需要注意的是,所有继承了HandlerWrapper的Handler都具有了过滤器Handler的特征,比如ContextHandler、SessionHandler和WebAppContext等。
|
||||
第三种是内容Handler,说白了就是这些Handler会真正调用Servlet来处理请求,生成响应的内容,比如ServletHandler。如果浏览器请求的是一个静态资源,也有相应的ResourceHandler来处理这个请求,返回静态页面。
|
||||
|
||||
|
||||
如何实现Servlet规范
|
||||
|
||||
上文提到,ServletHandler、ContextHandler以及WebAppContext等,它们实现了Servlet规范,那具体是怎么实现的呢?为了帮助你理解,在这之前,我们还是来看看如何使用Jetty来启动一个Web应用。
|
||||
|
||||
//新建一个WebAppContext,WebAppContext是一个Handler
|
||||
WebAppContext webapp = new WebAppContext();
|
||||
webapp.setContextPath("/mywebapp");
|
||||
webapp.setWar("mywebapp.war");
|
||||
|
||||
//将Handler添加到Server中去
|
||||
server.setHandler(webapp);
|
||||
|
||||
//启动Server
|
||||
server.start();
|
||||
server.join();
|
||||
|
||||
|
||||
上面的过程主要分为两步:
|
||||
|
||||
第一步创建一个WebAppContext,接着设置一些参数到这个Handler中,就是告诉WebAppContext你的WAR包放在哪,Web应用的访问路径是什么。
|
||||
|
||||
第二步就是把新创建的WebAppContext添加到Server中,然后启动Server。
|
||||
|
||||
WebAppContext对应一个Web应用。我们回忆一下Servlet规范中有Context、Servlet、Filter、Listener和Session等,Jetty要支持Servlet规范,就需要有相应的Handler来分别实现这些功能。因此,Jetty设计了3个组件:ContextHandler、ServletHandler和SessionHandler来实现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。我们通过一张图来理解一下。
|
||||
|
||||
|
||||
|
||||
通过对比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有什么区别呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
62
专栏/深入拆解TomcatJetty/11总结:从Tomcat和Jetty中提炼组件化设计规范.md
Normal file
62
专栏/深入拆解TomcatJetty/11总结:从Tomcat和Jetty中提炼组件化设计规范.md
Normal file
@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 总结:从Tomcat和Jetty中提炼组件化设计规范
|
||||
在当今的互联网时代,我们每个人获取信息的机会基本上都是平等的,但是为什么有些人对信息理解得更深,并且有自己独到的见解呢?我认为是因为他们养成了思考和总结的好习惯。当我们学习一门技术的时候,如果可以勤于思考、善于总结,可以帮助我们看到现象背后更本质的东西,让我们在成长之路上更快“脱颖而出”。
|
||||
|
||||
我们经常谈敏捷、快速迭代和重构,这些都是为了应对需求的快速变化,也因此我们在开始设计一个系统时就要考虑可扩展性。那究竟该怎样设计才能适应变化呢?或者要设计成什么样后面才能以最小的成本进行重构呢?今天我来总结一些Tomcat和Jetty组件化的设计思想,或许从中我们可以得到一些启发。
|
||||
|
||||
组件化及可配置
|
||||
|
||||
Tomcat和Jetty的整体架构都是基于组件的,你可以通过XML文件或者代码的方式来配置这些组件,比如我们可以在server.xml配置Tomcat的连接器以及容器组件。相应的,你也可以在jetty.xml文件里组装Jetty的Connector组件,以及各种Handler组件。也就是说,Tomcat和Jetty提供了一堆积木,怎么搭建这些积木由你来决定,你可以根据自己的需要灵活选择组件来搭建你的Web容器,并且也可以自定义组件,这样的设计为Web容器提供了深度可定制化。
|
||||
|
||||
那Web容器如何实现这种组件化设计呢?我认为有两个要点:
|
||||
|
||||
|
||||
第一个是面向接口编程。我们需要对系统的功能按照“高内聚、低耦合”的原则进行拆分,每个组件都有相应的接口,组件之间通过接口通信,这样就可以方便地替换组件了。比如我们可以选择不同连接器类型,只要这些连接器组件实现同一个接口就行。
|
||||
第二个是Web容器提供一个载体把组件组装在一起工作。组件的工作无非就是处理请求,因此容器通过责任链模式把请求依次交给组件去处理。对于用户来说,我只需要告诉Web容器由哪些组件来处理请求。把组件组织起来需要一个“管理者”,这就是为什么Tomcat和Jetty都有一个Server的概念,Server就是组件的载体,Server里包含了连接器组件和容器组件;容器还需要把请求交给各个子容器组件去处理,Tomcat和Jetty都是责任链模式来实现的。
|
||||
|
||||
|
||||
用户通过配置来组装组件,跟Spring中Bean的依赖注入相似。Spring的用户可以通过配置文件或者注解的方式来组装Bean,Bean与Bean的依赖关系完全由用户自己来定义。这一点与Web容器不同,Web容器中组件与组件之间的关系是固定的,比如Tomcat中Engine组件下有Host组件、Host组件下有Context组件等,但你不能在Host组件里“注入”一个Wrapper组件,这是由于Web容器本身的功能来决定的。
|
||||
|
||||
组件的创建
|
||||
|
||||
由于组件是可以配置的,Web容器在启动之前并不知道要创建哪些组件,也就是说,不能通过硬编码的方式来实例化这些组件,而是需要通过反射机制来动态地创建。具体来说,Web容器不是通过new方法来实例化组件对象的,而是通过Class.forName来创建组件。无论哪种方式,在实例化一个类之前,Web容器需要把组件类加载到JVM,这就涉及一个类加载的问题,Web容器设计了自己类加载器,我会在专栏后面的文章详细介绍Tomcat的类加载器。
|
||||
|
||||
Spring也是通过反射机制来动态地实例化Bean,那么它用到的类加载器是从哪里来的呢?Web容器给每个Web应用创建了一个类加载器,Spring用到的类加载器是Web容器传给它的。
|
||||
|
||||
组件的生命周期管理
|
||||
|
||||
不同类型的组件具有父子层次关系,父组件处理请求后再把请求传递给某个子组件。你可能会感到疑惑,Jetty的中Handler不是一条链吗,看上去像是平行关系?其实不然,Jetty中的Handler也是分层次的,比如WebAppContext中包含ServletHandler和SessionHandler。因此你也可以把ContextHandler和它所包含的Handler看作是父子关系。
|
||||
|
||||
而Tomcat通过容器的概念,把小容器放到大容器来实现父子关系,其实它们的本质都是一样的。这其实涉及如何统一管理这些组件,如何做到一键式启停。
|
||||
|
||||
Tomcat和Jetty都采用了类似的办法来管理组件的生命周期,主要有两个要点,一是父组件负责子组件的创建、启停和销毁。这样只要启动最上层组件,整个Web容器就被启动起来了,也就实现了一键式启停;二是Tomcat和Jetty都定义了组件的生命周期状态,并且把组件状态的转变定义成一个事件,一个组件的状态变化会触发子组件的变化,比如Host容器的启动事件里会触发Web应用的扫描和加载,最终会在Host容器下创建相应的Context容器,而Context组件的启动事件又会触发Servlet的扫描,进而创建Wrapper组件。那么如何实现这种联动呢?答案是观察者模式。具体来说就是创建监听器去监听容器的状态变化,在监听器的方法里去实现相应的动作,这些监听器其实是组件生命周期过程中的“扩展点”。
|
||||
|
||||
Spring也采用了类似的设计,Spring给Bean生命周期状态提供了很多的“扩展点”。这些扩展点被定义成一个个接口,只要你的Bean实现了这些接口,Spring就会负责调用这些接口,这样做的目的就是,当Bean的创建、初始化和销毁这些控制权交给Spring后,Spring让你有机会在Bean的整个生命周期中执行你的逻辑。下面我通过一张图帮你理解Spring Bean的生命周期过程:
|
||||
|
||||
|
||||
|
||||
组件的骨架抽象类和模板模式
|
||||
|
||||
具体到组件的设计的与实现,Tomcat和Jetty都大量采用了骨架抽象类和模板模式。比如说Tomcat中ProtocolHandler接口,ProtocolHandler有抽象基类AbstractProtocol,它实现了协议处理层的骨架和通用逻辑,而具体协议也有抽象基类,比如HttpProtocol和AjpProtocol。对于Jetty来说,Handler接口之下有AbstractHandler,Connector接口之下有AbstractConnector,这些抽象骨架类实现了一些通用逻辑,并且会定义一些抽象方法,这些抽象方法由子类实现,抽象骨架类调用抽象方法来实现骨架逻辑。
|
||||
|
||||
这是一个通用的设计规范,不管是Web容器还是Spring,甚至JDK本身都到处使用这种设计,比如Java集合中的AbstractSet、AbstractMap等。 值得一提的是,从Java 8开始允许接口有default方法,这样我们可以把抽象骨架类的通用逻辑放到接口中去。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我总结了Tomcat和Jetty的组件化设计,我们可以通过搭积木的方式来定制化自己的Web容器。Web容器为了支持这种组件化设计,遵循了一些规范,比如面向接口编程,用“管理者”去组装这些组件,用反射的方式动态的创建组件、统一管理组件的生命周期,并且给组件生命状态的变化提供了扩展点,组件的具体实现一般遵循骨架抽象类和模板模式。
|
||||
|
||||
通过今天的学习,你会发现Tomcat和Jetty有很多共同点,并且Spring框架的设计也有不少相似的的地方,这正好说明了Web开发中有一些本质的东西是相通的,只要你深入理解了一个技术,也就是在一个点上突破了深度,再扩展广度就不是难事。并且我建议在学习一门技术的时候,可以回想一下之前学过的东西,是不是有相似的地方,有什么不同的地方,通过对比理解它们的本质,这样我们才能真正掌握这些技术背后的精髓。
|
||||
|
||||
课后思考
|
||||
|
||||
在我们的实际项目中,可能经常遇到改变需求,那如果采用组件化设计,当需求更改时是不是会有一些帮助呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
130
专栏/深入拆解TomcatJetty/12实战:优化并提高Tomcat启动速度.md
Normal file
130
专栏/深入拆解TomcatJetty/12实战:优化并提高Tomcat启动速度.md
Normal file
@ -0,0 +1,130 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 实战:优化并提高Tomcat启动速度
|
||||
到目前为止,我们学习了Tomcat和Jetty的整体架构,还知道了Tomcat是如何启动起来的,今天我们来聊一个比较轻松的话题:如何优化并提高Tomcat的启动速度。
|
||||
|
||||
我们在使用Tomcat时可能会碰到启动比较慢的问题,比如我们的系统发布新版本上线时,可能需要重启服务,这个时候我们希望Tomcat能快速启动起来提供服务。其实关于如何让Tomcat启动变快,官方网站有专门的文章来介绍这个话题。下面我也针对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的依赖应该指定为<scope>provided</scope>。
|
||||
|
||||
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子标签,像下面这样。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如果你的项目使用了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的属性,像下面这样。
|
||||
|
||||
|
||||
|
||||
更进一步,如果你不需要WebSocket这个功能,你可以把Tomcat lib目录下的websocket-api.jar和tomcat-websocket.jar这两个JAR文件删除掉,进一步提高性能。
|
||||
|
||||
关闭JSP支持
|
||||
|
||||
跟关闭WebSocket一样,如果你不需要使用JSP,可以通过类似方法关闭JSP功能,像下面这样。
|
||||
|
||||
|
||||
|
||||
我们发现关闭JSP用的也是containerSciFilter属性,如果你想把WebSocket和JSP都关闭,那就这样配置:
|
||||
|
||||
|
||||
|
||||
禁止Servlet注解扫描
|
||||
|
||||
Servlet 3.0引入了注解Servlet,Tomcat为了支持这个特性,会在Web应用启动时扫描你的类文件,因此如果你没有使用Servlet注解这个功能,可以告诉Tomcat不要去扫描Servlet注解。具体配置方法是,在你的Web应用的web.xml文件中,设置<web-app>元素的属性metadata-complete="true",像下面这样。
|
||||
|
||||
|
||||
|
||||
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里面的<absolute-ordering>元素直接指定了哪些JAR包需要扫描web fragment,如果<absolute-ordering/>元素是空的, 则表示不需要扫描,像下面这样。
|
||||
|
||||
|
||||
|
||||
随机数熵源优化
|
||||
|
||||
这是一个比较有名的问题。Tomcat 7以上的版本依赖Java的SecureRandom类来生成随机数,比如Session ID。而JVM 默认使用阻塞式熵源(/dev/random), 在某些情况下就会导致Tomcat启动变慢。当阻塞时间较长时, 你会看到这样一条警告日志:
|
||||
|
||||
|
||||
<DATE> org.apache.catalina.util.SessionIdGenerator createSecureRandom-
|
||||
INFO: Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [8152] milliseconds.
|
||||
|
||||
|
||||
这其中的原理我就不展开了,你可以阅读资料获得更多信息。解决方案是通过设置,让JVM使用非阻塞式的熵源。
|
||||
|
||||
我们可以设置JVM的参数:
|
||||
|
||||
-Djava.security.egd=file:/dev/./urandom
|
||||
|
||||
|
||||
或者是设置java.security文件,位于$JAVA_HOME/jre/lib/security目录之下: securerandom.source=file:/dev/./urandom
|
||||
|
||||
这里请你注意,/dev/./urandom中间有个./的原因是Oracle JRE中的Bug,Java 8里面的 SecureRandom类已经修正这个Bug。 阻塞式的熵源(/dev/random)安全性较高, 非阻塞式的熵源(/dev/./urandom)安全性会低一些,因为如果你对随机数的要求比较高, 可以考虑使用硬件方式生成熵源。
|
||||
|
||||
并行启动多个Web应用
|
||||
|
||||
Tomcat启动的时候,默认情况下Web应用都是一个一个启动的,等所有Web应用全部启动完成,Tomcat才算启动完毕。如果在一个Tomcat下你有多个Web应用,为了优化启动速度,你可以配置多个应用程序并行启动,可以通过修改server.xml中Host元素的startStopThreads属性来完成。startStopThreads的值表示你想用多少个线程来启动你的Web应用,如果设成0表示你要并行启动Web应用,像下面这样的配置。
|
||||
|
||||
|
||||
|
||||
这里需要注意的是,Engine元素里也配置了这个参数,这意味着如果你的Tomcat配置了多个Host(虚拟主机),Tomcat会以并行的方式启动多个Host。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我讲了不少提高优化Tomcat启动速度的小贴士,现在你就可以把它们用在项目中了。不管是在开发环境还是生产环境,你都可以打开Tomcat的启动日志,看看目前你们的应用启动需要多长时间,然后尝试去调优,再看看Tomcat的启动速度快了多少。
|
||||
|
||||
如果你是用嵌入式的方式运行Tomcat,比如Spring Boot,你也可以通过Spring Boot的方式去修改Tomcat的参数,调优的原理都是一样的。
|
||||
|
||||
课后思考
|
||||
|
||||
在Tomcat启动速度优化上,你都遇到了哪些问题,或者你还有自己的“独门秘籍”,欢迎把它们分享给我和其他同学。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
88
专栏/深入拆解TomcatJetty/13热点问题答疑(1):如何学习源码?.md
Normal file
88
专栏/深入拆解TomcatJetty/13热点问题答疑(1):如何学习源码?.md
Normal file
@ -0,0 +1,88 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 热点问题答疑(1):如何学习源码?
|
||||
不知道你有没有留意到,不少高端开发岗位在招聘要求里往往会写这么一条:研究过框架和中间件源码的优先考虑。这是因为一切秘密都藏在源码之中,阅读源码会让我们对框架或者中间件的理解更加深刻。有时候即使你阅读了大量原理性的文档,但如果不看源码,可能仍然会觉得还没有理解透。另外如果你能深入源码,招聘者从侧面也能感觉到你的学习热情和探索精神。
|
||||
|
||||
今天我们就来聊聊源码学习这个话题。对于Java后端开发来说,有不少经典的开源框架和中间件,下面我帮你按照后端的分层架构整理出来供你参考。
|
||||
|
||||
|
||||
服务接入层:反向代理Nginx;API网关Node.js。
|
||||
业务逻辑层:Web容器Tomcat、Jetty;应用层框架Spring、Spring MVC和Spring Boot;ORM框架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缓存。我们通过一张图来理解一下:
|
||||
|
||||
|
||||
|
||||
请你注意,这要求Processor中的任务能在短时间完成,否则会阻塞这个EventLoop上其他Channel的处理。因此在Netty中,可以设置业务处理和I/O处理的时间比率,超过这个比率则将任务扔到专门的业务线程池来执行,这一点跟Jetty的EatWhatYouKill线程策略有异曲同工之妙。
|
||||
|
||||
而Kafka把Selector和Processor跑在不同的线程里,因为Kafka的业务逻辑大多涉及与磁盘读写,处理时间不确定,所以Kafka有专门的业务处理线程池来运行Processor。与此类似,Tomcat也采用了这样的策略,同样我们还是通过一张图来理解一下。
|
||||
|
||||
|
||||
|
||||
我们再来看看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中有一些起骨架作用的核心类,其他类都是在这个骨架上进行扩展或补充细节来实现。因此在学习前期就要紧紧抓住这些类,先不要深入到其他细节,你可以先画出一张骨架类图。
|
||||
|
||||
|
||||
|
||||
在此之后,我们还需要将源码跑起来,打打断点,看看变量的值和调用栈。我建议用内嵌式的方式来启动和调试Tomcat,体会一下Spring Boot是如何使用Tomcat的,这里有示例源码。在源码阅读过程中要充分利用IDE的功能,比如通过快捷键查找某个接口的所有实现类、查找某个类或者函数在哪些地方被用到。
|
||||
|
||||
我们还要带着问题去学习源码,比如你想弄清楚Tomcat如何启停、类加载器是如何设计的、Spring Boot是如何启动Tomcat的、Jetty是如何通过Handler链实现高度定制化的,如果要你来设计这些功能会怎么做呢?带着这些问题去分析相关的源码效率会更高,同时你在寻找答案的过程中,也会碰到更多问题,等你把这些问题都弄清楚了,你获得的不仅仅是知识,更重要的是你会树立起攻克难关的信心。同时我还建议,在你弄清楚一些细节后要及时记录下来,画画流程图或者类图,再加上一些关键备注以防遗忘。
|
||||
|
||||
当然在这个过程中,你还可以看看产品的官方文档,熟悉一下大概的设计思路。在遇到难题时,你还可以看看网上的博客,参考一下别人的分析。但最终还是需要你自己去实践和摸索,因为网上的分析也不一定对,只有你自己看了源码后才能真正理解它,印象才更加深刻。
|
||||
|
||||
今天说了这么多,就是想告诉你如果理解透彻一两个中间件,有了一定的积累,这时再来学一个新的系统,往往你只需要瞧上几眼,就能明白它所用的架构,而且你会自然联想到系统存在哪些角色,以及角色之间的关系,包括静态的依赖关系和动态的协作关系,甚至你会不由自主带着审视的眼光,来发现一些可以改进的地方。如果你现在就是这样的状态,那么恭喜你,你的技术水平已经成长到一个新的层面了。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
185
专栏/深入拆解TomcatJetty/14NioEndpoint组件:Tomcat如何实现非阻塞I_O?.md
Normal file
185
专栏/深入拆解TomcatJetty/14NioEndpoint组件:Tomcat如何实现非阻塞I_O?.md
Normal file
@ -0,0 +1,185 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 NioEndpoint组件:Tomcat如何实现非阻塞I_O?
|
||||
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。内核等待网卡数据到来,把数据从网卡拷贝到内核空间,接着把数据拷贝到用户空间,再把用户线程叫醒。
|
||||
|
||||
|
||||
|
||||
同步非阻塞I/O:用户线程不断的发起read调用,数据没到内核空间时,每次都返回失败,直到数据到了内核空间,这一次read调用后,在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的,等数据到了用户空间再把线程叫醒。
|
||||
|
||||
|
||||
|
||||
I/O多路复用:用户线程的读取操作分成两步了,线程先发起select调用,目的是问内核数据准备好了吗?等内核把数据准备好了,用户线程再发起read调用。在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的。那为什么叫I/O多路复用呢?因为一次select调用可以向内核查多个数据通道(Channel)的状态,所以叫多路复用。
|
||||
|
||||
|
||||
|
||||
异步I/O:用户线程发起read调用的同时注册一个回调函数,read立即返回,等内核将数据准备好后,再调用指定的回调函数完成处理。在这个过程中,用户线程一直没有阻塞。
|
||||
|
||||
|
||||
|
||||
NioEndpoint组件
|
||||
|
||||
Tomcat的NioEndpoint组件实现了I/O多路复用模型,接下来我会介绍NioEndpoint的实现原理,下一期我会介绍Tomcat如何实现异步I/O模型。
|
||||
|
||||
总体工作流程
|
||||
|
||||
我们知道,对于Java的多路复用器的使用,无非是两步:
|
||||
|
||||
|
||||
创建一个Selector,在它身上注册各种感兴趣的事件,然后调用select方法,等待感兴趣的事情发生。
|
||||
|
||||
感兴趣的事情发生了,比如可以读了,这时便创建一个新的线程从Channel中读数据。
|
||||
|
||||
|
||||
Tomcat的NioEndpoint组件虽然实现比较复杂,但基本原理就是上面两步。我们先来看看它有哪些组件,它一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件,它们的工作过程如下图所示。
|
||||
|
||||
|
||||
|
||||
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 > 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扩展了AQS,AQS是Java并发包中的一个核心类,它在内部维护一个状态和一个线程队列,可以用来控制线程什么时候挂起,什么时候唤醒。我们可以扩展它来实现自己的同步器,实际上Java并发包里的锁和条件变量等等都是通过AQS来实现的,而这里的LimitLatch也不例外。
|
||||
|
||||
理解上面的代码时有两个要点:
|
||||
|
||||
|
||||
用户线程通过调用LimitLatch的countUpOrAwait方法来拿到锁,如果暂时无法获取,这个线程会被阻塞到AQS的队列中。那AQS怎么知道是阻塞还是不阻塞用户线程呢?其实这是由AQS的使用者来决定的,也就是内部类Sync来决定的,因为Sync类重写了AQS的tryAcquireShared()方法。它的实现逻辑是如果当前连接数count小于limit,线程能获取锁,返回1,否则返回-1。
|
||||
|
||||
如何用户线程被阻塞到了AQS的队列,那什么时候唤醒呢?同样是由Sync内部类决定,Sync重写了AQS的tryReleaseShared()方法,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样前面阻塞的线程将会被唤醒。
|
||||
|
||||
|
||||
其实你会发现AQS就是一个骨架抽象类,它帮我们搭了个架子,用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由你来决定。我们还注意到,当前线程数被定义成原子变量AtomicLong,而limit变量用volatile关键字来修饰,这些并发编程的实际运用。
|
||||
|
||||
Acceptor
|
||||
|
||||
Acceptor实现了Runnable接口,因此可以跑在单独线程里。一个端口号只能对应一个ServerSocketChannel,因此这个ServerSocketChannel是在多个Acceptor线程之间共享的,它是Endpoint的属性,由Endpoint完成初始化和端口绑定。初始化过程如下:
|
||||
|
||||
serverSock = ServerSocketChannel.open();
|
||||
serverSock.socket().bind(addr,getAcceptCount());
|
||||
serverSock.configureBlocking(true);
|
||||
|
||||
|
||||
从上面的初始化代码我们可以看到两个关键信息:
|
||||
|
||||
|
||||
bind方法的第二个参数表示操作系统的等待队列长度,我在上面提到,当应用层面的连接数到达最大值时,操作系统可以继续接收连接,那么操作系统能继续接收的最大连接数就是这个队列长度,可以通过acceptCount参数配置,默认是100。
|
||||
|
||||
ServerSocketChannel被设置成阻塞模式,也就是说它是以阻塞的方式接收连接的。
|
||||
|
||||
|
||||
ServerSocketChannel通过accept()接受新的连接,accept()方法返回获得SocketChannel对象,然后将SocketChannel对象封装在一个PollerEvent对象中,并将PollerEvent对象压入Poller的Queue里,这是个典型的“生产者-消费者”模式,Acceptor与Poller线程之间通过Queue通信。
|
||||
|
||||
Poller
|
||||
|
||||
Poller本质是一个Selector,它内部维护一个Queue,这个Queue定义如下:
|
||||
|
||||
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
|
||||
|
||||
|
||||
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设计了一个包装类叫作SocketWrapper,Http11Processor只调用SocketWrapper的方法去读写数据。
|
||||
|
||||
Executor
|
||||
|
||||
Executor是Tomcat定制版的线程池,它负责创建真正干活的工作线程,干什么活呢?就是执行SocketProcessor的run方法,也就是解析请求并通过容器来处理请求,最终会调用到我们的Servlet。后面我会用专门的篇幅介绍Tomcat怎么扩展和使用Java原生的线程池。
|
||||
|
||||
高并发思路
|
||||
|
||||
在弄清楚NioEndpoint的实现原理后,我们来考虑一个重要的问题,怎么把这个过程做到高并发呢?
|
||||
|
||||
高并发就是能快速地处理大量的请求,需要合理设计线程模型让CPU忙起来,尽量不要让线程阻塞,因为一阻塞,CPU就闲下来了。另外就是有多少任务,就用相应规模的线程数去处理。我们注意到NioEndpoint要完成三件事情:接收连接、检测I/O事件以及处理请求,那么最核心的就是把这三件事情分开,用不同规模的线程数去处理,比如用专门的线程组去跑Acceptor,并且Acceptor的个数可以配置;用专门的线程组去跑Poller,Poller的个数也可以配置;最后具体任务的执行也由专门的线程池来处理,也可以配置线程池的大小。
|
||||
|
||||
本期精华
|
||||
|
||||
I/O模型是为了解决内存和外部设备速度差异的问题。我们平时说的阻塞或非阻塞是指应用程序在发起I/O操作时,是立即返回还是等待。而同步和异步,是指应用程序在与内核通信时,数据从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发。
|
||||
|
||||
在Tomcat中,Endpoint组件的主要工作就是处理I/O,而NioEndpoint利用Java NIO API实现了多路复用I/O模型。其中关键的一点是,读写数据的线程自己不会阻塞在I/O等待上,而是把这个工作交给Selector。同时Tomcat在这个过程中运用到了很多Java并发编程技术,比如AQS、原子类、并发容器,线程池等,都值得我们去细细品味。
|
||||
|
||||
课后思考
|
||||
|
||||
Tomcat的NioEndpoint组件的名字中有NIO,NIO是非阻塞的意思,似乎说的是同步非阻塞I/O模型,但是NioEndpoint又是调用Java的的Selector来实现的,我们知道Selector指的是I/O多路复用器,也就是我们说的I/O多路复用模型,这不是矛盾了吗?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
217
专栏/深入拆解TomcatJetty/15Nio2Endpoint组件:Tomcat如何实现异步I_O?.md
Normal file
217
专栏/深入拆解TomcatJetty/15Nio2Endpoint组件:Tomcat如何实现异步I_O?.md
Normal file
@ -0,0 +1,217 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 Nio2Endpoint组件:Tomcat如何实现异步I_O?
|
||||
我在专栏上一期里提到了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,最后调用应用程序指定的回调函数。
|
||||
|
||||
你可能通过下面这张图来回顾一下同步与异步的区别:
|
||||
|
||||
|
||||
|
||||
我们可以看到在异步模式下,应用程序当了“甩手掌柜”,内核则忙前忙后,但最大限度提高了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件事情:
|
||||
|
||||
|
||||
创建一个线程池,这个线程池用来执行来自内核的回调请求。
|
||||
创建一个AsynchronousChannelGroup,并绑定一个线程池。
|
||||
创建AsynchronousServerSocketChannel,并绑定到AsynchronousChannelGroup。
|
||||
绑定一个监听端口。
|
||||
调用accept方法开始监听连接请求,同时传入一个回调类去处理连接请求。请你注意,accept方法的第一个参数是this对象,就是Nio2Server对象本身,我在下文还会讲为什么要传入这个参数。
|
||||
|
||||
|
||||
你可能会问,为什么需要创建一个线程池呢?其实在异步I/O模型里,应用程序不知道数据在什么时候到达,因此向内核注册回调函数,当数据到达时,内核就会调用这个回调函数。同时为了提高处理速度,会提供一个线程池给内核使用,这样不会耽误内核线程的工作,内核只需要把工作交给线程池就立即返回了。
|
||||
|
||||
我们再来看看处理连接的回调类AcceptHandler是什么样的。
|
||||
|
||||
//AcceptHandler类实现了CompletionHandler接口的completed方法。它还有两个模板参数,第一个是异步通道,第二个就是Nio2Server本身
|
||||
public class AcceptHandler implements CompletionHandler<AsynchronousSocketChannel, Nio2Server> {
|
||||
|
||||
//具体处理连接请求的就是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<V,A> {
|
||||
|
||||
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<Integer, ByteBuffer> {
|
||||
//读取到消息后的处理
|
||||
@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有哪些组件。
|
||||
|
||||
|
||||
|
||||
从图上看,总体工作流程跟NioEndpoint是相似的。
|
||||
|
||||
LimitLatch是连接控制器,它负责控制最大连接数。
|
||||
|
||||
Nio2Acceptor扩展了Acceptor,用异步I/O的方式来接收连接,跑在一个单独的线程里,也是一个线程组。Nio2Acceptor接收新的连接后,得到一个AsynchronousSocketChannel,Nio2Acceptor把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<AsynchronousSocketChannel>
|
||||
implements CompletionHandler<AsynchronousSocketChannel, Void> {
|
||||
|
||||
@Override
|
||||
public void completed(AsynchronousSocketChannel socket,
|
||||
Void attachment) {
|
||||
|
||||
if (isRunning() && !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<Integer, SocketWrapperBase<Nio2Channel>>() {
|
||||
public void completed(Integer nBytes, SocketWrapperBase<Nio2Channel> attachment) {
|
||||
...
|
||||
//通过附件类SocketWrapper拿到所有的上下文
|
||||
Nio2SocketWrapper.this.getEndpoint().processSocket(attachment, SocketEvent.OPEN_READ, false);
|
||||
}
|
||||
|
||||
public void failed(Throwable exc, SocketWrapperBase<Nio2Channel> 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是同一个吗?如果不是,它们有什么关系?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
162
专栏/深入拆解TomcatJetty/16AprEndpoint组件:TomcatAPR提高I_O性能的秘密.md
Normal file
162
专栏/深入拆解TomcatJetty/16AprEndpoint组件:TomcatAPR提高I_O性能的秘密.md
Normal file
@ -0,0 +1,162 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 AprEndpoint组件:Tomcat APR提高I_O性能的秘密
|
||||
我们在使用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呢?
|
||||
|
||||
APR(Apache 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方式来调用。JNI(Java Native Interface) 是JDK提供的一个编程接口,它允许Java程序调用其他语言编写的程序或者代码库,其实JDK本身的实现也大量用到JNI技术来调用本地C程序库。
|
||||
|
||||
在今天这一期文章,首先我会讲AprEndpoint组件的工作过程,接着我会在原理的基础上分析APR提升性能的一些秘密。在今天的学习过程中会涉及到一些操作系统的底层原理,毫无疑问掌握这些底层知识对于提高你的内功非常有帮助。
|
||||
|
||||
AprEndpoint工作过程
|
||||
|
||||
下面我还是通过一张图来帮你理解AprEndpoint的工作过程。
|
||||
|
||||
|
||||
|
||||
你会发现它跟NioEndpoint的图很像,从左到右有LimitLatch、Acceptor、Poller、SocketProcessor和Http11Processor,只是Acceptor和Poller的实现和NioEndpoint不同。接下来我分别来讲讲这两个组件。
|
||||
|
||||
Acceptor
|
||||
|
||||
Accpetor的功能就是监听连接,接收并建立连接。它的本质就是调用了四个操作系统API:Socket、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->sock, a);
|
||||
return rv;
|
||||
}
|
||||
|
||||
|
||||
专栏里我就不展开JNI的细节了,你可以扩展阅读获得更多信息和例子。我们要注意的是函数名字要符合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程序代码在运行过程中用到的内存就是本地内存中分配的。下面我们通过一张图来理解一下。
|
||||
|
||||
|
||||
|
||||
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可能会发生GC,GC过程中对象可能会被移动,也就是说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的处理逻辑无非是两步:
|
||||
|
||||
|
||||
从磁盘读取HTML到内存。
|
||||
将这段内存的内容通过Socket发送出去。
|
||||
|
||||
|
||||
但是在传统方式下,有很多次的内存拷贝:
|
||||
|
||||
|
||||
读取文件时,首先是内核把文件内容读取到内核缓冲区。
|
||||
如果使用HeapByteBuffer,文件数据从内核到JVM堆内存需要经过本地内存中转。
|
||||
同样在将文件内容推入网络时,从JVM堆到内核缓冲区需要经过本地内存中转。
|
||||
最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。
|
||||
|
||||
|
||||
从下面的图你会发现这个过程有6次内存拷贝,并且read和write等系统调用将导致进程从用户态到内核态的切换,会耗费大量的CPU和内存资源。
|
||||
|
||||
|
||||
|
||||
而Tomcat的AprEndpoint通过操作系统层面的sendfile特性解决了这个问题,sendfile系统调用方式非常简洁。
|
||||
|
||||
sendfile(socket, file, len);
|
||||
|
||||
|
||||
它带有两个关键参数:Socket和文件句柄。将文件从磁盘写入Socket的过程只有两步:
|
||||
|
||||
第一步:将文件内容读取到内核缓冲区。
|
||||
|
||||
第二步:数据并没有从内核缓冲区复制到Socket关联的缓冲区,只有记录数据位置和长度的描述符被添加到Socket缓冲区中;接着把数据直接从内核缓冲区传递给网卡。这个过程你可以看下面的图。
|
||||
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
对于一些需要频繁与操作系统进行交互的场景,比如网络通信,Java的效率没有C语言高,特别是TLS协议握手过程中需要多次网络交互,这种情况下使用APR本地库能够显著提升性能。
|
||||
|
||||
除此之外,APR提升性能的秘密还有:通过DirectByteBuffer避免了JVM堆与本地内存之间的内存拷贝;通过sendfile特性避免了内核与应用之间的内存拷贝以及用户态和内核态的切换。其实很多高性能网络通信组件,比如Netty,都是通过DirectByteBuffer来收发网络数据的。由于本地内存难于管理,Netty采用了本地内存池技术,感兴趣的同学可以深入了解一下。
|
||||
|
||||
课后思考
|
||||
|
||||
为什么不同的操作系统,比如Linux和Windows,都有自己的Java虚拟机?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
213
专栏/深入拆解TomcatJetty/17Executor组件:Tomcat如何扩展Java线程池?.md
Normal file
213
专栏/深入拆解TomcatJetty/17Executor组件:Tomcat如何扩展Java线程池?.md
Normal file
@ -0,0 +1,213 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 Executor组件:Tomcat如何扩展Java线程池?
|
||||
在开发中我们经常会碰到“池”的概念,比如数据库连接池、内存池、线程池、常量池等。为什么需要“池”呢?程序运行的本质,就是通过使用系统资源(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<Runnable> 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<Runnable>());
|
||||
}
|
||||
|
||||
public static ExecutorService newCachedThreadPool() {
|
||||
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
|
||||
60L, TimeUnit.SECONDS,
|
||||
new SynchronousQueue<Runnable>());
|
||||
}
|
||||
|
||||
|
||||
从上面的代码你可以看到:
|
||||
|
||||
|
||||
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原生线程池的任务处理逻辑比较简单:
|
||||
|
||||
|
||||
前corePoolSize个任务时,来一个任务就创建一个新线程。
|
||||
后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
|
||||
如果总线程数达到maximumPoolSize,执行拒绝策略。
|
||||
|
||||
|
||||
Tomcat线程池扩展了原生的ThreadPoolExecutor,通过重写execute方法实现了自己的任务处理逻辑:
|
||||
|
||||
|
||||
前corePoolSize个任务时,来一个任务就创建一个新线程。
|
||||
再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
|
||||
如果总线程数达到maximumPoolSize,则继续尝试把任务添加到任务队列中去。
|
||||
如果缓冲队列也满了,插入失败,执行拒绝策略。
|
||||
|
||||
|
||||
观察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) {
|
||||
//如果总线程数达到maximumPoolSize,Java原生线程池执行拒绝策略
|
||||
if (super.getQueue() instanceof TaskQueue) {
|
||||
final TaskQueue queue = (TaskQueue)super.getQueue();
|
||||
try {
|
||||
//继续尝试把任务放到任务队列中去
|
||||
if (!queue.force(command, timeout, unit)) {
|
||||
submittedCount.decrementAndGet();
|
||||
//如果缓冲队列也满了,插入失败,执行拒绝策略。
|
||||
throw new RejectedExecutionException("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从这个方法你可以看到,Tomcat线程池的execute方法会调用Java原生线程池的execute去执行任务,如果总线程数达到maximumPoolSize,Java原生线程池的execute方法会抛出RejectedExecutionException异常,但是这个异常会被Tomcat线程池的execute方法捕获到,并继续尝试把这个任务放到任务队列中去;如果任务队列也满了,再执行拒绝策略。
|
||||
|
||||
定制版的任务队列
|
||||
|
||||
细心的你有没有发现,在Tomcat线程池的execute方法最开始有这么一行:
|
||||
|
||||
submittedCount.incrementAndGet();
|
||||
|
||||
|
||||
这行代码的意思把submittedCount这个原子变量加一,并且在任务执行失败,抛出拒绝异常时,将这个原子变量减一:
|
||||
|
||||
submittedCount.decrementAndGet();
|
||||
|
||||
|
||||
其实Tomcat线程池是用这个变量submittedCount来维护已经提交到了线程池,但是还没有执行完的任务个数。Tomcat为什么要维护这个变量呢?这跟Tomcat的定制版的任务队列有关。Tomcat的任务队列TaskQueue扩展了Java中的LinkedBlockingQueue,我们知道LinkedBlockingQueue默认情况下长度是没有限制的,除非给它一个capacity。因此Tomcat给了它一个capacity,TaskQueue的构造函数中有个整型的参数capacity,TaskQueue将capacity传给父类LinkedBlockingQueue的构造函数。
|
||||
|
||||
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
|
||||
|
||||
public TaskQueue(int capacity) {
|
||||
super(capacity);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这个capacity参数是通过Tomcat的maxQueueSize参数来设置的,但问题是默认情况下maxQueueSize的值是Integer.MAX_VALUE,等于没有限制,这样就带来一个问题:当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。
|
||||
|
||||
为了解决这个问题,TaskQueue重写了LinkedBlockingQueue的offer方法,在合适的时机返回false,返回false表示任务添加失败,这时线程池会创建新的线程。那什么是合适的时机呢?请看下面offer方法的核心源码:
|
||||
|
||||
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
|
||||
|
||||
...
|
||||
@Override
|
||||
//线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
|
||||
public boolean offer(Runnable o) {
|
||||
|
||||
//如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
|
||||
if (parent.getPoolSize() == parent.getMaximumPoolSize())
|
||||
return super.offer(o);
|
||||
|
||||
//执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
|
||||
//表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
|
||||
|
||||
//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
|
||||
if (parent.getSubmittedCount()<=(parent.getPoolSize()))
|
||||
return super.offer(o);
|
||||
|
||||
//2. 如果已提交的任务数大于当前线程数,线程不够用了,返回false去创建新线程
|
||||
if (parent.getPoolSize()<parent.getMaximumPoolSize())
|
||||
return false;
|
||||
|
||||
//默认情况下总是把任务添加到任务队列
|
||||
return super.offer(o);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
从上面的代码我们看到,只有当前线程数大于核心线程数、小于最大线程数,并且已提交的任务个数大于当前线程数时,也就是说线程不够用了,但是线程数又没达到极限,才会去创建新的线程。这就是为什么Tomcat需要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的情况下,让线程池有机会创建新的线程。
|
||||
|
||||
当然默认情况下Tomcat的任务队列是没有限制的,你可以通过设置maxQueueSize参数来限制任务队列的长度。
|
||||
|
||||
本期精华
|
||||
|
||||
池化的目的是为了避免频繁地创建和销毁对象,减少对系统资源的消耗。Java提供了默认的线程池实现,我们也可以扩展Java原生的线程池来实现定制自己的线程池,Tomcat就是这么做的。Tomcat扩展了Java线程池的核心类ThreadPoolExecutor,并重写了它的execute方法,定制了自己的任务处理流程。同时Tomcat还实现了定制版的任务队列,重写了offer方法,使得在任务队列长度无限制的情况下,线程池仍然有机会创建新的线程。
|
||||
|
||||
课后思考
|
||||
|
||||
请你再仔细看看Tomcat的定制版任务队列TaskQueue的offer方法,它多次调用了getPoolSize方法,但是这个方法是有锁的,锁会引起线程上下文切换而损耗性能,请问这段代码可以如何优化呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
205
专栏/深入拆解TomcatJetty/18新特性:Tomcat如何支持WebSocket?.md
Normal file
205
专栏/深入拆解TomcatJetty/18新特性:Tomcat如何支持WebSocket?.md
Normal file
@ -0,0 +1,205 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 新特性:Tomcat如何支持WebSocket?
|
||||
我们知道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。
|
||||
|
||||
|
||||
|
||||
如果服务器支持WebSocket,同样会在HTTP响应里加上WebSocket相关的HTTP头部。
|
||||
|
||||
|
||||
|
||||
这样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 = "/websocket/chat")
|
||||
public class ChatEndpoint {
|
||||
|
||||
private static final String GUEST_PREFIX = "Guest";
|
||||
|
||||
//记录当前有多少个用户加入到了聊天室,它是static全局变量。为了多线程安全使用原子变量AtomicInteger
|
||||
private static final AtomicInteger connectionIds = new AtomicInteger(0);
|
||||
|
||||
//每个用户用一个CharAnnotation实例来维护,请你注意它是一个全局的static变量,所以用到了线程安全的CopyOnWriteArraySet
|
||||
private static final Set<ChatEndpoint> connections =
|
||||
new CopyOnWriteArraySet<>();
|
||||
|
||||
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("* %s %s", nickname, "has joined.");
|
||||
broadcast(message);
|
||||
}
|
||||
|
||||
//浏览器关闭连接时,Tomcat会回调这个函数
|
||||
@OnClose
|
||||
public void end() {
|
||||
connections.remove(this);
|
||||
String message = String.format("* %s %s",
|
||||
nickname, "has disconnected.");
|
||||
broadcast(message);
|
||||
}
|
||||
|
||||
//浏览器发送消息到服务器时,Tomcat会回调这个函数
|
||||
@OnMessage
|
||||
public void incoming(String message) {
|
||||
// Never trust the client
|
||||
String filteredMessage = String.format("%s: %s",
|
||||
nickname, HTMLFilter.filter(message.toString()));
|
||||
broadcast(filteredMessage);
|
||||
}
|
||||
|
||||
//WebSocket连接出错时,Tomcat会回调这个函数
|
||||
@OnError
|
||||
public void onError(Throwable t) throws Throwable {
|
||||
log.error("Chat Error: " + 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<Class<?>> clazzes, ServletContext ctx) throws ServletException {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
一旦定义好了SCI,Tomcat在启动阶段扫描类时,会将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连接器的组件图。
|
||||
|
||||
|
||||
|
||||
你可以看到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,而是用专门的UpgradeProcessor,UpgradeProcessor最终会调用相应的Endpoint实例来处理请求。下面我们通过一张图来理解一下。
|
||||
|
||||
|
||||
|
||||
你可以看到,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的原理。
|
||||
|
||||
课后思考
|
||||
|
||||
今天我举的聊天室的例子实现的是群发消息,如果要向某个特定用户发送消息,应该怎么做呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
237
专栏/深入拆解TomcatJetty/19比较:Jetty的线程策略EatWhatYouKill.md
Normal file
237
专栏/深入拆解TomcatJetty/19比较:Jetty的线程策略EatWhatYouKill.md
Normal file
@ -0,0 +1,237 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 比较:Jetty的线程策略EatWhatYouKill
|
||||
我在前面的专栏里介绍了Jetty的总体架构设计,简单回顾一下,Jetty总体上是由一系列Connector、一系列Handler和一个ThreadPool组成,它们的关系如下图所示:
|
||||
|
||||
|
||||
|
||||
相比较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;
|
||||
|
||||
//"Selector更新任务"队列
|
||||
private Deque<SelectorUpdate> _updates = new ArrayDeque<>();
|
||||
private Deque<SelectorUpdate> _updateable = new ArrayDeque<>();
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这些成员变量中其他的都好理解,就是“Selector更新任务”队列_updates和执行策略_strategy可能不是很直观。
|
||||
|
||||
SelectorUpdate接口
|
||||
|
||||
为什么需要一个“Selector更新任务”队列呢,对于Selector的用户来说,我们对Selector的操作无非是将Channel注册到Selector或者告诉Selector我对什么I/O事件感兴趣,那么这些操作其实就是对Selector状态的更新,Jetty把这些操作抽象成SelectorUpdate接口。
|
||||
|
||||
/**
|
||||
* A selector update to be done when the selector has been woken.
|
||||
*/
|
||||
public interface SelectorUpdate
|
||||
{
|
||||
void update(Selector selector);
|
||||
}
|
||||
|
||||
|
||||
这意味着如果你不能直接操作ManageSelector中的Selector,而是需要向ManagedSelector提交一个任务类,这个类需要实现SelectorUpdate接口update方法,在update方法里定义你想要对ManagedSelector做的操作。
|
||||
|
||||
比如Connector中Endpoint组件对读就绪事件感兴趣,它就向ManagedSelector提交了一个内部任务类ManagedSelector.SelectorUpdate:
|
||||
|
||||
_selector.submit(_updateKeyAction);
|
||||
|
||||
|
||||
这个_updateKeyAction就是一个SelectorUpdate实例,它的update方法实现如下:
|
||||
|
||||
private final ManagedSelector.SelectorUpdate _updateKeyAction = new ManagedSelector.SelectorUpdate()
|
||||
{
|
||||
@Override
|
||||
public void update(Selector selector)
|
||||
{
|
||||
//这里的updateKey其实就是调用了SelectionKey.interestOps(OP_READ);
|
||||
updateKey();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
我们看到在update方法里,调用了SelectionKey类的interestOps方法,传入的参数是OP_READ,意思是现在我对这个Channel上的读就绪事件感兴趣了。
|
||||
|
||||
那谁来负责执行这些update方法呢,答案是ManagedSelector自己,它在一个死循环里拉取这些SelectorUpdate任务类逐个执行。
|
||||
|
||||
Selectable接口
|
||||
|
||||
那I/O事件到达时,ManagedSelector怎么知道应该调哪个函数来处理呢?其实也是通过一个任务类接口,这个接口就是Selectable,它返回一个Runnable,这个Runnable其实就是I/O事件就绪时相应的处理逻辑。
|
||||
|
||||
public interface Selectable
|
||||
{
|
||||
//当某一个Channel的I/O事件就绪后,ManagedSelector会调用的回调函数
|
||||
Runnable onSelected();
|
||||
|
||||
//当所有事件处理完了之后ManagedSelector会调的回调函数,我们先忽略。
|
||||
void updateKey();
|
||||
}
|
||||
|
||||
|
||||
ManagedSelector在检测到某个Channel上的I/O事件就绪时,也就是说这个Channel被选中了,ManagedSelector调用这个Channel所绑定的附件类的onSelected方法来拿到一个Runnable。
|
||||
|
||||
这句话有点绕,其实就是ManagedSelector的使用者,比如Endpoint组件在向ManagedSelector注册读就绪事件时,同时也要告诉ManagedSelector在事件就绪时执行什么任务,具体来说就是传入一个附件类,这个附件类需要实现Selectable接口。ManagedSelector通过调用这个onSelected拿到一个Runnable,然后把Runnable扔给线程池去执行。
|
||||
|
||||
那Endpoint的onSelected是如何实现的呢?
|
||||
|
||||
@Override
|
||||
public Runnable onSelected()
|
||||
{
|
||||
int readyOps = _key.readyOps();
|
||||
|
||||
boolean fillable = (readyOps & SelectionKey.OP_READ) != 0;
|
||||
boolean flushable = (readyOps & SelectionKey.OP_WRITE) != 0;
|
||||
|
||||
// return task to complete the job
|
||||
Runnable task= fillable
|
||||
? (flushable
|
||||
? _runCompleteWriteFillable
|
||||
: _runFillable)
|
||||
: (flushable
|
||||
? _runCompleteWrite
|
||||
: null);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
|
||||
上面的代码逻辑很简单,就是读事件到了就读,写事件到了就写。
|
||||
|
||||
ExecutionStrategy
|
||||
|
||||
铺垫了这么多,终于要上主菜了。前面我主要介绍了ManagedSelector的使用者如何跟ManagedSelector交互,也就是如何注册Channel以及I/O事件,提供什么样的处理类来处理I/O事件,接下来我们来看看ManagedSelector是如何统一管理和维护用户注册的Channel集合。再回到今天开始的讨论,ManagedSelector将I/O事件的生产和消费看作是生产者消费者模式,为了充分利用CPU缓存,生产和消费尽量放到同一个线程处理,那这是如何实现的呢?Jetty定义了ExecutionStrategy接口:
|
||||
|
||||
public interface ExecutionStrategy
|
||||
{
|
||||
//只在HTTP2中用到,简单起见,我们先忽略这个方法。
|
||||
public void dispatch();
|
||||
|
||||
//实现具体执行策略,任务生产出来后可能由当前线程执行,也可能由新线程来执行
|
||||
public void produce();
|
||||
|
||||
//任务的生产委托给Producer内部接口,
|
||||
public interface Producer
|
||||
{
|
||||
//生产一个Runnable(任务)
|
||||
Runnable produce();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们看到ExecutionStrategy接口比较简单,它将具体任务的生产委托内部接口Producer,而在自己的produce方法里来实现具体执行逻辑,也就是生产出来的任务要么由当前线程执行,要么放到新线程中执行。Jetty提供了一些具体策略实现类:ProduceConsume、ProduceExecuteConsume、ExecuteProduceConsume和EatWhatYouKill。它们的区别是:
|
||||
|
||||
|
||||
ProduceConsume:任务生产者自己依次生产和执行任务,对应到NIO通信模型就是用一个线程来侦测和处理一个ManagedSelector上所有的I/O事件,后面的I/O事件要等待前面的I/O事件处理完,效率明显不高。通过图来理解,图中绿色表示生产一个任务,蓝色表示执行这个任务。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ProduceExecuteConsume:任务生产者开启新线程来运行任务,这是典型的I/O事件侦测和处理用不同的线程来处理,缺点是不能利用CPU缓存,并且线程切换成本高。同样我们通过一张图来理解,图中的棕色表示线程切换。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ExecuteProduceConsume:任务生产者自己运行任务,但是该策略可能会新建一个新线程以继续生产和执行任务。这种策略也被称为“吃掉你杀的猎物”,它来自狩猎伦理,认为一个人不应该杀死他不吃掉的东西,对应线程来说,不应该生成自己不打算运行的任务。它的优点是能利用CPU缓存,但是潜在的问题是如果处理I/O事件的业务代码执行时间过长,会导致线程大量阻塞和线程饥饿。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
EatWhatYouKill:这是Jetty对ExecuteProduceConsume策略的改良,在线程池线程充足的情况下等同于ExecuteProduceConsume;当系统比较忙线程不够时,切换成ProduceExecuteConsume策略。为什么要这么做呢,原因是ExecuteProduceConsume是在同一线程执行I/O事件的生产和消费,它使用的线程来自Jetty全局的线程池,这些线程有可能被业务代码阻塞,如果阻塞得多了,全局线程池中的线程自然就不够用了,最坏的情况是连I/O事件的侦测都没有线程可用了,会导致Connector拒绝浏览器请求。于是Jetty做了一个优化,在低线程情况下,就执行ProduceExecuteConsume策略,I/O侦测用专门的线程处理,I/O事件的处理扔给线程池处理,其实就是放到线程池的队列里慢慢处理。
|
||||
|
||||
|
||||
分析了这几种线程策略,我们再来看看Jetty是如何实现ExecutionStrategy接口的。答案其实就是实现Produce接口生产任务,一旦任务生产出来,ExecutionStrategy会负责执行这个任务。
|
||||
|
||||
private class SelectorProducer implements ExecutionStrategy.Producer
|
||||
{
|
||||
private Set<SelectionKey> _keys = Collections.emptySet();
|
||||
private Iterator<SelectionKey> _cursor = Collections.emptyIterator();
|
||||
|
||||
@Override
|
||||
public Runnable produce()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
//如何Channel集合中有I/O事件就绪,调用前面提到的Selectable接口获取Runnable,直接返回给ExecutionStrategy去处理
|
||||
Runnable task = processSelected();
|
||||
if (task != null)
|
||||
return task;
|
||||
|
||||
//如果没有I/O事件就绪,就干点杂活,看看有没有客户提交了更新Selector的任务,就是上面提到的SelectorUpdate任务类。
|
||||
processUpdates();
|
||||
updateKeys();
|
||||
|
||||
//继续执行select方法,侦测I/O就绪事件
|
||||
if (!select())
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SelectorProducer是ManagedSelector的内部类,SelectorProducer实现了ExecutionStrategy中的Producer接口中的produce方法,需要向ExecutionStrategy返回一个Runnable。在这个方法里SelectorProducer主要干了三件事情
|
||||
|
||||
|
||||
如果Channel集合中有I/O事件就绪,调用前面提到的Selectable接口获取Runnable,直接返回给ExecutionStrategy去处理。
|
||||
如果没有I/O事件就绪,就干点杂活,看看有没有客户提交了更新Selector上事件注册的任务,也就是上面提到的SelectorUpdate任务类。
|
||||
干完杂活继续执行select方法,侦测I/O就绪事件。
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
多线程虽然是提高并发的法宝,但并不是说线程越多越好,CPU缓存以及线程上下文切换的开销也是需要考虑的。Jetty巧妙设计了EatWhatYouKill的线程策略,尽量用同一个线程侦测I/O事件和处理I/O事件,充分利用了CPU缓存,并减少了线程切换的开销。
|
||||
|
||||
课后思考
|
||||
|
||||
文章提到ManagedSelector的使用者不能直接向它注册I/O事件,而是需要向ManagedSelector提交一个SelectorUpdate事件,ManagedSelector将这些事件Queue起来由自己来统一处理,这样做有什么好处呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
199
专栏/深入拆解TomcatJetty/20总结:Tomcat和Jetty中的对象池技术.md
Normal file
199
专栏/深入拆解TomcatJetty/20总结:Tomcat和Jetty中的对象池技术.md
Normal file
@ -0,0 +1,199 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 总结:Tomcat和Jetty中的对象池技术
|
||||
Java对象,特别是一个比较大、比较复杂的Java对象,它们的创建、初始化和GC都需要耗费CPU和内存资源,为了减少这些开销,Tomcat和Jetty都使用了对象池技术。所谓的对象池技术,就是说一个Java对象用完之后把它保存起来,之后再拿出来重复使用,省去了对象创建、初始化和GC的过程。对象池技术是典型的以空间换时间的思路。
|
||||
|
||||
由于维护对象池本身也需要资源的开销,不是所有场景都适合用对象池。如果你的Java对象数量很多并且存在的时间比较短,对象本身又比较大比较复杂,对象初始化的成本比较高,这样的场景就适合用对象池技术。比如Tomcat和Jetty处理HTTP请求的场景就符合这个特征,请求的数量很多,为了处理单个请求需要创建不少的复杂对象(比如Tomcat连接器中SocketWrapper和SocketProcessor),而且一般来说请求处理的时间比较短,一旦请求处理完毕,这些对象就需要被销毁,因此这个场景适合对象池技术。
|
||||
|
||||
Tomcat的SynchronizedStack
|
||||
|
||||
Tomcat用SynchronizedStack类来实现对象池,下面我贴出它的关键代码来帮助你理解。
|
||||
|
||||
public class SynchronizedStack<T> {
|
||||
|
||||
//内部维护一个对象数组,用数组实现栈的功能
|
||||
private Object[] stack;
|
||||
|
||||
//这个方法用来归还对象,用synchronized进行线程同步
|
||||
public synchronized boolean push(T obj) {
|
||||
index++;
|
||||
if (index == size) {
|
||||
if (limit == -1 || size < limit) {
|
||||
expand();//对象不够用了,扩展对象数组
|
||||
} else {
|
||||
index--;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
stack[index] = obj;
|
||||
return true;
|
||||
}
|
||||
|
||||
//这个方法用来获取对象
|
||||
public synchronized T pop() {
|
||||
if (index == -1) {
|
||||
return null;
|
||||
}
|
||||
T result = (T) stack[index];
|
||||
stack[index--] = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
//扩展对象数组长度,以2倍大小扩展
|
||||
private void expand() {
|
||||
int newSize = size * 2;
|
||||
if (limit != -1 && newSize > limit) {
|
||||
newSize = limit;
|
||||
}
|
||||
//扩展策略是创建一个数组长度为原来两倍的新数组
|
||||
Object[] newStack = new Object[newSize];
|
||||
//将老数组对象引用复制到新数组
|
||||
System.arraycopy(stack, 0, newStack, 0, size);
|
||||
//将stack指向新数组,老数组可以被GC掉了
|
||||
stack = newStack;
|
||||
size = newSize;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这个代码逻辑比较清晰,主要是SynchronizedStack内部维护了一个对象数组,并且用数组来实现栈的接口:push和pop方法,这两个方法分别用来归还对象和获取对象。你可能好奇为什么Tomcat使用一个看起来比较简单的SynchronizedStack来做对象容器,为什么不使用高级一点的并发容器比如ConcurrentLinkedQueue呢?
|
||||
|
||||
这是因为SynchronizedStack用数组而不是链表来维护对象,可以减少结点维护的内存开销,并且它本身只支持扩容不支持缩容,也就是说数组对象在使用过程中不会被重新赋值,也就不会被GC。这样设计的目的是用最低的内存和GC的代价来实现无界容器,同时Tomcat的最大同时请求数是有限制的,因此不需要担心对象的数量会无限膨胀。
|
||||
|
||||
Jetty的ByteBufferPool
|
||||
|
||||
我们再来看Jetty中的对象池ByteBufferPool,它本质是一个ByteBuffer对象池。当Jetty在进行网络数据读写时,不需要每次都在JVM堆上分配一块新的Buffer,只需在ByteBuffer对象池里拿到一块预先分配好的Buffer,这样就避免了频繁的分配内存和释放内存。这种设计你同样可以在高性能通信中间件比如Mina和Netty中看到。ByteBufferPool是一个接口:
|
||||
|
||||
public interface ByteBufferPool
|
||||
{
|
||||
public ByteBuffer acquire(int size, boolean direct);
|
||||
|
||||
public void release(ByteBuffer buffer);
|
||||
}
|
||||
|
||||
|
||||
接口中的两个方法:acquire和release分别用来分配和释放内存,并且你可以通过acquire方法的direct参数来指定buffer是从JVM堆上分配还是从本地内存分配。ArrayByteBufferPool是ByteBufferPool的实现类,我们先来看看它的成员变量和构造函数:
|
||||
|
||||
public class ArrayByteBufferPool implements ByteBufferPool
|
||||
{
|
||||
private final int _min;//最小size的Buffer长度
|
||||
private final int _maxQueue;//Queue最大长度
|
||||
|
||||
//用不同的Bucket(桶)来持有不同size的ByteBuffer对象,同一个桶中的ByteBuffer size是一样的
|
||||
private final ByteBufferPool.Bucket[] _direct;
|
||||
private final ByteBufferPool.Bucket[] _indirect;
|
||||
|
||||
//ByteBuffer的size增量
|
||||
private final int _inc;
|
||||
|
||||
public ArrayByteBufferPool(int minSize, int increment, int maxSize, int maxQueue)
|
||||
{
|
||||
//检查参数值并设置默认值
|
||||
if (minSize<=0)//ByteBuffer的最小长度
|
||||
minSize=0;
|
||||
if (increment<=0)
|
||||
increment=1024;//默认以1024递增
|
||||
if (maxSize<=0)
|
||||
maxSize=64*1024;//ByteBuffer的最大长度默认是64K
|
||||
|
||||
//ByteBuffer的最小长度必须小于增量
|
||||
if (minSize>=increment)
|
||||
throw new IllegalArgumentException("minSize >= increment");
|
||||
|
||||
//最大长度必须是增量的整数倍
|
||||
if ((maxSize%increment)!=0 || increment>=maxSize)
|
||||
throw new IllegalArgumentException("increment must be a divisor of maxSize");
|
||||
|
||||
_min=minSize;
|
||||
_inc=increment;
|
||||
|
||||
//创建maxSize/increment个桶,包含直接内存的与heap的
|
||||
_direct=new ByteBufferPool.Bucket[maxSize/increment];
|
||||
_indirect=new ByteBufferPool.Bucket[maxSize/increment];
|
||||
_maxQueue=maxQueue;
|
||||
int size=0;
|
||||
for (int i=0;i<_direct.length;i++)
|
||||
{
|
||||
size+=_inc;
|
||||
_direct[i]=new ByteBufferPool.Bucket(this,size,_maxQueue);
|
||||
_indirect[i]=new ByteBufferPool.Bucket(this,size,_maxQueue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从上面的代码我们看到,ByteBufferPool是用不同的桶(Bucket)来管理不同长度的ByteBuffer,因为我们可能需要分配一块1024字节的Buffer,也可能需要一块64K字节的Buffer。而桶的内部用一个ConcurrentLinkedDeque来放置ByteBuffer对象的引用。
|
||||
|
||||
private final Deque<ByteBuffer> _queue = new ConcurrentLinkedDeque<>();
|
||||
|
||||
|
||||
你可以通过下面的图再来理解一下:
|
||||
|
||||
|
||||
|
||||
而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率比较高,这个时候你就可以考虑使用对象池了。
|
||||
|
||||
还有一种情况是你需要对资源的使用做限制,比如数据库连接,不能无限制地创建数据库连接,因此就有了数据库连接池,你也可以考虑把一些关键的资源池化,对它们进行统一管理,防止滥用。
|
||||
|
||||
课后思考
|
||||
|
||||
请你想想在实际工作中,有哪些场景可以用“池化”技术来优化。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
172
专栏/深入拆解TomcatJetty/21总结:Tomcat和Jetty的高性能、高并发之道.md
Normal file
172
专栏/深入拆解TomcatJetty/21总结:Tomcat和Jetty的高性能、高并发之道.md
Normal file
@ -0,0 +1,172 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 总结:Tomcat和Jetty的高性能、高并发之道
|
||||
高性能程序就是高效的利用CPU、内存、网络和磁盘等资源,在短时间内处理大量的请求。那如何衡量“短时间和大量”呢?其实就是两个关键指标:响应时间和每秒事务处理量(TPS)。
|
||||
|
||||
那什么是资源的高效利用呢? 我觉得有两个原则:
|
||||
|
||||
|
||||
减少资源浪费。比如尽量避免线程阻塞,因为一阻塞就会发生线程上下文切换,就需要耗费CPU资源;再比如网络通信时数据从内核空间拷贝到Java堆内存,需要通过本地内存中转。
|
||||
当某种资源成为瓶颈时,用另一种资源来换取。比如缓存和对象池技术就是用内存换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期和第16期。其实池化的本质就是用内存换CPU;而零拷贝就是不做无用功,减少资源浪费。
|
||||
|
||||
高效的并发编程
|
||||
|
||||
我们知道并发的过程中为了同步多个线程对共享变量的访问,需要加锁来实现。而锁的开销是比较大的,拿锁的过程本身就是个系统调用,如果锁没拿到线程会阻塞,又会发生线程上下文切换,尤其是大量线程同时竞争一把锁时,会浪费大量的系统资源。因此作为程序员,要有意识的尽量避免锁的使用,比如可以使用原子类CAS或者并发集合来代替。如果万不得已需要用到锁,也要尽量缩小锁的范围和锁的强度。接下来我们来看看Tomcat和Jetty如何做到高效的并发编程的。
|
||||
|
||||
缩小锁的范围
|
||||
|
||||
缩小锁的范围,其实就是不直接在方法上加synchronized,而是使用细粒度的对象锁。
|
||||
|
||||
protected void startInternal() throws LifecycleException {
|
||||
|
||||
setState(LifecycleState.STARTING);
|
||||
|
||||
// 锁engine成员变量
|
||||
if (engine != null) {
|
||||
synchronized (engine) {
|
||||
engine.start();
|
||||
}
|
||||
}
|
||||
|
||||
//锁executors成员变量
|
||||
synchronized (executors) {
|
||||
for (Executor executor: executors) {
|
||||
executor.start();
|
||||
}
|
||||
}
|
||||
|
||||
mapperListener.start();
|
||||
|
||||
//锁connectors成员变量
|
||||
synchronized (connectorsLock) {
|
||||
for (Connector connector: connectors) {
|
||||
// If it has already failed, don't try and start it
|
||||
if (connector.getState() != LifecycleState.FAILED) {
|
||||
connector.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
比如上面的代码是Tomcat的StandardService组件的启动方法,这个启动方法要启动三种子组件:Engine、Executors和Connectors。它没有直接在方法上加锁,而是用了三把细粒度的锁,来分别用来锁三个成员变量。如果直接在方法上加synchronized,多个线程执行到这个方法时需要排队;而在对象级别上加synchronized,多个线程可以并行执行这个方法,只是在访问某个成员变量时才需要排队。
|
||||
|
||||
用原子变量和CAS取代锁
|
||||
|
||||
下面的代码是Jetty线程池的启动方法,它的主要功能就是根据传入的参数启动相应个数的线程。
|
||||
|
||||
private boolean startThreads(int threadsToStart)
|
||||
{
|
||||
while (threadsToStart > 0 && isRunning())
|
||||
{
|
||||
//获取当前已经启动的线程数,如果已经够了就不需要启动了
|
||||
int threads = _threadsStarted.get();
|
||||
if (threads >= _maxThreads)
|
||||
return false;
|
||||
|
||||
//用CAS方法将线程数加一,请注意执行失败走continue,继续尝试
|
||||
if (!_threadsStarted.compareAndSet(threads, threads + 1))
|
||||
continue;
|
||||
|
||||
boolean started = false;
|
||||
try
|
||||
{
|
||||
Thread thread = newThread(_runnable);
|
||||
thread.setDaemon(isDaemon());
|
||||
thread.setPriority(getThreadsPriority());
|
||||
thread.setName(_name + "-" + thread.getId());
|
||||
_threads.add(thread);//_threads并发集合
|
||||
_lastShrink.set(System.nanoTime());//_lastShrink是原子变量
|
||||
thread.start();
|
||||
started = true;
|
||||
--threadsToStart;
|
||||
}
|
||||
finally
|
||||
{
|
||||
//如果最终线程启动失败,还需要把线程数减一
|
||||
if (!started)
|
||||
_threadsStarted.decrementAndGet();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
你可以看到整个函数的实现是一个while循环,并且是无锁的。_threadsStarted表示当前线程池已经启动了多少个线程,它是一个原子变量AtomicInteger,首先通过它的get方法拿到值,如果线程数已经达到最大值,直接返回。否则尝试用CAS操作将_threadsStarted的值加一,如果成功了意味着没有其他线程在改这个值,当前线程可以继续往下执行;否则走continue分支,也就是继续重试,直到成功为止。在这里当然你也可以使用锁来实现,但是我们的目的是无锁化。
|
||||
|
||||
并发容器的使用
|
||||
|
||||
CopyOnWriteArrayList适用于读多写少的场景,比如Tomcat用它来“存放”事件监听器,这是因为监听器一般在初始化过程中确定后就基本不会改变,当事件触发时需要遍历这个监听器列表,所以这个场景符合读多写少的特征。
|
||||
|
||||
public abstract class LifecycleBase implements Lifecycle {
|
||||
|
||||
//事件监听器集合
|
||||
private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
volatile关键字的使用
|
||||
|
||||
再拿Tomcat中的LifecycleBase作为例子,它里面的生命状态就是用volatile关键字修饰的。volatile的目的是为了保证一个线程修改了变量,另一个线程能够读到这种变化。对于生命状态来说,需要在各个线程中保持是最新的值,因此采用了volatile修饰。
|
||||
|
||||
public abstract class LifecycleBase implements Lifecycle {
|
||||
|
||||
//当前组件的生命状态,用volatile修饰
|
||||
private volatile LifecycleState state = LifecycleState.NEW;
|
||||
|
||||
}
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
高性能程序能够高效的利用系统资源,首先就是减少资源浪费,比如要减少线程的阻塞,因为阻塞会导致资源闲置和线程上下文切换,Tomcat和Jetty通过合理的I/O模型和线程模型减少了线程的阻塞。
|
||||
|
||||
另外系统调用会导致用户态和内核态切换的过程,Tomcat和Jetty通过缓存和延迟解析尽量减少系统调用,另外还通过零拷贝技术避免多余的数据拷贝。
|
||||
|
||||
高效的利用资源还包括另一层含义,那就是我们在系统设计的过程中,经常会用一种资源换取另一种资源,比如Tomcat和Jetty中使用的对象池技术,就是用内存换取CPU,将数据压缩后再传输就是用CPU换网络。
|
||||
|
||||
除此之外,高效的并发编程也很重要,多线程虽然可以提高并发度,也带来了锁的开销,因此我们在实际编程过程中要尽量避免使用锁,比如可以用原子变量和CAS操作来代替锁。如果实在避免不了用锁,也要尽量减少锁的范围和强度,比如可以用细粒度的对象锁或者低强度的读写锁。Tomcat和Jetty的代码也很好的实践了这一理念。
|
||||
|
||||
课后思考
|
||||
|
||||
今天的文章提到我们要有意识尽量避免系统调用,那你知道有哪些Java API会导致系统调用吗?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
60
专栏/深入拆解TomcatJetty/22热点问题答疑(2):内核如何阻塞与唤醒进程?.md
Normal file
60
专栏/深入拆解TomcatJetty/22热点问题答疑(2):内核如何阻塞与唤醒进程?.md
Normal file
@ -0,0 +1,60 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 热点问题答疑(2):内核如何阻塞与唤醒进程?
|
||||
在专栏的第三个模块,我们学习了Tomcat连接器组件的设计,其中最重要的是各种I/O模型及其实现。而I/O模型跟操作系统密切相关,要彻底理解这些原理,我们首先需要弄清楚什么是进程和线程,什么是虚拟内存和物理内存,什么是用户空间和内核空间,线程的阻塞到底意味着什么,内核又是如何唤醒用户线程的等等这些问题。可以说掌握这些底层的知识,对于你学习Tomcat和Jetty的原理,乃至其他各种后端架构都至关重要,这些知识可以说是后端开发的“基石”。
|
||||
|
||||
在专栏的留言中我也发现很多同学反馈对这些底层的概念很模糊,那今天作为模块的答疑篇,我就来跟你聊聊这些问题。
|
||||
|
||||
进程和线程
|
||||
|
||||
我们先从Linux的进程谈起,操作系统要运行一个可执行程序,首先要将程序文件加载到内存,然后CPU去读取和执行程序指令,而一个进程就是“一次程序的运行过程”,内核会给每一个进程创建一个名为task_struct的数据结构,而内核也是一段程序,系统启动时就被加载到内存中了。
|
||||
|
||||
进程在运行过程中要访问内存,而物理内存是有限的,比如16GB,那怎么把有限的内存分给不同的进程使用呢?跟CPU的分时共享一样,内存也是共享的,Linux给每个进程虚拟出一块很大的地址空间,比如32位机器上进程的虚拟内存地址空间是4GB,从0x00000000到0xFFFFFFFF。但这4GB并不是真实的物理内存,而是进程访问到了某个虚拟地址,如果这个地址还没有对应的物理内存页,就会产生缺页中断,分配物理内存,MMU(内存管理单元)会将虚拟地址与物理内存页的映射关系保存在页表中,再次访问这个虚拟地址,就能找到相应的物理内存页。每个进程的这4GB虚拟地址空间分布如下图所示:
|
||||
|
||||
|
||||
|
||||
进程的虚拟地址空间总体分为用户空间和内核空间,低地址上的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的寄存器中,然后执行进程上下文指定的下一条指令。
|
||||
|
||||
|
||||
|
||||
而阻塞的本质就是将进程的task_struct移出运行队列,添加到等待队列,并且将进程的状态的置为TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE,重新触发一次CPU调度让出CPU。
|
||||
|
||||
那线程怎么唤醒呢?线程在加入到等待队列的同时向内核注册了一个回调函数,告诉内核我在等待这个Socket上的数据,如果数据到了就唤醒我。这样当网卡接收到数据时,产生硬件中断,内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的task_struct从等待队列移到运行队列,并且将task_struct的状态置为TASK_RUNNING,这样进程就有机会重新获得CPU时间片。
|
||||
|
||||
这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。
|
||||
|
||||
|
||||
|
||||
当read系统调用返回时,CPU又从内核态切换到用户态,继续执行read调用的下一行代码,并且能从用户空间上的Buffer读到数据了。
|
||||
|
||||
小结
|
||||
|
||||
今天我们谈到了一次Socket read系统调用的过程:首先CPU在用户态执行应用程序的代码,访问进程虚拟地址空间的用户空间;read系统调用时CPU从用户态切换到内核态,执行内核代码,内核检测到Socket上的数据未就绪时,将进程的task_struct结构体从运行队列中移到等待队列,并触发一次CPU调度,这时进程会让出CPU;当网卡数据到达时,内核将数据从内核空间拷贝到用户空间的Buffer,接着将进程的task_struct结构体重新移到运行队列,这样进程就有机会重新获得CPU时间片,系统调用返回,CPU又从内核态切换到用户态,访问用户空间的数据。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
217
专栏/深入拆解TomcatJetty/23Host容器:Tomcat如何实现热部署和热加载?.md
Normal file
217
专栏/深入拆解TomcatJetty/23Host容器:Tomcat如何实现热部署和热加载?.md
Normal file
@ -0,0 +1,217 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 Host容器:Tomcat如何实现热部署和热加载?
|
||||
从这一期我们开始学习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() {
|
||||
//请注意这里传入的参数是"宿主类"的实例
|
||||
processChildren(ContainerBase.this);
|
||||
}
|
||||
|
||||
protected void processChildren(Container container) {
|
||||
try {
|
||||
//1. 调用当前容器的backgroundProcess方法。
|
||||
container.backgroundProcess();
|
||||
|
||||
//2. 遍历所有的子容器,递归调用processChildren,
|
||||
//这样当前容器的子孙都会被处理
|
||||
Container[] children = container.findChildren();
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
//这里请你注意,容器基类有个变量叫做backgroundProcessorDelay,如果大于0,表明子容器有自己的后台线程,无需父容器来调用它的processChildren方法。
|
||||
if (children[i].getBackgroundProcessorDelay() <= 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. 触发容器的"周期事件",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方法比较复杂,总结起来,主要完成了下面这些任务:
|
||||
|
||||
|
||||
停止和销毁Context容器及其所有子容器,子容器其实就是Wrapper,也就是说Wrapper里面Servlet实例也被销毁了。
|
||||
停止和销毁Context容器关联的Listener和Filter。
|
||||
停止和销毁Context下的Pipeline和各种Valve。
|
||||
停止和销毁Context的类加载器,以及类加载器加载的类文件资源。
|
||||
启动Context容器,在这个过程中会重新创建前面四步被销毁的资源。
|
||||
|
||||
|
||||
在这个过程中,类加载器发挥着关键作用。一个Context容器对应一个类加载器,类加载器在销毁的过程中会把它加载的所有类也全部销毁。Context容器在启动过程中,会创建一个新的类加载器来加载新的类文件。
|
||||
|
||||
在Context的reload方法里,并没有调用Session管理器的destroy方法,也就是说这个Context关联的Session是没有销毁的。你还需要注意的是,Tomcat的热加载默认是关闭的,你需要在conf目录下的context.xml文件中设置reloadable参数来开启这个功能,像下面这样:
|
||||
|
||||
<Context reloadable="true"/>
|
||||
|
||||
|
||||
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 < 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方法来实现热部署呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
224
专栏/深入拆解TomcatJetty/24Context容器(上):Tomcat如何打破双亲委托机制?.md
Normal file
224
专栏/深入拆解TomcatJetty/24Context容器(上):Tomcat如何打破双亲委托机制?.md
Normal file
@ -0,0 +1,224 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 Context容器(上):Tomcat如何打破双亲委托机制?
|
||||
相信我们平时在工作中都遇到过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<?> loadClass(String name) {
|
||||
|
||||
//查找一下这个类是不是已经加载过了
|
||||
Class<?> 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<?> findClass(String name){
|
||||
//1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
|
||||
...
|
||||
|
||||
//2. 调用defineClass将字节数组转成Class对象
|
||||
return defineClass(buf, off, len);
|
||||
}
|
||||
|
||||
// 将字节码数组解析成一个Class对象,用native方法实现
|
||||
protected final Class<?> 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个类加载器,另外你也可以自定义类加载器,它们的关系如下图所示。
|
||||
|
||||
|
||||
|
||||
|
||||
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又会委托给BootstrapClassLoader,BootstrapClassLoader发现自己已经加载过了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<?> findClass(String name) throws ClassNotFoundException {
|
||||
...
|
||||
|
||||
Class<?> 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方法里,主要有三个步骤:
|
||||
|
||||
|
||||
先在Web应用本地目录下查找要加载的类。
|
||||
如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器AppClassLoader。
|
||||
如何父加载器也没找到这个类,抛出ClassNotFound异常。
|
||||
|
||||
|
||||
loadClass方法
|
||||
|
||||
接着我们再来看Tomcat类加载器的loadClass方法的实现,同样我也去掉了一些细节:
|
||||
|
||||
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
|
||||
|
||||
synchronized (getClassLoadingLock(name)) {
|
||||
|
||||
Class<?> 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方法稍微复杂一点,主要有六个步骤:
|
||||
|
||||
|
||||
先在本地Cache查找该类是否已经加载过,也就是说Tomcat的类加载器是否已经加载过这个类。
|
||||
如果Tomcat类加载器没有加载过这个类,再看看系统类加载器是否加载过。
|
||||
如果都没有,就让ExtClassLoader去加载,这一步比较关键,目的防止Web应用自己的类覆盖JRE的核心类。因为Tomcat需要打破双亲委托机制,假如Web应用里自定义了一个叫Object的类,如果先加载这个Object类,就会覆盖JRE里面的那个Object类,这就是为什么Tomcat的类加载器会优先尝试用ExtClassLoader去加载,因为ExtClassLoader会委托给BootstrapClassLoader去加载,BootstrapClassLoader发现自己已经加载了Object类,直接返回给Tomcat的类加载器,这样Tomcat的类加载器就不会去加载Web应用下的Object类了,也就避免了覆盖JRE核心类的问题。
|
||||
如果ExtClassLoader加载器加载失败,也就是说JRE核心类中没有这类,那么就在本地Web应用目录下查找并加载。
|
||||
如果本地目录下没有这个类,说明不是Web应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。
|
||||
如果上述加载过程全部失败,抛出ClassNotFound异常。
|
||||
|
||||
|
||||
从上面的过程我们可以看到,Tomcat的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载器,而是先在本地目录下加载,为了避免本地目录下的类覆盖JRE的核心类,先尝试用JVM扩展类加载器ExtClassLoader去加载。那为什么不先用系统类加载器AppClassLoader去加载?很显然,如果是这样的话,那就变成双亲委托机制了,这就是Tomcat类加载器的巧妙之处。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我介绍了JVM的类加载器原理和源码剖析,以及Tomcat的类加载器是如何打破双亲委托机制的,目的是为了优先加载Web应用目录下的类,然后再加载其他目录下的类,这也是Servlet规范的推荐做法。
|
||||
|
||||
要打破双亲委托机制,需要继承ClassLoader抽象类,并且需要重写它的loadClass方法,因为ClassLoader的默认实现就是双亲委托。
|
||||
|
||||
课后思考
|
||||
|
||||
如果你并不想打破双亲委托机制,但是又想定义自己的类加载器来加载特定目录下的类,你需要重写findClass和loadClass方法中的哪一个?还是两个都要重写?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
91
专栏/深入拆解TomcatJetty/25Context容器(中):Tomcat如何隔离Web应用?.md
Normal file
91
专栏/深入拆解TomcatJetty/25Context容器(中):Tomcat如何隔离Web应用?.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 Context容器(中):Tomcat如何隔离Web应用?
|
||||
我在专栏上一期提到,Tomcat通过自定义类加载器WebAppClassLoader打破了双亲委托机制,具体来说就是重写了JVM的类加载器ClassLoader的findClass方法和loadClass方法,这样做的目的是优先加载Web应用目录下的类。除此之外,你觉得Tomcat的类加载器还需要完成哪些需求呢?或者说在设计上还需要考虑哪些方面?
|
||||
|
||||
我们知道,Tomcat作为Servlet容器,它负责加载我们的Servlet类,此外它还负责加载Servlet所依赖的JAR包。并且Tomcat本身也是一个Java程序,因此它需要加载自己的类和依赖的JAR包。首先让我们思考这一下这几个问题:
|
||||
|
||||
|
||||
假如我们在Tomcat中运行了两个Web应用程序,两个Web应用中有同名的Servlet,但是功能不同,Tomcat需要同时加载和管理这两个同名的Servlet类,保证它们不会冲突,因此Web应用之间的类需要隔离。
|
||||
假如两个Web应用都依赖同一个第三方的JAR包,比如Spring,那Spring的JAR包被加载到内存后,Tomcat要保证这两个Web应用能够共享,也就是说Spring的JAR包只被加载一次,否则随着依赖的第三方JAR包增多,JVM的内存会膨胀。
|
||||
跟JVM一样,我们需要隔离Tomcat本身的类和Web应用的类。
|
||||
|
||||
|
||||
在了解了Tomcat的类加载器在设计时要考虑的这些问题以后,今天我们主要来学习一下Tomcat是如何通过设计多层次的类加载器来解决这些问题的。
|
||||
|
||||
Tomcat类加载器的层次结构
|
||||
|
||||
为了解决这些问题,Tomcat设计了类加载器的层次结构,它们的关系如下图所示。下面我来详细解释为什么要设计这些类加载器,告诉你它们是怎么解决上面这些问题的。
|
||||
|
||||
|
||||
|
||||
我们先来看第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<?> forName(String className) {
|
||||
Class<?> 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);
|
||||
|
||||
|
||||
这是为什么呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
208
专栏/深入拆解TomcatJetty/26Context容器(下):Tomcat如何实现Servlet规范?.md
Normal file
208
专栏/深入拆解TomcatJetty/26Context容器(下):Tomcat如何实现Servlet规范?.md
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 Context容器(下):Tomcat如何实现Servlet规范?
|
||||
我们知道,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和BasicValve,Wrapper的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<String, FilterDef> filterDefs = new HashMap<>();
|
||||
|
||||
|
||||
那上面提到的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 < n) {
|
||||
ApplicationFilterConfig filterConfig = filters[pos++];
|
||||
Filter filter = filterConfig.getFilter();
|
||||
|
||||
filter.doFilter(request, response, this);
|
||||
return;
|
||||
}
|
||||
|
||||
servlet.service(request, response);
|
||||
|
||||
}
|
||||
|
||||
|
||||
从ApplicationFilterChain的源码我们可以看到几个关键信息:
|
||||
|
||||
|
||||
Filter链中除了有Filter对象的数组,还有一个整数变量pos,这个变量用来记录当前被调用的Filter在数组中的位置。
|
||||
Filter链中有个Servlet实例,这个好理解,因为上面提到了,每个Filter链最后都会调到一个Servlet。
|
||||
Filter链本身也实现了doFilter方法,直接调用了一个内部方法internalDoFilter。
|
||||
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<Object> applicationEventListenersList = new CopyOnWriteArrayList<>();
|
||||
|
||||
//监听生命事件的监听器
|
||||
private Object applicationLifecycleListenersObjects[] = new Object[0];
|
||||
|
||||
|
||||
剩下的事情就是触发监听器了,比如在Context容器的启动方法里,就触发了所有的ServletContextListener:
|
||||
|
||||
//1.拿到所有的生命周期监听器
|
||||
Object instances[] = getApplicationLifecycleListeners();
|
||||
|
||||
for (int i = 0; i < 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容器来管理Servlet,Wrapper包装了Servlet本身以及相应的参数,这体现了面向对象中“封装”的设计原则。
|
||||
|
||||
Tomcat会给每个请求生成一个Filter链,Filter链中的最后一个Filter会负责调用Servlet的service方法。
|
||||
|
||||
对于Listener来说,我们可以定制自己的监听器来监听Tomcat内部发生的各种事件:包括Web应用级别的、Session级别的和请求级别的。Tomcat中的Context容器统一维护了这些监听器,并负责触发。
|
||||
|
||||
最后小结一下这3期内容,Context组件通过自定义类加载器来加载Web应用,并实现了Servlet规范,直接跟Web应用打交道,是一个核心的容器组件。也因此我用了很重的篇幅去讲解它,也非常建议你花点时间阅读一下它的源码。
|
||||
|
||||
课后思考
|
||||
|
||||
Context容器分别用了CopyOnWriteArrayList和对象数组来存储两种不同的监听器,为什么要这样设计,你可以思考一下背后的原因。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
202
专栏/深入拆解TomcatJetty/27新特性:Tomcat如何支持异步Servlet?.md
Normal file
202
专栏/深入拆解TomcatJetty/27新特性:Tomcat如何支持异步Servlet?.md
Normal file
@ -0,0 +1,202 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 新特性:Tomcat如何支持异步Servlet?
|
||||
通过专栏前面的学习我们知道,当一个新的请求到达时,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 = {"/async"}, 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("Handling Async Servlet");
|
||||
} catch (IOException e) {}
|
||||
|
||||
//3. 异步Servlet处理完了调用异步上下文的complete方法
|
||||
ctx.complete();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
上面的代码有三个要点:
|
||||
|
||||
|
||||
通过注解的方式来注册Servlet,除了@WebServlet注解,还需要加上asyncSupported=true的属性,表明当前的Servlet是一个异步Servlet。
|
||||
Web应用程序需要调用Request对象的startAsync方法来拿到一个异步上下文AsyncContext。这个上下文保存了请求和响应对象。
|
||||
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<S,Processor> connections = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
之所以要缓存是因为这个请求接下来还要接着处理,还是由原来的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<?> socketWrapper = getSocketWrapper();
|
||||
if (socketWrapper != null) {
|
||||
socketWrapper.processSocket(event, dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
而SocketWrapper的processSocket方法会创建SocketProcessor任务类,并通过Tomcat线程池来处理:
|
||||
|
||||
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
|
||||
SocketEvent event, boolean dispatch) {
|
||||
|
||||
if (socketWrapper == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SocketProcessorBase<S> sc = processorCache.pop();
|
||||
if (sc == null) {
|
||||
sc = createSocketProcessor(socketWrapper, event);
|
||||
} else {
|
||||
sc.reset(socketWrapper, event);
|
||||
}
|
||||
//线程池运行
|
||||
Executor executor = getExecutor();
|
||||
if (dispatch && executor != null) {
|
||||
executor.execute(sc);
|
||||
} else {
|
||||
sc.run();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
请你注意createSocketProcessor函数的第二个参数是SocketEvent,这里我们传入的是OPEN_READ。通过这个参数,我们就能控制SocketProcessor的行为,因为我们不需要再把请求发送到容器进行处理,只需要向浏览器端发送数据,并且重新在这个Socket上监听新的请求就行了。
|
||||
|
||||
最后我通过一张在帮你理解一下整个过程:
|
||||
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
非阻塞I/O模型可以利用很少的线程处理大量的连接,提高了并发度,本质就是通过一个Selector线程查询多个Socket的I/O事件,减少了线程的阻塞等待。
|
||||
|
||||
同样,异步Servlet机制也是减少了线程的阻塞等待,将Tomcat线程和业务线程分开,Tomcat线程不再等待业务代码的执行。
|
||||
|
||||
那什么样的场景适合异步Servlet呢?适合的场景有很多,最主要的还是根据你的实际情况,如果你拿不准是否适合异步Servlet,就看一条:如果你发现Tomcat的线程不够了,大量线程阻塞在等待Web应用的处理上,而Web应用又没有优化的空间了,确实需要长时间处理,这个时候你不妨尝试一下异步Servlet。
|
||||
|
||||
课后思考
|
||||
|
||||
异步Servlet将Tomcat线程和Web应用线程分开,体现了隔离的思想,也就是把不同的业务处理所使用的资源隔离开,使得它们互不干扰,尤其是低优先级的业务不能影响高优先级的业务。你可以思考一下,在你的Web应用内部,是不是也可以运用这种设计思想呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
240
专栏/深入拆解TomcatJetty/28新特性:SpringBoot如何使用内嵌式的Tomcat和Jetty?.md
Normal file
240
专栏/深入拆解TomcatJetty/28新特性:SpringBoot如何使用内嵌式的Tomcat和Jetty?.md
Normal file
@ -0,0 +1,240 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 新特性:Spring Boot如何使用内嵌式的Tomcat和Jetty?
|
||||
为了方便开发和部署,Spring Boot在内部启动了一个嵌入式的Web容器。我们知道Tomcat和Jetty是组件化的设计,要启动Tomcat或者Jetty其实就是启动这些组件。在Tomcat独立部署的模式下,我们通过startup脚本来启动Tomcat,Tomcat中的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<T extends WebServerFactory> {
|
||||
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 && servletContext == null) {
|
||||
//通过Web容器工厂来创建
|
||||
ServletWebServerFactory factory = this.getWebServerFactory();
|
||||
//注意传入了一个"SelfInitializer"
|
||||
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("tomcat");
|
||||
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. 创建定制版的"Context"组件。
|
||||
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("/hello")
|
||||
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(),"/hello");
|
||||
}
|
||||
|
||||
|
||||
这段代码实现的方法返回一个ServletRegistrationBean,并将它当作Bean注册到Spring中,因此你需要把这段代码放到Spring Boot自动扫描的目录中,或者放到@Configuration标识的类中。
|
||||
|
||||
3. 动态注册
|
||||
|
||||
你还可以创建一个类去实现前面提到的ServletContextInitializer接口,并把它注册为一个Bean,Spring Boot会负责调用这个接口的onStartup方法。
|
||||
|
||||
@Component
|
||||
public class MyServletRegister implements ServletContextInitializer {
|
||||
|
||||
@Override
|
||||
public void onStartup(ServletContext servletContext) {
|
||||
|
||||
//Servlet 3.0规范新的API
|
||||
ServletRegistration myServlet = servletContext
|
||||
.addServlet("HelloServlet", HelloServlet.class);
|
||||
|
||||
myServlet.addMapping("/hello");
|
||||
|
||||
myServlet.setInitParameter("name", "Hello Servlet");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里请注意两点:
|
||||
|
||||
|
||||
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<ConfigurableServletWebServerFactory> {
|
||||
|
||||
public void customize(ConfigurableServletWebServerFactory factory) {
|
||||
factory.setPort(8081);
|
||||
factory.setContextPath("/hello");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
第二种方式是通过特定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("traceid").setString("1234xxxxabcd");
|
||||
|
||||
Valve next = getNext();
|
||||
if (null == next) {
|
||||
return;
|
||||
}
|
||||
|
||||
next.invoke(request, response);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
跟第一种方式类似,再添加一个定制器,代码如下:
|
||||
|
||||
@Component
|
||||
public class MyTomcatCustomizer implements
|
||||
WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
|
||||
|
||||
@Override
|
||||
public void customize(TomcatServletWebServerFactory factory) {
|
||||
factory.setPort(8081);
|
||||
factory.setContextPath("/hello");
|
||||
factory.addEngineValves(new TraceValve() );
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
今天我们学习了Spring Boot如何利用Web容器的API来启动Web容器、如何向Web容器注册Servlet,以及如何定制化Web容器,除了给Web容器配置参数,还可以增加或者修改Web容器本身的组件。
|
||||
|
||||
课后思考
|
||||
|
||||
我在文章中提到,通过ServletContextInitializer接口可以向Web容器注册Servlet,那ServletContextInitializer跟Tomcat中的ServletContainerInitializer有什么区别和联系呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
319
专栏/深入拆解TomcatJetty/29比较:Jetty如何实现具有上下文信息的责任链?.md
Normal file
319
专栏/深入拆解TomcatJetty/29比较:Jetty如何实现具有上下文信息的责任链?.md
Normal file
@ -0,0 +1,319 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 比较:Jetty如何实现具有上下文信息的责任链?
|
||||
我们知道Tomcat和Jetty的核心功能是处理请求,并且请求的处理者不止一个,因此Tomcat和Jetty都实现了责任链模式,其中Tomcat是通过Pipeline-Valve来实现的,而Jetty是通过HandlerWrapper来实现的。HandlerWrapper中保存了下一个Handler的引用,将各Handler组成一个链表,像下面这样:
|
||||
|
||||
WebAppContext -> SessionHandler -> SecurityHandler -> ServletHandler
|
||||
|
||||
这样链中的Handler从头到尾能被依次调用,除此之外,Jetty还实现了“回溯”的链式调用,那就是从头到尾依次链式调用Handler的方法A,完成后再回到头节点,再进行一次链式调用,只不过这一次调用另一个方法B。你可能会问,一次链式调用不就够了吗,为什么还要回过头再调一次呢?这是因为一次请求到达时,Jetty需要先调用各Handler的初始化方法,之后再调用各Handler的请求处理方法,并且初始化必须在请求处理之前完成。
|
||||
|
||||
而Jetty是通过ScopedHandler来做到这一点的,那ScopedHandler跟HandlerWrapper有什么关系呢?ScopedHandler是HandlerWrapper的子类,我们还是通过一张图来回顾一下各种Handler的继承关系:
|
||||
|
||||
|
||||
|
||||
从图上我们看到,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的父类是HandlerWrapper,ScopedHandler重写了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<ScopedHandler> __outerScope= new ThreadLocal<ScopedHandler>();
|
||||
|
||||
|
||||
我们看到__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链是这样的:
|
||||
|
||||
|
||||
|
||||
上面的过程只是设置了_handler变量,那_outScope和_nextScope需要设置成什么样呢?为了方便你理解,我们先来看最后的效果图:
|
||||
|
||||
|
||||
|
||||
从上图我们看到:scopedA的_nextScope=scopedB,scopedB的_nextScope=scopedC,为什么scopedB的_nextScope不是WrapperX呢,因为WrapperX不是一个ScopedHandler。scopedC的_nextScope是null(因为它是链尾,没有下一个节点)。因此我们得出一个结论:_nextScope指向下一个Scoped节点的引用,由于WrapperX不是Scoped节点,它没有_outScope和_nextScope变量。
|
||||
|
||||
注意到scopedA的_outerScope是null,scopedB和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 && _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<String, String>();
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
我们看到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() > _contextPath.length())
|
||||
{
|
||||
if (_contextPath.length() > 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监听器,并将"开始处理一个新请求"这个事件通知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,为什么需要这样做呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
204
专栏/深入拆解TomcatJetty/30热点问题答疑(3):Spring框架中的设计模式.md
Normal file
204
专栏/深入拆解TomcatJetty/30热点问题答疑(3):Spring框架中的设计模式.md
Normal file
@ -0,0 +1,204 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 热点问题答疑(3):Spring框架中的设计模式
|
||||
在构思这个专栏的时候,回想当时我是如何研究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;
|
||||
<T> T getBean(String name, Class<T> requiredType);
|
||||
Object getBean(String name, Object... args);
|
||||
<T> T getBean(Class<T> requiredType);
|
||||
<T> T getBean(Class<T> 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<?> typeToMatch);
|
||||
Class<?> getType(String name);
|
||||
String[] getAliases(String name);
|
||||
}
|
||||
|
||||
|
||||
我们可以通过它的具体实现类(比如ClassPathXmlApplicationContext)来获取Bean:
|
||||
|
||||
BeanFactory bf = new ClassPathXmlApplicationContext("spring.xml");
|
||||
User userBean = (User) bf.getBean("userBean");
|
||||
|
||||
|
||||
从上面代码可以看到,使用者不需要自己来new对象,而是通过工厂类的方法getBean来获取对象实例,这是典型的简单工厂模式,只不过Spring是用反射机制来创建Bean的。
|
||||
|
||||
工厂方法模式
|
||||
|
||||
工厂方法模式说白了其实就是简单工厂模式的一种升级或者说是进一步抽象,它可以应用于更加复杂的场景,灵活性也更高。在简单工厂中,由工厂类进行所有的逻辑判断、实例创建;如果不想在工厂类中进行判断,可以为不同的产品提供不同的工厂,不同的工厂生产不同的产品,每一个工厂都只对应一个相应的对象,这就是工厂方法模式。
|
||||
|
||||
Spring中的FactoryBean就是这种思想的体现,FactoryBean可以理解为工厂Bean,先来看看它的定义:
|
||||
|
||||
public interface FactoryBean<T> {
|
||||
T getObject();
|
||||
Class<?> getObjectType();
|
||||
boolean isSingleton();
|
||||
}
|
||||
|
||||
|
||||
我们定义一个类UserFactoryBean来实现FactoryBean接口,主要是在getObject方法里new一个User对象。这样我们通过getBean(id) 获得的是该工厂所产生的User的实例,而不是UserFactoryBean本身的实例,像下面这样:
|
||||
|
||||
BeanFactory bf = new ClassPathXmlApplicationContext("user.xml");
|
||||
User userBean = (User) bf.getBean("userFactoryBean");
|
||||
|
||||
|
||||
单例模式
|
||||
|
||||
单例模式是指一个类在整个系统运行过程中,只允许产生一个实例。在Spring中,Bean可以被定义为两种模式:Prototype(多例)和Singleton(单例),Spring Bean默认是单例模式。那Spring是如何实现单例模式的呢?答案是通过单例注册表的方式,具体来说就是使用了HashMap。请注意为了方便你阅读,我对代码进行了简化:
|
||||
|
||||
public class DefaultSingletonBeanRegistry {
|
||||
|
||||
//使用了线程安全容器ConcurrentHashMap,保存各种单实例对象
|
||||
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>;
|
||||
|
||||
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("保存成功");
|
||||
}
|
||||
}
|
||||
|
||||
//代理对象
|
||||
public class StudentDaoProxy implements IStudentDao{
|
||||
//持有目标对象的引用
|
||||
private IStudentDao target;
|
||||
public StudentDaoProxy(IStudentDao target){
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
//在目标功能对象方法的前后加入事务控制
|
||||
public void save() {
|
||||
System.out.println("开始事务");
|
||||
target.save();//执行目标对象的方法
|
||||
System.out.println("提交事务");
|
||||
}
|
||||
}
|
||||
|
||||
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("开始事务");
|
||||
Object result = method.invoke(obj, args);
|
||||
System.out.println("开始事务");
|
||||
|
||||
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方法是如何实现的。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
166
专栏/深入拆解TomcatJetty/31Logger组件:Tomcat的日志框架及实战.md
Normal file
166
专栏/深入拆解TomcatJetty/31Logger组件:Tomcat的日志框架及实战.md
Normal file
@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 Logger组件:Tomcat的日志框架及实战
|
||||
每一个系统都有一些通用的模块,比如日志模块、异常处理模块、工具类等,对于Tomcat来说,比较重要的通用模块有日志、Session管理和集群管理。从今天开始我会分三期来介绍通用模块,今天这一期先来讲日志模块。
|
||||
|
||||
日志模块作为一个通用的功能,在系统里通常会使用第三方的日志框架。Java的日志框架有很多,比如:JUL(Java Util Logging)、Log4j、Logback、Log4j2、Tinylog等。除此之外,还有JCL(Apache Commons Logging)和SLF4J这样的“门面日志”。下面是SLF4J与日志框架Logback、Log4j的关系图:
|
||||
|
||||
|
||||
|
||||
我先来解释一下什么是“门面日志”。“门面日志”利用了设计模式中的门面模式思想,对外提供一套通用的日志记录的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路径下,包含了几个比较重要的组件,我们通过一张图来理解一下:
|
||||
|
||||
|
||||
|
||||
从图上我们看到这样几个重要的组件:
|
||||
|
||||
|
||||
Logger:用来记录日志的类。
|
||||
Handler:规定了日志的输出方式,如控制台输出、写入文件。
|
||||
Level:定义了日志的不同等级。
|
||||
Formatter:将日志信息格式化,比如纯文本、XML。
|
||||
|
||||
|
||||
我们可以通过下面的代码来使用这些组件:
|
||||
|
||||
public static void main(String[] args) {
|
||||
Logger logger = Logger.getLogger("com.mycompany.myapp");
|
||||
logger.setLevel(Level.FINE);
|
||||
logger.setUseParentHandlers(false);
|
||||
Handler hd = new ConsoleHandler();
|
||||
hd.setLevel(Level.FINE);
|
||||
logger.addHandler(hd);
|
||||
logger.info("start log");
|
||||
}
|
||||
|
||||
|
||||
JULI
|
||||
|
||||
JULI对日志的处理方式与Java自带的基本一致,但是Tomcat中可以包含多个应用,而每个应用的日志系统应该相互独立。Java的原生日志系统是每个JVM有一份日志的配置文件,这不符合Tomcat多应用的场景,所以JULI重新实现了一些日志接口。
|
||||
|
||||
DirectJDKLog
|
||||
|
||||
Log的基础实现类是DirectJDKLog,这个类相对简单,就包装了一下Java的Logger类。但是它也在原来的基础上进行了一些修改,比如修改默认的格式化方式。
|
||||
|
||||
LogFactory
|
||||
|
||||
Log使用了工厂模式来向外提供实例,LogFactory是一个单例,可以通过SeviceLoader为Log提供自定义的实现版本,如果没有配置,就默认使用DirectJDKLog。
|
||||
|
||||
private LogFactory() {
|
||||
// 通过ServiceLoader尝试加载Log的实现类
|
||||
ServiceLoader<Log> logLoader = ServiceLoader.load(Log.class);
|
||||
Constructor<? extends Log> m=null;
|
||||
|
||||
for (Log log: logLoader) {
|
||||
Class<? extends Log> 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中就自定义了两个Handler:FileHandler和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的进化版,在性能和可用性方面都有所提升。你可以参考官网上这篇文章来了解Logback的优势。
|
||||
|
||||
基于此我们来实战一下如何将Tomcat默认的日志框架切换成为“SLF4J + Logback”。具体的步骤是:
|
||||
|
||||
1.根据你的Tomcat版本,从这里下载所需要文件。解压后你会看到一个类似于Tomcat目录结构的文件夹。-
|
||||
2.替换或拷贝下列这些文件到Tomcat的安装目录:
|
||||
|
||||
|
||||
|
||||
3.删除<Tomcat>/conf/logging.properties-
|
||||
4.启动Tomcat
|
||||
|
||||
本期精华
|
||||
|
||||
今天我们谈了日志框架与日志门面的区别,以及Tomcat的日志模块是如何实现的。默认情况下,Tomcat的日志模板叫作JULI,JULI的日志门面采用了JCL,而具体实现是基于Java默认的日志框架Java Util Logging,Tomcat在Java Util Logging基础上进行了改造,使得它自身的日志框架不会影响Web应用,并且可以分模板配置日志的输出文件和格式。最后我分享了如何将Tomcat的日志模块切换到时下流行的“SLF4J + Logback”,希望对你有所帮助。
|
||||
|
||||
课后思考
|
||||
|
||||
Tomcat独立部署时,各种日志都输出到了相应的日志文件,假如Spring Boot以内嵌式的方式运行Tomcat,这种情况下Tomcat的日志都输出到哪里去了?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
283
专栏/深入拆解TomcatJetty/32Manager组件:Tomcat的Session管理机制解析.md
Normal file
283
专栏/深入拆解TomcatJetty/32Manager组件:Tomcat的Session管理机制解析.md
Normal file
@ -0,0 +1,283 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 Manager组件:Tomcat的Session管理机制解析
|
||||
我们可以通过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。
|
||||
|
||||
但这并不是我们拿到的Request,Tomcat为了避免把一些实现细节暴露出来,还有基于安全上的考虑,定义了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类其实是RequestFacade,RequestFacade的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。
|
||||
|
||||
因此最后还是到了StandardManager,StandardManager的父类叫ManagerBase,这个createSession方法定义在ManagerBase中,StandardManager直接重用这个方法。
|
||||
|
||||
接着我们来看ManagerBase的createSession是如何实现的:
|
||||
|
||||
@Override
|
||||
public Session createSession(String sessionId) {
|
||||
//首先判断Session数量是不是到了最大值,最大Session数可以通过参数设置
|
||||
if ((maxActiveSessions >= 0) &&
|
||||
(getActiveSessions() >= maxActiveSessions)) {
|
||||
rejectedSessions++;
|
||||
throw new TooManyActiveSessionsException(
|
||||
sm.getString("managerBase.createSession.ise"),
|
||||
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<String, Session> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
请注意Session的具体实现类是StandardSession,StandardSession同时实现了javax.servlet.http.HttpSession和org.apache.catalina.Session接口,并且对程序员暴露的是StandardSessionFacade外观类,保证了StandardSession的安全,避免了程序员调用其内部方法进行不当操作。StandardSession的核心成员变量如下:
|
||||
|
||||
public class StandardSession implements HttpSession, Session, Serializable {
|
||||
protected ConcurrentMap<String, Object> attributes = new ConcurrentHashMap<>();
|
||||
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<SessionListener> listeners = new ArrayList<>();
|
||||
protected transient Manager manager = null;
|
||||
protected volatile int maxInactiveInterval = -1;
|
||||
protected volatile boolean isNew = false;
|
||||
protected volatile boolean isValid = false;
|
||||
protected transient Map<String, Object> notes = new Hashtable<>();
|
||||
protected transient Principal principal = null;
|
||||
}
|
||||
|
||||
|
||||
Session的清理
|
||||
|
||||
我们再来看看Tomcat是如何清理过期的Session。在Tomcat热加载和热部署的文章里,我讲到容器组件会开启一个ContainerBackgroundProcessor后台线程,调用自己以及子容器的backgroundProcess进行一些后台逻辑的处理,和Lifecycle一样,这个动作也是具有传递性的,也就是说子容器还会把这个动作传递给自己的子容器。你可以参考下图来理解这个过程。
|
||||
|
||||
|
||||
|
||||
其中父容器会遍历所有的子容器并调用其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 < sessions.length; i++) {
|
||||
// Session 的过期是在isValid()方法里处理的
|
||||
if (sessions[i]!=null && !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) && (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 && listeners.length > 0) {
|
||||
|
||||
//创建HttpSessionEvent
|
||||
HttpSessionEvent event = new HttpSessionEvent(getSession());
|
||||
for (int i = 0; i < listeners.length; i++) {
|
||||
//判断是否是HttpSessionListener
|
||||
if (!(listeners[i] instanceof HttpSessionListener))
|
||||
continue;
|
||||
|
||||
HttpSessionListener listener = (HttpSessionListener) listeners[i];
|
||||
//注意这是容器内部事件
|
||||
context.fireContainerEvent("beforeSessionCreated", listener);
|
||||
//触发Session Created 事件
|
||||
listener.sessionCreated(event);
|
||||
|
||||
//注意这也是容器内部事件
|
||||
context.fireContainerEvent("afterSessionCreated", listener);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
上面代码的逻辑是,先通过StandardContext将HttpSessionListener类型的Listener取出,然后依次调用它们的sessionCreated方法。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我们从Request谈到了Session的创建、销毁和事件通知,里面涉及不少相关的类,下面我画了一张图帮你理解和消化一下这些类的关系:
|
||||
|
||||
|
||||
|
||||
Servlet规范中定义了HttpServletRequest和HttpSession接口,Tomcat实现了这些接口,但具体实现细节并没有暴露给开发者,因此定义了两个包装类,RequestFacade和StandardSessionFacade。
|
||||
|
||||
Tomcat是通过Manager来管理Session的,默认实现是StandardManager。StandardContext持有StandardManager的实例,并存放了HttpSessionListener集合,Session在创建和销毁时,会通知监听器。
|
||||
|
||||
课后思考
|
||||
|
||||
TCP连接的过期时间和Session的过期时间有什么区别?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
297
专栏/深入拆解TomcatJetty/33Cluster组件:Tomcat的集群通信原理.md
Normal file
297
专栏/深入拆解TomcatJetty/33Cluster组件:Tomcat的集群通信原理.md
Normal file
@ -0,0 +1,297 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 Cluster组件:Tomcat的集群通信原理
|
||||
为了支持水平扩展和高可用,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里的这一行的注释去掉就行:
|
||||
|
||||
|
||||
|
||||
变成这样:
|
||||
|
||||
|
||||
|
||||
虽然只是简单的一行配置,但这一行配置等同于下面这样的配置,也就是说Tomcat给我们设置了很多默认参数,这些参数都跟集群通信有关。
|
||||
|
||||
<!--
|
||||
SimpleTcpCluster是用来复制Session的组件。复制Session有同步和异步两种方式:
|
||||
同步模式下,向浏览器的发送响应数据前,需要先将Session拷贝到其他节点完;
|
||||
异步模式下,无需等待Session拷贝完成就可响应。异步模式更高效,但是同步模式
|
||||
可靠性更高。
|
||||
同步异步模式由channelSendOptions参数控制,默认值是8,为异步模式;4是同步模式。
|
||||
在异步模式下,可以通过加上"拷贝确认"(Acknowledge)来提高可靠性,此时
|
||||
channelSendOptions设为10
|
||||
-->
|
||||
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
|
||||
channelSendOptions="8">
|
||||
<!--
|
||||
Manager决定如何管理集群的Session信息。
|
||||
Tomcat提供了两种Manager:BackupManager和DeltaManager。
|
||||
BackupManager-集群下的某一节点的Session,将复制到一个备份节点。
|
||||
DeltaManager- 集群下某一节点的Session,将复制到所有其他节点。
|
||||
DeltaManager是Tomcat默认的集群Manager。
|
||||
|
||||
expireSessionsOnShutdown-设置为true时,一个节点关闭时,
|
||||
将导致集群下的所有Session失效
|
||||
notifyListenersOnReplication-集群下节点间的Session复制、
|
||||
删除操作,是否通知session listeners
|
||||
|
||||
maxInactiveInterval-集群下Session的有效时间(单位:s)。
|
||||
maxInactiveInterval内未活动的Session,将被Tomcat回收。
|
||||
默认值为1800(30min)
|
||||
-->
|
||||
<Manager className="org.apache.catalina.ha.session.DeltaManager"
|
||||
expireSessionsOnShutdown="false"
|
||||
notifyListenersOnReplication="true"/>
|
||||
|
||||
<!--
|
||||
Channel是Tomcat节点之间进行通讯的工具。
|
||||
Channel包括5个组件:Membership、Receiver、Sender、
|
||||
Transport、Interceptor
|
||||
-->
|
||||
<Channel className="org.apache.catalina.tribes.group.GroupChannel">
|
||||
<!--
|
||||
Membership维护集群的可用节点列表。它可以检查到新增的节点,
|
||||
也可以检查没有心跳的节点
|
||||
className-指定Membership使用的类
|
||||
address-组播地址
|
||||
port-组播端口
|
||||
frequency-发送心跳(向组播地址发送UDP数据包)的时间间隔(单位:ms)。
|
||||
dropTime-Membership在dropTime(单位:ms)内未收到某一节点的心跳,
|
||||
则将该节点从可用节点列表删除。默认值为3000。
|
||||
-->
|
||||
<Membership className="org.apache.catalina.tribes.membership.
|
||||
McastService"
|
||||
address="228.0.0.4"
|
||||
port="45564"
|
||||
frequency="500"
|
||||
dropTime="3000"/>
|
||||
|
||||
<!--
|
||||
Receiver用于各个节点接收其他节点发送的数据。
|
||||
接收器分为两种:BioReceiver(阻塞式)、NioReceiver(非阻塞式)
|
||||
|
||||
className-指定Receiver使用的类
|
||||
address-接收消息的地址
|
||||
port-接收消息的端口
|
||||
autoBind-端口的变化区间,如果port为4000,autoBind为100,
|
||||
接收器将在4000-4099间取一个端口进行监听。
|
||||
selectorTimeout-NioReceiver内Selector轮询的超时时间
|
||||
maxThreads-线程池的最大线程数
|
||||
-->
|
||||
<Receiver className="org.apache.catalina.tribes.transport.nio.
|
||||
NioReceiver"
|
||||
address="auto"
|
||||
port="4000"
|
||||
autoBind="100"
|
||||
selectorTimeout="5000"
|
||||
maxThreads="6"/>
|
||||
|
||||
<!--
|
||||
Sender用于向其他节点发送数据,Sender内嵌了Transport组件,
|
||||
Transport真正负责发送消息。
|
||||
-->
|
||||
<Sender className="org.apache.catalina.tribes.transport.
|
||||
ReplicationTransmitter">
|
||||
<!--
|
||||
Transport分为两种:bio.PooledMultiSender(阻塞式)
|
||||
和nio.PooledParallelSender(非阻塞式),PooledParallelSender
|
||||
是从tcp连接池中获取连接,可以实现并行发送,即集群中的节点可以
|
||||
同时向其他所有节点发送数据而互不影响。
|
||||
-->
|
||||
<Transport className="org.apache.catalina.tribes.
|
||||
transport.nio.PooledParallelSender"/>
|
||||
</Sender>
|
||||
|
||||
<!--
|
||||
Interceptor : Cluster的拦截器
|
||||
TcpFailureDetector-TcpFailureDetector可以拦截到某个节点关闭
|
||||
的信息,并尝试通过TCP连接到此节点,以确保此节点真正关闭,从而更新集
|
||||
群可用节点列表
|
||||
-->
|
||||
<Interceptor className="org.apache.catalina.tribes.group.
|
||||
interceptors.TcpFailureDetector"/>
|
||||
|
||||
<!--
|
||||
MessageDispatchInterceptor-查看Cluster组件发送消息的
|
||||
方式是否设置为Channel.SEND_OPTIONS_ASYNCHRONOUS,如果是,
|
||||
MessageDispatchInterceptor先将等待发送的消息进行排队,
|
||||
然后将排好队的消息转给Sender。
|
||||
-->
|
||||
<Interceptor className="org.apache.catalina.tribes.group.
|
||||
interceptors.MessageDispatchInterceptor"/>
|
||||
</Channel>
|
||||
|
||||
<!--
|
||||
Valve : Tomcat的拦截器,
|
||||
ReplicationValve-在处理请求前后打日志;过滤不涉及Session变化的请求。
|
||||
-->
|
||||
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
|
||||
filter=""/>
|
||||
<Valve className="org.apache.catalina.ha.session.
|
||||
JvmRouteBinderValve"/>
|
||||
|
||||
<!--
|
||||
Deployer用于集群的farm功能,监控应用中文件的更新,以保证集群中所有节点
|
||||
应用的一致性,如某个用户上传文件到集群中某个节点的应用程序目录下,Deployer
|
||||
会监测到这一操作并把文件拷贝到集群中其他节点相同应用的对应目录下以保持
|
||||
所有应用的一致,这是一个相当强大的功能。
|
||||
-->
|
||||
<Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"
|
||||
tempDir="/tmp/war-temp/"
|
||||
deployDir="/tmp/war-deploy/"
|
||||
watchDir="/tmp/war-listen/"
|
||||
watchEnabled="false"/>
|
||||
|
||||
<!--
|
||||
ClusterListener : 监听器,监听Cluster组件接收的消息
|
||||
使用DeltaManager时,Cluster接收的信息通过ClusterSessionListener
|
||||
传递给DeltaManager,从而更新自己的Session列表。
|
||||
-->
|
||||
<ClusterListener className="org.apache.catalina.ha.session.
|
||||
ClusterSessionListener"/>
|
||||
|
||||
</Cluster>
|
||||
|
||||
|
||||
从上面的的参数列表可以看到,默认情况下Session管理组件DeltaManager会在节点之间拷贝Session,DeltaManager采用的一种all-to-all的工作方式,即集群中的节点会把Session数据向所有其他节点拷贝,而不管其他节点是否部署了当前应用。当集群节点数比较少时,比如少于4个,这种all-to-all的方式是不错的选择;但是当集群中的节点数量比较多时,数据拷贝的开销成指数级增长,这种情况下可以考虑BackupManager,BackupManager只向一个备份节点拷贝数据。
|
||||
|
||||
在大体了解了Tomcat集群实现模型后,就可以对集群作出更优化的配置了。Tomcat推荐了一套配置,使用了比DeltaManager更高效的BackupManager,并且通过ReplicationValve设置了请求过滤。
|
||||
|
||||
这里还请注意在一台服务器部署多个节点时需要修改Receiver的侦听端口,另外为了在节点间高效地拷贝数据,所有Tomcat节点最好采用相同的配置,具体配置如下:
|
||||
|
||||
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
|
||||
channelSendOptions="6">
|
||||
|
||||
<Manager className="org.apache.catalina.ha.session.BackupManager"
|
||||
expireSessionsOnShutdown="false"
|
||||
notifyListenersOnReplication="true"
|
||||
mapSendOptions="6"/>
|
||||
|
||||
<Channel className="org.apache.catalina.tribes.group.
|
||||
GroupChannel">
|
||||
|
||||
<Membership className="org.apache.catalina.tribes.membership.
|
||||
McastService"
|
||||
address="228.0.0.4"
|
||||
port="45564"
|
||||
frequency="500"
|
||||
dropTime="3000"/>
|
||||
|
||||
<Receiver className="org.apache.catalina.tribes.transport.nio.
|
||||
NioReceiver"
|
||||
address="auto"
|
||||
port="5000"
|
||||
selectorTimeout="100"
|
||||
maxThreads="6"/>
|
||||
|
||||
<Sender className="org.apache.catalina.tribes.transport.
|
||||
ReplicationTransmitter">
|
||||
<Transport className="org.apache.catalina.tribes.transport.
|
||||
nio.PooledParallelSender"/>
|
||||
</Sender>
|
||||
|
||||
<Interceptor className="org.apache.catalina.tribes.group.
|
||||
interceptors.TcpFailureDetector"/>
|
||||
|
||||
<Interceptor className="org.apache.catalina.tribes.group.
|
||||
interceptors.MessageDispatchInterceptor"/>
|
||||
|
||||
<Interceptor className="org.apache.catalina.tribes.group.
|
||||
interceptors.ThroughputInterceptor"/>
|
||||
</Channel>
|
||||
|
||||
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
|
||||
filter=".*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\
|
||||
.htm|.*\.html|.*\.css|.*\.txt"/>
|
||||
|
||||
<Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"
|
||||
tempDir="/tmp/war-temp/"
|
||||
deployDir="/tmp/war-deploy/"
|
||||
watchDir="/tmp/war-listen/"
|
||||
watchEnabled="false"/>
|
||||
|
||||
<ClusterListener className="org.apache.catalina.ha.session.
|
||||
ClusterSessionListener"/>
|
||||
</Cluster>
|
||||
|
||||
|
||||
集群工作过程
|
||||
|
||||
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 B,Tomcat B同样知道Session 2已经超时。因此对于Tomcat集群有一点非常重要,所有节点的操作系统时间必须一致。不然会出现某个节点Session已过期而在另一节点此Session仍处于活动状态的现象。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我谈了Tomcat的集群工作原理和配置方式,还通过官网上的一个例子说明了Tomcat集群的工作过程。Tomcat集群对Session的拷贝支持两种方式:DeltaManager和BackupManager。
|
||||
|
||||
当集群中节点比较少时,可以采用DeltaManager,因为Session数据在集群中各个节点都有备份,任何一个节点崩溃都不会对整体造成影响,可靠性比较高。
|
||||
|
||||
当集群中节点数比较多时,可以采用BackupManager,这是因为一个节点的Session只会拷贝到另一个节点,数据拷贝的开销比较少,同时只要这两个节点不同时崩溃,Session数据就不会丢失。
|
||||
|
||||
课后思考
|
||||
|
||||
在Tomcat官方推荐的配置里,ReplicationValve被配置成下面这样:
|
||||
|
||||
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
|
||||
filter=".*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\
|
||||
.htm|.*\.html|.*\.css|.*\.txt"/>
|
||||
|
||||
|
||||
你是否注意到,filter的值是一些JS文件或者图片等,这是为什么呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
149
专栏/深入拆解TomcatJetty/34JVMGC原理及调优的基本思路.md
Normal file
149
专栏/深入拆解TomcatJetty/34JVMGC原理及调优的基本思路.md
Normal file
@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 JVM GC原理及调优的基本思路
|
||||
和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堆分成如下区域:
|
||||
|
||||
|
||||
|
||||
与CMS相比,G1收集器有两大特点:
|
||||
|
||||
|
||||
G1可以并发完成大部分GC的工作,这期间不会“Stop-The-World”。
|
||||
G1使用非连续空间,这使G1能够有效地处理非常大的堆。此外,G1可以同时收集年轻代和年老代。G1并没有将Java堆分成三个空间(Eden、Survivor和Old),而是将堆分成许多(通常是几百个)非常小的区域。这些区域是固定大小的(默认情况下大约为2MB)。每个区域都分配给一个空间。 G1收集器的Java堆如下图所示:
|
||||
|
||||
|
||||
|
||||
|
||||
图上的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<Greeting> objCache = new ConcurrentLinkedDeque<>();
|
||||
|
||||
@RequestMapping("/greeting")
|
||||
public Greeting greeting() {
|
||||
Greeting greeting = new Greeting("Hello World!");
|
||||
|
||||
if (objCache.size() >= 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。
|
||||
|
||||
|
||||
|
||||
4.使用GCViewer工具打开GC日志,我们可以看到这样的图:
|
||||
|
||||
|
||||
|
||||
我来解释一下这张图:
|
||||
|
||||
|
||||
图中上部的蓝线表示已使用堆的大小,我们看到它周期的上下震荡,这是我们的对象池要扩展到200000才会清空。
|
||||
图底部的绿线表示年轻代GC活动,从图上看到当堆的使用率上去了,会触发频繁的GC活动。
|
||||
图中的竖线表示Full GC,从图上看到,伴随着Full GC,蓝线会下降,这说明Full GC收集了年老代中的对象。
|
||||
|
||||
|
||||
基于上面的分析,我们可以得出一个结论,那就是Java堆的大小不够。我来解释一下为什么得出这个结论:
|
||||
|
||||
|
||||
GC活动频繁:年轻代GC(绿色线)和年老代GC(黑色线)都比较密集。这说明内存空间不够,也就是Java堆的大小不够。
|
||||
Java的堆中对象在GC之后能够被回收,说明不是内存泄漏。
|
||||
|
||||
|
||||
我们通过GCViewer还发现累计GC暂停时间有55.57秒,如下图所示:
|
||||
|
||||
|
||||
|
||||
因此我们的解决方案是调大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分析图如下:
|
||||
|
||||
|
||||
|
||||
你可以看到,没有发生Full GC,并且年轻代GC也没有那么频繁了,并且累计GC暂停时间只有3.05秒。
|
||||
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
今天我们首先回顾了CMS和G1两种垃圾收集器背后的设计思路以及它们的区别,接着分析了GC调优的总体原则。
|
||||
|
||||
对于CMS来说,我们要合理设置年轻代和年老代的大小。你可能会问该如何确定它们的大小呢?这是一个迭代的过程,可以先采用JVM的默认值,然后通过压测分析GC日志。
|
||||
|
||||
如果我们看年轻代的内存使用率处在高位,导致频繁的Minor GC,而频繁GC的效率又不高,说明对象没那么快能被回收,这时年轻代可以适当调大一点。
|
||||
|
||||
如果我们看年老代的内存使用率处在高位,导致频繁的Full GC,这样分两种情况:如果每次Full GC后年老代的内存占用率没有下来,可以怀疑是内存泄漏;如果Full GC后年老代的内存占用率下来了,说明不是内存泄漏,我们要考虑调大年老代。
|
||||
|
||||
对于G1收集器来说,我们可以适当调大Java堆,因为G1收集器采用了局部区域收集策略,单次垃圾收集的时间可控,可以管理较大的Java堆。
|
||||
|
||||
课后思考
|
||||
|
||||
如果把年轻代和年老代都设置得很大,会有什么问题?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
181
专栏/深入拆解TomcatJetty/35如何监控Tomcat的性能?.md
Normal file
181
专栏/深入拆解TomcatJetty/35如何监控Tomcat的性能?.md
Normal file
@ -0,0 +1,181 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 如何监控Tomcat的性能?
|
||||
专栏上一期我们分析了JVM GC的基本原理以及监控和分析工具,今天我们接着来聊如何监控Tomcat的各种指标,因为只有我们掌握了这些指标和信息,才能对Tomcat内部发生的事情一目了然,让我们明白系统的瓶颈在哪里,进而做出调优的决策。
|
||||
|
||||
在今天的文章里,我们首先来看看到底都需要监控Tomcat哪些关键指标,接着来具体学习如何通过JConsole来监控它们。如果系统没有暴露JMX接口,我们还可以通过命令行来查看Tomcat的性能指标。
|
||||
|
||||
Web应用的响应时间是我们关注的一个重点,最后我们通过一个实战案例,来看看Web应用的下游服务响应时间比较长的情况下,Tomcat的各项指标是什么样子的。
|
||||
|
||||
Tomcat的关键指标
|
||||
|
||||
Tomcat的关键指标有吞吐量、响应时间、错误数、线程池、CPU以及JVM内存。
|
||||
|
||||
我来简单介绍一下这些指标背后的意义。其中前三个指标是我们最关心的业务指标,Tomcat作为服务器,就是要能够又快有好地处理请求,因此吞吐量要大、响应时间要短,并且错误数要少。
|
||||
|
||||
而后面三个指标是跟系统资源有关的,当某个资源出现瓶颈就会影响前面的业务指标,比如线程池中的线程数量不足会影响吞吐量和响应时间;但是线程数太多会耗费大量CPU,也会影响吞吐量;当内存不足时会触发频繁地GC,耗费CPU,最后也会反映到业务指标上来。
|
||||
|
||||
那如何监控这些指标呢?Tomcat可以通过JMX将上述指标暴露出来的。JMX(Java Management Extensions,即Java管理扩展)是一个为应用程序、设备、系统等植入监控管理功能的框架。JMX使用管理MBean来监控业务资源,这些MBean在JMX MBean服务器上注册,代表JVM中运行的应用程序或服务。每个MBean都有一个属性列表。JMX客户端可以连接到MBean Server来读写MBean的属性值。你可以通过下面这张图来理解一下JMX的工作原理:
|
||||
|
||||
|
||||
|
||||
Tomcat定义了一系列MBean来对外暴露系统状态,接下来我们来看看如何通过JConsole来监控这些指标。
|
||||
|
||||
通过JConsole监控Tomcat
|
||||
|
||||
首先我们需要开启JMX的远程监听端口,具体来说就是设置若干JVM参数。我们可以在Tomcat的bin目录下新建一个名为setenv.sh的文件(或者setenv.bat,根据你的操作系统类型),然后输入下面的内容:
|
||||
|
||||
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote"
|
||||
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote.port=9001"
|
||||
export JAVA_OPTS="${JAVA_OPTS} -Djava.rmi.server.hostname=x.x.x.x"
|
||||
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote.ssl=false"
|
||||
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote.authenticate=false"
|
||||
|
||||
|
||||
重启Tomcat,这样JMX的监听端口9001就开启了,接下来通过JConsole来连接这个端口。
|
||||
|
||||
jconsole x.x.x.x:9001
|
||||
|
||||
|
||||
我们可以看到JConsole的主界面:
|
||||
|
||||
|
||||
|
||||
前面我提到的需要监控的关键指标有吞吐量、响应时间、错误数、线程池、CPU以及JVM内存,接下来我们就来看看怎么在JConsole上找到这些指标。
|
||||
|
||||
吞吐量、响应时间、错误数
|
||||
|
||||
在MBeans标签页下选择GlobalRequestProcessor,这里有Tomcat请求处理的统计信息。你会看到Tomcat中的各种连接器,展开“http-nio-8080”,你会看到这个连接器上的统计信息,其中maxTime表示最长的响应时间,processingTime表示平均响应时间,requestCount表示吞吐量,errorCount就是错误数。
|
||||
|
||||
|
||||
|
||||
线程池
|
||||
|
||||
选择“线程”标签页,可以看到当前Tomcat进程中有多少线程,如下图所示:
|
||||
|
||||
|
||||
|
||||
图的左下方是线程列表,右边是线程的运行栈,这些都是非常有用的信息。如果大量线程阻塞,通过观察线程栈,能看到线程阻塞在哪个函数,有可能是I/O等待,或者是死锁。
|
||||
|
||||
CPU
|
||||
|
||||
在主界面可以找到CPU使用率指标,请注意这里的CPU使用率指的是Tomcat进程占用的CPU,不是主机总的CPU使用率。
|
||||
|
||||
|
||||
|
||||
JVM内存
|
||||
|
||||
选择“内存”标签页,你能看到Tomcat进程的JVM内存使用情况。
|
||||
|
||||
|
||||
|
||||
你还可以查看JVM各内存区域的使用情况,大的层面分堆区和非堆区。堆区里有分为Eden、Survivor和Old。选择“VM Summary”标签,可以看到虚拟机内的详细信息。
|
||||
|
||||
|
||||
|
||||
命令行查看Tomcat指标
|
||||
|
||||
极端情况下如果Web应用占用过多CPU或者内存,又或者程序中发生了死锁,导致Web应用对外没有响应,监控系统上看不到数据,这个时候需要我们登陆到目标机器,通过命令行来查看各种指标。
|
||||
|
||||
1.首先我们通过ps命令找到Tomcat进程,拿到进程ID。
|
||||
|
||||
|
||||
|
||||
2.接着查看进程状态的大致信息,通过cat/proc/<pid>/status命令:
|
||||
|
||||
|
||||
|
||||
3.监控进程的CPU和内存资源使用情况:
|
||||
|
||||
|
||||
|
||||
4.查看Tomcat的网络连接,比如Tomcat在8080端口上监听连接请求,通过下面的命令查看连接列表:
|
||||
|
||||
|
||||
|
||||
你还可以分别统计处在“已连接”状态和“TIME_WAIT”状态的连接数:
|
||||
|
||||
|
||||
|
||||
5.通过ifstat来查看网络流量,大致可以看出Tomcat当前的请求数和负载状况。
|
||||
|
||||
|
||||
|
||||
实战案例
|
||||
|
||||
在这个实战案例中,我们会创建一个Web应用,根据传入的参数latency来休眠相应的秒数,目的是模拟当前的Web应用在访问下游服务时遇到的延迟。然后用JMeter来压测这个服务,通过JConsole来观察Tomcat的各项指标,分析和定位问题。
|
||||
|
||||
主要的步骤有:
|
||||
|
||||
1.创建一个Spring Boot程序,加入下面代码所示的一个RestController:
|
||||
|
||||
@RestController
|
||||
public class DownStreamLatency {
|
||||
|
||||
@RequestMapping("/greeting/latency/{seconds}")
|
||||
public Greeting greeting(@PathVariable long seconds) {
|
||||
|
||||
try {
|
||||
Thread.sleep(seconds * 1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
Greeting greeting = new Greeting("Hello World!");
|
||||
|
||||
return greeting;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从上面的代码我们看到,程序会读取URL传过来的seconds参数,先休眠相应的秒数,再返回请求。这样做的目的是,客户端压测工具能够控制服务端的延迟。
|
||||
|
||||
为了方便观察Tomcat的线程数跟延迟之间的关系,还需要加大Tomcat的最大线程数,我们可以在application.properties文件中加入这样一行:
|
||||
|
||||
server.tomcat.max-threads=1000server.tomcat.max-threads=1000
|
||||
|
||||
|
||||
2.启动JMeter开始压测,这里我们将压测的线程数设置为100:
|
||||
|
||||
|
||||
|
||||
请你注意的是,我们还需要将客户端的Timeout设置为1000毫秒,这是因为JMeter的测试线程在收到响应之前,不会发出下一次请求,这就意味我们没法按照固定的吞吐量向服务端加压。而加了Timeout以后,JMeter会有固定的吞吐量向Tomcat发送请求。
|
||||
|
||||
|
||||
|
||||
3.开启测试,这里分三个阶段,第一个阶段将服务端休眠时间设为2秒,然后暂停一段时间。第二和第三阶段分别将休眠时间设置成4秒和6秒。
|
||||
|
||||
|
||||
|
||||
4.最后我们通过JConsole来观察结果:
|
||||
|
||||
|
||||
|
||||
下面我们从线程数、内存和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应用的健康状态?遇到性能问题的时候是如何做问题定位的呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
99
专栏/深入拆解TomcatJetty/36TomcatI_O和线程池的并发调优.md
Normal file
99
专栏/深入拆解TomcatJetty/36TomcatI_O和线程池的并发调优.md
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 Tomcat I_O和线程池的并发调优
|
||||
上一期我们谈到了如何监控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线程池中有哪些关键参数:
|
||||
|
||||
|
||||
|
||||
这里面最核心的就是如何确定maxThreads的值,如果这个参数设置小了,Tomcat会发生线程饥饿,并且请求的处理会在队列中排队等待,导致响应时间变长;如果maxThreads参数值过大,同样也会有问题,因为服务器的CPU的核数有限,线程数太多会导致线程在CPU上来回切换,耗费大量的切换开销。
|
||||
|
||||
那maxThreads设置成多少才算是合适呢?为了理解清楚这个问题,我们先来看看什么是利特尔法则(Little’s 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时间是小于请求处理时间的。
|
||||
|
||||
虽然这两个公式是从不同的角度来看待问题的,但都是理想情况,都有一定的前提条件。
|
||||
|
||||
|
||||
请求处理时间越长,需要的线程数越多,但前提是CPU核数要足够,如果一个CPU来支撑10000 TPS并发,创建10000个线程,显然不合理,会造成大量线程上下文切换。
|
||||
请求处理过程中,I/O等待时间越长,需要的线程数越多,前提是CUP时间和I/O时间的比率要计算的足够准确。
|
||||
请求进来的速率越快,需要的线程数越多,前提是CPU核数也要跟上。
|
||||
|
||||
|
||||
实际场景下如何确定线程数
|
||||
|
||||
那么在实际情况下,线程池的个数如何确定呢?这是一个迭代的过程,先用上面两个公式大概算出理想的线程数,再反复压测调整,从而达到最优。
|
||||
|
||||
一般来说,如果系统的TPS要求足够大,用第一个公式算出来的线程数往往会比公式二算出来的要大。我建议选取这两个值中间更靠近公式二的值。也就是先设置一个较小的线程数,然后进行压测,当达到系统极限时(错误数增加,或者响应时间大幅增加),再逐步加大线程数,当增加到某个值,再增加线程数也无济于事,甚至TPS反而下降,那这个值可以认为是最佳线程数。
|
||||
|
||||
线程池中其他的参数,最好就用默认值,能不改就不改,除非在压测的过程发现了瓶颈。如果发现了问题就需要调整,比如maxQueueSize,如果大量任务来不及处理都堆积在maxQueueSize中,会导致内存耗尽,这个时候就需要给maxQueueSize设一个限制。当然,这是一个比较极端的情况了。
|
||||
|
||||
再比如minSpareThreads参数,默认是25个线程,如果你发现系统在闲的时候用不到25个线程,就可以调小一点;如果系统在大部分时间都比较忙,线程池中的线程总是远远多于25个,这个时候你就可以把这个参数调大一点,因为这样线程池就不需要反复地创建和销毁线程了。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我们学习了I/O调优,也就是如何选择连接器的类型,以及在选择过程中有哪些需要注意的地方。
|
||||
|
||||
后面还聊到Tomcat线程池的各种参数,其中最重要的参数是最大线程数maxThreads。理论上我们可以通过利特尔法则或者CPU时间与I/O时间的比率,计算出一个理想值,这个值只具有指导意义,因为它受到各种资源的限制,实际场景中,我们需要在理想值的基础上进行压测,来获得最佳线程数。
|
||||
|
||||
课后思考
|
||||
|
||||
其实调优很多时候都是在找系统瓶颈,假如有个状况:系统响应比较慢,但CPU的用率不高,内存有所增加,通过分析Heap Dump发现大量请求堆积在线程池的队列中,请问这种情况下应该怎么办呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
171
专栏/深入拆解TomcatJetty/37Tomcat内存溢出的原因分析及调优.md
Normal file
171
专栏/深入拆解TomcatJetty/37Tomcat内存溢出的原因分析及调优.md
Normal file
@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 Tomcat内存溢出的原因分析及调优
|
||||
作为Java程序员,我们几乎都会碰到java.lang.OutOfMemoryError异常,但是你知道有哪些原因可能导致JVM抛出OutOfMemoryError异常吗?
|
||||
|
||||
JVM在抛出java.lang.OutOfMemoryError时,除了会打印出一行描述信息,还会打印堆栈跟踪,因此我们可以通过这些信息来找到导致异常的原因。在寻找原因前,我们先来看看有哪些因素会导致OutOfMemoryError,其中内存泄漏是导致OutOfMemoryError的一个比较常见的原因,最后我们通过一个实战案例来定位内存泄漏。
|
||||
|
||||
内存溢出场景及方案
|
||||
|
||||
java.lang.OutOfMemoryError: Java heap space
|
||||
|
||||
JVM无法在堆中分配对象时,会抛出这个异常,导致这个异常的原因可能有三种:
|
||||
|
||||
|
||||
内存泄漏。Java应用程序一直持有Java对象的引用,导致对象无法被GC回收,比如对象池和内存池中的对象无法被GC回收。
|
||||
配置问题。有可能是我们通过JVM参数指定的堆大小(或者未指定的默认大小),对于应用程序来说是不够的。解决办法是通过JVM参数加大堆的大小。
|
||||
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
|
||||
|
||||
抛出这个异常的过程大概是这样的:
|
||||
|
||||
|
||||
Java程序向JVM请求创建一个新的Java线程。
|
||||
JVM本地代码(Native Code)代理该请求,通过调用操作系统API去创建一个操作系统级别的线程Native Thread。
|
||||
操作系统尝试创建一个新的Native Thread,需要同时分配一些内存给该线程,每一个Native Thread都有一个线程栈,线程栈的大小由JVM参数-Xss决定。
|
||||
由于各种原因,操作系统创建新的线程可能会失败,下面会详细谈到。
|
||||
JVM抛出“java.lang.OutOfMemoryError: Unable to create new native thread”错误。
|
||||
|
||||
|
||||
因此关键在于第四步线程创建失败,JVM就会抛出OutOfMemoryError,那具体有哪些因素会导致线程创建失败呢?
|
||||
|
||||
1.内存大小限制:我前面提到,Java创建一个线程需要消耗一定的栈空间,并通过-Xss参数指定。请你注意的是栈空间如果过小,可能会导致StackOverflowError,尤其是在递归调用的情况下;但是栈空间过大会占用过多内存,而对于一个32位Java应用来说,用户进程空间是4GB,内核占用1GB,那么用户空间就剩下3GB,因此它能创建的线程数大致可以通过这个公式算出来:
|
||||
|
||||
Max memory(3GB) = [-Xmx] + [-XX:MaxMetaSpaceSize] + number_of_threads * [-Xss]
|
||||
|
||||
|
||||
不过对于64位的应用,由于虚拟进程空间近乎无限大,因此不会因为线程栈过大而耗尽虚拟地址空间。但是请你注意,64位的Java进程能分配的最大内存数仍然受物理内存大小的限制。
|
||||
|
||||
2. ulimit限制,在Linux下执行ulimit -a,你会看到ulimit对各种资源的限制。
|
||||
|
||||
|
||||
|
||||
其中的“max user processes”就是一个进程能创建的最大线程数,我们可以修改这个参数:
|
||||
|
||||
|
||||
|
||||
3. 参数sys.kernel.threads-max限制。这个参数限制操作系统全局的线程数,通过下面的命令可以查看它的值。
|
||||
|
||||
|
||||
|
||||
这表明当前系统能创建的总的线程是63752。当然我们调整这个参数,具体办法是:
|
||||
|
||||
在/etc/sysctl.conf配置文件中,加入sys.kernel.threads-max = 999999。
|
||||
|
||||
4. 参数sys.kernel.pid_max限制,这个参数表示系统全局的PID号数值的限制,每一个线程都有ID,ID的值超过这个数,线程就会创建失败。跟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<Object> objs = new LinkedList<>();
|
||||
|
||||
@Scheduled(fixedRate = 1000)
|
||||
public void run() {
|
||||
|
||||
for (int i = 0; i < 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是程序的进程ID,2000表示每隔2秒执行一次,1000表示持续执行1000次。下面是命令的输出:
|
||||
|
||||
|
||||
|
||||
其中每一列的含义是:
|
||||
|
||||
|
||||
S0C:第一个Survivor区总的大小;
|
||||
S1C:第二个Survivor区总的大小;
|
||||
S0U:第一个Survivor区已使用内存的大小;
|
||||
S1U:第二个Survivor区已使用内存的大小。
|
||||
|
||||
|
||||
后面的列相信从名字你也能猜出是什么意思了,其中E代表Eden,O代表Old,M代表Metadata;YGC表示Minor GC的总时间,YGCT表示Minor GC的次数;FGC表示Full GC。
|
||||
|
||||
通过这个工具,你能大概看到各个内存区域的大小、已经GC的次数和所花的时间。verbosegc参数对程序的影响比较小,因此很适合在生产环境现场使用。
|
||||
|
||||
3.通过GCViewer工具查看GC日志,用GCViewer打开第一步产生的gc.log,会看到这样的图:
|
||||
|
||||
|
||||
|
||||
图中红色的线表示年老代占用的内存,你会看到它一直在增加,而黑色的竖线表示一次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文件,通过内存泄漏分析,得到这样一个分析报告:
|
||||
|
||||
|
||||
|
||||
从报告中可以看到,JVM内存中有一个长度为4000万的List,至此我们也就找到了泄漏点。
|
||||
|
||||
本期精华
|
||||
|
||||
今天我讲解了常见的OutOfMemoryError的场景以及解决办法,我们在实际工作中要根据具体的错误信息去分析背后的原因,尤其是Java堆内存不够时,需要生成Heap Dump来分析,看是不是内存泄漏;排除内存泄漏之后,我们再调整各种JVM参数,否则根本的问题原因没有解决的话,调整JVM参数也无济于事。
|
||||
|
||||
课后思考
|
||||
|
||||
请你分享一下平时在工作中遇到了什么样的OutOfMemoryError,以及你是怎么解决的。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
122
专栏/深入拆解TomcatJetty/38Tomcat拒绝连接原因分析及网络优化.md
Normal file
122
专栏/深入拆解TomcatJetty/38Tomcat拒绝连接原因分析及网络优化.md
Normal file
@ -0,0 +1,122 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 Tomcat拒绝连接原因分析及网络优化
|
||||
专栏上一期我们分析各种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包,服务端回复SYN+ACK,同时将这个处于SYN_RECV状态的连接保存到半连接队列。客户端返回ACK包完成三次握手,服务端将ESTABLISHED状态的连接移入accept队列,等待应用程序(Tomcat)调用accept方法将连接取走。这里涉及两个队列:
|
||||
|
||||
|
||||
半连接队列:保存SYN_RECV状态的连接。队列长度由net.ipv4.tcp_max_syn_backlog设置。
|
||||
accept队列:保存ESTABLISHED状态的连接。队列长度为min(net.core.somaxconn,backlog)。其中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的默认值是10000,APR默认是8192。
|
||||
|
||||
所以你会发现Tomcat的最大并发连接数等于maxConnections + acceptCount。如果acceptCount设置得过大,请求等待时间会比较长;如果acceptCount设置过小,高并发情况下,客户端会立即触发Connection reset异常。
|
||||
|
||||
Tomcat网络调优实战
|
||||
|
||||
接下来我们通过一个直观的例子来加深对上面两个参数的理解。我们先重现流量高峰时accept队列堆积的情况,这样会导致客户端触发“Connection reset”异常,然后通过调整参数解决这个问题。主要步骤有:
|
||||
|
||||
1.下载和安装压测工具JMeter。解压后打开,我们需要创建一个测试计划、一个线程组、一个请求和,如下图所示。
|
||||
|
||||
测试计划:
|
||||
|
||||
|
||||
|
||||
线程组(线程数这里设置为1000,模拟大流量):
|
||||
|
||||
|
||||
|
||||
请求(请求的路径是Tomcat自带的例子程序):
|
||||
|
||||
|
||||
|
||||
2.启动Tomcat。
|
||||
|
||||
3.开启JMeter测试,在View Results Tree中会看到大量失败的请求,请求的响应里有“Connection reset”异常,也就是前面提到的,当accept队列溢出时,服务端的内核发送了RST给客户端,使得客户端抛出了这个异常。
|
||||
|
||||
|
||||
|
||||
4.修改内核参数,在/etc/sysctl.conf中增加一行net.core.somaxconn=2048,然后执行命令sysctl -p。
|
||||
|
||||
5.修改Tomcat参数acceptCount为2048,重启Tomcat。
|
||||
|
||||
|
||||
|
||||
6.再次启动JMeter测试,这一次所有的请求会成功,也看不到异常了。我们可以通过下面的命令看到系统中ESTABLISHED的连接数增大了,这是因为我们加大了accept队列的长度。
|
||||
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
在Socket网络通信过程中,我们不可避免地会碰到各种Java异常,了解这些异常产生的原因非常关键,通过这些信息我们大概知道问题出在哪里,如果一时找不到问题代码,我们还可以通过网络抓包工具来分析数据包。
|
||||
|
||||
在这个基础上,我们还分析了Tomcat中两个比较重要的参数:acceptCount和maxConnections。acceptCount用来控制内核的TCP连接队列长度,maxConnections用于控制Tomcat层面的最大连接数。在实战环节,我们通过调整acceptCount和相关的内核参数somaxconn,增加了系统的并发度。
|
||||
|
||||
课后思考
|
||||
|
||||
在上面的实验中,我们通过netstat命令发现有大量的TCP连接处在TIME_WAIT状态,请问这是为什么?它可能会带来什么样的问题呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
134
专栏/深入拆解TomcatJetty/39Tomcat进程占用CPU过高怎么办?.md
Normal file
134
专栏/深入拆解TomcatJetty/39Tomcat进程占用CPU过高怎么办?.md
Normal file
@ -0,0 +1,134 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 Tomcat进程占用CPU过高怎么办?
|
||||
在性能优化这个主题里,前面我们聊过了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 -> 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。
|
||||
|
||||
|
||||
|
||||
4.接着我们用更精细化的top命令查看这个Java进程中各线程使用CPU的情况:
|
||||
|
||||
#top -H -p 4361
|
||||
|
||||
|
||||
|
||||
|
||||
从图上我们可以看到,有个叫“scheduling-1”的线程占用了较多的CPU,达到了42.5%。因此下一步我们要找出这个线程在做什么事情。
|
||||
|
||||
5.为了找出线程在做什么事情,我们需要用jstack命令生成线程快照,具体方法是:
|
||||
|
||||
jstack 4361
|
||||
|
||||
|
||||
jstack的输出比较大,你可以将输出写入文件:
|
||||
|
||||
jstack 4361 > 4361.log
|
||||
|
||||
|
||||
然后我们打开4361.log,定位到第4步中找到的名为“scheduling-1”的线程,发现它的线程栈如下:
|
||||
|
||||
|
||||
|
||||
从线程栈中我们看到了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的输出结果,主要是看这些线程池中的线程是不是真的在干活,还是在“休息”呢?
|
||||
|
||||
|
||||
|
||||
通过上面的图我们发现这些“pool-1-thread-x”线程基本都处于WAITING的状态,那什么是WAITING状态呢?或者说Java线程都有哪些状态呢?你可以通过下面的图来理解一下:
|
||||
|
||||
|
||||
|
||||
从图上我们看到“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,正好跟线程池中的线程数相等。
|
||||
|
||||
|
||||
|
||||
你可能好奇了,那剩下的220%的CPU到底被谁消耗了呢?分析到这里,我们应该怀疑CPU的上下文切换开销了,因为我们看到Java进程中的线程数比较多。下面我们通过vmstat命令来查看一下操作系统层面的线程上下文切换活动:
|
||||
|
||||
|
||||
|
||||
如果你还不太熟悉vmstat,可以在这里学习如何使用vmstat和查看结果。其中cs那一栏表示线程上下文切换次数,in表示CPU中断次数,我们发现这两个数字非常高,基本证实了我们的猜测,线程上下文切切换消耗了大量CPU。那么问题来了,具体是哪个进程导致的呢?
|
||||
|
||||
我们停止Spring Boot测试程序,再次运行vmstat命令,会看到in和cs都大幅下降了,这样就证实了引起线程上下文切换开销的Java进程正是4361。
|
||||
|
||||
|
||||
|
||||
本期精华
|
||||
|
||||
当我们遇到CPU过高的问题时,首先要定位是哪个进程的导致的,之后可以通过top -H -p pid命令定位到具体的线程。其次还要通jstack查看线程的状态,看看线程的个数或者线程的状态,如果线程数过多,可以怀疑是线程上下文切换的开销,我们可以通过vmstat和pidstat这两个工具进行确认。
|
||||
|
||||
课后思考
|
||||
|
||||
哪些情况可能导致程序中的线程数失控,产生大量线程呢?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
182
专栏/深入拆解TomcatJetty/40谈谈Jetty性能调优的思路.md
Normal file
182
专栏/深入拆解TomcatJetty/40谈谈Jetty性能调优的思路.md
Normal file
@ -0,0 +1,182 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 谈谈Jetty性能调优的思路
|
||||
关于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将很快拒绝请求,无法处理正常的高峰负载,以下是配置示例:
|
||||
|
||||
<Configure id="Server" class="org.eclipse.jetty.server.Server">
|
||||
<Set name="ThreadPool">
|
||||
<New class="org.eclipse.jetty.util.thread.QueuedThreadPool">
|
||||
<!-- specify a bounded queue -->
|
||||
<Arg>
|
||||
<New class="java.util.concurrent.ArrayBlockingQueue">
|
||||
<Arg type="int">6000</Arg>
|
||||
</New>
|
||||
</Arg>
|
||||
<Set name="minThreads">10</Set>
|
||||
<Set name="maxThreads">200</Set>
|
||||
<Set name="detailedDump">false</Set>
|
||||
</New>
|
||||
</Set>
|
||||
</Configure>
|
||||
|
||||
|
||||
那如何配置Jetty的线程池中的线程数呢?跟Tomcat一样,你可以根据实际压测,如果I/O越密集,线程阻塞越严重,那么线程数就可以配置多一些。通常情况,增加线程数需要更多的内存,因此内存的最大值也要跟着调整,所以一般来说,Jetty的最大线程数应该在50到500之间。
|
||||
|
||||
Jetty性能测试
|
||||
|
||||
接下来我们通过一个实验来测试一下Jetty的性能。我们可以在这里下载Jetty的JAR包。
|
||||
|
||||
|
||||
|
||||
第二步我们创建一个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("text/html; charset=utf-8");
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.getWriter().println("<h1>Hello World</h1>");
|
||||
baseRequest.setHandled(true);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
//根据传入的参数控制线程池中最大线程数的大小
|
||||
int maxThreads = Integer.parseInt(args[0]);
|
||||
System.out.println("maxThreads:" + 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的使用,你可以参考这里。
|
||||
|
||||
ab -n 200000 -c 100 http://localhost:8000/
|
||||
|
||||
|
||||
上面命令的意思是向Jetty server发出20万个请求,开启100个线程同时发送。
|
||||
|
||||
经过多次压测,测试结果稳定以后,在Linux 4核机器上得到的结果是这样的:
|
||||
|
||||
|
||||
|
||||
从上面的测试结果我们可以看到,20万个请求在9.99秒内处理完成,RPS达到了20020。 不知道你是否好奇,为什么我把最大线程数设置为4呢?是不是有点小?
|
||||
|
||||
别着急,接下来我们就试着逐步加大最大线程数,直到找到最佳值。下面这个表格显示了在其他条件不变的情况下,只调整线程数对RPS的影响。
|
||||
|
||||
|
||||
|
||||
我们发现一个有意思的现象,线程数从4增加到6,RPS确实增加了。但是线程数从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时最佳,这是不是矛盾了?
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
80
专栏/深入拆解TomcatJetty/41热点问题答疑(4):Tomcat和Jetty有哪些不同?.md
Normal file
80
专栏/深入拆解TomcatJetty/41热点问题答疑(4):Tomcat和Jetty有哪些不同?.md
Normal file
@ -0,0 +1,80 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 热点问题答疑(4): Tomcat和Jetty有哪些不同?
|
||||
作为专栏最后一个模块的答疑文章,我想是时候总结一下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期中的Spring Boot应用程序。首先用Spring Boot默认的Tomcat作为内嵌式Web容器,经过一轮压测后,将内嵌式的Web容器换成Jetty,再做一轮测试,然后比较结果。为了方便观察各种指标,我在本地开发机器上做这个实验。
|
||||
|
||||
我们会在每个请求的处理过程中休眠1秒,适当地模拟Web应用的I/O等待时间。JMeter客户端的线程数为100,压测持续10分钟。在JMeter中创建一个Summary Report,在这个页面上,可以看到各种统计指标。
|
||||
|
||||
|
||||
|
||||
第一步,压测Tomcat。启动Spring Boot程序和JMeter,持续10分钟,以下是测试结果,结果分为两部分:
|
||||
|
||||
吞吐量、延迟和错误率
|
||||
|
||||
|
||||
|
||||
资源使用情况
|
||||
|
||||
|
||||
|
||||
第二步,我们将Spring Boot的Web容器替换成Jetty,具体步骤是在pom.xml文件中的spring-boot-starter-web依赖修改下面这样:
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-tomcat</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jetty</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
编译打包,启动Spring Boot,再启动JMeter压测,以下是测试结果:
|
||||
|
||||
吞吐量、延迟和错误率
|
||||
|
||||
|
||||
|
||||
资源使用情况
|
||||
|
||||
|
||||
|
||||
下面我们通过一个表格来对比Tomcat和Jetty:
|
||||
|
||||
|
||||
|
||||
从表格中的数据我们可以看到:
|
||||
|
||||
|
||||
Jetty在吞吐量和响应速度方面稍有优势,并且Jetty消耗的线程和内存资源明显比Tomcat要少,这也恰好说明了Jetty在设计上更加小巧和轻量级的特点。
|
||||
但是Jetty有2.45%的错误率,而Tomcat没有任何错误,并且我经过多次测试都是这个结果。因此我们可以认为Tomcat比Jetty更加成熟和稳定。
|
||||
|
||||
|
||||
当然由于测试场景的限制,以上数据并不能完全反映Tomcat和Jetty的真实能力。但是它可以在我们做选型的时候提供一些参考:如果系统的目标是资源消耗尽量少,并且对稳定性要求没有那么高,可以选择轻量级的Jetty;如果你的系统是比较关键的企业级应用,建议还是选择Tomcat比较稳妥。
|
||||
|
||||
最后用一句话总结Tomcat和Jetty的区别:Tomcat好比是一位工作多年比较成熟的工程师,轻易不会出错、不会掉链子,但是他有自己的想法,不会轻易做出改变。而Jetty更像是一位年轻的后起之秀,脑子转得很快,可塑性也很强,但有时候也会犯一点小错误。
|
||||
|
||||
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。
|
||||
|
||||
|
||||
|
||||
|
35
专栏/深入拆解TomcatJetty/特别放送如何持续保持对学习的兴趣?.md
Normal file
35
专栏/深入拆解TomcatJetty/特别放送如何持续保持对学习的兴趣?.md
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
特别放送 如何持续保持对学习的兴趣?
|
||||
你好,我是李号双。今天我们抛开技术本身的内容,来聊聊专栏或者一门新技术的学习方法,我也分享一下自己是如何啃下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内核的相关参数等。
|
||||
|
||||
除此之外,调优的过程还需要借助大量的工具,包括性能监控工具、日志分析工具、网络抓包工具和流量压测工具等,熟练使用这些工具也是每一个后端程序员必须掌握的看家本领,因此在实战环节,我也设计了一些场景来带你熟悉这些工具。
|
||||
|
||||
说了那么多,就是希望你保持对学习的热情,树立明确的目标,再加上亲自动手实践。专栏学习到现在这个阶段,是时候开始动手实践了,希望你每天都能积累一点,每天都能有所进步。
|
||||
|
||||
最后欢迎你在留言区分享一下你学习一门新技术的方法和心得,与我和其他同学一起讨论。
|
||||
|
||||
|
||||
|
||||
|
35
专栏/深入拆解TomcatJetty/结束语静下心来,品味经典.md
Normal file
35
专栏/深入拆解TomcatJetty/结束语静下心来,品味经典.md
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 静下心来,品味经典
|
||||
从专栏上线发布到现在,不知不觉三个月时间过去了,感谢你的一路陪伴,今天到了说再见的时候,我想简单回顾一下专栏的内容,并且聊聊我的一些感受。
|
||||
|
||||
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 API,JVM和操作系统在背后为我们做了什么呢?挖得更深一点,我们对程序的理解也就更深刻,也许就是因为深入的这一小步,能够让我们在竞争中脱颖而出。
|
||||
|
||||
不知不觉,我从Tomcat和Jetty的学习谈到了如何优雅地设计一个复杂的系统。由点及面,你可以把Tomcat和Jetty当作一个支点,从我们身边“熟悉又陌生”的Tomcat和Jetty入手,不光掌握它们的使用,更能从它们的源码中汲取经验,提升自己的系统设计能力。学习这件事千万不能浮躁,很难做到一口吃成大胖子,最重要的是需要静下心慢慢体会和思考。我看到不少同学的留言,从提问的内容我能感受到你们的好奇心和思考,有些问题我也还要去查阅源码才能回答上来,在这个过程中我自己也主动或被动的学到不少东西,所以说多和同行们交流也非常有必要。
|
||||
|
||||
学习永远在路上,最后祝我们一起进步!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user