· 这不是引子
我在本篇中要讲的内容,其实很多都可以在 Java 的官方 API 文档中查到。我只是挑了常用的一部份,糅合我个人的实际经验做说明。而说明中的所有实例都是用了原生态的代码、或伪代码,目的是为了更纯粹地表现思想,万莫过于关注语法等次要信息。
另外可能有些小猿对看完下面的内容后会不屑一顾,觉得为了一点点的性能而这样写代码太抠门了。所谓“不积跬步无以至千里,不积小流无以成大海”,性能最好的代码往往都是抠出来的。
· 女神都是吃货
Java 是什么?
Java 被使用了十几年,她早已不再是一种纯粹的编程语言。
我估计现在在森林里随便捡只猿,不认识 Java 的都属于珍贵品种了。时到如今,Java 早已成为漫山遍野程序猿心中白手兴家的首选女神。而如同其他的女神一样,Java 也毫不留情地升级了她们的共同技能:吃。
Java 乱吃内存、CPU 的行为,早已是每个程序猿痛心疾首的回忆,大家对她是爱而生畏、追不得又舍不得——毕竟“小吃怡情,大吃致贫,吃饱还吃真要命”啊。为此我们就必须从源头上约束女神的吃货行为——虽然对待女神不能像秋风扫落叶,但也不能太纵容了。
· 不要轻易触碰女神的底线
ArrayList 是最广为人知的 Java 女神之一。但我发现一个奇怪的现象,很多人跟这位女神约会的时候都不约定日期,所以经常会看到这样的代码:
List arrayList = new ArrayList();
这样用写代码什么问题?你没有约定数组的长度!
ArrayList 如字面意思,就是数组列表。要知道,数组是一段 定长 的连续空间,实际上 ArrayList 就是数组的包装类。在默认情况下,ArrayList 只是战斗力只有10的渣渣:
所以当我们一旦给她多于 10 个对象,ArrayList 肯定吃不完。凭着 ArrayList 直率的性格,吃不完,就会直接兜着走:她会马上申请一个原长 1.5 倍的数组,然后把旧数据尽数复制过去。
这就是 ArrayList 的 自动扩容 机制。从扩容行为可以知道,在扩容的瞬间会吃掉大量的内存空间、同时带来不必要的性能消耗,而且随着 ArrayList 长度的增长,副作用就越明显。
其实 ArrayList 女神是非常矜持的,如果你不想触碰女神的底线,那么与她约会时,即使不能有确切的日期,也应该告诉她大概的时间。所以你完全可以这样做:
List arrayList = new ArrayList(size);
· 你倾慕的女神不一定是你需要的
慢慢地开始有小猿觉得 ArrayList 令人太心烦,于是选择了另一个女神 LinkedList。LinkedList 女神非常开放,只要想见就可以见:
List linkList = new LinkedList();
LinkedList 如字面意思,就是链表列表。链表就是一个 不定长 的不连续空间。因此在使用 LinkedList 的时候,无需担心自动扩容的问题。
但事实却并非如此,因为我发现了更严重的问题,有很多这样的代码:
linkList.get(i);
这样用写代码什么问题?你在对一个链表做随机访问!
LinkedList 女神拥有非常多的秘密,你如果经常对她问这问那,她就必需从头到尾理清自己的思绪才能告诉你答案。长久下去你们之间的距离就会越来越远,因为你问的每个问题她都需要考虑 n 秒才能决定。
相比之下,ArrayList 女神就纯情得多,你可以随时问她任何问题,她都会在 1 秒内回答。所以,只有当你确定自己不会随便发问的时候,才考虑 LinkedList; 如果想舒舒服服过日子,还是 ArrayList 更适合你。不然把 O(1) 的时间复杂度变成 O(n),实在是得不偿失。
· 不要试图改变女神的想法
所谓“女神心,海底针”,即使你认为你有多了解你的女神,都不要试图改变她的想法。因为你越是觉得了解她,其实就说明你越不了解她。ArrayList 女神就是一个很好的例子,我看到过这样的代码:
for (int i = 0; i < arrayList.size(); i++) {
arrayList.remove(i);
}
这样写代码有什么问题?且不论游标错位和索引溢出的问题,你在破坏数组的连续性!
ArrayList 为了保证数组连续性,会把删除元素位置后的全部对象前移,这时删除一个元素最坏的时间复杂度是 O(n)。而且在访问数组的同时做删除操作,必然会导致游标错位、读取了期望以外的数据,甚至会抛出指针溢出异常。
但总有一些自我主义的人喜欢孜孜不倦地实现女神改造计划,于是乎我又看到了这种代码:
List cloneArrayList = new ArrayList();
cloneArrayList.addAll(arrayList);
for (int i = 0; i < cloneArrayList.size(); i++) {
Object o = cloneArrayList.get(i);
arrayList.remove(o);
}
真是 no zuo no die。我承认这段代码的功能没有问题,但在性能方面没有任何可取之处。这段代码非常成功地保证了在 O(n^2) 的时间复杂度前提下,还能用 addAll 多浪费了一倍的内存,而目的仅仅只是为了解决游标异常。
其实你完全可以这样做:
for (Iterator it = arrayList.iterator(); it.hasNext(); ) {
it.remove();
}
或者是这样做:
for (int i = arrayList.size() - 1; i >=0; i--) {
arrayList.remove(i);
}
这两种做法都没有浪费多余的内存。前者使用了迭代器 Iteartor,Java 构造迭代器的代价是非常低的,而在迭代器执行删除操作的代价就更低了,因为它只需要维护元素之间的指针。而后者虽然没有用迭代器,却保证了最少限度的元素前移。
所以,如果你不希望你的女神变成“女神经”,就应该打消强势去改变她的想法,这是愚蠢的行为。不妨试试旁敲侧击、或逆向思维的做法,或者你会收获女神对你的认同感。
· 切忌对女神的体重感到好奇
经过前面几轮的深入发展,一些小猿已经可以开始和女神讨论一些敏感的问题了——体重。在 Java 中,每一个 List 女神都自带了一个体重仪 size()。
但似乎失忆总是女神的专利——她们几乎都会故意忘记自己的体重。所以无论你问了她多少次,都只能费力地帮她回想刚刚吃了什么,借此进行体重推演。于是长久下来,不能循环地问女神的体重基本已成为一个共识,因为每次的时间复杂度都是 O(n)。
但是如果你和女神独处在单线程环境下,不妨在她 add 或 remove 的时候顺手做一下小抄,这样就可以快速知道女神的实时体重了:
public class MyList {
public void add(Object o) {
list.add(o);
cnt++;
}
public void remove(Object o) {
list.remove(o);
cnt--;
}
public int getSize() {
return cnt;
}
}
但在多线程环境下就尽量不要这样做了,因为所有的女神都不会喜欢自己的体重在公众环境下过于明码实价。虽然你可以利用同步 synchronized 做掩饰,使你做小抄的行为更安全隐秘,但这样会浪费太多时间在 add 和 remove 上面,发反而得不偿失。
· 女神不需要被过度保护
我常常在多线程环境下看到很多 synchronized,虽说目的是对代码做同步保护,但不少的同步都有点多余。确实我们都希望可以在各种环境下保护女神不受伤害,但任何事物一旦过度只会适得其反。
同步块会让代码在多线程环境中更安全,但似乎甚少人会去考虑同步的代价。姑且不论同步不当带来的死锁问题,同步所带来的性能下降至少是100倍。因为同步所牺牲的成本并不是CPU获取锁时花费的时间,而是失去了并行机会——为了在内存得到一致的值,所有线程不能不停下来等待一条线程完成工作,这是极大的浪费。
当女神被过度保护起来之后,她无论做什么都只能先征求一下你的意见,做事效率则自然低下。举一个常见的栗子,单例模式:
public class SingleCase {
public synchronized SingleCase getInstn() {
if (instance == null) {
instance = new SingleCase();
return instance;
}
}
}
这样写代码有什么问题?过度同步!
单例的一个特点就是,它的方法都要先获取单例对象才能调用。而获取单例的方法被这样写,就无异于其他方法都被被强制同步,调用性能大打折扣。
而事实上,单例模式只需要在初始化时同步,其他方法一般都无需同步。我们完全可以这样重构代码:
public class SingleCase {
public SingleCase getInstn() {
if (instance == null) {
synchronized (SingleCase.class) {
if (instance == null) {
instance = new SingleCase();
}
return instance;
}
}
}
}
在这段代码中,对单例 instance 做了两次 null 判断。其中,在 synchronized 内部的 null 判断,是为了保证单例不要被重复初始化。而在外部的 null 判断则限制了同步只在初始化时发生,从而避免了过度同步。
· 不要总盯着女神的缺点
try catch
是 Java 常用的异常捕获手段,如果我们知道代码在某些情况下会发生异常,率先将其捕获则可增强代码的容错率。
但有些小猿似乎对异常捕获情有独钟,不管三七滥用 try catch
。虽说有缺点的女神才是完美的,但我们不能总盯着女神的缺点看,这只会让她感到厌烦。如这样的代码:
try {
i = 0;
while(true) {
System.out.println(arrayList.get(i++));
}
} catch (ArrayIndexOutOfBoundsException e) {
// TODO
}
且不论这段代码的对错,我先解释一下它的用意:一般在遍历一个数组元素时,都会先获取数组长度 size,然后定义一个自增索引,每遍历一个元素则与 size 比较一次是否越界。而 ArrayLits 的 get 方法本身也有一个越界检查,这段代码就是为了不重复做越界检查,利用 get 抛出的越界异常终止遍历。
乍一看视乎是高端大气上档次的优化代码,而事实上,这段代码没有任何可取之处,它至少犯了 3 个认知错误:
- 条件判断几乎不花时间,这种优化没必要;
try catch
内部的代码不会被编译器优化,这种做法得不偿失;- 违反了异常逻辑只能用于异常处理的原则。
第 1 点就不解释了。至于第 2 点,编译器在编译代码时,会做一些特定优化(如去掉冗余代码),而 try catch
需要确切知道哪一行代码出现了问题,在编译时自然不会优化代码,在这里牺牲的代价是沉重的。
而第③是原则性的问题,可以用正常逻辑判断的,就不要让异常发生,甚至不要刻意去制造异常。
· 饥不择食是大忌
编码的时候,在每个关键点做日志输出是很普遍且必要的事。但往往输出日志只是打印一行字符串,久而久之其输出效率就会被忽视。
在实际场景中,一行日志的信息量可能非常大,而其信息源通常又来自于不同的字符串,于是每个日志输出的地方都有大量的字符串拼接。而其中最方便的拼接方式 +
出现频率最高。
要知道,字符串String的本质就是定长的字符数组,它的内容一旦被初始化就无法被修改。而连接符 +
不是通过修改 String 的内容实现拼接的,它的原理类似于 ArrayList 的自动扩容,而每一次拼接操作的时间复杂度都是 O(n^2),效率极其低下。
其实如果拼接量不多,使用 +
也无妨。而若有大量拼接操作的情况下,应优先考虑 StringBuffer 或 StringBuilder。StringBuffer 是多线程安全的,而 StringBuilder 虽多线程不安全,但性能比 StringBuffer 要高。
String 有方便性、StringBuffer 有安全性、StringBuilder 有高效性,三位女神各有所长,应据实谨慎选择,无谓图一时方便而饥不择食,总是滥用 String 的拼接只会给日志器带来额外的负担。
· 双子女神
在 Java 里面有一位双子女神 HashMap,她糅合了 ArrayList 和 LinkedList 两位女神的特点,是一本用于快速查找的 KV 字典。然而实际上,我看见很多人所使用的都是低效的 HashMap,类似这样的代码简直是屡见不鲜:
Map map = new HashMap()
这样写代码有什么问题?首先需要了解 HashMap 的数据结构。
HashMap 的核心结构是散列表数组。又由于她使用了链地址法解决地址冲突,因此散列表的每一个元素都是链头,每个链头下挂载了所有地址冲突元素的链表。整体的数据结构类似于十字链表。
从HashMap的数据结构可知,作为数组的散列表同样存在 ArrayList 的自动扩容特性,因此约定长度以避免自动扩容就很必要了:
Map map = new HashMap(size)
但只是这样做还远未达到高效的效果。HashMap 的检索效率取决于散列表的利用率、以及冲突链表的长度。这需要保证把 KV 对放入散列表的每个位置的概率都相等,这时散列表空间利用率最高、地址冲突率最低(冲突链表最短)。
一般情况下可通过 key 对散列表长度求模计算散列地址,实现概率均等。但 Java 认为求模操作过于消耗性能,采用了与运算代替之:
- 求模法:
add_val_mod = key % size
- 与运算:
add_val_java = key & (size - 1)
但与运算法有一个明显的缺点,它无法等概率计算出散列表的各个地址值,除非散列表的长度为 2^n。所以最高效的 HashMap 使用方法应该是这样:
Map map = new HashMap(2^n)
· 欲速不达,欲擒先纵
其实追求女神不难,难在有效率。但有些时候高效却不一定是最好的。
例如要求删除硬盘上的 10000 个文件,最高效的方法就是一次性删除。但这未必是最好的方法——我在win7上删除大量文件的时候,有时中途点了取消,却长时间无法取消删除,win7 依然专注地删除文件。
从这个层面看,一次性删除是最高效的,但同时给用户的感觉却是最差。如果把删除任务进行分片,每删 1000 个文件检查一次用户是否点了取消按钮,用户的感知就会改善。
同样地,当我们专注于高效地追求女神的时候,可能会忽略了一些细节。所谓欲速则不达,有时放缓一下脚步,不追得太紧,未免就是一件坏事。
追求女神,任重而道远(啊)。