Tomcat内核设计剖析

978-7-115-45130-9
作者: 汪建
译者:
编辑: 傅道坤

图书目录:

详情

本书是深入剖析Tomcat Web服务器运行机制的权威图书,总共分为3个部分,第1部分是基础知识,讲解了各种协议的工作方式,为读者深入学习Tomcat的工作机制打下基础;第2部分步入正题,讲解了Tomcat的运行机制;第3部分讲解了Tomcat中一些核心组件的工作方式。

图书摘要

版权信息

书名:Tomcat内核设计剖析

ISBN:978-7-115-45130-9

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    汪 建

  责任编辑  傅道坤

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Tomcat是一款免费的开源应用服务器,因其性能稳定、体积小巧、扩展性好等特点而被传统和互联网行业广泛应用。

本书是深入剖析Tomcat Web服务器运行机制的权威图书,共分为22章。本书从Web服务器相关的基础知识及原理开始逐渐深入Tomcat内部设计,比如涵盖了HTTP协议、Socket通信及服务器模型等必备的基础知识。另外还包括Servlet规范,这些都是深入Tomcat必不可少的知识。然后介绍了Tomcat的启动与关闭过程,接着从整体预览Tomcat的内部结构,让读者对Tomcat内部有个整体的了解。最后开始层层剖析Tomcat内部结构,包括Server组件,Service组件,内存泄漏检测,Connector组件(HTTP协议、AJP协议、BIO模式、NIO模式和APR模式),Engine容器,Host容器,Context容器,Wrapper容器(Servlet种类机制、Comet模式、WebSocket协议、异步Servlet),生命周期管理,日志框架及其国际化(日志系统、日志国际化及访问日志),公共与隔离的加载器(多个Web应用如何做到资源隔离),Mapper组件(局部路由、全局路由),Tomcat集成JNDI,JSP编译器(JSP语法解析、JSP编译成Servlet、Servlet编译成Class),运行及通信的安全管理,处理请求和响应的管道(管道机制),多样化的会话管理器(标准会话管理器、持久化会话管理器、集群增量会话管理器及集群备份管理器),高可用的Tomcat集群的实现(从单机到集群),Tomcat集群通信框架,Tomcat内部监控与管理。

本书适用于想深入了解Web服务器原理、想知道在浏览器上点击某个按钮后发生的事情、想了解Tomcat内部工作原理、想基于Tomcat做二次开发的人员。


Tomcat作为一款免费的开源应用服务器,凭借技术先进、性能稳定、体积小巧、扩展性好等优势,深受开发者和软件开发商认可。鉴于Tomcat是一款较轻量级的应用服务器,它广泛使用在中小型系统中,并且是一个很流行的Web服务器。那么,如此优秀的Tomcat是怎样创造出来的呢?它的架构是怎样的呢?内部到底又是怎样运作的呢?需要哪些技术来支撑呢?有很多疑问都需要我们去研究和探索,作者试图在本书中阐明Tomcat内部的秘密。

虽然Tomcat已经广泛使用了很长时间,市面上也有很多相关图书,但多数关于Tomcat的图书基本都停留在如何使用Tomcat、如何在Tomcat服务器上进行Web应用开发等方面。本书将从Web服务器基础知识开始讲起,循序渐进,让读者不仅能了解Tomcat内核的设计,还能掌握Web服务器的原理,体会到一个工业级的Web服务器是如何设计的。本书可以帮助读者快速建立Tomcat的内部运作模型。

重复发明轮子不是我们提倡的,本书并不鼓励读者重复开发轮子,而是鼓励大家去研究开源软件,学习其中的优秀架构,从中借签优秀的设计理念,看看这些优秀开源产品的过人之处,从而提高自己的软件素养。

本书具备如下特点。

本书旨在剖析Tomcat的内核设计及其原理,全书共分为22章,主要内容如下。

在本书交稿时,我仍在担心本书是否遗漏了某些知识点,其中的内容是否翔实齐备,是否能让读者有更多收获,是否会因为自己理解的偏差而误导读者。由于写作水平和写作时间所限,本书中难免存在谬误,恳请读者评判指正。

读者可将任何意见及建议发送到邮箱wyzz8888@foxmail.com,本书相关的勘误也会发布到我的个人博客blog.csdn.net/wangyangzhizhou上。欢迎读者通过邮件或博客与我交流。


汪建,毕业于广东工业大学光信息科学与技术专业,毕业后从事航空系统、电信系统、中间件、基础架构、智能客服等研发工作,目前主要关注分布式、高并发、大数据、搜索引擎、机器学习等方面的技术。崇尚开源,崇尚技术自由,更崇尚思想自由。个人博客地址为blog.csdn.net/wangyangzhizhou。


首先,感谢读者,你的阅读让本书更加有价值。

其次,感谢在本书编写过程中帮助过我的人,感谢公司提供的平台让我得到了很多学习和成长的机会,还要感谢人民邮电出版社的傅道坤编辑,根据他的建议我对本书内容进行了多处改进,使内容更加丰富,结构更加清晰。

最后,感谢一直鼓励我、支持我的家人,特别是我的爱妻,挺着身孕仍然孜孜不倦地帮我审稿,你们让我的世界更丰富多彩。同时也将本书献给我即将出生的孩子。


所有的Web服务器都根据规定好的协议机制进行不同的实现及扩展。有的Web服务器只能处理静态资源,而有的可以完成动态处理。有的Web服务器用C++语言实现,而有的用Java语言实现。但不管Web服务器具体如何实现及扩展,它都必须要遵循基本的协议规定。在深入研究Tomcat之前很有必要先了解Web服务器的一些机制。

本章分别从通信协议、Socket通信、Web服务器模型三方面对Web服务器机制进行介绍。

HTTP是Hyper Text Transfer Protocol(超文本传输协议)的缩写。HTTP协议是用于从Web服务器传输超文本到本地浏览器的协议,它能使浏览器更加高效,使网络传输减少,保证计算机正确快速地传输超文本文档。现在我们普遍使用的版本是HTTP1.1。

HTTP是一个应用层协议,它由请求和响应组成,是一个标准的B/S模型。同时,它也是一个无状态的协议,即同一个客户端上,此次请求与上一次请求是没有对应关系的。

