HashMap 源码分析+面试题+ (TODO)

hashMap 是我们日常中最常用的一种集合类型,他继承了AbstractMap类 实现了Map<K,V> Cloneable,Serializable 接口

继承关系图如下

 

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

HashMap是根据hashCode值进行存储的,大多数情况可以直接定位到值,所以具有很快的访问速度。但是是无顺序的。是非线程安全的,如果需要满足线程安全,可以使用Collection的SynchronizedMap 或者使用ConcurrentHashMap。

HashTable: 是线程安全的 但是并发不如ConcurrentHashMap,功能和HashMap 类似。

TreeMap: 因为TreeMap 实现了 Sortedmap接口,所以他存储时候有序的,默认是按照键的升序排列。

HashMap 是数组+链表+红黑树(jdk1.8增加红黑树部分),当链表长度大于8时转换为红黑树。

那么HashMap 到底底层数据具体储存的是什么?优点是什么呢

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //定位索引位置
        final K key;
        V value;
        Node<K,V> next; //指向下一个

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
         //key和key对比 value 和value 对比
if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }

这个Node是HashMap的一个内部类,实现了Map.Entry<K,V>接口,所以本质就是键值对(映射),其中在jdk1.8中使用node替换了原来的entry

 transient Entry[] table;

基于链地址法的原理使用put<K,V> 存储对象达到hashMap中,使用get(key)从中获取,我们在put的时候,先对key调用hashCode()方法来计算其哈希值 从而得到放的链表的储存位置。那么get方法就是通过对key计算哈希值来获取存放的位置,进而确定value。

如何确定哈希桶数组的索引位置?

不管是增删查改,定位到哈希桶数组的位置都是很重要的,通过对key的 hashcode值进行高位运算和取模运算。

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

如果key的hashCode返回值相同,那么方法一计算出来的值也是相同的,把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。

这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。且在jdk1.8中优化了高位运算的算法。

通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

接下里看hashmap如何put key的

public V put(K key, V value) {
  // 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true); }
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {

  //hash值 key value Node
<K,V>[] tab; Node<K,V> p; int n, i;
    //1.如果tab 为null 或者 length=0 运行resize 扩展
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
    //i 是索引,如果索引为空 那么插入node
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else {
     Node
<K,V> e; K k;

    //判断如何hash值相等,且key不为空且相等,那么替换value
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) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st TREEIFY_THRESHOLD =8 判断创建红黑树 treeifyBin(tab, hash); break; }
            //如果key存在 直接覆盖value
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; // 增加fail-fast数值
    //超过阈值就扩容
if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }

扩容resize()

字面意思,就是扩充容量,在不停的想hashmap里面添加元素的时候,内部的数组无法装载更多的内容,就会扩大数组的长度,以便能装更多的数据。java数组是无法自动扩容的。那么久需要一个新的大的数组去代替原数组。

 final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //之前的Node数组
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                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) // initial capacity was placed in 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) { 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) ((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; 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) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }

jdk1.8扩充的时候不需要想1.7一样重新计算hash  只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

如果hashmap 通过链表将产生碰撞的元素组织起来。在jdk1.8中 ,如果在一个位置中,碰撞的元素超过一个限制,默认是8,则使用红黑树来代替链表,来提高速率,而如果两个不同的key计算出的哈希值相同,定位到同一个存储位置,那么我们称之为hash冲突/hash碰撞

如果hash算法计算的哈希值越分散均匀,发生冲突的几率就越小,map懂得存储效率就越会高。当时table[]的大小也会决定发生碰撞的几率。如果table越大,即使算法较差也会冲突很小,反之,table越小,即使算法很好,也会发生很多碰撞。所以需要在空间成本和时间成本做权衡

所以我们需要合适的table大小和好的hash算法。

 transient Node<K,V>[] table; //哈希桶

既然说到哈希桶的大小,那么我们就要说一下扩容。从hashmap的构造方法中得知

    int threshold;             //是hashmap的阈值,用来判断hashmap储存的极限容量?? threshold= 容量* 负载因子 当hashmap存储的数量达到了阈值,hashmap就要增加容量
     final float loadFactor;    // 负载因子
     int modCount;  
     int size;

 首先,Node[]table的初始长度length(默认值是16),Load factor为负载因子(默认值是0.75) threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;

结合公式得知,threshold 就是允许的最大存储值。超过这个数值,就要调用resize()方法扩容, 扩容后容量是之前的两倍。

size 就是hashmap中键值对的数量。而modCount 是几率hashmap内部结构发生变化的次数 ,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。

有一个问题就是即使敷在引资和hash算法设计在合理,也免不了出现拉链过长的情况,一旦拉链过长就会严重影响hashmap的性能,于是在1.8中对数据结构做了优化,引入红黑树,当链表长途太长,默认是8,链表就会转为红黑树。利用红黑树快速增删查改的特性提高hashmap的性能。如下图

hash冲突的解决办法:

在hashmap中解决冲突的办法是采用链地址法。(常用的方法有:开放地址法,再哈希法,链地址法,建立公共溢出法   后续讨论 TODO

线程不安全

 在多线程使用场景中,应该避免使用hashmap,转向使用线程安全的ConcurrentHashMap

public class HashMapInfiniteLoop {  

    private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);  
    public static void main(String[] args) {  
        map.put(5, "C");  

        new Thread("Thread1") {  
            public void run() {  
                map.put(7, "B");  
                System.out.println(map);  
            };  
        }.start();  
        new Thread("Thread2") {  
            public void run() {  
                map.put(3, "A);  
                System.out.println(map);  
            };  
        }.start();        
    }  
}

其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。

通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。

注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。

线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。

e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。

JDK1.8与JDK1.7的性能对比

HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7,下面我们从两个方面用例子证明这一点。

Hash较均匀的情况

为了便于测试,我们先写一个类Key,如下:

class Key implements Comparable<Key> {

    private final int value;

    Key(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(Key o) {
        return Integer.compare(this.value, o.value);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Key key = (Key) o;
        return value == key.value;
    }

    @Override
    public int hashCode() {
        return value;
    }
}

这个类复写了equals方法,并且提供了相当好的hashCode函数,任何一个值的hashCode都不会相同,因为直接使用value当做hashcode。为了避免频繁的GC,我将不变的Key实例缓存了起来,而不是一遍一遍的创建它们。代码如下:

public class Keys {

    public static final int MAX_KEY = 10_000_000;
    private static final Key[] KEYS_CACHE = new Key[MAX_KEY];

    static {
        for (int i = 0; i < MAX_KEY; ++i) {
            KEYS_CACHE[i] = new Key(i);
        }
    }

    public static Key of(int value) {
        return KEYS_CACHE[value];
    }
}

现在开始我们的试验,测试需要做的仅仅是,创建不同size的HashMap(1、10、100、......10000000),屏蔽了扩容的情况,代码如下:

   static void test(int mapSize) {

        HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
        for (int i = 0; i < mapSize; ++i) {
            map.put(Keys.of(i), i);
        }

        long beginTime = System.nanoTime(); //获取纳秒
        for (int i = 0; i < mapSize; i++) {
            map.get(Keys.of(i));
        }
        long endTime = System.nanoTime();
        System.out.println(endTime - beginTime);
    }

    public static void main(String[] args) {
        for(int i=10;i<= 1000 0000;i*= 10){
            test(i);
        }
    }

在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:

通过观测测试结果可知,JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。

Hash极不均匀的情况

假设我们又一个非常差的Key,它们所有的实例都返回相同的hashCode值。这是使用HashMap最坏的情况。代码修改如下:

class Key implements Comparable<Key> {

    //...

    @Override
    public int hashCode() {
        return 1;
    }
}

仍然执行main方法,得出的结果如下表所示:

从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。

小结

(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。

(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。

(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。

(4) JDK1.8引入红黑树大程度优化了HashMap的性能。

参考

Java 8系列之重新认识HashMap https://zhuanlan.zhihu.com/p/21673805

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

1.HashMap原理,内部数据结构?

底层使用哈希表(数组加链表)来存储,链表过长会将链表转成红黑树,以实现在O(logn)时间复杂度内查找

2.讲一下HashMap中的put方法过程?

对key求哈希值然后计算下标
如果没有哈希碰撞则直接放入槽中
如果碰撞了以链表的形式链接到后面
如果链表长度超过阈值(默认阈值是8),就把链表转成红黑树
如果节点已存在就替换旧值
如果槽满了(容量*加载因子),就需要resize

3.HashMap中哈希函数是怎么实现的?还有哪些hash实现方式?

高16bit不变,低16bit和高16bit做异或
(n-1)&hash获得下标
还有哪些哈希实现方式?(查资料和博客)

4.HashMap如何解决冲突,讲一下扩容过程。如果一个值在原数组中,扩容后移动到了新数组,位置肯定改变了,如何定位到这个值在新数组中的位置?

将节点加到链表后
容量扩充为原来的两倍,然后对每个节点重新计算哈希值
这个值只可能在两个地方:一种是在原下标位置,另一种是在下标为<原下标+原容量>的位置

5.抛开HashMap,哈希冲突有哪些解决方法?

开放地址法,链地址法

6.针对HashMap中某个Entry链太长,查找的时间复杂度可能达到O(n),如何优化?

将链表转为红黑树,JDK1.8已经实现

1.HashMap原理,内部数据结构?

底层使用哈希表(数组加链表)来存储,链表过长会将链表转成红黑树,以实现在O(logn)时间复杂度内查找

2.讲一下HashMap中的put方法过程?

对key求哈希值然后计算下标
如果没有哈希碰撞则直接放入槽中
如果碰撞了以链表的形式链接到后面
如果链表长度超过阈值(默认阈值是8),就把链表转成红黑树
如果节点已存在就替换旧值
如果槽满了(容量*加载因子),就需要resize

3.HashMap中哈希函数是怎么实现的?还有哪些hash实现方式?

高16bit不变,低16bit和高16bit做异或
(n-1)&hash获得下标
还有哪些哈希实现方式?(查资料和博客)

4.HashMap如何解决冲突,讲一下扩容过程。如果一个值在原数组中,扩容后移动到了新数组,位置肯定改变了,如何定位到这个值在新数组中的位置?

将节点加到链表后
容量扩充为原来的两倍,然后对每个节点重新计算哈希值
这个值只可能在两个地方:一种是在原下标位置,另一种是在下标为<原下标+原容量>的位置

5.抛开HashMap,哈希冲突有哪些解决方法?

开放地址法,链地址法

6.针对HashMap中某个Entry链太长,查找的时间复杂度可能达到O(n),如何优化?

将链表转为红黑树,JDK1.8已经实现

猜你喜欢

转载自www.cnblogs.com/xiaosisong/p/12290251.html