Java性能调优20——Java SE API 技巧

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

在某些 Java SE API 的实现中,存在一些会影响性能的怪异行为,本章将对其进行探讨。JDK 中有很多这样的实现细节,经常会其导致的性能问题。

缓冲式I/O

下面就来谈一下缓冲式 I/O 的性能。InputStream.read() 和 OutputStream.write() 方法操作的是一个字符。由于所访问资源不同,这些方法有可能非常慢。而在 fileInputStream 上调用 read() 方法,更是慢得难以形容:每次调用该方法,都要进入内核,去取一个字节的数据。在大多数操作系统上,内核都会缓冲 I/O,因此,很幸运,该场景不会在每次调用 read() 方法时触发一次磁盘读取操作。但是这种缓冲保存在内核中,而非应用中,这就意味着每次读取一个字节时,每个方法调用还是会涉及一次代价高昂的系统调用。

写数据也是如此:使用 write() 方法向 fileOutputStream 发送一个字节,也需要一次系统调用,将该字节存储到内核缓冲区中。最后(当文件关闭或刷新时),内核会把缓冲区中的内容写入磁盘。

对于使用二进制数据的文件 I/O,记得使用一个 BufferedInputStream 或 BufferedOutputStream 来包装底层的文件流。对于使用字符(字符串)数据的文件 I/O,记得使用一个 BufferedReader 或 BufferedWriter 来包装底层的流。

在探讨文件 I/O 时,这一性能问题很好理解,不过它几乎存在于所有类型的 I/O 中。从 Socket 返回的流(通过 getInputStream() 或 getOutputStream())是以同样的方式运作的,在 Socket 上每次读写一个字节的 I/O 操作相当慢。所以同样要记得正确地使用一个缓冲过滤器流来包装一下。

在使用 ByteArrayInputStream 和 ByteArrayOutputStream 类时,微妙的问题更多。首先,这些类基本上就是大的内存缓冲区。在很多情况下,用缓冲管理器流包装它们,意味着数据会被复制两次:一次是缓冲在过滤器流中,一次是缓冲在 ByteArrayInputStream 中(输出流的情况相反)。除非还设计了其他流,否则这种情况下应该避免缓冲式 I/O。

当涉及其他过滤器流时是否要用缓冲,这个问题就更复杂了。有种常见的情况是使用这些流来序列化或反序列化数据。比如,在EJB调优章节就探讨了显式地管理类的数据序列化时的各种性能得失。在那一章中,有一个简化版的 writeObject() 方法,如下所示:

private void writeObject(ObjectOutputStream out) throws IOException {
    if (prices == null) {
        makePrices();
    }
    out.defaultWriteObject();
}

protected void makePrices() throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new ObjectOutputStream(baos);
    oos.writeObject(prices);
    oos.close();
}

在这种情况下,如果将 baos 流包装在一个 BufferedOutputStream 中,因为多了一次数据复制,所以会有性能损失。

在这个例子中,将 prices 数组中的数据压缩一下,效率会更高,而代码就变成了下面这样:

private void writeObject(ObjectOutputStream out) throws IOException {
    if (prices == null) {
        makeZippedPrices();
    }
    out.defaultWriteObject();
}

protected void makeZippedPrices() throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    GZIPOutputStream zip = new GZIPOutputStream(baos);
    BufferedOutputStream bos = new BufferedOutputStream(zip);
    ObjectOutputStream oos = new ObjectOutputStream(bos);
    oos.writeObject(prices);
    oos.close();
    zip.close();
}

现在,缓冲数据流就是必要的了,因为 GZIPOutputStream 处理一块数据比处理一个字节的数据更高效。在上面这两种情况下,ObjectOutputStream 都会将单字节数据发送给下一个流。如果下一个流就是最终目的地,比如 ByteArrayOutputStream 这种情况,则无需缓冲。而如果中间还有另一个过滤器流(比如这个例子中的 GZIPOutputStream),则缓冲往往是必要的。

到底何时需要在两个不同的流中间插入一个缓冲流,并没有一个统一的规则。这最终取决于所用流的类型,但是在多数情况下,操作一块数据(来自缓冲的流)通常要好于操作一系列单个字节(来自 ObjectOutputStream)。

同样的情况也适用于输入流。举个具体的例子,GZIPInputStream 操作一块字节数据更高效;一般情况下,对于插入在 ObjectInputStream 和原始的字节数据源之间的流,如果配合一块数据,其表现会更好。

注意,这种情况特别适用于编解码器流。当在字节和字符之间转换时,操作尽可能大的一段数据,性能最佳。如果提供给编解码器的是单个的字节或字符,性能会很差。

操作序列化时间(秒)反序列化时间(秒)
无缓冲压缩 / 解压缩60.379.3
带缓存压缩 / 解压缩26.812.7

没有正确地缓冲 I/O,性能差距多达 6 倍。


缓冲IO小结

  1. 围绕缓冲式 I/O 有一些很常见的问题,这是由简单输入输出流类的默认实现引发的。

  2. 文件和 Socket 的 I/O 必须正确地缓冲,对于像压缩和字符串编解码等内部操作,也是如此

类加载

对于任何尝试优化程序启动或优化新代码在动态系统中的部署(比如向 Java EE 应用服务器中部署一个新应用,或者是在浏览器中加载一个 Applet)的人而言,类加载的性能都让人头疼。

原因是多方面的。最主要的一点是,类数据(也就是 Java 字节码)通常无法快速访问到。它必须从磁盘或者网络上加载过来,必须能在 classpath 下的某个 JAR 文件中找到,还必须能在某个类加载器中找到。对此有一些改进方案,比如,Java WebStart 会将从网络读取的类数据写入一个隐藏目录,这样当下次启动同一应用时,就可以从本地磁盘读取数据,而不再需要从网络读取,速度得以提升。在打包应用时,减少所生成的 JAR 文件数,也能提升类加载的性能。

在复杂环境中,提升速度的明显方式之一就是将类加载并行化。以一个典型的应用服务器为例:在启动时,它可能需要初始化多个应用,其中每个应用都使用了自己的类加载器。假设有多个 CPU 可供大部分应用服务器使用,并行化应该有明显优势。

不过有两个因素会影响其可伸缩性。第一,类数据很可能保存在同一个磁盘上,因此如果有两个类加载器并发运行,它们会向同一设备发出读请求。尽管操作系统擅于处理这种情况:它们可以随着磁盘旋转,分割读操作,并抓取字节数据,但是此时磁盘仍然有很大机会成为瓶颈。

在 Java 7 之前,ClassLoader 类本身的设计一直存在一个比较大的问题。如图所示,Java 的类加载器存在于一个层次结构中,这是某个 Java EE 容器中类加载器的理想情况。当运行在第一个 Servlet 应用中的类加载器需要某个类时,请求会流向第一个 Web 应用类加载器(App Classloader),但是这个类加载器会将请求委派给其父类加载器:系统类加载器(System Classloader)。它是与 classpath 关联的类加载器,负责加载 Java EE 相关的类(比如 Java Server Faces,即 JSF 接口)以及这些类在该容器中的实现。系统类加载器也会将一些加载工作委派给其父类加载器,即启动类加载器(Bootstrap Classloader),它负责加载核心 JDK 类。

多个类加载器的理想结构

最后的结果是,当请求加载一个类时,启动类加载器是尝试去找这个类的第一段代码(Java 的类加载器使用了双亲委派模型,一个类加载器在收到加载类的请求后,首先会把这个请求委派给父类加载器去完成,每个层次的类加载器都如此处理,一直到启动类加载器;如果启动类加载器无法完成加载请求,才会沿这个路径返回,找到合适的类加载器);之后是系统类加载器(classpath),再找不到则诉诸应用类加载器。从功能角度看,这是说得通的:java.lang.String 类的字节码必须由启动类加载器加载,而不能是由层次结构中的其他某个类加载器有意或无意加载的别的实现。

