Java-高性能指南-四-

161 阅读51分钟

Java 高性能指南(四)

原文:zh.annas-archive.org/md5/075370e1159888d7fd67fe4f209e6d1e

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:Java SE API 提示

本章涵盖了 Java SE API 的一些实现怪癖,影响其性能。JDK 中存在许多这样的实现细节;这些是我在不同地方发现性能问题的地方(甚至是在我自己的代码中)。本章包括如何处理字符串(特别是重复字符串)的最佳方式;如何正确缓冲 I/O;类加载和如何改进使用大量类的应用程序的启动方式;正确使用集合;以及 JDK 8 的特性,如 lambda 和流。

字符串

字符串(不出所料地)是最常见的 Java 对象。在本节中,我们将探讨处理所有由字符串对象消耗的内存的各种方法;这些技术通常可以显著减少程序所需的堆内存量。我们还将介绍 JDK 11 中涉及字符串连接的新特性。

紧凑字符串

在 Java 8 中,所有字符串都编码为 16 位字符数组,而不考虑字符串的编码。这是不经济的:大多数西方地区可以将字符串编码为 8 位字节数组,即使在需要所有字符为 16 位的地区,像程序常量这样的字符串通常也可以编码为 8 位字节。

在 Java 11 中,除非明确需要 16 位字符,否则字符串编码为 8 位字节数组;这些字符串称为紧凑字符串。Java 6 中的类似(实验性)特性称为压缩字符串;紧凑字符串在概念上是相同的,但在实现上有很大不同。

因此,Java 11 中的平均 Java 字符串大小大约是 Java 8 中同一字符串大小的一半。这通常是巨大的节省:通常,典型 Java 堆的 50% 可能由字符串对象占用。当然,程序会有所不同,但平均而言,使用 Java 11 运行的此类程序的堆需求仅为 Java 8 运行相同程序的 75%。

很容易构建出这种有着超额好处的示例。可以在 Java 8 中运行一个需要大量时间执行垃圾收集的程序。在 Java 11 中以相同大小的堆运行相同程序可能几乎不需要时间进行收集,从而导致性能提升为三到十倍。对于这样的声明要持保留态度:通常情况下,您不太可能在这样的受限堆中运行任何 Java 程序。一切条件相同,您将看到垃圾收集所花时间减少。

对于调优良好的应用程序,真正的好处在于内存使用:您可以立即将典型程序的最大堆大小减少 25%,并且仍然获得相同的性能。反之,如果保持堆大小不变,您应该能够将更多负载引入应用程序,而不会遇到任何 GC 瓶颈(尽管应用程序的其他部分必须能够处理增加的负载)。

这一功能由 -XX:+CompactStrings 标志控制,默认为 true。但与 Java 6 中的压缩字符串不同,紧凑字符串既健壮又高效;你几乎总是希望保持默认设置。唯一的可能例外是在所有字符串都需要 16 位编码的程序中:在紧凑字符串中,对这些字符串的操作可能比未压缩的字符串稍长。

重复字符串与字符串池化

创建许多包含相同字符序列的字符串对象是常见的。这些对象在堆中占用了不必要的空间;由于字符串是不可变的,通常最好重用现有的字符串。我们在第七章讨论了一般情况,其中涉及具有规范表示的任意对象;本节扩展了这个想法,特别是与字符串相关的部分。

知道是否有大量重复的字符串需要堆分析。以下是使用 Eclipse Memory Analyzer 的方法之一:

  1. 加载堆转储。

  2. 从查询浏览器中选择 Java Basics → 按值分组。

  3. 对于 objects 参数,输入 java.lang.String

  4. 单击完成按钮。

结果显示在图 12-1 中。我们有超过 30 万个 NameMemnorParent Name 字符串的副本。还有几个其他字符串也有多个副本;总体而言,此堆中有超过 230 万个重复的字符串。

重复字符串及其内存大小。

图 12-1. 重复字符串占用的内存

可以通过三种方式去除重复的字符串:

  • 通过 G1 GC 执行自动去重

  • 使用 String 类的 intern() 方法创建字符串的规范版本

  • 使用一种自定义方法创建字符串的规范版本

字符串去重

最简单的机制是让 JVM 找到重复的字符串并去重它们:安排所有引用指向单个副本,然后释放剩余的副本。这只有在使用 G1 GC 并且指定 -XX:+UseStringDeduplication 标志(默认为 false)时才可能。此功能仅在 Java 8 的第 20 版之后和所有 Java 11 发行版中存在。

这一功能默认未启用,原因有三。首先,在 G1 GC 的年轻和混合阶段需要额外处理,使它们稍长。其次,它需要一个额外的线程与应用程序并发运行,可能会从应用程序线程中获取 CPU 周期。第三,如果有很少的去重字符串,应用程序的内存使用将更高(而不是更低);这额外的内存来自于跟踪所有字符串以查找重复所涉及的簿记。

这种选项在投入生产之前需要进行彻底测试:它可能会帮助你的应用,尽管在某些情况下会使情况变得更糟。不过,运气会偏向你一些:Java 工程师估计启用字符串去重的预期收益为 10%。

如果你想查看字符串去重在你的应用中的行为,可以在 Java 8 中使用-XX:+PrintStringDeduplicationStatistics标志,或在 Java 11 中使用-Xlog:gc+stringdedup*=debug标志来运行它。生成的日志会类似于以下内容:

[0.896s][debug][gc,stringdedup]   Last Exec: 110.434ms, Idle: 729.700ms,
                                  Blocked: 0/0.000ms
[0.896s][debug][gc,stringdedup]     Inspected:           62420
[0.896s][debug][gc,stringdedup]       Skipped:               0(  0.0%)
[0.896s][debug][gc,stringdedup]       Hashed:            62420(100.0%)
[0.896s][debug][gc,stringdedup]       Known:                 0(  0.0%)
[0.896s][debug][gc,stringdedup]       New:               62420(100.0%)
                                                         3291.7K
[0.896s][debug][gc,stringdedup]     Deduplicated:        15604( 25.0%)
                                                         731.4K( 22.2%)
[0.896s][debug][gc,stringdedup]       Young:                 0(  0.0%)
                                                         0.0B(  0.0%)
[0.896s][debug][gc,stringdedup]       Old:               15604(100.0%)
                                                         731.4K(100.0%)

这次字符串去重线程的运行时间为 110 毫秒,在此期间找到了 15,604 个重复的字符串(在被识别为去重候选项的 62,420 个字符串中)。由此节省的总内存为 731.4K,大约是我们希望从这个优化中获得的 10%。

生成此日志的代码设置了字符串的 25%是重复的,这是 JVM 工程师表示 Java 应用程序的典型情况。 (根据我的经验,正如我之前提到的,堆中字符串的比例更接近 50%; chacun à son goût。)¹ 之所以我们没有节省 25%的字符串内存是因为此优化只安排字符串的后备字符或字节数组进行共享; 字符串对象的其余部分不共享。 字符串对象具有 24 到 32 个字节的开销用于其其他字段(差异是由于平台实现)。 因此,两个相同的 16 个字符的字符串在去重之前每个占用 44(或 52)字节; 在去重之后,它们将占用 64 字节。 如果字符串被内化(如下一节所讨论的),它们将仅占用 40 字节。

正如我之前提到的,这些字符串的处理是与应用线程同时进行的。但实际上,这是处理过程的最后阶段。在年轻代收集期间,所有年轻代中的字符串都会被检查。那些晋升到老年代的字符串成为后台线程检查的候选项(一旦年轻代收集完成)。此外,请回忆一下第六章中关于对象在年轻代幸存者空间中老化的讨论:对象在成为老年代之前可能会在幸存者空间中来回移动几次。默认情况下,寿命为 3 的字符串(即它们被复制到幸存者空间三次)也会成为去重的候选项,并将由后台线程处理。

这导致短暂存在的字符串不会被去重,这很可能是件好事:您可能不希望花费 CPU 周期和内存来去重即将被丢弃的东西。与一般调整 tenuring 周期类似,更改此时发生的点需要大量测试,并且仅在不寻常的情况下才会进行。但为了记录,控制老年化字符串何时可收集的点是通过-XX:StringDeduplicationAgeThreshold=*N*标志,其默认值为 3。

字符串国际化

在编程级别处理重复字符串的典型方式是使用String类的intern()方法。

像大多数优化一样,字符串池的国际化不应该随意进行,但如果大量重复字符串占据了堆的重要部分,则可能是有效的。但通常需要进行特殊调整(在下一节中,我们将探讨一种在某些情况下有益的自定义方式)。

国际化字符串存储在一个特殊的哈希表中,该哈希表位于本地内存中(尽管字符串本身位于堆中)。这个哈希表与您在 Java 中熟悉的哈希表和哈希映射不同,因为这个本地哈希表具有固定的大小:在 Java 8 中为 60,013,在 Java 11 中为 65,536。(如果您使用的是 32 位 Windows JVM,则大小为 1,009。)这意味着在哈希表开始发生碰撞之前,您只能存储大约 32,000 个国际化字符串。

可以通过使用标志-XX:StringTableSize=N(默认为 1,009、60,013 或 65,536,如前所述)在 JVM 启动时设置此表的大小。如果应用程序将会国际化大量字符串,则应增加此数字。如果该值为质数,则字符串国际化表的操作效率最高。

intern() 方法的性能受到字符串表大小调整的主导。例如,表 12-1 显示了使用和不使用该调整创建和国际化 100 万个随机创建的字符串的总时间。

表 12-1. 国际化 100 万个字符串的时间

调整100% 命中率0% 命中率
字符串表大小 600134.992 ± 2.9 秒2.759 ± 0.13 秒
字符串表大小 100 万2.446 ± 0.6 秒2.737 ± 0.36 秒

注意,当完全命中率为 100%时,未正确调整大小的字符串国际化表的严重惩罚。一旦根据预期数据调整了表的大小,性能将得到显著改善。

0% 命中率表可能有点令人惊讶,因为调优前后的性能基本相同。在这个测试案例中,字符串在进入表后会立即被丢弃。内部字符串表的功能就像键是弱引用一样,所以当字符串被丢弃时,字符串表可以清除它。因此,在这个测试案例中,字符串表实际上从未填满过;最终只有几个条目(因为任何时候只有少量字符串被强引用)。

为了查看字符串表的性能,可以使用 -XX:+PrintStringTableStatistics 参数(默认为 false)运行应用程序。当 JVM 退出时,它将打印出如下表格:

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :   2002784 =  48066816 bytes, avg  24.000
Number of literals      :   2002784 = 606291264 bytes, avg 302.724
Total footprint         :           = 654838184 bytes
Average bucket size     :    33.373
Variance of bucket size :    33.459
Std. dev. of bucket size:     5.784
Maximum bucket size     :        60

这个输出来自于 100% 命中率的示例。在这之后的迭代中,我们有 2,002,784 个内部化字符串(200 万来自我们进行了一个预热和一个测量周期的测试;其余来自 jmh 和 JDK 类)。我们最关心的条目是平均和最大桶大小:我们平均需要遍历 33 个条目,最多需要遍历 60 个条目以搜索哈希表中的一个条目。理想情况下,平均长度应小于一,最大长度接近一。这正是我们在 0% 命中率情况下看到的情况:

Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      2753 =     66072 bytes, avg  24.000
Number of literals      :      2753 =    197408 bytes, avg  71.707
Total footprint         :           =    743584 bytes
Average bucket size     :     0.046
Variance of bucket size :     0.046
Std. dev. of bucket size:     0.214
Maximum bucket size     :         3

因为字符串很快就会从表中释放,所以我们最终只有 2,753 个表条目,对于默认大小为 60,013 来说是可以接受的。

应用程序分配的内部化字符串数(及其总大小)也可以通过 jmap 命令获取:

% jmap -heap process_id
... other output ...
36361 interned Strings occupying 3247040 bytes.

设置字符串表大小过高的惩罚很小:每个桶只占用 8 字节,因此比最优状态多几千个条目只是一次性的几千字节本机(非堆)内存成本。

自定义字符串内部化

字符串表的调优有些尴尬;我们是否可以通过仅使用保留重要字符串的自定义内部化方案来实现更好的效果?在第二章中也概述了该代码。

表 12-2 指引我们找到了问题的答案。除了使用常规的 ConcurrentHashMap 来保存内部化字符串之外,该表还展示了使用 JSR166 开发的额外类中的 CustomConcurrentHashMap 的使用。该自定义映射允许我们为键使用弱引用,因此其行为更接近字符串内部化表。

