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 解析器的参考实现。应用可以使用任意解析器(当然,只要该解析器实现了所需要的接口)。

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

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

  • 工厂可通过多种不同的方式进行配置,其中一些配置(包括默认配置)从性能的角度来看并不是最优的。

  • 其他的解析器实现可能比默认的更快。

工厂和解析器的重用

XML 和 JSON 解析器工厂的创建代价很高。幸运的是,工厂是线程安全的,所以很容易保存在全局静态变量中,可以在需要的时候重用。

但一般来说,解析器无法重用,也不是线程安全的。因此,解析器通常是因需而建。

SAX 解析器的一个优点是可以重用解析器对象。重用时,只需要在使用解析器之前调用 reset() 方法即可。不过解析器仍然不是线程安全的,所以务必确保同一时间只在单个线程中重用解析器。

让我们依次来看上述几点。

为求平均的解析速度,这些测试解析了 1 百万次数据(10 000 次预热解析之后)。下面的示例代码确保只构造一次工厂,在测试开始时调用的初始化方法中完成。每轮测试中的解析器实例则由工厂因需而创建。由此,SAX 测试包含的代码如下所示:

SAXParserFactory spf;
// 只在程序初始化时调用一次
protected void engineInit(RunParams rp) throws IOException {
    spf = SAXParserFactory.newInstance();
}
// 每轮迭代时调用
protected XMLReader getReader() Throws SAXException {
    return spf.newSAXParser().getXMLReader();
}

StAX 解析器与此类似,调用 XMLFactory.newInstance() 获得工厂(类型为 XMLInputFactory),然后调用工厂的 createStreamReader() 方法获得 StreamReader。对于 JSON,相应的调用方法是 Json.createParserFactory() 和 createParser()。

如果要用另一种解析器实现,我们必须用另一种工厂,才能使工厂的调用返回所需要的实现。这就是关于工厂配置的第二点:确保所用的工厂是经过优化设定的。

可以通过 3 种方法设定 XML 工厂。此处所用的工厂(javax.xml.stream.XMLInputFactory)默认设定的是 StAX 解析器。为了覆盖默认的 SAX 解析器,需设置成 javax.xml.parsers.SAXParserFactory。

为确定使用的是哪种工厂,需要按以下顺序查找选项。

  1. 使用由系统属性 -Djavax.xml.stream.XMLInput Factory=my.factory.class 指定的工厂。

  2. JAVA/jre/lib 下的文件 jaxp.properties 内所指定的工厂,类似这样一行:

javax.xml.stream.XMLInputFactory=my.factory.class
  1. 在 classpath 上搜索文件 META-INF/services/javax.xml.stream.XMLInputFactory。该文件需要包含单独的一行 my.factory.class。

  2. 使用 JDK 定义的默认工厂。

上面第 3 种方法有明显的性能问题,特别是在环境设置了很长的 classpath 的时候。为了查看某个备选的实现是否已被设定,必须扫描整个 classpath,搜索每个入口中的 META-INF/services 目录下的特定文件。而且,每次创建工厂时都会重复这个查找过程。所以,如果类加载器没有缓存资源的查找结果(大多数类加载器没有缓存),初始化工厂的代价就非常高。

更好的做法是用前两种办法配置应用。系统会依上述列表的顺序查找工厂,一旦找到,搜索过程就会停止。

这两种方法的不足之处在于,它们是全局的,会影响应用服务器上的所有代码。如果两个不同的企业应用部署到了同一个服务器上,并且需要不同的解析器工厂,那服务器就要必须依靠在 classpath 上搜索工厂的技术了(可能会很慢)。

发现解析器工厂的方法甚至还影响了默认工厂:JDK 必须要搜索完 classpath 后才知道使用默认工厂。因此,即便你使用默认工厂,你也应该通过配置全局系统属性或 Java 运行时环境(JRE)属性文件来指向默认实现。否则,只有在第 3 步花费了昂贵代价搜索之后,才会使用默认工厂(列表中的第 4 项)。

