文档结构  
翻译进度:已翻译     翻译赏金:0 元 (?)    ¥ 我要打赏

最近“全网域(Web Scale)”一词被炒得火热,人们也正在通过扩展他们的应用程序架构来使他们的系统变得更加“全网域”。

但是究竟什么是全网域?或者说如何确保全网域?

扩展的不同方面

全网域被炒作的最多的是扩展负载(Scaling load),比如支持单个用户访问的系统也可以支持10个、100个、甚至100万个用户访问。在理想情况下,我们的系统应该保持尽可能的“无状态化(stateless)”。即使必须存在状态,也可以在网络的不同处理终端上转化并进行传输。当负载成为瓶颈时候,可能就不会出现延迟。所以对于单个请求来说,耗费50到100毫秒也是可以接受的。这就是所谓的横向扩展(Scaling out)。

第 1 段(可获 1.73 积分)

扩展在全网域优化中的表现则完全不同,比如确保成功处理一条数据的算法也可成功处理10条、100条甚至100万条数据。无论这种度量类型是是否可行,事件复杂度(大O符号)是最佳描述。延迟是性能扩展杀手。你会想尽办法将所有的运算处理在同一台机器上进行。这就是所谓的纵向扩展(Scaling up)。

如果天上能掉馅饼的话(当然这是不可能的),我们或许能把横向扩展和纵向扩展组合起来。但是,今天我们只打算介绍下面几条提升效率的简单方法。

第 2 段(可获 1.48 积分)

大O符号

Java 7 的 ForkJoinPool 和 Java8 的并行数据流(parallel Stream) 都对并行处理有所帮助。但在多核处理器上部署 Java 程序时表现尤为明显,因所有的处理器都可以访问相同的内存。

所以,这种并行处理较之在跨网络的不同机器上进行扩展,根本的好处是几乎可以完全消除延迟。

但不要被并行处理的效果所迷惑!请谨记下面两点:

  • 并行处理会吃光处理器资源。并行处理为批处理带来了极大的好处,但同时也是非同步服务器(如 HTTP)的噩梦。有很多原因可以解释,为什么在过去的几十年中我们一直在使用单线程的Servlet模型。并行处理仅在纵向扩展时才能带来实际的好处。
  • 并行处理对算法复杂度没有影响。如果你的算法的时间复杂度为 O(nlogn),让算法在 c 个处理器上运行,事件复杂度仍然为 O(nlogn/c), 因为 c 只是算法中的一个无关紧要的常量。你节省的仅仅是时钟时间(wall-clock time),实际的算法复杂度并没有降低!
第 3 段(可获 2.13 积分)

降低算法复杂度毫无疑问是改善性能最行之有效的办法。比如对于一个 HashMap 实例的 lookup() 方法来说,事件复杂度 O(1) 或者空间复杂度 O(1) 是最快的。但这种情况往往是不可能的,更别提轻易地实现。

如果你不能降低算法的复杂度,也可以通过找到算法中的关键点并加以改善的方法,来起到改善性能的作用。假设我们有下面这样的算法示意图:

algorithm

该算法的整体时间复杂度为 O(N3),如果按照单独访问顺序计算也可得出复杂度为 O(N x O x P)。但是不管怎样,在我们分析这段代码时会发现一些奇怪的场景:

第 4 段(可获 1.26 积分)
  • 在开发环境中,通过测试数据可以看到:左分支(N->M->Heavy operation)的时间复杂度 M 的值要大于右边的 O 和 P,所以在我们的分析器中仅仅看到了左分支。
  • 在生产环境中,你的维护团队可能会通过 AppDynamicsDynaTrace 或其它小工具发现,真正导致问题的罪魁祸首是右分支(N -> O -> P -> Easy operation or also N.O.P.E.)。

在没有生产数据参照的情况下,我们可能会轻易的得出要优化“高开销操作”的结论。但我们做出的优化对交付的产品没有起到任何效果。

第 5 段(可获 1.08 积分)

优化的法则不外乎以下内容:

  • 良好的设计将会使优化变得更加容易。
  • 过早的优化并不能解决多有的性能问题,但是不良的设计将会导致优化难度的增加。

理论就先谈到这里。假设我们已经发现了问题出现在了右分支上,很有可能是因产品中的简单处理因耗费了大量的时间而失去响应(假设N、O和 P 的值非常大), 请注意文章中提及的左分支的时间复杂度为 O(N3)。这里所做出的努力并不能扩展,但可以为用户节省时间,将困难的性能改善推迟到后面再进行。

第 6 段(可获 1.68 积分)

以下是Java中10个最简单的性能优化建议:

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]

 

第 7 段(可获 0.58 积分)

但是如果稍后你需要使用可选部分来修正的字符串,那会发生什么?

