HashMap源码分析系列 -- 第二弹:HashMap的常见方法的实现流程

HashMap源码分析

笔记首页

序号内容链接地址
1HashMap的继承体系,HashMap的内部类,成员变量https://blog.csdn.net/weixin_44141495/article/details/108327490
2HashMap的常见方法的实现流程https://blog.csdn.net/weixin_44141495/article/details/108329558
3HashMap的一些特定算法,常量的分析https://blog.csdn.net/weixin_44141495/article/details/108305494
4HashMap的线程安全问题(1.7和1.8)https://blog.csdn.net/weixin_44141495/article/details/108250160
5HashMap的线程安全问题解决方案https://blog.csdn.net/weixin_44141495/article/details/108420327
6Map的四种遍历方式,以及删除操作https://blog.csdn.net/weixin_44141495/article/details/108329525
7HashMap1.7和1.8的区别https://blog.csdn.net/weixin_44141495/article/details/108402128

HashMap的常见方法的实现流程

HashMap的常见方法的实现流程

Map接口常见方法

我们先看一下Map接口的方法(常见方法)

增删改相关

返回值方法名描述
Vput(K key, V value)将指定的值与该映射中的指定键相关联(可选操作)。
voidputAll(Map<? extends K,? extends V> m)将指定地图的所有映射复制到此映射(可选操作)。
Vremove(Object key)如果存在(从可选的操作),从该地图中删除一个键的映射。
default booleanremove(Object key, Object value)仅当指定的密钥当前映射到指定的值时删除该条目。
default Vreplace(K key, V value)只有当目标映射到某个值时,才能替换指定键的条目。
default booleanreplace(K key, V oldValue, V newValue)仅当当前映射到指定的值时,才能替换指定键的条目。
default voidreplaceAll(BiFunction<? super K,? super V,? extends V> function)将每个条目的值替换为对该条目调用给定函数的结果,直到所有条目都被处理或该函数抛出异常。

查询相关

返回值方法名描述
inthashCode()返回此地图的哈希码值。
intsize()返回此地图中键值映射的数量。
Collection<V>values()返回此地图中包含的值的Collection视图。
booleanisEmpty()如果此地图不包含键值映射,则返回 true
Vget(Object key)返回到指定键所映射的值,或 null如果此映射包含该键的映射。
booleancontainsKey(Object key)如果此映射包含指定键的映射,则返回 true
booleancontainsValue(Object value)如果此地图将一个或多个键映射到指定的值,则返回 true

比较相关

返回值方法名描述
booleanequals(Object o)将指定的对象与此映射进行比较以获得相等性。

转换相关

返回值方法名描述
Set<K>keySet()返回此地图中包含的键的Set视图。
Set<Map.Entry<K,V>>entrySet()返回此地图中包含的映射的Set视图。

HashMap是AbstractMap的子类,AbstractMap以及帮我们写好了很多的方法。

HashMap的常用方法(面向面试题)

Hash算法

我的这篇博文:链接,有对这个算法的讲解。简单的说是保留了高位的性质,这也是面试常问的。

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

我们看到我们在put的时候,用hash算法计算了hash值,将hash,key,value和两个布尔值传入putVal方法

image-20200831192825264

putVal

前三个我们明白的,我们看一下后面两个在HashMap的其他调用场景这两个布尔值分别是什么?

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {

这是构造时传入一个Map容器,这里evict传入了false,onlyIfAbsent传入了true

public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
}

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    	......
        putVal(hash(key), key, value, false, evict);
}

这是putIfAbsent方法,这里都传入了true,我们发现这个参数的名字和这个方法的名字一致,这个方法大致一致是,如果没有这个值,就插入。这个方法应用场景也很广,有时候我们并不是需要覆盖key-value,可以使用这个方法,这样我们就不用去contains判断,再去执行添加操作

 @Override
    public V putIfAbsent(K key, V value) {
        return putVal(hash(key), key, value, true, true);
    }

readObject方法,是将流数据读取到Map中。这个方法是HashMap新增的,如果我们用Map<> map=new HashMap<>();的方式创建Map实例,我们就调用不了这个方法了!不过一般情况下,没有特殊需求,我们的接口指向子类的实例化方法没毛病!

private void readObject(java.io.ObjectInputStream s)
    throws IOException, ClassNotFoundException {
		.......
            putVal(hash(key), key, value, false, false);
        }
    }
}

我们对比一下

方法名putIfAbsentevict
putMapEntriesfalsefalse
putfalsetrue
putIfAbsenttruetrue
readObjectfalsefalse

结论

putIfAbsent表示我们是否替代原值。

evict表示是否是创建过程,因为Map构造和readObject都是put已经写好的元素了!

putVal源码

看看就好,我下面花了一个流程图,方便理解。

简单概括一下:

  • ①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

  • ②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果 table[i]不为空,转向③;

  • ③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

  • ④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

  • ⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

  • ⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

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;
    	//如果对应的位置还没有插入元素
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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
                            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;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

img

与Jdk1.7相比有以下特性

  • 加入红黑树
  • 初始化方法也是resize()扩容
resize方法

代码太长了,看看就好,多结合源码分析,也可以试着看看能不能独立的写出注释。