在 Java 7 之前,类加载器存在的问题是,用于加载类的方法是同步的:在某个时刻,只有一个类加载器可以将任务委派给系统类加载器。这对使用多个类加载器实现并行化有着极大限制,因为每个类加载器都要等待,只有轮到它时,才能访问系统类加载器和启动类加载器。Java 7 利用一组基于类名的锁解决了这种状况。 现在,如果有两个类加载器在寻找同一个类,它们仍然会争用某个锁,但是类层次结构中寻找其他类的类加载器可以并行执行。

如果使用了诸如 URLClassLoader 等 Java 提供的类加载器,也能体会到 Java 7 带来的这一好处。在 Java 6 中,如果其他的类加载器以 URLClassLoader 为父类加载器,它会成为并行操作的同步瓶颈;而在 Java 7 中,类加载器可以并行使用。Java 7 所提供的类加载器是可以并行的(parallel-capable)。

自定义的类加载器默认是不支持并行的。如果希望自己的类加载器也能并行使用,必须采取一些措施。措施总共分为两步。

首先,确保类加载器的层次结构中没有任何回环。回环并不多见。如果存在回环,代码会很难维护,因为在某一时刻,某个类加载器必须直接满足请求,而不能将请求传给其父类加载器(否则委派就成了无限循环)。因此,对于一组存在回环的类加载器而言,尽管在技术上支持并行是可能的,但是过程会非常复杂(要在本已非常复杂的代码上实现)。因为编写高性能 Java 代码的一个规则是采用惯用的方法,以及编写便于编译器优化的简单代码,所以我们不推荐使用存在回环的类加载器层次结构。

第二,在定义加载器类时,在静态初始化部分将其注册为可以并行的:

public class MyCustomClassLoader extends SecureClassLoader {

    static {
        registerAsParallelCapable();
    }

    ....
}

这个调用必须放在每个具体的类加载器实现之内。SecureClassLoader 本身是可以并行的,但是其子类并不会自动具备这种能力。如果在我们的代码内还有一个类继承了 MyCustomClassLoader,那个类也必须自己注册为支持并行的。

对于大部分类加载器而言,只需要这两步。在编写类加载器时,建议重写 findClass() 方法。如果自定义的类加载器重写的是 loadClass() 方法,而非 findClass() 方法,则一定要确保在每个类加载器实例内,对于每个类名,defineClass() 方法只调用一次。

在涉及围绕锁的可伸缩性时,和与此有关的所有性能问题一样,该优化的性能净收益还与代码被锁住多久有关。举一个简单的例子,考虑如下代码:

URL url = new File(args[0]).toURL();
URLClassLoader ucl = new URLClassLoader(url);
for (String className : classNames) {
    ucl.loadClass(className);
}

在命令行中,一个 JAR 文件的名字被作为 args 的第一个元素传入了,这里自定义的类加载器会在这个 JAR 文件中查找。它会遍历由类名组成的数组(在其他地方定义),并从这个 JAR 文件中加载每个类。

其父类加载器就是系统类加载器(查找 classpath)。当有两个或多个线程并发执行这个循环时,因为它们会把类的查找委派给系统类加载器,所以两个线程会彼此等待。下表列出了当系统的 classpath 为空时该循环的性能。

线程数在JDK 7中用的时间(秒)在JDK 6中用的时间(秒)
130.35327.696
234.81131.409
448.10672.208
8117.34184.45

这是有 1500 个类的类名列表循环 100 次所用的时间。这里可以得出几个有趣的结论。首先,JDK 7 中的代码更为复杂(以支持并行加载),因此会在最简单的情况下引入一点点性能损失——这是越简单的代码跑得越快这一原则的一个例证。即使在两个线程的情况下,JDK 7 的新模型还是要稍微慢点,因为代码在父类加载器中几乎没花时间:被锁在父类加载器上所花的时间,远远不及在程序其他地方所花的时间多。

当有 4 个线程时,情况就不一样了。首先,在有 4 个 CPU 的机器上,这 4 个线程会与其他进程(尤其像正在显示用到了 Flash 的页面的浏览器,它会占用一个 CPU 的 40%)争用 CPU 周期。因此即使在 JDK 7 中,伸缩也不是线性的。但至少可伸缩性有了提高:在 JDK 6 中,围绕父类加载器的竞争非常严重。

产生这种竞争有两个原因。首先,争用 CPU 实际上会增加类加载器锁的持有时间;其次,争用锁的线程数也成了原来的 2 倍。

增加系统 classpath 的长度也会极大增加父类加载器锁的持有时间。下表重复了这个实验,classpath 下有 266 个条目(GlassFish 发行版中的 JAR 文件数)。(GlassFish 不会简单地把这些文件都放到一个类加载器中;之所以选择它,只是方便举例而已。)

线程数在JDK 7中用的时间(秒)在JDK 6中用的时间(秒)
198.14692.129
2111.16316.01
4150.98708.24
8287.971461.5

现在,即便只有两个线程,竞争也非常严重:没有支持并行的类加载器,加载这些类的时间是原来的 3 倍。如果是在压力已经很大的系统中,可伸缩性就更糟糕了。最后,性能会慢 7 倍。

这里有一个有趣的取舍:是采用更复杂的代码,稍微牺牲一下单线程情况下的性能,还是针对其他情况做优化——特别是像上面例子中的情况,两种选择的性能差距非常大。这种性能取舍时常会遇到,在这种情况下,JDK 团队将第 2 种选择当作了默认情况。作为一个平台,同时提供这两种选择是个不错的主意(即使默认的只能有一个)。因此,在 JDK 7 中,要想获得 JDK 6 的这种行为,可以使用 -XX:+AlwaysLockClassLoader 标志(它默认为 false)开启。如果启动周期较长,而且没有并发的线程会从不同的类加载中加载类,这种情况使用该标志可能稍微有好处。

类加载小结

  1. 在存在多个类加载器的复杂应用(特别是应用服务器)中,让这些类加载器支持并行,可以解决系统类加载器或者启动类加载器上的瓶颈问题。

  2. 如果应用是在单线程内,则通过一个类加载器加载很多类,关掉 Java 7 支持并行的特性可能会有好处。

随机数

Java 7 提供了 3 个标准的随机数生成器类:java.util.Random、java.util.concurrent.ThreadLocalRandom 以及 java.security.SecureRandom。这三个类在性能方面差距很大。

Random 和 ThreadLocalRandom 两个类的差别是,Random 类的主要操作(nextGaussian())是同步的。任何要获取随机值的方法都会用到这个方法,所以不管如何使用该随机数生成器,都会存在锁竞争:如果两个线程同时使用同一随机数生成器,那一个线程要等待另一个先完成其操作。之所以会使用 ThreadLocalRandom,原因就在于此:每个线程都有自己的随机数生成器,Random 类的同步就不是问题了。(因为创建对象成本很高,而 ThreadLocalRandom 类会重用对象,所以有很大的性能优势。)

SecureRandom 类与上面介绍的两个类的区别是,所用的算法不同。Random 类(以及继承它而来的 ThreadLocalRandom)实现了一个典型的伪随机数算法。尽管那些算法非常复杂,但到底是确定性的。如果知道初始种子,很容易确定该引擎将生成的数字的精确序列。这意味着,黑客能够从特定的生成器看到数字序列,也就能够指出下一个数字是什么。尽管好的伪随机数生成器可以生成看上去真正随机的数字序列(甚至符合随机性的概率期望),但这仍然不是真正的随机。