对 JSON 来说,配置有少许不同:指定其他实现的唯一办法是,在 META-INF/services 下指定一个名为 javax.json.spi.JsonProvider 的包含新 JSON 解析器实现类的类名的文件。不幸的是,查找 JSON 工厂时,没有办法避免搜索整个 classpath。

选择解析器的最后一个性能考量点是备选实现的性能。本节只是对一些解析器实现性能的快速浏览,没有必要在意表面上的结果。不同的实现之间总有差异。就性能而言,不同实现之间可能各有千秋。某些时候,备选实现会比参考实现快(到 JDK 新的发布版或者新的 JSON-P 参考实现时,参考实现可能就会超过备选实现)。

比如Woodstox 的 StAX 解析器就比 JDK 7 和 8 所带的解析器要快一些.

数据条数JDK StAX解析器(毫秒)Woodstox StAX解析器(毫秒)
10143125
20265237

解析器选择小结

  1. 选择的解析器是否合适,对应用的性能有巨大的影响。

  2. 推模式解析器通常比拉模式解析器快。

  3. 查找解析器工厂的算法非常耗时;如果可能的话,应该通过系统属性直接指定工厂而不是用现有的实现。

  4. 在不同的时间点上,最快的解析器实现的赢家可能会不同。适当的时候,应该从备选的解析器中找。

XML验证

解析器可依据一个 schema(意为“模式”)对 XML 数据进行验证,拒绝语法不正确的文档——指缺少某些必要的信息,或者包含了不该有的信息的文档。此处所说的“语法正确”是指文档内容,如果文档有语法错误(比如文档没有包含在 XML 标签中,或者缺少 XML 闭合标签等),所有解析器都不会接收该文档。

这种验证是 XML 相比 JSON 所具有的一个优点。解析 JSON 文档时你可以自己提供验证逻辑,但解析 XML 时,解析器能替你做这些验证。但这个好处是有性能代价的。

XML 验证是依据一个或多个 schema 或 DTD 文件进行的。虽然 DTD 的验证更快,但 XML schema 更灵活,现在是 XML 世界中的主流。schema 比 DTD 慢的一个原因是,schema 通常在多个文件中设定。所以减少验证成本的第一个方法就是整合 schema 文件:schema 文件越多,验证的代价越高。需要在多个文件的可维护性和性能收益之间进行权衡。不幸的是,由于 schema 文件维护了不同的命名空间,所以整合起来并不容易(就像 CSS 或 JavaScript 文件那样)。

从何处装载 schema 文件对性能有极大的影响。如果必须反复从网络上装载 schema 或 DTD,性能就会变糟糕。理想情况下,schema 文件应该随着应用代码一起分发,这样就能从本地文件系统装载了。

对常见的 SAX 验证来说,只需要用代码为 SAX 解析器工厂设置一些属性即可(这只对 SAX 解析器有效;对 StAX 解析器而言,除非使用本节后面讨论的 Validator 对象,否则验证依据的是 DTD 文件而不是 schema)。

SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setValidating(true);
spf.setNamepsaceAware(true);
SAXParser parser = spf.newSAXParser();
// 注意:创建解析器时可以执行上面的几行代码
// 如果重用该解析器,而不是调用parser.reset(),则需要设置属性
parser.setProperty(JAXPConstants.JAXP_SCHEMA_LANGUAGE,
       XMLConstants.W3C_XML_SCHEMA_NS_URI);
XMLReader xr = parser.getXMLReader();
xr.setErrorHandler(new MyCustomErrorHandler());

解析器默认没有验证,所以得先调用 setValidating(),然后设置属性,将验证所依据的语言告诉解析器——本例中为 W3C XML schema 语言(例如 XSD 文件)。最后,设置解析器在验证出错时的处理程序。

这种处理方式——XML 文档的默认处理方式——会在每次解析新文档时重读 schema,即便解析器本身已经重用。为了更好的性能,可以考虑重用 schema。

