>  기사  >  Java  >  Java의 10가지 간단한 성능 최적화에 대한 자세한 설명

Java의 10가지 간단한 성능 최적화에 대한 자세한 설명

黄舟
黄舟원래의
2017-07-27 10:41:091347검색

hashCode() 메서드를 최적화할 계획이 있나요? 정규식을 우회하고 싶으신가요? Lukas Eder는 프로그램 성능을 확장하기 위한 간단하고 편리한 성능 최적화 팁과 기술을 많이 소개합니다.

“웹 스케일”이라는 용어가 최근 매우 인기를 얻었으며 사람들은 애플리케이션 아키텍처를 확장하여 시스템을 더욱 “웹 스케일”로 만들고 있습니다. 그런데 풀 도메인이란 정확히 무엇입니까? 아니면 전체 네트워크 도메인을 보장하는 방법은 무엇입니까?

스케일링의 다양한 측면

전체 도메인에서 가장 과장된 것은 로드 확장입니다. 예를 들어 단일 사용자의 액세스를 지원하는 시스템은 10, 100 또는 심지어 100만 명의 사용자 액세스도 지원할 수 있습니다. 이상적으로 우리 시스템은 가능한 한 "상태 비저장" 상태로 유지되어야 합니다. 상태가 반드시 존재하더라도 네트워크의 다른 처리 터미널에서 변환되어 전송될 수 있습니다. 로드에 병목 현상이 발생하면 대기 시간이 발생하지 않을 수 있습니다. 따라서 단일 요청의 경우 50~100밀리초가 허용됩니다. 이를 스케일 아웃이라고 합니다.

도메인 전체 최적화에서 확장 프로그램의 성능은 완전히 다릅니다. 예를 들어 하나의 데이터를 성공적으로 처리하는 알고리즘은 10개, 100개 또는 심지어 100만 개의 데이터도 성공적으로 처리할 수 있습니다. 이러한 유형의 메트릭이 실현 가능한지 여부에 관계없이 이벤트 복잡성(빅 O 표기법)이 가장 좋은 설명입니다. 지연 시간은 성능 확장의 킬러입니다. 동일한 컴퓨터에서 모든 계산을 수행하기 위해 최선을 다할 것입니다. 이를 규모 확장이라고 합니다.

파이가 하늘에서 떨어진다면(물론

이건 불가능합니다) 수평 확장과 수직 확장을 결합할 수도 있습니다. 그러나 오늘은 효율성을 높이기 위해 다음과 같은 간단한 방법만 소개하려고 합니다.

Big O 표기법

Java 7의 ForkJoinPool과 Java8의 병렬 데이터 스트림(parallel Stream)은 모두 병렬 처리에 유용합니다. 이는 모든 프로세서가 동일한 메모리에 액세스할 수 있으므로 멀티 코어 프로세서에 Java 프로그램을 배포할 때 특히 그렇습니다.

따라서 네트워크의 여러 시스템에 걸쳐 병렬 처리를 확장하는 것보다 이 병렬 처리의 근본적인 이점은 대기 시간이 거의 완전히 제거된다는 것입니다.

하지만 병렬 처리의 효과에 속지 마세요! 다음 두 가지 사항에 유의하세요.

  • 병렬 처리는 프로세서 리소스를 소모합니다. 병렬 처리는 일괄 처리에 큰 이점을 제공하지만 비동기 서버(예: HTTP)에는 악몽이기도 합니다. 지난 수십 년 동안 우리가 단일 스레드 서블릿 모델을 사용해 온 데에는 여러 가지 이유가 있습니다. 병렬 처리는 수직적으로 확장할 때만 실질적인 이점을 제공합니다.

  • 병렬 처리는 알고리즘 복잡성에 영향을 미치지 않습니다. 알고리즘의 시간 복잡도가 O(nlogn)이고 알고리즘이 c 프로세서에서 실행되는 경우 c는 알고리즘에서 중요하지 않은 상수이기 때문에 이벤트 복잡도는 여전히 O(nlogn/c)입니다. 저장하는 것은 벽시계 시간일 뿐이며 실제 알고리즘 복잡성은 줄어들지 않습니다.

알고리즘 복잡성을 줄이는 것이 의심할 여지 없이 성능을 향상시키는 가장 효과적인 방법입니다. 예를 들어 HashMap 인스턴스의 lookup() 메서드의 경우 이벤트 복잡도 O(1) 또는 공간 복잡도 O(1)이 가장 빠릅니다. 그러나 이것은 쉽게 성취되기는커녕 불가능할 때도 많습니다.

