现在出现了很多关于流行语 “网络规模“ 的宣传。人们使用大量时间,通过重新组织他们的应用架构来获取系统“规模化”。
但是什么是规模化,我们应该怎样确定能够规模化?
规模化的不同方面
宣传中主要提到关于负载的规模化。比如,只要系统可以为 1 个用户工作,就能保证为 10 个用户、100 个用户或者成千上万的用户工作。想像一下,你的系统可以做到“状态无关”,那么少数真正保留的状态就能在网络中的任何处理单元进行传输和转换。如果负载是你的短板而延迟不是,那么个别需要 50-100ms 的请求就不会有问题。
另一个完全不同的方面是关于性能的规模化。比如,只要某个算法能处理 1 个信息片段,它就同样能处理 10个片段、100 个片段或成千上万个片段。这个类型的规模化最适合用大 O 符号 [ 译者注:大O表示时间复杂度 ] 来描述。延迟是规模化性能的杀手。你想尽一切可能把所有计算放在一台机器上进行,这通常称为按比例扩大规模。如果有免费的午餐 (并没有),我们可以无限度的联合扩展规模 。先不管这个,今天我们要看看如何通过一些简单的事情改善性能。
大 O 符号
Java 7 的 ForkJoinPool 和 Java 8 的 并行 Stream 有助于并行程序,如果你的 Java 程序发布在多核处理器的机器上,这会非常有用。与在网络中不同机器间的规模化架构相比,并行的优势在于它几乎可以完全消除延迟的影响,因为所有内核都能访问相同的内存。
但不要被并行计算的效果欺骗!一定要记住两件事情:
并行消耗核心。这对于批处理来说很好,但对异步服务器(比如 HTTP)简直就是噩梦。在过去的几十年中,我们有很好的理由来使用单线程 Servlet 模型。所以并行只对规模扩展有帮助。
并行不会对算法的大O符号产生影响。如果算法的时间复杂度是 O(n log n),让这个算法在 c 个核心上运行,时间复杂度会是 O(n log n / c),由于 c 在算法的复杂度中是一个微不足道的常量,你会节省一些时间,但不会降低复杂度。
提升性能最好的方式当然是减少复杂度。目标是获得 O(1) 或者接近 O(1) 的复杂度。当然,HashMap 查找就是一个例子。但这并不总是可能的,十分不易。
如果不能降低复杂度,仍然有可能调整有问题的算法来获得更好的性能,当然你得找到问题所在。假设下面是一个算法的图示:这个算法整体的复杂度是 O(N3),或者如果我们想处理个别的订单,复杂度是 O(N x O x P)。不过,在分析这个代码的时候,你会发现一个有趣的现象:
在你的开发矩形中,左边的分支 (N -> M -> Heavy operation) 是在分析中唯一关注的分支,因为 O 和 P 的值在样本数据中都很小。
而在生产环境,右边的分支 (N -> O -> P -> Easy operationor 或者 N.O.P.E.) 才是真正出现问题的地方。你的操作团队可能使用 AppDynamics 或 DynaTrace 或一些模拟软件,指出了这一点。
没有生产数据的情况下,你可能会迅速得出结论,需要优化 “heavy operation”。然而把修改应用到生产中却毫无作用。
设计良好的应用程序更容易优化
过早的优化不能解决任何性能问题,但如果应用程序设计得不好,反过来又会使它变得难以优化
理论已经足够了。假设你已经发现了右边的分支才是问题所在。可能是一个简单的操作爆发了这个问题,因为它被调用了很多很多次(比如 N、O、P 很大)。这篇文章中有一个在叶节点上必然存在 O(N3) 算法的问题,请阅读它。这些优化对你的规模没有帮助。它们能快速有效地帮你提升性能,但整体算法的改善还需日后解决。
这里有 10 条在 Java 中简单地进行性能优化的技巧:
1. 使用 StringBuilder
几乎所有 Java 代码中你都应该考虑这个问题。避免使用 + 号。你可能会认为 StringBuilder 只是个语法糖,比如:
String x = "a" + args.length + "b";
… 会编译成
0 new java.lang.StringBuilder [16]
3 dup
4 ldc <String "a"> [18]
6 invokespecial java.lang.StringBuilder(java.lang.String) [20]
9 aload_0 [args]
10 arraylength
11 invokevirtual java.lang.StringBuilder.append(int) : java.lang.StringBuilder [23]
14 ldc <String "b"> [27]
16 invokevirtual java.lang.StringBuilder.append(java.lang.String) : java.lang.StringBuilder [29]
19 invokevirtual java.lang.StringBuilder.toString() : java.lang.String [32]
22 astore_1 [x]
但是之后你需要根据条件来修改字符串,会发生什么事情呢?
String x = "a" + args.length + "b";
if (args.length == 1)
x = x + args[0];
你现在会有第二个 StringBuilder,这个 StringBuilder 本来没有存在的必要,它会消耗堆内存,给 GC 增加负担。你应该这样写:
StringBuilder x = new StringBuilder("a");
x.append(args.length);
x.append("b");
if (args.length == 1);
x.append(args[0]);
关键点
在上面的例子中,显式地使用 StringBuilder 实例,和 Java 编译器隐式使用 StringBuilder 实例是没有关联的。但是记住,我们在 N.O.P.E 分支。我们在每个 CPU 周期对 GC 和为 StringBuilder 分配空间所产生的浪费,都会浪费 N x O x P 倍。
总是使用 StringBuilder,不用 + 运算符是一个不错的规则。如果你的字符串构建起来很复杂,尽可能在多个方法间使用同一个 StringBuilder。这就是你在生成复杂的 SQL 时 jOOQ 所做的事情。只有一个 StringBuilder 在整个 SQL AST (Abstract Syntax Tree) 中“游走”。
如果你还有 StringBuffer 引用,大声哭吧,把它们换成 StringBuilder,因为你很少需要同步创建一个字符串。[译者注:StringBuffer 与 StringBuilder 的区别就在于 StringBuffer 是线程安全的,但在同步代码中没必要使用 StringBuffer,它会带来额外的性能开销。]
2. 避免正则表达式
正则表达式相对便宜和方便。但是如果你在 N.O.P.E 分支,那很糟糕了。如果你必须在计算机密集的代码段中使用正则表达式,至少把 Pattern 的引用缓存下来,避免每次都对其重新编译:
static final Pattern HEAVY_REGEX =
Pattern.compile("(((X)*Y)*Z)*");
但是如果你的正则表达式真的很简单,就像
String[] parts = ipAddress.split("\\.");
… 然后你真的最好诉诸普通的 char[] 或基于索引的操作。例如下面很信读的一段代码做了同样的事情:
int length = ipAddress.length();
int offset = 0;
int part = 0;
for (int i = 0; i < length; i++) {
if (i == length - 1 ||
ipAddress.charAt(i + 1) == '.') {
parts[part] =
ipAddress.substring(offset, i + 1);
part++;
offset = i + 2;
}
}
… 这也说明了为什么你不应该过早进行优化。与 split() 的版本相比,这简直不可维护。
挑战:请读者们找到更快的方法。
分析
正则表达式很有用,但需要代价。如果你在 N.O.P.E 分支,就必须避免正则表达式的代价。小心使用各种 JDK String 那些使用正则表达式的方法,比如 String.replaceAll()、String.split() 等。
请使用像 Apache Commons Lang 这样的知名类库来操作字符串。
3. 不要使用 iterator()
这个建议不太适用于常规用例,只适用于 N.O.P.E. 分支,但你也可以用用看。编写 Java-5 风格的 foreach 循环很方便。 你可以完全忽略循环内部变量,并编写:
for (String value : strings) {
// Do something useful here
}
然而,每当你运行到循环内部时,如果 string 是一个 Iterable,你就要创建一个新的 Iterator 实例。如果你正在使用 ArrayList,这将会在堆上分配一个含 3 个 int 的对象:
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = modCount;
// ...
相反,你可以编写以下代码——等价循环体,并且在栈上仅“浪费”一个 int 值,开销低:
int size = strings.size();
for (int i = 0; i < size; i++) {
String value : strings.get(i);
// Do something useful here
}
… 或者,你可以选择不改变链表,在数组版本上使用同样的操作:
for (String value : stringArray) {
// Do something useful here
}
关键点
从可写性和可读性以及从 API 设计的角度来看,Iterators、Iterable 和 foreach 循环都是非常有用的。但它们在堆上为每次单独的迭代创建一个小的新实例。 如果你运行这个迭代许多次,又想避免创建这个无用的实例,可以使用基于索引的迭代。
讨论
关于上面观点相反的有趣意见(特别是使用索引访问替代迭代器)已在 Reddit 上讨论研究。
4. 不要调用这些方法
一些方法简单但开销不小。在N.O.P.E.分支示例中,我们没有在叶节点上使用这样的方法,但你可能使用到了。我们假设 JDBC 驱动程序需要耗费大量资源来计算 ResultSet.wasNull() 的值。你可能会用下列代码开发 SQL 框架:
if (type == Integer.class) {
result = (T) wasNull(rs,
Integer.valueOf(rs.getInt(index)));
}
// And then...
static final <T> T wasNull(ResultSet rs, T value)
throws SQLException {
return rs.wasNull() ? null : value;
}
此处逻辑每次都会在你从结果集中获得一个 int 之后立即调用 ResultSet.wasNull()。但getInt() 的约定是:
返回: 列的数目;如果这个值是 SQL NULL,这个值将返回 0。
因此,对上述问题的简单但可能有效的改进将是:
static final <T extends Number> T wasNull(
ResultSet rs, T value
)
throws SQLException {
return (value == null ||
(value.intValue() == 0 && rs.wasNull()))
? null : value;
}
因此,这不需要过多考虑。
关键点
不要在算法的“叶节点”中调用开销昂贵的方法,而是缓存该调用,或者如果方法规约允许则规避之。
5. 使用基本类型和栈
上面的例子来自 jOOQ,它大量使用了泛型。泛型会强制对 byte、short、int 和 long 这些类型进行装箱 —— 至少在这之前:泛型会在 Java 10 和 Valhalla 项目中实现专业化。不过现在你的代码里并没实现这种约束,所以你得采取措施:
// Goes to the heap
Integer i = 817598;
… 替换为下面这个:
// Stays on the stack
int i = 817598;
如果你使用数组的话,情况不太妙:
// Three heap objects!
Integer[] i = { 1337, 424242 };
… 替换成这个:
// One heap object.
int[] i = { 1337, 424242 };
关键点
当你在深入 N.O.P.E. 分支时,要小心使用装箱类型。你可能会给 GC 制造很大的压力,因为它必须一直清理你的烂摊子。
有一个特别有效的办法对此进行优化,即使用某些基本类型,并为它创建一个巨大的一维数组,以及相应的定位变量来明确指出编码后的对象放在数组的哪个位置。
LGPL 授权的 trove4j 库实现了基本数据类型的集合,它看起来比 int[] 要好些。
例外
此规则有一个例外:boolean 和 byte 值很小,足够 JDK 完全缓存。你可以这样写:
Boolean a1 = true; // ... syntax sugar for:
Boolean a2 = Boolean.valueOf(true);
Byte b1 = (byte) 123; // ... syntax sugar for:
Byte b2 = Byte.valueOf((byte) 123);
对于其他整型基本类型的低值也是如此,包括 char、short、int、long。
但只在对它们自动装箱,或调用 Type.valueOf() 时,而不是在你调用构造函数时!
除非你确实需要一个新实例,否则永远不要在 wrapper 类型上调用构造函数
堆外
当然,你可能还想测试堆外库,虽然它们更多地是一个战略决策,而不是局部优化。
关于这个主题,Peter Lawrey 和 Ben Cotton 还发布了一篇有趣的文章:OpenJDK 和 HashMap… 针对老手的(堆外)平稳教学技巧
6. 避免递归
像Scala这样的现代函数式编程语言鼓励使用递归,因为它们提供了将尾部递归算法优化为迭代算法的机制。如果你的编程语言支持这样的优化,你可能会感觉不错。但是即便如此,算法最细微的改变也可能产生一个分支,用于避免递归变成尾递归。希望编译器会检测到这种情况!否则,你可能浪费了很多堆栈帧的资源,用于保存可能仅使用一些局部变量就可以实现的功能。
关键点
除此之外没有什么可说的:当你深入 N.O.P.E 分支时,总是优先选择迭代,而不是递归。
7. 使用 entrySet()
当你需要在 Map 中迭代,并且需要访问键和值时,你可能会写如下代码:
for (K key : map.keySet()) {
V value : map.get(key);
}
…而不是下面这种代码:
for (Entry<K, V> entry : map.entrySet()) {
K key = entry.getKey();
V value = entry.getValue();
}
当你处于 N.O.P.E. 分支时,你应该警惕 Map,因为很多很多 O(1) 的 Map 访问操作仍然包含很多其他操作。而且访问也不是免费的。 但如果没有 Map,你可以使用 entrySet() 来迭代他们! Map.Entry 实例仍然存在,你只需要访问它即可。
关键点
在 map 迭代过程中当你需要同时访问键值和值本身时,总是使用 entrySet()。
8. 使用 EnumSet 或者 EnumMap
有时候映射表中可能存在的键的数量是预先可以知道的 —— 比如配置表。如果这个数量相对较小,你应该考虑使用 EnumSet 或 EnumMap,而不是常用的 HashSet 或 HashMap。看下面的 EnumMap.put() 就明白了:
private transient Object[] vals;
public V put(K key, V value) {
// ...
int index = key.ordinal();
vals[index] = maskNull(value);
// ...
}
这个实现的本质是使用索引值的数组来代替了哈希表。插入一个新值的时候,我们要做的就是通过枚举的原始常量值来查找映射表的项,这个常量值是 Java 编译器为每个枚举类型生成的。如果这是一个全局的配置表(假如只有一个实例),EnumMap 的执行效率会比 HashMap 高。HashMap 会用稍少一些的堆类型,但它需要对每个键进行 hashCode() 和 equals() 计算。
关键点
Enum 和 EnumMap 是好朋友。只要你把像枚举的结构作为键,就应该考虑把那些结构转换为枚举,并用它们作为 EnumMap 的键。
9. 优化 hashCode() 和 equals() 方法
如果你没有使用 EnumMap,至少应该优化 hashCode() 和 equals() 方法。一个好的 hashCode() 方法很有必要,因为它会减少进一步调用 eqauls() 的次数。好的哈希算法会为每组实例产生更多不同的哈希桶 [ 译者注:桶指把哈希码相同的一组数据放在一起的列表结构 ]。
在每个类层次结构中都可能存在简单的对象。来看看 jOOQ 的 org.jooq.Table 实现。
这是 hashCode() 最简单最快的实现:
// AbstractTable, a common Table base implementation:
@Override
public int hashCode() {
// [#1938] This is a much more efficient hashCode()
// implementation compared to that of standard
// QueryParts
return name.hashCode();
}
…其中 name 只是简单的表名。我们甚至不用考虑表结构或其它表属性,因为表名在数据库中已经足以保证唯一性。而且 name 是个字符串,所以它已经在内部缓存了 hashCode() 的值。
这里的注释非常重要,因为 AbstractTable 继承自 AbstractQueryPart,它是 ATS(抽象语法树)元素的公共实现。公共的 AST 元素没有任何属性,所以它不能基于假设来优化 hashCode() 实现。因此,得像这样覆写方法:
// AbstractQueryPart, a common AST element
// base implementation:
@Override
public int hashCode() {
// This is a working default implementation.
// It should be overridden by concrete subclasses,
// to improve performance
return create().renderInlined(this).hashCode();
}
换句话说,整个 SQL 生成的工作流必定触发对公共 AST 元素计算哈希码。
equals() 更有意思:
// AbstractTable, a common Table base implementation:
@Override
public boolean equals(Object that) {
if (this == that) {
return true;
}
// [#2144] Non-equality can be decided early,
// without executing the rather expensive
// implementation of AbstractQueryPart.equals()
if (that instanceof AbstractTable) {
if (StringUtils.equals(name,
(((AbstractTable<?>) that).name))) {
return super.equals(that);
}
return false;
}
return false;
}
首要的事情:总是 (不仅是在 N.O.P.E. 分支) 以下列情况中止调用 equals():
this == 参数
this 与参数 "类型不兼容"
注意,如果你使用 instanceof 来检查兼容类型,后者其实包含了参数 == null 的情况。
在根据明显情况提前中止比较之后,你可能还想通过部分对比来早些结束比较。举例来说, jOOQ 的 Table.equals() 用来比较两个表是否相等,无论它们具体类型是什么,它们必须有相同的名称。下面这两项是不可能相等的:
com.example.generated.Tables.MY_TABLE
DSL.tableByName("MY_OTHER_TABLE")
如果参数与 this 不等,而且我们可以早一些检查出来,那就应该这样做,并在检查失败时中止比较。检查成功后再继续进行 super 中相对重量级的实现。因为大多数对象是不等的,我们会通过简化该方法来节约大量时间。
某些对象比其它对象更平等
在这个 jOOQ 的示例中,多数实例实际上是由 jOOQ 源码生成器生成的表,它们的 equals() 已经优化了。其它几十个表类型(派生表、表值函数、数组表、连接表、透视表、公共表表达式等)可以保留他们的“简单”实现。
10. 深入了解集合而不是其中单独的元素
最后同样重要的是,有一个与 Java 无关,但适用于所有语言的东西。此外,我们应该撇开 N.O.P.E. 分支,因为这个建议可能只是帮助你从 O(N3) 降低到 O(n log n,或类似的情况。
许多程序员仅了解简单的局部算法。他们一步一步地、一个分支一个分支地、一个循环一个循环地、一个方法一个方法地解决问题。 这是命令式和/或函数式编程的风格。 虽然从纯命令式到面向对象(仍然是命令式)到函数式编程,建模“更大场景”变得越来越容易,但所有这些样式都缺少 SQL 和 R 及其类似语言包含的概念:
声明式编程。
你可以在 SQL (这是 jOOQ 博客) 中申明想从数据库中获得的结果,不需要使用任何算法。数据库会考虑所有可用的元数据(比如约束、键、索引等)并指出最佳算法。
理论上,这是 SQL 和相关计算的主要思想。实践中,SQL 厂商已在过去10年里实现了高性能的 CBO (基于成本的优化器),所以在 2010 年的时候 SQL 已经发挥了其全部潜力。
不过你也不需要在数据集中使用 SQL。集/集合/包/列表在所有语言和库中都有。使用集合最主要的优势在于你的算法会变得简洁很多。很容易写出来:
SomeSet INTERSECT SomeOtherSet
而不是这样:
// Pre-Java 8
Set result = new HashSet();
for (Object candidate : someSet)
if (someOtherSet.contains(candidate))
result.add(candidate);
// Even Java 8 doesn't really help
someSet.stream()
.filter(someOtherSet::contains)
.collect(Collectors.toSet());
很多人认为在 Java8 中的函数式编程会更容易,算法会更简洁。但实际并不是这样。你可以翻译指令,把 Java-7-loop 变成 函数式的 Java-8 流集合,但你还是要写同样的算法。写一个 SQL-esque 表达式是不一样的。
SomeSet INTERSECT SomeOtherSet
... 通过实现引擎以1000种方式实现。现在我们知道,在运行 INTERSECT 操作之前,或许自动转换成两个集合到 EnumSet 的做法是明智的。也许我们可以并行化这个 INTERSECT 而不让底层来调用 Stream.parallel()。