使用实时 Java 进行开发,第 2 部分: 改善服务质量
 

2009-12-04 作者:Mark Stoodley,Charlie Gracie 来源:IBM

 
本文内容包括:
一些 Java™ 应用程序未能提供适当的服务质量,尽管实现了其他性能目标,比如平均延迟或总吞吐量。通过引入不受应用程序控制的暂停或中断机制,Java 语言和运行时系统有时可能使应用程序无法满足服务性能指标。本文(本系列 的第二部分)解释 JVM 中的延迟和中断的根源,介绍可用于减轻这些问题的技术,使您的应用程序能够交付更加一致的服务质量。

Java 应用程序中的易变性(通常是由暂停或延迟导致的,其发生时间无法预测)可能在整个软件栈中发生。延迟可由以下因素引起:

  • 硬件(缓存期间)
  • 固件(处理 CPU 温度数据等系统管理中断的过程中)
  • 操作系统(响应一个中断或执行定期调度的后台活动)
  • 在相同系统上运行的其他程序
  • JVM(垃圾收集、即时编译和类加载)
  • Java 应用程序本身

很难在较高级别上补偿较低级别上的延迟,所以,如果您试图仅在应用程序级别解决易变性,您可能只是转移了 JVM 或 OS 延迟,并没有解决实际问题。幸运的是,较低级别的延迟可能比较高级别上的延迟相对短一些,所以只有在降低易变性的需求非常强烈时,才需要深入到比 JVM 或 OS 更低的级别上。如果需求不是那么强烈,您可以将精力集中在 JVM 级别上或应用程序中。

实时 Java 提供了必要的工具来堵截 JVM 和应用程序中的易变性源头,交付用户要求的服务质量。本文详细介绍 JVM 和应用程序级别上的易变性源头,介绍可用于减轻其影响的工具和技术。然后介绍一个简单的 Java 服务器应用程序来演示其中一些概念。

解决易变性源头

JVM 中的易变性主要源自于 Java 语言的动态特性:

  • 内存绝不会被应用程序显式释放,而是被垃圾收集器定期回收。
  • 类在被应用程序首次使用时才进行解析。
  • 本机代码在应用程序运行时由即时(JIT)编译器编译(而且可以重新编译),基于经常调用的类和方法。

在 Java 应用程序级别上,线程管理是与易变性相关的关键区域。

垃圾收集暂停

当垃圾收集器回收程序不再使用的内存时,它可以停止任何应用程序线程。(这种类型的收集器称为 Stop-the-world 或 STW 收集器)。或者它可以与应用程序同时执行自己的一些工作。无论是哪种情况,垃圾收集器需要的资源都不能供应用程序使用,所以,众所周知,垃圾收集(GC)是 Java 应用程序性能中的暂停和易变性的源头。尽管许多 GC 模型都具有自己的优缺点,但当应用程序的目标是缩短 GC 暂停时,两个主要的选择将是分代(generational)实时 收集器。

分代收集器将堆组织为至少两个部分,这两个部分通常称为(有时称为保留)空间。新对象始终在新空间中分配。当新空间耗尽空闲内存时,将仅在该空间中进行垃圾收集。使用相对较小的新空间可能时 GC 周期更短。在多次新空间垃圾收集过程中存留下来的对象会被提升到旧空间中。旧空间垃圾收集发生的频率通常比新空间垃圾收集低得多,但是由于旧空间比新空间大得多,所以这些 GC 周期可能长得多。分代垃圾收集器提供了相对较短的平均 GC 暂停时间,但是旧空间收集的开销可能导致这些暂停时间的标准偏差非常大。对于活动数据集不会经常更改,但会产生大量垃圾的应用程序而言,分代收集器是最有效的。在这种场景中,旧空间收集极少发生,因此 GC 暂停时间取决于短的新空间收集时间。

与分代收集器相反,实时垃圾收集器会控制自身的行为,以显著缩短 GC 周期的长度(通过在应用程序空闲时执行周期)或减轻这些周期对应用程序性能的影响(通过基于与应用程序之间的一种 “契约”,以更小的增量执行工作)。使用这类收集器,您可以预测完成特定任务的最遭情形。例如,IBM® WebSphere® Real-Time JVM 中的垃圾收集器将 GC 周期划分为较小的工作片段(称为 GC 限额),这些限额可以增量方式完成。对限额的调度对应用程序性能的影响极小,其延迟可低至几百微秒,通常小于 1 毫秒。为了达到这种延迟级别,垃圾收集器必须能够计划自己的工作,方法是引入应用程序利用契约 的概念。此契约管理允许 GC 中断应用程序执行工作的频率。例如,默认的利用契约为 70%,也就是在实时操作系统上运行时,仅允许 GC 使用每 10 毫秒中的至多 3 毫秒,典型的暂停时间大约为 500 微秒。(参见 “实时 Java,第 4 部分: 实时垃圾收集”,获取对 IBM WebSphere Real Time 垃圾收集器操作的详细介绍)。