而HTTPS简单地说就是HTTP的安全版。通常,在安全性要求比较高的网站(例如银行网站)上会看到HTTPS,它本质上也是HTTP协议,只是在HTTP增加了一个SSL或TLS协议层。如图1.1所示,如果在TCP协议上加一层SSL或TLS协议,就构成HTTPS协议了。SSL/TLS协议提供了加解密的机制,所以它比HTTP明文传输更安全。从图1.1中可以看出,HTTP可以直接进入TCP传输层,也可以在TCP层上加一层SSL/TLS层,这样就先经过SSL/TLS再进入TCP传输层。这两种方式便是HTTP与HTTPS。一般HTTP的端口号为80,而HTTPS的端口号为443。

简单地说,SSL/TLS协议层主要的职责就是借助下层协议的信道安全地协商出一份加密密钥,并且用此密钥来加密HTTP请求响应报文。它解决了以下三个安全性方面的议题。

▲图1.1 HTTP与HTTPS

HTTPS运用越来越广泛,而且在安全场景中它是一个很好的解决方案,一般作为解决安全传输的首选解决方案。下面深入了解一下HTTPS的工作原理及流程。

在理解HTTPS工作原理前,先了解一些加密解密算法与Hash算法。

图1.2详细描述了HTTPS完成一次通信要做哪些事情。因为HTTPS是基于TCP/IP协议通信的,属于可靠传输,所以它必须要先进行三次握手,完成连接的建立。接着是SSL的握手协议,此协议非常有效地让客户和服务器之间完成相互之间的身份验证及密钥协商。

▲图1.2 HTTPS的工作原理及流程

① 客户端浏览器向服务器发送SSL/TLS协议的版本号、加密算法的种类、产生的随机数,以及其他需要的各种信息。

② 服务器从客户端支持的加密算法中选择一组加密算法与Hash算法,并且把自己的证书(包含网站地址、加密公钥、证书颁发机构等)也发送给客户端。

③ 浏览器获取服务器证书后验证其合法性,验证颁发机构是否合法,验证证书中的网址是否与正在访问的地址一致,通过验证的浏览器会显示一个小锁头,否则,提示证书不受信。

④ 客户端浏览器生成一串随机数并用服务器传来的公钥加密,再使用约定好的Hash算法计算握手消息,发送到服务器端。

⑤ 服务器接到握手消息后用自己的私钥解密,并用散列算法验证,这样双方都有了此次通信的密钥。

⑥ 服务器再使用密钥加密一段握手消息,返回给客户端浏览器。

⑦ 浏览器用密钥解密,并用散列算法验证,确定算法与密钥。

完成以上7步后双方就可以利用此次协商好的密钥进行通信。

从某种意义上来说,HTTP协议永远都由客户端发起请求,由服务器进行响应并发送回响应报文。如果没有客户端进行请求或曾经请求过,那么服务器是无法将消息推送到客户端的。HTTP采用了请求/响应模型,一个HTTP请求与响应一般如图1.3所示,客户端向服务器发送一个请求,请求头包含请求方法、URI、协议版本、请求修饰符、客户信息,以及类似于MIME结构的消息内容。服务器以一个状态行作为响应,内容包括消息协议版本、成功(或失败)编码、服务器信息、实体元信息及一些实体内容。这样就完成了一个请求/响应过程。

▲图1.3 HTTP请求/响应模型

通常,一个HTTP请求/响应的工作流程大概可以用以下4步来概括。

① 客户端浏览器先要与服务器建立连接,即通过三次握手建立连接。在浏览器上最常见的场景就是单击一个链接,这就触发了连接的建立。

② 连接建立后,客户端浏览器发送一个请求到服务器,这个过程其实是组装请求报文的过程,详细的报文格式与解析会在下一节介绍。

③ 服务器端接收到请求报文后,对报文进行解析,组装成一定格式的响应报文,返回给客户端。

④ 客户端浏览器接收到响应报文后,通过浏览器内核对其进行解析,按照一定的外观进行显示,然后与服务器断开连接。

上一节介绍了HTTP请求/响应模型,那么具体请求与响应报文格式是怎样的?报文又是怎样解析的?本节将论述HTTP报文解析的整体格式。要深入理解Web服务器就必须对HTTP协议报文有所了解。HTTP报文是面向文本的,报文中每个字段都是一些ASCII码串,它包括请求报文和响应报文。

首先看看HTTP请求报文。一个HTTP请求由三部分组成:请求行、请求头部、请求体。图1.4详细展示了一个HTTP请求报文的结构。请求行(request line)由请求方法字段、URL字段和HTTP协议版本字段组成,它们用空格分隔并以“\r\n”结尾。请求头部(request header)包含若干个属性与属性值,它们通过冒号分隔,格式为“属性名:属性值”,每个属性-属性值对以“\r\n”结尾,整个请求头部又以“\r\n”结尾。请求体(request body)一般在POST方法里使用,而不在GET方法中使用,例如浏览器将表单中的组件格式化成param1=value1&param2=value2键值对组,然后将其存放至请求体中,以此完成对表单参数的传输。

▲图1.4 HTTP请求报文

GET和POST是最常见的请求方法,除此之外,还包括DELETE、HEAD、OPTIONS、PUT、TRACE。当我们单击网页链接或在浏览器输入网址访问时,就使用了GET方法,请求参数和值附加在URL后面,用问号隔开,如/index.jsp?id=10000。用GET方法传递的参数都能在地址栏上看到,大多浏览器对地址的字符长度做了限制,最多是1024个字符,所以要传送大量数据,就要选择用POST方法。POST方法允许客户端提交更多信息给服务器,它把请求参数封装到请求体中,可以传输大量数据,不会对数据大小进行限制,同时也不在地址栏显示参数。其他请求方法不再展开讨论,感兴趣的读者可查阅相关资料。

请求头部常见的典型属性有以下几种。

接着看HTTP响应报文。与请求报文一样,响应报文由三部分组成:响应行、响应头部、响应体(如图1.5所示)。响应行(response line)包含协议及版本、状态码及描述,并以“\r\n”结尾。响应头部(response header)包含若干个属性与属性值,它们通过冒号分隔,格式为“属性名:属性值”,每个属性-键值对都以“\r\n”结尾,并且响应头部最后以“\r\n”结尾。响应体(response body)一般存放我们真正需要的文本。