表 12-2. 通过自定义代码内部化 100 万个字符串所需时间

实现100% 命中率0% 命中率
ConcurrentHashMap7.665 ± 6.9 秒5.490 ± 2.462 秒
CustomConcurrentHashMap2.743 ± 0.4 秒3.684 ± 0.5 秒

在 100% 命中率测试中,ConcurrentHashMap 遭受与内部字符串表相同的问题:每次迭代都会有大量来自条目的 GC 压力。这是在一个 30 GB 堆上的测试结果;更小的堆将会得到更糟糕的结果。

与所有微基准测试一样,在这里深思熟虑使用情况。Concurren⁠t​HashMap 可以被显式管理,而不是我们目前的设置,不断地将新创建的字符串放入其中。根据应用程序的情况,这可能很容易或很难做到;如果足够容易,ConcurrentHashMap 测试将显示与常规内部化或 CustomConcurrentHashMap 测试相同的好处。在实际应用中,GC 压力才是关键:我们只会使用这种方法来删除重复的字符串,以尝试节省 GC 循环。

然而,没有一种情况真的比正确调整过的字符串表的测试更好。自定义映射的优点在于不需要事先设置大小:它可以根据需要调整大小。因此,它比使用 intern() 方法并根据应用程序的情况调整字符串表大小要适应更多应用程序。

字符串连接

字符串连接是另一个潜在的性能陷阱。考虑一个简单的字符串连接,如下所示:

String answer = integerPart + "." + mantissa;

Java 中的特殊优化可以处理这种结构(尽管各版本之间的细节有所不同)。

在 Java 8 中,javac 编译器将该语句转换为以下代码:

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

JVM 有特殊的代码来处理这种类型的结构(通过设置 -XX:+OptimizeStringConcat 标志来控制,其默认值为 true)。

在 Java 11 中,javac 编译器生成的字节码非常不同;该代码调用 JVM 本身内部的特殊方法来优化字符串连接。

这是少数情况之一,其中字节码在不同版本之间很重要。通常,当您迁移到较新版本时,无需重新编译旧代码:字节码将保持不变。(当然,您会希望使用新编译器编译新代码以使用新的语言特性。)但是,此特定优化取决于实际的字节码。如果您使用 Java 8 编译并运行执行字符串连接的代码,Java 11 JDK 将应用与 Java 8 中相同的优化。代码仍将被优化并运行得相当快。

如果您在 Java 11 下重新编译代码,字节码将使用新的优化,并且可能会更快。

让我们考虑以下三种连接两个字符串的情况:

@Benchmark
public void testSingleStringBuilder(Blackhole bh) {
    String s = new StringBuilder(prefix).append(strings[0]).toString();
    bh.consume(s);
}

@Benchmark
public void testSingleJDK11Style(Blackhole bh) {
    String s = prefix + strings[0];
    bh.consume(s);
}

@Benchmark
public void testSingleJDK8Style(Blackhole bh) {
    String s = new StringBuilder().append(prefix).append(strings[0]).toString();
    bh.consume(s);
}

第一种方法是我们手工编写此操作的方式。第二种方法(在使用 Java 11 编译时)将产生最新的优化,而最终方法(无论使用哪个编译器)在 Java 8 和 Java 11 中将以相同方式进行优化。

表 12-3 显示了这些操作的结果。

表 12-3. 单个连接的性能

模式每次操作的时间
JDK 11 优化47.7 ± 0.3 ns
JDK 8 优化42.9 ± 0.3 ns
字符串构建器87.8 ± 0.7 ns

在这种情况下,旧(Java 8)和新(Java 11)连接优化之间几乎没有实质性的区别;尽管 jmh 告诉我们这种差异在统计上是显著的,但它们并不特别重要。关键点是这两种优化都优于手动编码这个简单的情况。这有点令人惊讶,因为手动编码的情况似乎更简单:与 JDK 8 情况相比,它调用了一个更少的 append() 方法,因此执行的工作名义上较少。但是 JVM 内的字符串连接优化没有捕捉到这种特定模式,所以它最终会更慢。

JDK 8 优化并不适用于所有连接,但我们可以略微改变我们的测试,如下所示:

@Benchmark
public void testDoubleJDK11Style(Blackhole bh) {
    double d = 1.0;
    String s = prefix + strings[0] + d;
    bh.consume(s);
}

@Benchmark
public void testDoubleJDK8Style(Blackhole bh) {
    double d = 1.0;
    String s = new StringBuilder().append(prefix).
                   append(strings[0]).append(d).toString();
    bh.consume(s);
}

现在性能不同了,正如 表 12-4 所示。

表 12-4. 使用双精度值连接的性能

模式每次操作所需时间
JDK 11 优化49.4 ± 0.6 ns
JDK 8 优化77.0 ± 1.9 ns

JDK 11 的时间与最后一个示例类似,即使我们附加了一个新值并且做了稍微更多的工作。但是 JDK 8 的时间要糟糕得多——它慢了大约 50%。这并不完全是因为额外的连接操作;而是因为连接操作的类型。JDK 8 优化对字符串和整数效果很好,但无法处理双精度(以及大多数其他类型的数据)。在这些情况下,JDK 8 代码会跳过特殊优化,并像之前手动编码的测试一样运行。

当我们进行多个连接操作时,这两种优化都不会延续,特别是在循环内部进行的操作。考虑以下测试:

    @Benchmark
    public void testJDK11Style(Blackhole bh) {
        String s = "";
        for (int i = 0; i < nStrings; i++) {
            s = s + strings[i];
        }
        bh.consume(s);
    }

    @Benchmark
    public void testJDK8Style(Blackhole bh) {
        String s = "";
        for (int i = 0; i < nStrings; i++) {
            s = new StringBuilder().append(s).append(strings[i]).toString();
        }
        bh.consume(s);
    }

    @Benchmark
    public void testStringBuilder(Blackhole bh) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < nStrings; i++) {
            sb.append(strings[i]);
        }
        bh.consume(sb.toString());
    }

现在结果更有利于手动编码,这是有道理的。特别是 Java 8 实现,必须在每次循环迭代中创建一个新的 StringBuilder 操作,即使在 Java 11 中,每次循环创建字符串的开销(而不是在字符串构建器中累加)也会产生影响。这些结果显示在 表 12-5 中。

表 12-5. 多个字符串连接的性能

模式10 个字符串1,000 个字符串
JDK 11 代码613 ± 8 ns2,463 ± 55 μs
JDK 8 代码584 ± 8 ns2,602 ± 209 μs
字符串构建器412 ± 2 ns38 ± 211 μs

要点:不要害怕在可以在单个(逻辑)行上完成连接时使用连接,但是除非连接的字符串不在下一次循环迭代中使用,否则永远不要在循环内部使用字符串连接。否则,始终明确使用 StringBuilder 对象以获得更好的性能。在 第一章 中,我提到了有时候需要“过早”进行优化,当该短语用于简单表示“编写良好的代码”时。这是一个典型的例子。

快速摘要

  • 字符串的一行连接表现良好。

  • 对于多个连接操作,请务必使用 StringBuilder

  • 在 JDK 11 中重新编译涉及某些类型的字符串一行连接将显著提高速度。

缓冲 I/O

当我在 2000 年加入 Java 性能组时,我的老板刚刚出版了关于 Java 性能的第一本书,那时最热门的话题之一是缓冲 I/O。十四年后,我准备认为这个话题已经老掉牙,决定在第一版书中不再提及它。然而,就在我开始第一版大纲的那周,我在两个无关的项目中提交了缓冲 I/O 严重影响性能的 bug 报告。几个月后,当我在为第一版书编写示例时,我摸着头想知道为什么我的“优化”如此缓慢。后来我意识到:傻瓜,你忘记了正确地进行 I/O 缓冲。

至于第二版:在我重新审视这一部分之前的两周内,有三位同事来找我,他们在缓冲 I/O 方面犯了我在第一版示例中犯的同样错误。

那么让我们来谈谈缓冲 I/O 的性能。InputStream.read()OutputStream.write()方法操作一个字符。根据它们访问的资源,这些方法可能非常慢。使用read()方法的FileInputStream将非常慢:每次方法调用都需要进入内核以获取 1 字节数据。在大多数操作系统上,内核将对 I/O 进行缓冲,因此(幸运的是)这种情况不会触发每次read()方法调用的磁盘读取。但是该缓冲区位于内核中,而不是应用程序中,每次逐字节读取都意味着为每个方法调用进行昂贵的系统调用。

写入数据也是一样的:使用write()方法将单个字节发送到FileOutputStream需要一个系统调用来将字节存储到内核缓冲区。最终(当文件关闭或刷新时),内核将把该缓冲区写入磁盘。

对于使用二进制数据的基于文件的 I/O,请始终使用BufferedInputStreamBufferedOutputStream来包装底层文件流。对于使用字符(字符串)数据的基于文件的 I/O,请始终使用BufferedReaderBufferedWriter来包装底层流。

尽管讨论文件 I/O 时最容易理解这个性能问题,但这是一个通用问题,几乎适用于每一种类型的 I/O。从套接字返回的流(通过getInputStream()getOutputStream()方法)以相同的方式操作,通过套接字逐字节进行 I/O 操作会非常慢。在这里,同样确保流适当地包装在一个缓冲过滤流中。

当使用ByteArrayInputStreamByteArrayOutputStream类时,会出现更微妙的问题。这些类本质上只是大的内存缓冲区。在许多情况下,将它们与缓冲过滤流包装在一起意味着数据被复制两次:一次到过滤流的缓冲区,一次到ByteArrayInputStream的缓冲区(或者对于输出流来说反过来)。在没有其他流参与的情况下,应避免缓冲 I/O。

当涉及其他过滤流时,是否进行缓冲的问题变得更加复杂。本章后面,您将看到一个涉及多个过滤流(使用ByteArrayOutputStreamObjectOutputStreamGZIPOutputStream类)的对象序列化示例。

没有压缩输出流的情况下,该示例的过滤器如下所示:

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

在这种情况下,将baos流包装在BufferedOutputStream中将导致额外复制数据,从而降低性能。

一旦我们添加了压缩,编写代码的最佳方式如下:

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和原始字节源之间的流也会更喜欢数据块。

请注意,这种情况特别适用于流编码器和解码器。当在字节和字符之间转换时,尽可能处理尽可能大的数据块将提供最佳性能。如果将单个字节或字符提供给编码器和解码器,它们的性能将会受到影响。

事实上,没有对 gzip 流进行缓冲正是我在编写该压缩示例时犯的错误。正如表 12-6 中的数据所示,这是一个代价高昂的错误。

表 12-6. 使用压缩序列化和反序列化Stock对象所需的时间

模式时间
未缓冲的压缩/解压缩21.3 ± 8 ms
缓冲压缩/解压缩5.7 ± 0.08 ms

没有正确进行 I/O 缓冲导致了高达四倍的性能惩罚。

快速总结

  • 因为简单输入和输出流类的默认实现,围绕缓冲 I/O 的问题是很常见的。

  • 对于文件和套接字以及压缩和字符串编码等内部操作,I/O 必须进行适当的缓冲。

类加载

类加载性能是任何试图优化程序启动或在动态系统中部署新代码的人的困扰。

有很多原因导致这种情况。首先,类数据(即 Java 字节码)通常不容易访问。这些数据必须从磁盘或网络加载,它们必须在类路径上的几个 JAR 文件中找到,并且它们必须由几个类加载器中的一个找到。有一些方法可以帮助加快这个过程:一些框架将它们从网络读取的类缓存到一个隐藏目录中,以便在下次启动同一应用程序时可以更快地读取这些类。将应用程序打包成更少的 JAR 文件也将加快其类加载性能。

在这一节中,我们将看一下 Java 11 的一个新特性,以加快类加载速度。

类数据共享

类数据共享CDS)是一种机制,可以在 JVM 之间共享类的元数据。当运行多个 JVM 时,这对于节省内存很有用:通常每个 JVM 都会有自己的类元数据,而这些单独的副本会占用一些物理内存。如果共享这些元数据,只需要在内存中保留一份副本。

结果表明,对单个 JVM 来说,CDS 非常有用,因为它还可以改善启动时间。