在实时垃圾收集器上运行应用程序时,堆大小和应用程序利用率是要考虑的重要调优选项。随着应用程序利用率的增加,垃圾收集器完成其工作的时间会更短,因此需要更大的堆来确保 GC 周期可以增量式地完成。如果垃圾收集器无法跟上分配速度,GC 将采用同步收集。

例如,与在使用分代垃圾收集器的 JVM 上(未提供利用契约)运行时相比,在 IBM WebSphere Real-Time JVM 上运行的应用程序(具有 70% 的默认应用程序利用契约)默认需要更大的堆。由于实时垃圾收集器控制着 GC 暂停时间的长度,所以增加堆大小会降低 GC 频率,不会延长各次暂停时间。另一方面,在非实时垃圾收集器中,增加堆大小通常会降低 GC 周期的频率,这会降低垃圾收集器的总体影响。当发生垃圾收集时,暂停时间通常会更长(因为需要检查更大的堆)。

在 IBM WebSphere Real Time JVM 中,可以使用 -Xmx<size> 选项调整堆大小。例如,-Xmx512m 指定堆大小为 512MB。还可以调整应用程序利用率。例如,-Xgc:targetUtilization=80 将利用率设置为 80%。

Java 类加载暂停

Java 语言规范要求在应用程序首次引用类时对类进行解析、加载、验证和初始化。如果对一个类 C 的首次引用发生在时间关键型操作期间,那么解析、验证、加载和初始化 C 的时间可能导致执行操作的时间比预期更长。由于加载 C 涉及到验证该类(这可能需要加载其他类),所以 Java 应用程序为了能够首次使用特定类而发生的总延迟可能比预期长很多。

为什么类只能在应用程序执行期间首次被引用?很少执行的路径是加载新类的一个常见原因。例如,清单 1 中的代码包含一个可能很少发生的 if 条件。(为了简单起见,我们尽可能省略了本文中所有清单中的异常和错误处理)。


清单 1. 用于加载新类的很少执行的条件示例
 
				
Iterator<MyClass> cursor = list.iterator();
while (cursor.hasNext()) {
    MyClass o = cursor.next();
    if (o.getID() == 17) {
        NeverBeforeLoadedClass o2 = new NeverBeforeLoadedClass(o);
        // do something with o2
    }
    else {
        // do something with o
    }
}

异常类是只能在应用程序执行期间加载的类的另一个例子,因为异常在理想情况下(但不一定会遇到这种情况)很少发生。由于异常通常难以快速处理,所以加载额外的类的附加开销可能使操作延迟超出重要阈值。一般而言,应该尽可能避免在时间关键型操作期间抛出异常。

也可以在 Java 类库中使用某些服务(比如反射)时加载新类。反射类的底层实现会动态生成将加载到 JVM 中的新类。在时间敏感型代码中反复使用反射类可能导致持续不断的类加载活动,这会引起延迟。使用 -verbose:class 选项是检测正在被创建的类的最佳方式。或许避免在程序执行期间创建这些类的最佳方式在于,避免在应用程序的时间关键型部分使用反射服务来从字符串映射类、字段或方法。相反,在应用程序执行过程中尽早调用这些服务并存储结果共以后使用,这可以避免在不需要时动态创建大部分这样的类。

一种在应用程序的时间敏感型部分避免类加载延迟通用技术是,在应用程序启动或初始化期间预先加载类。尽管这个预加载步骤带来一定的启动延迟(改善一个指标通常会对其他指标带来负面影响),但是如果小心使用,这一步可以在以后消除不需要的类加载过程。这种启动流程很容易实现,如清单 2 所示:

清单 2. 从一组类中以受控方式加载类
 
				
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n=clazz.getName();
    } catch (Exception e) {
    System.err.println("Could not load class: " + className);
    System.err.println(e);
}

注意 clazz.getName() 调用,它强制执行类初始化。构建类列表需要在应用程序运行时从其中收集信息,或者使用一个实用工具来确定应用程序将加载哪些类。例如,可以使用 -verbose:class 选项在程序运行时捕获输出。清单 3 显示了在使用 IBM WebSphere Real Time 产品时,此命令的可能输出:


清单 3. 使用 -verbose:class 命令运行 java 的部分输出
 
    ...
    class load: java/util/zip/ZipConstants
    class load: java/util/zip/ZipFile
    class load: java/util/jar/JarFile
    class load: sun/misc/JavaUtilJarAccess
    class load: java/util/jar/JavaUtilJarAccessImpl
    class load: java/util/zip/ZipEntry
    class load: java/util/jar/JarEntry
    class load: java/util/jar/JarFile$JarFileEntry
    class load: java/net/URLConnection
    class load: java/net/JarURLConnection
    class load: sun/net/www/protocol/jar/JarURLConnection
    ...

通过存储应用程序在执行时将加载的类列表,并使用该列表填充 清单 2 中显示的循环的类名称列表,可以确保在应用程序开始运行之前加载这些类。当然,不同时刻执行应用程序可能加载不同的路径,所以一次执行的列表可能并不完整。出于此原因,如果应用程序正在开发之中,新编写或修改的代码可能依赖于未包含在列表中的新类(或者虽然包含在列表中,但不再需要的类)不幸的是,维护类列表可能是使用此方法预加载类的非常模麻烦的一部分。如果使用此方法,请记住,-verbose:class 输出的类名称与 -verbose:class does not match the format that's needed by Class.forName() 需要的格式并不匹配:详细输出中使用正斜杠将类包分开,而 Class.forName() 期望用句点来分开它们。

对于存在类加载问题的应用程序,可以借助一些工具来管理预加载,包括 Real Time Class Analysis Tool (RATCAT) 和 IBM Real Time Application Execution Optimizer for Java(参见 参考资料)。这些工具能够在一定程度上自动识别要预加载的正确类列表,以及将类预加载代码合并到应用程序中。

JIT 代码编译暂停

JVM 中的第三个延迟来源是 JIT 编译器。在应用程序运行时,它将程序的方法从 javac 生成的字节码翻译为运行应用程序的 CPU 的本机指令。JIT 编译器是 Java 平台取得成功的基础,因为它实现了很高的应用程序性能,而且没有牺牲 Java 字节码的平台独立性。在过去 10 多年中,JIT 编译器工程师一直在尽力改善 Java 应用程序的吞吐量和延迟。

不幸的是,这类改进带来了 Java 应用程序性能的暂停,因为 JIT 编译器从应用程序 “偷取” 了一些周期来为特定方法生成已编译(或要重新编译)的代码。取决于被编译方法的大小和 JIT 选择编译它的积极程度,编译时间可能小于 1 微妙,也可能大于 1 秒(对于 JIT 编译器发现的非常大的方法,这类方法会占用应用程序的大量执行时间)。但是 JIT 编译器本身的行为并不是应用程序计时中的意外偏差的唯一来源。因为 JIT 编译器工程师将绝大部分精力都用在平均性能上,以最有效地改进吞吐量和延迟性能,所以 JIT 编译器通常执行多种优化,这些优化 “通常” 是正确的或 “在大部分情况下” 具有很高的性能。一般而言,这些优化非常有效,并且开发了启发方法来使优化很好地符合最常见的应用程序运行场景。但是,在一些情形下,这类优化可能带来严重的性能易变性。

除了预加载所有类,还可以请求 JIT 编译器在应用程序初始化期间显式编译这些类的方法。清单 4 扩展了 清单 2 中的类预加载代码,以控制方法编译:

清单 4. 受控的方法编译
 
				
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n = clazz.name();
        java.lang.Compiler.compileClass(clazz);
    } catch (Exception e) {
        System.err.println("Could not load class: " + className);
        System.err.println(e);
    }
}
java.lang.Compiler.disable();  // optional

这段代码将使 JIT 编译器加载一组类并编译所有这些类的方法。最后一行为应用程序的其余执行部分禁用 JIT 编译器。

与允许 JIT 编译器自由选择将编译哪些方法相比,此方法通常会导致较低的总吞吐量或延迟性能。因为在 JIT 编译器运行之前不必调用方法,JIT 编译器仅拥有少量与如何最佳地优化它要编译的方法相关的信息,所以这些方法的执行速度会更慢。而且,由于编译器被禁用,不会重新编译任何方法,即使这些方法占用了程序执行时间的一大部分,所以,大多数现代 JVM 中使用的这类自适应 JIT 编译框架将不起作用。要减少大量由 JIT 编译器引起的暂停,不是必须使用 Compiler.disable() 命令,但是保留下来的暂停将是在应用程序的热方法上执行的更加频繁的重编译,这通常需要更长的编译时间,对应用程序计时的潜在影响更大。在调用 disable() 方法时,可能不会卸载特定 JVM 中的 JIT 编译器,所以在应用程序运行时阶段,仍然可能消耗内存、加载共享库以及出现其他 JIT 编译器工件。