即便从文件系统装载 schema,重用 schema 也会有很大的好处。装载 schema 时,必须解析和处理它(毕竟它自己也是 XML 文档)。保留处理结果并重用可以极大地提升 XML 的处理效率。这在绝大多数应用场景下都是正确的:应用接收和处理成千上万的 XML 文档,而所有这些文档都遵循相同的一个(或一组)schema。

重用 schema 的方法有两种,第一种(只对 SAX 解析器有效)是创建 schema 对象并与 SAXParserFactory 关联:

SchemaFactory sf = SchemaFactory.newInstance(
        XMLConstants.W3C_XML_SCHEMA_NS_URI);
StreamSource ss = new StreamSource(rp.getSchemaFileName());
Schema schema = sf.newSchema(new Source[]{ss});
SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setValidating(false);
spf.setNamespaceAware(true);
spf.setSchema(schema);
parser = spf.newSAXParser();

请注意,这个例子中调用 setValidating() 时参数为 false。setSchema() 和 setValidating() 是两种互相矛盾的文档验证方法.

重用 schema 对象的第二种方法是使用 Validator,将解析与验证分离,使得两种操作可以在不同时间进行。用 StAX 解析器解析时,可以在流的验证过程中嵌入特定的 reader 来进行验证。

使用 Validator 时,首先要创建特定的 reader。reader 的处理逻辑和之前相同:查找起始元素(start element)itemID,找到之后保存这些 ID。不过,这些操作必须委托给默认的 StAX 流 reader:

private class MyXMLStreamReader extends StreamReaderDelegate() {
    XMLStreamReader reader;
    public MyXMLStreamReader(XMLStreamReader xsr) {
        reader = xsr;
    }

    public int next() throws XMLStreamException {
        int state = super.next();
        switch (state) {
            case XMLStreamConstants.START_ELEMENT:
                ……处理起始元素Item ID……
                break;
            case XMLStreamConstants.CHARACTERS:
                ……如果是item id,则保存当前的字符。
                break;
        }
        return state;
    }
}

接下来,将这个 reader 与 Validator 所用的输入流关联。

SchemaFactory sf = SchemaFactory.newInstance(
        XMLConstants.W3C_XML_SCHEMA_NS_URI);
StreamSource ss = new StreamSource(rp.getSchemaFileName());
Schema schema = sf.newSchema(new Source[]{ss});
XMLInputFactory staxFactory = XMLInputFactory.newInstance();
staxFactory.setProperty (XMLInputFactory.IS_VALIDATING, Boolean.FALSE);
XMLStreamReader xsr = staxFactory.createXMLStreamReader(ins);
XMLStreamReader reader = new MyXMLStreamReader(xsr);
Validator validator = schema.newValidator();
validator.validate(new StAXSource(new StaxSource(reader)));

validate() 在进行常规验证时还会调用 StreamReaderDelegate(此处为 MyXMLStreamReader),它会从输入数据中解析所需要的信息(实质上是验证过程的意外收获)。

这种方法的不足之处在于,处理过程无法在读取若干条数据之后干净利索地终止。next() 依旧可以在抛出异常之后捕获该异常——就像前面的 SAXDoneException。此处有个问题,即默认的 schema 监听器在处理遇到异常时会打印错误信息。

下表列出了所有这些操作的影响。与简单的解析(不进行验证的)相比,带有默认验证的解析会付出更大的性能代价。重用 schema 可以弥补一些性能损耗,并能确保 XML 文档是合乎语法规范的,但验证总会有些显著的代价。

解析模式SAX(毫秒)StAX(毫秒)
无验证231265
默认验证730N/A
重用 schema 的验证6491392

XML验证小结

  1. 如果业务需要进行 schema 验证,那就用它。只是要留意,验证对解析数据的性能会带来显著的损耗。

  2. 总是重用 schema,以将验证对性能的影响降至最低。

文档模型

