重新认识HashMap

1,708 阅读15分钟

简介

HashMap是Java程序员使用频率最高的用于映射(键、值对)处理的数据类型,它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null,且HashMap不是线程安全的类,可以使用ConcurrentHashMap和Collections的SynchronizedMap方法使HashMap具有线程安全的能力。在JDK1.8中对HashMap底层的实现进行了优化,引入红黑树、扩容优化等。那就重新认识一下JDK1.8的HashMap,来看看对其做了什么优化。

存储结构

要搞清楚HashMap,首先需要知道HashMap是什么,即它的存储结构;其次弄明白它能干什么,即它的功能如何实现。我们都知道HashMap使用哈希表来存储的数据的,根据key的哈希值进行存储的,但是不同的key之间可能存在相同的哈希值,这样就会产生冲突;哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中的HashMap采用了链地址法来解决哈希冲突,简单来说就是数组加链表的结合。在每个数组元素上都一个链表结构,当存放数据的时候如果产生了哈希冲突,先得到数组下标,把数据放在对应下标元素的链表上。这里我们思考一个问题,即使哈希算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树;当链表长度太长(默认超过7)时,链表就转换为红黑树,如下图所示。

hashMap.png

重要字段

HashMap是根据key的哈希值进行存取的,那HashMap的性能和哈希算法的好坏有着直接的关系,哈希算法计算结果越分散均匀,哈希碰撞的概率就越小,map的存取效率就会越高。当然,也和哈希数组的大小有关系,如果哈希数组很大,即使较差的哈希算法也会比较分散,如果哈希数组较小,即使较好的哈希算法也会出现较多的碰撞,所以就需要权衡空间和时间成本,找到比较平衡的值。

JDK1.8版本也是权衡了时间、空间成本以及效率,对之前的版本做出了很多优化;不仅对数据结构进行了优化,除此之外还对扩容进行的优化,大大的提高的HashMap的性能。下面我们通过源码来一起看一下具体的实现。

我们来看一下HashMap中比较重要的几个属性。

//默认的初始容量,必须是2的幂次方.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//所能容纳 key-value 个数的极限,当Map 的size > threshold 会进行扩容 。容量 * 扩容因子
int threshold;

//hashMap最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;

//HashMap 默认的桶数组的大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16

//默认的加载因子.当容量超过 0.75*table.length 扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//HashMap的加载因子,在构造器中指定的.
final float loadFactor;

//链表节点数大于8个链表转红黑树
static final int TREEIFY_THRESHOLD = 8;

//红黑树节点转换链表节点的阈值, 6个节点转
static final int UNTREEIFY_THRESHOLD = 6;

//以Node数组存储元素,长度为2的次幂。
transient Node<K,V>[] table;

// 转红黑树, table的最小长度
static final int MIN_TREEIFY_CAPACITY = 64; 

// 链表节点, 继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {  
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    // ... ...
}

// 红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
   
    // ...
}

HashMap中的属性还是比较好理解的。其实到这里会有一个疑问,为什么默认的哈希桶数组table的长度为16,而且长度必须为2的n次方呢?

这里我们先说一下为什么哈希数组的长度是2的n次方。

其实不管是在JDK1.7还是JDK1.8中,计算key索引位置都是通过hash & (length-1)计算得来的。

我们应该知道 hash % length 等价于 hash & (length - 1),在length是2的n次方时

假如有一个key的哈希值二进制如下:这里我们就只看低位。
hahsCode         0010 0011       ———————转成十进制—————————>        35           
&                                                             %
(length-1)=15:   0000 1111                                length = 16  
-----------------------------------------------------------------------------------------------
             (二进制) 0011  = (十进制)3                            3

为什么不用 hash % length 计算索引位,要使用 hash & (length -1)来计算呢?计算机底层是二进制数进行计算和存储,&是接近计算机底层运算,相比于% 运算效率上应该会快。

那为什么length必须是2的n次方呢?

hahsCode         0010 0011                     0010 1111    
&                                     
(length-1)=15:   0000 1111    (length-1) = 13: 0000 1111  
------------------------------------------------------------
                      0011                          1111 
hahsCode         0010 1110                     1110 1100  
&                                     
(length-1)=13:   0000 0101    (length-1) = 13: 0000 0101  
-----------------------------------------------------------
                      0100                          0100 