当然,本机代码编译对各个应用程序的性能的影响程度不尽相同。确定编译是否存在问题的最好方法是打开详细输出,确定编译发生的时间,进而确定它们是否影响应用程序计时。例如,使用 IBM WebSphere Real Time JVM,您可以使用 -Xjit:verbose 命令行选项打开 JIT 详细日志。

除了这种预加载和早期编译方法,应用程序作者无法执行太多操作来避免由 JIT 编译器引起的暂停,但特定于供应商的 JIT 编译器命令行选项除外(一种充满风险的方法)。JVM 供应商很少在生产场景中支持这些选项。由于它们不是默认的配置,所以供应商没有很好地测试它们,它们在各个版本中的名称和含义也可能不同。

但是,一些替代的 JVM 可以为您提供一些选项,具体取决于 JIT 编译器引起的暂停对您有多重要。设计用于硬实时 Java 系统的实时 JVM 提供了更多选项。例如,IBM WebSphere Real Time For Real Time Linux® JVM 具有 5 种代码编译战略,可以将它们与各种功能结合使用来减少 JIT 编译器暂停:

  • 默认 JIT 编译,JIT 编译器线程在较低优先级上运行
  • 较低优先级上的默认 JIT 编译,在最初使用了提前(Ahead-of-time,AOT)编译代码
  • 在启动时受程序控制的编译,启用了重新编译
  • 在启动时受程序控制的编译,禁用了重新编译
  • 仅 AOT 编译代码

这些选项根据预期的吞吐量/延迟性能级别和预期的暂停时间的降序来排列。默认的 JIT 编译选项使用在最低优先级(可能低于应用程序线程)上运行的 JIT 编译线程,该选项提供了最高的预期吞吐量性能,但也可能显示由(这 5 个选项的)JIT 编译引起的最多的暂停。前两个选项使用异步编译,这意味着如果应用程序线程尝试调用被选择用于重新编译的方法,那么该线程无需等到编译完成。最后一个选项具有最低的预期吞吐量/延迟性能,但没有由 JIT 编译器引起的暂停,因为此方案完全禁用了 JIT 编译器。

IBM WebSphere Real Time for Real Time Linux JVM 提供了一个称为 admincache 的工具,支持创建包含来自一组 JAR 文件的类文件的共享类缓存,也可以在相同代码中存储这些类的提前编译代码。可以在您的 java 命令行设置一个选项,以将存储在共享类缓存中的类从缓存加载到 JVM 中,以及在加载类时将 AOT 代码自动加载到 JVM 中。类似于 清单 2 中所示的类预加载循环已足够确保您充分获取提前编译代码的优势。参见 参考资料,获取 admincache 文档的链接。

线程管理

在交易服务器等多线程应用程序中,控制线程的执行对于消除交易时间的易变性至关重要。尽管 Java 编程语言定义了一种线程模型,该模型包含线程优先级的概念,但实际 JVM 中的线程行为主要由实现定义,包含 Java 程序可以依赖的许多规则。例如,尽管可以为 Java 线程分配 10 个线程优先级中的一个,但这些应用程序级优先级到操作系统优先级值之间的映射是由实现定义的。(对于 JVM,将所有 Java 线程优先级映射到相同的操作系统优先级值是一种非常有效的方法)。出于此原因,Java 线程的调度策略也是由实现定义的,但是通常在最终被分成一些时间段,所以即使是高优先级的线程最终也会与低优先级线程共享 CPU 资源。与较低优先级线程共享资源可能导致在调度较高优先级线程时出现延迟,以让其他任务可以获得一个时间片段。请记住,线程获取的 CPU 量不仅依赖于优先级,还依赖于需要调度的线程总数。除非可以严格控制在任何给定时间有多少活动线程,否则,即使是最高优先级线程用于执行操作的时间也可能出现相对较大的差异。