在 Java 8(及之前的版本)中提供了类数据共享,但有一个限制,即仅适用于 rt.jar 中的类,并且仅在使用客户端 JVM 的串行收集器时。换句话说,它在 32 位单 CPU Windows 桌面机上有所帮助。

在 Java 11 中,CDS 在所有平台上通常是可用的,尽管它不是开箱即用的,因为没有默认的共享类元数据存档。 Java 12 具有常见 JDK 类的默认共享存档,因此所有应用程序默认会获得一些启动(和内存)的好处。无论哪种情况,通过为我们的应用程序生成更完整的共享存档,我们可以做得更好,因为在 Java 11 中,CDS 可以与任何一组类一起工作,无论哪个类加载器加载它们,它们从哪个 JAR 或模块加载。有一个限制:CDS 仅适用于从模块或 JAR 文件加载的类。您不能共享(或快速加载)来自文件系统或网络 URL 的类。

从某种意义上说,这意味着有两种 CDS:常规 CDS(共享默认的 JDK 类)和应用程序类数据共享,它可以共享任何一组类。应用程序类数据共享实际上是在 Java 10 中引入的,并且它的工作方式与常规 CDS 不同:程序需要使用不同的命令行参数来使用它。这种区分现在已经过时,在 Java 11 及以后的版本中,不管被共享的类是什么,CDS 的工作方式都是相同的。

使用 CDS 所需的第一件事是共享类的共享存档。正如我提到的,Java 12 自带了一个默认的 JDK 类共享存档,位于 $JAVA_HOME/lib/server/classes.jsa(或在 Windows 上是 %JAVA_HOME%\bin\server\classes.jsa)。该存档包含 12,000 个 JDK 类的数据,因此其核心类的覆盖范围非常广泛。要生成你自己的存档,首先需要一个你想启用共享的所有类的列表(从而实现快速加载)。该列表可以包括 JDK 类和应用程序级别的类。

获取这样一个列表有很多方法,但最简单的是使用带有 -XX:+DumpLoadedClassList=filename 标志运行你的应用程序,这将在 filename 中生成你的应用程序已加载的所有类的列表。

第二步是使用该类列表生成共享存档,像这样:

$ java -Xshare:dump -XX:SharedClassListFile=filename \
    -XX:SharedArchiveFile=myclasses.jsa \
    ... classpath arguments ...

这将根据文件列表创建一个新的共享存档文件,并使用给定的名称(这里是 myclasses.jsa)。你必须设置类路径与正常运行应用程序时相同(即使用 -cp-jar 参数)。

这个命令将会生成大量关于找不到的类的警告。这是预期的,因为这个命令无法找到动态生成的类:代理类,基于反射的类等等。如果你看到一个你期望加载的类的警告,试着调整该命令的类路径。不能找到所有类并不是问题;这意味着它们将会从类路径正常加载,而不是从共享存档加载。因此加载特定的类会稍慢一些,但是这样的几个类几乎不会被注意到,所以在这一步不要太过于担心。

最后,使用共享存档来运行应用程序:

$ java -Xshare:auto -XX:SharedArchiveFile=myclasses.jsa ... other args ...

对于这个命令有几点备注。首先,-Xshare 命令有三个可能的取值:

off

不要使用类数据共享。

on

一定要使用类数据共享。

auto

尝试使用类数据共享。

CDS 依赖于将共享存档映射到内存区域,而在某些(大多数情况下是罕见的)情况下,这可能会失败。如果指定了 -Xshare:on,应用程序将在此情况发生时无法运行。因此,默认值是 -Xshare:auto,这意味着通常会使用 CDS,但如果由于某种原因无法映射存档,则应用程序将在没有它的情况下继续运行。由于此标志的默认值为 auto,因此实际上不必在前述命令中指定它。

其次,此命令提供了共享存档的位置。SharedArchiveFile 标志的默认值是前面提到的 classes.jsa 路径(位于 JDK server 目录内)。因此,在 Java 12 中(其中存在该文件),如果我们只想使用(仅限 JDK 的)默认共享存档,就不需要提供任何命令行参数。

在一种常见情况下,加载共享存档可能会失败:用于生成共享存档的类路径必须是用于运行应用程序的类路径的子集,并且自共享存档创建以来 JAR 文件不能发生更改。因此,您不希望生成除 JDK 外的类的共享存档,并将其放在默认位置,因为任意命令的类路径将不匹配。

此外,还要注意更改 JAR 文件。如果使用 -Xshare:auto 的默认设置并更改了 JAR 文件,则应用程序仍将运行,尽管未使用共享存档。更糟糕的是,不会收到任何警告;您唯一能看到的影响是应用程序启动更慢。这是考虑指定 -Xshare:on 而不是默认设置的原因之一,尽管共享存档可能失败的其他原因还有很多。

要验证是否从共享存档加载类,请在命令行中包含类加载日志记录(-Xlog:class+load=info);您将看到通常的类加载输出,并且从共享存档加载的类将显示如下:

[0.080s][info][class,load] java.lang.Comparable source: shared objects file

类数据共享的好处

类数据共享对启动时间的好处显然取决于要加载的类的数量。表 12-7 显示了在书籍示例中启动样本股票服务器应用程序所需的时间;需要加载 6,314 个类。

表 12-7. 使用 CDS 启动应用程序所需的时间

CDS 模式启动时间
-Xshare:off8.9 秒
-Xshare:on(默认)9.1 秒
-Xshare:on(自定义)7.0 秒

在默认情况下,我们仅使用 JDK 的共享存档;最后一行是所有应用程序类的自定义共享存档。在这种情况下,CDS 可以节省我们 30% 的启动时间。

CDS 还会节省一些内存,因为类数据将在进程之间共享。总体而言,正如您在第八章 中看到的例子一样,在本地内存中的类数据相对较小,特别是与应用程序堆相比。在具有大量类的大型程序中,CDS 将节省更多内存,尽管大型程序可能需要更大的堆,使比例节省仍然很小。然而,在一个特别缺乏本机内存并且运行多个使用大量相同类的 JVM 副本的环境中,CDS 也将为内存节省提供一些好处。

快速总结

  • 加快类加载速度的最佳方法是为应用程序创建一个类数据共享存档。幸运的是,这不需要进行任何编程更改。

随机数

接下来我们将看一些涉及随机数生成的 API。Java 自带三个标准的随机数生成器类:java.util.Randomjava.util.concurrent.ThreadLocalRandomjava.security.SecureRandom。这三个类具有重要的性能差异。

Random类和ThreadLocalRandom类之间的区别在于Random类的主要操作(nextGaussian()方法)是同步的。该方法被检索随机值的任何方法使用,因此无论如何使用随机数生成器,该锁定都可能成为争用点:如果两个线程同时使用相同的随机数生成器,则其中一个将不得不等待另一个完成其操作。这就是为什么有线程本地版本可用的原因:当每个线程有自己的随机数生成器时,Random类的同步不再是问题。(正如在第七章 中讨论的那样,线程本地版本还提供显著的性能优势,因为它重用了昂贵的创建对象。)

这些类与SecureRandom类之间的区别在于所使用的算法。Random类(以及通过继承的ThreadLocalRandom类)实现了典型的伪随机算法。虽然这些算法非常复杂,但最终是确定性的。如果初始种子是已知的,就可以确定引擎将生成的确切数字系列。这意味着黑客能够查看特定生成器的数字系列,并最终推断出下一个数字是什么。尽管良好的伪随机数生成器可以生成看起来非常随机的数字系列(甚至符合随机性的概率预期),但它们并非真正随机。

另一方面,SecureRandom类使用系统接口获取其随机数据的种子。生成数据的方式是特定于操作系统的,但通常情况下,这个来源提供基于真正随机事件(例如鼠标移动时)的数据。这被称为基于熵的随机性,对依赖随机数的操作更加安全。

Java 区分两个随机数源:一个用于生成种子,一个用于生成随机数本身。种子用于创建公钥和私钥,例如通过 SSH 或 PuTTY 访问系统时使用的密钥。这些密钥长期存在,因此需要最强大的加密算法。安全随机数还用于种子常规的随机数流,包括 Java 的 SSL 库的默认实现中使用的流。

在 Linux 系统上,这两个来源分别是*/dev/random*(用于种子)和*/dev/urandom*(用于随机数)。这两个系统都基于机器内的熵源:真正随机的事物,如鼠标移动或键盘击键。熵的量是有限的,会随机再生,因此作为真正随机性的来源是不可靠的。这两个系统处理方式不同:/dev/random会阻塞,直到有足够的系统事件生成随机数据,而*/dev/urandom则会退而使用伪随机数生成器(PRNG)。PRNG 会从一个真正随机的来源初始化,因此通常与/dev/random产生的数据流一样强大。然而,生成种子所需的熵可能不可用,此时从/dev/urandom得到的数据流理论上可能会受到影响。关于这一问题的争论有两面观点,但普遍的共识是——用/dev/random生成种子,用/dev/urandom*处理其他一切——这是 Java 采纳的方案。

结论是获取大量随机数种子可能需要很长时间。调用SecureRandom类的generateSeed()方法将花费不确定的时间,取决于系统中未使用的熵量。如果没有可用的熵,调用可能会出现挂起的情况,可能会持续几秒钟,直到所需的熵可用。这使得性能的定时变得非常困难:性能本身变得随机起来。

另一方面,generateSeed()方法仅用于两个操作。首先,某些算法使用它获取未来调用nextRandom()方法的种子。这通常只需要在应用程序生命周期中执行一次,或者定期执行。其次,创建长期存在的密钥时也会使用此方法,这也是相当少见的操作。

由于这些操作受限,大多数应用程序不会耗尽熵。但对于在启动时创建密码的应用程序来说,熵的限制可能是一个问题,特别是在主机操作系统随机数设备在多个虚拟机和/或 Docker 容器之间共享的云环境中。在这种情况下,程序活动的时间将具有非常大的差异,由于安全种子的使用通常发生在程序初始化时,因此在这个领域的应用程序启动可能会非常慢。

我们有几种方式来处理这种情况。在紧急情况下,以及可以更改代码的情况下,解决这个问题的替代方案是使用 Random 类来运行性能测试,尽管生产中将使用 SecureRandom 类。如果性能测试是模块级测试,这是有道理的:这些测试将需要比生产系统在同一时间段内需要的更多的随机种子。但最终,必须使用 SecureRandom 类来测试预期负载,以确定生产系统的负载是否可以获得足够数量的随机种子。

第二个选择是配置 Java 的安全随机数生成器使用 /dev/urandom 作为种子以及随机数。有两种方法可以实现这一点:首先,可以设置系统属性 -Djava.security​.egd=file:/dev/urandom。²

第三个选项是在 $JAVA_HOME/jre/lib/security/java.security 中更改此设置:

securerandom.source=file:/dev/random

该行定义了用于种子操作的接口,并且如果您希望确保安全的随机数生成器永远不会阻塞,可以将其设置为 /dev/urandom

然而,更好的解决方案是设置操作系统以提供更多熵,通过运行 rngd 守护程序来完成。只需确保 rngd 守护程序配置为使用可靠的硬件熵源(例如,如果可用,则使用 /dev/hwrng),而不是像 /dev/urandom 这样的东西。这种解决方案的优势在于解决了机器上所有程序的熵问题,而不仅仅是 Java 程序。

快速总结

  • Java 的默认 Random 类初始化昂贵,但一旦初始化完成,可重复使用。

  • 在多线程代码中,首选 ThreadLocalRandom 类。

  • 有时,SecureRandom 类会表现出任意的、完全随机的性能。对使用该类的代码进行性能测试必须谨慎计划。

  • 使用 SecureRandom 类可能会遇到阻塞问题,可以通过配置更改来避免,但最好通过增加系统熵在操作系统级别解决这些问题。

Java 本地接口

关于 Java SE 的性能提示(特别是在 Java 刚开始时),通常会说如果想要真正快速的代码,应该使用本地代码。但事实上,如果你希望编写尽可能快的代码,应避免使用 Java 本地接口(JNI)。

在当前 JVM 版本上,良好编写的 Java 代码至少与相应的 C 或 C++ 代码一样快(现在已不是 1996 年了)。语言纯粹主义者将继续辩论 Java 和其他语言的相对性能优点,无疑可以找到用另一种语言编写的应用程序比用 Java 编写的同一应用程序更快的例子(尽管这些例子通常包含编写不良的 Java 代码)。然而,这种辩论忽略了本节的重点:当应用程序已经用 Java 编写时,出于性能原因调用本地代码几乎总是一个坏主意。

