0.前言

Java 7 ForkJoinPool和 Java 8 的并行Stream有助于并行化东西,这在您将 Java 程序部署到多核处理器机器上时非常有用。与跨网络上的不同机器进行扩展相比,这种并行性的优势在于您几乎可以完全消除延迟效应,因为所有内核都可以访问相同的内存。但是不要被并行的效果所迷惑!记住以下两点:

  • 并行性会吞噬你的核心。这对于批处理非常有用,但对于异步服务器(例如 HTTP)来说则是一场噩梦。在过去的几十年里,我们使用单线程 servlet 模型是有充分理由的。因此,并行性仅在扩大规模时才有帮助。
  • 并行性对算法的Big O Notation没有影响。如果您的算法是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 operation或NOPE)确实造成了麻烦。您的运营团队可能已经使用AppDynamics或DynaTrace或一些类似软件解决了这个问题。

如果没有生产数据,您可能会很快得出结论并优化“繁重操作”。你运送到生产环境,你的修复没有效果。除了以下事实之外,没有优化的黄金法则:

  • 设计良好的应用程序更容易优化
  • 过早的优化不会解决任何性能问题,反而会使您的应用程序设计得不那么好,从而使优化变得更加困难

理论够了。让我们假设您找到了正确的分支是问题所在。很可能是一个非常简单的操作在生产中失败了,因为它被调用了很多次(如果N、O和P很大)。请在不可避免算法的叶节点出现问题的情况下阅读本文。这些优化不会帮助您扩展。他们将帮助您暂时节省客户的时间,将整体算法的困难改进推迟到以后!O(N3) 以下是 Java 中最简单的 10 个性能优化:

1.使用StringBuilder

这应该是几乎所有 Java 代码中的默认设置。尽量避免使用+操作符。当然,您可能会争辩说,它只是StringBuilder的语法糖,例如:

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

翻译为:

new java.lang.StringBuilder [16]
dup
ldc <String "a"> [18]
invokespecial java.lang.StringBuilder(java.lang.String) [20]
aload_0 [args]
arraylength
invokevirtual java.lang.StringBuilder.append(int) : java.lang.StringBuilder [23]
ldc <String "b"> [27]
invokevirtual java.lang.StringBuilder.append(java.lang.String) : java.lang.StringBuilder [29]
invokevirtual java.lang.StringBuilder.toString() : java.lang.String [32]
astore_1 [x]

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

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.分支中。我们浪费在像 GC 或分配 aStringBuilder的默认容量这样愚蠢的事情上的每个 CPU 周期,我们都在浪费N x O x P时间。根据经验,始终使用 aStringBuilder而不是+运算符。如果可以的话,如果你的构建更复杂,请保留StringBuilder多个方法的引用。只有一个StringBuilder“遍历”你的整个 SQL AST(抽象语法树) 对于大声喊叫,如果您仍然有StringBuffer参考资料,请将它们替换为StringBuilder. 您几乎不需要同步正在创建的字符串。

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.replaceAll(), 或String.split(). 请改用Apache Commons Lang之类的流行库来进行字符串操作。

3.不要使用iterator()

现在,此建议实际上不适用于一般用例,而仅适用于N.O.P.E.分支的深层。尽管如此,你应该考虑一下。编写 Java-5 风格的 foreach 循环很方便。您可以完全忘记循环内部,并编写:

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

但是,每次遇到此循环时,如果strings是一个Iterable,您将创建一个新Iterator实例。如果您使用的是ArrayList,这将ints在您的堆上分配一个 3 的对象:

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 设计的角度来看,迭代器、Iterable 和 foreach 循环都非常有用。但是,它们会在每次迭代时在堆上创建一个小的新实例。如果你多次运行这个迭代,你要确保避免创建这个无用的实例,而是编写基于索引的迭代。

4.不用用浪费时间的方法

有些方法简单费时。在我们的N.O.P.E.分支示例中,我们在叶子中没有这样的方法,但您可能有一个。让我们假设您的 JDBC 驱动程序需要经历令人难以置信的麻烦来计算ResultSet.wasNull(). 您自己开发的 SQL 框架代码可能如下所示:

if (type == Integer.class) {
    result = (T) wasNull(rs,
        Integer.valueOf(rs.getInt(index)));
}
static final <T> T wasNull(ResultSet rs, T value)
throws SQLException {
    return rs.wasNull() ? null : value;
}

ResultSet.wasNull() 现在,每次您int从结果集中获得一个时, 都会调用此逻辑。但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.使用原语和堆栈

上面的例子,它使用了很多泛型,因此被迫使用包装器类型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 造成很大的压力,它必须一直在清理你的烂摊子。一个特别有用的优化可能是使用一些原始类型并创建它的大型一维数组,以及几个分隔符变量来指示您的编码对象在数组上的确切位置。trove4jint[]是一个优秀的原始集合库,它比你的平均水平要复杂一些,它与 LGPL 一起提供。例外 此规则有一个例外:和booleanbyte很少有足够的值完全被 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。但仅当您自动装箱或调用TheType.valueOf()时,才不会在调用构造函数时!

永远不要在包装类型上调用构造函数,除非你真的想要一个新实例

这个事实也可以帮助你为你的同事写一个复杂的愚人节笑话 堆 外 当然,你可能还想尝试堆外库,尽管它们更多的是战略决策,而不是本地优化。

