HashMap原理?没有那么难

栏目: 数据库 · 发布时间: 5年前

内容简介:相信大多数朋友都使用过HashMap,面试也经常会被问到,但往往都回答的都不尽人意,确实,HashMap还算是比较复杂的一个数据结构,尤其是在JDK1.8之后又引入了红黑树之后。本文就基于JDK1.8的HashMap源码,带大家将常用方法、重要属性及相关方法进行分析,HashMap 源码中可分析的点很多,本文很难一一覆盖,请见谅。HashMap是基于hash算法实现的,也就是不同于数组,每次添加数据时,下标自增的操作,而是根据Key的hash值以及数组的长度计算出对应的下标,放入元素,那么在查找的时候就直接

相信大多数朋友都使用过HashMap,面试也经常会被问到,但往往都回答的都不尽人意,确实,HashMap还算是比较复杂的一个数据结构,尤其是在JDK1.8之后又引入了红黑树之后。本文就基于JDK1.8的HashMap源码,带大家将常用方法、重要属性及相关方法进行分析,HashMap 源码中可分析的点很多,本文很难一一覆盖,请见谅。

本文篇幅较长,请客官耐心观看

如果本文中有不正确的结论、说法,请大家提出和我讨论,共同进步,谢谢。

1.原理

HashMap是基于hash算法实现的,也就是不同于数组,每次添加数据时,下标自增的操作,而是根据Key的hash值以及数组的长度计算出对应的下标,放入元素,那么在查找的时候就直接能够定位到对应的元素,如果在没有hash冲突的时候,时间复杂度基本就是O(1)了,引用一张图大致整体看下HashMap的数据结构。

HashMap原理?没有那么难

1.1 hash冲突

有朋友可能就会有疑惑了,那当元素越来越多的时候,就算通过hash算法计算,那万一两个元素计算出的下标一样呢?那后面的元素往哪放?这里采用的是链表的形式,当发生hash冲突的时候,第一个元素直接指向第二个元素,再有hash冲突元素时,直接插到链表尾部,这样形成一条链。

那么如果冲突的元素很多,那么链表岂不是会很长,因为我们知道链表查询是效率很低的,需要一个一个的遍历,那么在JDK1.8中,当链表长度超过一定阈值时,直接进行数据结构转换,将链表转化成红黑树,红黑树是一种平衡二叉树,时间复杂度是O(logn),具体红黑树的原理就不分析了,不在此文章范围内。

1.2 扩容

从上面分析,我们也可以看明白,HashMap的数据结构是由数组和链表(或树形结构)组成,所以本质还是由数组开始,我们知道数组是需要提前知道容量的,比如初始位10,那么当元素越来越多,因为下标范围是0-9,所以hash冲突会越来越多,这样形成很多链表或者树,查询时效率非常低,这时候就需要扩容了,也就是扩大原有数组的长度,至于扩多大,什么时候该扩容,下面分析源码时,将一一给大家讲解,但是我们要注意的一点是, 扩容是需要再次Hash的,为什么呢,因为hash算法是hashCode取余数组长度,所以必须要再次Hash确定每个元素的位置

1.3 hashCode

hash算法是基于key的hashcode方法的,hashcode是object的方法,每个对象都可以进行复写,这里就衍生出一个问题,什么类适合作为更适合作为HashMap的键?答案是String, Interger这样的wrapper类, 因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象,而且比较安全,碰撞的几率就会小些,这样就能提高HashMap的性能。

2 源码

2.1 初始化

老规矩,先上构造方法总是没错的

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
	public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
复制代码

可以看到重载了4个构造方法,我们大多数基本用的就是第一个无参方法,其他的几个方法也是做一些初始化操作,主要关心这几个变量:

名称 用途
initialCapacity HashMap 初始容量
loadFactor 负载因子
threshold 当前 HashMap 所能容纳键值对数量的最大值,超过这个值,则需扩容

HashMap 初始容量是16,负载因子为 0.75, 但是有的朋友会细心发现,第一个构造方法,摆明就只是赋值了负载因子,初始容量和阈值都没有被初始化,这里先不解释,后面扩容机制会告诉你答案 ,然后看最后一个构造函数,我们可以把初始容量和负载因子作为值传递进来,threshold是通过一个方法计算出来的,看看方法具体实现:

/**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
复制代码

相信大家和我一样,第一次看到这个方法是蒙蔽的....先把结论给出来:找到大于或等于 cap 的最小2的幂,这里引用一张图解释下,侵删:

HashMap原理?没有那么难

比如cap等于5,那么最终返回的就是8,如果cap等于10,返回的就是16,这样一说大家结合上面的应该能理解了。

2.1 插入

插入逻辑算是比较复杂的了,我们先来看看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算法找到下标,如果对应的位置为空,直接将数据放进去
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
           	//对应的位置不为空,hash冲突 
            Node<K,V> e; K k;
            //判断插入的key如果等于当前位置的key的话,先将 e 指向该键值对,后续覆盖
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
            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) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果链表中包含该节点,赋值,后续覆盖,跳出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //判断插入的是否存在HashMap中,上面e被赋值,不为空,则说明存在,更新旧的键值对
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //当前HashMap键值对超过阈值时,进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
复制代码

可以看到主要逻辑在putVal()方法中,不清楚的可以看下注释,总结一下主要是几个方面:

  • 如果当前table为空,先进行初始化
  • 查找插入的键值对是否存在,存在的话,先进行赋值,后续将更新旧的键值对
  • 不存在,插入链表尾部,如果链表长度大于一个阈值,进行链表转化树的操作
  • 如果size大于一个阈值,进行扩容

那么重点当然就是扩容方法了,看看具体实现:

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;
            }
            //通过位运算,计算出新的容量以及新的阈值,2倍计算
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
       //使用 threshold 变量暂时保存 initialCapacity 参数的值
        else if (oldThr > 0) 
            newCap = oldThr;
        else {
            //这里就能回答上面的初始化的问题了,调用空的构造函数时的赋值
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
    	// newThr 为 0 时,按阈值计算公式进行计算,容量*负载因子
        if (newThr == 0) {
            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 {
                        //遍历整个链表,重新hash,根据新的下标重新分组
                        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;
    }
复制代码

代码稍微长了点,大家耐心点看下逻辑,总结也就几点

  • 判断当前oldTab长度是否为空,如果为空,则进行初始化桶数组,也就回答了 空构造函数初始化为什么没有对容量和阈值进行辅助 ,如果不为空,则进行位运算,左移一位,2倍运算。
  • 扩容,创建一个新容量的数组,遍历旧的数组:
    • 如果节点为空,直接赋值插入
    • 如果节点为红黑树,则需要进行进行拆分操作
    • 如果为链表,根据hash算法进行重新计算下标,将链表进行拆分分组

这里主要说明下链表拆分是什么意思, 我们知道下标计算是hash&(n-1),假如原始数组长度为16,进行求余计算:那么n-1也就是15,对应二进制 0000 1111,这时候分别有2个hash值分别为:1101 1100和1110 1100,计算可以得到,得到的下标都是0000 1100,也就是12,如果进行扩容之后呢?长度变成32,n-1也就对应 0001 1111,2个hash再次进行计算得到的就是 0001 1100 和 0000 1100,一个下标还是12,而另一个则是28了

可以看到扩容后,参与模运算的位数由4位变为了5位,所以对应得出来的值自然就不一样了,相信大家也应该理解了

2.2 查找

相对于复杂的插入操作,查找的逻辑相对就相对简单点了,代码如下:

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) {
                //如果第一个节点是TreeNode类型,去遍历红黑树
                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;
    }
复制代码

上面也提到了,通过 (n - 1) & hash 即可算出在数组中的位置,这里简单解释一下。HashMap 中桶数组的大小 length 总是2的幂,此时, (n - 1) & hash 等价于对 length 取余。但取余的计算效率没有位运算高,所以 (n - 1) & hash 也是一个小的优化

还有一个计算hash值得方法

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

可以看到,这里的hash并不是用原有对象的hashcode最为最终的hash值,而是做了一定位运行,具体原因个人想法如下:

因为如果 (n-1)的值太小的话(n - 1) & hash 的值就完全依靠hash的低位值,比如 n-1 为0000 1111,那么最终的值就完全依赖于hash值的低4位了,这样的话hash的高位就玩完全失去了作用, h ^ (h >>> 16) ,通过这种方式,让高位数据与低位数据进行异或,也是变相的加大了hash的随机性,这样就不单纯的依赖对象的hashcode方法了。

2.3 删除

有了前面一些铺垫,删除操作也并不复杂

public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        //和之前的判断一样
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            //如果键的值与链表第一个节点相等,则将 node 指向该节点
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                //如果是TreeNode类型,指向该节点
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    //遍历链表,找到该节点
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            //通过节点类型进行删除操作
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }
复制代码

相信有了之前的基础,这里理解就不困难了,具体实现就不多说了,有兴趣的朋友可以深入源码看下

3.总结

大致分析就到一段落了,这里总结下几个问题,希望能够帮助到大家一些面试过程。

  • HashMap底层数据结构由数组+链表+红黑树实现(JDK1.8),通过键 key 经过扰动函数扰动后得到 hash 值,然后再通过 hash & (length - 1) 代替取模的方式进行元素定位,查找效率最好情况是O(1)
  • Hash冲突是指不同对象的hashCode通过hash算法后得出了相同定位的下标,这时候采用链地址法,会将此元素插入至此位置链表的最后一位,形成单链表。当存在位置的链表长度 大于等于 8 并且当前数组容量超过64时,HashMap会将链表 转变为 红黑树,这里要说明一点,往往后者的条件会被大多数人忽略, 当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。容量小时,优先扩容可以避免一些列的不必要的树化过程。
  • HashMap的容量是2的n次方,有利于提高计算元素存放位置时的效率,也降低了hash冲突的几率,从上面代码分析我们也能看出来,就算传递进来一个不是2次方的数,内部也会通过位运算找到大于或等于 cap 的最小2的幂,来设置给容器。
  • 在使用HashMap的时候,尽量的选择不可变的对象作为key,避免对象的改变引起hash的变化,导致数据的不准确性
  • HashMap是非线程安全的,在多线程的操作下会存在异常情况,可以使用HashTable或者ConcurrentHashMap进行代替

以上所述就是小编给大家介绍的《HashMap原理?没有那么难》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Convergence Culture

Convergence Culture

Henry Jenkins / NYU Press / 2006-08-01 / USD 30.00

"Convergence Culture" maps a new territory: where old and new media intersect, where grassroots and corporate media collide, where the power of the media producer, and the power of the consumer intera......一起来看看 《Convergence Culture》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试