但是,有时 JNI 是非常有用的。Java 平台提供了许多操作系统的常见功能,但如果需要访问特定于操作系统的特殊功能,那么就需要 JNI。而且,如果商业(本地)版本的代码已经准备就绪,为什么要构建自己的库来执行操作呢?在这些以及其他情况下,问题就变成了如何编写最有效的 JNI 代码。

答案是尽可能避免从 Java 到 C 的调用。跨 JNI 边界(称为进行跨语言调用)是昂贵的。因为调用现有的 C 库本身就需要编写粘合代码,所以要花时间通过该粘合代码创建新的粗粒度接口:一次性在 C 库中执行多个、多次调用。

有趣的是,反过来未必成立:调用 Java 返回 C 的 C 代码并不会受到很大的性能惩罚(取决于涉及的参数)。例如,请考虑以下代码摘录:

@Benchmark
public void testJavaJavaJava(Blackhole bh) {
    long l = 0;
    for (int i = 0; i < nTrials; i++) {
        long a = calcJavaJava(nValues);
        l += a / nTrials;
    }
    bh.consume(l);
}

private long calcJavaJava(int nValues) {
    long l = 0;
    for (int i = 0; i < nValues; i++) {
        l += calcJava(i);
    }
    long a = l / nValues;
    return a;
}

private long calcJava(int i) {
    return i * 3 + 15;
}

这段(完全无意义的)代码有两个主要循环:一个在基准方法内部,然后一个在 calcJavaJava() 方法内部。那是全部 Java 代码,但我们可以选择使用本地接口,将外部计算方法写在 C 中代替:

@Benchmark
public void testJavaCC(Blackhole bh) {
    long l = 0;
    for (int i = 0; i < nTrials; i++) {
        long a = calcCC(nValues);
        l += 50 - a;
    }
    bh.consume(l);
}

private native long calcCC(int nValues);

或者我们可以在 C 中实现内部调用(其代码应该是显而易见的)。

Table 12-8 展示了在给定 10,000 次试验和 10,000 个值的各种排列情况下的性能。

表 12-8. 跨 JNI 边界计算时间

calculateErrorCalcRandomJNI 转换总时间
JavaJavaJava00.104 ± 0.01 秒
JavaJavaC10,000,0001.96 ± 0.1 秒
JavaCC10,0000.132 ± 0.01 秒
CCC00.139 ± 0.01 秒

仅在 C 中实现最内层方法会产生 JNI 边界最多的交叉(numberOfTrials × numberOfLoops,即 1000 万次)。将交叉数量减少到 numberOfTrials(10,000)可以大大减少这种开销,将其进一步减少到 0 可以提供最佳性能。

如果涉及的参数不是简单的原始类型,则 JNI 代码的性能会变差。这种开销涉及两个方面。首先,对于简单的引用,需要地址转换。其次,对于基于数组的数据,在本地代码中需要进行特殊处理。这包括 String 对象,因为字符串数据本质上是字符数组。要访问这些数组的各个元素,必须进行特殊调用以将对象固定在内存中(对于 JDK 8 中的 String 对象,还要将其从 Java 的 UTF-16 编码转换为 UTF-8)。当不再需要数组时,必须在 JNI 代码中显式释放它。

在数组被固定时,垃圾收集器无法运行——因此 JNI 代码中最昂贵的错误之一就是在长时间运行的代码中固定字符串或数组。这会阻止垃圾收集器运行,从而有效阻塞所有应用程序线程,直到 JNI 代码完成。非常重要的是使数组被固定的关键部分尽可能短暂。

通常,您会在 GC 日志中看到术语 GC Locker Initiated GC。这表明垃圾收集器需要运行,但由于线程在 JNI 调用中固定了数据,所以无法运行。一旦该数据解除固定,垃圾收集器就会运行。如果经常看到这个 GC 原因,请考虑使 JNI 代码更快;其他应用程序线程正在等待 GC 运行时会出现延迟。

有时,为了短暂固定对象的目标与减少跨 JNI 边界调用的目标冲突。在这种情况下,后者的目标更为重要,即使这意味着在 JNI 边界上进行多次交叉调用,因此请尽可能使固定数组和字符串的部分尽可能短。

快速总结

  • JNI 不是性能问题的解决方案。几乎总是比调用本地代码更快。

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

  • 使用数组或字符串的 JNI 代码必须固定这些对象;限制它们固定的时间长度,以免影响垃圾收集器。

异常

Java 异常处理以昂贵著称。尽管在大多数情况下,其额外成本并不值得尝试绕过它,但它比处理常规控制流昂贵一些。另一方面,由于它并非免费,异常处理也不应作为通用机制。指导方针是根据良好程序设计的一般原则使用异常:主要是,代码只应在发生意外情况时抛出异常。遵循良好的代码设计意味着你的 Java 代码不会因异常处理而变慢。

两件事可能会影响异常处理的一般性能。首先是代码块本身:设置 try-catch 块是否昂贵?虽然很久以前可能是这样,但多年来情况并非如此。不过,因为互联网记忆力强,有时您会看到建议仅因为 try-catch 块而避免异常。这些建议已过时;现代 JVM 可以生成处理异常的代码。

第二个方面是异常涉及在异常点获取堆栈跟踪(尽管在本节后面您会看到一个例外)。这个操作可能很昂贵,特别是如果堆栈跟踪很深。

让我们来看一个例子。这里有三种特定方法的实现要考虑:

private static class CheckedExceptionTester implements ExceptionTester {
    public void test(int nLoops, int pctError, Blackhole bh) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < nLoops; i++) {
            try {
                if ((i % pctError) == 0) {
                    throw new CheckedException("Failed");
                }
                Object o = new Object();
                al.add(o.toString());
            } catch (CheckedException ce) {
                // continue
            }
        }
        bh.consume(al);
    }
}

private static class UncheckedExceptionTester implements ExceptionTester {
    public void test(int nLoops, int pctError, Blackhole bh) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < nLoops; i++) {
            Object o = null;
            if ((i % pctError) != 0) {
                o = new Object();
            }
            try {
                al.add(o.toString());
            } catch (NullPointerException npe) {
                // continue
            }
        }
        bh.consume(al);
    }
}

private static class DefensiveExceptionTester implements ExceptionTester {
    public void test(int nLoops, int pctError, Blackhole bh) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < nLoops; i++) {
            Object o = null;
            if ((i % pctError) != 0) {
                o = new Object();
            }
            if (o != null) {
                al.add(o.toString());
            }
        }
        bh.consume(al);
    }
}

每个方法都会创建一个从新创建的对象中生成的任意字符串数组。该数组的大小将根据需要抛出的异常数目而变化。

表 12-9 显示了在最坏情况下(pctError 为 1,每次调用生成一个异常,结果是一个空列表)完成每种方法的时间,例子代码可能是浅层(意味着只有 3 个类在堆栈上)或者深层(意味着在堆栈上有 100 个类)。

表 12-9. 处理异常所需的时间(100%)

方法浅层时间深层时间
已检查的异常24031 ± 127 μs30613 ± 329 μs
未经检查的异常21181 ± 278 μs21550 ± 323 μs
防御性编程21088 ± 255 μs21262 ± 622 μs

该表显示了三个有趣的点。首先,在检查异常的情况下,浅层情况和深层情况之间的时间差异显著。构建堆栈跟踪需要时间,这取决于堆栈深度。

但第二种情况涉及未经检查的异常,在 JVM 创建空指针解引用异常时。发生的情况是编译器在某个时刻优化了系统生成的异常情况;JVM 开始重用同一个异常对象,而不是每次需要时都创建新的异常对象。无论调用堆栈如何,该对象每次执行相关代码时都被重用,并且异常实际上不包含调用堆栈(即 printStackTrace() 方法没有输出)。这种优化在完全抛出完整堆栈异常相当长时间后才会发生,因此,如果您的测试用例不包括足够的预热周期,您将看不到其效果。

最后,考虑没有抛出异常的情况:注意到它与未检查的异常情况几乎具有相同的性能。这种情况在这个实验中起到了控制作用:测试会进行大量的工作来创建对象。防御性编程和其他情况之间的区别在于实际花费在创建、抛出和捕获异常上的时间。因此,总体时间非常短。在 100,000 次调用中平均下来,个体执行时间的差异几乎不会被注意到(请注意,这是最坏的情况示例)。

所以,对于不慎使用异常而言,性能惩罚要比预期的小得多,而对于大量相同系统异常的惩罚几乎不存在。然而,在某些情况下,你可能会遇到只是简单地创建了太多异常的代码。由于性能惩罚来自于填充堆栈跟踪信息,可以设置-XX:-StackTraceInThrowable标志(默认为true)以禁用堆栈跟踪信息的生成。

这通常不是一个好主意:堆栈跟踪存在是为了使我们能够分析发生意外错误的原因。启用此标志后,这种能力就会丢失。实际上有代码检查堆栈跟踪并根据其中的信息决定如何从异常中恢复。这本身就是一个问题,但问题的关键是禁用堆栈跟踪可能会神秘地破坏代码。

JDK 本身存在一些 API,异常处理可能会导致性能问题。当从集合类中检索不存在的项时,许多集合类会抛出异常。例如,当调用pop()方法时,如果堆栈为空,则Stack类会抛出EmptyStackException。在这种情况下,通常最好通过首先检查堆栈长度来使用防御性编程。(另一方面,与许多集合类不同,Stack类支持null对象,因此pop()方法不能返回null来指示空堆栈。)

在 JDK 中最臭名昭著的一个例子是类加载中对异常使用的质疑:当ClassLoader类的loadClass()方法试图加载它无法找到的类时会抛出ClassNotFoundException。这实际上并不是一个异常情况。一个单独的类加载器不应该知道如何加载应用程序中的每个类,这就是为什么有类加载器层次结构的原因。

在一个存在数十个类加载器的环境中,这意味着在搜索类加载器层次结构以找到知道如何加载给定类的类加载器时会创建大量的异常。在我曾经使用过的非常大的应用服务器中,禁用堆栈跟踪生成可以加快启动时间多达 3%。这些服务器从数百个 JAR 文件中加载超过 30,000 个类;这当然是一种因人而异的情况。³

快速总结

  • 异常处理并不一定是处理昂贵的操作,但应该在适当的时候使用。

  • 堆栈越深,处理异常的代价就越高。

  • JVM 将优化频繁创建的系统异常的堆栈惩罚。

  • 禁用异常中的堆栈跟踪有时可以提高性能,尽管在这个过程中通常会丢失关键信息。

日志记录

日志记录是性能工程师既爱又恨的事情之一,或者(通常)两者都是。每当我被问及为什么程序运行不佳时,我首先要求提供任何可用的日志,希望应用程序产生的日志可以提供关于应用程序正在执行的操作的线索。每当我被要求审查工作代码的性能时,我立即建议关闭所有日志记录语句。

这里涉及多个日志。JVM 生成其自己的日志语句,其中最重要的是 GC 日志(参见第六章)。该日志可以定向输出到一个独立的文件,文件的大小可以由 JVM 管理。即使在生产代码中,GC 日志(即使启用详细日志记录)的开销非常低,并且如果出现问题,预期的好处非常大,因此应始终打开。

HTTP 服务器生成的访问日志在每个请求时都会更新。该日志通常会产生显著影响:关闭该日志记录肯定会改善针对应用服务器运行的任何测试的性能。从诊断性的角度来看,当出现问题时,这些日志(根据我的经验)通常没有太大帮助。然而,从业务需求的角度来看,该日志通常至关重要,因此必须保持启用状态。

虽然它不是 Java 的标准,但许多 HTTP 服务器支持 Apache mod_log_config约定,允许您为每个请求指定要记录的信息(不遵循mod_log_config语法的服务器通常支持另一种日志自定义)。关键是尽量记录尽可能少的信息,同时满足业务需求。日志的性能取决于写入的数据量。

特别是在 HTTP 访问日志中(以及一般来说,在任何类型的日志中),建议以数字形式记录所有信息:使用 IP 地址而不是主机名,时间戳(例如,自纪元以来的秒数)而不是字符串数据(例如,“2019 年 6 月 3 日星期一 17:23:00 -0600”),等等。尽量减少需要时间和内存计算的数据转换,以便系统的影响也最小化。日志始终可以进行后处理以提供转换后的数据。