所以,即使您为工作者线程指定最高的 Java 线程优先级(java.lang.Thread.MAX_PRIORITY),也不会提供与系统上较低优先级任务之间太高的隔离级别。不幸的是,除了使用固定的工作线程集(不继续分配新线程,而依赖于 GC 收集未使用的线程,或者扩大和缩小线程池)并尝试将在应用程序运行时系统上低优先级活动的数量减到最少,您无法采取其他更多措施,因为标准 Java 线程模型未提供控制线程行为所需的工具。在这里,即使是软实时 JVM(如果它依赖于标准 Java 线程模型)也不能经常提供帮助。

但是,与标准 Java 相比,支持 Real Time Specification for Java (RTSJ) 的硬实时 JVM(比如 IBM WebSphere Real Time for Real Time Linux V2.0 或 Sun 的 RTS 2)可以提供大大改进的线程行为。在对标准 Java 语言和 VM 规范的增强中,RTSJ 引入了两类新的线程 RealtimeThreadNoHeapRealtimeThread,它们的定义比标准 Java 线程模型要严格得多。这些线程类型提供了真正基于抢占优先级的调度机制:如果需要执行高优先级任务并且处理器核心上目前计划执行一个较低优先级任务,那么该较低优先级任务将被抢占,以便高优先级任务可以执行。

大部分实时 OS 都能够在数十微秒内执行这种抢占机制,这仅会影响到具有极高的计时需求的应用程序。两种新线程类型都是用 FIFO(先进先出)调度策略,而不是在大部分 OS 上运行的 JVM 所使用的熟悉的循环调度策略。循环调度和 FIFO 调度策略之间最明显的区别在于,在具有相同优先级的线程中,一旦计划让一个线程继续执行,那么它只有在遇到阻塞或资源释放处理器时才会停止。此模型的优点在于,执行特定任务的时间更加容易预测,因为处理器不是共享的,即使有多个具有相同优先级的任务。出于此原因,如果您可以通过消除同步和 I/O 优先级来使线程不受阻塞,在线程启动之后,OS 将不会干预它。但是,在实际中,消除所有同步非常困难,所以很难为实际任务实现这种理想目标。尽管如此,FIFO 调度机制为尝试减少延迟的应用程序设计师提供了一项重要帮助。

您可以将 RTSJ 想作一个大型工具箱,它可以帮助您设计具有实时行为的应用程序,您可以仅使用两三个工具,或者可以重新编写应用程序来提供高度可预测的性能。修改应用程序来使用 RealtimeThread 通常不会很困难,甚至可以在不访问实时 JVM 来编译 Java 代码的情况下实现这一点,但是请小心使用 Java 反射服务。

但是,要利用 FIFO 调度机制的易变性优势,可能需要进一步更改您的应用程序。FIFO 调度的工作方式与循环调度不同,这些区别可能导致一些 Java 程序挂起。例如,如果应用程序依赖于 Thread.yield() 来允许其他线程在核心上运行(这种技术经常用于在不使用整个核心的情况下轮询某个条件),那么预期的效果将不会发生,因为使用 FIFO 调度机制,Thread.yield() 不会阻塞当前线程。由于当前线程仍然是可调度的,并且它位于 OS 内核中的调度队列的最前面,所以它将继续执行。因此,意图提供对 CPU 资源的公平访问(等待某个条件变为真)的编码模式实际上将消耗运行它的整个 CPU 核心。而这是最可能的结果。如果需要设置此条件的线程具有较低的优先级,那么它可能始终不能访问核心来设置条件。在如今的多核处理器中,这种问题可能很少发生,但是它强调您需要谨慎考虑在采用 RealtimeThread 线程时使用哪些优先级。最安全的方法是让所有线程使用一个优先级值,不使用 Thread.yield() 和将消耗整个 CPU(因为它们不会被阻塞)的其他类型的自旋循环。当然,充分利用可用于 RealtimeThread 的优先级值将最容易满足服务质量目标。(关于在应用程序中使用 RealtimeThread 的更多技巧,请参阅 “实时 Java,第 3 部分: 线程化和同步。”)

Java 服务器示例

在本文剩余部分,我们将应用在前面章节中介绍的一些想法,使用 Java 类库中的 Executors 服务构建一个相对简单的 Java 服务器应用程序。只需少量应用程序代码,Executors 服务就可以用于创建一个服务器来管理工作者线程池,如清单 5 所示:

清单 5. 使用 Executors 服务的 ServerTaskHandler
 
				
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;

class Server {
    private ExecutorService threadPool;
    Server(int numThreads) {
        ThreadFactory theFactory = new ThreadFactory();
        this.threadPool = Executors.newFixedThreadPool(numThreads, theFactory);
    }

