hashmap如何去键值对_jdk1.8中的HashMap刨根问底

1.什么是HashMap

HashMap是Map族中最为常用的一种,也是 Java Collection Framework 的重要成员。在开发工程师的面试中,HashMap的原理也是最为经常被问到的题目之一。因此,对于其实现原理是非常有必要去深入了解的。

2.HashMap的定义

我们从HashMap的源码中去看HashMap的定义,其定义如下:

public class HashMap extends AbstractMap    implements Map, Cloneable, Serializable

可以看到HashMap继承于AbstractMap,实现了Map, Cloneable, Serializable三个接口。

3.HashMap构造函数

HashMap 提供了四个构造函数:

//构造方法1public HashMap(int initialCapacity) {    this(initialCapacity, DEFAULT_LOAD_FACTOR);}//构造方法2public HashMap() {        this.loadFactor = DEFAULT_LOAD_FACTOR;     }   //构造方法3public 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);    }//构造方法4public HashMap(Map extends K, ? extends V> m) {    this.loadFactor = DEFAULT_LOAD_FACTOR;    putMapEntries(m, false);}

上面的构造函数中用的最多的可能就是构造方法1和构造方法2。构造方法1完全使用默认值,构造方法2初始容量自定义,进而调用构造方法3,构造方法3则是初始容量加载因子自定义。构造方法4则是将另一个Map的额映射拷贝一份到自己的存储结构中,不是很常用。所以我们重点分析一下构造方法3。

构造方法3前面主要是关于一些异常情况的处理,最后调用了tableSizeFor方法。tableSizeFor的功能(不考虑大于最大容量的情况)是返回大于输入参数且最近的2的整数次幂的数。比如10,则返回16。代码如下所示:

    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;    }

我们通过一张图来理解一下:

先来假设n的二进制为01xxxx...xxxx。如下图中第一行

对n右移1位:001xxxx...xxxx,再位或:011xxxx...xxxx

对n右移2为:00011...xxx,再位或:01111...xxx

此时前面已经有四个1了,再右移4位且位或可得8个1

同理,有8个1,右移8位肯定会让后八位也为1。

综上可得,该算法让最高位的1后面的位全变为1。

最后再让结果n+1,即得到了2的整数次幂的值了。

非常巧妙的算法。

8db497145bee0e123df5d4442e9684ef.png

到此构造函数就结束了,但是其实这个时候并没有去分配实际的空间,实际上HashMap一直到插入才会真正去做初始化,这是一种lazy load的方式。关于HashMap的真正的初始化会在put和resize 中介绍。

4.HashMap操作之get

因为HashMap 的get 操作相对put而言会简单很多,因此我们先看get。

源码如下:

public V get(Object key) {    Node e;    return (e = getNode(hash(key), key)) == null ? null : e.value;}final Node getNode(int hash, Object key) {    Node[] tab; Node 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)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;}

查找的真正做事情的函数是getNode函数