对于应用程序日志,我们应该牢记三个基本原则。首先是在记录数据和记录级别之间保持平衡。JDK 中有七个标准的日志记录级别,并且默认情况下记录器配置为输出其中的三个级别(INFO及更高)。这经常在项目中造成混淆:INFO级别的消息听起来应该是相当常见的,并且应该提供应用程序流程的描述("现在我正在处理任务 A","现在我在执行任务 B"等等)。特别是对于大量线程和可伸缩的应用程序,这样多的日志记录会对性能产生不利影响(更不用说过于啰嗦而无用了)。不要害怕使用更低级别的日志记录语句。

类似地,当代码提交到组仓库时,考虑项目使用者的需求,而不是作为开发者的个人需求。我们都希望在代码集成到更大系统并通过一系列测试后能够得到很多有用的反馈,但如果一条消息对最终用户或系统管理员来说没有意义,默认启用它并不会有所帮助。这只会减慢系统速度(并使最终用户感到困惑)。

第二个原则是使用细粒度的记录器。每个类一个记录器可能配置起来有些繁琐,但能够更精确地控制日志输出通常是值得的。在小模块中为一组类共享一个记录器是一个不错的折衷方案。请记住,生产环境中的问题——特别是在负载较大或与性能相关的问题——如果环境发生显著变化,可能会很难复现。打开过多的日志记录通常会改变环境,使得原始问题不再显现。

因此,您必须能够仅为一小部分代码(至少最初只是一小部分FINE级别的日志语句,然后是更多的FINERFINEST级别的语句)打开日志记录,以确保不影响代码的性能。

在这两个原则之间,应该可以在生产环境中启用小型消息子集而不影响系统性能。这通常是一个要求:生产系统管理员可能不会在降低系统性能的情况下启用日志记录,如果系统变慢,那么再现问题的可能性就会降低。

在向代码引入日志记录时的第三个原则是记住,编写具有意外副作用的日志记录代码是很容易的,即使未启用日志记录也是如此。这是另一种情况下,“过早”优化代码是一个好事情的例子:正如第一章的例子所示,记得在需要记录的信息包含方法调用、字符串连接或任何其他类型的分配(例如,为 MessageFormat 参数分配 Object 数组)时,始终使用 isLoggable() 方法。

快速总结

  • 代码应包含大量日志记录,以便用户了解其功能,但默认情况下不应启用任何日志记录。

  • 在调用记录器之前不要忘记测试日志记录级别,如果记录器的参数需要方法调用或对象分配。

Java 集合 API

Java 的集合 API 非常广泛;它至少拥有 58 个集合类。在编写应用程序时,选择适当的集合类以及适当使用集合类,是重要的性能考量。

使用集合类的第一条规则是选择适合应用程序算法需求的集合类。这些建议并不局限于 Java;它实际上是数据结构的基础知识。LinkedList 不适合搜索;如果需要随机访问数据,请将集合存储在 HashMap 中。如果数据需要保持排序状态,请使用 TreeMap 而不是尝试在应用程序中对数据进行排序。如果数据将通过索引进行访问,请使用 ArrayList,但如果需要经常在数组中间插入数据,则不要使用 ArrayList。等等……选择哪种集合类的算法选择非常关键,但在 Java 中的选择与其他编程语言中的选择并无不同。

在使用 Java 集合时,需要考虑一些特殊情况。

同步与非同步

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

第九章提出了一个微基准测试,比较基于 CAS 的保护与传统同步。这在多线程情况下是不切实际的,但如果所讨论的数据始终由单个线程访问,那么完全不使用任何同步会有什么影响呢?表 12-10 显示了该比较结果。由于没有尝试模拟争用,因此在这种情况下的微基准测试在这一个特定情况下是有效的:当不存在争用时,并且所讨论的问题是“过度同步”访问资源的成本。

表 12-10. 同步访问与非同步访问的性能

模式单次访问10,000 次访问
CAS 操作22.1 ± 11 ns209 ± 90 μs
同步方法20.8 ± 9 ns179 ± 95 μs
非同步方法15.8 ± 5 ns104 ± 55 μs

使用任何数据保护技术相对于简单的非同步访问都会有一些小的惩罚。就像使用微基准测试一样,差异微小:大约在 5-8 纳秒的数量级上。如果所讨论的操作在目标应用程序中执行频率足够高,则性能惩罚会有些明显。在大多数情况下,这种差异将被应用程序其他领域的远大于此的效率低下所抵消。还要记住,这里的绝对数字完全取决于运行测试的目标机器(我的家用机器带有 AMD 处理器);为了获得更真实的测量结果,需要在与目标环境相同的硬件上运行测试。

因此,在同步列表(例如从Collections类的synchronizedList()方法返回的列表)和非同步ArrayList之间进行选择,应该使用哪一个?对ArrayList的访问速度稍快,而且根据列表的访问频率不同,可能会产生可测量的性能差异。(正如在第九章中指出的,对同步方法的过度调用也可能对某些硬件平台的性能产生负面影响。)

另一方面,这假设代码永远不会被多个线程访问。今天可能是这样,但明天呢?如果可能会改变,最好现在使用同步集合,并减轻由此产生的任何性能影响。这是一个设计选择,未来是否使代码具有线程安全性值得花费时间和精力将取决于正在开发的应用程序的情况。

集合大小调整

集合类设计为容纳任意数量的数据元素,并根据需要进行扩展,随着新项添加到集合中。适当调整集合的大小对其整体性能可能很重要。

尽管 Java 中集合类提供的数据类型非常丰富,但在基本水平上,这些类必须仅使用 Java 基本数据类型来保存其数据:数字(integerdouble等),对象引用以及这些类型的数组。因此,ArrayList包含一个实际数组:

private transient Object[] elementData;

当在ArrayList中添加和删除项时,它们将存储在elementData数组中的所需位置(可能会导致数组中的其他项移动)。同样,HashMap包含一个称为HashMap$Entry的内部数据类型的数组,该数组将每个键值对映射到由键的哈希码指定的数组中的位置。

并非所有集合都使用数组来保存它们的元素;例如,LinkedList 将每个数据元素保存在内部定义的 Node 类中。但是,那些确实使用数组来保存它们的元素的集合类需要特别考虑大小。可以通过查看它的构造函数来判断某个特定类是否属于这一类别:如果它有一个允许指定集合初始大小的构造函数,它就在内部使用数组来存储项目。

对于那些集合类,准确指定初始大小是很重要的。以 ArrayList 的简单示例为例:elementData 数组(默认情况下)将以初始大小为 10 开始。当第 11 个项被插入到 ArrayList 中时,列表必须扩展 elementData 数组。这意味着分配一个新数组,将原始内容复制到该数组中,然后添加新项。例如,HashMap 类使用的数据结构和算法要复杂得多,但原理是相同的:在某个时候,这些内部结构必须调整大小。

ArrayList 类选择通过增加大约现有大小的一半来调整数组大小,因此 elementData 数组的大小将首先为 10,然后为 15,然后为 22,然后为 33,依此类推。无论使用何种算法来调整数组大小(参见侧边栏),这都会导致内存浪费(进而影响应用程序执行 GC 所花费的时间)。此外,每次必须调整数组大小时,都必须执行昂贵的数组复制操作,将内容从旧数组转移到新数组中。

为了最大限度地减少这些性能惩罚,请确保尽可能准确地估计集合的最终大小来构造它们。

集合和内存效率

刚刚看到了集合内存效率不够优化的一个例子:在用于保存集合中元素的后备存储时通常会有一些内存浪费。

对于稀疏使用的集合,这可能特别成问题:那些只有一两个元素的集合。如果广泛使用这些稀疏使用的集合,它们可能会浪费大量内存。解决这个问题的一种方法是在创建集合时调整其大小。另一种方法是考虑在这种情况下是否真的需要集合。

当大多数开发人员被问及如何快速对任何数组进行排序时,他们会提出快速排序作为答案。性能良好的工程师会想知道数组的大小:如果数组足够小,最快的排序方式将是使用插入排序。⁴ 大小是重要的。

类似地,HashMap 是根据键值查找项目的最快方式,但如果只有一个键,与使用简单对象引用相比,HashMap 就过度了。即使有几个键,维护几个对象引用的内存消耗也远远小于完整的 HashMap 对象,这对 GC 的影响是积极的。

快速总结

  • 仔细考虑如何访问集合并选择适当的同步类型。然而,对于内存受保护的集合(特别是使用 CAS-based 保护的集合),无竞争访问的惩罚是最小的;有时最好保险起见。

  • 集合的大小对性能有很大影响:如果集合太大,可能会减慢垃圾收集器;如果集合太小,则可能会导致大量复制和调整大小。

Lambdas 和匿名类

对许多开发人员来说,Java 8 最令人兴奋的特性是添加了 lambda。毫无疑问,lambda 对 Java 开发人员的生产力有巨大的积极影响,尽管这种好处很难量化。但是我们可以检查使用 lambda 构造的代码的性能。

关于 lambda 性能的最基本问题是它们与它们的替代品——匿名类的比较。结果显示几乎没有区别。

使用 lambda 类的典型示例通常以创建匿名内部类的代码开始(通常示例使用Stream而不是此处显示的迭代器;有关Stream类的信息稍后在本节中介绍):

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 int calc() {
   IntegerInterface a3 = () -> { return 3; };
   IntegerInterface a2 = () -> { return 2; };
   IntegerInterface a1 = () -> { return 1; };
    return a3.getInt() + a2.getInt() + a1.getInt();
}

Lambda 或匿名类的主体至关重要:如果主体执行任何重要操作,那么操作花费的时间将会压倒 lambda 或匿名类实现中的任何小差异。然而,即使在这种最小的情况下,执行此操作所需的时间基本相同,如表 12-11 所示,尽管随着表达式(即类/lambda 的数量)的增加,确实会出现一些小差异。

表 12-11. 使用 lambda 和匿名类执行calc()方法的时间

实现1,024 个表达式3 个表达式
匿名类781 ± 50 μs10 ± 1 ns
Lambda587 ± 27 μs10 ± 2 ns
静态类734 ± 21 μs10 ± 1 ns

在这个例子中典型用法的一个有趣之处是,使用匿名类的代码每次调用方法时都会创建一个新对象。如果方法被频繁调用(如在性能测试中必须这样做),那么许多匿名类的实例会很快被创建和丢弃。正如你在第五章中看到的,这种使用通常对性能影响不大。分配(更重要的是初始化)对象存在非常小的成本,并且由于它们很快被丢弃,它们实际上不会拖慢垃圾收集器。然而,在纳秒级的测量范围内,这些小时间确实会累积起来。

表中的最后一行使用的是预构建对象,而不是匿名类:

private IntegerInterface a1 = new IntegerInterface() {
    public int getInt() {
        return 1;
    }
};
... Similarly for the other interfaces....
public void calc() {
       return a1.get() + a2.get() + a3.get();
   }
}

典型的 lambda 用法在循环的每次迭代中不会创建新对象,这是一些边界情况下 lambda 使用性能优势的地方。即使在这个例子中,差异也是微小的。

快速总结

  • 使用 lambda 还是匿名类的选择应该由编程的便利性决定,因为它们在性能上没有区别。

  • Lambdas 并非作为匿名类实现,因此在类加载行为重要的环境中有一个例外;在这种情况下,lambda 会稍微更快。

流和过滤器性能

Java 8 的另一个关键特性,也是经常与 lambda 一起使用的特性,是新的Stream工具。流的一个重要性能特性是它们可以自动并行化代码。关于并行流的信息可以在第九章找到;本节讨论流和过滤器的一般性能特性。

懒遍历

流的第一个性能优势是它们被实现为惰性数据结构。假设我们有一个股票符号列表,目标是找到列表中第一个不包含字母A的符号。通过流执行此操作的代码如下:

@Benchmark
public void calcMulti(Blackhole bh) {
    Stream<String> stream = al.stream();
    Optional<String> t = 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();
    String answer = t.get();
    bh.consume(answer);
}

显然,有一个更好的方法可以使用单一的过滤器来实现,但我们将在本节稍后讨论这个问题。现在,考虑在这个例子中实现惰性流的含义。每个filter()方法返回一个新的流,因此在这里实际上有四个逻辑流。

filter()方法事实上并不执行任何操作,只是设置一系列指针。其效果是当在流上调用findFirst()方法时,尚未执行任何数据处理——还没有对数据与字符A进行比较。