    public void start() {
        while (true) {
            // main server handling loop, find a task to do
            // create a "TaskHandler" object to complete this operation
            TaskHandler task = new TaskHandler();
            this.threadPool.execute(task);
        }
        this.threadPool.shutdown();
    }

    public static void main(String[] args) {
        int serverThreads = Integer.parseInt(args[0]);
        Server theServer = new Server(serverThreads);
        theServer.start();
    }
}

class TaskHandler extends Runnable {
    public void run() {
        // code to handle a "task"
    }
}

此服务器可以创建所有需要的线程,直到达到创建服务器(从此示例中的命令行解码)时指定的最大数量。每个工作者线程使用 TaskHandler 类执行一部分工作。出于我们的目的,我们将创建一个 TaskHandler.run() 方法,它每次运行都应该花相同的时间。因此,执行 TaskHandler.run() 的时间上的任何易变性都源自于底层 JVM 中的暂停或易变性、某个线程问题或在堆栈的较低级别上引入的暂停。清单 6 给出了 TaskHandler 类:

清单 6. 具有可预测性能的 TaskHandler
 
				
import java.lang.Runnable;
class TaskHandler implements Runnable {
    static public int N=50000;
    static public int M=100;
    static long result=0L;
    
    // constant work per transaction
    public void run() {
        long dispatchTime = System.nanoTime();
        long x=0L;
        for (int j=0;j < M;j++) {
            for (int i=0;i < N;i++) {
                x = x + i;
            }
        }
        result = x;
        long endTime = System.nanoTime();
        Server.reportTiming(dispatchTime, endTime);
    }
}

run() 方法中的循环计算 N (50,000) 个整数中前 M (100) 个整数的和。MN 的值已经选定,以便运行循环的事务时间为 10 毫秒左右,使一项操作可以被一个 OS 调度限额(通常持续约 10 毫秒)中断。我们在此计算构造此循环的目的在于,使 JIT 编译器能够生成将执行高度可预测的时间量的出色代码:run() 方法不会显式在对 System.nanoTime() 的两次调用中显式阻塞,这两次调用用于计算循环运行的时间。由于上述代码高度可预测,所以我们可以使用它来展示延迟和易变性的不一定来源于您测试的代码。

我们使此应用程序稍微真实一些,在运行 TaskHandler 代码时激活垃圾收集器子系统。清单 7 给出了这个 GCStressThread 类:

清单 7. 用于不断生成垃圾的 GCStressThread
 
				
class GCStressThread extends Thread {
    HashMap<Integer,BinaryTree> map;
    volatile boolean stop = false;

    class BinaryTree {
        public BinaryTree left;
        public BinaryTree right;
        public Long value;
    }
    private void allocateSomeData(boolean useSleep) {
        try {
            for (int i=0;i < 125;i++) {
                if (useSleep)
                    Thread.sleep(100);
                BinaryTree newTree = createNewTree(15); // create full 15-level BinaryTree
                this.map.put(new Integer(i), newTree);
            }
        } catch (InterruptedException e) {
            stop = true;
        }
    }

    public void initialize() {
        this.map = new HashMap<Integer,BinaryTree>();
        allocateSomeData(false);
        System.out.println("\nFinished initializing\n");
    }

    public void run() {
        while (!stop) {
            allocateSomeData(true);
        }
    }
}

GCStressThread 通过 HashMap 维护一组 BinaryTree。它为存储新 BinaryTree 结构的 HashMap 迭代一组相同的 Integer 键,这些结构是完全填充的 15 级 BinaryTrees。(所以每个 BinaryTree 有 215 = 32,768 个节点被存储在 HashMap 中)。HashMap 在每次迭代时都持有 125 个 BinaryTree(活动数据),它每隔 10 毫秒就会将其中一个节点替换为新的 BinaryTree。通过这种方式,此数据结构维持着一个相当复杂的活动对象集,并且以特定速率生成垃圾。首先使用 initialize() 例程,借助 125 个 BinaryTree 来初始化HashMapinitialize() 例程不会在对每棵树的收集之间暂停。一旦启动了 GCStressThread(在启动服务器之前),它将通过服务器的工作者线程全权处理 TaskHandler 操作。

我们不打算使用客户端来驱动此服务器。我们将直接在服务器的主循环(在 Server.start() 方法中)中创建 NUM_OPERATIONS == 10000 操作。清单 8 给出了 Server.start() 方法:

清单 8. 在服务器内部分派操作
 
				
public void start() {
    for (int m=0; m < NUM_OPERATIONS;m++) {
        TaskHandler task = new TaskHandler();
        threadPool.execute(task);
    }
    try {
        while (!serverShutdown) { // boolean set to true when done
            Thread.sleep(1000);
        }
    }
    catch (InterruptedException e) {
    }
}

如果我们收集完成每个 TaskHandler.run() 调用的时间统计信息,我们可以看到 JVM 和应用程序设计引入了多少易变性。我们使用具有 8 个物理核心的 IBM xServer e5440,安装了 Red Hat RHEL MRG 实时操作系统。(禁用了超线程。注意,尽管超线程可以在基准测试中提供一些吞吐量改进,但是由于其虚拟核心并不完整,所以在启用超线程的处理器上的操作的物理核心性能可能具有明显不同的计时)。当在 8 核心机器上使用 IBM Java6 SR3 JVM 和 6 个线程运行此服务器时(我们将一个核心保留给 Server 主线程,另一个核心供 GCStressorThread 使用),我们得到了以下(代表性的)结果:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 16582 ms
Throughput is 603 operations / second
Histogram of operation times:
9ms - 10ms      9942    99 %
10ms - 11ms     2       0 %
11ms - 12ms     32      0 %
30ms - 40ms     4       0 %
70ms - 80ms     1       0 %
200ms - 300ms   6       0 %
400ms - 500ms   6       0 %
500ms - 542ms   6       0 %

可以看到,几乎所有操作都在 10 毫秒内完成,但是一些操作花了超过半秒(长 50 倍)的时间。这个差异太大了!我们看看如何消除 Java 加载、JIT 本机代码编译、GC 和线程导致的延迟,从而消除这种易变性。

我们首先通过 -verbose:class 完整地运行应用程序,收集应用程序加载的类列表。我们将输出存储到一个文件,然后修改它,使该文件的每行上都具有一个格式正确的名称。我们将一个 preload() 方法包含到 Server 类中,以加载每个类,JIT 编译这些类的所有方法,然后禁用 JIT 编译器,如清单 9 所示:

清单 9. 预加载服务器的类和方法
 
				
private void preload(String classesFileName) {
    try {
        FileReader fReader = new FileReader(classesFileName);
        BufferedReader reader = new BufferedReader(fReader);
        String className = reader.readLine();
        while (className != null) {
            try {
                Class clazz = Class.forName(className);
                String n = clazz.getName();
                Compiler.compileClass(clazz);
            } catch (Exception e) {
            }
            className = reader.readLine();
        }
    } catch (Exception e) {
    }
    Compiler.disable();
}

在这个简单的服务器中,类加载并不是一个重大问题,因为 TaskHandler.run() 方法非常简单:一旦加载该类,就不会在以后执行 Server 时发生太多类加载操作,这可以通过运行 -verbose:class 来验证。主要的优点源自于在运行任何测试 TaskHandler 操作之前运行方法。尽管我们可以使用预备循环,但此方法是特定于 JVM 的,因为 JIT 编译器用于选择要编译方法的启发机制在各个 JVM 实现之间有所不同。使用 Compiler.compile() 服务器会提供更加可控的编译行为,但正如本文前面提到的,使用此方法应该会使吞吐量下降。使用这些选项运行应用程序的结果如下:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms     9509    95 %
12ms - 13ms     478     4 %
13ms - 14ms     1       0 %
400ms - 500ms   6       0 %
500ms - 527ms   6       0 %

注意,尽管最长的延迟没有多大变化,但直方图比最初小多了。JTI 编译器明确引入了许多较短的延迟,所以较早执行编译,然后禁用 JIT 编译器显然是一大进步。另一个有趣的结果是,普通操作时间变得更长了(从 9 - 10 毫秒增加到了 11 - 12 毫秒)。操作变慢了,原因在于在调用方法之前强制执行 JIT 编译所生成的代码质量比完整编译的代码质量要低很多。这个结果并不奇怪,因为 JIT 编译器的一个最大优势是利用应用程序的动态特征来使其更高效地运行。

我们将在本文余下部分继续使用此类预加载和方法预编译代码。

