Ohhnews

分类导航

$ cd ..
DZone Java原文

务实的过早优化:不要错失关键3%的性能机会

#过早优化#性能优化#java#代码质量#最佳实践

……过早优化是万恶之源……

—— Donald Ervin Knuth

引言

“过早优化是万恶之源。”大多数软件工程师都知道这句话,它出自 Donald Knuth,也就是《计算机程序设计艺术》的作者、计算机科学领域最具影响力的人物之一。许多人还因此得出了一个实际结论:“先让它能跑,性能以后再说。”毕竟,增加一台 EC2 实例比找到根本原因要容易得多。

但 Knuth 真正的原话是:“我们应该忘记小的效率问题,大约 97% 的时候如此:过早优化是万恶之源。然而,我们也不应错过那关键 3% 中的机会。”

有点不一样,对吧?第二句话几乎从未被引用——这很方便,因为它把一个谨慎的论述变成了一个简单的借口。有时是出于懒惰,有时是因为人们认为优化意味着牺牲可读性:晦涩的位操作、难以理解的技巧、只有作者凌晨两点才能看懂的代码。

我相信 Knuth 确实是在警告这种优化。但这个假设出错的频率比人们想象的更高。良好、干净的代码往往也是高效的代码——这并非偶然,而是因为为工作选择合适的工具通常既更清晰又更快速。本文中的例子就是证明。

范围

本文聚焦于简单、廉价且不易出错的技巧,它们可以普遍适用——无论你的架构、框架或领域是什么。以我的经验,它们几乎不会带来把事情弄糟的风险。架构、设计、网络、数据库连接、线程——这些被刻意排除在讨论范围之外。不是因为它们不重要,而是因为它们依赖具体上下文。正确答案取决于你的具体系统,而且这些话题每一个都值得单独写一篇文章。

示例

字符串操作

我们都熟悉 JDK 内置的字符串工具,比如 equals()startsWith()endsWith()contains()

$ java
s1.equals(s2);
s1.startsWith(s2);
s1.endsWith(s2);
s1.contains(s2);

不幸的是,JDK 只提供了一个用于忽略大小写比较的函数:s1.equalsIgnoreCase(s2)。没有用于忽略大小写的 startsWith()endsWith()contains() 函数。所以,我们经常将 toLowerCase()toUpperCase()startsWith()endsWith()contains() 组合使用:

$ java
s1.toLowerCase().startsWith(s2.toLowerCase());
s1.toLowerCase().endsWith(s2.toLowerCase());
s1.toLowerCase().contains(s2.toLowerCase());

虽然有点冗长且容易遇到空指针,但如果不处于关键路径上,也还可以。然而,这种技巧可能会导致一些性能问题。别忘了 String 是不可变类,所以这并不是在两个字符串之间进行逐字符比较,而是创建了两个额外的字符串,之后它们必须被垃圾回收。考虑到 String 是对 char 数组的封装,内存分配可能会变得昂贵。

解决方案是使用不同库提供的忽略大小写工具,例如 Apache Lang3:

$ java
startsWithIgnoreCase(s1, s2);
endsWithIgnoreCase(s1, s2);
containsIgnoreCase(s1, s2);

或者,从 3.18.0 版本开始:

$ java
Strings.CI.startsWith(s1, s2);
Strings.CS.startsWith(s1, s2);

其中 CI 暴露不区分大小写的工具,CS 则暴露区分大小写的工具。

许多人喜欢正则表达式,有时会在并不必要的地方使用 java.util.Pattern 类。例如:

Pattern.compile("^prefix.+suffix$").matcher(s).find()

而不是:

$ java
s.startsWith("prefix") && s.endsWith("suffix")

甚至:

  • Pattern.compile("^prefix").matcher(s).find() 而不是 s.startsWith("prefix")
  • Pattern.compile("suffix$").matcher(s).find() 而不是 s.endsWith("suffix")

模式匹配明显比简单的子串匹配慢得多。下表显示了 100 万次操作的评估时间:

操作(× 100 万次)时间(毫秒)
s.equals("hello")7
s.startsWith("hello")6
s.endsWith("hello")11
s.contains("hello")24
s.toUpperCase().startsWith("HELLO")65
s.equalsIgnoreCase("hello")5
Pattern.compile("hello").matcher(s).find()238
pattern.matcher(s).find()31

我们从这张表能看到什么?

  1. equals()startsWith() 的性能相似。
  2. endsWith() 的成本是 equals() 的 2 倍。
  3. contains() 的成本是 equals() 的 4 倍。
  4. 大小写转换后接 startsWith() 的时间是普通 startsWith() 的 10 倍(!)。
  5. 忽略大小写的比较函数没有任何性能损失。
  6. 使用预编译模式搜索子串比直接使用 contains() 方法贵约 20%。
  7. 编译模式再使用,比直接使用 contains() 方法几乎贵 10 倍。

