编写高质量代码:改善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扩展包,这些足以应对我们的开发需要。

posted @ 2016-09-24 09:52  阿赫瓦里  阅读(1667)  评论(3编辑  收藏  举报