面试4-Java常见集合源码HashMap

基于jdk1.8

1、浅析

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    	//默认数组容量16。左移4位,也即2的4次方
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
        //负载因子
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
        //数组
        transient Node<K,V>[] table;
        //HashMap 中实际存在的键值对数量
        transient int size;
        //记录 HashMap 内部结构发生变化的次数
        transient int modCount;
...
...
    }

HashMap 底层数据结构是数组 + 链表 + 红黑树
1、数组的主要作用是方便快速查找,时间复杂度是 O(1),默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素叫做 Node,当多个 key 的 hashcode 一致,但 key 值不相同时,单个 Node 就会转化为链表。
2、链表的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化为红黑树。
3、红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。

2、HashMap中数组大小的设计

在 HashMap 中,数组的长度大小必须是 2 的 n 次方,这是一种非常规的设计。常规来说是把数组的大小设计为素数。相对来说,素数导致冲突的概率要小于非素数。HashTable 初始化桶的大小为 11,就是把桶大小设计为素数的应用。HashMap 采用这种非常规的设计,主要是为了在取模扩容做优化,同时为了减少冲突,HashMap 定位数组索引位置时,也加入了高位参与运算的过程。(使用位运算)

3、红黑树的引入

这里存在一个问题,即使负载因子和 Hash 算法设计的再合理,也免不了会出现链表过长的情况,一旦出现链表过长,则会严重影响 HashMap 的性能。于是,在 JDK 1.8 版本中,对数据结构作了进一步优化,当链表长度大于等于 8 并且数组长度大于等于 64 时,链表就转换为红黑树(二分搜索树),利用红黑树快速增删改差的特点提高 HashMap 的性能,从 O(n) 到 O(log n)。如果数组长度小于 64,则只会扩容不会树化。

为什么是 8 呢?这个答案在源码中注释又说到。大概意思就是,在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多时,才会转化成红黑树,但红黑树的占用空间是链表的 2 倍,考虑到转化时间和空间消耗,所以我们需要定义出转化的边界值。

(1)HashMap 开头源码中给出的8的设计注释

     //由泊松分布概率函数下:
     
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

当链表长度是 8 的时候,出现的概率是 0.000000006,不到千万分之一,所以说,正常情况下,链表的长度不可能到达 8,而一旦到达了 8,肯定是 hash 算法出了问题。

4、HashMap put设计

 public V put(K key, V value) {
        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) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length; //1、获取数组长度
            // 2、计算数组索引((n - 1) & hash)
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
           ...
           ...
    }

如上是put源码:

(1)数组索引的计算设计

1、我们首先想到的取模法,也即通过 hash值对数组长度取模得到索引,而且最好取模一个素数,这样索引的分布相对来说也是比较均匀的,但是模运算的消耗还是比较大的,而采用 hash & (n - 1) ,当 n 为 2 的次方时,(n - 1) & hash 等价于取模运算即(n - 1) & hash = hash % n,但是 & 比 % 具有更高的效率。

(2)hash方法

static final int hash(Object key) {
        int h;
        //首先计算出key的hashCode值 h
        h = key.hashCode();
        //h >>> 16   代表无符号右移16位
        return (key == null) ? 0 : h^ (h >>> 16); //h异或h右移16位的值
    }

取 hash 的时候通过 hashCode() 的高十六位和低十六位异或得到,这样做在数组长度比较小的时候也能保证高地位 bit 都能参与到 hash 的计算中,同时不会有太大开销。

在这里插入图片描述

(3)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;
        //1.tab 为空,则创建一个tab数组
        if ((tab = table) == null || (n = tab.length) == 0)
           // n 为数组长度
            n = (tab = resize()).length;
        //2.计算下标
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //3.如果存在节点 key,直接覆盖 value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //4.如果是红黑树
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //5.链表插入
                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
                            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;
        //6.如果超过最大容量,则扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

待续。。。

1、首先判断数组,当数组为空或者数组未申请空间时进行扩容处理
2、根据元素的key计算数组索引
3、往指定索引位置放元素,放之前先判断这个索引位置是否有元素?

  • 有元素:判断key是否存在?

(1) key 已存在:直接覆盖
(2) key 不存在:table[i] 是否为红黑树?

  • 红黑树:直接插入红黑树
  • 非红黑树:遍历链表

若不存在元素:则插入末尾并判断链表大小是否大于8且数组容量是否大于64。不满足时只扩容不树化。
若存在元素:则覆盖

  • 无元素:直接插入