所以,下次你准备使用 Pattern.compile() 时,值得停顿一秒钟想一想:这里真的需要正则表达式吗?还是普通字符串方法既更简单又更快?如果确实需要模式,至少提前编译好——更好的是,将其声明为 private static final 类成员。

集合

假设我们想知道某个给定列表是否包含特定元素:

$ java
list.contains("red");

实际上,这个调用会执行类似这样的代码:

$ java
int n = list.size();
for (int i = 0; i < n; i++) {
    if ("red".equals(list.get(i))) {
        return true;
    }
}

从 Java 8 开始,我们有流式 API,它只是向我们隐藏了同样繁琐的细节:

$ java
list.stream().anyMatch("red"::equals);

当列表很短、变化频繁或只是偶尔查找时,这完全没问题。但如果列表很大、稳定且被反复查找,HashSet 才是合适的工具——它提供平均 O(1) 的查找,而不是 O(n)。

如果你无法改变原始数据结构,那么在初始化时转换一次,之后一直使用 Set 进行查找,几乎总是值得的。如果既需要保证元素顺序,又需要快速查找,我们可以持有重复的数据结构——一个 List 用于保持顺序,一个 Set 用于搜索——或者直接使用 LinkedHashSet,它同时解决了这两个问题。

另一种常见情况是忽略大小写的搜索。我们前面已经看到,toLowerCase()toUpperCase() 与比较的组合会显著降低性能。这可以通过使用带有自定义比较器的 TreeSet 来解决,例如 String.CASE_INSENSITIVE_ORDER

$ java
Set set = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);

这会给你一个已排序且不区分大小写的 Set,无需额外分配——同样的方法也适用于 TreeMap,当你的数据是键值对时。

枚举查找

众所周知,可以通过内置方法 valueOf(s) 按名称查找枚举条目。然而,如果给定的字符串是小写,而遵循命名约定的枚举条目却是大写形式,该怎么办?有些人使用 toUpperCase()valueOf() 的组合,工作得很好,但有我们上面讨论过的性能损失。

然而,人们经常更喜欢创建一个表示“自定义”名称的特殊字段,于是像这样的简单枚举:

$ java
enum Color {
    RED, GREEN, BLUE
}

变成了:

$ java
enum Color {
    RED("red"),
    GREEN("green"),
    BLUE("blue"),
    ...
}

我们得指出,这种设计至少有两点劣势:

  1. 重复数据:自定义名称与内置名称相同,只是大小写不同,而这可以更简单地解决。
  2. 这允许使用真正的自定义名称,但根据我的经验,在大多数情况下这些名称并不需要,只会制造所谓的“边界情况”,而这些边界情况大多时候只是设计不佳的信号,并可能导致大量“愚蠢”的 bug。

不过,我们继续。人们通常如何使用这个自定义名称?

$ java
public static Color ofColor(String color) {
    return Arrays.stream(values())
        .filter(c -> c.color.equals(color))
        .findFirst()
        .orElseThrow(() -> new IllegalArgumentException("No enum constant %s.%s".formatted(Color.class.getName(), color)));
}

这个实现看起来相当不错,但这种做法意味着每次调用 ofColor() 都会遍历列表。是的,大多数情况下枚举不会很大,所以列表很短,但无论如何,如果我们可以在初始化时创建一个从自定义名称到枚举条目的映射,然后以 O(1) 复杂度使用它,为什么还要这么做呢?

下面的例子一次性解决了两个问题:它使用不区分大小写的 Map,在初始化时以枚举条目的标准 name() 作为键:

$ java
private static final Map colors =
    Arrays.stream(values()).collect(toMap(Enum::name, e -> e, (existing, replacement) -> replacement, () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER)));

于是,现在 ofColor() 方法变得很简单:

$ java
public static Color ofColor(String color) {
    return Optional.ofNullable(colors.get(color))
        .orElseThrow(() -> new IllegalArgumentException("No enum constant for " + color));
}

有人可能会说,基于 Map 的实现并不总是可行,因为有时查找条件太复杂,无法简化为一个简单键。虽然我大体同意,但我可以说,在许多(如果不是大多数)情况下这仍然是可行的。

到目前为止,查找键是一个简单字符串。但如果搜索条件是一个范围而不是精确值呢?考虑一个更符合物理实际的模型,将颜色表示为电磁波的波长范围。

$ java
public enum Color {
    BLUE(450, 495),
    GREEN(495, 570),
    RED(620, 750);
    ...
}

如何实现 ofWaveLength(int waveLength) 方法?直接的方法是遍历枚举值,将给定波长与每个条目的范围进行比较,也就是实现 O(n) 搜索。但我们可以使用 NavigableMap 做得更好,它正是为这种范围查询而设计的:

$ java
private static final NavigableMap<Integer, Color> wavelengthMap =
    Arrays.stream(values())
        .collect(Collectors.toMap(
            color -> color.minNm,
            color -> color,
            (existing, replacement) -> existing,
            TreeMap::new
        ));