这里出现了HashMap的Node结构

    static class Node implements Map.Entry {        final int hash;        final K key;        V value;        Node next;        Node(int hash, K key, V value, Node next) {            this.hash = hash;            this.key = key;            this.value = value;            this.next = next;        }

Node中存储了hash值,key的值,value的值,next引用等等。

接着我们继续看getNode函数,首先计算哈希桶的下标。

first = tab[(n - 1) & hash]

这种方法的目的和 hash % ( n - 1)计算下标的思想是类似的。

但是由于n是2的幂,因此(n - 1)& hash 等价于对哈希桶的长度取模。

c76ee60f6048fde257bba4e6fa2ea350.png

计算完哈希桶的下标之后,查看对应的哈希桶的哈希值是否相同,除此还会比较key是否相同,这里使用== 或者 equals 去比较。

if (first.hash == hash && // always check first node            ((k = first.key) == key || (key != null && key.equals(k))))            return first;

如果对应的哈希桶的不是我们要找的值,就会继续往下查找,首先判断该节点是否是红黑树的节点,如果是就会采用红黑树的查找方法。如果不了解红黑树,可以参考我的另一篇文章: 红黑树的原理详解。

if (first instanceof TreeNode)                return ((TreeNode)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);

到这里为止get就结束了,接下来我们要看put方法。

5.HashMap操作之put

通过对get的分析,大家对 HashMap 底层的数据结构应该有了个初步的认识。接下来,先来看一下插入操作的源码:

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[] tab; Node p; int n, i;    // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化    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 e; K k;        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            e = p;                    // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法        else if (p instanceof TreeNode)              e = ((TreeNode)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;                }                                // 条件为 true,表示当前链表包含要插入的键值对,终止遍历                if (e.hash == hash &&                    ((k = e.key) == key || (key != null && key.equals(k))))                    break;                p = e;            }        }                // 判断要插入的键值对是否存在 HashMap 中        if (e != null) { // existing mapping for key            V oldValue = e.value;            // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值            if (!onlyIfAbsent || oldValue == null)                e.value = value;            afterNodeAccess(e);            return oldValue;        }    }    ++modCount;    // 键值对数量超过阈值时,则进行扩容    if (++size > threshold)        resize();    afterNodeInsertion(evict);    return null;}

插入操作的入口方法是 put(K,V),但核心逻辑在V putVal(int, K, V, boolean, boolean) 方法中。putVal 方法主要做了这么几件事情:

  1. 当桶数组 table 为空时,通过扩容的方式初始化 table
  2. 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
  3. 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
  4. 判断键值对数量是否大于阈值,大于的话则进行扩容操作

首先将table赋值给tab,如果tab=null或者tab的长度为0,说明HashMap还没有初始化,这个时候就会调用resize方法,之前我们提到过这是一种lazy load的方式,就是一直到插入才会真正的去初始化。

        Node[] tab; Node p; int n, i;        if ((tab = table) == null || (n = tab.length) == 0)            n = (tab = resize()).length;

接着通过hash值计算hash桶的下标,如果对应位置是null,说明这个位置还没有元素,就新建键值对节点插入桶中。

        if ((p = tab[i = (n - 1) & hash]) == null)            tab[i] = newNode(hash, key, value, null);

接下来如果对应下标的hash桶已经有元素,那么就会看这个节点的key的哈希值和要插入的hash值是否相同,相同的话还会用==和equals继续比较。当经过比较,两个key相同时,我们就需要把这个插入转化成一个key对应的value的更新问题了。

        Node e; K k;        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            e = p;

接下来如果对应下表的哈希桶是红黑树的节点,那么就需要调用红黑树的插入方法。

        // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法        else if (p instanceof TreeNode)              e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);

接下来就是对链表这种情况的处理了,对链表进行便利查找,看是否有相同的key。

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;}

接下来就是处理存在相同key的情况,因为之前我们在发现有相同的key的时候只是把这个几点赋值给了e,并没有处理。 onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值

        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方法中出现了afterNodeAccess,afterNodeInsertion等函数,实际上在HashMap中只是一个空函数,我们可以忽略它。通过查看源码,可以知道这三个函数是为LinkedHashMap而写的。

    // Callbacks to allow LinkedHashMap post-actions    void afterNodeAccess(Node p) { }    void afterNodeInsertion(boolean evict) { }    void afterNodeRemoval(Node p) { }

6.HashMap操作之resize

前面我们说到HashMap在初始化的时候是lazy load的,一直到put的时候调用resize才真正开始初始化。

在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。

HashMap 的扩容机制与其他变长集合的套路不太一样,HashMap 按当前桶数组长度的2倍进行扩容,阈值也变为原来的2倍(如果计算过程中,阈值溢出归零,则按阈值公式重新计算)。扩容之后,要重新计算键值对的位置,并把它们移动到合适的位置上去。以上就是 HashMap 的扩容大致过程,接下来我们来看看具体的实现:

    final Node[] resize() {        Node[] 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)                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) {            float ft = (float)newCap * loadFactor;            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?                      (int)ft : Integer.MAX_VALUE);        }        threshold = newThr;        @SuppressWarnings({"rawtypes","unchecked"})        Node[] newTab = (Node[])new Node[newCap];        table = newTab;        if (oldTab != null) {            for (int j = 0; j < oldCap; ++j) {                Node 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)e).split(this, newTab, j, oldCap);                    else { // preserve order                        Node loHead = null, loTail = null;                        Node hiHead = null, hiTail = null;                        Node 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;    }

源码相对比较长,但是主要做了如下几件事情

  1. 计算新桶数组的容量 newCap 和新阈值 newThr
  2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
  3. 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。

首先看第一段

        Node[] 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)                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) {            float ft = (float)newCap * loadFactor;            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?                      (int)ft : Integer.MAX_VALUE);        }        threshold = newThr;        @SuppressWarnings({"rawtypes","unchecked"})        Node[] newTab = (Node[])new Node[newCap];        table = newTab;

这一段主要是判断到底是初始化和还是扩容。其可以概括为下表所示:

b74d6dcb302fd1c0c075603ad8282a8d.png

后面这段是相对比较复杂难懂的一部分。

这段的意思是将哈希桶中的元素重新放到新的扩容后的哈希桶中。

     if (oldTab != null) {            for (int j = 0; j < oldCap; ++j) {                Node 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)e).split(this, newTab, j, oldCap);

后面这段代码相对复杂。

                       else { // preserve order                        Node loHead = null, loTail = null;                        Node hiHead = null, hiTail = null;                        Node 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;                        }                    }                }            }        }

e.hash & oldCap是啥意思?我刚开始看也非常困惑。

可以看下图,令n=4,看零个hash值35和7,首先当n=4时候,他们计算出的下标都是3.

然后当n=8时,他们一个下标仍然是3,一个变成了7。因为一开始n=4时,n-1=3,只有后2位参加了求与运算,然而n=8,即n-1=7时,后三位参与了运算,如果hash值第三位是1,那么重新计算的hash值就是oldCap+原位置。

根据这样的判断,我们只要用n & hash值,就知道链表中的元素重新哈希之后是否需要换位置。

db8630f37d326ce416bb70e6b4db1aff.png

如下图所示,一开始35、7、19、15都在3这个哈希桶上。当n=8的时候,35和19仍然在3上。

而7和15则在7上。

0db8e0326e7182129ef75546be175a2a.png

总结

讲到这里,基本上将HashMap的几个重要的模块讲解完了,包含了构造方法、get方法、put方法和resize方法。

本篇文章是自己在阅读HashMap源码时的一些心得和一些记录,也方便自己今后遗忘后的快速上手。如果内容当中有任何问题,希望大家在评论区中指出,我也会及时的修改。

感谢大家的阅读!

参考文章

https://segmentfault.com/a/1190000012926722

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值