从源码深入理解HashMap(附加HashMap面试题)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/swpu_ocean/article/details/85048608

HashMap向来是面试中的热点话题,深入理解了HashMap的底层实现后,才能更好的掌握它,也有利于在项目中更加灵活的使用。

本文基于JDK8进行解析

一、HashMap解析

1. 结构

HashMap结构由数组加**链表(或红黑树)**构成。主干是Entry数组,Entry是HashMap的基本单位,每一个Entry包含key-value键值对。每个Entry可以看成是一个链表(或红黑树),但是比较特殊的是,当链表中的节点个数超过8时,链表会转为红黑树进行元素存储。

####2. 原理
HashMap中的每个数通过哈希函数计算得出的hash值作为数组下标,存储着一对key-value键值对。当对一个元素进行查找时,若该下标处Entry为空,则表示元素不存在,否则通过遍历链表方式进行查找。

哈希冲突:哈希冲突也叫哈希碰撞,当对两个不同数进行hash函数计算后可能会得到相同的hash值,即存入数组下标相同,此时就发生了碰撞,hashMap使用链地址法存储具有相同hash值的元素。

除此之外,解决hash冲突的办法还有开放地址法(发生hash冲突,寻找下一块未被占用的存储地址)、再散列函数法(对求得的hash值再进行一遍hash运算)。

注意:HashMap是线程不安全,当多个线程进行put操作时,可能会造成put死循环。

3. 源码分析

HashMap位于java.util.HashMap

部分成员变量
//默认数组大小16,即2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//数组最大容量,2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

//当链表转换为红黑树时,若数组大小小于64,即为32、16或更小时,需要对数组进行扩容
static final int MIN_TREEIFY_CAPACITY = 64;

//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//用于判断数组是否需要进行扩容,即当数组元素 >= 数组容量*负载因子时,需要对数组进行扩容
int threshold;

HashMap默认构造器大小为16,并且要求数组长度必须为2的次幂。HashMap中有负载因子,默认值为0.75.当HashMap中元素个数超过数组长度*0.75时,会对数组进行扩容,扩容后会重新计算每个元素的哈希值,即涉及到数组元素的迁移。

Node节点

在JDK7中使用Entry表示数组中的每个元素,JDK8中使用Node,这两者并没有什么区别,为了更好的理解数组中的每个元素,下面将以Node进行描述。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
		...
}

每一个节点处都存有key的hash值、key、value和next链四个属性,其中next链用来指向相同hash值不同key值的下一个节点。

put操作
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //table为空则创建
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通过hash值找到对应的数组下标,如果该下标处元素为空,则直接插入值
        //通过(n - 1) & hash运算,可以保证计算出的下标在数组大小范围内
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //该下标处元素不为空
        else {
            Node<K,V> e; K k;
            //判断数组下标处的元素是否就是要插入的元素,判断key值是否相等
            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);
                        //当链表中节点数超过8个时,将链表转换成红黑树存储
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //存在该key则跳出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //e != null表示存在与插入值相同的key,直接进行覆盖
            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;
    }

put流程:

  1. 判断数组是否为空,为空则初始化数组
  2. 计算该key的hash值作为key存入数组的下标
  3. 判断该下标处有无元素,没有则直接插入
  4. 若有元素存在,则首先判断该元素是否为待插入的元素,是则直接覆盖,否则判断该元素是否为红黑树的节点,是则进行红黑树的插入,否则遍历链表进行插入,如果遍历过程中找到key相同的元素则替换,否则在链表尾部插入该节点

在代码93行可以发现,在put操作完成之后需要对数组大小进行判断,若超过数组容量阈值,则需要对数组进行扩容。

treeifyBin操作

JDK8之前对于数组元素以链表的形式存储,对于数组的索引通过hash值可以直接定位,时间复杂度为O(1),因此对于HashMap查询的时间复杂度取决于遍历链表所花费的时间。当链表长度过长时会对索引带来不必要的麻烦,因此在JDK8开始,采用链表或者红黑树的方式进行相同hash值不同key值元素的存储。

treeifyBin(Node,int)方法则就是当链表长度过长时,将链表转为红黑树进行存储。

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //判断数组长度是否大于64,小于则扩容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
				//将Node节点转换成TreeNode节点
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
get操作
    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;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //首先判断定位到的数组下标的第一个节点是否是要找的节点
            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;
    }

get流程:

  1. 计算key值的hash值
  2. 通过hash值找到数组下标,若该下标元素为空则返回null,否则判断该元素是否是待查找的值,是就直接返回
  3. 若不是判断该元素节点是否为红黑树节点,是则进行红黑树的查找,否则进行链表的查找
resize操作

在构造hash表时如果不指定数组的大小,则数组默认初始化大小为16,当数组元素超过最大容量的75%时就需要对数组进行扩容,而且扩容是件非常耗时的操作。

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) {
            //如果旧数组容量大于等于最大容量,则修改threshold的值,并返回旧数组
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //否则将数组大小扩大为原来的两倍(二进制左移一位),并且将threshold也扩大为原来的两倍
            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) {
            //新数组长度乘以负载因子作为新数组的threshold
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        //创建新数组
        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
                        //创建两条链,lo链跟hi链,也就是将原先本应该在一条链表上的节点如今分成两条链存放
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //将(e.hash & oldCap)计算出值为偶数的放在lo链上
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //奇数的放在hi链上
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //将lo链放在新数组的原位置
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //将hi链放在新数组的原位置+oldCap的下标处
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

总结

HashMap底层通过数组加链表或红黑树(JDK8开始)进行数据存储,当链表中存储过长时会导致查询效率降低,因此当链表长度超过8个时将采用红黑树的方式进行存储。

当数组元素超过数组容量*负载因子大小时,将对数组进行扩容,扩容也可以使得其中的链表长度降低,从而提高查询速度。

对于key值的使用推荐使用不可变类,比如IntegerString等等。因为对于key值的定位是通过计算它的hash值找到在数组中的下标,再通过key的equals方法找到在链表中的位置。因此要求两个key值相等的对象,它们的hash值必须相等,而相同的hash值并不要求一定equals。

二、HashMap面试题

1.HashMap的工作原理,其中get()方法的工作原理?
2.我们能否让HashMap同步?
3.关于HashMap中的哈希冲突(哈希碰撞)以及冲突解决办法?
4.如果HashMap的大小超过负载因子定义的容量会怎么办?
5.你了解重新调整HashMap大小存在什么问题吗?
6.为什么String, Interger这样的wrapper类适合作为键?
7.我们可以使用自定义的对象作为键吗?
8.我们可以使用CocurrentHashMap来代替Hashtable吗?
9.HashMap扩容问题?
10.为什么HashMap是线程不安全的?如何体现出不安全的?
11.能否让HashMap实现线程安全,如何做?
12.HashMap中hash函数是怎么实现的?
13.HashMap什么时候需要重写hashcode和equals方法?

长期收录HashMap面试题,答案也会在下篇文章中给出。
更多了解,还请关注我的个人博客:www.zhyocean.cn

猜你喜欢

转载自blog.csdn.net/swpu_ocean/article/details/85048608