而另一方面,SecureRandom 类使用一个系统接口来获得随机数。数据生成方式与所用的操作系统有关,不过一般而言,这类源(这里的源类似于伪随机数中的初始种子。)提供了基于真正随机事件(比如鼠标移动时)的数据。这就是所谓的基于熵的随机性,比依赖随机数的操作更安全。SSL 熵是这类操作中最广为人知的例子:加密所用的随机数不可能通过基于熵的源来确定。(即便在算法中使用了 SecureRandom 随机数生成器,还是有其他方式可以攻破数据的加密算法。)

遗憾的是,计算机生成的熵的数量是有限的,所以要从一个安全随机数生成器获得大量的随机数,需要很长时间。调用 SecureRandom 类的 nextRandom() 方法消耗的时间并不确定,跟系统中还有多少熵尚未使用有关。如果没有熵可用,这个调用看上去就挂起了,可能一次长达数秒,直到有可用的熵为止。所以对性能的计时非常困难,因为性能本身也是随机的。

对于会创建很多 SSL 连接,或者需要大量安全随机数的应用而言,这往往会成为问题;这样的应用要花很多时间去执行其操作。当在一个这样的应用上执行性能测试时,请注意,计时会有很多变数。除了像第 2 章讨论的那样运行大量示例测试,其实没什么办法处理此类变数。另一种选择是联系操作系统厂商,看他们是不是有更多(或更好的)基于熵的源。

必要时,可以使用第 3 种选择,即使用 Random 类运行性能测试,即便在生产环境中使用的是 SecureRandom 类。 如果性能测试是模块级的,这会很有意义:在同样的一段时间内,与产品系统相比,这些测试需要的随机数更多(比如需要更多 SSL 套接字)。但是,最终预期的负载必须用 SecureRandom 类来测试,以确定生产系统上的负载是否能够获得足够的随机数。

随机数小结

  1. Java 默认的 Random 类的初始化的成本很高,但是一旦初始化完毕,就可以重用。

  2. 在多线程代码中,应该首选 ThreadLocalRandom 类。

  3. SecureRandom 类表现出的性能也是随意的和完全随机的。在对用到这个类的代码做性能测试时,一定要认真规划。

Java原生接口

如果想编写尽可能快的代码,要避免使用 JNI。

在现行的 JVM 版本上,编写得好的 Java 代码至少会与相应的 C 或 C++ 代码跑得一样快(现在可不是 1996 年了)。语言纯粹主义者会继续争论 Java 和其他语言的相对性能指标,当然肯定能找到相应的例子,证明用其他语言编写的应用比用 Java 编写的相同应用快(不过这类例子中往往会包含写得很差的 Java 代码)。然而,这类争论并非本节的要领,这里要说的是:如果某个应用已经是用 Java 编写的,那出于性能原因调用原生代码几乎总是一个坏主意。

JNI 有时仍然非常有用。Java 平台提供了不同操作系统的很多公共特性,但如果需要访问一个特殊的、特定于操作系统的函数,那 JNI 就派上用场了。如果有现成的商用原生代码,那为什么还要构建自己的执行操作的库呢?在这种情况和其他一些情况下,问题就变成了如何编写最高效的 JNI 代码。

答案是尽可能避免从 Java 调用 C。跨 JNI 边界(边界是描述跨语言调用的术语)成本非常高,这是因为,调用一个现有的 C 库首先需要一些胶水代码,需要花时间通过胶水代码创建新的、粗粒度的接口,一下子要多次进入 C 库。

有趣的是,反过来就未必如此了:从 C 代码调用回 Java 不会有很大的性能损失(与所用的参数有关)。比如,考虑下面的代码:


public void main() {
    calculateError();
}

public void calculateError() {
    for (int i = 0; i < numberOfTrials; i++) {
       error += 50 - calc(numberOfIterations);
    }
}

public double calc(int n) {
    double sum = 0;
    for (int i = 0; i < n; i++) {
        int r = random(100); // 返回1至100之间的一个随机值
        sum += r;
    }
    return sum / n;
}

这段(完全没有实际意义的)代码有两个主循环:内层循环多次调用生成随机数的代码,外层循环重复调用内层循环,看看所得的随机数与预期值(这里是 50)的接近程度。通过 JNI,可以用 C 实现 calculateError()、calc() 和 random() 这些方法中的任何一个或多个。如下表展示了不同组合情况下的性能,其中 numberOfTrials 为 10 000。

calculateErrorCalcRandomJNI转移总时间(秒)
JavaJavaJava012.4
JavaJavaC10 000 00032.1
JavaCC10 00024.4
CJavaJava10 00012.4
CCC012.4

仅用 JNI 调用实现最内层方法,跨 JNI 边界的次数最多(numberOfTrials * numberOfloops,1 千万次)。将跨边界次数减少到 numberOfTrials(即 10 000)可以大幅减少开销,而将其减到 0,性能会最好——至少从 JNI 角度看是这样,尽管纯 Java 实现和完全使用原生代码一样快。

如果所用的参数不是简单的基本类型,JNI 代码性能会更糟。这一开销涉及两个方面。第一,对于简单的引用,需要地址转换。这也是为什么在上面的例子中,从 Java 调用 C 比从 C 调用 Java 开销更大:从 Java 调用 C,会隐式地把问题中的对象(this)传递给 C 函数,从 C 调用 Java 则无需传递任何对象。

第二,对于基于数组的数据,其中的操作在原生代码中会进行特殊处理。这包括 String 对象,因为字符串数据本质上是一个字符数组。要访问这类数组中的单个元素,必须调用一个特殊的方法,将该对象固定在内存中(对于 String 对象,要将其从 Java 的 UTF-16 编码转换成 UTF-8)。当不再需要数组时,必须在 JNI 代码中显式地释放。当有数组被固定在内存中时,垃圾收集器就无法运行——所以 JNI 代码中代价最高的错误之一就是在长期运行的代码中固定了一个字符串或数组。这会阻碍垃圾收集器运行,实际上也阻塞了所有应用线程,直到 JNI 代码完成。对于会固定数组的临界区,尽可能缩短固定时间极为重要。

有时,后面这个目标会与减少跨 JNI 边界调用这个目标冲突(本来一次 JNI 调用可以完成的事情,因为要缩短固定数组的时间,所以可能要分成几次,以防影响垃圾收集器工作。)。这种情况下,后一个目标更重要:即使这意味着要多次跨 JNI 边界,也要让固定数组和字符串的代码区尽可能短。

原生接口小结

  1. JNI 并不能解决性能问题。Java 代码几乎总是比调用原生代码跑得快。

  2. 当使用 JNI 时,应该限制从 Java 到 C 的调用次数;跨 JNI 边界的调用成本很高。

  3. 使用数组或字符串的 JNI 代码必须固定这些对象;为避免影响垃圾收集器,应该限制固定对象的时间。

异常

Java 的异常处理一直有代价高昂的坏名声。其代价确实比处理正常的控制流高一些,不过在大多数情况下,这种代价并不值得浪费精力去绕过。另一方面,因为异常处理是有成本的,所以不应将其用作一种通用机制。这里的指导方针是,根据良好程序设计的一般原则来使用异常:基本上,代码仅应该通过抛出异常来说明发生了意料之外的情况。遵循良好的代码设计原则,意味着 Java 代码不会因异常处理而变慢。

有两个因素会影响异常处理的一般性能。一个是代码块本身:创建一个 try-catch 块代价高吗?尽管很久以前可能是这样,但是近几年来,情况已非如此。不过在互联网上有些信息会留存很久,所以偶尔还会看到有人建议避免使用异常,因为 try-catch 块代价较高。这些建议都是老黄历了,因为现代 JVM 生成的代码可以非常高效地处理异常。

第二个方面是,(大部分)异常会涉及获取该异常发生时的栈轨迹信息。这一操作代价可能会很高,特别是在栈的轨迹很深时。