알고리즘의 복잡성을 줄일 수 없다면 알고리즘의 핵심 포인트를 찾아 개선함으로써 성능을 향상시킬 수도 있습니다. 다음과 같은 알고리즘 다이어그램이 있다고 가정합니다.


이 알고리즘의 전체 시간 복잡도는 O(N3)입니다. 개별 액세스 순서에 따라 계산하면 복잡도도 O(N x O x P)로 알 수 있습니다. 하지만 어쨌든 이 코드를 분석하면 몇 가지 이상한 장면을 발견할 수 있습니다.

  • 개발 환경에서는 테스트 데이터를 통해 왼쪽 분기의 시간(N->M-> Heavy Operation) 복잡도 M의 값이 오른쪽의 O, P보다 크기 때문에 우리 분석기에서는 왼쪽 가지만 보입니다.

  • 생산 환경에서 유지 관리 팀은 AppDynamics, DynaTrace 또는 기타 장치를 통해 문제를 일으키는 실제 원인이 올바른 분기(N -> O -> P -> 쉬운 작동 또는 )라는 것을 발견할 수 있습니다. N.O.P.E.).

생산 데이터를 참조하지 않으면 "오버헤드가 높은 작업"을 최적화해야 한다는 결론에 쉽게 도달할 수 있습니다. 그러나 우리가 수행한 최적화는 제공된 제품에 아무런 영향을 미치지 않았습니다.

최적화의 황금률은 다음과 같습니다.

  • 좋은 디자인은 최적화를 더 쉽게 만듭니다.

  • 미숙한 최적화로는 많은 성능 문제를 해결할 수 없지만, 잘못된 설계로 인해 최적화가 어려워집니다.

먼저 이론부터 이야기해 보겠습니다. 올바른 브랜치에서 문제가 발생하는 것을 발견했다고 가정하면 제품의 단순 처리는 시간이 많이 소모되기 때문에 응답을 잃을 가능성이 높습니다(N, O, P의 값이 매우 높다고 가정). 대형), 기사에서 언급한 내용에 주의하세요. 및 왼쪽 분기의 시간 복잡도는 O(N3)입니다. 여기에서 수행된 노력은 확장 가능하지 않지만 어려운 성능 개선을 나중으로 연기하여 사용자 시간을 절약할 수 있습니다.

다음은 Java 성능을 향상시키는 10가지 팁입니다.

1. StringBuilder 사용

StingBuilder는 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을 개선하려면 다음 부분을 사용해야 합니까?

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]);

小结

在上面的样例中,如果你是依靠Java编译器来隐式生成实例的话,那么编译的效果几乎和是否使用了 StringBuilder 实例毫无关系。请记住:在  N.O.P.E 分支中,每次CPU的循环的时间到白白的耗费在GC或者为 StringBuilder 分配默认空间上了,我们是在浪费 N x O x P 时间。

一般来说,使用 StringBuilder 的效果要优于使用 + 操作符。如果可能的话请在需要跨多个方法传递引用的情况下选择 StringBuilder,因为 String 要消耗额外的资源。JOOQ在生成复杂的SQL语句便使用了这样的方式。在整个抽象语法树(AST Abstract Syntax Tree)SQL传递过程中仅使用了一个 StringBuilder 。

更加悲剧的是,如果你仍在使用 StringBuffer 的话,那么用 StringBuilder 代替 StringBuffer 吧,毕竟需要同步字符串的情况真的不多。

2、避免使用正则表达式

正则表达式给人的印象是快捷简便。但是在 N.O.P.E 分支中使用正则表达式将是最糟糕的决定。如果万不得已非要在计算密集型代码中使用正则表达式的话,至少要将 Pattern 缓存下来,避免反复编译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) == &#39;.&#39;) {
        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格式的循环写法非常的方便,以至于我们可以忘记内部的循环方法,比如:

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

当每次代码运行到这个循环时,如果 strings 变量是一个 Iterable 的话,代码将会自动创建一个Iterator 的实例。如果使用的是 ArrayList 的话,虚拟机会自动在堆上为对象分配3个整数类型大小的内存。

