Java性能调优16——Java EE 性能调优(上)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

本章关注的是 Java EE(特别是 Java EE 6 和 7),涵盖了 JSP、servlet 和 EJB 3.0 会话 Bean——因为 EJB 3.0 实体 Bean(Java 持久化 API 实体,即 JPA)并不限定于 Java EE 技术(后面持久化优化章节将深入讨论),所以本章没有涵盖。

Web容器的基本性能

ava 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 会导致频繁刷新网络缓存。从性能优化角度看,频繁刷新缓存是非常昂贵的操作——比重新编码这些数据更昂贵。与此类似,对一大块数据进行编码的代价通常不会比一小块数据高很多:最主要的代价是建立到编码器的调用。因此,对小段动态数据来说,频繁地编码及发送编码后的字节数组会拖慢应用:多次调用编码器所花费的时间,比一次调用编码所有的东西(包括静态数据)要长。

      代码的预编码在某些情况下有一定作用,但要视情况而定。

与测试相比,这些优化措施实际运行中的性能会有很大差别。下表显示了可能会出现的结果。测试中所用股票历史 servlet 产生的输出比较长,获取的数据范围有 10 年。所产生的结果是未经压缩和未去除空格的 HTML 页面,大约为 100 KB。为了将带宽的影响降至最低,测试只运行单个用户,思考时间为 100 毫秒,然后测量请求的平均响应时间。使用局域网时,测试通过 100 MB 的交换机在本地网络上运行;使用宽带时,测试在家里的电缆上运行(平均每秒 30 Mb 的下载速度)。使用本地咖啡店中的公共 WiFi 连接的广域网时——网速是相当不可靠的(表格中展示了历时 4 个小时的平均样本)。

所使用的优化应用响应时间(局域网,毫秒)应用响应时间(宽带,毫秒)应用响应时间(公共WiFi,毫秒)
20261003
去除空格201043
压缩输出结果30517

这张表强调了在应用的实际部署环境中进行测试的重要性。如果只在实验室环境中进行测试调优,那得到的一大半性能都是不太靠谱的。虽然这个例子中的测试实际运行在远程应用服务器上(使用公有云服务),但硬件模拟器可以模拟出实验室环境,控制所有相关的机器。(云服务机器也比局域网机器快;它们之间的机器数量无法直接进行比较。)

通用概念小结

  1. 在 Java EE 应用所实际运行的网络基础设施上对它们进行测试。

  2. 外部网络相对内部网络来说仍然是慢的。限制应用所写的数据量会取得很好的性能。

HTTP会话状态