下面看一个例子。假如现在有一个特定方法的 3 种实现:

public ArrayList<String> testSystemException() {
    ArrayList<String> al = new ArrayList<>();
    for (int i = 0; i < numTestLoops; i++) {
        Object o = null;
        if ((i % exceptionFactor) != 0) {
            o = new Object();
        }
        try {
            al.add(o.toString());
        } catch (NullPointerException npe) {
            // 继续获取下一个字符串
        }
    }
    return al;
}

public ArrayList<String> testCodeException() {
    ArrayList<String> al = new ArrayList<>();
    for (int i = 0; i < numTestLoops; i++) {
        try {
            if ((i % exceptionFactor) == 0) {
                throw new NullPointerException("Force Exception");
            }
            Object o = new Object();
            al.add(o.toString());
        } catch (NullPointerException npe) {
            // 继续获取下一个字符串
        }
    }
    return al;
}

public ArrayList<String> testDefensiveProgramming() {
    ArrayList<String> al = new ArrayList<>();
    for (int i = 0; i < numTestLoops; i++) {
        Object o = null;
        if ((i % exceptionFactor) != 0) {
            o = new Object();
        }
        if (o != null) {
            al.add(o.toString());
        }
    }
    return al;
}

每个方法都返回一个字符串数组,其元素是从新创建的对象得到的。数组的大小会变化,跟抛出异常的次数有关。

表中列出了在最坏情况下(即 exceptionFactor 为 1,每次迭代都会生成异常,得到的结果是一个空列表)为 100 000 次迭代执行每个方法的时间。示例代码中,有的方法栈轨迹很浅(当调用这个方法时,栈上只有 3 个类),有的栈轨迹很深(当调用这个方法时,栈上有 100 个类)。

方法浅时间(毫秒)深时间(毫秒)
代码异常38110 673
系统异常1515
防御性编程22

这里有 3 点有趣的差别。首先,在每次迭代显式地构建异常的代码中,栈较浅和栈较深两种情况下时间差别很大。构建栈轨迹需要时间,这个时间和栈的深度有关。

第二个有趣的差别在这两种情况之间:代码显式地创建异常,或者是当 JVM 解析到空指针时创建异常(见表中的前两行)。目前的情况是,在某一个时刻,编译器会优化掉系统生成的异常;JVM 开始重用同一个异常对象,而不是每次需要时创建一个新的。不管调用栈是什么样的,相关的代码每次执行时都会重用这个对象;而且这个异常实际上没有包含调用栈(也就是说,printStackTrace() 没有输出)。这种优化在完整的栈异常信息抛出很长一段时间之后才会出现,所以如果测试用例中没有包含足够长的热身周期,是不会看到这种效果的。

最后,在访问对象之前先判断一下是否为 null,这种防御性编程性能最好。在这个例子中,这一点并不意外,因为整个循环变成了空操作。所以对这个数字要持保留态度。

尽管这些实现存在一些差别,但是请注意,大部分情况下,所用的时间都很少,是毫秒级的。平均到 100 000 次调用,每次调用的执行时间几乎看不到什么差别(别忘了,这还是最坏的情况)。

如果异常使用得当,这些循环中的异常数目就会非常小。下表列出了执行 100 000 次循环时,产生 1000 次异常(1% 的几率)需要的时间。

方法浅时间(毫秒)深时间(毫秒)
代码异常56157
系统异常5152
防御性编程5050

现在 toString() 方法的处理时间成了计算的大头。在栈较深的情况下,创建异常仍然有性能损失,不过提前测试 null 值的收益都被抵消了。

所以异常使用不当所带来的性能损失并没有想象的那么大。有些情况下,我们仍然会遇到创建太多异常的代码。因为性能损失主要来自填充栈轨迹信息,因此可以使用 -XX:-StackTraceInThrowable 标志(默认为 false)来禁止生成栈轨迹信息。

这并不是个好主意:栈轨迹的存在就是为帮我们分析哪里出问题的。如果使用了 -XX:-StackTraceInThrowable 标志,也就丢失了这种能力。而且有些代码实际上会检查栈轨迹,并以此确定如何从异常恢复。(CORBA 的参考实现就是这么工作的。)这种方式本身就有问题,但关键还在于禁止栈跟踪信息会使代码出现莫名其妙的问题。

JDK 中有些 API 的异常处理会导致性能问题。当集合中并不存在要检索的元素时,很多集合类就会抛出异常。比如 Stack 类,如果栈是空的,当调用 pop() 时,就会抛出 EmptyStackException。这种情况下,先通过防御性编程方式检查一下栈的长度会好一些。(另一方面,和很多集合类不同的是,Stack 类支持保存为 null 的对象,所以不能用 pop() 方法返回 null 来说明栈是空的。 )

关于异常的不当使用,JDK 中最臭名昭著的例子是类加载:当使用 ClassLoader 类的 loadClass() 方法加载某个找不到的类时,就会抛出 ClassNotFoundException。这实际并不是一个异常条件。不要期望一个类加载器能知道如何加载应用中的每个类,这也是之所以会有类加载器的层次结构的原因了。

在一个存在大量类加载器的环境中,这意味着,在层次化的类加载器中搜索知道如何加载给定类的那个类加载器时,会有大量的异常。比如在本章前面类加载的例子中,如果关闭栈轨迹信息,运行速度会提升 3%。

不过,类加载只是个例外。那个例子是使用很长的 classpath 做的微基准测试,而且即便是在这样的条件下,每次调用的差别也是毫秒级的。

异常小结

  1. 处理异常的代价未必会很高,不过还是应该在适合的时候才用。

  2. 栈越深,处理异常的代价越高。

  3. 对于会频繁创建的系统异常,JVM 会将栈上的性能损失优化掉。

  4. 关闭异常中的栈轨迹信息,有时可以提高性能,不过这个过程往往会丢失一些关键信息。

字符串的性能

字符串对 Java 非常重要,其性能在其他章节也已经讨论过,这里再强调几点。

字符串保留

  创建多个包含相同字符序列的字符串对象,这种情况很常见。没有必要在堆中为所有这些对象都分配空间;因为字符串是不可变的,所以重用现有的字符串往往更好。

字符串编码

  Java 的字符串采用的是 UTF-16 编码,而其他地方多是使用其他编码,所以将字符串编码到不同的字符集的操作很常见。对于 Charset 类的 encode() 和 decode() 方法而言,如果一次只处理一个或几个字符,它们会非常慢;务必完整缓存一些数据,再进行处理,本章前面也讨论过。

网络编码

  在编码静态字符串(来自 JSP 文件等地方)时,Java EE 应用服务器往往会特殊处理;更多细节可参见EJB优化章节。

字符串连接是另一个可能会出现性能问题的地方。考虑这样一个简单的字符串连接操作:

String answer = integerPart + "." + mantissa;

这行代码实际上非常高效;javac 编译器的语法糖会将其转换为如下代码:

String answer = new StringBuilder(integerPart).append(".").
                                append(mantissa).toString();

不过问题来了,如果这个字符串是逐步构造起来的:

String answer = integerPart;
answer +=".";
answer += mantissa;

那么这段代码就会被翻译为:

String answer = new StringBuilder(integerPart).toString();
answer = new StringBuilder(answer).append(".").toString();
answer = new StringBuilder(answer).append(mantissa).toString();

所有那些临时的 StringBuilder 对象和中间的 String 对象都很低效。永远不要使用连接来构造字符串,除非能在逻辑意义上的一行代码内完成;也不要在循环内使用字符串连接,除非连接后的字符串不会用于下一次循环迭代。对于其他情况,应该总是使用 StringBuilder,以获得更好的性能。第一章曾经说过某种情况下“过早的优化”只是表示要“编写良好的代码”。这就是最好的例子。