String x = "a" + args.length + "b";

if (args.length == 1)
    x = x + args[0];

现在你将会有第二个StringBuilder,这样会白白的浪费你的堆内存,给你的GC增加压力。相反的,你可以这样写:

StringBuilder x = new StringBuilder("a");
x.append(args.length);
x.append("b");

if (args.length == 1);
    x.append(args[0]);

小结

在上面的例子中,如果你使用了显式的StringBuilder实例或者依赖Java编译器创建隐式的实例,那就可能是完全不相关的。但是请记住,我们是在N.O.P.E.分支。如果我们在每一个CPU周期里,做些如GC或者为StringBuilder分配默认空间这种愚蠢的事情的话,那我们其实是浪费了N x O x P 倍的时间。

第 8 段(可获 1.25 积分)

一般来说,总是使用StringBuilder而不是使用+操作符。而且如果你的字符串过于复杂,你还可以让StringBuilder的引用跨多个方法。当你使用jOOQ生成一个复杂的SQL语句时,它就是这么做的,也就是说只有一个StringBuilder对象来“遍历”你的整个SQL的 AST(抽象语法树Abstract Syntax Tree)。

而且,如果你仍然还在使用StringBuffer的引用,请务必将它们替换为StringBuilder,(因为)你几乎用不着在字符串上进行同步。

2. 避免使用正则表达式

正则表达式相对来说 是廉价且方便易用的。但是如果你在N.O.P.E.分支上, 它们大概就是最糟糕的事情了。如果你在计算密集型的代码段里确实必须要使用正则表达式,那么至少要将Pattern(模式)的引用进行缓存而不是每次都去编译它:

第 9 段(可获 1.69 积分)
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;
    }
}

 

第 10 段(可获 0.36 积分)

…这段代码同时也表明了为什么你不要 过早的进行优化。相比于split()的版本,这段代码比较难于维护。

挑战:聪明的读者可能会发现有更快的算法。

小结

正则表达式是很有用,但是使用它们是要付出代价的。如果你在一个N.O.P.E. 分支深处时,你就应该不惜一切代价的避免使用正则表达式。(因此)要小心那些使用正则表达式的JDK字符串方法, 比如 String.replaceAll(),或者String.split()

相反的,应该使用流行的类库像Apache Commons Lang,来进行字符串的操作。

第 11 段(可获 1.11 积分)

3. 不要使用iterator()

目前这条建议并不是适合一些通用的场合,而是只是用于N.O.P.E.分支的场景 。 尽管如此,你也应该考虑一下它。编写Java-5风格的foreach循环是很方便的。你可以把内部循环完全忘掉,然后这样写:

for (String value : strings) {
    // Do something useful here
}

然而,每当代码运行到这个循环时,如果strings对象是一个Iterable,那么就会创建一个新的Iterator实例。如果你在使用ArrayList,那么这将会在你的堆内存里创建一个对象,并分配3个int类型的空间:

第 12 段(可获 1.05 积分)
private class Itr implements Iterator<E> {
    int cursor;
    int lastRet = -1;
    int expectedModCount = modCount;
    // ...

相反的,你可以像下面这样书写代码,一样效果的循环,而只是“浪费”了一个单独的栈里的整型值,相当的廉价:

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
}

小结

第 13 段(可获 0.5 积分)

Iterators、Iterable和foreach循环从易写程度和可读性方面来说都是相当有用的,而且在API设计方面来看也是如此。然而,它们在每一次单独迭代时都会在堆内存里产生一个新的小实例。如果你多次运行这个迭代,你会想要尽量避免创建这个无用的实例,而且使用基于索引的迭代来代替。

讨论

对于上面的某些部分有一些比较有趣的分歧(特别是将Iterator的使用替换为通过索引进行访问),具体讨论内容请查阅Reddit

4. 不要调用那些(特殊的)方法

第 14 段(可获 1.1 积分)

一些方法简单但开销不小。在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() 的约定是:

第 15 段(可获 0.91 积分)

返回类型:变量值;如果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项目中被专门化之前,不应该成为代码的限制。因为可以通过下面的方法来进行替换:

第 16 段(可获 1.45 积分)
// 在堆内存创建了一个对象
Integer i = 817598;

… 用如下代码替代:

// 仍然留在栈内存中
int i = 817598;

当你使用数组的时候事情就变得糟糕了:

// 产生了三个堆内存对象
Integer[] i = { 1337, 424242 };

… 用如下代码替代:

// 只有一个堆内存对象
int[] i = { 1337, 424242 };

小结

如果你是在N.O.P.E.分支, 你就应该非常谨慎使用包装器类型。因为很用可能会JVM的垃圾收集器GC带来很大的压力,不得不让GC耗费不少时间来清理由此带来的垃圾。

