• 如果您想对本站表示支持,请随手点击一下广告即可~
  • 本站致力于提供原创、优秀的技术文章~
  • 有任何疑问或建议 均可以在站点右侧栏处 通过各种方式联系站长哦~
  • 【Java性能调优】 如何有效率地追求女神?

    JAVA EXP 153阅读 0评论

    原版 PPT 讲稿下载


    · 这不是引子

    我在本篇中要讲的内容,其实很多都可以在Java的官方API文档中查到。我只是挑了常用的一部份,糅合我个人的实际经验做说明。而说明中的所有实例都是用了原生态的代码、或伪代码,目的是为了更纯粹地表现思想,万莫过于关注语法等次要信息。
      另外可能有些小猿对看完下面的内容后会不屑一顾,觉得为了一点点的性能而这样写代码太抠门了。所谓“不积跬步无以至千里,不积小流无以成大海”,性能最好的代码往往都是抠出来的。

    · 女神都是吃货

    Java是什么?
      Java被使用了十几年,她早已不再是一种纯粹的编程语言。
      我估计现在在森林里随便捡只猿,不认识Java的都属于珍贵品种了。时到如今,Java早已成为漫山遍野程序猿心中白手兴家的首选女神。而如同其他的女神一样,Java也毫不留情地升级了她们的共同技能:吃。
      Java乱吃内存、CPU的行为,早已是每个程序猿痛心疾首的回忆,大家对她是爱而生畏、追不得又舍不得——毕竟“小吃怡情,大吃致贫,吃饱还吃真要命”啊。为此我们就必须从源头上约束女神的吃货行为——虽然对待女神不能像秋风扫落叶,但也不能太纵容了。

    · 不要轻易触碰女神的底线

    ArrayList是最广为人知的Java女神之一。但我发现一个奇怪的现象,很多人跟这位女神约会的时候都不约定日期,所以经常会看到这样的代码:

    这样用写代码什么问题?你没有约定数组的长度!
      ArrayList如字面意思,就是数组列表。要知道,数组是一段 定长 的连续空间,实际上ArrayList就是数组的包装类。在默认情况下,ArrayList只是战斗力只有10的渣渣:

    所以当我们一旦给她多于10个对象,ArrayList肯定吃不完。凭着ArrayList直率的性格,吃不完,就会直接兜着走:她会马上申请一个原长1.5倍的数组,然后把旧数据尽数复制过去。
      这就是ArrayList的 自动扩容 机制。从扩容行为可以知道,在扩容的瞬间会吃掉大量的内存空间、同时带来不必要的性能消耗,而且随着ArrayList长度的增长,副作用就越明显。
      其实ArrayList女神是非常矜持的,如果你不想触碰女神的底线,那么与她约会时,即使不能有确切的日期,也应该告诉她大概的时间。所以你完全可以这样做:

    · 你倾慕的女神不一定是你需要的

    慢慢地开始有小猿觉得ArrayList令人太心烦,于是选择了另一个女神LinkedList。LinkedList女神非常开放,只要想见就可以见:

    LinkedList如字面意思,就是链表列表。链表就是一个 不定长 的不连续空间。因此在使用LinkedList的时候,无需担心自动扩容的问题。
      但事实却并非如此,因为我发现了更严重的问题,有很多这样的代码:

    这样用写代码什么问题?你在对一个链表做随机访问!
      LinkedList女神拥有非常多的秘密,你如果经常对她问这问那,她就必需从头到尾理清自己的思绪才能告诉你答案。长久下去你们之间的距离就会越来越远,因为你问的每个问题她都需要考虑n秒才能决定。

    相比之下,ArrayList女神就纯情得多,你可以随时问她任何问题,她都会在1秒内回答。所以,只有当你确定自己不会随便发问的时候,才考虑LinkedList;如果想舒舒服服过日子,还是ArrayList更适合你。不然把O(1)的时间复杂度变成O(n),实在是得不偿失。

    · 不要试图改变女神的想法

    所谓“女神心,海底针”,即使你认为你有多了解你的女神,都不要试图改变她的想法。因为你越是觉得了解她,其实就说明你越不了解她。ArrayList女神就是一个很好的例子,我看到过这样的代码:

    这样写代码有什么问题?且不论游标错位和索引溢出的问题,你在破坏数组的连续性!
      ArrayList为了保证数组连续性,会把删除元素位置后的全部对象前移,这时删除一个元素最坏的时间复杂度是O(n)。而且在访问数组的同时做删除操作,必然会导致游标错位、读取了期望以外的数据,甚至会抛出指针溢出异常。
      但总有一些自我主义的人喜欢孜孜不倦地实现女神改造计划,于是乎我又看到了这种代码:

    真是no zuo no die。我承认这段代码的功能没有问题,但在性能方面没有任何可取之处。这段代码非常成功地保证了在O(n^2)的时间复杂度前提下,还能用addAll多浪费了一倍的内存,而目的仅仅只是为了解决游标异常。
      其实你完全可以这样做:

    或者是这样做:

    这两种做法都没有浪费多余的内存。前者使用了迭代器Iteartor,Java构造迭代器的代价是非常低的,而在迭代器执行删除操作的代价就更低了,因为它只需要维护元素之间的指针。而后者虽然没有用迭代器,却保证了最少限度的元素前移。
      所以,如果你不希望你的女神变成“女神经”,就应该打消强势去改变她的想法,这是愚蠢的行为。不妨试试旁敲侧击、或逆向思维的做法,或者你会收获女神对你的认同感。

    · 切忌对女神的体重感到好奇

    经过前面几轮的深入发展,一些小猿已经可以开始和女神讨论一些敏感的问题了——体重。在Java中,每一个List女神都自带了一个体重仪size()。
      但似乎失忆总是女神的专利——她们几乎都会故意忘记自己的体重。所以无论你问了她多少次,都只能费力地帮她回想刚刚吃了什么,借此进行体重推演。于是长久下来,不能循环地问女神的体重基本已成为一个共识,因为每次的时间复杂度都是O(n)。
      但是如果你和女神独处在单线程环境下,不妨在她add或remove的时候顺手做一下小抄,这样就可以快速知道女神的实时体重了:

    但在多线程环境下就尽量不要这样做了,因为所有的女神都不会喜欢自己的体重在公众环境下过于明码实价。虽然你可以利用同步synchronized做掩饰,使你做小抄的行为更安全隐秘,但这样会浪费太多时间在add和remove上面,发反而得不偿失。

    · 女神不需要被过度保护

    我常常在多线程环境下看到很多synchronized,虽说目的是对代码做同步保护,但不少的同步都有点多余。确实我们都希望可以在各种环境下保护女神不受伤害,但任何事物一旦过度只会适得其反。
      同步块会让代码在多线程环境中更安全,但似乎甚少人会去考虑同步的代价。姑且不论同步不当带来的死锁问题,同步所带来的性能下降至少是100倍。因为同步所牺牲的成本并不是CPU获取锁时花费的时间,而是失去了并行机会——为了在内存得到一致的值,所有线程不能不停下来等待一条线程完成工作,这是极大的浪费。
      当女神被过度保护起来之后,她无论做什么都只能先征求一下你的意见,做事效率则自然低下。举一个常见的栗子,单例模式:

    这样写代码有什么问题?过度同步!
      单例的一个特点就是,它的方法都要先获取单例对象才能调用。而获取单例的方法被这样写,就无异于其他方法都被被强制同步,调用性能大打折扣。
      而事实上,单例模式只需要在初始化时同步,其他方法一般都无需同步。我们完全可以这样重构代码:

    在这段代码中,对单例instance做了两次null判断。其中,在synchronized内部的null判断,是为了保证单例不要被重复初始化。而在外部的null判断则限制了同步只在初始化时发生,从而避免了过度同步。

    · 不要总盯着女神的缺点

    try catch是Java常用的异常捕获手段,如果我们知道代码在某些情况下会发生异常,率先将其捕获则可增强代码的容错率。
      但有些小猿似乎对异常捕获情有独钟,不管三七滥用try catch。虽说有缺点的女神才是完美的,但我们不能总盯着女神的缺点看,这只会让她感到厌烦。如这样的代码:

    且不论这段代码的对错,我先解释一下它的用意:一般在遍历一个数组元素时,都会先获取数组长度size,然后定义一个自增索引,每遍历一个元素则与size比较一次是否越界。而ArrayLits的get方法本身也有一个越界检查,这段代码就是为了不重复做越界检查,利用get抛出的越界异常终止遍历。
      乍一看视乎是高端大气上档次的优化代码,而事实上,这段代码没有任何可取之处,它至少犯了3个认知错误:
        ① 条件判断几乎不花时间,这种优化没必要;
        ② try catch内部的代码不会被编译器优化,这种做法得不偿失;
        ③ 违反了异常逻辑只能用于异常处理的原则。
      第①点就不解释了。至于第②点,编译器在编译代码时,会做一些特定优化(如去掉冗余代码),而try catch需要确切知道哪一行代码出现了问题,在编译时自然不会优化代码,在这里牺牲的代价是沉重的。
      而第③是原则性的问题,可以用正常逻辑判断的,就不要让异常发生,甚至不要刻意去制造异常。

    · 饥不择食是大忌

    编码的时候,在每个关键点做日志输出是很普遍且必要的事。但往往输出日志只是打印一行字符串,久而久之其输出效率就会被忽视。
      在实际场景中,一行日志的信息量可能非常大,而其信息源通常又来自于不同的字符串,于是每个日志输出的地方都有大量的字符串拼接。而其中最方便的拼接方式“+”出现频率最高。
      要知道,字符串String的本质就是定长的字符数组,它的内容一旦被初始化就无法被修改。而连接符“+”不是通过修改String的内容实现拼接的,它的原理类似于ArrayList的自动扩容,而每一次拼接操作的时间复杂度都是O(n^2),效率极其低下。
      其实如果拼接量不多,使用“+”也无妨。而若有大量拼接操作的情况下,应优先考虑StringBuffer或StringBuilder。StringBuffer是多线程安全的,而StringBuilder虽多线程不安全,但性能比StringBuffer要高。
      String有方便性、StringBuffer有安全性、StringBuilder有高效性,三位女神各有所长,应据实谨慎选择,无谓图一时方便而饥不择食,总是滥用String的拼接只会给日志器带来额外的负担。

    · 双子女神

    在Java里面有一位双子女神HashMap,她糅合了ArrayList和LinkedList两位女神的特点,是一本用于快速查找的KV字典。然而实际上,我看见很多人所使用的都是低效的HashMap,类似这样的代码简直是屡见不鲜:

    这样写代码有什么问题?首先需要了解HashMap的数据结构。
      HashMap的核心结构是散列表数组。又由于她使用了链地址法解决地址冲突,因此散列表的每一个元素都是链头,每个链头下挂载了所有地址冲突元素的链表。整体的数据结构类似于十字链表。
      从HashMap的数据结构可知,作为数组的散列表同样存在ArrayList的自动扩容特性,因此约定长度以避免自动扩容就很必要了:

    但只是这样做还远未达到高效的效果。HashMap的检索效率取决于散列表的利用率、以及冲突链表的长度。这需要保证把KV对放入散列表的每个位置的概率都相等,这时散列表空间利用率最高、地址冲突率最低(冲突链表最短)。
      一般情况下可通过key对散列表长度求模计算散列地址,实现概率均等。但Java认为求模操作过于消耗性能,采用了与运算代替之:

    但与运算法有一个明显的缺点,它无法等概率计算出散列表的各个地址值,除非散列表的长度为2^n。所以最高效的HashMap使用方法应该是这样:

    · 欲速不达,欲擒先纵

    其实追求女神不难,难在有效率。但有些时候高效却不一定是最好的。
      例如要求删除硬盘上的10000个文件,最高效的方法就是一次性删除。但这未必是最好的方法——我在win7上删除大量文件的时候,有时中途点了取消,却长时间无法取消删除,win7依然专注地删除文件。
      从这个层面看,一次性删除是最高效的,但同时给用户的感觉却是最差。如果把删除任务进行分片,每删1000个文件检查一次用户是否点了取消按钮,用户的感知就会改善。
      同样地,当我们专注于高效地追求女神的时候,可能会忽略了一些细节。所谓欲速则不达,有时放缓一下脚步,不追得太紧,未免就是一件坏事。

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

    转载请注明:EXP 技术分享博客 » 【Java性能调优】 如何有效率地追求女神?

    喜欢 (3) 分享 (0)
    发表我的评论
    取消评论

    表情

    Hi,您需要填写昵称和邮箱!

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址