正则表达式

慎用正则表达式,特别是当你不知道它的两种模式差别时。(这一部分有很多可以自行理解)

日志

日志是让程序员爱恨交织的事情之一。每当有人问为什么某个程序运行这么糟时,我首先要做的就是拿到任何可用的日志,希望在应用生成的日志中找到线索,以了解应用的状况。而每当有人让我审查工作代码的性能时,我又会立即建议把所有的日志语句关掉。

日志有很多种。GC 会生成自己的日志语句(常见GC调优章节)。日志可以定向到一个单独的文件中,其大小可以由 JVM 管理。即便在生产代码中,GC 日志(使用 -XX:+PrintGCDetails 标志开启)的开销也是非常低的,而当出现问题时,它们的好处非常大,所以 GC 日志应该一直打开。

Java EE 应用服务器会生成一个访问日志,每当有请求时都会更新。这类日志的影响通常比较明显:不管在应用服务器上运行的是何种测试,关闭这类日志可以明显改进性能。根据我的经验,从诊断角度看,当出现问题时这些日志的帮助不是很大。不过在业务需求方面,这类日志往往非常关键,此时必须开启。

很多应用服务器都支持 Apache 的 mod_log_config 标准,尽管它并非一个 Java EE 标准。它可以针对每个请求精确地指定想要记录的信息(不支持 mod_log_config 语法的服务器通常也会支持某种形式的定制)。这里的关键是,记录的信息应该尽可能少,同时仍要满足业务需求。日志的性能会受所写数据量的影响。

特别是在 HTTP 访问日志中(或者笼统地说,在任何种类的日志中),记录下所有的数字信息是个不错的主意:记录 IP 地址而不是主机名,记录时间戳(比如从 Unix 纪元到现在所经过的秒数)而不是字符串数据(比如“Monday, June 3, 2013 17:23:00 -0600”),诸如此类。尽量减少需要花时间和内存去计算的任何数据转换,以便使日志对系统的影响将至最低。转换后的数据总是可以通过对日志做后续处理来获得。

对于应用日志,需要记住 3 个基本原则。第一,协调好要打日志的数据和所选级别(Level)之间的关系。JDK 中有 7 个标准的日志级别,而且 Logger 实例一般默认配置为输出其中的 3 个级别(INFO 及更高级别)。在项目中,这往往会导致混淆:INFO 级别听上去好像应该非常常见,而且应该提供与应用流程相关的描述(“现在正在处理 A 任务”,“现在正在做 B 任务”,等等)。特别是对于存在大量线程的可扩展应用(包括 Java EE 应用服务器)而言,这类日志多了会给性能带来不利影响(更不用说太多没什么用的日志信息带来的风险了)。要学会使用更低级别的日志语句。

类似地,当把代码签入到组库中时,应该考虑的是项目使用者的需求,而不是我们作为开发者的需求。在将自己的代码集成到一个更大的系统中之后,这些代码运行状况如何呢?我们都希望获得与此相关的大量积极反馈,另外还会运行一连串的测试。但如果消息对最终用户或系统管理员没什么意义,那默认开启这些日志就没什么帮助。它们的“作用”不过是拖慢了系统(还会让最终用户迷惑不解)。

第二个原则是使用细粒度的 Logger 实例。对每个类的 Logger 实例进行配置可能会很繁琐,但这么做是值得的,因为能够更好地控制日志输出。在一个较小的模块中,让一组类共享一个 Logger 实例,是个不错的折中办法。要记住的关键一点是,如果生产环境变化很大,有些问题(特别是那些在高负载情况下出现的问题,或者是其他与性能有关的问题)很难重现。打开太多日志往往会改变环境,导致原来的问题不再复现。

因此,我们必须能够做到仅打开一小组代码的日志(至少最初能控制一小组 FINE 级别的日志语句,然后是控制更多 FINER 和 FINEST 级别的),这样就不会影响代码的性能了。

在这两个原则之间,应该能够支持在生产环境中生成信息的小子集,前提是不影响系统性能。无论如何这都是应该考虑的,原因在于:如果日志会让生产系统变慢,其管理员很可能不会开启日志;在这种情况下,如果系统确实变慢了,重现问题的可能性也小了。

第三个原则是,在向代码引入日志时,应该注意,很容易编写出带来意想不到的副作用的日志代码,即使这个日志并没有开启。这是可以说明“过早的优化”很不错的又一种情况:如第 1 章的例子所示,每当要打日志的信息包含方法调用、字符串连接或者其他任何形式的资源分配(比如为 MessageFormat 参数分配一个 Object 数组)时,记得使用 isLoggable() 方法。

日志小结

  1. 为帮助用户找出问题,代码应该包含大量日志,但是这些日志默认都应该是关闭的。

  2. 如果 Logger 实例的参数需要调用方法或者分配对象,那么在调用该实例之前,不要忘了测试日志级别。

Java集合类API

Java 的集合类 API 有很大的选择余地;Java 7 至少提供了 58 个不同的集合类。在编写应用时,选择恰当的集合类,以及恰当地使用集合类,是一个重要的性能考量。

使用集合类的第一条规则是,选择适合应用的算法需求的集合类。该建议并不是特定于 Java 的;这实际上是数据结构入门课程就会介绍的。LinkedList 不适合做搜索;如果需要访问一段随机的数据,应该将集合保存到 HashMap 中。如果数据需要有序排列,则应使用 TreeMap,而不是尝试在应用中做排序。如果会用索引访问数据,则使用 ArrayList;但如果会频繁地向该数组中间插入数据,则不要使用它,诸如此类。根据算法选择要使用哪个集合类,这非常重要,但是在 Java 中做选择和在其他编程语言中做选择并没有多少区别。

然而在使用 Java 的集合类时,还有一些特殊的地方需要考虑。

同步还是非同步

默认情况下,几乎所有的 Java 集合类都是非同步的(主要的例外是 Hashtable、Vector 及与其相关的类)。

同步的集合类

如果想了解为什么 Vector 和 Hashtable(及相关类)是同步的,就得先来看一点历史。

在 Java 早期,它们是 JDK 中仅有的集合类。当时(在 Java 1.2 之前)还没有集合类框架(Collections Framework)的正式定义;它们只是最初的 Java 平台提供的几个有用的类。

在 Java 发布第一个版本时,大部分开发者对多线程知之甚少,而 Java 试图让开发者能够更容易地避免在多线程环境中编程的某些陷阱。因此,这些类就设计成了线程安全的。

遗憾的是,在早期的 Java 版本中,同步——甚至是不存在竞争时的同步——是个很大的性能问题,所以当第一个重大修订版本发布时,集合类框架采用了相反的做法:所有新的集合类默认都是非同步的。即使从那时开始同步性能已经有了显著提高,但仍然不是没有成本的;能够选择非同步的集合类,可以帮助大家编写更快的程序(偶尔会出现因并发修改某个非同步的集合而导致的 bug)。

前面曾经有一个微基准测试,比较了基于 CAS 的保护和传统的同步。这个例子在多线程的情况下不太实用,但如果问题中的数据只会由一个线程访问,又会怎么样呢?如果不使用任何同步,效果又会如何?下表列出了比较情况。因为这里没有试图考虑竞争,所以在这样一种情况下,这里的微基准测试是有效的:没有竞争,手头上的问题是研究同步访问资源有些多余时的损失。

模式总时间(秒)平均到每次操作的时间(纳秒)
CAS 操作6.613
同步方法11.823
非同步方法3.97.8

