zoukankan      html  css  js  c++  java
  • 编写高质量代码:改善Java程序的151个建议(第5章:数组和集合___建议79~82)

    建议79:集合中的哈希码不要重复

      在一个列表中查找某值是非常耗费资源的,随机存取的列表是遍历查找,顺序存储的列表是链表查找,或者是Collections的二分法查找,但这都不够快,毕竟都是遍历嘛,最快的还要数以Hash开头的集合(如HashMap、HashSet等类)查找,我们以HashMap为例,看看是如何查找key值的,代码如下: 

     1 public class Client79 {
     2     public static void main(String[] args) {
     3         int size = 10000;
     4         List<String> list = new ArrayList<String>(size);
     5         // 初始化
     6         for (int i = 0; i < size; i++) {
     7             list.add("value" + i);
     8         }
     9         // 记录开始时间,单位纳秒
    10         long start = System.nanoTime();
    11         // 开始查找
    12         list.contains("value" + (size - 1));
    13         // 记录结束时间,单位纳秒
    14         long end = System.nanoTime();
    15         System.out.println("List的执行时间:" + (end - start) + "ns");
    16         // Map的查找时间
    17         Map<String, String> map = new HashMap<String, String>(size);
    18         for (int i = 0; i < size; i++) {
    19             map.put("key" + i, "value" + i);
    20         }
    21         start = System.nanoTime();
    22         map.containsKey("key" + (size - 1));
    23         end = System.nanoTime();
    24         System.out.println("map的执行时间:" + (end - start) + "ns");
    25     }
    26 }

      两个不同的集合容器,一个是ArrayList,一个是HashMap,都是插入10000个元素,然后判断是否包含最后一个加入的元素。逻辑相同,但是执行时间差别却非常大,结果如下:

      

      HahsMap比ArrayList快了两个数量级!两者的contains方法都是判断是否包含指定值,为何差距如此巨大呢?而且如果数据量增大,差距也会非线性增长。

      我们先来看看ArrayList,它的contains方法是一个遍历对比,这很easy,不多说。我们看看HashMap的ContainsKey方法是如何实现的,代码如下:

    public boolean containsKey(Object key) {
            //判断getEntry是否为空
            return getEntry(key) != null;
        }

      getEntry方法会根据key值查找它的键值对(也就是Entry对象),如果没有找到,则返回null。我们再看看该方法又是如何实现的,代码如下: 

     1 final Entry<K,V> getEntry(Object key) {
     2          //计算key的哈希码
     3             int hash = (key == null) ? 0 : hash(key);
     4             //定位Entry、indexOf方法是根据hash定位数组的位置的
     5             for (Entry<K,V> e = table[indexFor(hash, table.length)];
     6                  e != null;
     7                  e = e.next) {
     8                 Object k;
     9                 //哈希码相同,并且键值也相等才符合条件
    10                 if (e.hash == hash &&
    11                     ((k = e.key) == key || (key != null && key.equals(k))))
    12                     return e;
    13             }
    14             return null;
    15         }

      注意看上面代码中红色字体部分,通过indexFor方法定位Entry在数组table中的位置,这是HashMap实现的一个关键点,怎么能根据hashCode定位它在数组中的位置呢?

      要解释此问题,还需要从HashMap的table数组是如何存储元素的说起,首先要说明三点:

    • table数组的长度永远是2的N次幂。
    • table数组的元素是Entry类型
    • table数组中的元素位置是不连续的

      table数组为何如此设计呢?下面逐步来说明,先来看HashMap是如何插入元素的,代码如下: 

     1 public V put(K key, V value) {
     2         //null键处理
     3         if (key == null)
     4             return putForNullKey(value);
     5         //计算hash码,并定位元素
     6         int hash = hash(key);
     7         int i = indexFor(hash, table.length);
     8         for (Entry<K, V> e = table[i]; e != null; e = e.next) {
     9             Object k;
    10             //哈希码相同,并且key相等,则覆盖
    11             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
    12                 V oldValue = e.value;
    13                 e.value = value;
    14                 e.recordAccess(this);
    15                 return oldValue;
    16             }
    17         }
    18         modCount++;
    19         //插入新元素,或者替换哈希的旧元素并建立链表
    20         addEntry(hash, key, value, i);
    21         return null;
    22     }

      注意看,HashMap每次增加元素时都会先计算其哈希码值,然后使用hash方法再次对hashCode进行抽取和统计,同时兼顾哈希码的高位和低位信息产生一个唯一值,也就是说hashCode不同,hash方法返回的值也不同,之后再通过indexFor方法与数组长度做一次与运算,即可计算出其在数组中的位置,简单的说,hash方法和indexFor方法就是把哈希码转变成数组的下标,源代码如下:

     1    final int hash(Object k) {
     2         int h = 0;
     3         if (useAltHashing) {
     4             if (k instanceof String) {
     5                 return sun.misc.Hashing.stringHash32((String) k);
     6             }
     7             h = hashSeed;
     8         }
     9 
    10         h ^= k.hashCode();
    11 
    12         // This function ensures that hashCodes that differ only by
    13         // constant multiples at each bit position have a bounded
    14         // number of collisions (approximately 8 at default load factor).
    15         h ^= (h >>> 20) ^ (h >>> 12);
    16         return h ^ (h >>> 7) ^ (h >>> 4);
    17     }
    1 /**
    2      * Returns index for hash code h.
    3      */
    4     static int indexFor(int h, int length) {
    5         return h & (length-1);
    6     }

      顺便说一下,null值也是可以作为key值的,它的位置永远是在Entry数组中的第一位。

      现在有一个很重要的问题摆在前面了:哈希运算存在着哈希冲突问题,即对于一个固定的哈希算法f(k),允许出现f(k1)=f(k2),但k1≠k2的情况,也就是说两个不同的Entry,可能产生相同的哈希码,HashMap是如何处理这种冲突问题的呢?答案是通过链表,每个键值对都是一个Entry,其中每个Entry都有一个next变量,也就是说它会指向一个键值对---很明显,这应该是一个单向链表,该链表是由addEntity方法完成的,其代码如下: 

    1  void addEntry(int hash, K key, V value, int bucketIndex) {
    2         if ((size >= threshold) && (null != table[bucketIndex])) {
    3             resize(2 * table.length);
    4             hash = (null != key) ? hash(key) : 0;
    5             bucketIndex = indexFor(hash, table.length);
    6         }
    7 
    8         createEntry(hash, key, value, bucketIndex);
    9     }
     void createEntry(int hash, K key, V value, int bucketIndex) {
            //取得当前位置元素
            Entry<K,V> e = table[bucketIndex];
           //生成新的键值对,并进行替换,建立链表
            table[bucketIndex] = new Entry<>(hash, key, value, e);
            size++;
        }

      这段程序涵盖了两个业务逻辑,如果新加入的元素的键值对的hashCode是唯一的,那直接插入到数组中,它Entry的next值则为null;如果新加入的键值对的hashCode与其它元素冲突,则替换掉数组中的当前值,并把新加入的Entry的next变量指向被替换的元素,于是一个链表就产生了,如下图所示:

      

       HashMap的存储主线还是数组,遇到哈希码冲突的时候则使用链表解决。了解了HashMap是如何存储的,查找也就一目了然了:使用hashCode定位元素,若有哈希冲突,则遍历对比,换句话说,如果没有哈希冲突的情况下,HashMap的查找则是依赖hashCode定位的,因为是直接定位,那效率当然就高了。

      知道HashMap的查找原理,我们就应该很清楚:如果哈希码相同,它的查找效率就与ArrayList没什么两样了,遍历对比,性能会大打折扣。特别是哪些进度紧张的项目中,虽重写了hashCode方法但返回值却是固定的,此时如果把哪些对象插入到HashMap中,查找就相当耗时了。

      注意:HashMap中的hashCode应避免冲突。

    建议80:多线程使用Vector或HashTable

      Vector是ArrayList的多线程版本,HashTable是HashMap的多线程版本,这些概念我们都很清楚,但我们经常会逃避使用Vector和HashTable,因为用的少,不熟嘛!只有在真正需要的时候才会想要使用它们,但问题是什么时候真正需要呢?我们来看一个例子,看看使用多线程安全的Vector是否可以解决问题,代码如下: 

     1 public class Client80 {
     2     public static void main(String[] args) {
     3         // 火车票列表
     4         final List<String> tickets = new ArrayList<String>(100000);
     5         // 初始化票据池
     6         for (int i = 0; i < 100000; i++) {
     7             tickets.add("火车票" + i);
     8         }
     9         // 退票
    10         Thread returnThread = new Thread() {
    11             @Override
    12             public void run() {
    13                 while (true) {
    14                     tickets.add("车票" + new Random().nextInt());
    15                 }
    16 
    17             };
    18         };
    19 
    20         // 售票
    21         Thread saleThread = new Thread() {
    22             @Override
    23             public void run() {
    24                 for (String ticket : tickets) {
    25                     tickets.remove(ticket);
    26                 }
    27             }
    28         };
    29         // 启动退票线程
    30         returnThread.start();
    31         // 启动售票线程
    32         saleThread.start();
    33 
    34     }
    35 }

      模拟火车站售票程序,先初始化一堆火车票,然后开始出售,同时也有退票产生,这段程序有木有问题呢?可能会有人看出了问题,ArrayList是线程不安全的,两个线程访问同一个ArrayList数组肯定会有问题。

      没错,确定有问题,运行结果如下:

      

     运气好的话,该异常马上就会抛出,也会会有人说这是一个典型错误,只须把ArrayList替换成Vector即可解决问题,真的是这样吗?我们把ArrayList替换成Vector后,结果依旧。仍然抛出相同的异常,Vector应经是线程安全的,为什么还会报这个错呢?

     这是因为它混淆了线程安全和同步修改异常,基本上所有的集合类都有一个快速失败(Fail-Fast)的校验机制,当一个集合在被多个线程修改并访问时,就可能出现ConcurrentModificationException异常,这是为了确保集合方法一致而设置的保护措施,它的实现原理就是我们经常提到的modCount修改计数器:如果在读列表时,modCount发生变化(也就是有其它线程修改)则会抛出ConcurrentModificationException异常,这与线程同步是两码事,线程同步是为了保护集合中的数据不被脏读、脏写而设置的,我们来看看线程安全到底用在什么地方,代码如下:

     1 public static void main(String[] args) {
     2         // 火车票列表
     3         final List<String> tickets = new ArrayList<String>(100000);
     4         // 初始化票据池
     5         for (int i = 0; i < 100000; i++) {
     6             tickets.add("火车票" + i);
     7         }
     8         // 10个窗口售票
     9         for (int i = 0; i < 10; i++) {
    10             new Thread() {
    11                 public void run() {
    12                     while (true) {
    13                         System.out.println(Thread.currentThread().getId()
    14                                 + "----" + tickets.remove(0));
    15                         if (tickets.size() == 0) {
    16                             break;
    17                         }
    18                     }
    19                 };
    20             }.start();
    21         }
    22     }

      还是火车站售票程序,有10个窗口在卖火车票,程序打印出窗口号(也就是线程号)和车票编号,我们很快就可以看到这样的输出:

      

      注意看,上面有两个线程在卖同一张火车票,这才是线程不同步的问题,此时把ArrayList修改为Vector即可解决问题,因为Vector的每个方法前都加上了synchronized关键字,同时知会允许一个线程进入该方法,确保了程序的可靠性。

      虽然在系统开发中我们一再说明,除非必要,否则不要使用synchronized,这是从性能的角度考虑的,但是一旦涉及到多线程(注意这里说的是真正的多线程,并不是并发修改的问题,比如一个线程增加,一个线程删除,这不属于多线程的范畴),Vector会是最佳选择,当然自己在程序中加synchronized也是可行的方法。

      HashMap的线程安全类HashTable与此相同,不再赘述。

    建议81:非稳定排序推荐使用List

      我们知道Set和List的最大区别就是Set中的元素不可以重复(这个重复指的是equals方法的返回值相等),其它方面则没有太大区别了,在Set的实现类中有一个比较常用的类需要了解一下:TreeSet,该类实现了默认排序为升序的Set集合,如果插入一个元素,默认会按照升序排列(当然是根据Comparable接口的compareTo的返回值确定排序位置了),不过,这样的排序是不是在元素经常变化的场景中也适用呢?我们来看看例子:  

     1 public class Client81 {
     2     public static void main(String[] args) {
     3         SortedSet<Person> set = new TreeSet<Person>();
     4         // 身高180CM
     5         set.add(new Person(180));
     6         // 身高175CM
     7         set.add(new Person(175));
     8         for (Person p : set) {
     9             System.out.println("身高:" + p.getHeight());
    10         }
    11     }
    12 
    13     static class Person implements Comparable<Person> {
    14         // 身高
    15         private int height;
    16 
    17         public Person(int _height) {
    18             height = _height;
    19         }
    20 
    21         public int getHeight() {
    22             return height;
    23         }
    24 
    25         public void setHeight(int height) {
    26             this.height = height;
    27         }
    28 
    29         // 按照身高排序
    30         @Override
    31         public int compareTo(Person o) {
    32             return height - o.height;
    33         }
    34 
    35     }
    36 }

      这是Set的简单用法,定义一个Set集合,之后放入两个元素,虽然175后放入,但是由于是按照升序排列的,所以输出结果应该是175在前,180在后。

      这没有问题,随着时间的推移,身高175cm的人长高了10cm,而180cm却保持不变,那排序位置应该改变一下吧,代码如下: 

     1 public static void main(String[] args) {
     2         SortedSet<Person> set = new TreeSet<Person>();
     3         // 身高180CM
     4         set.add(new Person(180));
     5         // 身高175CM
     6         set.add(new Person(175));
     7         set.first().setHeight(185);
     8         for (Person p : set) {
     9             System.out.println("身高:" + p.getHeight());
    10         }
    11     }

      找出身高最矮的人,也就是排在第一位的人,然后修改一下身高值,重新排序了?我们看下输出结果:

      

      很可惜,竟然没有重现排序,偏离了我们的预期。这正是下面要说明的问题,SortedSet接口(TreeSet实现了此接口)只是定义了在给集合加入元素时将其进行排序,并不能保证元素修改后的排序结果,因此TreeSet适用于不变量的集合数据排序,比如String、Integer等类型,但不使用与可变量的排序,特别是不确定何时元素会发生变化的数据集合。

      原因知道了,那如何解决此类重排序问题呢?有两种方式:

      (1)、Set集合重排序:重新生成一个Set对象,也就是对原有的Set对象重新排序,代码如下:

             set.first().setHeight(185);
            //set重排序
            set=new TreeSet<Person>(new ArrayList<Person>(set));

      就这一行红色代码即可重新排序,可能有人会问,使用TreeSet<SortedSet<E> s> 这个构造函数不是可以更好的解决问题吗?不行,该构造函数只是原Set的浅拷贝,如果里面有相同的元素,是不会重新排序的。

      (2)、彻底重构掉TreeSet,使用List解决问题

        我们之所以使用TreeSet是希望实现自动排序,即使修改也能自动排序,既然它无法实现,那就用List来代替,然后使用Collections.sort()方法对List排序,代码比较简单,不再赘述。

      两种方式都可以解决我们的问题,如果需要保证集合中元素的唯一性,又要保证元素值修改后排序正确,那该如何处理呢?List不能保证集合中的元素唯一,它是可以重复的,而Set能保证元素唯一,不重复。如果采用List解决排序问题,就需要自行解决元素重复问题(若要剔除也很简单,转变为HashSet,剔除后再转回来)。若采用TreeSet,则需要解决元素修改后的排序问题,孰是孰非,就需要根据具体的开发场景来决定了。

      注意:SortedSet中的元素被修改后可能会影响到其排序位置。

    建议82:由点及面,集合大家族总结

      Java中的集合类实在是太丰富了,有常用的ArrayList、HashMap,也有不常用的Stack、Queue,有线程安全的Vector、HashTable,也有线程不安全的LinkedList、TreeMap,有阻塞式的ArrayBlockingQueue,也有非阻塞式的PriorityQueue等,整个集合大家族非常庞大,可以划分以下几类:

      (1)、List:实现List接口的集合主要有:ArrayList、LinkedList、Vector、Stack,其中ArrayList是一个动态数组,LinkedList是一个双向链表,Vector是一个线程安全的动态数组,Stack是一个对象栈,遵循先进后出的原则。  

      (2)、Set:Set是不包含重复元素的集合,其主要实现类有:EnumSet、HashSet、TreeSet,其中EnumSet是枚举类型专用Set,所有元素都是枚举类型;HashSet是以哈希码决定其元素位置的Set,其原理与HashMap相似,它提供快速的插入和查找方法;TreeSet是一个自动排序的Set,它实现了SortedSet接口。

      (3)、Map:Map是一个大家族,他可以分为排序Map和非排序Map,排序Map主要是TreeMap类,他根据key值进行自动排序;非排序Map主要包括:HashMap、HashTable、Properties、EnumMap等,其中Properties是HashTable的子类,它的主要用途是从Property文件中加载数据,并提供方便的操作,EnumMap则是要求其Key必须是某一个枚举类型。

       Map中还有一个WeakHashMap类需要说明,  它是一个采用弱键方式实现的Map类,它的特点是:WeakHashMap对象的存在并不会阻止垃圾回收器对键值对的回收,也就是说使用WeakHashMap装载数据不用担心内存溢出的问题,GC会自动删除不用的键值对,这是好事。但也存在一个严重的问题:GC是静悄悄的回收的(何时回收,God,Knows!)我们的程序无法知晓该动作,存在着重大的隐患。

      (4)、Queue:对列,它分为两类,一类是阻塞式队列,队列满了以后再插入元素会抛出异常,主要包括:ArrayBlockingQueue、PriorityQueue、LinkedBlockingQueue,其中ArrayBlockingQueue是一个以数组方式实现的有界阻塞队列;另一类是非阻塞队列,无边界的,只要内存允许,都可以持续追加元素,我们经常使用的是PriorityQuene类。

      还有一种队列,是双端队列,支持在头、尾两端插入和移除元素,它的主要实现类是:ArrayDeque、LinkedBlockingDeque、LinkedList。

      (5)、数组:数组与集合的最大区别就是数组能够容纳基本类型,而集合就不行,更重要的一点就是所有的集合底层存储的都是数组。

      (6)、工具类:数组的工具类是java.util.Arrays和java.lang.reflect.Array,集合的工具类是java.util.Collections,有了这两个工具类,操作数组和集合就会易如反掌,得心应手。

      (7)、扩展类:集合类当然可以自行扩展了,想写一个自己的List?没问题,但最好的办法还是"拿来主义",可以使用Apache的common-collections扩展包,也可以使用Google的google-collections扩展包,这些足以应对我们的开发需要。

  • 相关阅读:
    JS系统函数
    匿名函数
    使用递归计算1~n之间所有整数的和
    交换两个变量的值
    创建函数,传递一个数字n,返回斐波那契数列的第n的值。
    创建函数function
    打印本世纪(2000~2100)的前10个闰年
    打印九九乘法表
    计算1~100之间所有整数的和
    循环执行
  • 原文地址:https://www.cnblogs.com/selene/p/5899372.html
Copyright © 2011-2022 走看看