6.避免递归

像 Scala 这样的现代函数式编程语言鼓励使用递归,因为它们提供了将尾递归算法优化回迭代算法的方法。如果你的语言支持这样的优化,你可能没问题。但即便如此,算法的最轻微变化也可能会产生一个分支,阻止你的递归是尾递归的。希望编译器会检测到这一点!否则,您可能会浪费大量堆栈帧,而这些堆栈帧可能仅使用几个局部变量就可以实现。当你深入N.O.P.E.分支时,总是更喜欢迭代而不是递归

7.使用 entrySet()

当您想要遍历 aMap并且需要键和值时,您必须有充分的理由编写以下内容:

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.分支时,无论如何你都应该警惕递归,因为大量的O(1)地图访问操作仍然是大量的操作。而且访问也不是免费的。但至少,如果您不能没有地图,请使用它entrySet()来迭代它们!无论如何,该Map.Entry实例都在那里,您只需要访问它。entrySet()在 map 迭代过程中同时需要键和值时 始终使用。

8.使用 EnumSet 或 EnumMap

在某些情况下,映射中可能的键的数量是预先知道的——例如在使用配置映射时。如果该数字相对较小,您应该真正考虑使用EnumSetor EnumMap,而不是常规HashSetor HashMap。这很容易通过查看来解释EnumMap.put():

private transient Object[] vals;
 
public V put(K key, V value) {
    // ...
    int index = key.ordinal();
    vals[index] = maskNull(value);
    // ...
}

这个实现的本质是,我们有一个索引值数组,而不是一个哈希表。当插入一个新值时,为了查找映射条目,我们所要做的就是向enum查询它的常数序数,该常数序数是由Java编译器在每个enum类型上生成的。如果这是一个全局配置映射(即只有一个实例),增加的访问速度将帮助EnumMap大大超过HashMap,它可能使用更少的堆内存,但必须在每个键上运行hashCode()和equals()。Enum和EnumMap是非常亲密的朋友。当您使用类似于枚举的结构作为键时,请实际考虑将这些结构作为枚举,并在EnumMap中使用它们作为键。

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

如果不能使用EnumMap,至少优化hashCode()和equals()方法。一个好的hashCode()方法是必要的,因为它将防止进一步调用开销大得多的equals(),因为它将为每个实例集生成更多不同的散列桶。在每个类层次结构中,都可能有流行的和简单的对象。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()值。注释很重要,因为AbstractTableextends是任何AST(抽象语法树)元素AbstractQueryPart的通用基础实现。通用 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;
}

第一件事:总是(不仅在NOPE 分支中)提前中止每个equals()方法,如果:

  • this == argument
  • this “incompatible type” argument

请注意,后一个条件包括argument == null, 如果您instanceof用于检查兼容类型。我们之前在10 Subtle Best Practices when Coding Java中对此进行了博文。现在,在明显情况下尽早中止比较之后,您可能还希望在可以做出部分决定时尽早中止比较。例如,约定Table.equals()是两个表被认为是相等的,它们必须具有相同的名称,而不管具体的实现类型如何。例如,这两项不可能相等:

  • com.example.generated.Tables.MY_TABLE
  • DSL.tableByName(“MY_OTHER_TABLE”)

如果argument 不能等于this,并且我们可以轻松地检查它,那么让我们这样做并在检查失败时中止。如果检查成功,我们仍然可以从super. 鉴于宇宙中的大多数对象都不相等,我们将通过快捷方式节省大量 CPU 时间。

10.在集合中思考,而不是在单个元素

最后但并非最不重要的一点是,有一件事与 Java 无关,但适用于任何语言。此外,我们将离开N.O.P.E.分支,因为此建议可能只会帮助您从 迁移到,或类似的东西。不幸的是,许多程序员从简单的本地算法的角度来思考。他们正在逐步解决问题,一个分支一个分支,一个循环一个循环,一个方法一个方法。这就是命令式和/或函数式编程风格。虽然在从纯命令式到面向对象(仍然是命令式)再到函数式编程时,对“更大的图景”进行建模变得越来越容易,但所有这些风格都缺乏只有 SQL 和 R 以及类似语言才有的东西:声明式编程。在 SQL 中(我们喜欢它,因为这是O(N3)O(n log n)) 你可以声明你想从你的数据库中得到的结果,而不会对算法产生任何影响。然后,数据库可以考虑所有可用的元数据(例如约束、键、索引等),以找出可能的最佳算法。从理论上讲,这从一开始就是SQL 和关系演算背后的主要思想。使用集合的主要优点是您的算法将变得更加简洁。

而不是:

// 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());

有些人可能会争辩说,函数式编程和 Java 8 将帮助您编写更简单、更简洁的算法。这不一定是真的。您可以将命令式 Java-7 循环转换为功能性 Java-8 Stream 集合,但您仍在编写相同的算法。编写类似 SQL 的表达式是不同的。

SomeSet 相交 SomeOtherSet

可以通过实现引擎以 1000 种方式实现。EnumSet正如我们今天所了解的,在运行操作之前将这两个集合自动转换为明智的做法INTERSECT。也许我们可以在INTERSECT不进行低级调用的情况下将其并行化Stream.parallel()

分类: java