从第 2 列可以很明显地看出,与简单的非同步访问相比,如果使用了任何一种数据保护技术,都会有比较小的性能损失。然而,别忘了这是执行了 5 亿次操作的一个微基准测试,所以平均到每次操作,差别就只在 15 纳秒的量级上。如果相关操作在目标应用中执行得足够频繁,性能损失就会有点明显。在大部分情况下,这种差别会被应用中其他更为低效的地方抵消掉。还要记住,这里的绝对数字完全是由测试所运行的目标机器决定的;要获得更为真实的测量结果,测试需要在与目标环境相同的硬件上运行。

那么,如果要在同步的 Vector 和非同步的 ArrayList 之间做出选择,该选择哪个呢?访问 ArrayList 会稍微快一些,这与访问这个列表的频繁程度有关,性能差异是可以测量的。

另一方面,这里假设代码不会被多个线程访问。今天可能确实如此,那明天会怎么样呢?如果情况可能会变,那更好的办法是现在就使用同步的集合,并减轻它所带来的性能影响。这是一个设计选择,为使代码经受住时间的考验而将其设计为线程安全的,在这上面投入时间和精力是不是值得,取决于开发应用时的情况。

如果要在非同步集合和使用了 CAS 法则的集合之间做出选择(比如在 HashMap 和 ConcurrentHashMap 之间),它们的性能差别会微乎其微。当基于 CAS 的类用于不存在竞争的环境中时,几乎没有什么开销(继续阅读,后面会讨论特定的 hashmap 在内存使用方面的差别)。

设定集合的大小

集合类的用途是保存任意数量的数据元素,并随着集合中新条目的添加,在必要时进行扩展。性能方面有两点需要考虑。

尽管 Java 中的集合类提供的数据类型非常丰富,但是在基本层面上,这些类都必须仅使用 Java 基本的数据类型来保存其数据:数值(整型、双精度浮点型等)、对象引用和这些类型的数组。因此,ArrayList 中包含一个真正的数组:

private transient Object[] elementData;

随着在 ArrayList 中添加和移除条目,这些条目会保存在 elementData 数组内的期望位置(可能会导致数组中的其他条目变更位置)。类似地,HashMap 中包含着一个由内部数据类型 HashMap$Entry 组成的数组,HashMap 会将每个键 - 值对映射到这个数组中,具体位置根据键的哈希码值来确定。

并非所有的集合类都使用数组保存其元素;比如 LinkedList,它以内部定义的 Node 类保存每个数据元素。但是使用数组保存元素的集合类都会涉及一个问题,就是要考虑数组的大小。如何确定某个特定的类是不是属于这个范畴呢?可以看看它的构造函数:如果它有一个构造函数支持指定该集合的初始大小,那它内部就使用了某个数组来存储元素。

对于这样的集合类,精确地指定初始大小非常重要。以 ArrayList 作为一个简单的例子:elementData 数组默认的初始大小为 10。当向某个 ArrayList 实例中插入第 11 个元素时,它就会扩展 elementData 数组。这意味着分配一个新数组,将原来的内容复制到这个数组中,然后添加新元素。可以说 HashMap 使用的数据结构和算法更复杂一些,但基本原理是一样的:在某一时刻,必须重新调整内部数据结构的大小。

ArrayList 类调整数组大小的方法是,在现有基础上增加约一半。所以 elementData 数组的大小最初是 10,然后是 15,22,33,以此类推。不管使用何种算法调整数组大小(参见后面方框内的文字),都会导致一些内存被浪费(这反过来又会影响应用花在执行 GC 上的时间)。此外,每当数组必须调整大小时,都伴随一个成本很高的数组复制操作,将老数组中的内容转移到新数组中。

要减少这些性能损失,必须尽可能准确地估计一下集合最终的大小,并用这个值来构建集合。

非集合类中的数据扩展

很多非集合类也会在内部数组中保存大量数据。比如,ByteArrayOutputStream 类必须把写入到该流中的所有数据保存到一个内部缓冲区中;类似地,StringBuilder 和 StringBuffer 类也必须将所有字符保存到一个内部的字符数组中。

这些类大多会使用同样的算法调整内部数组的大小:需要调整时就加倍。这意味着,平均而言,内部的数组要比当前包含的数据多 25%。

这里的性能考量是相似的:使用的内存量多于 ArrayList 这个例子,需要复制数据的次数要少一些,但原理是一样的。在构建某个对象时,如果可以设置其大小,可以评估一下这个对象最终会保存多少数据,然后选择接受大小参数的那个构造函数。

集合与内存使用效率

我们刚看了一个集合的内存使用效率没有达到最佳的例子:在用于保存集合中的元素的底层存储中,往往会浪费一些内存。

对于元素比较稀疏的集合(只有一两个元素),这存在较大的问题。如果这样的集合用得非常多,则会浪费大量内存。解决方案之一就是在创建集合时设定其大小。另一种方案是,考虑一下这种情况是不是真的需要集合。

大部分开发者被问及如何快速地排序任意一个数组时,答案都会是快速排序(quicksort)。而好的性能工程师希望了解数组的大小:如果数组足够小,那最快的方式是使用插入排序(insertion sort)。(对于较小的数组来说,基于快速排序的算法通常会使用插入排序;就 Java 而言,Arrays.sort() 方法的实现就假定,少于 47 个元素的数组用插入排序比用快速排序更快。)数组大小至关重要。

JDK 7u40 中集合类内存大小

很多应用中经常出现因集合类使用不当而导致的问题,所以 JDK 7u40 向 ArrayList 和 HashMap 的实现中引入了一个新的优化:默认情况下(比如在调用构造函数时没有使用大小参数),这些类不再为数据分配任何底层存储,而是在向该集合中插入第一个元素时才分配。

这就是延迟初始化技术的一个例子,在测试一些常见的应用时,因为减少了对 GC 的需求,所以性能有所改进。这些应用中有很多从来没用过的集合;所以延迟分配其底层存储在性能方面有优势。因为每次访问时本来就要检查底层存储的大小,所以检查底层存储是不是已经分配,并没有性能损失(不过创建最初的底层存储所需要的时间从创建对象时变成了向对象中插入第一个数据时)。

类似地,在基于某个键值查找数据时,HashMap 是最快的;但如果只有一个键,与使用一个简单的对象引用相比,使用 HashMap 就是大材小用了。即使有几个键,维护几个对象引用所需要的内存也比一个完整的 HashMap 对象少,而且这样对 GC 也有积极的影响。

除了以上这些,关于集合类的内存使用还有很重要的一点区别需要了解,那就是 HashMap 对象和 ConcurrentHashMap 对象大小的差别。在 Java 7 之前,一个空的或者元素稀疏的 ConcurrentHashMap 对象非常大:超过 1 KB(即便向其构造函数传了一个很小的大小)。在 Java 7 中,其大小只有 208 字节(与之相比,构造时没有指定大小的空 HashMap 占 128 字节,指定大小为 1 的 HashMap 占 72 字节)。

在存在很多小型 Map 的应用中,大小的差别仍然非常重要,但是 Java 7 中引入的优化使得这种差别不那么显著了。为提高性能,在内存非常重要且存在大量 Map 的应用中,有人(包括我)建议避免使用 ConcurrentHashMap 类。这些建议的核心其实是两个因素之间的取舍:是要更快地访问 Map(如果存在竞争),还是要小心更大的 Map 所引发的对垃圾收集器的压力。这个取舍如今仍然存在,但重心已更多地偏向了使用 ConcurrentHashMap。

集合类API小结

  1. 仔细考虑如何访问集合,并为其选择恰当的同步类型。不过,在不存在竞争的条件下访问使用了内存保护的集合(特别是使用了基于 CAS 的保护的集合),性能损失会极小;有时候,保证安全性才是上策。

  2. 设定集合的大小对性能影响很大:集合太大,会使得垃圾收集器变慢;集合太小,又会导致大量的大小调整与复制。

AggressiveOpts标志