由于我们的 GCStressThread 生成了一个不断变化的活动数据集,所以使用分代 GC 策略并不能提供太多暂停时间优势。相反,我们尝试使用 IBM WebSphere Real Time for Real Time Linux V2.0 SR1 产品中的实时垃圾收集器。最初的结果令人失望,甚至在添加了 -Xgcthreads8 选项之后也是如此,该选项允许收集器使用 8 个 GC 线程,而不是默认的 1 个线程。(使用一个 GC 线程,收集器无法可靠地跟上此应用程序的分配速度)。

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms     82      0 %
12ms - 13ms     250     2 %
13ms - 14ms     19      0 %
14ms - 15ms     50      0 %
15ms - 16ms     339     3 %
16ms - 17ms     889     8 %
17ms - 18ms     730     7 %
18ms - 19ms     411     4 %
19ms - 20ms     287     2 %
20ms - 30ms     1051    10 %
30ms - 40ms     504     5 %
40ms - 50ms     846     8 %
50ms - 60ms     1168    11 %
60ms - 70ms     1434    14 %
70ms - 80ms     980     9 %
80ms - 90ms     349     3 %
90ms - 100ms    28      0 %
100ms - 112ms   7       0 %

使用实时收集器显著缩短了最大操作时间,但是同时也增加了操作时间跨度。更糟的是,吞吐率大大降低。

最后一步是为工作者线程使用 RealtimeThread,而不是常规 Java 线程。我们创建一个 RealtimeThreadFactory 类,我们可以将该类提供给 Executors 服务,如清单 10 所示:

清单 10. RealtimeThreadFactory
 
				
import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;

class RealtimeThreadFactory implements ThreadFactory {
    public Thread newThread(Runnable r) {
        RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);

        // adjust parameters as needed
        PriorityParameters pp = (PriorityParameters) rtThread.getSchedulingParameters();
        PriorityScheduler scheduler = PriorityScheduler.instance();
        pp.setPriority(scheduler.getMaxPriority());

        return rtThread;
    }
}

如果将 RealtimeThreadFactory 类的一个实例传递给 Executors.newFixedThreadPool() 服务器,将导致工作者线程变为使用具有最高优先级的 FIFO 调度机制的 RealtimeThread。垃圾收集器仍然会在需要执行工作时中断这些线程,但其他较低优先级任务不会干扰工作者线程:

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms     159     1 %
12ms - 13ms     61      0 %
13ms - 14ms     17      0 %
14ms - 15ms     63      0 %
15ms - 16ms     1613    16 %
16ms - 17ms     4249    42 %
17ms - 18ms     2862    28 %
18ms - 19ms     975     9 %
19ms - 20ms     1       0 %

通过此更改,我们显著改善了最差的操作时间(降低到 19 毫秒)和总吞吐量(提升到每秒 357 个操作)。所以我们显著改善了操作时间的易变性,但是在吞吐量性能上付出了昂贵的代价。垃圾收集器的操作(使用每 10 毫秒中的至多 3 毫秒)解释了为什么通常需要约 12 毫秒的操作可改善至 4 - 5 毫秒,这也是大量操作现在需要花 16 - 17 毫秒时间的原因。吞吐量的下降可能比预期更严重,因为除了使用 Metronome 垃圾收集器,实时 JVM 还修改了预防优先级反转的锁定原语,优先级反转是使用 FIFO 调度时的一个重要问题(参见 “实时 Java,第 1 部分: 使用 Java 语言编写实时系统”)。不幸的是,主线程与工作者线程之间的同步导致了最终影响吞吐量的大量开销,但是我们没有将其作为任何操作时间的一部分来进行测量(所以没有在直方图中显示)。

所以,尽管我们在服务器中通过修改改善了可预测性,但它的吞吐量明显降低。但是,如果将少数很长的操作时间当作不可接受的服务质量水平,那么使用 RealtimeThread 和实时 JVM 可能是正确的解决方案。

结束语

在 Java 应用程序领域,吞吐量和延迟是应用程序和基准测试设计师选择用于报告和优化的传统指标。这种选择对构建用于改善性能的 Java 运行时具有广泛影响。尽管 Java 运行时已成为具有极慢的运行时延迟和极低的吞吐量的解释器,但现在对于许多应用程序,JVM 仍然可以在这些指标上与其他语言抗衡。但是,直到最近,这种观点不再适合一些可能对应用程序的已知性能产生极大影响的其他指标,尤其是会影响服务质量的易变性。

实时 Java 的引入为应用程序设计师提供了必要的工具来解决 JVM 和应用程序中的易变性源头,从而交付消费者和客户期望的服务质量。本文介绍了许多技术,您可以使用它们修改 Java 应用程序,以减少源自于 JVM 和线程调度的暂停和易变性。减少易变性通常会增加延迟时间和降低吞吐量性能。可接受的降低程度取决于适用于特定应用程序的工具。

参考资料

学习 获得产品和技术 讨论
火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。

资源网站: UML软件工程组织