集合类-hashMap

部分内容来源公众号:楚狂声哥

HashMap

转载自: https://mp.weixin.qq.com/s/TZWsVlE5HOOyf24SktUv1Q

HashMap 也算是面试常客了。
HashMap 几乎是我们在 Java 开发中最常用的类之一,它基于 Hash 表实现了一个 Map 结构,使得我们可以根据 Key 对 Value 进行快速查找,时间复杂度接近1 。HashMap 允许 null 键和 null 值,其中 null 键的 hash 值记为 0。除此以外,HashMap 是线程不安全的一个类,当多个线程同时读写一个 HashMap 时,可能会出现问题

类签名与成员变量

源码中 HashMap 类签名如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

HashMap 继承了 AbstractMap 类,并且实现了 Map、Cloneable 和 Serializable 接口。AbstractMap 类主要就是对 Map 接口的一个初步的实现。Cloneable 接口表示 HashMap 实现了 Object 类中的 clone() 方法,而 Serializable 接口则说明这个类是可序列化的,当然众所周知,Serializable 接口仅仅是一个标志接口,没有规定任何需要实现的方法

HashMap 有几个默认属性:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;  // 最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;   // 加载因子
static final int TREEIFY_THRESHOLD = 8;   // 树化阈值
static final int UNTREEIFY_THRESHOLD = 6;   // 反树化阈值
static final int MIN_TREEIFY_CAPACITY = 64;   // 最小树化容量

内部存储结构

在 HashMap 中有两个静态内部类,分别是 Node<K, V> 和 TreeNode<K, V>,其中,Node<K, V> 继承了 Map.Entry<K, V>,而 TreeNode<K, V> 继承了 LinkedHashMap.Entry<K, V>LinkedHashMap.Entry<K, V> 则继承了 Node<K, V> 类,从而 TreeNode<K, V> 间接也继承了 Node<K, V>类。Node 类代表一个普通的键值对节点,而 TreeNode 代表一个二叉树节点。它们之间的变换将在下面讲解。

HashMap 内部有一个成员变量:transient Node<K,V>[] table,是一个 Node 数组,每一个 Node 就是一个 hash 桶。HashMap 中的所有数据实际上都存储在这个数组中。(PS:由于 TreeNode 也是 Node 的子类,所以 TreeNode 也是可以存入 Table 的)

有一个问题就是,table 数组明明是用于保存 HashMap 所有的数据的,为什么被声明为 transient 的(声明为 transient 类型的变量在对象序列化时不会参与)? 
ANS:
    
HashMap 在确定一个 key 被存储在 table 的哪个元素中时,是通过 Object.hashCode() 方法获取到对象的哈希值,并将哈希值与桶个数(就是 table 数组的长度)取模来确定的。Object.hashCode() 方法是一个 native 方法,其实现依赖于 JVM 虚拟机的实现。所以在不同平台,同一个对象的哈希值可能是不同的,这就导致了其保存在 table 中的位置可能是不同的,直接将一个 HashMap 传输过去可能会出错。所以现有的 HashMap 的序列化做法,是将其中的所有 key 都直接保存,在反序列化时再重新生成一个 HashMap,并将 key 逐个插入。
    另一个不太重要的原因是,table 数组(每个元素都是node对象)中的很多成员可能根本就没有被使用,对没有被用到的空间进行序列化会导致结果较大且没有意义,所以序列化时不会保存 table 数组,而是只保存 key。

构造方法

HashMap有三个构造方法:

public HashMap();
public HashMap(int initialCapacity);
public HashMap(int initialCapacity, float loadFactor);

第一个和第二个方法都需要调用第三个方法,不同的仅仅是使用默认值而已。第三个方法需要两个参数,initialCapacity 和 loadFactor,分别是上面提到的 Node 数组 table 的初始化容量,和一个值 loadFactor,表示负载系数。这两个值的默认值分别是 DEFAULT_INITIAL_CAPACITY 和 DEFAULT_LOAD_FACTOR。也就是说,在无参构造一个 HashMap 时,Hash 桶的初始大小(capacity)为 16,且负载系数(loadFactor)是 0.75。那么负载系数是什么呢
    负载系数是 Map 扩容中的一个重要参数,当 Map 中的元素数量逐渐增多,使得 size / capacity 大于等于负载因子时注意是 size,是容量,即 key 的个数,而不是占用 Hash 桶的个数,多个 key 可能占用一个桶),就会触发 HashMap 的一次扩容操作。那么,这个值的默认值为什么是 0.75?
    在HashMap的注释中,Java的开发者解释了这个问题。

作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷较高的值会降低空间开销,但提高查找成本(高负载系数引发多桶一key的概率更大)。设置初始大小时,应该考虑预计的 entry 数及其负载系数,并且尽量减少 rehash 操作的次数。如果初始容量大于最大条目数除以负载因子,rehash 操作将不会发生。

好的,回到主题,我们来看看构造方法~

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