AggressiveOpts 标志(默认为 false)会影响一些基本 Java 操作的行为。其目标是试验性地引入一些优化;随着时间的推移,原来由这个标志启用的优化有望成为 JVM 的默认设置。很多 Java 6 中的这类优化在 Java 7u4 中都成了默认的设置。在每个 JDK 版本中,都应该重新测试该标志,看看它对应用是否还有积极的影响。

替代实现

启用 AggressiveOpts 标志的主要影响是,它会为 JDK 中的一些基本的类引入不同的替代实现:尤其是 java.math 包中的 BigDecimal、BigInteger 和 MutableBigDecimal 类;java.text 中的 DecimalFormat、DigitalList 和 NumberFormat 类;java.util 包中的 HashMap、LinkedHashMap 和 TreeMap 类。

这些类在功能上与它们所替代的标准 JDK 中的类是一致的,但是采用了更高效的实现。在 Java 8 中,这些替代实现已经去掉,或者是合并到了基本的 JDK 类中,或者是以其他方式改进了基本的 JDK 类。

之所以要通过设置 AggressiveOpts 标志才能启用这些类(在 Java 7 中),原因在于它们的行为可能会在应用代码中引发一些微妙的 bug。比如,HashMap 类的激进实现所生成的迭代器,其返回键值的顺序与标准实现相比会有所不同。应用首先绝不能依赖该迭代器返回元素的顺序,但现实中很多应用都会犯这个错误。出于兼容性考虑,这种更高效的实现并没有覆盖掉原来的实现,所以必须通过设置 AggressiveOpts 标志来获得更好的性能。

因为 Java 8 去掉了这些类,所以在升级时可能会损害到 bug 兼容性——这又一次暗示了从一开始就写好代码的重要性。

其他标志

开启 AggressiveOpts 标志会影响其他一些较为次要的 JVM 调优。

设置 AggressiveOpts 标志会开启 Autofill 标志(它在 JDK 7 到 7u4 这几个版本中默认为 false)。这个标志开启后,编译器会对循环进行更好的优化。类似地,AggressiveOpts 标志还会开启 DoEscapeAnalysis 标志(在 JDK 7u4 及后续版本中,这个标志也成了默认的)。

AutoBoxCacheMax 标志(默认为 128)被设置为 20 000,支持对更多值进行自动装箱,这会轻微改进特定应用的性能(代价是使用的内存也会稍微增多)。BiasedLockingStartupDelay 会从默认的 2000 减到 500,这意味着偏向锁会在应用开始执行后更短的时间内启用。

最后,该标志还会开启 OptimizeStringConcat 标志,允许 JVM 优化 StringBuilder 对象的使用,具体而言,当编写下面这样的代码时,javac 编译器会创建 StringBuilder 对象:

String s = obj1 + ":" + obj2 + ":" + obj3;

javac 编译器会将这行代码翻译为一个 StringBuilder 对象上的一系列 append() 调用。当 OptimizeStringConcat 标志开启时,JVM 即时编译器(JIT)会把 StringBuilder 对象的创建优化掉。在 JDK 7 到 7u4 的版本中,OptimizeStringConcat 标志默认为 false,而在开启 AggressiveOpts 标志后,其默认值变成了 true。

AggressiveOpts小结

  1. AggressiveOpts 标志会在一些基本的类中开启某些优化。大多数情况下,这些类要快于它们所替代的类,不过可能有意想不到的副作用。

  2. Java 8 中去掉了这些类。

Lambda表达式和匿名类

对很多开发者而言,Java 8 最激动人心的特性就是加入了 Lambda 表达式。不可否认,Lambda 对 Java 开发者的开发效率有着非常积极的影响,尽管收益难以量化,但是我们可以使用 Lambda 表达式来考查代码的性能。

关于 Lambda 表达式的性能,一个最基本的问题是,它们与其所对应的替代物匿名类相比如何。其实几乎没什么差别。关于如何使用 Lambda 表达式,常见的例子一般是从创建匿名内部类的代码入手(不过这类例子往往使用 Stream,而不是像下面这样使用迭代器):

private volatile int sum;

public interface IntegerInterface {
    int getInt();
}

public void calc() {
    IntegerInterface a1 = new IntegerInterface() {
        public int getInt() {
            return 1;
        }
    };
    IntegerInterface a2 = new IntegerInterface() {
        public int getInt() {
            return 2;
        }
    };
    IntegerInterface a3 = new IntegerInterface() {
        public int getInt() {
            return 3;
        }
    };
    sum = a1.get() + a2.get() + a3.get();
}

可以将其与下面使用了 Lambda 表达式的代码对比一下:

public void calc() {
    IntegerInterface a3 -> { return 3 };
    IntegerInterface a2 -> { return 2 };
    IntegerInterface a1 -> { return 1 };
    sum = a3.get() + a2.get() + a1.get();
}

这里 Lambda 表达式或匿名类的代码体至关重要:如果其中执行了任何较为重型的操作,那花在这一操作上的时间会把 Lambda 表达式或匿名类实现上的细微差距掩盖掉。然而,即便在这种最简单的情况下,执行该操作的时间也基本一样,如表所示。

实现方式所用时间(微秒)
匿名类87.2
Lambda 表达式87.9

数字看上去比较正式,让人印象深刻,但除了说这两种实现性能基本相同,我们也得不出其他结论。确实如此,因为测试中存在随机波动,再加上这些调用都是用 System.nanoTime() 测量的。在这个层次上,这样计时还没有准确到足以让人信服;总而言之,我们所知道的就是它们的性能相同。

在这个例子中的典型用法中,有一点比较有趣,即每当方法被调用时,使用匿名类的代码都会创建一个新对象。如果这个方法调用次数非常多(当然必须在某个基准测试中测量其性能),会有很多这个匿名类的对象被快速创建并丢弃。这种用法对性能几乎没有什么影响。分配对象(以及更重要的初始化操作)的成本非常低,而且因为它们很快就会被丢弃,实际上不会拖慢垃圾收集器。

尽管如此,我们总是可以构造一些用例,来说明分配对性能影响很大,以及最好重用对象:

private IntegerInterface a1 = new IntegerInterface() {
    public int getInt() {
        return 1;
    }
};
…… 其他接口类似……
public void calc() {
       return a1.get() + a2.get() + a3.get();
    }
}

而 Lambda 表达式的这种典型用法,通常不会在每次循环迭代时创建一个新对象,所以在个别案例下,使用 Lambda 表达式的性能会好一些。尽管如此,即便要构造性能差异有影响的微基准测试,都是非常困难的。

Lambda表达式与匿名类加载

有种极端情况,即在启动和类加载时,两种实现的性能差别很明显。人们很容易查看 Lambda 表达式的代码,并断定它不过是语法糖,底层还是创建匿名类(特别是从长远来看,两者的性能一样)。但现在的工作方式并不是这样的。在 JDK 8 中,Lambda 表达式的代码会创建一个静态方法,这个方法通过一个特殊的辅助类来调用。而匿名类是一个真正的 Java 类,有单独的 class 文件,并通过类加载器加载。

如本章前面所介绍的,类加载的性能可能很重要,特别是在 classpath 很长的情况下。如果这个例子就是在这样的情况下运行——calc() 方法每次都在一个新的类加载器中执行,那匿名类实现就处于劣势了。下表列出了这种情况下的差别。

实现方式所用时间(微秒)
匿名类267
Lambda 表达式181

关于这些数字,有一点要提一下:它们都是在经过一段适当的热身周期(以开启编译)之后再测量的。但是在热身阶段会发生另一件事:class 文件第一次被从磁盘读取出来。操作系统会把这些文件保存在内存(操作系统的文件缓冲区)中。所以代码第一次执行需要的时间比较长,因为要通过读文件的系统调用把文件从磁盘中真正地加载进来。随后的调用会快很多:尽管仍然需要通过系统调用读文件,但因为这些文件已经在操作系统的内存中,所以数据可以快速返回。因此,匿名类实现的性能可能要比想象中好,因为它并没有真正地从磁盘读取 class 文件。

