聊聊Java的HashMap

前言

前不久,在一次和好友喝茶聊天的时候谈到Java的HashMap集合问题,我们在一起探讨过好多,现在又到了程序员找工作的黄金时期“金三银四”,对此方便一些伙伴面试的需要以及把自己的一些见解总结出来提供一种学习以及解决问题思路。

实现原理

  1. 利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
  2. 存储时,如果出现hash值相同的key,此时有两种情况。(1)如果key相同,则覆盖原始值;(2)如果key不同(出现冲突),则将当前的key-value放入链表中或者红黑树中
  3. 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
  4. 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。

实现演进

不同版本 JDK1.7 JDK1.8
存储结构 数组+链表 数组+链表+红黑树
初始化方式 单独方法:inflateTable() 直接集成到拓容方法resize()种
Hash值计算方式 扰动处理=9次扰动=4次位运算+5次异或运算 扰动处理=2次=1一次位运算+1次异或运算
存放数据的规则 无冲突时存放数组,冲突时存放链表 无冲突时存放数组;冲突且链表长度<8时,存放单链表;冲突且链表长度>8时,树化并存放红黑树
插入数据方式 头插法(将原来位置的数据移到后一位,再插入数据到该位置) 尾插法(直接插入到链表或者红黑树的尾部)
扩容后存储位置的计算方式 全部按照原来的方法进行计算(hashCode->>扰动方法->>(h&length-1)) 按照扩容后的规律计算(即扩容后的位置=原位置或者原来位置+旧容量)

源码解剖

存储方式

JDK1.7采用的是数组+链表的形式,而JDK1.8在数组容量大于64且链表长度大于8的情况下会使用红黑树。源码里也有很详细的解释,这里不过多赘述。具体可以查看文章HashMap部分源码的理解

初始化方式

在JDK1.7中,table数组默认值为EMPTY_TABLE,在添加元素的时候判断table是否为EMPTY_TABLE来调用inflateTable。在构造HashMap实例的时候默认threshold阈值等于初始容量。当构造方法的参数为Map时,调用inflateTable(threshold)方法对table数组容量进行设置。

public HashMap(Map<? extends K, ? extends V> m) {
    this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                    DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
    inflateTable(threshold);

    putAllForCreate(m);
}

在JDK1.8中初始化的过程则是直接集成到了resize()函数中。

扰动方法变化

在JDK1.7中扰动方法如下:

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
 
        h ^= k.hashCode();
 
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
}

在JDK1.8中如下:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

上面分别为两个版本的扰动方法,在JDK1.8中简化不少。

1.png

从上述图片中,我们样本采用352个字符串做测试,发现在bits较低的情况下,扰动函数的collisions概率会有10%左右的降低,至于扰动方法的作用,涉及如下另一段源码:

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

改方法就是用来取下标的,但这时,就算散列值分布再松散,只取最后几位的话碰撞依然会很严重,在随机少量的情况下影响尤为大,所以引入了扰动函数来减少hash碰撞,而至于为什么JDK1.8进行了缩减,可能是因为做多了离散分布提升不明显,还是为了效率考虑,进行了缩减。

存放数据的规则

存放数据的规则网上有不错文章进行剖析,由于篇幅有限就不再此分析,可以参考文章HashMap部分源码的理解

插入数据方式

在JDK1.7中采用的是头插法,JDK1.8改成了尾插法,为了避免头插法导致的在多线程的情况下HashMap在put元素时产生的环形链表的问题,但1.8仍然存在数据覆盖的问题,所以依旧不是线程安全的.具体推荐两篇不错文章

TIPS:在阅读的时候务必自己参照源码,否则理解起来比较困难。

其实概括讲HashMap 在JDK1.7中与JDK1.8中表现线程安全是不一样,主要表现如下:

  1. 在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况;
  2. 在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况;

扩容后存储位置的计算方式

扩容就是resize()方法,在JDK1.7和JDK1.8里面都有对应源码方法。

扩容情况

在进行扩容时候存在两种情况:

  • 设定threshold, 当threshold = 默认容量(16) * 加载因子(0.75)的时候,进行resize();
  • 如上文所讲,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表,因为树化本身就是一个效率不高的操作,因为红黑树非常复杂,涉及到了自旋的问题,所以hashmap的设计者也将树化的长度设置为8,因为经过概率计算,8是一个比较难达到的链表长度,在链表长度比较小的时候,虽然是O(n),但并不会对效率有什么影响。

在JDK1.7版本中,如下:

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //判断是否有超出扩容的最大值,如果达到最大值则不进行扩容操作
    if (oldCapacity == MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return;
    }
 
    Entry[] newTable = new Entry[newCapacity];
    // transfer()方法把原数组中的值放到新数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //设置hashmap扩容后为新的数组引用
    table = newTable;
    //设置hashmap扩容新的阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  }
 
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);
        }
        //通过key值的hash值和新数组的大小算出在当前数组中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

从上述源码中可以看到在1.7中,借助transfer()方法(jdk1.8中已移除),在扩容的时候会重新计算threshold,数组长度并进行rehash,这样的效率是偏低的。

在JDK1.8版本中,如下:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//首次初始化后table为Null
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;//默认构造器的情况下为0
        int newCap, newThr = 0;
        if (oldCap > 0) {//table扩容过
             //当前table容量大于最大值得时候返回当前table
             if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
            //table的容量乘以2,threshold的值也乘以2           
            newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
        //使用带有初始容量的构造器时,table容量为初始化得到的threshold
        newCap = oldThr;
        else {  //默认构造器下进行扩容  
             // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
        //使用带有初始容量的构造器在此处进行扩容
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                HashMap.Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // help gc
                    oldTab[j] = null;
                    if (e.next == null)
                        // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
                        // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof HashMap.TreeNode)
                        // 当前index对应的节点为红黑树,当树的高度小于等于UNTREEIFY_THRESHOLD则转成链表
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 把当前index对应的链表分成两个链表,减少扩容的迁移量
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                // 扩容后不需要移动的链表
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                // 扩容后需要移动的链表
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            // help gc
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            // help gc
                            hiTail.next = null;
                            // 扩容长度为当前index位置+旧的容量
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

从上面的源码中可以分析出扩容分为三种情况:

  1. 第一种是初始化阶段,此时的newCap和newThr均设为0,从之前的源码可知,第一次扩容的时候,默认的阈值就是threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12。而 DEFAULT_INITIAL_CAPACITY为16, DEFAULT_LOAD_FACTOR 为0.75;
  2. 第二种是如果oldCap大于0,也就是扩容过的话,每次table的容量和threshold都会扩围原来的两倍;
  3. 第三种是如果指定了threshold的初始容量的话,newCap就会等于这个threshold,新的threshold会重新计算;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章