注意一下这里的 threshold 表示下一次扩容操作时 table 要达到的容量,由于该构造方法并没有对 table 进行初始化,那么下一次扩容操作就需要扩容到 threshold 大小。而在其他情况下,threshold 参数表示当前 map 中元素的上限,即 map 的容量与负载系数的乘积,在元素个数超出 threshold 时就会发生扩容。
    tableSizeFor() 方法如下,作用是对于一个初始容量,给出大于等于这个容量的最小的 2 的幂次方

    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;  //该算法让最高位的1后面的位全变为1。
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

>>:带符号右移。正数右移高位补0,负数右移高位补1。比如:

4 >> 1,结果是2;-4 >> 1,结果是-2。-2 >> 1,结果是-1。

>>>:无符号右移。无论是正数还是负数,高位通通补0。

关于 |= 运算符:|= 运算符和 += 这一类的运算符一样,拆解开就是 a = a | b;   |是按位或           &是按位与

^是按位异或 : 如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0。

java中的“&”、“|”、“^”、“~”运算符怎么用?  https://zhidao.baidu.com/question/2208085450191966228.html 

Java8 HashMap之tableSizeFor  https://www.cnblogs.com/loading4/p/6239441.html

put()方法

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

   注意这里传入 putVal() 的是 key 的 hash 值,hash() 方法的实现如下:

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

    这里就体现了为什么 HashMap 的 key 可以是 null,当 key 为 null 时,其 Hash 值被当作 0 处理。那么问题来了,为什么不直接使用 key 的 hashCode,而是要将 hashCode 右移 16 位再和自己异或?这就是扰动算法的原理了。

哈希码产生的依据:哈希码并不是完全唯一的,它是一种算法,让同一个类的对象按照自己不同的特征尽量的有不同的哈希码,但不表示不同的对象哈希码完全不同。也有相同的情况,看程序员如何写哈希码的算法。

下面给出几个常用的哈希码的算法。
1:Object类的hashCode.返回对象的内存地址经过处理后的结构,由于每个对象的内存地址都不一样,所以哈希码也不一样。
2:String类的hashCode.根据String类包含的字符串的内容,根据一种特殊算法返回哈希码,只要字符串内容相同,返回的哈希码也相同。
3:……

扰动算法:
    hashCode() 方法,是 Object 类自带的 Hash 函数,返回一个 int 的 Hash 值,理论上,这个值应当均匀得分布在 int 的范围内,即 -2147483648 到 2147483647 之间,接近 40 亿的长度,这显然是很难产生碰撞的。
    但是问题在于,HashMap 中的 Hash 桶并没有 40 亿大小,事实上,在最初默认初始化的时候,才 16 个桶。所以需要将这个哈希值映射到这 16 个桶上,一个比较简单均匀的办法,就是取模,对容量取模。例如,以初始长度 16 为例,二进制数为 00000000 00000000 00001111,如果我们让 10100101 11000100 00100101 这个数对 16 取模的话,其实仅仅是取这个数的低四位,而高位全部清零。这种情况下,就会导致很严重的碰撞。
    这时就需要扰动函数了,它将一个 32 位数的高 16 位和低 16 位做一个异或操作,就变相地将高位的部分信息保存在了低位,增加了低位的随机性,减少的碰撞的几率

putVal() 方法将进行实际的 put 操作。
  putVal() 开头首先判断当前 HashMap 的 table(存储空间)是否已经初始化或者为空,因为在构造函数中,并没有对 table 进行任何操作,如果是空的话需要进行初始的扩容:

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
扩容部分后续再说。

接着获取了 table 上对应位置的 Hash 桶,并判断是否为 null,如果是 null,说明没有发生Hash 碰撞,直接新建一个节点放进去即可,否则要进行碰撞处理:

if ((p = tab[i = (n - 1) & hash]) == null)  //(n - 1) & hash hash值与长度取模
    tab[i] = newNode(hash, key, value, null);
else {
    // 碰撞处理部分
    ...
}

注意这个地方取出 Hash 桶的下标的方法,桶的下标为:(n - 1) & hash,其中 n 是桶的个数。上面说过,理论上将大的 Hash 值映射到较小的数组上,使用的取模运算。
    这个公式其实就是一种快速的取模运算,大致原理可以看上面那张图的计算下标部分。这种方法有一个局限性,就是  的二进制表示必须全是 1,也就是说,n 必须是 2 的幂次方。这就是 table 的容量必须是 2 的幂次方的原因

接着我们看一下碰撞处理的部分:

if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;  // p是等会儿要被换掉的
else if (p instanceof TreeNode)
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
    ...
}

   分为三种情况讨论,第一种就是当前桶的头节点的 hash 和现有的相等,且 key 也相同的情况下(key相同的hash碰撞),将其暂时保存在 e 中,后面需要进行替换,说明这次 push 是更新一个已存在的 key 的value。