相反,findFirst()要求前一个流(从 filter 4 返回)提供一个元素。该流目前没有元素,因此它回调到由 filter 3 产生的流,依此类推。Filter 1 将从数组列表(从技术上讲是从流中)获取第一个元素,并测试其第一个字符是否为A。如果是,则完成回调并将该元素传递到下游;否则,它继续迭代数组直到找到匹配的元素(或耗尽数组)。Filter 2 的行为类似——当回调到 Filter 1 返回时,它测试第二个字符是否不是A。如果是,则完成其回调并将符号传递到下游;如果不是,则再次回调到 Filter 1 获取下一个符号。

所有这些回调听起来可能效率低下,但考虑一下替代方案。急切处理流的算法可能如下所示:

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

@Benchmark
public void calcEager(Blackhole bh) {
    ArrayList<String> al1 = calcArray(al, 0, 'A');
    ArrayList<String> al2 = calcArray(al1, 1, 'A');
    ArrayList<String> al3 = calcArray(al2, 2, 'A');
    ArrayList<String> al4 = calcArray(al3, 3, 'A');
    String answer = al4.get(0);
    bh.consume(answer);
}

这种替代方案比 Java 实际采用的懒惰实现效率低的原因有两点。首先,它需要创建大量的ArrayList类的临时实例。其次,在懒惰的实现中,一旦findFirst()方法获得一个元素,处理就可以停止了。这意味着只有部分项目实际上需要通过过滤器。相反,急切的实现必须多次处理整个列表,直到创建最后的列表。

因此,在这个例子中,懒惰的实现比替代方案要更高效并不奇怪。在这种情况下,测试正在处理一个按字母顺序排序的、包含 456,976 个四个字母符号的列表。急切的实现在遇到符号BBBB之前只处理了 18,278 个符号就停止了。而迭代器则需要更长时间才能找到答案,如表 12-12 所示,需要两个数量级的时间。

表 12-12. 懒惰与急切过滤器处理时间

实现
过滤器/findFirst0.76 ± 0.047 毫秒
迭代器/findFirst108.4 ± 4 毫秒

因此,过滤器比迭代器快得多的一个原因是,它们可以利用算法上的优化机会:懒惰的过滤器实现只需在完成需要的工作后停止处理,处理的数据量较少。

如果整个数据集必须被处理,过滤器和迭代器在这种情况下的基本性能如何?例如,我们稍微改变了测试。之前的例子很好地说明了多个过滤器如何工作,但理想情况下,显而易见的是使用单个过滤器代码性能会更好:

@Benchmark
public void countFilter(Blackhole bh) {
    count = 0;
    Stream<String> stream = al.stream();
    stream.filter(
        symbol -> symbol.charAt(0) != 'A' &&
        symbol.charAt(1) != 'A' &&
        symbol.charAt(2) != 'A' &&
        symbol.charAt(3) != 'A').
          forEach(symbol -> { count++; });
    bh.consume(count);
}

这个例子还改变了最终代码以计算符号的数量,以便整个列表都能被处理。与此同时,急切的实现现在可以直接使用迭代器:

@Benchmark
public void countIterator(Blackhole bh) {
    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++;
      }
    bh.consume(count);
}

即使在这种情况下,懒惰的过滤器实现也比迭代器更快(见表 12-13)。

表 12-13. 单个过滤器与迭代器处理时间对比

实现所需时间
过滤器7 ± 0.6 毫秒
迭代器7.4 ± 3 毫秒

快速总结

  • 过滤器通过允许在迭代数据时中途结束来提供显著的性能优势。

  • 即使整个数据集被处理,单个过滤器的性能也略优于迭代器。

  • 多个过滤器会带来额外开销;务必编写良好的过滤器。

对象序列化

对象序列化 是一种将对象的二进制状态写出的方法,以便稍后可以重新创建它。JDK 提供了一个默认机制来序列化实现了 SerializableExternalizable 接口的对象。实际上几乎每种对象的序列化性能都可以从默认的序列化代码中得到改善,但这绝对是一种在没有充分理由时不应该优化的情况。编写专门的序列化和反序列化代码将花费相当多的时间,并且这样的代码比使用默认序列化更难维护。序列化代码有时候也比较棘手,因此尝试优化它会增加生成错误代码的风险。

临时字段

一般来说,改进对象序列化成本的方法是序列化更少的数据。这可以通过将字段标记为 transient 来实现,默认情况下这些字段不会被序列化。然后类可以提供特殊的 writeObject()readObject() 方法来处理这些数据。如果数据不需要被序列化,将其标记为 transient 就足够了。

覆盖默认的序列化

writeObject()readObject() 方法允许完全控制数据的序列化方式。伴随着极大的控制权而来的是极大的责任:很容易搞砸这个。

要了解为什么序列化优化很棘手,可以看看一个简单的代表位置的 Point 对象的情况:

public class Point implements Serializable {
    private int x;
    private int y;
    ...
}

可以编写特殊序列化的代码如下:

public class Point implements Serializable {
    private transient int x;
    private transient int y;
    ....
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
	oos.writeInt(x);
	oos.writeInt(y);
    }
    private void readObject(ObjectInputStream ois)
	                        throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
	x = ois.readInt();
	y = ois.readInt();
    }
}

在像这样的简单例子中,更复杂的代码不会更快,但它仍然是功能正确的。但要注意在一般情况下使用这种技术时:

public class TripHistory implements Serializable {
    private transient Point[] airportsVisited;
    ....
    // THIS CODE IS NOT FUNCTIONALLY CORRECT
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        oos.writeInt(airportsVisited.length);
        for (int i = 0; i < airportsVisited.length; i++) {
            oos.writeInt(airportsVisited[i].getX());
            oos.writeInt(airportsVisited[i].getY());
        }
    }

    private void readObject(ObjectInputStream ois)
	                        throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        int length = ois.readInt();
        airportsVisited = new Point[length];
        for (int i = 0; i < length; i++) {
            airportsVisited[i] = new Point(ois.readInt(), ois.readInt();
        }
    }
}

在这里,airportsVisited 字段是一个数组,记录了我曾经飞过或从中飞出的所有机场,按照我访问它们的顺序排列。因此,像 JFK 这样的机场在数组中频繁出现;SYD 目前只出现了一次。

因为写入对象引用的成本很高,所以这段代码肯定比默认的序列化机制更快:在我的机器上,一个包含 100,000 个 Point 对象的数组在序列化时需要 15.5 ± 0.3 毫秒,反序列化时需要 10.9 ± 0.5 毫秒。使用这种“优化”方法,序列化只需 1 ± 0.600 毫秒,反序列化只需 0.85 ± 0.2 微秒。

然而,这段代码是不正确的。在序列化之前,一个单一的对象表示 JFK,并且该对象的引用多次出现在数组中。在数组被序列化然后再次反序列化之后,多个对象表示 JFK。这改变了应用程序的行为。

在这个例子中,当数组被反序列化时,那些指向 JFK 的引用最终变成了单独的、不同的对象。现在,当这些对象中的一个被更改时,只有那个对象被更改了,并且它最终拥有与其他引用 JFK 的对象不同的数据。

这是一个重要的原则要牢记,因为优化序列化通常涉及对象引用的特殊处理。如果处理得当,这可以极大地提高序列化代码的性能。如果处理不当,可能会引入难以察觉的错误。

考虑到这一点,让我们探讨StockPriceHistory类的序列化,看看如何进行序列化优化。该类中的字段包括以下内容:

public class StockPriceHistoryImpl implements StockPriceHistory {
    private String symbol;
    protected SortedMap<Date, StockPrice> prices = new TreeMap<>();
    protected Date firstDate;
    protected Date lastDate;
    protected boolean needsCalc = true;
    protected BigDecimal highPrice;
    protected BigDecimal lowPrice;
    protected BigDecimal averagePrice;
    protected BigDecimal stdDev;
    private Map<BigDecimal, ArrayList<Date>> histogram;
    ....
    public StockPriceHistoryImpl(String s, Date firstDate, Date lastDate) {
        prices = ....
    }
}

当为给定符号s构建股票历史记录时,对象创建并存储了一个按日期排序的prices映射,其中包含startend之间所有价格的日期。代码还保存了firstDatelastDate。构造函数不填充任何其他字段;它们是惰性初始化的。当调用这些字段中的任何一个 getter 时,getter 会检查needsCalc是否为true。如果是,它将必要时一次性计算剩余字段的适当值。

此计算包括创建histogram,记录股票以特定价格收盘的天数。直方图包含与prices映射中相同的数据(以BigDecimalDate对象表示),只是以不同的方式查看数据。

因为所有惰性初始化的字段都可以从prices数组计算得出,它们都可以标记为transient,因此在序列化或反序列化它们时不需要特殊处理。在这种情况下,示例很容易,因为代码已经在字段的惰性初始化上进行了处理;它可以在接收数据时重复执行这种惰性初始化。即使代码急切地初始化了这些字段,仍然可以将任何计算出的字段标记为transient,并在类的readObject()方法中重新计算它们的值。

还要注意,这保留了priceshistogram对象之间的对象关系:当重新计算直方图时,它将只向新映射中插入现有对象。

这种优化几乎总是一件好事,但在某些情况下可能会影响性能。表 12-14 显示了序列化和反序列化这种情况时,histogram对象是 transient 和 nontransient 的时间,以及每种情况下序列化数据的大小。

表 12-14。对象带有 transient 字段的序列化和反序列化时间

对象序列化时间反序列化时间数据大小
没有 transient 字段19.1 ± 0.1 毫秒16.8 ± 0.4 毫秒785,395 字节
transient 直方图16.7 ± 0.2 毫秒14.4 ± 0.2 毫秒754,227 字节

到目前为止,这个示例节省了大约 15%的总序列化和反序列化时间。但是此测试实际上还没有在接收端重新创建histogram对象。当接收端代码首次访问它时,该对象将被创建。

有时histogram对象可能不会被需要;接收方可能只对特定日期的价格感兴趣,而不关心直方图。这就是更不寻常的情况:如果histogram总是需要,并且计算直方图花费超过 2.4 毫秒,那么使用延迟初始化字段的情况实际上会导致性能下降。

在这种情况下,计算直方图并不属于那种情况——它是一个非常快速的操作。一般来说,可能很难找到重新计算数据比序列化和反序列化数据更昂贵的情况。但在优化代码时需要考虑这一点。

这个测试实际上并不传输数据;数据写入和读取都是从预分配的字节数组进行的,因此只测量了序列化和反序列化的时间。但是,请注意,将histogram字段设为瞬态也节省了大约 13%的数据大小。如果要通过网络传输数据,这一点将非常重要。

压缩序列化数据

代码的序列化性能可以通过第三种方式进行改进:压缩序列化数据以便更快地传输。在股票历史类中,通过在序列化过程中压缩prices映射来实现:

public class StockPriceHistoryCompress
	implements StockPriceHistory, Serializable {

    private byte[] zippedPrices;
    private transient SortedMap<Date, StockPrice> prices;

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

    private void readObject(ObjectInputStream in)
    		throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        unzipPrices();
    }

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

    protected void unzipPrices()
    		throws IOException, ClassNotFoundException {
        ByteArrayInputStream bais = new ByteArrayInputStream(zippedPrices);
        GZIPInputStream zip = new GZIPInputStream(bais);
        ObjectInputStream ois = new ObjectInputStream(
		new BufferedInputStream(zip));
        prices = (SortedMap<Date, StockPrice>) ois.readObject();
        ois.close();
        zip.close();
    }
}

zipPrices()方法将价格映射序列化为字节数组并保存生成的字节,在writeObject()方法中调用defaultWriteObject()方法时会将其正常序列化。(实际上,只要自定义了序列化,将zippedPrices数组设为瞬态并直接写出其长度和字节会略微更好。但这个示例代码更清晰,简单更好。)在反序列化时,执行反向操作。

如果目标是将数据序列化为字节流(如原始示例代码中),这是一个失败的建议。这并不令人惊讶;压缩字节所需的时间远远长于将它们写入本地字节数组的时间。这些时间显示在表格 12-15 中。

表格 12-15. 使用压缩序列化和反序列化 10,000 个对象所需的时间