private class Itr implements Iterator<E> {
    int cursor;
    int lastRet = -1;
    int expectedModCount = modCount;
    // ...

也可以用下面等价的循环方式来替代上面的 for 循环,仅仅是在栈上“浪费”了区区一个整形,相当划算。

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 循环都是非常好用的。但代价是,使用它们时是会额外在堆上为每个循环子创建一个对象。如果循环要执行很多很多遍,请注意避免生成无意义的实例,最好用基本的指针循环方式来代替上述迭代器、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项目中被专门化之前,不应该成为代码的限制。因为可以通过下面的方法来进行替换:

//存储在堆上
Integer i = 817598;

……如果这样写的话:

// 存储在栈上
int i = 817598;

在使用数组时情况可能会变得更加糟糕:

//在堆上生成了三个对象Integer[] i = { 1337, 424242 };

小结

当我们处于 N.O.P.E. 分支的深处时,应该极力避免使用包装类。这样做的坏处是给GC带来了很大的压力。GC将会为清除包装类生成的对象而忙得不可开交。

所以一个有效的优化方法是使用基本数据类型、定长数组,并用一系列分割变量来标识对象在数组中所处的位置。

遵循LGPL协议的 trove4j 是一个Java集合类库,它为我们提供了优于整形数组 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。

不要在调用构造方法时将这些整型基本类型自动装箱或者调用 TheType.valueOf() 方法。

也不要在包装类上调用构造方法,除非你想得到一个不在堆上创建的实例。这样做的好处是为你为同事献上一个巨坑的愚人节笑话。

非堆存储

当然了,如果你还想体验下堆外函数库的话,尽管这可能参杂着不少战略决策,而并非最乐观的本地方案。一篇由Peter Lawrey和 Ben Cotton撰写的关于非堆存储的很有意思文章请点击: OpenJDK与HashMap——让老手安全地掌握(非堆存储!)新技巧。

6、避免递归

现在,类似Scala这样的函数式编程语言都鼓励使用递归。因为递归通常意味着能分解到单独个体优化的尾递归(tail-recursing)。如果你使用的编程语言能够支持那是再好不过。不过即使如此,也要注意对算法的细微调整将会使尾递归变为普通递归。

希望编译器能自动探测到这一点,否则本来我们将为只需使用几个本地变量就能搞定的事情而白白浪费大量的堆栈框架(stack frames)。

小结

这节中没什么好说的,除了在 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的话,那么要用 entrySet() 方法去迭代!这样的话,我们要访问的就仅仅是Map.Entry的实例。

小结

在需要迭代键值对形式的Map时一定要用 entrySet() 方法。

8、使用EnumSet或EnumMap

在某些情况下,比如在使用配置map时,我们可能会预先知道保存在map中键值。如果这个键值非常小,我们就应该考虑使用 EnumSet 或 EnumMap,而并非使用我们常用的 HashSet 或 HashMap。下面的代码给出了很清楚的解释:

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

上段代码的关键实现在于,我们用数组代替了哈希表。尤其是向map中插入新值时,所要做的仅仅是获得一个由编译器为每个枚举类型生成的常量序列号。如果有一个全局的map配置(例如只有一个实例),在增加访问速度的压力下,EnumMap 会获得比 HashMap 更加杰出的表现。原因在于 EnumMap 使用的堆内存比 HashMap 要少 一位(bit),而且 HashMap 要在每个键值上都要调用 hashCode() 方法和 equals() 方法。

小结

Enum 和 EnumMap 是亲密的小伙伴。在我们用到类似枚举(enum-like)结构的键值时,就应该考虑将这些键值用声明为枚举类型,并将之作为 EnumMap 键。

9、优化自定义hasCode()方法和equals()方法

在不能使用EnumMap的情况下,至少也要优化 hashCode() 和 equals() 方法。一个好的 hashCode() 方法是很有必要的,因为它能防止对高开销 equals() 方法多余的调用。

在每个类的继承结构中,需要容易接受的简单对象。让我们看一下jOOQ的 org.jooq.Table 是如何实现的?

最简单、快速的 hashCode() 实现方法如下:

// AbstractTable一个通用Table的基础实现:
 
@Override
public int hashCode() {
 
    // [#1938] 与标准的QueryParts相比,这是一个更加高效的hashCode()实现
    return name.hashCode();
}

name即为表名。我们甚至不需要考虑schema或者其它表属性,因为表名在数据库中通常是唯一的。并且变量 name 是一个字符串,它本身早就已经缓存了一个 hashCode() 值。

这段代码中注释十分重要,因继承自 AbstractQueryPart 的 AbstractTable 是任意抽象语法树元素的基本实现。普通抽象语法树元素并没有任何属性,所以不能对优化 hashCode() 方法实现抱有任何幻想。覆盖后的 hashCode() 方法如下:

// AbstractQueryPart一个通用抽象语法树基础实现:
 
@Override
public int hashCode() {
    // 这是一个可工作的默认实现。
    // 具体实现的子类应当覆盖此方法以提高性能。
    return create().renderInlined(this).hashCode();
}

换句话说,要触发整个SQL渲染工作流程(rendering workflow)来计算一个普通抽象语法树元素的hash代码。

equals() 方法则更加有趣:

// AbstractTable通用表的基础实现:
 
@Override
public boolean equals(Object that) {
    if (this == that) {
        return true;
    }
 
    // [#2144] 在调用高开销的AbstractQueryPart.equals()方法前,
    // 可以及早知道对象是否不相等。
    if (that instanceof AbstractTable) {
        if (StringUtils.equals(name,
            (((AbstractTable<?>) that).name))) {
            return super.equals(that);
        }
 
        return false;
    }
 
    return false;
}

首先,不要过早使用 equals() 方法(不仅在N.O.P.E.中),如果:

  • this == argument

  • this“不兼容:参数

注意:如果我们过早使用 instanceof 来检验兼容类型的话,后面的条件其实包含了argument == null。我在以前的博客中已经对这一点进行了说明,请参考10个精妙的Java编码最佳实践

在我们对以上几种情况的比较结束后,应该能得出部分结论。比如jOOQ的 Table.equals() 方法说明是,用来比较两张表是否相同。不论具体实现类型如何,它们必须要有相同的字段名。比如下面两个元素是不可能相同的:

  • com.example.generated.Tables.MY_TABLE

  • DSL.tableByName(“MY_OTHER_TABLE”)

如果我们能方便地判断传入参数是否等于实例本身(this),就可以在返回结果为 false 的情况下放弃操作。如果返回结果为 true,我们还可以进一步对父类(super)实现进行判断。在比较过的大多数对象都不等的情况下,我们可以尽早结束方法来节省CPU的执行时间。

一些对象的相似度比其它对象更高。

在jOOQ中,大多数的表实例是由jOOQ的代码生成器生成的,这些实例的 equals() 方法都经过了深度优化。而数十种其它的表类型(衍生表 (derived tables)、表值函数(table-valued functions)、数组表(array tables)、连接表(joined tables)、数据透视表(pivot tables)、公用表表达式(common table expressions)等,则保持 equals() 方法的基本实现。

10、考虑使用set而并非单个元素

最后,还有一种情况可以适用于所有语言而并非仅仅同Java有关。除此以外,我们以前研究的 N.O.P.E. 分支也会对了解从 O(N3) 到 O(n log n)有所帮助。

不幸的是,很多程序员的用简单的、本地算法来考虑问题。他们习惯按部就班地解决问题。这是命令式(imperative)的“是/或”形式的函数式编程风格。这种编程风格在由纯粹命令式编程向面对象式编程向函数式编程转换时,很容易将“更大的场景(bigger picture)”模型化,但是这些风格都缺少了只有在SQL和R语言中存在的:

声明式编程。

在SQL中,我们可以在不考虑算法影响下声明要求数据库得到的效果。数据库可以根据数据类型,比如约束(constraints)、键(key)、索引(indexes)等不同来采取最佳的算法。

在理论上,我们最初在SQL和关系演算(relational calculus)后就有了基本的想法。在实践中,SQL的供应商们在过去的几十年中已经实现了基于开销的高效优化器CBOs (Cost-Based Optimisers) 。然后到了2010版,我们才终于将SQL的所有潜力全部挖掘出来。

但是我们还不需要用set方式来实现SQL。所有的语言和库都支持Sets、collections、bags、lists。使用set的主要好处是能使我们的代码变的简洁明了。比如下面的写法:

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

有些人可能会对函数式编程和Java 8能帮助我们写出更加简单、简洁的算法持有不同的意见。但这种看法不一定是对的。我们可以把命令式的Java 7循环转换成Java 8的Stream collection,但是我们还是采用了相同的算法。但SQL风格的表达式则是不同的:

SomeSet INTERSECT SomeOtherSet

上面的代码在不同的引擎上可以有1000种不同的实现。我们今天所研究的是,在调用 INTERSECT 操作之前,更加智能地将两个set自动的转化为 EnumSet 。甚至我们可以在不需要调用底层的 Stream.parallel() 方法的情况下进行并行 INTERSECT 操作。

总结

在这篇文章中,我们讨论了关于N.O.P.E.分支的优化。比如深入高复杂性的算法。作为jOOQ的开发者,我们很乐于对SQL的生成进行优化。

  • 每条查询都用唯一的StringBuilder来生成。

  • 模板引擎实际上处理的是字符而并非正则表达式。

  • 选择尽可能的使用数组,尤其是在对监听器进行迭代时。

  • 对JDBC的方法敬而远之。

  • 等等。

jOOQ处在“食物链的底端”,因为它是在离开JVM进入到DBMS时,被我们电脑程序所调用的最后一个API。位于食物链的底端意味着任何一条线路在jOOQ中被执行时都需要 N x O x P 的时间,所以我要尽早进行优化。

위 내용은 Java의 10가지 간단한 성능 최적화에 대한 자세한 설명의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.