其实我们可以发现,当哈希数组的长度为2的n次方时,length - 1的二进制码全都是1,这样的话索引的位置,完全依赖于hash值的低位值,而且产生冲突的几率要比容量不是2的n次方的概率要低,索引位就完全依赖于哈希值的低位,所以只要哈希值分布均匀,那产生冲突的概率就会低很多,故而length是2的n次方更优。

其次,当length为2的n次方时,也方便做扩容,JDK1.8在扩容算法上也进行了优化,使用的方法也非常巧妙。会在扩容方法的时候讲到。

功能实现

确定索引位置

不管是增加、删除、查找,都需要定位到哈希桶的数组位置,前面也说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用再遍历链表查询,大大优化了查询效率。

tableSizeFor()这个方法,就是保证在HashMap()进行初始化的时候,哈希桶数组的大小永远是2^n。

static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        
  /**
  假如现在传入的参数cap = 3
  那 n = 2 对应的二进制 为 10 
  n  = n | n>>>1  10|01  得到 11
  ....
  ....
  n = 11(二进制)  = (10进制) 3 
  最后return 返回的是4
  */
}
//JDK1.8的Hash算法
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// JDK 1.7的Hash算法
 static final int hash(int h) {
    h ^= k.hashCode(); 
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
 }

//索引位置
index = hash & (length-1);

//JDK1.7 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
//JDK 1.8 简化了hash函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算。

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,相比于JDK1.7来说,JDK1.8降低了哈希函数扰动的次数,也算是优化了hash算法。这么做可以在HashMap容量较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

假如有一个key的哈希值二进制如下
hahsCode               0000 0000 0011 0011 0111 1010 1000 1011

hahsCode>>>16          0000 0000 0000 0000 0000 0000 0011 0011
 ———————————————————————————————————————————————————————————————
位或^运算               0000 0000 0011 0011 0111 1010 1011 1000
 &
HashMap.size()-1       0000 0000 0000 0000 0000 0000 0000 1111
 ———————————————————————————————————————————————————————————————
                       0000 0000 0000 0000 0000 0000 0000 1000 转成十进制是 8

put方法