简单概括:

  • ①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;

  • ②.每次扩展的时候,都是扩展2倍;

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
        if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
            threshold = Integer.MAX_VALUE;
            return oldTab;//返回
        }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
    }
    // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
    // 直接将该值赋给新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的threshold = 新的cap * 0.75
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 计算出新的数组长度后赋给当前成员变量table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
    table = newTab;//将新数组的值复制给旧的hash桶数组
    // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
    if (oldTab != null) {
        // 遍历新数组的所有桶下标
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
                oldTab[j] = null;
                // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
                if (e.next == null)
                    // 用同样的hash映射算法把该元素加入新的数组
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是链表的头并且e.next!=null,那么处理链表中元素重排
                else { // preserve order
                    // loHead,loTail 代表扩容后不用变换下标,见注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表扩容后变换下标,见注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
                                // 代表下标保持不变的链表的头元素
                                loHead = e;
                            else                                
                                // loTail.next指向当前e
                                loTail.next = e;
                            // loTail指向当前的元素e
                            // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
                            // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
                            // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

与Jdk1.7相比

  • jdk1.7是扩容完重新计算hash来计算插入位置,而1.8对此进行了优化。扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。

  • 对比1.7多了一些树化的流程,也减少了方法嵌套,基本上一个方法就搞定了扩容,不过阅读性也比较差。

树化和反树化

大家可以看这一篇,对红黑树树化分析比较全面。https://blog.csdn.net/wildyuhao/article/details/108272239

remove方法

如果删除成功我们返回删除的值value,否则返回null,这个操作非常好用,有时候我们取出来后需要把数据删除,可以应用到转移元素上。

我们看到删除方法也有两个布尔类型的参数,matchValue和movable,我们看看HashMap哪边调用了removeNode方法,和之前putVal的方式一样,以此得到布尔值的含义。

image-20200831202840296

computeIfPresent

  • computeIfAbsent的方法有两个参数 第一个是所选map的key,第二个是需要做的操作。这个方法当key值不存在时才起作用。

  • 当key存在返回当前value值,不存在执行函数并保存到map中

public V computeIfPresent(K key,BiFunction<? super K, ? super V, ? extends V> 											remappingFunction) {
    ......
    removeNode(hash, key, null, false, true);

}

keySet

HashMap有转换为Set的操作,这里也调用了删除方法,布尔值和remove方法一致

final class KeySet extends AbstractSet<K> {

    public final boolean remove(Object key) {
        ......
        return removeNode(hash(key), key, null, false, true) != null;
    }
}

EntrySet

HashMap有转换为Entry<K,V>集合的操作,这里也调用了删除方法,matchValue为true

final class EntrySet extends AbstractSet<Map.Entry<K,V>> {

        public final boolean remove(Object o) {
            ...
            return removeNode(hash(key), key, value, true, true) != null;
}

HashIterator

Hash迭代器,这里的remove方法也调用了removeNode,这里两个布尔值都是false

abstract class HashIterator {
    public final void remove() {
    ……
    removeNode(hash(key), key, null, false, false);
    }
}

初此以外还有很多地方也调用了removeNode,大部分都是 false,true

对比

方法名matchValuemovable
removefalsetrue
keySet.removefalsetrue
EntrySet.removetruetrue
HashIterator.removefalsefalse

区别

matchValue:

  • 我们发现只有EntrySet中是传入了value,所以matchValue表示删除时是否关心value的值,即是否 equals(value),其他方法都是按照key删除,所以传入的是false

movable:

  • 我们知道,我们循环删除HashMap的元素,可能会出现java.util.ConcurrentModificationException: null,这里引入一个优秀的链接[HashMap遍历删除时遇见的坑!!!](java.util.ConcurrentModificationException: null),和循环遍历删除ArrayList是一个道理。
  • 如果是movable=false,那么就不要移动节点,所以我们循环遍历,删除元素,Java推荐我们使用迭代器。
removeNode方法

对比前面几个70~80行的代码,这个算短了。

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;
        //这个p在上面的if里面赋值了,如果当前值等于我们我们要删除的值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //标记我们要删除的值
            node = p;
        //如果如果当前位置不等于我们我们要删除的值,说明要删除的值可能在这个node的后面
        else if ((e = p.next) != null) {
            //如果我们要删除的key对应桶的位置是个树节点
            if (p instanceof TreeNode)
                //调用树的删除方法
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                //循环判断,直至e等于null
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //如果我们之前的node不为空,说明找到了要删除的元素
        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;
}
get方法

相对于remove,我们是拿而不取。实现也比较简单

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
总结
  • HashMap中:removeNode,resize,removeTreeNode,putTreeVal只要这几个方法超过60行的,其他的方法都没这么长,吃透这几个方法,也就吃透了HashMap的一半,当然还有putVal。
  • HashMap除了树化以外,很多地方都进行了优化,尤其是方法都偏向于集中了,而不是各种套娃,估计换了一个很有实力的的团队。
  • HashMap的代码行数是1.7的一倍以上,阅读源码,我们发现HashMap提供了多种转换方式以及内部类,比如keySet,EntrySet,HashIterator等,这里我们要灵活运用,我单独把HashMap的遍历方式整理到这篇博文每日面试题9:Map的四种遍历方式,以及删除操作;
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值