使用案例序列化时间反序列化时间数据大小
无压缩16.7 ± 0.2 毫秒14.4 ± 0.2 毫秒754,227 字节
压缩/解压缩43.6 ± 0.2 毫秒18.7 ± 0.5 毫秒231,844 字节
仅压缩43.6 ± 0.2 毫秒.720 ± 0.3 毫秒231,844 字节

这张表格最有趣的一点在于最后一行。在这个测试中,数据在发送之前被压缩,但readObject()方法中并未调用unzipPrices()方法。相反,它在需要的时候才会被调用,这将是接收方首次调用getPrice()方法的时候。如果没有这个调用,反序列化时只需要处理少量的BigDecimal对象,速度相当快。

在这个例子中,接收者可能根本不需要实际的价格数据:接收者可能只需要调用 getHighPrice() 等方法来检索关于数据的聚合信息。只要这些方法是所需的全部内容,延迟解压缩 prices 信息可以节省大量时间。如果正在持久化的对象是需要的(例如,如果它是 HTTP 会话状态,作为备份副本存储以防应用服务器失败),那么延迟解压缩数据既可以节省 CPU 时间(跳过解压缩)又可以节省内存(因为压缩数据占用的空间更少)。

因此——特别是如果目标是节省内存而不是时间——对序列化的数据进行压缩,然后延迟解压缩可能是有用的。

如果序列化的目的是在网络上传输数据,我们会根据网络速度做出通常的权衡。在快速网络上,压缩所花费的时间很可能比传输更少数据所节省的时间长;而在较慢的网络上,情况可能恰恰相反。在这种情况下,我们将传输大约少了 500,000 字节的数据,因此可以根据传输这么多数据的平均时间来计算成本或节省。在这个例子中,我们将花费大约 40 毫秒来压缩数据,这意味着我们需要传输少约 500,000 字节的数据。在 100 Mbit/秒的网络上,这种情况下是打平的,意味着慢速的公共 WiFi 将会从启用压缩中受益,但更快的网络则不会。

跟踪重复对象

"对象序列化" 以一个例子开始,说明了如何不序列化包含对象引用的数据,以免在反序列化时破坏对象引用。然而,在 writeObject() 方法中可能实现的更强大的优化之一是不写出重复的对象引用。对于 StockPriceHistoryImpl 类而言,这意味着不会写出 prices 映射的重复引用。因为示例中使用了该映射的标准 JDK 类,我们不必担心这个问题:JDK 类已经被优化为最佳序列化它们的数据。尽管如此,深入了解这些类如何执行它们的优化仍然是有益的,以便理解可能的优化方式。

StockPriceHistoryImpl 类中,关键结构是一个 TreeMap。该映射的简化版本出现在图 12-2 中。使用默认序列化,JVM 将为节点 A 写出其原始数据字段;然后对节点 B(然后是节点 C)递归调用 writeObject() 方法。节点 B 的代码将写出其原始数据字段,然后递归写出其 parent 字段的数据。

树图结构

图 12-2. 简单的 TreeMap 结构

但是请等一下——那个 parent 字段是节点 A,它已经被写入。对象序列化代码足够智能以意识到这一点:它不会重新写入节点 A 的数据。相反,它只是向先前写入的数据添加一个对象引用。

跟踪先前写入的对象集合以及所有递归操作会对对象序列化造成轻微的性能损失。然而,正如在一个 Point 对象数组示例中所演示的那样,这是无法避免的:代码必须跟踪先前写入的对象并恢复正确的对象引用。不过,可以通过抑制可以在反序列化时轻松重新创建的对象引用来执行智能优化。

不同的集合类处理方式各不相同。例如,TreeMap 类只需遍历树并仅写入键和值;序列化过程会丢弃关于键之间关系(即它们的排序顺序)的所有信息。当数据被反序列化后,readObject() 方法会重新对数据进行排序以生成树。虽然再次对对象进行排序听起来可能会很昂贵,但实际上并非如此:在一个包含 10,000 个股票对象的集合上,这一过程比使用默认对象序列化快约 20%。

TreeMap 类也从这种优化中受益,因为它可以写出更少的对象。在地图中,一个节点(或者在 JDK 语言中称为 Entry)包含两个对象:键和值。因为地图不能包含两个相同的节点,序列化代码不需要担心保留对节点的对象引用。在这种情况下,它可以跳过写入节点对象本身,直接写入键和值对象。因此,writeObject() 方法最终看起来像这样(语法适应阅读的便利性):

private void writeObject(ObjectOutputStream oos) throws IOException {
    ....
    for (Map.Entry<K,V> e : entrySet()) {
        oos.writeObject(e.getKey());
        oos.writeObject(e.getValue());
    }
    ....
}

这看起来非常像对 Point 示例不起作用的代码。在这种情况下的不同之处在于,当这些对象可能相同时,代码仍然会写出对象。TreeMap 不能有两个相同的节点,因此不需要写出节点引用。然而,TreeMap 可以 有两个相同的值,因此必须将值作为对象引用写出。

这将我们带回了起点:正如我在本节开头所述,正确进行对象序列化优化可能会有些棘手。但是,当对象序列化在应用程序中成为显著的瓶颈时,正确优化它确实可以带来重要的好处。

快速总结

  • 数据的序列化可能是一个性能瓶颈。

  • 将实例变量标记为 transient 将使序列化更快,并减少要传输的数据量。这两者通常都会带来显著的性能提升,除非在接收端重新创建数据需要很长时间。

  • 通过 writeObject()readObject() 方法的其他优化可以显著加快序列化的速度。在使用时要小心,因为很容易出错并引入微妙的 bug。

  • 即使数据不会通过缓慢的网络传输,压缩序列化数据通常也是有益的。

总结

本节对 Java SE JDK 的关键领域进行了介绍,这也结束了我们对 Java 性能的考察。本章的大多数主题之一是展示了 JDK 本身性能的演进。随着 Java 作为一个平台的发展和成熟,其开发人员发现,重复生成的异常不需要花费时间提供线程堆栈;使用线程本地变量来避免随机数生成器同步是一个好的选择;ConcurrentHashMap 的默认大小太大等等。

连续不断的改进过程正是 Java 性能调优的全部内容。从调优编译器和垃圾收集器,到有效使用内存,理解 API 中关键性能差异,等等,本书中的工具和流程将允许您在自己的代码中提供类似的持续改进。

¹ Chacun à son goût 是(抱歉,约翰·施特劳斯二世)歌剧爱好者说“YMMV”(你的效果可能会有所不同)的方式。

² 由于早期 Java 版本中的一个 bug,有时建议将此标志设置为 /dev/./urandom 或其他变体。在 Java 8 及更高版本的 JVM 中,您可以简单地使用 /dev/urandom

³ 或者我应该说:Chacun à son goût

⁴ 快速排序的实现通常会在小数组中使用插入排序;在 Java 中,Arrays.sort() 方法假定任何少于 47 个元素的数组都可以通过插入排序比快速排序更快地排序。

附录:调优标志摘要

本附录涵盖了常用标志并指导何时使用它们。常用 在这里包括了在早期 Java 版本中常用且不再推荐的标志;旧版本 Java 的文档和提示可能会推荐这些标志,因此在这里进行了提及。

表 A-1. 调整即时编译器的标志

标志功能使用时机参见
-server此标志不再起作用,会被默默忽略。不适用“分层编译”
-client此标志不再起作用,会被默默忽略。不适用“分层编译”
-XX:+TieredCompilation使用分层编译。除非内存严重受限,否则始终使用。“分层编译” 和 “分层编译的权衡”
-XX:ReservedCodeCacheSize=<MB>为 JIT 编译器编译的代码保留空间。运行大型程序时,如果看到代码缓存不足的警告。“调整代码缓存”
-XX:InitialCodeCacheSize=<MB>为 JIT 编译器编译的代码分配初始空间。如果需要预先分配代码缓存的内存(这种情况很少见)。“调整代码缓存”
-XX:CompileThreshold=<N>设置方法或循环执行多少次后进行编译。此标志已不再推荐使用。“编译阈值”
-XX:+PrintCompilation提供 JIT 编译器操作的日志。当怀疑某个重要方法未被编译,或者对编译器的操作感到好奇时。“检查编译过程”
-XX:CICompilerCount=<N>设置 JIT 编译器使用的线程数。当启动了过多的编译器线程时。主要影响运行多个 JVM 的大型机器。“编译线程”
-XX:+DoEscapeAnalysis启用编译器的激进优化。在罕见情况下可能引发崩溃,因此有时建议禁用。除非确定它引起了问题,否则不要禁用。“逃逸分析”
-XX:UseAVX=<N>设置在 Intel 处理器上使用的指令集。在 Java 11 早期版本中应将此设置为 2;在后续版本中,默认为 2。“特定于 CPU 的代码”
-XX:AOTLibrary=<path>使用指定库进行预编译。在某些有限情况下,可能加速初始程序执行。仅在 Java 11 中为实验特性。“预编译”

表 A-2. 选择 GC 算法的标志

Flag它的作用何时使用它另请参阅
------------
-XX:+UseSerialGC使用简单的单线程 GC 算法。适用于单核虚拟机和容器,或者小(100 MB)堆。“串行垃圾收集器”
-XX:+UseParallelGC使用多线程收集年轻代和老年代,同时应用程序线程停止。用于通过吞吐量调优而不是响应性;Java 8 的默认选项。“吞吐量收集器”
-XX:+UseG1GC使用多线程收集年轻代,同时应用程序线程停止,以及后台线程从老年代中删除垃圾,最小化暂停时间。当您有可用的 CPU 用于后台线程,并且不希望出现长时间的 GC 暂停时使用。Java 11 的默认选项。“G1 GC 收集器”
-XX:+UseConcMarkSweepGC使用后台线程从老年代中删除垃圾,最小化暂停时间。不再推荐使用;请改用 G1 GC。“CMS 收集器”
-XX:+UseParNewGC与 CMS 一起,使用多线程收集年轻代,同时应用程序线程停止。不再推荐使用;请改用 G1 GC。“CMS 收集器”
-XX:+UseZGC使用实验性的 Z 垃圾收集器(仅适用于 Java 12)。为了减少年轻代 GC 的暂停时间,可以同时收集。“并发压缩:ZGC 和 Shenandoah”
-XX:+UseShenandoahGC使用实验性的 Shenandoah 垃圾收集器(仅适用于 Java 12 OpenJDK)。为了减少年轻代 GC 的暂停时间,可以同时收集。“并发压缩:ZGC 和 Shenandoah”
-XX:+UseEpsilonGC使用实验性的 Epsilon 垃圾收集器(仅适用于 Java 12)。如果您的应用程序从不需要执行 GC。“无收集:Epsilon GC”

表 A-3. 所有 GC 算法共同的标志