从网上找到了一个流程图,感觉很不错,就直接拿过来用了,嘿嘿....画的也是比较清楚的。看着流程图,再结合源码一看就明白。 图片名称

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        
        //判断哈希桶数组是否为空。
        if ((tab = table) == null || (n = tab.length) == 0)
        
         //如果哈希桶数组为空,对其进行初始化。默认的桶数组大小为16
        n = (tab = resize()).length;
            
        //如果桶数组不为空,得到计算key的索引位置,判断此索引所在位置是否已经被占用了。
        if ((p = tab[i = (n - 1) & hash]) == null)
        
        //如果没有被占用,那就封装成Node节点,放入哈希桶数组中。
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //如果要插入的Node节点已经存在,那就将旧的Node替换。
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果不存在,那就插入,判断插入的节点是不是树节点。
            else if (p instanceof TreeNode)
            
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                
            else {
            //如果是普通节点,循环哈希桶对应的链表,将节点插入到链表末尾
                for (int binCount = 0; ; ++binCount) {
                    
                    if ((e = p.next) == null) {d
                    
                        p.next = newNode(hash, key, value, null);
                        
                        //如果链表的长度大于7,就把节点转成树节点
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果链表中节点已经存在,那就将旧的节点替换。
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //如果超过了扩容的临界点,就进行扩容。
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

get方法

get方法相对于put方法可能简单一点,通过源码一看就能明白。废话不多说,直接上代码看一下吧。

 public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {

        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //哈希桶数组不为空,且根据传入的key计算出索引位置的Node不为空。
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //如果计算出来的第一个哈希桶位置的Node就是要找的Node节点,直接返回。
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            
            if ((e = first.next) != null) {
                //如果是树节点,直接通过树节点的方式查找。
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //循环遍历哈希桶所在的链表
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
  }

扩容机制(resize)

我个人认为HashMap的扩容算法是整个HashMap中的精髓部分,使用的方法也十分巧妙,不得不佩服,大佬还是大佬。

final Node<K,V>[] resize() {
        //将当前的table 暂存的 oldTab中进行操作
        Node<K,V>[] oldTab = table;
        //当前的哈希桶数组大小
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //当前的扩容临界值
        int oldThr = threshold;
        int newCap, newThr = 0;
        //如果当前哈希桶数组不为空
        if (oldCap > 0) {
          //如果容量超过最大值
            if (oldCap >= MAXIMUM_CAPACITY) {
            //修改阈值为2^31-1
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }  
            // 没超过最大值,就扩充为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) 
          // 当使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
            newCap = oldThr; 
        else {
          //如果代码到这里,说明hashMap还没有进行初始化, 对应 new HashMap();
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 计算新的resize上限
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
       // 将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量。将旧Hash桶中的元素,移动到新的Hash数组中。
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
       /**
        如果只是对HashMap的初始化来说,到这里已经结束了
        下面代码是将老的数组中的数据,移动到扩容以后的哈希桶数组中。
       **/
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                
                if ((e = oldTab[j]) != null) {
                // 将老表的节点设置为空, 以便垃圾收集器回收
                    oldTab[j] = null;
                    
              //判断哈希桶位置是否只有一个节点。如果这个位置只有一个节点,那就将这个节点在扩容以后的哈希桶数组中重新计算位置
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        //如果是红黑树节点,则进行红黑树的重hash分布
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                    //如果是普通的链表节点,则需要将这个位置上的所有节点重新在新的哈希桶数组中计算位置。
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
          //如果要移动节点的hash值与老的容量进行 "&" 运算,如果结果为0,那在扩容以后的数组中,还是同样的位置。
                           if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                    //如果运算不为0,则扩容后的索引位置为:老表的索引位置+扩容之前的
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

在源码中有这么一段(e.hash & oldCap) == 0,怎么理解这个呢,我们通过下面的来看一下。

假设扩容之前 数组大小为16
假如有两个key:
key1(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
key2(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
          &
    length-1 = 15    0000 0000 0000 0000 0000 0000 0000 1111
——————————————————————————————————————————————————————————————————
                                                  key1: 1000 转成十进制 8
                                                  key2: 1000 转成十进制 8
                                                  
哈希冲突的两个key,在扩容到32之后
key1(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
                                                    
key2(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
           &
         length-1 = 31    0000 0000 0000 0000 0000 0000 0001 1111
——————————————————————————————————————————————————————————————————
                                                   key1:   1 1000 转乘二进制 24=16+8
                                                   key2:   0 1000 转乘二进制 8

我们可以发现,代码中利用的是 hash & oldCap(key的哈希值 & 扩容之前的哈希桶长度),而不是利用hash & newCap- 1 ,为什么要这样做呢?

通过上面的情况我们应该可以看出,原本冲突的两个key,在扩容以后的位置是由新增的那一个bit位来决定的。所以直接用 hash & 扩容之前的容量来判断新增的那个bit位是0 还是 1 ,就可以知道是在原来的位置上,还是在 原来位置+oldCap 位置上。

哈希冲突的两个key,在扩容到32之后
key1(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
  
key2(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
           &
         oldCap=16        0000 0000 0000 0000 0000 0000 0001 0000

通过上面我们也能看到,原来在同一个位置上的两个key,通过扩容之后的位置要不在原来的位置上,要不在oldCap+原位置上。这样不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。同时也是更加充分的说明了,为什么HashMap的容量必须是2的n次方了。

知道HashTable的同学应该也知道,HashTable默认的容量是11,每次扩容之后的容量是length*2+1。HashTable在设计上,想通过保证哈希桶数组为质数,来尽可能保证哈希散列均匀。这里也就不对HashTable的容量为什么是质数进行展开,感兴趣的同学可以查一下资料,为什么对质数求模会比对合数求模的哈希散列效果要好,可以参考blog.csdn.net/liuqiyao_01…

HashMap保证哈希数组的大小为2的n次方,这个设计确实非常的巧妙,利用2的n次方 - 1 二进制码全为1这个特性,在扩容的时候,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

hashMap_resize.png

总结

  1. 扩容是一个特别耗性能的操作,所以当我们在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
  2. HashMap是线程不安全的,不要在并发的环境中使用HashMap,使用ConcurrentHashMap或者SynchronizedMap
  3. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改。