前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文吃透hashmap的前世与今生

一文吃透hashmap的前世与今生

作者头像
柏炎
发布2022-08-23 14:10:43
2530
发布2022-08-23 14:10:43
举报
文章被收录于专栏:深入浅出java后端

前言

hello,everyone。五一是不是都过得很开心,开始上班是不是有一种恍恍惚惚的感觉,还沉浸在放假当中。今天将给大家介绍的是hashmap,这是日常工作中使用频率最高,面试官最喜欢问的java数据结构之一。不知道大家是不是之前对hashmap【文中无特殊指出,均基于JDK1.8】有过了解,或者阅读过源码,都可以看看这篇文章,希望能给大家带来帮助。如果文中有错误解读之处,也请大家指出~

一.hashmap介绍

学习一个知识之前我们首先要先知道,这个知识对我们的工作与生活有什么用。本文将系统的介绍hashmap的原理。

那么hashmap是什么呢?

Hashmap是一种java中键值对的数据结构,通过key-value形式存储数据。通过key值可以找到对应的value值,类似于字典,通过拼音找到对应的汉字。

二.关键概念介绍

hashmap中有比较多的一些概念,为了照顾一些初学java或者对数据结构不太了解的朋友,这里贴一下一篇博客中的概念介绍

1.数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

2.线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

3.二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

4.哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下(后面会探讨下哈希冲突的情况),仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。    这个函数可以简单描述为:存储位置 = f(关键字) ,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作: 插入过程如下图所示

哈希表数据插入过程
哈希表数据插入过程

查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。 5.哈希冲突 然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。 来源:https://blog.csdn.net/woshimaxiao1/article/details/83661464

得知hashmap的关键基础概念之后,我们来深入解析以下haspmap到底是个什么东西。

三.关键成员变量

3.1.成员变量说明

代码语言:javascript
复制
//构造hashmap时,默认初始化容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

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

//默认扩容的扩展因子,当hashmap中的元素个数达到当前容量的75%时,触发扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//hashmap数组节点上链表转换为红黑的的阈值,链表节点达到8个时转换为红黑树【注意:这里不是绝对,切往后看】
static final int TREEIFY_THRESHOLD = 8;

//链表节点数小于6个时,从红黑树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;

//链表转化为红黑的第二个要求,与TREEIFY_THRESHOLD对应,最小的链转树的数组大小。
static final int MIN_TREEIFY_CAPACITY = 64;

//当前已经存在于hashmap中key-value的个数,与容量不同。
transient int size;

//记录hashmap节点修改次数,这个字段用于保障在for循环遍历hashmap时,不可以对hashmap里面的数据发生结构性改变,如删除其中一个key-value,会导致fast-fail【抛出ConcurrentModificationException】,正确的方式是使用迭代器遍历删除。
transient int modCount;

//下个容器的容量,初始化时将会使得容器为2的背书,比如输入容量为11,则容器的初始化大小为16,详见HashMap#tableSizeFor(int cap)方法
int threshold;

//当前hashmap的扩容因子,默认值为DEFAULT_LOAD_FACTOR
final float loadFactor;

3.1.1.modCount变量说明

modCount使用来记录遍历hashmap的过程中,hashmap被修改的次数,来看一下modCount的遍历hashmap时的作用

错误示范

代码语言:javascript
复制
public static void main(String[] args) {
    HashMap<Object, Object> map = new HashMap<>(15);
    for (int i = 0; i < 100; i++) {
        map.put(i,i);
    }
    for (Map.Entry child:map.entrySet()){
        if(Objects.equals(child.getKey(),5)){
            map.remove(child.getKey());
        }
    }
    System.out.println(map.size());
}

控制台输出
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.HashMap$HashIterator.nextNode(HashMap.java:1445)
    at java.util.HashMap$EntryIterator.next(HashMap.java:1479)
    at java.util.HashMap$EntryIterator.next(HashMap.java:1477)
    at com.examp.util.HashmapDemo.main(HashmapDemo.java:19)

正确方式