比较有效的的一个优化办法是使用原始类型,然后创建一个比较大的一维数组存储这些原始类型的变量,和一些用于标识这些对象在数组中位置的分割变量。

第 17 段(可获 1.2 积分)

在这里,我推荐使用一个优秀的用于操作原始类型集合的类库,名字叫 trove4j,它比普通的int[]整形数组功能要强大的多,而且它遵循LGPL(宽通用公共许可证,Lesser General Public License)协议。

例外

这条规则有一个例外,就是由于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。

但是只在能对它们进行自动装箱或者调用.valueOf()方法的时候,而在调用构造器时请不要这样使用

第 18 段(可获 0.91 积分)

也不要在包装类上调用构造方法,除非你想得到一个不在堆上创建的实例。

这样做的好处是为你为同事献上一个巨坑的愚人节笑话

非堆存储

当然了,如果你还想体验下堆外函数库的话,尽管这可能参杂着不少战略决策,而并非最乐观的本地方案。

一篇由Peter Lawrey和 Ben Cotton撰写的关于非堆存储的很有意思文章请点击: OpenJDK与HashMap——让老手安全地掌握(非堆存储!)新技巧

6、避免递归

现在,类似Scala这样的函数式编程语言都鼓励使用递归。因为递归通常意味着能分解到单独个体优化的尾递归(tail-recursing)。如果你使用的编程语言能够支持那是再好不过。不过即使如此,也要注意对算法的细微调整将会使尾递归变为普通递归。 希望编译器能自动探测到这一点,否则本来我们将为只需使用几个本地变量就能搞定的事情而白白浪费大量的堆栈框架(stack frames)。

第 19 段(可获 2.05 积分)

小结

没有太多可说的,除了:当你在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实例是存在的,你只需要访问它即可。

第 20 段(可获 1.5 积分)

小结

当你在遍历map的时候如果同时需要键和值,那么请总是优先使用entrySet()

8. 使用EnumSet或EnumMap

有些情况下,map中所有可能的键的数量是确定已知的,比如当使用一个用于配置的map时。如果这个数量相对来说较少,那么你就真的可以考虑使用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);
    // ...
}

 

第 21 段(可获 0.83 积分)

这一实现的本质是用所引致的数组代替哈希表。当插入一个新值时,我们要做的就是通过枚举常量值来查看映射表的项,它由 Java 编译器产生,作用于每个枚举类型。如果这是一个全局的配置表(如,只有一个示例), EnumMap 的执行效率会比 HashMap 高。HashMap 会用稍少一些的堆类型,但它需要对每个键进行 hashCode() 和 equals() 计算。

小结

EnumEnumMap 是关系密切的好朋友。一旦你将类似枚举的结构作为键,就应该实时考虑将结构转化为枚举,并将它们用作 EnumMap 中的键。

第 22 段(可获 1.49 积分)

9. 优化hashCode()和equals()方法

如果不能使用EnumMap,至少也要优化下hashCode()和equals()方法。一个好的hashCode()方法是很有必要的,因为它可以避免以后对开销更高的equals()方法的调用,由于它将会对每一个实例集都产生截然不同的哈希桶。

在每一个类的层次结构里,你都会看到普通且简单的对象。让我们看下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();
}

 

第 23 段(可获 0.95 积分)

… 这里name变量的值就是表的名字。我们甚至都不会考虑模式或者该表的任何其他属性,因为在一个数据库里表名通常就是用来区分彼此的。同时,name 是一个字符串变量,因此它本身就有一个缓存的hashCode() 值在里面了。

注释很重要,因为AbstractTable继承了AbstractQueryPart,这是一个任何AST (抽象语法树)元素的共同基础实现。这个公用的AST元素没有任何属性,所以它不能对hashCode()实现做什么优化。因此,覆盖的方法就如下所示:

第 24 段(可获 1.1 积分)
// 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;
}

 

第 25 段(可获 0.34 积分)

首先要做的事: 总是 (不仅仅是在N.O.P.E.分支) 尽早放弃每一个equals()方法,如果有以下情况:

  • this == argument
  • this 为"不兼容类型的" 参数

如果你使用instanceof来检查兼容性的类型,请注意第二个条件包含argument == null的情形。我们之前已经在《使用Java编码的十个最佳实践》这篇博文中讨论过这个问题。

在一些场合下如果你能明显发现无需过多的对比就及早的退出,因此当你能作出部分判断之后你就会想早点结束对比。例如,jOOQ的Table.equals()方法里的条件是两个表如果要被认定为相同的,他们必须有相同的名字,而不管具体的实现类型是什么。例如,下面这两个东西是不可能相等的:

第 26 段(可获 1.36 积分)
  • com.example.generated.Tables.MY_TABLE
  • DSL.tableByName("MY_OTHER_TABLE")

如果参数不可能跟this相等,而且我们能够轻易的判断,那就这么做,而检查失败后就退出。如果检查成功,我们仍然可以继续执行父类中比较重的实现。由于宇宙中的大部分对象都是不相等的,我们就可以使用这种简洁的方法(即及早结束比较)来节省大量的CPU时间。

有些对象比其他对象更接近些

在jOOQ的某些场景中,大部分实例实际上是由jOOQ源代码生成器所生成的表对象,它们的equals()方法的实现都是经过深入优化的。其他几十种类型的表(如衍生表,表值函数, 数组表,连接表,数据透视表,公用表表达式等)则维持它们“简洁的”实现。

第 27 段(可获 1.46 积分)

10. 多考虑使用集合,而不把它们当作独立的元素

最后但并非最不重要的是,还有一件事虽然不是Java相关的,但是却是适合于任何语言的。另外我们将不考虑 N.O.P.E.分支,因为这条建议可能会帮助你将算法的时间复杂度从O(N3)变为O(n log n)。

不幸的是,很多程序员只考虑简单的本地算法。他们解决问题时喜欢按部就班。那是命令式的和/或函数的编程方式。当从纯粹命令式转向面向对象(依然是命令式的)再到函数式编程的过程中,越来越容易将“更大的场景”模型化,所有这些风格就缺乏只有SQL和R语言及其类似语言才有的东西:

第 28 段(可获 1.53 积分)

声明式编程。

在SQL (而且我们喜欢它,因为这里有一篇JOOQ的文章) 里,你可以声明你想要从你的数据库里获取的结果,而不会对算法产生任何的影响。数据库则会考虑所有的元数据(比如约束, 键,索引等)来推断出可能是最佳的算法。

理论上,这就是SQL和关系演算最初就考虑到的主要思想。 在实际中呢,SQL实现者已经在过去的十年内实现了高效的CBOs (基于成本的优化器 Cost-Based Optimisers) ,因此在新的十年内(2010‘s)SQL终于将要释放其全部潜能了(也是时候了!)。

第 29 段(可获 1.36 积分)

但是你没必要在SQL里使用集合。Sets / collections / bags / lists 在所有的语言和类库里都是支持的。使用集合的主要优势是可以使你的算法变得相当的简洁。更容易写出如下代码:

SomeSet INTERSECT SomeOtherSet

而不是:

// Java 8以前
Set result = new HashSet();
for (Object candidate : someSet)
    if (someOtherSet.contains(candidate))
        result.add(candidate);

// 即使Java 8也并不能真正帮上忙
someSet.stream()
       .filter(someOtherSet::contains)
       .collect(Collectors.toSet());

 

第 30 段(可获 0.63 积分)

有些人会认为函数式编程和Java 8会帮助你写更容易、更简洁的算法。然而这也并不一定。你可以将你的命令式的Java7风格的循环语句翻译成函数式的Java8的流式集合,但是你还是要写同样的逻辑代码。编写一个 SQL式的表达式就是不一样的了。这个语句:

SomeSet INTERSECT SomeOtherSet

… 可以由执行引擎以1000种方式实现。正如我们今天学习到的,在运行INTERSECT操作之前将两个set自动转换成EnumSet,也许是很明智的。我们活血可以将这个INTERSECT进行并行化处理,而不用对低层的Stream.parallel()进行调用。

第 31 段(可获 1.13 积分)

总结

本文中我们讨论了对 N.O.P.E 分支上的优化,重点在对高复杂度算法的优化上。在我们的示例中,要成为 jOOQ 开发者,还要关注优化 SQL 生成:

  • 每个查询都只用一个 StringBuilder 来生成

  • 模板引擎实际上是按字符解析,而不是用正则表达式

  • 尽可能的使用数组,尤其是在遍历监听器的时候

  • 不要使用 JDBC 中非必要的方法

  • 其它…

jOOQ 在 “食物链的底部”,因为它是用户应用在离开 JVM 进入 DBMS [ 译者注:数据库管理系统,一般就是指数据库 ] 之前调用的最后(或倒数第二)层 API。在食物链的底部就意味着 JOOQ 的每一行代码都可能被调用 N x O x P 次,所以我们必须加强优化。

第 32 段(可获 1.89 积分)

你的商业逻辑并没有深入 N.O.P.E 分支中,但你自己产生的基础逻辑( 自定义的 SQL 框架、库等 )或许能到达那个位置。它们应该按照我们今日所见的规则进行优化,如,使用 Java Mission Control 或其他类似工具。

第 33 段(可获 0.64 积分)

文章评论

大连访客
好像在其他地方看过