▲图1.5 HTTP响应报文

响应状态码由三位数字组成,常用的状态码如下。

常用的响应报文头属性如下。

套接字通信是应用层与TCP/IP协议族通信的中间抽象层,它是一组接口。应用层通过调用这些接口发送和接收数据。一般这种抽象层由操作系统提供或者由JVM自己实现。使用套接字通信可以简单地实现应用程序在网络上的通信。一台机器上的应用向套接字中写入信息,另外一台相连的机器能读取到。TCP/IP协议族中有两种套接字类型,分别是流套接字和数据报套接字,分别对应TCP协议和UDP协议。一个TCP/IP套接字由一个互联网地址、一个协议及一个端口号唯一确定。

如图1.6所示,套接字抽象层位于传输层与应用层之间。增加这一层不但很有必要而且很有用。它类似于设计模式中的门面模式,用户没必要知道和处理复杂的TCP/IP协议族业务逻辑的细节,这时套接字就展现出它的优势了。它把这些复杂的处理过程都隐藏在套接字接口下面,帮助用户解析组织TCP/IP协议族报文数据,以符合TCP/IP协议族,这样用户只要简单调用接口即可实现数据的通信操作。

▲图1.6 套接字通信

单播通信是网络节点之间通信方式的一种。单个网络节点与单个网络节点之间的通信就称为单播通信。它是一种一对一的模式,发送、接收信息只在两者之间进行,同时它也是最常见的一种通信。如图1.7所示,你浏览网页访问服务器时发生的通信属于单播通信,报文的发送与接收发生在你的电脑与网站的服务器之间。

▲图1.7 单播通信

Java提供了JDK库,能方便实现单播通信。

在服务器端实现单播通信的代码如下。