代码语言:javascript
复制
public static void main(String[] args) {
    HashMap<Object, Object> map = new HashMap<>(15);
    for (int i = 0; i < 3; i++) {
        map.put(i,i);
    }
    Iterator<Map.Entry<Object, Object>> iterator = map.entrySet().iterator();
    while (iterator.hasNext()){
        Map.Entry<Object, Object> entry = iterator.next();
        //判断条件,value,可以用entry.getKey进行判断Key值
        if(entry.getValue().equals(1)){
            //删除
            iterator.remove();
        }
        //删除后输出发现并没有立即删除掉,在第二次循环进入后会发现元素已删,
        //因为立即删掉的是it迭代器中的,第二次循环进入后重新获取长度,这也是
        //为什么要使用迭代器删除的原因
        System.out.println(map.toString());
   }
    System.out.println(map.size());
}

控制台输出
{0=0, 1=1, 2=2}
{0=0, 2=2}
{0=0, 2=2}
2

解析

根据第一个错误示范的控制台输出点击remove方法进去

代码语言:javascript
复制
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 &amp;&amp; (n = tab.length) > 0 &amp;&amp;
        (p = tab[index = (n - 1) &amp; hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &amp;&amp;
            ((k = p.key) == key || (key != null &amp;&amp; key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &amp;&amp;
                        ((k = e.key) == key ||
                         (key != null &amp;&amp; key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null &amp;&amp; (!matchValue || (v = node.value) == value ||
                             (value != null &amp;&amp; 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
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

我们发现在remove方法执行之后,修改了modCount的值,然后在for循环遍历时,遍历到下一个元素时

代码语言:javascript
复制
final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    //进行比对
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    if ((next = (current = e).next) == null &amp;&amp; (t = table) != null) {
        do {} while (index < t.length &amp;&amp; (next = t[index++]) == null);
    }
    return e;
}

发现在元素遍历过程中,hashmap的结构性发生了改变产生了报错。

而使用迭代器方法进行remove时

代码语言:javascript
复制
public final void remove() {
    Node<K,V> p = current;
    if (p == null)
        throw new IllegalStateException();
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    current = null;
    K key = p.key;
    removeNode(hash(key), key, null, false, false);
    expectedModCount = modCount;
}

迭代器对hashmap进行操作时,则会对expectedModCount重新赋值,不会报错

那么开发hashmap的人为什么要使用这种机制呢?

回到迭代器的代码,迭代器对expectedModCount重新赋值,因为hashmap是线程不安全,循环的时候无法保证其他线程来修改map的数据结构。迭代器重新赋值了expectedModCount值后,其他线程则会立即检测到数据已经被修改,快速失败。

ps:这种机制我觉得也并没什么ruan用,就是多线程并发我直接给你报错,但是我想要的是数据可以按照先后顺序进行赋值,而且我更改的可能都不是一个key,你这就给我直接失败了,太不人性化了。所以说多线程并发的情况下为了数据能够正确的更新,推荐使用currentHashMap,这个后续有时间了再给大家介绍

四.关键方法解析

hashmap中关键方法为put与get方法,是我们日常工作,学习中95%以上的使用场景。下文将从这两个方法入手探讨hashmap的原理。

4.1.put方法

老规矩先上源码

代码语言:javascript
复制
//put方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
代码语言:javascript
复制
//key的hash值计算
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
代码语言:javascript
复制
/**
* 真正的put方法
*
* @param hash key的hash值
* @param key key值
* @param value value值
* @param onlyIfAbsent 如果为true,则当map中已经存在对应的key,则不进行修改value
* @param evict 如果为false,则表示当前hashmap处于初始构建模式(用于构建LinkedHashMap,此文不做讨论)
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //如果hashmap为空则进行新建,初始化容量
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //如果hash定位到的数组位为空则新建一个节点直接放置
    if ((p = tab[i = (n - 1) &amp; hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //如果数组节点上的hash值相等则进行数组值替换
        if (p.hash == hash &amp;&amp;
            ((k = p.key) == key || (key != null &amp;&amp; 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) {
                    //寻找到尾部key值如果不存在,则进行节点新建,尾插
                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;
                }
                //链表遍历找到相同节点则进行链表节点覆盖
                if (e.hash == hash &amp;&amp;
                    ((k = e.key) == key || (key != null &amp;&amp; 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;
    //容量超过最大容量进行扩容,避免设置扩展因子为1时,容量超出范围
    if (++size > threshold)
        resize();
    //用于构建LinkedHashMap,此文不做解析
    afterNodeInsertion(evict);
    return null;
}

4.1.1.树化:treeifyBin

代码语言:javascript
复制
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) &amp; hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            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);
    }
}

树化方法有一个很关键的点

如果数组长度小于最小树化容量(默认数组大小为64时),则优先使用数组扩容,而不是采用转换红黑树节点。

这里是面试官很喜欢的问的点,本菜鸡在早期的面试中也踩过坑,当时光背面经了==。

4.1.2.扩容:resize

代码语言:javascript
复制
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) {
            //如果当前元素已经超过最大容量,则不进行扩容操作,将当前容器最大容量赋值为Integer的最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
         // 没超过最大值,就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &amp;&amp;
                 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);
    }
    // 计算新的resize上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY &amp;&amp; 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) {
    // 把每个bucket都移动到新的buckets中
        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 &amp; (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 &amp; oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                     // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

扩容的过程中,将会让数组的长度扩大至当前容量的2倍,数组与链表上的节点进行重新hash计算,使用尾插法的形式,避免了在resize的过程中在JDK1.7Hashmap中会出现的环形链表情况。感兴趣的同学可以自行百度。

4.2.get方法

代码语言:javascript
复制
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
代码语言:javascript
复制
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //保证数组不为空,并且hash定位到的数组上的节点数据不为空
    if ((tab = table) != null &amp;&amp; (n = tab.length) > 0 &amp;&amp;
        (first = tab[(n - 1) &amp; hash]) != null) {
        //校验定位到的数组节点的链表或者树的第一个节点
        if (first.hash == hash &amp;&amp; // always check first node
        //相同则返回
            ((k = first.key) == key || (key != null &amp;&amp; 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 &amp;&amp;
                    ((k = e.key) == key || (key != null &amp;&amp; key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

4.3.小问题

这里抛出一个我在面试中很喜欢问别人的一个问题,如果重写hashmap的key的hashcode方法,没有重写equals方法,会出现什么问题,反过来呢?

4.3.1.重写hashCode不重写equals方法

重写hashcode方法后,可能导致不同的对象,hashcode的值变为一样了,需要再根据equals去进行比较,在hashmap上的哈希冲突变多了,比较查询到次数也变多了。

4.3.2.重写equals不重写hashcode方法

user这个对象有10个字段,通过判断身份证号我就判定这两个对象是一个人,但是这个对象年龄可能是不同的。这样会出现重写了equals方法,两个对象是相等的。hashcode方法可能不相等。对应到hashmap中,同样是张三对象这个key,我希望他们是做等价替换的,他们却分布在两个不同的数组节点(数组下挂在的链表或者红黑树)上。

五.总结

本文对hashmap中日常工作中高频出现的一些知识点做了介绍,现在做一个简单的总结。

1.扩容因子:扩容因子默认值设置为0.75,这个值的设置是开发hashmap的大佬通过泊松分布计算的到,如果扩容因子太小,则扩容频繁,浪费空间。扩容因子过大,将会导致hash冲突高频发生,导致链表,树化转换频繁,选中节点元素的时间变长。

2.map遍历:遍历的过程中hashmap通过维护modCount来记录hashmap被修改的次数,避免在多线程并发的情况下,hashmap的数据出现混乱,采用了fast-fail机制。多线程并发下使用hashmap推荐使用concurrentHashmap

3.链表转换红黑树:链表转换红黑树时会做校验,当数组长度大于64并且链表节点数目大于8时才会做转换,红黑树节点小于6个时,转换为链表。

4.扩容:扩容机制从JDK1.7的头插法改为尾插法,避免了在扩容过程中可能产生的环形链表问题,每次扩容大小为当前容量的2倍。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-05-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 一.hashmap介绍
  • 二.关键概念介绍
  • 三.关键成员变量
    • 3.1.成员变量说明
      • 3.1.1.modCount变量说明
  • 四.关键方法解析
    • 4.1.put方法
      • 4.1.1.树化:treeifyBin
      • 4.1.2.扩容:resize
    • 4.2.get方法
      • 4.3.小问题
        • 4.3.1.重写hashCode不重写equals方法
        • 4.3.2.重写equals不重写hashcode方法
    • 五.总结
    相关产品与服务
    容器服务
    腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档