构建文档对象模型(Document Object Model,DOM)或 JSON 对象只需要一组相对简单的方法调用。对象本身是随着底层的解析器而创建的,所以优化性能的一个重要举措是配置好解析器(DOM 默认使用 StAX 解析器)。

DOM 对象是随着 DocumentBuilder 对象而创建的,而 DocumentBuilder 来自 DocumentBuilderFactory。默认的 DocumentBuilderFactory 可通过属性 javax.xml.parsers.DocumentBuilderFactory(或者文件 META-INF/services/javax.xml.parsers.DocumentBuilderFactory)来指定。由于创建解析器时所配置的属性对性能优化很重要,所以创建 DocumentBuilder 时配置的系统属性也就变得很重要了。

和 SAX 解析器一样,只要在使用前调用 reset() 方法就能重用 DocumentBuilder 对象。

JSON 对象是随着 JsonReader 对象而创建的,而 JsonReader 可直接来自 Json 对象(通过调用 Json.createReader())或来自 JsonReaderFactory 对象(通过调用 Json.createJsonReaderFactory())。虽然 JSR 353 RI 现在还没有支持任何配置选项,但通过属性 Map 可以配置 JsonReaderFactory。JsonReader 对象不能重用。

如表所示,与简单解析数据相比,DOM 的创建代价比较高。

操作XML(毫秒)JSON(毫秒)
解析数据265146
构建文档348197

构建文档的时间包括解析的时间加上创建文档对象结构的时间——所以从这张表可以推算出,对 XML 而言构建文档结构的时间大约占了总时间的 33%,对 JSON 而言则占了 25%。对更复杂的文档来说,构建文档模型所占用的时间百分比会更大。

之前的解析测试有时候可能只关心前 10 个条目。如果对象展现类似地也包括这前 10 个元素,那就有两种选择。首先创建对象,调用各种方法遍历对象,并丢弃不需要的条目。这是 JSON 对象唯一的选择。

DOM 对象可以用 DOM 级别 3 的属性建立过滤解析器。首先要创建一个解析过滤器:

private class InputFilter implements LSParserFilter {
    private boolean done = false;
    private boolean itemCountReached;
    public short acceptNode(Node node) {
        if (itemCountReached) {
            String s = node.getNodeName();
            if ("ItemArray".equals(s)) {
               return NodeFilter.FILTER_ACCEPT;
            }
            if (done) {
               return NodeFilter.FILTER_SKIP;
            }
            // 我们不需要元素</Item>
            if ("Item".equals(s)) {
               done = true;
            }
        }
        return NodeFilter.FILTER_ACCEPT;
    }

    public int getWhatToShow() {
        return NodeFilter.SHOW_ALL;
    }

    public short startElement(Element element) {
        if (itemCountReached) {
            return NodeFilter.FILTER_ACCEPT;
        }
        String s = element.getTagName();
        if (ITEM_ID.equals(element.getTagName())) {
            if (addItemId(element.getNodeValue())) {
                itemCountReached = true;
            }
        }
        return NodeFilter.FILTER_ACCEPT;
    }
}

每个元素会调用两次解析过滤器:解析元素开始时,调用 startElement(),结束元素结束时,调用 acceptNode()。如果元素不应在最终的 DOM 文档中出现,就应在上述两个方法之一中返回 fiLTER_SKIP。在这个例子中,startElement() 用来追踪有多少条目已经被处理了,而 acceptNode() 则用来判定是否整个元素应该被跳过。请注意,acceptNode() 中也需要追踪结尾的 标签,以免跳过。也请注意,只有类型 ItemArray 的子节点元素才会被跳过,XML 文档的其他元素则不应该被跳过。

为了设定输入过滤器,可以使用以下代码:

System.setProperty(DOMImplementationRegistry.PROPERTY,
      "com.sun.org.apache.xerces.internal.dom.DOMImplementationSourceImpl");
DOMImplementationRegistry registry =
      DOMImplementationRegistry.newInstance();