否则,如果当前桶的头节点是一个树节点(TreeNode),那么就调用 putTreeVal() 方法向二叉树中插入这个节点。
否则,就是一个普通的链表插入操作了:(key不同的hash碰撞

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

    遍历链表至链表尾,将节点插在尾部,同时检查了一下链表的长度,如果长度大于了 TREEIFY_THRESHOLD (treeify:树饰 转化为树)的长度,就需要将这条链表转化为一棵红黑树,调用的是 treeifyBin() 方法。注意这里提一嘴 treeifyBin() 方法,调用了 treeifyBin() 方法并不是绝对会转化为红黑树,在该方法开头有一个判断:

if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();
else ...

   这里要求 table 的 length,即桶的数量大于等于 MIN_TREEIFY_CAPACITY 的值(64),才可以进行下一步,否则就仅仅会进行一次扩容,而不会将链表转化为红黑树。主要是为了防止在 HashMap 建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。
如果在遍历过程中发现了相同的 key,说明这次 put 就是一个更新操作,将在下面进行更新。

if (e != null) { // existing mapping for key
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
}

如果 put 是一个更新操作,就需要使用新值覆盖,并将旧的值返回,这里调用的 afterNodeAccess() 可以说是一个回调函数,在 HashMap 中并没有实现,在 LinkedHashMap 中有实现。

++modCount;
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

最终 size 自加一后判断与 threshold 的大小关系,如果大于 threshold,表明需要进行扩容,调用 resize() 方法扩容。

get()方法

get() 方法根据 key 找到对应的 value 并返回,如果找不到的话,返回 null。签名如下:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode() 方法用于根据给定的 hash 和 key 在 table 中寻找 Node。

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

它的实现很简单,大致流程是,首先根据 hash 值找到对应的 hash 桶,如果 hash 桶的头节点的 key 就等于想要的 key,那就直接返回头节点。否则,判断头节点的类型,如果是树节点,就使用 getTreeNode() 方法在红黑树中进行查找,否则就使用普通的链表查找,找到的话返回该节点,否则返回 null。

resize()方法

resize() 方法用于对 table 进行初始化,或者将 table 的容量扩容为原来的两倍。

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);
}
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

初始,对原本 table 的大小和 threshold 进行了一系列判断,如是否是最新初始化或者原大小是否已经超过上限之类的。最终确定要扩容到的大小 newCap 和新的阈值 newThr。如果没有超出上限的话,newCap,即新桶的大小,是 oldCap 左移一位。threshold 也直接变为原来的两倍。
    接着按照 newCap 的大小初始化一个 Node 数组 newTab,并直接将 table 的引用指向了newTab,那么旧的桶数组则由 oldTab 持有。判断 oldTab 是否为 null,如果是 null,则说明这是 HashMap 构造完成后的第一次resize(),无需迁移节点,直接返回 newTab 即可。
   迁移过程如下:

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);  // 调用 split() 方法对树进行拆分
        else { // preserve order
            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;
            }
        }
    }
}

迁移过程,就是对节点重新计算 Hash 并安排的过程。主要就是对桶的遍历,并且根据桶的头节点的类型来进行不同的动作,如果头节点是一个 TreeNode,那么就需要调用 split() 方法对树进行拆分(第 8 行),否则就对链表进行拆分。对树的操作暂且不说,主要来看对链表的拆分操作,从第 10 行开始。
    这里先说结论,在原 Map 中位于同一个桶的链表节点,在扩容后重新 Hash 后,必然位于两个桶之中(也有可能还在一个桶),且这两个桶位置相差 oldCap。较小的那个桶位于原位置。

我们来证明一下:
例如原 table 大小长度为16,那么扩容后的大小为 32,有两个节点的 Hash 分别为 5 和 21(简化情况),那么根据公式  我们来计算这两个节点在新 table 和旧 table 中的位置:
Hash 为 5 和 21 的 key 显然在原 table 中位于一个桶,在计算扩容后的桶的位置时,我们可以注意到,n-1 的二进制数多了一个 1,在这个例子中,就是在第 5 位上多了一个 1,那么由于是与操作,计算后的结果是否改变就仅仅取决于该节点的 Hash 的第 5 位上是否为 1,如果是 0 的话,那么桶的位置不变,否则,计算出的桶的位置就比原桶的位置大 2^{4} ,正好是原桶的大小。

再回到代码:
初始化的两个链表 loHead 和 hiHead 分别代表保持原位置的节点组成的链表和迁移到新位置的节点组成的链表。接着遍历原链表上的所有节点,将节点分配到两条链表上。第 6 行的 if 中条件,(e.hash & oldCap) 即可判断出那个关键的二进制位上是否为 0,为 0 的节点放入原位链表 loHead,否则放入新链表 hiHead。最后将这两条链表放到新 table 的对应位置即可。

红黑树退化成链表

在代码中有一个常量为 UNTREEIFY_THRESH

static final int UNTREEIFY_THRESHOLD = 6;

这个常量仅仅会在 split() 方法中使用,用于分裂红黑树并重新计算 Hash,split() 也只会在 resize() 中调用,即只有在扩容时,对红黑树进行分裂,如果树的大小不足6,就会退化为链表。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值