Public class SocketServer { 
    public static void main(String[] args) {
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(8888);
            Socket socket = serverSocket.accept();
            DataOutputStream dos = new DataOutputStream(socket
                    .getOutputStream());
            DataInputStream dis = new DataInputStream(socket.getInputStream());
            System.out.println("服务器接收到客户端的连接请求:" + dis.readUTF());
            dos.writeUTF("接受连接请求,连接成功!");
            socket.close();
            serverSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

首先,绑定本地8888端口,然后调用accept()方法进行阻塞,等待客户端的连接,一旦有连接到来就创建一个套接字并返回。接着,获取输入/输出流,输入流用于获取客户端传输的数据,而输出流则用来向客户端响应发送数据,处理完后关闭套接字。为了简化代码,这里完成一次响应后便把ServerSocket关闭。

在客户端实现单播通信的代码如下。

public class SocketClient {
public static void main(String[] args) {
          Socket socket = null;
try {
          socket = new Socket("localhost",8888);
          DataOutputStream dos = new DataOutputStream(socket
                    .getOutputStream());
          DataInputStream dis = new DataInputStream(socket.getInputStream());
          dos.writeUTF("我是客户端,请求连接!");
          System.out.println(dis.readUTF());
          socket.close();
       } catch (UnknownHostException e) {
          e.printStackTrace();        
} catch (IOException e) {
          e.printStackTrace();
       }
    }
}

服务器端的8888端口已经处于监听状态,客户端如果要与之通信,只须简单地先指定服务器端IP与端口号以实例化一个套接字,然后获取套接字的输出流与输入流。输出流用于向服务器发送数据,输入流用于读取服务器发送过来的数据。交互处理完后关闭套接字。

组播通信是为了优化单播通信某些场景下的不足。例如,一份数据要从某台主机发送到其余若干台主机上,这时如果还是使用单播通信模式,数据必须依次发送给其他若干台主机。单播通信的一个特点就是有多少台主机就要发送多少次,当主机的数量越来越大时可能会导致网络阻塞。此外,这种传送方式效率极低。于是引入了组播通信的概念。

如图1.8所示,(a)图为单播通信模式,机器S1向机器S2、S3和S4发送消息时必须发送三次,且每次都是从S1出发到各自目的地,传输效率低且浪费网络资源;(b)图为组播通信模式,S1向S2、S3和S4发送消息只须S1发送一次到路由器,连接S2、S3、S4客户端的路由器将负责向它们发送消息,解决了传输效率低及浪费网络资源的问题。

▲图1.8 单播模式与组播模式

所以组播通信其实是为了弥补单播通信在某些使用场景的局限性,它是一种一对多的传播方式。假如某个主机结点想接收相关的信息,它只需要向路由器或交换机申请加入某组即可,路由器或交换机在接收到相关信息后就会负责向组内所有成员发送信息。组播通信有以下特点:

组播通信中最重要的内容是如何维护路由器与主机之间的关系,其主要通过IGMP协议进行维护。它主要维护不同路由器与不同主机之间的成员关系,具体的维护方式比较复杂,因为涉及多个路由器且路由之间互相连接组成一个树状网络,而组内成员可能处于任何一个路由中,即树的任何叶结点,所以需要复杂的算法去维护这些关系才知道信息要往哪里发送。IGMP协议主要负责组成员的加入和退出、组内成员查询等功能,使用组播通信需要通过IGMP协议申请加入组成员才能接收组播的消息,而退出组后将无法接收消息。

因为组播通信相当于把主机与主机之间的通信压力转嫁到了路由器上面,所以要得到路由及网络的支持才能进行组播通信,整个传输过程中涉及的路由器或交换机都要支持组播通信,否则将无法使用组播通信。另外,你的主机必须支持组播通信,在TCP/IP层面支持组播发送与接收。

在IP层面需要一个组播地址以指定组播,它称为D类地址,范围是224.0.0.0~239.255.255.255。这些地址根据范围大致分为局域网地址和因特网地址,224.0.0.0~244.0.0.255用于局域网,224.0.1.0~238.255.255.255用于因特网。Tomcat默认的组播地址为228.0.0.4,而Tomcat为何会涉及组播通信则要归到集群的概念,因为集群涉及内存的共享问题,所以需要使用组播通信进行数据同步,第20章和第21章将进行更加深入的探讨。

在单播通信模式中有服务器端和客户端之分,而组播通信模式与单播通信模式不同,每个端都是以路由器或交换机作为中转广播站,任意一端向路由器或交换机发送消息,路由器或交换机负责发送给其他节点,每个节点都是等同的。

为方便开发者实现组播通信,Java在JDK中提供了java.net.MulticastSocket类。下面展示一个简单的例子,说明两个节点之间通过组播通信传输消息。

① 节点1,指定组播地址为228.0.0.4,端口号为8000。节点1通过调用MulticastSocket的JoinGroup方法申请将节点1加入到组播队伍中,接着使用一个无限循环往组里发“Hello from node1”消息,这是为了方便节点2加入后接收节点1的消息。需要说明的是,组播通信是通过DatagramPacket对象发送消息的,调用MulticastSocket的Send方法即可把消息发送出去。为了缩减例子长度,这里省去了退出组及关闭套接字的一些操作,实际使用中须完善。

public class Node1{
    private static int port = 8000;
    private static String address = "228.0.0.4";
    public static void main(String[] args) throws Exception {
        try {
            InetAddress group = InetAddress.getByName(address); 
            MulticastSocket mss = null;
            mss = new MulticastSocket(port);
            mss.joinGroup(group);
            while (true) {
                String message = "Hello from node1";
                byte[] buffer = message.getBytes(); 
                DatagramPacket dp = new DatagramPacket(buffer, buffer.length,
                        group, port);
                mss.send(dp); 
                Thread.sleep(1000);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } 
    }
}

② 节点2,指定同样的组播地址与端口,以申请加入与节点1相同的组播组。接着通过循环不断接收从其他节点发送的消息,通过MulticastSocket的Receive方法可读取消息,将不断接收到从节点1发送的消息“receive from node1:Hello from node1”。当然,节点2也可以向组播组发送消息,因为每个节点都是等同的,只要其他节点对组播消息进行接收。如果你还想增加其他节点,尽管申请加入组播组,所有节点都可以接收、发送消息。

public class Node2 {
    private static int port = 8000;
    private static String address = "228.0.0.4";
    public static void main(String[] args) throws Exception {
        InetAddress group = InetAddress.getByName(address); 
        MulticastSocket msr = null;
        try {
            msr = new MulticastSocket(port);
            msr.joinGroup(group);
            byte[] buffer = new byte[1024];
            while (true) {
                DatagramPacket dp = new DatagramPacket(buffer, buffer.length); 
                msr.receive(dp); 
                String s = new String(dp.getData(), 0, dp.getLength()); 
                System.out.println("receive from node1:"+s);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } 
    }
}

上一节说到的组播通信是一种一对多的传播方式,同样属于一对多的传播方式的还有广播通信。它与组播通信又有不同的地方。广播通信的重点在于广,它向路由器连接的所有主机都发送消息而不管主机想不想要,虽然浪费了网络资源,但它可以不用维护路由器与主机之间的成员关系。组播通信的重点在于组,它只会向加入了组的所有成员发送消息,具有针对性强、不浪费网络资源的特点。广播通信只能在局域网内传播,组播通信能在公网内传播。

如图1.9所示,在某局域网内,机器S1向网络中广播消息,网络中其他机器都将接收到消息。机器S2、S3、S4、S5和S6预先启动进程监听端口,S1将消息发往交换机,交换机负责将消息广播到这些机器上。

▲图1.9 广播通信

Java的JDK为我们提供了java.net.DatagramSocket类以实现广播通信功能。

在接收端,监听8888端口,一旦接收到广播消息则输出消息。

public class BroadCastReceiver {
    public static void main(String[] args) {
        try {
            DatagramSocket ds = new DatagramSocket(8888);
            byte[] buf = new byte[5];
            DatagramPacket dp = new DatagramPacket(buf, buf.length);
            ds.receive(dp);
            System.out.println(new String(buf));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在发送端,所属的网段为192.168.0,子网掩码为255.255.255.0,所以广播地址为192.168.0.255,然后往该网络中所有机器的8888端口发送“hello”消息,接收端将接收到此消息。

public class BroadCastSender {
    public static void main(String[] args) {
        try {
            InetAddress ip = InetAddress.getByName("192.168.0.255");
            DatagramSocket ds = new DatagramSocket();
            String str = "hello";
            DatagramPacket dp = new DatagramPacket(str.getBytes(),
                    str.getBytes().length, ip, 8888);
            ds.send(dp);
            ds.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这里探讨的服务器模型主要指的是服务器端对I/O的处理模型。从不同维度可以有不同的分类,本节将从I/O的阻塞与非阻塞、I/O处理的单线程与多线程角度探讨服务器模型。对于I/O,可以分成阻塞I/O与非阻塞I/O两大类型。阻塞I/O在做I/O读写操作时会使当前线程进入阻塞状态,而非阻塞I/O则不进入阻塞状态。对于线程,单线程情况下由一条线程负责所有客户端连接的I/O操作,而多线程情况下则由若干线程共同处理所有客户端连接的I/O操作。下面将对线程和(非)阻塞组合成的模型进行分析,看看各种服务器模型有哪些不同,各自的优缺点又有哪些。

单线程阻塞I/O模型是最简单的一种服务器模型,几乎所有程序员在刚开始接触网络编程时都从这个简单的模型开始。这种模型只能同时处理一个客户端访问,并且在I/O操作上是阻塞的,线程会一直在等待,而不会做其他事情。对于多个客户端访问,必须要等到前一个客户端访问结束才能进行下一个访问的处理,请求一个一个排队,只提供一问一答服务。

图1.10展示了同步阻塞服务器响应客户端访问的时间节点图。首先,服务器必须初始化一个套接字服务器,并绑定某个端口号并使之监听客户端的访问。接着,客户端1调用服务器的服务,服务器接收到请求后对其进行处理,处理完后写数据回客户端1,整个过程都是在一个线程里面完成的。最后,处理客户端2的请求并写数据回客户端2,期间就算客户端2在服务器处理完客户端1之前就进行请求,也要等服务器对客户端1响应完后才会对客户端2进行响应处理。

▲图1.10 单线程阻塞I/O模型

这种模型的特点在于单线程和阻塞I/O。单线程即服务器端只有一个线程处理客户端的所有请求,客户端连接与服务器端的处理线程比是n:1,它无法同时处理多个连接,只能串行处理连接。而阻塞I/O是指服务器在读写数据时是阻塞的,读取客户端数据时要等待客户端发送数据并且把操作系统内核复制到用户进程中,这时才解除阻塞状态。写数据回客户端时要等待用户进程将数据写入内核并发送到客户端后才解除阻塞状态。这种阻塞给网络编程带来了一个问题,服务器必须要等到客户端成功接收才能继续往下处理另外一个客户端的请求,在此期间线程将无法响应任何客户端请求。

该模型的特点:它是最简单的服务器模型,整个运行过程都只有一个线程,只能支持同时处理一个客户端的请求(如果有多个客户端访问,就必须排队等待),服务器系统资源消耗较小,但并发能力低,容错能力差。

针对单线程阻塞I/O模型的缺点,我们可以使用多线程对其进行改进,使之能并发地对多个客户端同时进行响应。多线程模型的核心就是利用多线程机制为每个客户端分配一个线程。如图1.11所示,服务器端开始监听客户端的访问,假如有两个客户端发送请求过来,服务器端在接收到客户端请求后分别创建两个线程对它们进行处理,每条线程负责一个客户端连接,直到响应完成。期间两个线程并发地为各自对应的客户端处理请求,包括读取客户端数据、处理客户端数据、写数据回客户端等操作。

▲图1.11 多线程阻塞I/O模型

这种模型的I/O操作也是阻塞的,因为每个线程执行到读取或写入操作时都将进入阻塞状态,直到读取到客户端的数据或数据成功写入客户端后才解除阻塞状态。尽管I/O操作阻塞,但这种模式比单线程处理的性能明显高了,它不用等到第一个请求处理完才处理第二个,而是并发地处理客户端请求,客户端连接与服务器端处理线程的比例是1:1。

多线程阻塞I/O模型的特点:支持对多个客户端并发响应,处理能力得到大幅提高,有较大的并发量,但服务器系统资源消耗量较大,而且多线程之间会产生线程切换成本,同时拥有较复杂的结构。

多线程阻塞I/O模型通过引入多线程确实提高了服务器端的并发处理能力,但每个连接都需要一个线程负责I/O操作。当连接数量较多时可能导致机器线程数量太多,而这些线程大多数时间却处于等待状态,造成极大的资源浪费。鉴于多线程阻塞I/O模型的缺点,有没有可能用一个线程就可以维护多个客户端连接并且不会阻塞在读写操作呢?下面介绍单线程非阻塞I/O模型。

单线程非阻塞I/O模型最重要的一个特点是,在调用读取或写入接口后立即返回,而不会进入阻塞状态。在探讨单线程非阻塞I/O模型前必须要先了解非阻塞情况下套接字事件的检测机制,因为对于单线程非阻塞模型最重要的事情是检测哪些连接有感兴趣的事件发生。一般会有如下三种检测方式。

(1)应用程序遍历套接字的事件检测

如图1.12所示,当多个客户端向服务器请求时,服务器端会保存一个套接字连接列表中,应用层线程对套接字列表轮询尝试读取或写入。对于读取操作,如果成功读取到若干数据,则对读取到的数据进行处理;如果读取失败,则下一个循环再继续尝试。对于写入操作,先尝试将数据写入指定的某个套接字,写入失败则下一个循环再继续尝试。

▲图1.12 非阻塞遍历套接字

这样看来,不管有多少个套接字连接,它们都可以被一个线程管理,一个线程负责遍历这些套接字列表,不断地尝试读取或写入数据。这很好地利用了阻塞的时间,处理能力得到提升。但这种模型需要在应用程序中遍历所有的套接字列表,同时需要处理数据的拼接,连接空闲时可能也会占用较多CPU资源,不适合实际使用。对此改进的方法是使用事件驱动的非阻塞方式。

(2)内核遍历套接字的事件检测

这种方式将套接字的遍历工作交给了操作系统内核,把对套接字遍历的结果组织成一系列的事件列表并返回应用层处理。对于应用层,它们需要处理的对象就是这些事件,这就是其中一种事件驱动的非阻塞方式的实现。

如图1.13所示,服务器端有多个客户端连接,应用层向内核请求读写事件列表。内核遍历所有套接字并生成对应的可读列表readList和可写列表writeList。readList标明了每个套接字是否可读,例如套接字1的值为1,表示可读,socket2的值为0,表示不可读。writeList则标明了每个套接字是否可写。应用层遍历读写事件列表readList和writeList,做相应的读写操作。

▲图1.13 内核遍历套接字的事件检测

内核遍历套接字时已经不用在应用层对所有套接字进行遍历,将遍历工作下移到内核层,这种方式有助于提高检测效率。然而,它需要将所有连接的可读事件列表和可写事件列表传到应用层,假如套接字连接数量变大,列表从内核复制到应用层也是不小的开销。另外,当活跃连接较少时,内核与应用层之间存在很多无效的数据副本,因为它将活跃和不活跃的连接状态都复制到应用层中。

(3)内核基于回调的事件检测

通过遍历的方式检测套接字是否可读可写是一种效率比较低的方式,不管是在应用层中遍历还是在内核中遍历。所以需要另外一种机制来优化遍历的方式,那就是回调函数。内核中的套接字都对应一个回调函数,当客户端往套接字发送数据时,内核从网卡接收数据后就会调用回调函数,在回调函数中维护事件列表,应用层获取此事件列表即可得到所有感兴趣的事件。

内核基于回调的事件检测方式有两种。第一种是用可读列表readList和可写列表writeList标记读写事件,套接字的数量与readList和writeList两个列表的长度一样,readList第一个元素标为1则表示套接字1可读,同理,writeList第二个元素标为1则表示套接字2可写。如图1.14所示,多个客户端连接服务器端,当客户端发送数据过来时,内核从网卡复制数据成功后调用回调函数将readList第一个元素置为1,应用层发送请求读、写事件列表,返回内核包含了事件标识的readList和writeList事件列表,进而分表遍历读事件列表readList和写事件列表writeList,对置为1的元素对应的套接字进行读或写操作。这样就避免了遍历套接字的操作,但仍然有大量无用的数据(状态为0的元素)从内核复制到应用层中。于是就有了第二种事件检测方式。

▲图1.14 内核基于回调的事件检测方式一

内核基于回调的事件检测方式二如图1.15所示。服务器端有多个客户端套接字连接。首先,应用层告诉内核每个套接字感兴趣的事件。接着,当客户端发送数据过来时,对应会有一个回调函数,内核从网卡复制数据成功后即调回调函数将套接字1作为可读事件event1加入到事件列表。同样地,内核发现网卡可写时就将套接字2作为可写事件event2添加到事件列表中。最后,应用层向内核请求读、写事件列表,内核将包含了event1和event2的事件列表返回应用层,应用层通过遍历事件列表得知套接字1有数据待读取,于是进行读操作,而套接字2则可以写入数据。

▲图1.15 内核基于回调的事件检测方式二

上面两种方式由操作系统内核维护客户端的所有连接并通过回调函数不断更新事件列表,而应用层线程只要遍历这些事件列表即可知道可读取或可写入的连接,进而对这些连接进行读写操作,极大提高了检测效率,自然处理能力也更强。

对于Java来说,非阻塞I/O的实现完全是基于操作系统内核的非阻塞I/O,它将操作系统的非阻塞I/O的差异屏蔽并提供统一的API,让我们不必关心操作系统。JDK会帮我们选择非阻塞I/O的实现方式,例如对于Linux系统,在支持epoll的情况下JDK会优先选择用epoll实现Java的非阻塞I/O。这种非阻塞方式的事件检测机制就是效率最高的“内核基于回调的事件检测”中的第二种方式。

在了解了非阻塞模式下的事件检测方式后,重新回到对单线程非阻塞I/O模型的讨论。虽然只有一个线程,但是它通过把非阻塞读写操作与上面几种检测机制配合就可以实现对多个连接的及时处理,而不会因为某个连接的阻塞操作导致其他连接无法处理。在客户端连接大多数都保持活跃的情况下,这个线程会一直循环处理这些连接,它很好地利用了阻塞的时间,大大提高了这个线程的执行效率。

单线程非阻塞I/O模型的主要优势体现在对多个连接的管理,一般在同时需要处理多个连接的发场景中会使用非阻塞NIO模式,此模型下只通过一个线程去维护和处理连接,这样大大提高了机器的效率。一般服务器端才会使用NIO模式,而对于客户端,出于方便及习惯,可使用阻塞模式的套接字进行通信。

单线程非阻塞I/O模型已经大大提高了机器的效率,而在多核的机器上可以通过多线程继续提高机器效率。最朴实、最自然的做法就是将客户端连接按组分配给若干线程,每个线程负责处理对应组内的连接。如图1.16所示,有4个客户端访问服务器,服务器将套接字1和套接字2交由线程1管理,而线程2则管理套接字3和套接字4,通过事件检测及非阻塞读写就可以让每个线程都能高效处理。

▲图1.16 多线程非阻塞I/O模型

最经典的多线程非阻塞I/O模型方式是Reactor模式。首先看单线程下的Reactor,Reactor将服务器端的整个处理过程分成若干个事件,例如分为接收事件、读事件、写事件、执行事件等。Reactor通过事件检测机制将这些事件分发给不同处理器去处理。如图1.17所示,若干客户端连接访问服务器端,Reactor负责检测各种事件并分发到处理器,这些处理器包括接收连接的accept处理器、读数据的read处理器、写数据的write处理器以及执行逻辑的process处理器。在整个过程中只要有待处理的事件存在,即可以让Reactor线程不断往下执行,而不会阻塞在某处,所以处理效率很高。

▲图1.17 Reactor模式

基于单线程Reactor模型,根据实际使用场景,把它改进成多线程模式。常见的有两种方式:一种是在耗时的process处理器中引入多线程,如使用线程池;另一种是直接使用多个Reactor实例,每个Reactor实例对应一个线程。

Reactor模式的一种改进方式如图1.18所示。其整体结构基本上与单线程的Reactor类似,只是引入了一个线程池。由于对连接的接收、对数据的读取和对数据的写入等操作基本上都耗时较少,因此把它们都放到Reactor线程中处理。然而,对于逻辑处理可能比较耗时的工作,可以在process处理器中引入线程池,process处理器自己不执行任务,而是交给线程池,从而在Reactor线程中避免了耗时的操作。将耗时的操作转移到线程池中后,尽管Reactor只有一个线程,它也能保证Reactor的高效。

▲图1.18 Reactor模式改进一

Reactor模式的另一种改进方式如图1.19所示。其中有多个Reactor实例,每个Reactor实例对应一个线程。因为接收事件是相对于服务器端而言的,所以客户端的连接接收工作统一由一个accept处理器负责,accept处理器会将接收的客户端连接均匀分配给所有Reactor实例,每个Reactor实例负责处理分配到该Reactor上的客户端连接,包括连接的读数据、写数据和逻辑处理。这就是多Reactor实例的原理。

▲图1.19 Reactor模式改进二

多线程非阻塞I/O模式让服务器端处理能力得到很大提高,它充分利用机器的CPU,适合用于处理高并发的场景,但它也让程序更复杂,更容易出现问题。


Java体系的Web服务器基本上都会遵循Servlet规范,该规范描述了HTTP请求及响应处理过程相关的对象及其作用。Tomcat其实可以看成一个Servlet容器,所以它也需要遵守Servlet规范。为了方便深入剖析Tomcat,本章将介绍Servlet规范主要的一些对象。

Servlet规范的核心接口即是Servlet接口,它是所有Servlet类必须实现的接口。在Java Servelt API中已经提供了两个抽象类方便开发者实现Servlet类,分别是GenericServlet和HttpServlet,GenericServlet定义了一个通用的、协议无关的Servlet,而HttpServlet则定义了HTTP的Servlet,这两个抽象类可以使Servlet类复用很多共性功能。

Servlet接口的核心方法为service方法,它是处理客户端请求的方法,客户端发起的请求会被路由到对应的Servlet对象上。前面说到的HttpServlet类的service方法把对HTTP协议的GET、POST、PUT、DELETE、HEAD、OPTIONS、TRACE等请求转发到各自的处理方法中,即doGet、doPost、doPut、doDelete、doHead、doOptions、doTrace等方法。HttpServlet提供了这些共性的处理逻辑,其他继承它的类就不用再各自实现,只需要在对应的方法中做具体的处理逻辑即可。例如我们做Web开发时常常会自己定义一个Servlet,并在doGet和doPost方法中做业务逻辑处理。

一般来说,在Servlet容器中,每个Servlet类只能对应一个Servlet对象,所有请求都由同一个Servlet对象处理,但如果Servlet实现了SingleThreadModel接口则可能会在Web容器中存在多个Servlet对象。对于Web容器来说,实现了SingleThreadModel接口意味着一个Servlet对象对应着一个线程,所以此时Servlet的成员变量不存在线程安全问题。

Servlet的生命周期主要包括加载实例化、初始化、处理客户端请求、销毁。加载实例化主要由Web容器完成,而其他三个阶段则对应Servlet的init、service和destroy方法。Servlet对象被创建后需要对其进行初始化操作,初始化工作可以放在以ServletConfig类型为参数的ini方法中,ServletConfig为web.xml配置文件中配置的对应的初始化参数,由Web容器完成web.xml配置读取并封装成ServletConfig对象。当Servlet初始化完成后,开始接受客户端的请求,这些请求被封装成ServletRequest类型的请求对象和ServletResponse类型的响应对象,通过service方法处理请求并响应客户端。当一个Servlet需要从Web容器中移除时,就会调用对应的destroy方法以释放所有的资源,并且调用destroy方法之前要保证所有正在执行service方法的线程都完成执行。

ServletRequest接口的实现类封装了客户端请求的所有信息,如果使用HTTP协议通信则包括HTTP的请求行和请求头部。HTTP对应的请求对象类型是HttpServletRequest类。ServletRequest接口的实现类中的信息包括以下几部分。

ServletRequest接口的对象只在Servlet的service方法或过滤器的doFilter方法作用域内有效,除非启用了异步处理以调用ServletRequest接口对象的startAsync方法,此时request对象会一直有效,直到调用AsyncContext的complete方法。另外,Web容器通常会出于性能原因而不销毁ServletRequest接口的对象,而是重复利用ServletRequest接口对象。

ServletContext接口定义了运行所有Servlet的Web应用的视图。其提供的内容包括以下几个部分。

所有Servlet及它们使用的类需要由一个单独的类加载器加载。每个实现ServletContext接口的对象都需要一个临时存储目录,Servlet容器必须为每个ServletContext分配一个临时目录,并可在ServletContext接口中通过javax.servlet.context.tempdir属性获取该目录。

ServletResponse接口的对象封装了服务器要返回客户端的所有信息。如果使用HTTP,则包含了HTTP的响应行、响应头部和响应体。

为了提高效率,一般ServletResponse接口对响应提供了输出缓冲。其中,getBufferSize用于获取缓冲区大小;setBufferSize用于设置缓冲区大小;flushBuffer强制刷新缓冲区;resetBuffer将清空缓冲区中的内容,但不清空请求头部和状态码;isCommitted判断是否有任何响应字节已经返回给客户端;reset清空缓冲区内容,同时清空头部信息和状态码。

ServletResponse接口对应HTTP的实现对象为HttpServletResponse,可以通过setHeader和addHeader方法向HttpServletResponse中添加头部;可以通过sendRedirect将客户端重定向到另外一个地址;可以通过sendError将错误信息输出到客户端。

当ServletResponse接口关闭时,缓冲区中的内容必须立即刷新到客户端,ServletResponse接口只在Servlet的service方法或过滤器的doFilter方法的作用域内有效,除非它关联的ServletResponse接口调用了startAsync方法启用异步处理,此时ServletResponse接口会一直有效,直到调用AsyncContext的complete方法。另外,Web容器通常会出于性能原因而不销毁ServletResponse接口对象,而是重复利用ServletResponse接口对象。

Filter接口允许Web容器对请求和响应做统一处理。例如,统一改变HTTP请求内容和响应内容,它可以作用于某个Servlet或一组Servlet。

Web应用部署完成后,必须实例化过滤器并调用其init方法。当请求进来时,获取第一个过滤器并调用doFilter方法,接着传入ServletRequest对象、ServletResponse对象及过滤器链(FilterChain),doFilter方法负责过滤器链中下一个实体的doFilter方法调用。当容器要移除某过滤器时必须先调用过滤器的destroy方法。

可以用“@WebFilter”注解或部署描述文件定义过滤器,XML配置形式使用<filter>元素定义,包括<filter-name>、<filter-class>和<init-params>子节点,并使用<filter-mapping>定义Web应用的Servlet和其他静态资源通过过滤器。

Servlet没有提出协议无关的会话规定,而是每个通信协议自己规定,HTTP对应的会话接口是HttpSession。Cookie是常用的会话跟踪机制,其中Cookie的标准名字必须为JSESSIONID。另外一种会话跟踪机制则是URL重写,即在URL后面添加一个jsessionid参数,当支持Cookie和SSL会话的情况下,不应该使用URL重写作为会话跟踪机制。

会话ID通过调用HttpSession.getId()获取,且能在创建后通过调用HttpServletRequest. changeSessionId()改变。HttpSession对象必须限定在ServletContext级别,会话里面的属性不能在不同ServletContext之间共享。

Servlet可将某对象以键值对形式保存到HttpSession中,处于同一个ServletContext和相同会话中的任意Servlet都可以使用会话中保存的对象。如果某些对象想要在保存到会话或从会话中移除时得到通知,可以让某个对象实现HttpSessionBindingListener接口,里面的valueBound和valueUnbound分别会在对应时刻触发。

Servlet容器默认会话的超时时间,可以通过HttpSession的getMaxInactiveInterval方法获取和setMaxInactiveInterval方法设置。

分布式环境中,会话的所有请求在同一时间必须仅被一个JVM处理,分布式容器迁移会话时会通知实现了HttpSessionActivationListener接口的所有会话属性。

Web应用中,使用了注解的类只有被放到WEB-INF/classes目录中或WEB-INF/lib目录下的jar中,注解才会被Web容器处理。web.xml配置文件的<web-app>元素的metadata-complete默认为false,这表示Web容器必须检查类的注解和Web Fragment,否则忽略注解和Web Fragment。下面介绍几个注解。

@WebServlet注解用于在Web项目中定义Servlet,它必须指定urlPatterns或value属性,默认的name属性为完全限定类名,@WebServlet注解的类必须继承javax.servlet.http.HttpServlet类。

@WebFilter注解用于在Web项目定义Filter,它必须指定urlPatterns、servletNames或value属性,默认的filterName属性为完全限定类名,使用@ WebFilter注解的类必须实现javax.servlet.Filter。

@WebInitParam注解用于指定传递到Servlet或Filter的初始化参数,它是WebServlet和WebFilter注解的一个属性。

@WebListener注解用于定义Web应用的各种监听器,使用@WebListener注解的类必须实现以下接口中的一个:

@MultipartConfig注解用于指定Servlet请求期望的是mime/multipart类型。

为了给Web开发人员提供更好的可插拔性和更少的配置,可以在一个库类或框架jar包的META-INF目录中指定Web Fragment,即web-fragment.xml配置文件,它可以看成Web的逻辑分区,web-fragment.xml与web.xml包含的元素基本上都相同。部署期间,Web容器会扫描WEB-INF/lib目录下jar包的META-INF/web-fragment.xml文件,并根据配置文件生成对应的组件。

一个Web应用可能会有一个web.xml和若干个web-fragment.xml文件,Web容器加载时会涉及顺序问题。有两种方式定义它们加载的顺序:绝对顺序,web.xml中的<absolute-ordering>元素用于描述加载资源的顺序;相对顺序,web-fragment.xml中的<ordering>元素用于描述web-fragment.xml之间的顺序。

请求分发器负责把请求转发给另外一个Servlet处理,或在响应中包含另外一个Servlet的输出,RequestDispatcher接口提供了此实现机制。用户可以通过ServletContext的getRequestDispatcher方法和getNamedDispatcher方法分别以路径或Servlet名称作为参数获取对应Servlet的RequestDispatcher。

请求分发器有include和forward两个方法。include方法是将目标Servlet包含到当前的Servlet中,主控制权在当前Servlet上。forward方法是将当前Servlet的请求转移到目标Servlet上,主控权在目标Servlet上,当前Servlet的执行终止。

Web应用和ServletContext接口对象是一对一的关系,ServletContext对象提供了一个Servlet和它的应用程序视图。Web应用可能包括Servlet、JSP、工具类、静态文件、客户端Java Applet等。Web应用结构包括WEB-INF/web.xml文件、WEB-INF/lib/目录下存放的所有jar包、WEB-INF/classes/目录中存放的所有类、META-INF目录存放的项目的一些信息,以及其他根据具体目录存放的资源。一般WEB-INF目录下的文件都不能由容器直接提供给客户端访问,但WEB-INF目录中的内容可以通过Servlet代码调用ServletContext的getResource和getResourceAsStream方法来访问,并可使用RequestDispatcher调用公开这些内容。

Web容器用于加载WAR文件中Servlet的类加载器必须提供getResource方法,以加载WAR文件的JAR包中包含的任何资源。容器不允许Web应用程序覆盖或访问容器的实现类。一个类加载器的实现必须保证部署到容器的每个Web应用,在调用Thread.currentThread. getContextClassLoader()时返回一个规定的ClassLoader实例。部署的每个Web应用程序的ClassLoader实例必须是一个单独的实例。

服务器应该能在不重启Web容器的情况下更新一个Web应用程序,而更新Web应用程序时Web容器应该提供可靠的方法保存这些Web应用的会话。

如果调用response的sendError方法或如果Servlet产生一个异常或把错误传播给容器,容器要按照Web应用部署描述文件中定义的错误页面列表,根据状态码或异常试图返回一个匹配的错误页面。如果Web应用部署描述文件的error-page元素没有包含exception-type或error-code子元素,则错误页面使用默认的错误页面。

Web应用的部署描述符中可以配置欢迎文件列表。当一个Web的请求URI没有映射到一个Web资源时,可以从欢迎文件列表中按顺序匹配适合的资源返回给客户端,如欢迎页为index.html,则http://localhost:8080/webapp请求实际变为http://localhost:8080/webapp/index.html。如果找不到对应的欢迎页,则返回404响应。

当一个Web应用程序部署到容器中时,在Web应用程序开始处理客户端请求之前,必须按照下述步骤顺序执行。

① 实例化部署描述文件中<listener>元素标识的每个事件监听器的一个实例。

② 对于已实例化且实现了ServletContextListener接口的监听器实例,调用contextInitialized()方法。

③ 实例化部署描述文件中<filter>元素标识的每个过滤器的一个实例,并调用每个过滤器实例的init()方法。

④ 根据load-on-startup元素值定义的顺序,包含<load-on-startup>元素的<servlet>元素为每个Servlet实例化一个实例,并调用每个Servlet实例的init()方法。

对于不包含任何Servlet、Filter或Listener的Web应用,或使用注解声明的Web应用,可以不需要web.xml部署描述符。

对于请求的URL,Web容器根据最长的上下文路径匹配请求URL,然后匹配Servlet,Servlet的路径是从整个请求URL中减去上下文和路径参数。匹配规则如下:

所有Servlet容器的Web应用程序部署描述文件需要支持以下类型的配置和部署信息:


相关图书

轻松学PHP
轻松学PHP
PHP、MySQL和JavaScript入门经典(第6版)
PHP、MySQL和JavaScript入门经典(第6版)
PHP和MySQL Web开发学习指南
PHP和MySQL Web开发学习指南
Yii2框架从入门到精通
Yii2框架从入门到精通
B/S项目开发实战 HTML+CSS+jQuery+PHP
B/S项目开发实战 HTML+CSS+jQuery+PHP
PHP、MySQL和Apache入门经典(第5版)
PHP、MySQL和Apache入门经典(第5版)

相关文章

相关课程