Lambda与匿名类小结

  1. 如果要在 Lambda 表达式和匿名类之间做出选择,则应该从方便编程的角度出发,因为性能上没什么差别。

  2. Lambda 表达式并没有实现为类,所以有个例外情况,即当类加载行为对性能影响很大时,Lambda 表达式略胜一筹。

流和过滤器的性能

新的 Stream 设施是 Java 8 的另一个关键特性,而且经常与 Lambda 表达式配合使用。在性能方面,流有一个很重要的特性,即它们可以自动并行化代码。

延迟遍历(Lazy Traversal)

Stream 的第一个性能优势是它们被实现为了延迟的数据结构。举个例子,有一组股票代码,我们想从其中找到第一个不含字母 A 的代码。通过流实现该功能的代码看上去就像下面这样:

public String findSymbol(ArrayList<String> al) {
    Optional<String> t = al.stream().
            filter(symbol -> symbol.charAt(0) != 'A').
            filter(symbol -> symbol.charAt(1) != 'A').
            filter(symbol -> symbol.charAt(2) != 'A').
            filter(symbol -> symbol.charAt(3) != 'A').
               findFirst();
    return t.get();
}

很明显,用一个过滤器实现会更好,不过我们还是稍后讨论。现在先思考一下,对例子中的这个流而言,实现为延迟数据结构有何意义?每个 filter() 方法都会返回一个新流,所以这里实际上有 4 个逻辑流。

其实除了设置一系列指针,filter() 方法什么都没做。其作用是,当在这个流上调用 findfirst() 方法时,不会执行任何的数据处理,也不会拿数据去跟字符 A 作比较。

相反,findfirst() 会向前面的流(从第 4 个过滤器返回的那个)要一个元素。而那个流还没有元素,于是又向后调用由第 3 个过滤器生成的流,以此类推。第 1 个过滤器将从 ArrayList(从技术上讲,就是从原始的流中)抓到第一个元素,并测试它的第一个字符是否不为 A。如果确实不是,它会完成回调,并将该股票代码向后返回;否则,它会继续迭代数组,直到找到一个匹配的股票代码(或者找遍整个数组)。第 2 个过滤器行为类似——当对第 1 个过滤器的回调返回时,它会测试第二个字符是否不为 A。如果确实不是,它会完成回调,并将该股票代码向后传递;否则,调用第 1 个过滤器获得下一个股票 代码。

这么多回调听上去效率很低,那就考虑一个替代方案。尽早处理流的算法类似这样:

private <T> ArrayList<T> calcArray(ArrayLisr<T> src, Predicate<T> p) {
    ArrayList<T> dst = new ArrayList<>();
    for (T s : src) {
        if (p.test(s))
            dst.add(s);
        }
    return dst;
}

private static long calcEager(ArrayList<String> a1) {
    long then = System.currentTimeMillis();
    ArrayList<String> a2 = calcArray(a1, (String s) -> s.charAt(0) != 'A');
    ArrayList<String> a3 = calcArray(a2, (String s) -> s.charAt(1) != 'A');
    ArrayList<String> a4 = calcArray(a3, (String s) -> s.charAt(2) != 'A');
    ArrayList<String> a5 = calcArray(a4, (String s) -> s.charAt(3) != 'A');
    answer = a5.get(0);
    long now = System.currentTimeMillis();
    return now - then;
}

与 Java 实际采用的延迟实现相比,这一替代方案效率要差些,原因有二。第一,它需要创建大量临时的 ArrayList 实例。第二,在延迟实现中,一旦 findfirst() 方法得到一个元素,处理就可以停止了。这意味着实际通过过滤器传递的只是所有元素的一个子集。而另一方面,在尽早处理的实现方案中,则必须多次处理整个 ArrayList,一直到最后一个创建。

因此,在这个例子中延迟实现比上面的替代方案性能更好,也就不足为奇了。具体而言,测试中所处理的 ArrayList 包含 456 976 个元素,每个元素都是 4 字母股票代码,元素按字母顺序排列。就延迟实现而言,在遇到 BBBB 之前,只处理了 18 278 个元素,到 BBBB 就可以停止了。迭代器实现所花的时间要比延迟实现长两个数量级,如表所示。

实现所用时间(秒)
过滤器/findFirst0.359
迭代器/findFirst48.706

为什么过滤器解决方案比迭代器快这么多呢?一个原因是,过滤器有机会使用算法优化:当完成需要做的任务时,就可以停下来,因此处理的数据较少。

如果必须处理整组数据,过滤器和迭代器的性能相比又会如何呢?对于这个例子,我们稍微修改一下测试。前面的例子很好地演示了多个过滤器如何工作,但是很明显,如果用一个过滤器处理,性能有望变得更好:

public int countSymbols(ArrayList<String> al) {
    int count = 0;
    t = al.stream().
            filter(symbol -> symbol.charAt(0) != 'A' &&
                             symbol.charAt(1) != 'A' &&
                             symbol.charAt(2) != 'A' &&
                             symbol.charAt(3) != 'A').
               forEach(symbol -> count++);
    return count;
}

这个例子也修改了最终代码,会计算股票代码的个数,这样就会处理整个列表了。另一方面,尽早处理的方案也可以稍作修改,直接使用迭代器:

public int countSymbols(ArrayList<String> al) {
    int count = 0;
    for (String symbol : al) {
        if (symbol.charAt(0) != 'A' &&
            symbol.charAt(1) != 'A' &&
            symbol.charAt(2) != 'A' &&
            symbol.charAt(3) != 'A')
            count++;
    return count;
}

即使在这种情况下,延迟过滤器的实现还是要比迭代器快

实现所用时间(秒)
多个过滤器18.0
单个过滤器6.5
迭代器 / 计数6.8

出于比较的目的,表的第一行是使用 4 个独立的迭代器处理整个列表的情况。和只用一个过滤器这种最优情况相比,使用一个过滤器还是要比使用一个迭代器稍微快些。

流和过滤器的性能小结

  1. 过滤器因为支持在迭代过程中结束处理,所以有很大的性能优势。

  2. 即使都要处理整个数据集,一个过滤器还是要比一个迭代器稍微快些。

  3. 多个过滤器有些开销,所以要确保编写好用的过滤器。

常见的几种设计模式调优

单例模式应该算是最简单的设计模式了吧。我们知道单例通常可以通过饥汉和懒汉模式实现(单例实现方式千差万别,这两种是最广为人知的)。而懒汉模式在并发环境中会有线程安全问题,而我们一般通过double-check双重加锁检查的方式。而前面章节也说过锁导致的性能的下降问题。这时我们可以考虑使用内部类的方式来实现。另外个人建议使用enum来实现单例。

在实例创建时我们通常可以通过原型和享元模式优化

原型模式:在创建多个实例时,对创建过程的性能进行调优;

享元模式:用减少创建实例的方式,来调优系统性能。

生产消费模式中:

生产者与消费者通过一个中间容器来解决强耦合关系,并以此来实现不同的生产与消费速度,从而达到缓冲的效果。

如此等等。。。(见后续有时间的设计模式系列章节)

另外在并发编程中采用合适的并发模型也能极大提高性能。(见后续Java并发编程系列章节)

系统性能遵循短板效应,像选择合适的数据结构高效的算法更不必多说(见数据结构与算法部分)。在开篇我们也曾经讲过在实际应用中80%的问题其实都来自数据库(见后续数据库优化系列章节)

完~

更新时间:2020-04-09 09:52:09

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

评论

Your browser is out of date!

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

×