务实的过早优化:不要错失关键3%的性能机会
……过早优化是万恶之源……
—— Donald Ervin Knuth
引言
“过早优化是万恶之源。”大多数软件工程师都知道这句话,它出自 Donald Knuth,也就是《计算机程序设计艺术》的作者、计算机科学领域最具影响力的人物之一。许多人还因此得出了一个实际结论:“先让它能跑,性能以后再说。”毕竟,增加一台 EC2 实例比找到根本原因要容易得多。
但 Knuth 真正的原话是:“我们应该忘记小的效率问题,大约 97% 的时候如此:过早优化是万恶之源。然而,我们也不应错过那关键 3% 中的机会。”
有点不一样,对吧?第二句话几乎从未被引用——这很方便,因为它把一个谨慎的论述变成了一个简单的借口。有时是出于懒惰,有时是因为人们认为优化意味着牺牲可读性:晦涩的位操作、难以理解的技巧、只有作者凌晨两点才能看懂的代码。
我相信 Knuth 确实是在警告这种优化。但这个假设出错的频率比人们想象的更高。良好、干净的代码往往也是高效的代码——这并非偶然,而是因为为工作选择合适的工具通常既更清晰又更快速。本文中的例子就是证明。
范围
本文聚焦于简单、廉价且不易出错的技巧,它们可以普遍适用——无论你的架构、框架或领域是什么。以我的经验,它们几乎不会带来把事情弄糟的风险。架构、设计、网络、数据库连接、线程——这些被刻意排除在讨论范围之外。不是因为它们不重要,而是因为它们依赖具体上下文。正确答案取决于你的具体系统,而且这些话题每一个都值得单独写一篇文章。
示例
字符串操作
我们都熟悉 JDK 内置的字符串工具,比如 equals()、startsWith()、endsWith()、contains():
不幸的是,JDK 只提供了一个用于忽略大小写比较的函数:s1.equalsIgnoreCase(s2)。没有用于忽略大小写的 startsWith()、endsWith()、contains() 函数。所以,我们经常将 toLowerCase() 或 toUpperCase() 与 startsWith()、endsWith()、contains() 组合使用:
虽然有点冗长且容易遇到空指针,但如果不处于关键路径上,也还可以。然而,这种技巧可能会导致一些性能问题。别忘了 String 是不可变类,所以这并不是在两个字符串之间进行逐字符比较,而是创建了两个额外的字符串,之后它们必须被垃圾回收。考虑到 String 是对 char 数组的封装,内存分配可能会变得昂贵。
解决方案是使用不同库提供的忽略大小写工具,例如 Apache Lang3:
或者,从 3.18.0 版本开始:
其中 CI 暴露不区分大小写的工具,CS 则暴露区分大小写的工具。
许多人喜欢正则表达式,有时会在并不必要的地方使用 java.util.Pattern 类。例如:
Pattern.compile("^prefix.+suffix$").matcher(s).find()
而不是:
甚至:
Pattern.compile("^prefix").matcher(s).find()而不是s.startsWith("prefix")Pattern.compile("suffix$").matcher(s).find()而不是s.endsWith("suffix")
模式匹配明显比简单的子串匹配慢得多。下表显示了 100 万次操作的评估时间:
我们从这张表能看到什么?
equals()和startsWith()的性能相似。endsWith()的成本是equals()的 2 倍。contains()的成本是equals()的 4 倍。- 大小写转换后接
startsWith()的时间是普通startsWith()的 10 倍(!)。 - 忽略大小写的比较函数没有任何性能损失。
- 使用预编译模式搜索子串比直接使用
contains()方法贵约 20%。 - 编译模式再使用,比直接使用
contains()方法几乎贵 10 倍。
所以,下次你准备使用 Pattern.compile() 时,值得停顿一秒钟想一想:这里真的需要正则表达式吗?还是普通字符串方法既更简单又更快?如果确实需要模式,至少提前编译好——更好的是,将其声明为 private static final 类成员。
集合
假设我们想知道某个给定列表是否包含特定元素:
实际上,这个调用会执行类似这样的代码:
从 Java 8 开始,我们有流式 API,它只是向我们隐藏了同样繁琐的细节:
当列表很短、变化频繁或只是偶尔查找时,这完全没问题。但如果列表很大、稳定且被反复查找,HashSet 才是合适的工具——它提供平均 O(1) 的查找,而不是 O(n)。
如果你无法改变原始数据结构,那么在初始化时转换一次,之后一直使用 Set 进行查找,几乎总是值得的。如果既需要保证元素顺序,又需要快速查找,我们可以持有重复的数据结构——一个 List 用于保持顺序,一个 Set 用于搜索——或者直接使用 LinkedHashSet,它同时解决了这两个问题。
另一种常见情况是忽略大小写的搜索。我们前面已经看到,toLowerCase() 或 toUpperCase() 与比较的组合会显著降低性能。这可以通过使用带有自定义比较器的 TreeSet 来解决,例如 String.CASE_INSENSITIVE_ORDER:
这会给你一个已排序且不区分大小写的 Set,无需额外分配——同样的方法也适用于 TreeMap,当你的数据是键值对时。
枚举查找
众所周知,可以通过内置方法 valueOf(s) 按名称查找枚举条目。然而,如果给定的字符串是小写,而遵循命名约定的枚举条目却是大写形式,该怎么办?有些人使用 toUpperCase() 和 valueOf() 的组合,工作得很好,但有我们上面讨论过的性能损失。
然而,人们经常更喜欢创建一个表示“自定义”名称的特殊字段,于是像这样的简单枚举:
变成了:
我们得指出,这种设计至少有两点劣势:
- 重复数据:自定义名称与内置名称相同,只是大小写不同,而这可以更简单地解决。
- 这允许使用真正的自定义名称,但根据我的经验,在大多数情况下这些名称并不需要,只会制造所谓的“边界情况”,而这些边界情况大多时候只是设计不佳的信号,并可能导致大量“愚蠢”的 bug。
不过,我们继续。人们通常如何使用这个自定义名称?
这个实现看起来相当不错,但这种做法意味着每次调用 ofColor() 都会遍历列表。是的,大多数情况下枚举不会很大,所以列表很短,但无论如何,如果我们可以在初始化时创建一个从自定义名称到枚举条目的映射,然后以 O(1) 复杂度使用它,为什么还要这么做呢?
下面的例子一次性解决了两个问题:它使用不区分大小写的 Map,在初始化时以枚举条目的标准 name() 作为键:
于是,现在 ofColor() 方法变得很简单:
有人可能会说,基于 Map 的实现并不总是可行,因为有时查找条件太复杂,无法简化为一个简单键。虽然我大体同意,但我可以说,在许多(如果不是大多数)情况下这仍然是可行的。
到目前为止,查找键是一个简单字符串。但如果搜索条件是一个范围而不是精确值呢?考虑一个更符合物理实际的模型,将颜色表示为电磁波的波长范围。
如何实现 ofWaveLength(int waveLength) 方法?直接的方法是遍历枚举值,将给定波长与每个条目的范围进行比较,也就是实现 O(n) 搜索。但我们可以使用 NavigableMap 做得更好,它正是为这种范围查询而设计的:
遗憾的是,查找方法不像前面的例子那样简单,但仍然非常简单和快速:
现在,我们来比较一下性能。
表中显示:
- 正如预期,
toUpperCase()使性能降低了一半。 - 使用
equals()进行迭代比valueOf()稍贵一些,尽管该枚举只有三个成员,而且随着枚举增大,迭代时间会线性增长。枚举成员越多,迭代耗时越长。 - 基于 Map 的实现甚至比基于内置
valueOf()的实现还要快。 - 基于流的迭代(
ofColor()迭代)慢得出奇。对于非常小的集合来说,流的设置开销(装箱、lambda 分发、Spliterator 初始化)不容小觑。
预初始化
这里的原则是:如果能一次完成,就不要做多次。最平凡的例子是字符串或数值常量:
然而,同样的原则也适用于更重的对象——而这一点才真正重要。我们来看看日志记录。大多数人都习惯在每个类的开头写下这行“神奇”代码(除非我们使用 Lombok 的 @Slf4j 注解):
这些修饰符(private static final)真的都需要吗?有些人试图节省打字时间:
而且,如果 logger 不是 static,我们还能更进一步:
这行代码看起来更好,因为它不易出错:这里的类名不是硬编码的,所以这行代码可以原样从一个类复制到另一个类,或者从基类继承。那么,问题是什么?问题在于,由于同步的注册表查找,获取正确的 logger 可能很昂贵。每次实例化都这样做,累积起来成本可观。
我的一个朋友告诉我,在他曾供职的公司里,在某个关键路径上做这个改动后,性能提升非常大,以至于他们成功将 AWS 集群减少了大约一百台大型 EC2 机器。
同样的规则也适用于模式编译。正如基准表所示,每次方法调用都编译一个模式,比重复使用预编译模式慢了近 10 倍。Pattern.compile() 的结果应始终存放在 static final 字段中。唯一的例外是正则表达式是动态生成的情况,但我们应尽力避免这种设计。
很多时候我们不得不格式化或解析日期。传统上,我会使用 SimpleDateFormat。还有什么比下面这个更显而易见的呢:
老实说,我多次按照上面所说的原则这样做:如果只需创建一次实例,就没有理由每次需要时都重新创建。问题在于 SimpleDateFormat 不是线程安全的,所以在不同线程之间共享同一个实例可能会引发问题。更糟糕的是:我们可能多年来一直带着这个 bug 而毫无察觉,因为它只会在高负载下出现,并且在某些情况下只会产生略微错误的结果,这些结果可能会淹没在大量有效数据中。
那么,我们应该每次需要时都创建 SimpleDateFormat 实例,让 CPU 和 GC 拼命工作吗?幸运的是,从 Java 8 开始,我们可以改用 DateTimeFormatter:
这个类是线程安全的,所以我们可以在不同线程之间共享其实例,并获得一致的结果。
结论
我们从一个几乎总被不完整引用的名言开始。Knuth 从没说过要忽略性能——他说的是不要为了投机性的收益而牺牲清晰度,同时提醒我们不要错过那关键 3% 中的机会。本文中的例子就属于那 3%。这里描述的性能问题都不应该出现在生产代码中。它们并不难避免——不需要性能分析器,不需要基准测试框架,也不需要架构讨论。只需要养成使用合适工具的习惯。而这个习惯会带来回报。
选择 equalsIgnoreCase() 而不是 toLowerCase().equals() 既更干净也更快。static final 的 logger 更简单也更廉价。预构建的枚举 Map 更易读,而且是 O(1)。在这里,好代码和高效代码并不冲突——它们就是同一段代码。唯一需要的是养成暂停一秒钟并问自己的习惯:我是不是在做 n 次,而其实一次就够了?
本文中的所有代码示例都可在 Gist 上获取。
DZone 贡献者所表达的观点属于他们自己。