你是否正打算优化hashCode()方法?是否想要绕开正则表达式?Lukas Eder介绍了很多简单方便的性能优化小贴士以及扩展程序性能的技巧。
最近“全网域(Web Scale)”一词被炒得火热,人们也正在通过扩展他们的应用程序架构来使他们的系统变得更加“全网域”。但是究竟什么是全网域?或者说如何确保全网域?
扩展的不同方面
全网域被炒作的最多的是扩展负载(Scaling load),比如支持单个用户访问的系统也可以支持10 个、100个、甚至100万个用户访问。在理想情况下,我们的系统应该保持尽可能的“无状态化(stateless)”。即使必须存在状态,也可以在网络的不同处理终端上转化并进行传输。当负载成为瓶颈时候,可能就不会出现延迟。所以对于单个请求来说,耗费50到100毫秒也是可以接受的。这就是所谓的横向扩展(Scaling out)。
扩展在全网域优化中的表现则完全不同,比如确保成功处理一条数据的算法也可成功处理10条、100条甚至100万条数据。无论这种度量类型是是否可行,事件复杂度(大O符号)是最佳描述。延迟是性能扩展杀手。你会想尽办法将所有的运算处理在同一台机器上进行。这就是所谓的纵向扩展(Scaling up)。
如果天上能掉馅饼的话(当然这是不可能的),我们或许能把横向扩展和纵向扩展组合起来。但是,今天我们只打算介绍下面几条提升效率的简单方法。
大O符号
Java 7的 ForkJoinPool 和Java8 的并行数据流(parallel Stream) 都对并行处理有所帮助。当在多核处理器上部署Java程序时表现尤为明显,因所有的处理器都可以访问相同的内存。
所以,这种并行处理较之在跨网络的不同机器上进行扩展,根本的好处是几乎可以完全消除延迟。
但不要被并行处理的效果所迷惑!请谨记下面两点:
①并行处理会吃光处理器资源。并行处理为批处理带来了极大的好处,但同时也是非同步服务器(如HTTP)的噩梦。有很多原因可以解释,为什么在过去的几十年中我们一直在使用单线程的Servlet模型。并行处理仅在纵向扩展时才能带来实际的好处。
②并行处理对算法复杂度没有影响。如果你的算法的时间复杂度为 O(nlogn),让算法在 c 个处理器上运行,事件复杂度仍然为 O(nlogn/c), 因为 c 只是算法中的一个无关紧要的常量。你节省的仅仅是时钟时间(wall-clock time),实际的算法复杂度并没有降低。
降低算法复杂度毫无疑问是改善性能最行之有效的办法。比如对于一个 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 -> Easy operation or also N.O.P.E.)。
在没有生产数据参照的情况下,我们可能会轻易的得出要优化“高开销操作”的结论。但我们做出的优化对交付的产品没有起到任何效果。
优化的金科玉律不外乎以下内容:
①良好的设计将会使优化变得更加容易。
②过早的优化并不能解决多有的性能问题,但是不良的设计将会导致优化难度的增加。
理论就先谈到这里。假设我们已经发现了问题出现在了右分支上,很有可能是因产品中的简单处理因耗费了大量的时间而失去响应(假设N、O和 P 的值非常大), 请注意文章中提及的左分支的时间复杂度为 O(N3)。这里所做出的努力并不能扩展,但可以为用户节省时间,将困难的性能改善推迟到后面再进行。
这里有10条改善Java性能的小建议:
1、使用StringBuilder
StingBuilder 应该是在我们的Java代码中默认使用的,应该避免使用 + 操作符。或许你会对 StringBuilder 的语法糖(syntax sugar)持有不同意见,比如:
1
|
String x =
"a"
+ args.length +
"b"
;
|
将会被编译为:
1
2
3
4
5
6
7
8
9
10
11
|
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 进行改善呢?
1
2
3
|
String x =
"a"
+ args.length +
"b"
;
if
(args.length ==
1
)
x = x + args[
0
];
|
现在使用到了第二个 StringBuilder,而且这个 StringBuilder 不会消耗堆中额外的内存,但却给 GC 带来了压力。
1
2
3
4
5
6
|
StringBuilder x =
new
StringBuilder(
"a"
);
x.append(args.length);
x.append(
"b"
);
if
(args.length ==
1
);
x.append(args[
0
]);
|
1
|
<span style=
"font-family:Microsoft YaHei;font-size:16px;"
>小结</span> <span style=
"font-family:Microsoft YaHei;font-size:16px;"
>在上面的样例中,如果你是依靠Java编译器来隐式生成实例的话,那么编译的效果几乎和是否使用了 StringBuilder 实例毫无关系。请记住:在 N.O.P.E 分支中,每次CPU的循环的时间到白白的耗费在GC或者为 StringBuilder 分配默认空间上了,我们是在浪费 N x O x P 时间。</span>
|
一般来说,使用 StringBuilder 的效果要优于使用 + 操作符。如果可能的话请在需要跨多个方法传递引用的情况下选择 StringBuilder,因为 String 要消耗额外的资源。JOOQ在生成复杂的SQL语句便使用了这样的方式。在整个抽象语法树(AST Abstract Syntax Tree)SQL传递过程中仅使用了一个 StringBuilder 。
更加悲剧的是,如果你仍在使用StringBuffer的话,那么用 StringBuilder 代替 StringBuffer 吧,毕竟需要同步字符串的情况真的不多。
2、避免使用正则表达式
正则表达式给人的印象是快捷简便。但是在 N.O.P.E 分支中使用正则表达式将是最糟糕的决定。如果万不得已非要在计算密集型代码中使用正则表达式的话,至少要将 Pattern 缓存下来,避免反复编译Pattern 。
1
2
|
static
final
Pattern HEAVY_REGEX =
Pattern.compile(
"(((X)*Y)*Z)*"
);
|
如果仅使用到了如下这样简单的正则表达式的话:
1
|
String[] parts = ipAddress.split(
"\\."
);
|
这是最好还是用普通的 char[] 数组或者是基于索引的操作。比如下面这段可读性比较差的代码其实起到了相同的作用。
1
2
3
4
5
6
7
8
9
10
11
12
|
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格式的循环写法非常的方便,以至于我们可以忘记内部的循环方法,比如:
1
2
3
|
for
(String value : strings) {
// Do something useful here
}
|
当每次代码运行到这个循环时,如果 strings 变量是一个 Iterable 的话,代码将会自动创建一个 Iterator 的实例。如果使用的是 ArrayList 的话,虚拟机会自动在堆上为对象分配3个整数类型大小的内存。
1
2
3
4
5
|
private
class
Itr
implements
Iterator<E> {
int
cursor;
int
lastRet = -
1
;
int
expectedModCount = modCount;
// ...
|
也可以用下面等价的循环方式来替代上面的 for 循环,仅仅是在栈上“浪费”了区区一个整形,相当划算。
1
2
3
4
5
|
int
size = strings.size();
for
(
int
i =
0
; i < size; i++) {
String value : strings.get(i);
// Do something useful here
}
|
如果循环中字符串的值是不怎么变化,也可用数组来实现循环。
1
2
3
|
for
(String value : stringArray) {
// Do something useful here
}
|
小结
无论是从易读写的角度来说,还是从API设计的角度来说迭代器、Iterable接口和 foreach 循环都是非常好用的。但代价是,使用它们时是会额外在堆上为每个循环子创建一个对象。如果循环要执行很多很多遍,请注意避免生成无意义的实例,最好用基本的指针循环方式来代替上述迭代器、Iterable接口和 foreach 循环。
讨论
一些与上述内容持反对意见的看法(尤其是用指针操作替代迭代器)详见Reddit上的讨论。
4、不要调用高开销方法
有些方法的开销很大。以 N.O.P.E 分支为例,我们没有提到叶子的相关方法,不过这个可以有。假设我们的JDBC驱动需要排除万难去计算 ResultSet.wasNull()方法的返回值。我们自己实现的SQL框架可能像下面这样:
1
2
3
4
5
6
7
8
9
10
|
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。
所以一个简单有效的改善方法如下:
1
2
3
4
5
6
7
8
|
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项目中被专门化之前,不应该成为代码的限制。因为可以通过下面的方法来进行替换:
1
2
|
//存储在堆上
Integer i =
817598
;
|
……如果这样写的话:
1
2
|
// 存储在栈上
int
i =
817598
;
|
在使用数组时情况可能会变得更加糟糕:
1
2
|
//在堆上生成了三个对象
Integer[] i = {
1337
,
424242
};
|
……如果这样写的话:
1
2
|
// 仅在堆上生成了一个对象
int
[] i = {
1337
,
424242
};
|
小结
当我们处于 N.O.P.E. 分支的深处时,应该极力避免使用包装类。这样做的坏处是给GC带来了很大的压力。GC将会为清除包装类生成的对象而忙得不可开交。
所以一个有效的优化方法是使用基本数据类型、定长数组,并用一系列分割变量来标识对象在数组中所处的位置。
遵循LGPL协议的trove4j 是一个Java集合类库,它为我们提供了优于整形数组 int[] 更好的性能实现。
例外
下面的情况对这条规则例外:因为 boolean 和 byte 类型不足以让JDK为其提供缓存方法。我们可以这样写:
1
2
3
4
5
|
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 时,必须要为下面的代码找到一个很好的理由:
1
2
3
|
for
(K key : map.keySet()) {
V value : map.get(key);
}
|
更不用说下面的写法:
1
2
3
4
|
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() 方法。