DOMImplementation domImpl = registry.getDOMImplementation("LS 3.0");
domLS = (DOMImplementationLS) domImpl;
LSParser lsp = domLS.createLSParser(DOMImplementationLS.MODE_SYNCHRONOUS,
                   "http://www.w3.org/2001/XMLSchema");
lsp.setFilter(new InputFilter());
LSInput lsi = domLS.createLSInput();
lsi.setByteStream(is);
Document doc = lsp.parse(lsi);

最后创建 Document 对象,如同它没有过滤输入——不过在这个例子中,结果文档要小得多。这是过滤的关键点:在实际的解析和过滤过程中,生成过滤的文档所花费的时间比生成包含所有原始信息的文档花费的时间要多。因为文档占用的内存更小,且能减少对垃圾收集器的压力,所以这在文档需要长时间存活(或者有许多这样的文档在使用)时很有用。

下表显示的是构造只有一半(10 个)条目的 DOM 对象时,解析一般的 XML 文件的速度差异。

标准DOM过滤DOM
创建 DOM 所用时间348 毫秒417 毫秒
DOM 的大小101 440 字节58 824 字节

文档模型小结

  1. 使用 DOM 和 JsonObject 比用简单解析器要强大得多,但构造模型所花的时间长度会很显著。

  2. 过滤模型数据比构造默认模型要花费更多的时间,但对于长时间运行或者很大的文档来说,仍然是值得的。

Java对象模型

处理文本数据的最后一种选择是在解析相关的数据之后创建一组 Java 类实例。JSR 有此类的 JSON 建议(proposal),但没有标准。对 XML 来说,这是通过 JAXB 来实现的。

JAXB 底层用的是 StAX 解析器,所以为你的平台选择最佳的 StAX 解析器配置有助于提高 JAXB 的性能。JAXB 通过创建 Unmarshaller 对象来创建 Java 对象:

JAXBContext jc = JAXBContext.newInstance("net.sdo.jaxb");
Unmarshaller u = jc.createUnmarshaller();

创建 JAXBContext 的代价比较昂贵。幸运的是,它是线程安全的:可以创建单个全局的 JAXBContext,然后重用(在多个线程间共享)。但 Unmarshaller 对象不是线程安全的,所以每个线程必须创建一个新对象。不过 Unmarshaller 对象可以重用,所以将它保存在线程本地变量中(或者对象池中),将有助于提高处理大量文档时的性能。

通过 JAXB 创建对象的代价要比解析或创建 DOM 文档的代价昂贵。但权衡下来,使用这些对象还是要比遍历文档快得多(甚至,使用对象就仅仅是写常规的 Java 代码,而不是用错综复杂的 API 来访问)。此外,依据 JAXB 文档编写 XML 要比直接从文档编写 XML 快得多。下表显示了性能差异,示例文档有 20 个条目。

编组(毫秒)解组(毫秒)
DOM348298
JAXB414232

过滤 XML 和 JAX-WS

即便使用 JAXB,你需要处理的通常也只是部分 XML 数据。而一般来说,JAX-WS 会基于 JAXB 将整个 XML 转换成 Java 对象。从易用角度来看,这种方法很好,它使得应用代码更容易编写和维护。但是,如果只需要访问部分 XML,用 JAXB 处理整个文档就太奢侈了,并且所有这些 JAXB 对象会消耗太多堆内存。

在这个例子中,XML 数据应该作为 SOAP 消息的附件发送(MIME 类型为 application/xml)。附件不会被转换成 JAXB 对象,你可以用 DOM builder 过滤,或者用简单的 StAX 解析器只处理你所关心的文档部分。

JAVA对象模型小结

  1. JAXB 将 XML 文档生成 Java 对象,以最简单的编程模型访问和使用数据。

  2. 创建 JAXB 对象的代价比创建 DOM 对象的昂贵。

  3. JAXB 写 XML 数据的速度要快于 DOM 对象。

更新时间:2020-04-06 18:12:01

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优16javaee性能调优上
最后更新:2020-04-06 18:12:01

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×