• 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)为什么在JDK1.7的时候是先进行扩容后进行插入,而在JDK1.8的时候则是先插入后进行扩容的呢?

    (2)为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢(面试蘑菇街问过)?

    哈希表如何解决Hash冲突?

     

     为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

     为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

     HashMap 中的 key若 Object类型, 则需实现哪些方法?

  • 相关阅读:
    【QT】Linux下安装QT开发环境
    【C#】Winform嵌入dll到exe中
    使用maven插件构建docker镜像并推送到阿里镜像仓库
    蓝牙耳机连接 win10音量异常
    max virtual memory areas vm.max_map_count [65530] is too low
    docker 安装 confluence
    jenkins docker push脚本
    docker 删除指定名称的所有容器
    docker 安装 xxl-job-admin
    docker 安装 sentinel
  • 原文地址:https://www.cnblogs.com/xiaosisong/p/12290251.html
Copyright © 2020-2023  润新知