10.1 Web容器的基本性能
Java EE 应用服务器性能的关键是 Web 容器,它通过基本的 servlet 和 JSP 页面处理 HTTP 请求。
有些基本的途径可以改善 Web 容器的性能,改进的具体方法因 Java EE 实现的不同而有所不同,但一些概念可以适用于所有服务器。
减少输出
减少服务器产生的结果输出可以加快 Web 页面返回到浏览器的速度。
减少空格
在 servlet 代码中调用 PrintWriter 时不要写入多余的空格,因为空格在网络上传输时同样需要时间(而且,相对于代码的处理,网络传输时间更为重要)。你应该用 print() 而不是 println(),主要是为了避免在返回结果的 HTML 中写入制表符或空格。虽然这确实会使有些人查看 Web 页面源代码时看不清结构,但如果他们真对源代码感兴趣,总会使用 XML 或 HTML 编辑器。也可以让内部 QA 或者性能优化小组来处理空格。毫无疑问,结构化的页面源代码可以简化调试,但为了改善应用的响应时间,我最后还得把它载入格式编辑器以去除多余的空格。绝大多数应用服务器都可以自动去除 JSP 页面中的空格。比如 Tomcat(以及基于 Tomcat 的开源 Java EE 服务器)中的 trimSpaces 指令,可以将 JSP 页面每行的前后空格都去掉。所以开发和维护 JSP 页面时可以有适当的(至少对人类来说是如此)缩进,而不用担心会在网络上传输不必要的空格。
合并 CSS 和 JavaScript 资源
对于开发者来说,把 CSS 保存在独立的文件中是有意义的,也更容易维护。对于 JavaScript 来说也是如此。但使用这些资源时,传输一个大文件的效率比传输几个小文件要高。Java EE 没有这方面的标准,而且绝大多数应用服务器也无法自动处理,不过有些开发工具可以帮助你合并这些资源。
压缩输出
从用户角度来看,执行 Web 请求的最长时间通常是服务器将 HTML 发回浏览器所需的时间。但由于客户端(模拟浏览器)到服务器的性能测试通常在快速局域网中进行,所以这个时间通常并不是最长的。虽然真实用户可能在“快速”广域网中,但仍然要比你实验室里的机器之间的 LAN 慢一个数量级。大多数应用服务器在将数据发回浏览器时都有压缩机制:HTML 数据压缩发送给浏览器,内容类型(content type)为 zip 或 gzip。这只有在初始请求指明浏览器支持压缩时才行得通。所有的现代浏览器都支持该特性。开启压缩要求服务器有更多的 CPU 周期,但通常数据量越小,网络传送的时间也越少,从而整体性能就会越高。然而与本节讨论的其他优化不同,它并不总能提高性能。本节后面的例子表明,在 LAN 开启压缩时,性能可能会下降。应用发送很小的页面时也会如此(尽管大多数应用服务器允许只有输出大于某个特定尺寸时才压缩)。
不要使用 JSP 动态编译
默认情况下,大多数 Java EE 应用服务器允许 JSP 页面动态更改:JSP 文件可以随时编辑(无论部署在哪里),而这些变化将在下次访问页面时起作用。这在开发新 JSP 时非常有用,但因为每次访问 JSP 时,服务器都要通过检查文件的最后修改日期来判断是否需要重新加载,所以在生产环境中就会拖慢服务器。这通常被称为开发模式,应该在生产和性能测试时关闭。
字符串是否应该预编码?
应用服务器在字符转换上要花费大量时间:从 Java 的
String对象(以 UTF-16 格式保存)转换成客户端所需要的字节数组。许多这样的字符串总是相同的。Web 页面的 HTML 字符串并不会总随着数据发生变动(如果发生了,它们也仍然是从字符串常量集合中获取的)。这些字符串是否应该预先编码成字节数组以便可以重用?答案取决于应用服务器和应用本身。
JSP 页面的 HTML 字符串由应用服务器所写。字符串是否预先编码取决于服务器:有些服务器会对此提供一个选项,有一些则是自动执行的。
在 servlet 中,这些字符串可以预编码,然后用
ServletOutputStream的write()通过网络发送,不要用PrintWriter的print()。不过动态数据仍然要用print()才能正确编码。(你可以从 header 中找到目标编码,然后对字符串编码,但这种方法相对容易出错。)
应用服务器实现这些输出接口以及在其内部缓存这些数据的方式有很大差别。对一些服务器来说,混用 servlet 的输出流(output stream)和它的小伙伴 print writer 会导致频繁刷新网络缓存。从性能优化角度看,频繁刷新缓存是非常昂贵的操作——比重新编码这些数据更昂贵。与此类似,对一大块数据进行编码的代价通常不会比一小块数据高很多:最主要的代价是建立到编码器的调用。因此,对小段动态数据来说,频繁地编码及发送编码后的字节数组会拖慢应用:多次调用编码器所花费的时间,比一次调用编码所有的东西(包括静态数据)要长。
代码的预编码在某些情况下有一定作用,但要视情况而定。
与测试相比,这些优化措施实际运行中的性能会有很大差别。表 10-1 显示了可能会出现的结果。测试中所用股票历史 servlet 产生的输出比较长,获取的数据范围有 10 年。所产生的结果是未经压缩和未去除空格的 HTML 页面,大约为 100 KB。为了将带宽的影响降至最低,测试只运行单个用户,思考时间为 100 毫秒,然后测量请求的平均响应时间。使用局域网时,测试通过 100 MB 的交换机在本地网络上运行;使用宽带时,测试在家里的电缆上运行(平均每秒 30 Mb 的下载速度)。使用本地咖啡店中的公共 WiFi 连接的广域网时——网速是相当不可靠的(表格中展示了历时 4 个小时的平均样本)。
表10-1:几种Web响应输出尺寸方面的优化在不同网络条件下的效果
| 所使用的优化 | 应用响应时间(局域网,毫秒) | 应用响应时间(宽带,毫秒) | 应用响应时间(公共WiFi,毫秒) |
|---|---|---|---|
| 无 | 20 | 26 | 1003 |
| 去除空格 | 20 | 10 | 43 |
| 压缩输出结果 | 30 | 5 | 17 |
这张表强调了在应用的实际部署环境中进行测试的重要性。如果只在实验室环境中进行测试调优,那得到的一大半性能都是不太靠谱的。虽然这个例子中的测试实际运行在远程应用服务器上(使用公有云服务),但硬件模拟器可以模拟出实验室环境,控制所有相关的机器。(云服务机器也比局域网机器快;它们之间的机器数量无法直接进行比较。)
快速小结
1. 在 Java EE 应用所实际运行的网络基础设施上对它们进行测试。
2. 外部网络相对内部网络来说仍然是慢的。限制应用所写的数据量会取得很好的性能。
HTTP会话状态
关于 HTTP 会话状态有两个重要的性能提示。
1. HTTP会话状态的内存占用
请注意应用管理 HTTP 会话状态的方式。HTTP 会话数据通常存活时间很长,所以很容易塞满堆内存,也常常容易导致 GC 运行太频繁的问题。(此外,回想第 7 章中的内容,堆中的存活数据越多,单次 GC 所用的时间也会越长。)
这个问题最好在应用层面解决:决定在 HTTP 会话中存储数据前需三思而后行。如果数据可以很容易地重建,最好就不要保存在会话状态中。此外,还需要留意会话数据保留的时长。应用会话数据保存的时长在 web.xml 文件中,默认值为 30 分钟:
<session-timeout>30</session-timeout>
会话数据保留的时间太长了——真的有用户在离开 29 分钟后再返回么?调低这个值可以显著缓解太多会话数据对堆内存造成的压力。
这部分是 Java EE 应用服务器的具体实现可以提供的帮助。虽然会话数据必须保留 30 分钟(或其他值),但数据没有必要保存在 Java 堆中。应用服务器可以(通过序列化)将会话数据移到磁盘或者远程缓存中——比如说,在空闲了 10 分钟之后进行。这可以释放应用服务器的堆内存空间,同时依然遵循保留应用状态 30 分钟(或其他值)的约定。如果用户 29 分钟之后回来了,那他的首次请求耗时会长一些,因为需要从磁盘读取状态,但在此期间的整体应用服务器性能会更好。
这也是测试时需要牢记的一个重要原则:面对应用的用户,什么样的会话管理是切实可预期的?他们是早上登录后一整天都使用该会话,还是来去很频繁,在服务器上留下大量的废弃会话,还是介于两者之间?无论答案是什么,都应该确保测试反映所期望的会话场景。否则生产服务器就会被错误调优,因为此时堆的使用完全不同于性能测试时的状况。
负载生成器有不同的会话管理方式,但一般来说,可以选择在测试的某个时间点开启一个新会话(可通过以下方式实现这一点,即关闭连接服务器的 socket 并且丢弃之前所有的 cookie)。本书所有的测试都使用 fhb,每个客户端线程的每轮测试维护一个线程。(不过,实际上 fhb 并没有创建新会话的选项,尽管通过 faban 中定制的驱动可以做到这点。)
2. HTTP会话状态的高可用(Highly available HTTP session state)
如果应用服务器在高可用(HA)配置下测试,那么必须留意服务器如何复制会话状态数据。应用服务器可以选择在每个请求中复制完整的会话状态,或者只在数据发生更改时复制。毫无疑问,第二种方法性能更高。同样,这是大多数应用服务器都支持的特性,不过不同供应商的设置不同。如何依据配置属性进行复制,请参考应用服务器文档。
不过,要使这个法子管用,开发人员必须遵循一定的规则来处理会话状态。特别是,应用服务器无法追踪已经存储在会话中的对象的变化。如果从会话中获取一个对象,然后改变它,必须调用 setAttribute() 方法让应用服务器知道那个对象的值发生了变化:
HttpSession session = request.getSession(true);ArrayList<StockPriceHistory> al =(ArrayList<StockPriceHistory>) session.getAttribute("saveHistory");al.add(……一些数据……);session.setAttribute("saveHistory", al);
在单个(非复制)服务器上,末尾的那句 setAttribute() 并不是必需的:因为 al 已经在会话状态里了。如果省略了该调用,将来该会话中的所有请求都会发到服务器,一切都会正常工作了。
对于复制服务器来说,如果省略了该调用,会话会被复制到备份服务器上,请求会被备份服务器处理,应用可能会发现 al 数据没有发生变化。这是因为应用服务器“优化”了会话状态的处理,即只复制变化的数据到备份服务器上。没调用 setAttribute() 的话,应用服务器就不知道 al 发生了变化,所以执行完上述代码后不会复制它。
某种程度上来说,这是 Java EE 规范中的灰色区域。规范没有强制在这种情况下必须调用 setAttribute(),但每种 Java EE 应用服务器实际上都遵循这种惯例。对某些应用服务器来说,这是会话复制机制正常工作的唯一方式。而其他还有些应用服务器则允许配置数据复制的方式——包括每次调用时都复制所有的会话状态数据,所以即便应用不调用 setAttribute(),也能正常工作。虽然这种做法功能上没问题,但性能要比只在属性更改时复制要差很多。
这个事实的真正含义在于:一旦你更改了会话状态中的对象值,都应该调用 setAttribute(),并确保你的应用服务器配置成只复制更改的数据。
快速小结
1. 会话状态会对应用服务器的性能造成重大影响。
2. 尽可能少地在会话状态中保留数据,尽可能缩短会话的有效期,以减少会话状态对垃圾收集的影响。
3. 仔细查看应用服务器的调优规范,将非活跃的会话数据移出堆。
4. 开启会话高可用时,需要确保将应用服务器配置成只在状态属性发生变化时进行会话复制。
快速小结