加载中...

「Java 性能调优」 如何有效率地追求女神?


· 这不是引子

我在本篇中要讲的内容,其实很多都可以在 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 个认知错误:

  1. 条件判断几乎不花时间,这种优化没必要;
  2. try catch 内部的代码不会被编译器优化,这种做法得不偿失;
  3. 违反了异常逻辑只能用于异常处理的原则。

第 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 个文件检查一次用户是否点了取消按钮,用户的感知就会改善。

同样地,当我们专注于高效地追求女神的时候,可能会忽略了一些细节。所谓欲速则不达,有时放缓一下脚步,不追得太紧,未免就是一件坏事。

追求女神,任重而道远(啊)。


文章作者: EXP
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 EXP !
  目录