遗憾的是,查找方法不像前面的例子那样简单,但仍然非常简单和快速:

$ java
public static Color ofWaveLength(int nm) {
    return Optional.ofNullable(wavelengthMap.floorEntry(nm))
        .map(Entry::getValue)
        .filter(value -> nm <= value.maxNm)
        .orElseThrow(() -> new IllegalArgumentException("No enum constant for wavelength: " + nm + " nm"));
}

现在,我们来比较一下性能。

操作(× 100 万次)时间(毫秒)
valueOf(s)34
valueOf(toUpperCase(s))78
使用 equals() 迭代40
Color.ofColor() 迭代166
Color.ofColor() map20
Color.ofWaveLength() map32

表中显示:

  1. 正如预期,toUpperCase() 使性能降低了一半。
  2. 使用 equals() 进行迭代比 valueOf() 稍贵一些,尽管该枚举只有三个成员,而且随着枚举增大,迭代时间会线性增长。枚举成员越多,迭代耗时越长。
  3. 基于 Map 的实现甚至比基于内置 valueOf() 的实现还要快。
  4. 基于流的迭代(ofColor() 迭代)慢得出奇。对于非常小的集合来说,流的设置开销(装箱、lambda 分发、Spliterator 初始化)不容小觑。

预初始化

这里的原则是:如果能一次完成,就不要做多次。最平凡的例子是字符串或数值常量:

$ java
private static final String FILE_NAME = "config.json";
private static final int MAX_VALUE = 10_000;

然而,同样的原则也适用于更重的对象——而这一点才真正重要。我们来看看日志记录。大多数人都习惯在每个类的开头写下这行“神奇”代码(除非我们使用 Lombok 的 @Slf4j 注解):

$ java
private static final Logger logger = LoggerFactory.getLogger(MyClass.class);

这些修饰符(private static final)真的都需要吗?有些人试图节省打字时间:

$ java
private final Logger logger = LoggerFactory.getLogger(MyClass.class);

而且,如果 logger 不是 static,我们还能更进一步:

$ java
private final Logger logger = LoggerFactory.getLogger(getClass());

这行代码看起来更好,因为它不易出错:这里的类名不是硬编码的,所以这行代码可以原样从一个类复制到另一个类,或者从基类继承。那么,问题是什么?问题在于,由于同步的注册表查找,获取正确的 logger 可能很昂贵。每次实例化都这样做,累积起来成本可观。

我的一个朋友告诉我,在他曾供职的公司里,在某个关键路径上做这个改动后,性能提升非常大,以至于他们成功将 AWS 集群减少了大约一百台大型 EC2 机器。

同样的规则也适用于模式编译。正如基准表所示,每次方法调用都编译一个模式,比重复使用预编译模式慢了近 10 倍。Pattern.compile() 的结果应始终存放在 static final 字段中。唯一的例外是正则表达式是动态生成的情况,但我们应尽力避免这种设计。

很多时候我们不得不格式化或解析日期。传统上,我会使用 SimpleDateFormat。还有什么比下面这个更显而易见的呢:

$ java
private static final String FORMAT = "yyyy-MM-dd HH:mm:ss";
private static final DateFormat format = new SimpleDateFormat(FORMAT);

老实说,我多次按照上面所说的原则这样做:如果只需创建一次实例,就没有理由每次需要时都重新创建。问题在于 SimpleDateFormat 不是线程安全的,所以在不同线程之间共享同一个实例可能会引发问题。更糟糕的是:我们可能多年来一直带着这个 bug 而毫无察觉,因为它只会在高负载下出现,并且在某些情况下只会产生略微错误的结果,这些结果可能会淹没在大量有效数据中。

那么,我们应该每次需要时都创建 SimpleDateFormat 实例,让 CPU 和 GC 拼命工作吗?幸运的是,从 Java 8 开始,我们可以改用 DateTimeFormatter

$ java
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_FORMAT);

这个类是线程安全的,所以我们可以在不同线程之间共享其实例,并获得一致的结果。

结论

我们从一个几乎总被不完整引用的名言开始。Knuth 从没说过要忽略性能——他说的是不要为了投机性的收益而牺牲清晰度,同时提醒我们不要错过那关键 3% 中的机会。本文中的例子就属于那 3%。这里描述的性能问题都不应该出现在生产代码中。它们并不难避免——不需要性能分析器,不需要基准测试框架,也不需要架构讨论。只需要养成使用合适工具的习惯。而这个习惯会带来回报。

选择 equalsIgnoreCase() 而不是 toLowerCase().equals() 既更干净也更快。static final 的 logger 更简单也更廉价。预构建的枚举 Map 更易读,而且是 O(1)。在这里,好代码和高效代码并不冲突——它们就是同一段代码。唯一需要的是养成暂停一秒钟并问自己的习惯:我是不是在做 n 次,而其实一次就够了?

本文中的所有代码示例都可在 Gist 上获取。

DZone 贡献者所表达的观点属于他们自己。