HashMap深入分析及使用要点

本文内容来自深入理解HashMap从数据结构谈HashMapHashMap深度分析

先说使用要点。

1、不要在并发场景中使用HashMap
  HashMap是线程不安全的,如果被多个线程共享的操作,将会引发不可预知的问题。

2、如果数据大小是固定的,那么最好给HashMap设定一个合理的容量值

  HashMap的初始默认容量是16,默认加载因子是0.75,也就是说,如果采用HashMap的默认构造函数,当增加数据时,数据实际容量超过16*0.75=12时,HashMap就扩容,扩容带来一系列的运算,新建一个是原来容量2倍的数组,对原有元素全部重新哈希,这是很耗费资源的。

  好了,现在来对HashMap进行“深入分析”。

  1、hashmap的数据结构

  在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。

  从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。

  在HashMap里有这样的一句属性声明:

transient Entry[] table;

  Entry就是HashMap存储数据所用的类,它拥有的属性如下:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        final int hash;
        Entry<K,V> next;
..........
}

  看到next了吗?它是一个指向下一个元素的引用,这就构成了链表。

  当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,将这个位置的原有Entry赋值给当前新加的 Entry的next属性,这样新加入的就放在了链头,而最先加入的就放在了链尾。

  从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

  从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服。哈希碰撞总是难免的,不过java里面使用了一个聪明的办法减少了哈希碰撞的几率,这个后面会讲到。

  2、HashMap的操作。

  现在来看看,当我们new、get、put的时候,HashMap到底都是怎么处理的。

  先看初始化

  与初始化有关的关键参数如下:

   /**
     * 默认容量
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 等于 16

    /**
     * 最大容量
     */
    static final int MAXIMUM_CAPACITY = 1 << 30; //2的30次方

    /**
     * 默认加载因子
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    /**
     * 当实际数据容量超过threshold时,HashMap会将容量扩容,threshold=容量*加载因子
     */
    int threshold;

    /**
     * 加载因子
     */
    final float loadFactor;

  HashMap有4个构造方法,值得一提的是下面这个:

  public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        init();
    }

  看这句: threshold = initialCapacity;

  也就是说,如果构造时写成new HashMap(9,0.75);那么当容量超过9时,HashMap才会扩容,而不是9*0.75时就扩容。这里的initialCapacity建议写成2的倍数,至于为什么后面会说到。

  然后是get。即如何获取我们要的元素。

  其实前面已经提过了,在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。然后通过key的equals方法在对应位置的链表中找到需要的元素。

  接下来是put 。

  我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 

  所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的:

static int indexFor(int h, int length) {
  return h & (length-1);
}

  首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。 

      看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
 

  所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。 
      说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。 
  所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。

  接下来是resize,也就是扩容。

  当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 
      那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。

  最后说说HashMap死锁。才

  死锁是扩容操作与put或get方法并发引起的,看源码:

  void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;//切换线程1
                e = next;
            }
        }
    }
  final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];//切换线程2
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

table初始状态:

假设执行getEntry的线程1在e = table[indexFor(hash, table.length)]后切换到线程2的put方法,而且进行了扩容。此时table和newTable的状态是: 

线程2在newTable[i] = e后切换到线程1,在for循环里e.next==e,永远不为空,若 

if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  

不满足,就会进入死循环。

ok,先说这么多,感谢各位同行的分享。

原文地址:https://www.cnblogs.com/bailiyi/p/3730508.html