4、是否需要扩容
5、结束

(4)扩容

 final Node<K,V>[] resize() {

  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) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                     // 左移1位,代表 *2
                newThr = oldThr << 1; // double threshold
        }
}
...
}

扩容的时候容量翻倍,也就是 x2,这也同时保证了长度依旧是 2 的次方,所以前面基于 (n - 1) & hash 取索引的优化得以保留。

既然数组长度改变了,那么肯定的重新计算索引位置呀?

jdk1.8这里又有一个优化点,当 n 为 2 次方时,x2 只不过是在高位补个 1,然后在进行与运算时,hash 的低位保持不变,高位是 1 得 1,是 0 得 0,也就是说 hash 高位为 1,索引就变成了原索引再加上旧桶值;高位为 0,索引就和原索引一致。这也就避免重新 (n - 1) & hash 操作获取索引,只需要看 hash 的高位是 1 还是 0 即可。

在这里插入图片描述
总结:所以,可以把 HashMap 的优化都归功于桶长度为 2 的次方。

参考文章

小结

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

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

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

面试题

1、为啥负载因子是0.75?初始化临界值是12?

HashMap中的threshold(阈值)是HashMap所能容纳键值对的最大值。计算公式为length*LoadFactory。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数也越大。

参数负载因子:默认0.75,当负载因子较大时,给hash表扩容的可能性就会减少,所以数组相对占用空间就会较少,但是每条entry链上(单链表)的元素会相对较多,查询的时间也会增长(时间上较多)。反之相反。所以负载因子是一个时间和空间上折中的说法。自己设计时看自己追求的是时间上还是空间上合理选择即可。一般使用默认0.75即可。这个是通过背后多重计算检验得到的可靠值。

参考文章

2、HashMap key可为null吗?value呢?

key可为空,value也可为空.
重复的key对应的value值会被后者覆盖

  Map<String, String> map = new HashMap<>();
        map.put(null, "aaa");
        map.put(null, null);
        map.put("Tom", null);

        Set<String> keys = map.keySet();
        for (String key : keys) {
            System.out.println("key:"+key+" value:"+map.get(key));
   
       }
--------------      
log:

key:null value:null
key:Tom value:null

可参考:

3、平时在使用HashMap时一般使用什么类型的元素作为Key?

面试者通常会回答,使用String或者Integer这样的类。这个时候可以继续追问为什么使用String、Integer呢?这些类有什么特点?如果面试者有很好的思考,可以回答出这些类是Immutable的,并且这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的,而且可以很好的优化比如可以缓存hash值,避免重复计算等等,那么基本上这道题算是过关了。

4、如果让你实现一个自定义的class作为HashMap的key该如何实现?

这个问题其实隐藏着几个知识点,覆写hashCode以及equals方法应该遵循的原则,在jdk文档以及《effective java》中都有明确的描述。当然这也在考察应聘者是如何自己实现一个Immutable类。如果面试者这个问题也能回答得很好,基本上可以获得一点面试官的好感了。

5、为什么要在数组长度大于64之后,链表才会进化为红黑树

在数组比较小时如果出现红黑树结构,反而会降低效率,而红黑树需要进行左旋右旋,变色,这些操作来保持平衡,同时数组长度小于64时,搜索时间相对要快些,总之是为了加快搜索速度,提高性能。

6、一般用什么作为HashMap的key?

一般用Integer、String这种不可变类当HashMap当key。因为String是不可变的,当创建字符串时,它的hashcode被缓存下来,不需要再次计算,相对于其他对象更快。

因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类很规范的重写了hashCode()以及equals()方法。

7、HashMap为什么线程不安全?

多线程下扩容死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题

多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK1.7和JDK1.8中都存在

put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题,此问题在JDK1.7和JDK1.8中都存在。

HashTable是线程安全的,相对hashmap其方法上都进行了加锁处理。

8、计算hash值时为什么要让低16bit和高16bit进行异或处理

如果我们不对hashCode进行按位异或,直接将hash和length-1进行按位与运算就有可能出现以下的情况

如果下一次生成的hashCode值高位起伏很大,而低位几乎没有变化时,高位无法参与运算可以看到,两次计算出的hash相等,产生了hash冲突所以无符号右移16位的目的是使高混乱度地区与地混乱度地区做一个中和,提高低位的随机性,减少哈希冲突

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值