Flag它的作用何时使用它另请参阅
------------
-Xms设置堆的初始大小。当默认的初始大小对您的应用程序来说太小时。“调整堆大小”
-Xmx设置堆的最大大小。当默认的最大大小对您的应用程序来说太小(或可能太大)时。“调整堆大小”
-XX:NewRatio设置年轻代与老年代的比例。增加此比例以减少分配给年轻代的堆空间比例;降低此比例以增加分配给年轻代的堆空间比例。这只是一个初始设置;除非关闭自适应大小调整,否则比例将会变化。随着年轻代大小的减少,您将看到更频繁的年轻代 GC 和较少的完全 GC(反之亦然)。“调整代大小”
-XX:NewSize设置年轻代的初始大小。当您已经精确调整了应用程序的需求时。“代际大小调整”
-XX:MaxNewSize设置年轻代的最大大小。当您已经精确调整了应用程序的需求时。“代际大小调整”
-Xmn设置年轻代的初始和最大大小。当您已经精确调整了应用程序的需求时。“代际大小调整”
-XX:MetaspaceSize=*N*设置元空间的初始大小。对于使用大量类的应用程序,可以增加此值以超过默认值。“大小调整元空间”
-XX:MaxMetaspaceSize=*N*设置元空间的最大大小。将此数字降低以限制类元数据使用的本机空间量。“大小调整元空间”
-XX:ParallelGCThreads=*N*设置垃圾收集器用于前台活动(例如收集年轻代和对吞吐量 GC 来说,收集老年代)的线程数。在运行多个 JVM 或者在 Java 8 更新 192 之前的 Docker 容器中,可以将此值降低。考虑在非常大的系统上增加这个值以支持非常大的堆的 JVM。“控制并行度”
-XX:+UseAdaptiveSizePolicy设置后,JVM 将调整各种堆大小以尝试满足 GC 目标。如果堆大小已经精确调整,请关闭此选项。“自适应大小调整”
-XX:+PrintAdaptiveSizePolicy向 GC 日志添加有关代的调整大小信息。使用此标志可以了解 JVM 的操作方式。使用 G1 时,检查此输出以查看是否通过巨大对象分配触发了完整 GC。“自适应大小调整”
-XX:+PrintTenuringDistribution将续期信息添加到 GC 日志中。使用续期信息确定是否以及如何调整续期选项。“续期和幸存者空间”
-XX:InitialSurvivorRatio=*N*设置年轻代中专门用于幸存者空间的空间量。如果短生命周期对象频繁晋升到老年代,可以增加此值。“续期和幸存者空间”
-XX:MinSurvivorRatio=*N*设置年轻代中专用于幸存者空间的自适应空间量。减少此值会减少幸存者空间的最大大小(反之亦然)。“续期和幸存者空间”
-XX:TargetSurvivorRatio=*N*JVM 尝试保持在幸存者空间中的空闲空间量。增加此值会减少幸存者空间的大小(反之亦然)。“续期和幸存者空间”
-XX:InitialTenuringThreshold=*N*JVM 尝试保持对象在 survivor 空间中的初始 GC 周期数。增加此数字以使对象在 survivor 空间中保持更长时间,尽管要注意 JVM 会对其进行调整。“Tenuring 和 Survivor Spaces”
-XX:MaxTenuringThreshold=*N*JVM 尝试保持对象在 survivor 空间中的最大 GC 周期数。增加此数字以使对象在 survivor 空间中保持更长时间;JVM 将在此值和初始阈值之间调整实际阈值。“Tenuring 和 Survivor Spaces”
-XX:+DisableExplicitGC>阻止对System.gc()的调用产生任何效果。用于防止糟糕的应用程序显式执行 GC。“Causing 和 Disabling Explicit Garbage Collection”
-XX:-AggressiveHeap启用了一组对具有大量内存的机器以及运行单个具有大堆的 JVM 进行了“优化”的调整标志。最好不要使用此标志,而是根据需要使用特定的标志。“AggressiveHeap”

表 A-4。控制 GC 日志记录的标志

标志作用何时使用另请参阅
-Xlog:gc*控制 Java 11 中的 GC 日志记录。应始终启用 GC 日志记录,即使在生产中也是如此。 与 Java 8 的以下一组标志不同,此标志控制 Java 11 GC 日志记录的所有选项; 有关将此选项映射到 Java 8 标志的文本,请参阅文本。“GC 工具”
-verbose:gc在 Java 8 中启用基本的 GC 日志记录。应始终启用 GC 日志记录,但通常最好使用其他更详细的日志记录。“GC 工具”
-Xloggc:<path>在 Java 8 中,将 GC 日志定向到特殊文件而不是标准输出。始终如此,以更好地保存日志中的信息。“GC 工具”
-XX:+PrintGC在 Java 8 中启用基本的 GC 日志记录。应始终启用 GC 日志记录,但通常更详细的日志记录更好。“GC 工具”
-XX:+PrintGCDetails在 Java 8 中启用详细的 GC 日志记录。始终如此,即使在生产中(日志记录开销很小)。“GC 工具”
-XX:+PrintGCTimeStamps在 Java 8 中,为 GC 日志中的每个条目打印相对时间戳。始终如此,除非启用了日期时间戳。“GC 工具”
-XX:+PrintGCDateStamps在 Java 8 中为 GC 日志中的每个条目打印时间戳。比时间戳的开销略大,但可能更容易处理。“GC 工具”
-XX:+PrintReferenceGC在 Java 8 中,在 GC 期间打印关于软引用和弱引用处理的信息。如果程序大量使用这些引用,请添加此标志以确定它们对 GC 开销的影响。“软引用、弱引用及其他引用”
-XX:+UseGCLogFileRotation启用 GC 日志的轮转以节省文件空间在 Java 8 中。在生产系统中,运行时间长达数周时,GC 日志可能会占用大量空间。“GC 工具”
-XX:NumberOfGCLogFiles=*N*当在 Java 8 中启用日志文件轮转时,指示要保留的日志文件数。在生产系统中,运行时间长达数周时,GC 日志可能会占用大量空间。“GC 工具”
-XX:GCLogFileSize=*N*当在 Java 8 中启用日志文件轮转时,指示每个日志文件在轮转之前的大小。在生产系统中,运行时间长达数周时,GC 日志可能会占用大量空间。“GC 工具”

表 A-5. 吞吐量收集器的标志

标志功能使用时机参见
------------
-XX:MaxGCPauseMillis=*N*给吞吐量收集器一个提示,暂停时间应该是多长;堆的大小动态调整以尝试达到该目标。如果默认计算出的堆大小不符合应用程序目标,作为调优吞吐量收集器的第一步。“自适应和静态堆大小调优”
-XX:GCTimeRatio=*N*给吞吐量收集器一个提示,你愿意在 GC 中花费多少时间;堆的大小动态调整以尝试达到该目标。如果默认计算出的堆大小不符合应用程序目标,作为调优吞吐量收集器的第一步。“自适应和静态堆大小调优”

表 A-6. G1 收集器的标志

标志功能使用时机参见
------------
-XX:MaxGCPauseMillis=*N*给 G1 收集器一个提示,暂停时间应该是多长;G1 算法会调整以尝试达到该目标。作为调优 G1 收集器的第一步;增加此值以尝试防止 Full GC。“调优 G1 GC”
-XX:ConcGCThreads=*N*设置用于 G1 后台扫描的线程数。当有大量 CPU 可用并且 G1 正在经历并发模式失败时。“调优 G1 GC”
-XX:InitiatingHeapOccupancyPercent=*N*设置 G1 后台扫描开始的阈值。如果 G1 正在经历并发模式失败,请降低此值。“调优 G1 GC”
-XX:G1MixedGCCountTarget=*N*设置混合 GC 的次数,G1 尝试释放已标记为主要包含垃圾的区域。如果 G1 经历并发模式失败,请降低此值;如果混合 GC 周期过长,请增加此值。“调优 G1 GC”
-XX:G1MixedGCCountTarget=*N*设置混合 GC 的次数,G1 尝试释放已标记为主要包含垃圾的区域。如果 G1 经历并发模式失败,请降低此值;如果混合 GC 周期过长,请增加此值。“调优 G1 GC”
-XX:G1HeapRegionSize=*N*设置 G1 区域的大小。对于非常大的堆或应用程序分配非常大的对象,请增加此值。“G1 GC 区域大小”
-XX:+UseStringDeduplication允许 G1 消除重复字符串。适用于有大量重复字符串且国际化不可行的程序。“重复字符串和字符串国际化”

表 A-7. CMS 收集器标志

标志功能使用时机参见
-XX:CMSInitiating​OccupancyFraction``=*N*确定 CMS 应在老年代后台扫描开始时刻。当 CMS 经历并发模式失败时,降低此值。“理解 CMS 收集器”
-XX:+UseCMSInitiating​OccupancyOnly导致 CMS 仅使用 CMSInitiatingOccupancyFraction 来确定何时启动 CMS 后台扫描。每当指定 CMSInitiatingOccupancyFraction 时。“理解 CMS 收集器”
-XX:ConcGCThreads=*N*设置用于 CMS 后台扫描的线程数。当大量 CPU 可用且 CMS 经历并发模式失败时。“理解 CMS 收集器”
-XX:+CMSIncrementalMode以增量模式运行 CMS。不再支持。N/A

表 A-8. 内存管理标志

标志功能使用时机参见
-XX:+HeapDumpOnOutOfMemoryError在 JVM 抛出内存溢出错误时生成堆转储。如果应用程序因堆空间或永久代导致内存溢出错误,请启用此标志,以便分析堆中的内存泄漏。“内存溢出错误”
-XX:HeapDumpPath=<path>指定自动生成堆转储时应写入的文件名。若要指定除了 java_pid.hprof 之外的路径用于在内存溢出错误或 GC 事件时生成的堆转储,请使用此选项。“内存溢出错误”
-XX:GCTimeLimit=<N>指定 JVM 在执行太多 GC 周期时不抛出OutOfMemoryException的时间。降低此值,以便在程序执行太多 GC 周期时,JVM 更早地抛出 OOM 异常。“内存不足错误”
-XX:HeapFreeLimit=<N>指定 JVM 必须释放的内存量,以防止抛出OutOfMemoryException降低此值,以便在程序执行太多 GC 周期时,JVM 更早地抛出 OOM 异常。“内存不足错误”
-XX:SoftRefLRUPolicyMSPerMB=*N*控制软引用在被使用后存活的时间。在低内存条件下,缩短此值以更快地清理软引用。“软引用、弱引用和其他引用”
-XX:MaxDirectMemorySize=*N*控制通过ByteBuffer类的allocateDirect()方法分配多少本机内存。如果要限制程序可以分配的直接内存量,考虑设置此标志。不再需要设置此标志来分配超过 64 MB 的直接内存。“本机 NIO 缓冲区”
-XX:+UseLargePages指示 JVM 从操作系统的大页系统中分配页面(如果适用)。如果操作系统支持,此选项通常会提高性能。“大页”
-XX:+StringTableSize=*N*设置 JVM 用于保存国际化字符串的哈希表的大小。如果应用程序执行大量的字符串国际化,则增加此值。“重复字符串和字符串国际化”
-XX:+UseCompressedOops模拟对象引用的 35 位指针。对于小于 32 GB 的堆,默认是这个值;禁用它永远没有好处。“压缩 Oops”
-XX:+PrintTLAB在 GC 日志中打印关于 TLAB 的摘要信息。在使用不支持 JFR 的 JVM 时,请确保 TLAB 分配工作效率。“线程本地分配缓冲区”
-XX:TLABSize=*N*设置 TLAB 的大小。当应用程序在 TLAB 外执行大量分配时,使用此值来增加 TLAB 的大小。“线程本地分配缓冲区”
-XX:-ResizeTLAB禁用 TLAB 的调整大小功能。每当指定TLABSize时,请确保禁用此标志。“线程本地分配缓冲区”

表 A-9。本机内存跟踪的标志

标志作用使用时机参见
-XX:NativeMemoryTracking=*X*启用本机内存跟踪。当需要查看 JVM 在堆外使用的内存时。“本机内存跟踪”
-XX:+PrintNMTStatistics在程序终止时打印本地内存跟踪统计信息。当需要查看 JVM 在堆外使用的内存时使用。“本地内存跟踪”

Table A-10. 线程处理标志

FlagWhat it doesWhen to use itSee also
-Xss<N>设置线程的本机堆栈大小。减小此大小以为 JVM 的其他部分提供更多内存。“调整线程堆栈大小”
-XX:-BiasedLocking禁用 JVM 的偏向锁定算法。可以改善基于线程池的应用程序的性能。“偏向锁定”

Table A-11. JVM 杂项标志

FlagWhat it doesWhen to use itSee also
-XX:+CompactStrings在可能的情况下使用 8 位字符串表示(仅适用于 Java 11)。默认;始终使用。“紧凑字符串”
-XX:-StackTraceInThrowable阻止每次抛出异常时收集堆栈跟踪。在系统具有非常深的堆栈并且频繁抛出异常的情况下使用(且修复代码以减少异常抛出不可行时)。“异常”
-Xshare控制类数据共享。使用此标志为应用程序代码创建新的 CDS 存档。“类数据共享”

Table A-12. Java Flight Recorder 标志

FlagWhat it doesWhen to use itSee also
-XX:+FlightRecorder启用 Java Flight Recorder。始终建议启用 Flight Recorder,因为除非实际进行记录(在这种情况下,根据使用的功能,开销将有所不同,但仍然相对较小)。“Java Flight Recorder”
-XX:+FlightRecorderOptions设置通过命令行进行默认录制的选项(仅适用于 Java 8)。控制如何为 JVM 进行默认录制。“Java Flight Recorder”
-XX:+StartFlightRecorder使用给定的 Flight Recorder 选项启动 JVM。控制如何为 JVM 进行默认录制。“Java Flight Recorder”
-XX:+UnlockCommercialFeatures允许 JVM 使用商业(非开源)功能。如果具有适当的许可证,则必须设置此标志才能在 Java 8 中启用 Java Flight Recorder。“Java Flight Recorder”