关于 HTTP 会话状态有两个重要的性能提示。

  1. HTTP会话状态的内存占用

    请注意应用管理 HTTP 会话状态的方式。HTTP 会话数据通常存活时间很长,所以很容易塞满堆内存,也常常容易导致 GC 运行太频繁的问题。(此外,堆中的存活数据越多,单次 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(),并确保你的应用服务器配置成只复制更改的数据。

HTTP会话状态小结

  1. 会话状态会对应用服务器的性能造成重大影响。

  2. 尽可能少地在会话状态中保留数据,尽可能缩短会话的有效期,以减少会话状态对垃圾收集的影响。

  3. 仔细查看应用服务器的调优规范,将非活跃的会话数据移出堆。

  4. 开启会话高可用时,需要确保将应用服务器配置成只在状态属性发生变化时进行会话复制。

线程池

前面一章深入介绍了线程池。Java EE 服务器则扩展了线程池的使用,同时里面介绍的关于如何正确调整线程池大小的所有内容也都适用于应用服务器。

应用服务器通常不只有一个线程池。一个线程池通常用来处理 servlet 的请求,另一个则用来处理远程 EJB 的请求,第三个则可以处理 Java Message Service(JMS)请求。也有些应用服务器允许每类请求可以有多个线程池:比如,同样是 servlet 请求,但 URL 不同,可以由单独的线程池处理,或同样是远程 EJB 请求,但调用的 EJB 不同,也可以由单独的线程池处理。

应用服务器中的线程池可以依据不同的请求量分成若干优先级。以运行在四 CPU 机器上的应用服务器为例,假设它的 HTTP 线程池有 12 个线程,EJB 线程池有 4 个。所有线程都会争抢 CPU,但当所有线程都跑满的时候,servlet 请求使用 CPU 的机会比 EJB 请求多两倍。实际上 servlet 线程池的优先级为 3x。

不过这里也有些限制。这些线程池没有办法单独设置,所以只有在没有待处理的 servlet 请求时,才会处理 EJB 请求。只要 EJB 线程池中还有可用线程,这些线程就会共同争用 CPU,无论 servlet 线程池有多忙。

类似地,当服务器因为其他原因空闲时,也请注意,不要将线程池的大小设置成低于预期的工作负荷。如果四 CPU 机器的 JMS 池只配置 3 个线程,那只处理 JMS 请求的话就无法充分利用 CPU。为了弥补这样的浪费,所有线程池的大小都能相应增大,不过这可能会使你的机器运行了太多的线程,从而加重计算机的负担。

因此,这种调整不太靠谱,并且取决于你的应用服务器是否有一个好的流量模型。它有助 于让你应用的性能百尺竿头更进一步。

EJB会话Bean

本节考察 EJB 3.0 会话 Bean 的性能。Java EE 容器管理 EJB 生命周期的方法很特殊,本节中的准则有助于确保容器管理生命周期时不会影响应用的性能。

调优EJB对象池

因为 EJB 对象创建(和销毁)的代价很高,所以它们通常保存在对象池中。如果没有池化,调用 EJB 包括以下步骤:

  • 创建 EJB 对象

  • 处理标注并且将依赖的资源注入这个新 EJB 对象

  • 调用标注为 @PostConstruct 的方法

  • 如果是状态 Bean,则调用标注为 @Init 的方法或者 ejbCreate() 方法

  • 执行业务方法

  • 调用任何标注为 @PreRemove 的方法

  • 如果是状态 Bean,则调用 remove() 方法

如果从池中获取 EJB,则只需要调用执行业务方法——其余 6 个步骤都可以跳过。虽然通常情况下并不需要对象池,但如果初始化对象的代价高,就值得池化。

EJB 对象池化的代价

Java EE 应用服务器可以用不同大小的 EJB 池来进行测试,从而衡量从池中获取对象和因需创建对象的不同性能,所以 EJB 池可以发挥对象池的益处。

在这个例子中,我在 GlassFish 4.0 应用服务器中配置了标准的 StockServlet。应用中的无状态 Bean 完全没有初始化的开销。虽然有 @PostConstruct 方法,但是方法体是空的。

@PostConstruct 方法通常用于初始化资源,比如,可以执行(代价相对较高的)Java 命名和目录接口(JNDI)查找。为了模仿这种情况,我把StorkServlet 的 @PostConstruct 方法改为 sleep,让它模拟时间消耗,或者执行一些初始化代码。

下表是在不同 EJB 池大小、不同 @PostConstruct 方法睡眠时间(模拟初始化时间)下模拟 64 个客户端访问应用时的响应时间。

EJB池大小初始化时间(毫秒)平均响应时间(秒)
100.37
6400.37
1250.40
64250.37
1500.42
64500.37

如果初始化不需要时间,EJB 池就没什么好处。当初始化需要 25 毫秒或者 50 毫秒,并且池的大小为 1 时——意味着每次调用都会创建一个 EJB 对象——不出所料,平均响应时间拉长了。

由于这个 EJB 池中只有 64 个(小)对象,因此不太可能发生 GC。这是好的对象池的另一个关键特性:小才是好。

只有在应用服务器池中还有可用的 EJB 对象时,性能才会提高,所以必须将应用服务器中的 EJB 对象数配置成应用同时使用的 EJB 数。如果应用使用 EJB 但没有池化的实例,应用服务器就会开启 EJB 对象的完整生命周期,从创建、初始化、使用到销毁 EJB 对象。

当然,应用所依赖的对象数取决于该应用如何被使用。通常情况下,由于一个请求最多只需要一个 EJB,所以在开始的时候一般需要确保 EJB 池中的对象数和应用服务器中的工作线程数一样多。请注意,EJB 池是按类型分的:如果应用有两个 EJB 类,应用服务器就会使用两个池(每个池都可以设置线程数)。

应用服务器不同,EJB 池的调优方式也不相同,不过通常来说,每个 EJB 池都有一个全局(或默认)配置,需要不同配置的 EJB 可以覆盖该选项(通常在它们的部署描述符中)。例如,对 GlassFish 应用服务器来说,EJB 容器默认每个池中有 32 个 EJB 实例,且在 sun-ejb-jar.xml 文件的以下段落中可以配置单个 bean 池的大小:

<bean-pool>
  <steady-pool-size>8</steady-pool-size>
  <resize-quantity>2</resize-quantity>
  <max-pool-size>64</max-pool-size>
  <pool-idle-timeout-in-seconds>300</pool-idle-timeout-in-seconds>
</bean-pool>

这个例子中 EJB 池的最大值扩大了一倍,是 64。

将 EJB 池大小设置为很大值的代价通常不是非常高。池中没有使用的实例会略微降低 GC 的效率,但通常来说,池不会很大,未使用的实例不会有很明显的影响。有个例外,即如果 EJB 占用了大量内存,GC 的影响就会变大。然而,从上面的 XML 可以看出,应用服务器通常用一个池的稳定值和最大值来管理池。在上面的例子中,如果流量主要来自 EJB 中的 10 个实例(比如 10 个并发请求),一直只有 10 个 EJB 实例,那么池就永远不会达到最大值 64。

如果有短暂的流量高峰,池会创建这 64 个实例,随着流量的衰减,这些 EJB 就会空闲。一旦空闲 300 秒,就会被销毁,内存就可以被 GC。这使得池对 GC 的影响最小。

因此,要更关心 EJB 池稳定值的调优,而不是最大值的调优。

对象池优化小结

  1. EJB 池是对象池的典型范例:初始化代价高,数量相对较少,所以池化更为有效。

  2. 通常来说,EJB 池的大小包括稳定值和最大值。对于特定的环境,两种值都需要调优,但从长期来看,为了降低对垃圾收集器的影响,应更注重稳定值的调优。

调优EJB缓存

对于状态会话 Bean,还需要考虑另外一个因素,即它们有可能被钝化(Passivation):为了节约内存,应用服务器会选择将 Bean 的状态序列化并保存到磁盘上。这对性能会有很严重的影响,绝大多数情况下应该极力避免。

坦白说,我建议在所有情况下都避免这么做。关于钝化常见的争论是,会话空闲了几个小时或者几天,该怎么办。当用户重新回到系统时(几天后),你总希望他能找回完整的状态数据。这种情形的问题在于,它假定 EJB 会话是唯一重要的状态数据。但通常来说,EJB 与 HTTP 会话会有关联,而我们并不建议长时间保留 HTTP 会话。如果应用服务器的某种非标准特性可以将 HTTP 会话保存到磁盘,并且能配置成同时钝化 HTTP 会话和 EJB 会话(持续的时间也相同),这就有意义了。然而即便如此,其他的外部状态也可能会缺失。(比如,用户购物车中的物品失效了怎么办?)

如果需要长时间存活的状态,通常你需要绕过常规的 Java EE 状态机制。

与会话关联的状态 Bean 并没有保存在 EJB 池中,而是保存在 EJB 缓存中。因此,必须对 EJB 缓存进行调优,以便容纳应用中同时活跃的最大会话量。如果容纳不了,最近最少使用的会话将会被钝化。如前所述,不同的应用服务器实现的方式也不同。GlassFish 默认的缓存为 512,全局值可以通过域配置进行覆盖,或在 sun-ejb-jar.xml 文件中分别设置每个 EJB。

对象池缓存小结

  1. EJB 缓存仅用于状态会话 Bean 与 HTTP 会话关联的时候。

  2. 应该充分优化 EJB 缓存,以避免钝化。

监控 EJB 池

怎么才能知道 EJB 池和缓存的大小应该是多少?一种方法是根据应用在其预期的负载下的工作情况来进行合理的猜测。不过,想知道是否创建了太多 EJB(或钝化了太多状态会话 Bean)的唯一方法就是借助应用服务器的监控设备来进行。

如图是 GlassFish 中监控的示例。在这个例子中,EJB 累计的销毁数不为 0,表明有些 EJB 创建出来后就被销毁了,因为有些操作无法从池中获得可用的 Bean。相应地,EJB 累计的创建数大于池的最大值(这个例子中为 4)。这意味着 EJB 池过小了。

EJB 池监控示例

为了了解应用的性能,像这样监控统计值非常重要,但也得留意,监控本身也有代价。在这个例子中,我将 GlassFish 中的 EJB 容器监控级别设置为 HIGH,以便生成这些统计数据,结果总吞吐量就降低了约 5%。应用本身并没有太大影响,但对应用服务器来说,你就得注意如何进行配置和监控了。在 GlassFish 中,监控级别设为 LOW 的影响几乎可以忽略不计——绝大多数操作都可以进行这个级别的监控,而需要更多信息时,监控级别可以动态地设置为 HIGH。

本地和远程实例

EJB 可以通过本地或远程接口访问。在标准的 Java EE 部署中,EJB 可通过 servlet 来访问,而 servlet 可以通过本地或远程接口访问 EJB。如果 EJB 在其他系统上,则必须使用远程接口,但如果 EJB 和 servlet 在一起(这是更常见的部署方式),servlet 通常应该使用本地接口访问 EJB。

由于远程接口包含网络调用,所以上述方式看起来很合理。但这并不是主要原因——当 servlet 和远程 EJB 部署在同一个应用服务器上时,大多数服务器都足够智能,可以旁路网络调用,从而通过常规的方法调用 EJB。

优先使用本地接口的主要原因是,两类接口处理参数的方法不同。传递(或返回)给本地 EJB 的参数合乎通常的 Java 语义:原生类型通过值传递,而对象则通过引用传递。(或者,严格来说,对象句柄也仍然是通过值传递,只不过对象的引用使对象看起来是通过引用传递的。)

而传递(或者返回)给远程 EJB 的参数则总是值传递。这种通过网络的传送只有一种方式:发送方将对象序列化后以字节流的方式传输出去,而接收方则反序列化字节流后重建对象。即使服务器优化本地调用,避免了网络开销,它也不能绕过序列化 / 反序列化步骤。(大多数服务器传输对象不可变时——字符串或者原生值——都能跳过序列化的步骤,但这不是一般情况。)无论服务器写得多好,使用远程 EJB 接口总是比本地接口慢。

Java EE 还包括其他部署场景。例如,可以将 servlet 和 EJB 部署在不同层上,且普通应用可以通过远程接口访问 EJB。也常常会有业务或者功能上的原因而使网络结构受限,例如,假设 EJB 需要访问企业数据库,你可能想将数据库放在防火墙后面的机器上,而防火墙则隔开了 servlet 容器和数据库。这些因素都是性能问题中需要重点考虑的。但严格地从性能角度来说,将访问 EJB 的组件和 EJB 部署到一起并使用本地接口总是比使用远程协议要快。

说到远程协议,所有的远程 EJB 都必须支持 IIOP(CORBA)协议。这十分有利于互通性,特别是对那些不是用 Java 编写的程序来说。对于远程访问来说,Java EE 服务器供应商也可以使用其他协议,包括专用协议。通常来说,这些专用协议都比 CORBA 快(这就是为什么供应商开发它的首要原因)。所以,如果必须使用远程 EJB 调用(不考虑不同语言之间的互通性),可以考虑那些应用服务器供应商所提供的访问协议选择。

即便在同一个服务器中,调用 EJB 远程接口也对性能有很大的影响。

XML和JSON处理

对于部署在 Java EE 应用服务器上的 servlet 应用来说,它们的输出会在浏览器中显示,而返回给用户的数据几乎总是 HTML。本节涵盖了一些如何处理这些数据交换的最佳实践。

程序之间交换数据也可以使用应用服务器,特别是通过 HTTP。Java EE 支持多种基于 HTTP 的数据传输:成熟的 Web Service 使用 JAX-WS,RESTful 使用 JAX-RS,甚至你可以自己调用 HTTP。这些 API 的共同点是,它们都使用基于本文的数据传输(基于 XML 或 JSON)。虽然 XML 和 JSON 的数据呈现有很大的不同,但 Java 处理它们的方式是类似的,并且性能的考量点也是类似的。

这并不意味着两种呈现之间没有功能上的重要差别。一般来说,选择哪种呈现还应该依据算法和可编程性上的考虑,而不仅仅是性能。如果目的是与其他系统交互,那选择何种方式就取决于接口定义。对于复杂应用来说,处理 Java 对象通常要比遍历文档树要腰容易得多;这种情况下,JAXB(即采用 XML)是更好的选择,至少可以节省时间:Java EE 7 遵循 JSR 353(提供文档模型的标准解析)只支持 JSON-P。编写本书时,JSON-B JSR(JSON 中支持类似 JAXB 的特性)还没有得到认可(但将来可能会)。

除了上述差别之外,XML 和 JSON 还有其他重要差别。所以,本节比较两者性能的真实目的在于理解如何尽可能地获得最佳性能,而不是在特定环境下如何选择优化,无论选择的是哪种呈现。

数据大小

在开篇的“Web 容器的基本性能”显示了数据大小对整体性能的影响。在分布式网络环境中,数据大小是很重要的。关于这方面,通常都认为 JSON 比 XML 小,虽然差别通常不大。在本节的测试中,我从 eBay 请求获取最畅销的 20 件商品,并用 XML 和 JSON 返回。例子中的 XML 有 23 031 字节,JSON 比较小,只有 16 078 字节。但 JSON 数据之间没有空格,所以易读性差,不过可读性并不是目标,所以并不碍事。XML 则与之不同,结构明晰,有许多空格,去掉空格后可以缩减为 20 556 字节。不过与 JSON 相比,字节数仍然有 25% 的差别,绝大部分是因为 XML 元素的结束标记。通常来说,这些结束标记总会使输出的 XML 较大。值得注意的是,有许多网站可以将 XML 自动转换成 JSON。

样本数据有效负载

贯穿本节的样本数据来自 eBay。像许多公司一样,eBay 为开发人员提供接口,以便他们在自己的应用中使用。通常来说,数据以 XML 或 JSON 的格式获取。

比如,获取 eBay 上销量排行榜前 20 位商品的列表。下面是简化后的 XML 样本数据:

<xml version="1.0" encoding="UTF-8"?>
  <FindPopularItemsResponse xmlns="urn:ebay:apis:eBLBaseComponents"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="urn:ebay:apis:eBLBaseComponents docs/xsd/ebay.xsd">
   <Timestamp>2013-03-29T01:57:46.530Z</Timestamp>
   <Ack>Success</Ack>
   <Build>E815_CORE_APILW2_15855352_R1</Build>
   <Version>815</Version>
   <ItemArray>
    <Item>
      <ItemID>140356481394</ItemID>
      ……其他17个属性 ……
    </Item>
    …… 其他相同结构的19个元素 ……
  </ItemArray>
</FindPopularItemsResponse>

JSON 数据与此类似(实际上它没有空格,此处只是为了增加可读性):

{"Timestamp":"2013-03-29T02:17:14.898Z",
 "Ack":"Success",
 "Build":"E815_CORE_APILW2_15855352_R1",
 "Version":"815",
 "ItemArray":{
    "Item":[{"ItemID":"140356481394", …… 其他17个属性 …… }],
    …… 其他相同结构的19个元素 ……
 },
}

无论采用哪种格式传输,数据压缩都能带来巨大的好处。实际上,两种格式压缩之后的大小非常接近:JSON 压缩后的大小为 3471 字节,XML 压缩后的大小为 3742 字节。如此一来,数据大小上的差异就不那么重要了,而传递压缩数据也和传递其他压缩后的 HTTP 数据一样,都有好处。

和 HTML 数据一样,程序中的数据也能从减少空格和压缩中获得巨大的益处。

解析和编组概述

给定一组 XML 或 JSON 字符串,程序必须将其转换成适合 Java 处理的数据。依据程序的上下文和输出结果,这个过程被称为编组(marshal)或解析。反过来——从数据生成 XML 或 JSON 串——则被称为解组(unmarshal)。

一般来说,处理这些数据涉及以下四种技术。

标识符解析器(Token parser):
  解析器遍查输入数据中的标识符,当发现标识符时则回调相应对象上的方法。

拉模式解析器(Pull parser):
  输入的数据与解析器关联,程序从解析器中请求(或拉取)标识符。

文档模型(Document model):
  输入数据被转换成文档风格的对象,以便程序在查找数据片段时可以遍历。

对象呈现(Object representation):
  通过与输入数据对应的预定义类,可以将数据转换成一个或多个 Java 对象(例如,可以用预定义的 Person 类来转换关于人的数据)。

虽然上述技术大体上按照性能从慢到快的顺序排列,但它们之间最主要的差别是功能而不是性能。前两种技术在功能上没有很大差别:它们都适用于大多数只需扫描一次就能提取信息的算法。不过解析器所能做的只是简单的扫描。解析器模式并不非常适合那种需要随机访问的数据,或者需多次遍历的数据。为了应对这些情况,使用简单解析器的程序应该构建内部的数据结构,虽然这只是个简单的编程问题,但文档对象模型和 Java 对象模型已经提供了结构化的数据,可能比你自己定义新结构要容易。

实际上,这就是使用解析器和数据编组之间的真实差别。前面两种是纯粹的解析器模式,取决于如何用解析器提供的方式处理数据逻辑。下面两项是数据编组器:它们必须使用解析器处理数据,但它们所提供的数据呈现可以用在更复杂的程序逻辑中。

所以,采用何种技术首先取决于应用是什么样的。如果程序只需要简单地过一遍数据,那么简单地使用最快的解析器是最有效的。如果数据需要保存为应用所定义的简单结构,那么直接使用解析器也是合适的;例如,示例数据中商品条目的价格需要保存为 ArrayList,以便应用的其他部分进行处理。

数据格式为重的时候,使用文档模型更合适。如果必须保留数据格式,那文档的格式转换就会很容易:数据读入后转成文档格式,用某种方法更改,然后可以很容易地写到新的数据流中。

为了尽可能便利,对象模型提供 Java 语言层面的数据呈现。数据可以通过对象及其属性的方式来操作。数据编组时所增加的复杂性(绝大部分)对开发人员透明,并且会使应用略微慢一些,但开发人员代码生产率上的提高可以抵消这个问题。

本节的示例读取有 20 个条目的 XML 或者 JSON 文档,并将条目 ID 保存到 ArrayList 中。对某些测试来说,只需要前 10 个条目。这是为了模拟真实世界里经常发生的事,即返回的数据总是超过实际所需要的数据。在 Web 服务的设计考量中,这是很好的点:调用的建立需要一些时间,用更少的远程调用(即便需要大量数据)而不是大量小的远程调用。

尽管所有的示例都展示了这类常见操作,但关键点并不在于直接比较这部分任务的性能。而是说,每个示例展示的是如何在所选择的框架下最有效地执行操作,因为框架的选择并不单是考虑解析和数据编组的性能。

解析和编组小结

  1. Java EE 应用中有很多办法处理程序所需要的数据。

  2. 虽然这些技术给开发人员提供了很多功能,但数据处理本身的代价也增加了。不要因此影响你在应用中选择正确处理数据的方法。

选择解析器

编程中所用的所有数据都必须能被解析。对应用来说,选择直接使用解析器,还是通过序列化框架间接使用解析器,对于数据操作的整体性能至关重要。

1. 拉模式解析器

从开发者的角度来看,拉模式的解析器最容易使用。在 XML 的世界中,广为人知的拉模式解析器就是 StAX(Streaming API for XML)解析器。JSON-P 只提供拉模式解析器。

拉模式解析器依据需要从流中获取数据。本节测试所用的基本拉模式解析器的主逻辑就是下面的这个循环:

XMLStreamReader reader = staxFactory.createXMLStreamReader(ins);
while (reader.hasNext()) {
    reader.next();
    int state = reader.getEventType();
    switch (state) {
        case XMLStreamConstants.START_ELEMENT:
            String s = reader.getLocalName();
            if (ITEM_ID.equals(s)) {
                isItemID = true;
            }
            break;
        case XMLStreamConstants.CHARACTERS:
            if (isItemID) {
                String id = reader.getText();
                isItemID = false;
                if (addItemId(id)) {
                    return;
                }
            }
            break;
        default:
            break;
    }
}

解析器返回一组 token。这个例子中的大多数 token 都会被丢弃。当遇到起始类型的 token 时,就会检查是不是 ITEM_ID。如果是,则下一个字符 token 就是应用所需要保存的 ID。ID 可通过 addItemId() 保存,如果成功保存 ID 则返回 true。一旦发生,循环就会返回,不再处理输入流中剩下的数据。

从概念上说,JSON 解析器的工作方式与此一模一样,只是有一些 API 调用上的变化:

while (parser.hasNext()) {
    Event event = parser.next();
    switch (event) {
        case KEY_NAME:
            String s = parser.getString();
            if (ITEM_ID.equals(s)) {
                isItemID = true;
            }
            break;
        case VALUE_STRING:
            if (isItemID) {
                if (addItemId(parser.getString())) {
                    return;
                }
                isItemID = false;
            }
            continue;
        default:
            continue;
    }
}

只处理必要的数据可以给性能带来可预见的好处。下表列出了解析样本文档的平均时间(毫秒),假设条件从解析 10 个条目后即可退出循环,到处理整个文档。解析 10 个条目后退出并没有节约 50% 的时间(因为文档的其他段落也需要解析),但差别还是很显著的。

拉模式解析器的性能

处理的条目数XML解析器(毫秒)JSON解析器(毫秒)
1014368
20265146

2. 推模式解析器

标准的 XML 解析器是 SAX(Simple API for XML)解析器。SAX 解析器是一种推模式解析器:读入数据,当发现 token 时,就会执行类中处理该 token 的回调方法。下面测试中的解析逻辑与之前相同,不过现在逻辑放在了类所定义的回调方法中:

protected class CustomizedInnerHandler extends DefaultHandler {
    public void startElement(String space, String name,
                      String raw, Attributes atts) {
             if (name.length() == 0)
                 name = raw;
             if (name.equalsIgnoreCase(ITEM_ID))
                 isItemID = true;
    }

    public void characters(char[] ch, int start,
                 int length) throws SAXDoneException {
        if (isItemID) {
            String s = new String(ch, start, length);
            isItemID = false;
            if (addItemId(s)) {
                throw new SAXDoneException("Done");
            }
        }
    }
}

这里程序逻辑上唯一的差别是,必须以抛出异常的方式来通知解析完成了,因为这是 XML 推模式解析框架检测到解析应该停止的唯一方法。这个例子中,应用所抛出的异常是 SAXDoneException。一般来说,任何 SAXException 都可以抛出,这个例子中使用的是该异常的子类,使得程序其他部分的逻辑可以区分哪个是实际错误,哪个是通知解析终止的信号。

SAX 解析器比 StAX 快,虽然性能上的差别很小——选择哪种解析器应该取决于开发中哪种模型更容易。表中展示了推模式解析器和拉模式解析器在处理时间上的差异。

条目数XML StAX解析器(毫秒)XML SAX解析器(毫秒)
10143132
20265231

JSON-P 没有相应的推模式解析器模型。

3. 其他解析机制的实现和解析器工厂

XML 和 JSON 规范定义了解析器的标准接口。JDK 提供了 XML 解析器的参考实现,JSON-P 项目则提供了 JSON 解析器的参考实现。应用可以使用任意解析器(当然,只要该解析器实现了所需要的接口)。

解析器是从解析器工厂中获得的。将解析器工厂设置成返回所需解析器的实例(而不是默认解析器),就可以使用不同的解析器实现。这其中隐含着某些性能问题。

  • 工厂初始化的代价昂贵:确保可以通过全局(或至少是线程本地变量)引用的方式重用工厂。

  • 工厂可通过多种不同的方式进行配置,其中一些配置(包括默认配置)从性