Java基础-HashMap详解(缺红黑树具体实现)

1 HashMap中的链表

说到HashMap的实现逻辑,我们必须先了解一下内部的数据结构,首先我们来看下链表的实现。
HashMap中的链表是一个单向链表。通过内部的Node对象来实现,我们看源码,整体也比较简单没有什么复杂的逻辑。

	static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

2 红黑树数据结构

2.1 树结构得常用术语

  • 路径:顺着节点的边从一个节点走到另一个节点,所经过的节点的顺序排列就称为"路径"
  • 根:树顶端的节点称为根,一棵树只有一个根。如果把一个节点和边的集合称为树,那么从根到任何其它节点都必须有且只有一条路径。
  • 父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。
  • 子节点:一个节点含有的子树的节点称为该节点的子节点。
  • 兄弟节点:具有相同父节点的节点互称为兄弟节点。
  • 叶节点:没有子节点的节点称为叶节点,也叫叶子节点。
  • 子树:每个节点都可以做为子树的根,它和它的所有子节点,子节点的子节点都包含在子树中。
  • 节点的层次:从根开始定义,根为第一层,根的子节点为第二层。
  • 深度:对于任意节点n,n的深度为从根到n的唯一路径长,根的深度为0。(从上往下看)
  • 高度:对于任意节点n,n的高度为从n到叶子节点最长路径长,所有叶子节点的高度为0。(从下往上看)

2.2 红黑树是特殊的二叉搜索树

  • 每个节点要么是黑色要么是红色
  • 根节点是黑色
  • 每个叶子节点是黑色,并且为空节点(还有另外一种说法就是,每个叶子结点都带有两个空的黑色结点(被称为黑哨兵),如果一个结点n的只有一个左孩子,那么n的右孩子是一个黑哨兵;如果结点n只有一个右孩子,那么n的左孩子是一个黑哨兵。)
  • 如果一个节点是红色,则它的子节点必须是黑色
  • 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。称为”黑平衡“
    注意:
  • 指定红黑树的每个叶子节点都是空节点,但是在Java实现中红黑树将使用null代表空节点,因此遍历红黑树时看不到黑色的叶子节点,反而见到的叶子节点是红色的
  • 从根节点到叶子节点的最长路径的长度不会超过任何其他路径的两倍,例如黑色高度为3的红黑树,其最短路径(路径指的是根节点到叶子节点)是2(黑节点-黑节点-黑节点),其最长路径为4(黑节点-红节点-黑节点-红节点-黑节点)。

由于红黑树的整体逻辑比较复杂,对于插入,删除各种规则涉及的左旋,右旋,变色这里就不做更详细的介绍了,后面涉及红黑树的源码也过多解读了。

3 HashMap的容量计算tableSizeFor方法

3.1 实现2的n次方计算

HashMap要求其容量大小必须是2的n次幂(为什么这么要求后面再说)。那对于任意设置的整数,如何快速的计算大于等于当前值的最小2的n次幂的数呢?(例如:给定3 通过计算后应该是4,给定5通过计算后应该是8)。
从数学的角度出发,我们只需要取当前数已2为底的对数,然后向上取整就能得到这里的n(复习一下高中数学知识)。
java的Math函数中没有以2为底的对数,只有以10为底和以e为底的对数函数。那么我么这里计算需要用到换底公式(哦,又是高中数学知识)。

	public static void main(String[] args) {
		//初始值13,我们期望得到的值是16
        int capacity = 13;
        //这里使用log已10为底进行换底公式计算
        double v = Math.log10(capacity)/Math.log10(2);;
        //计算出对数结果后,向上取整
        double ceil = Math.ceil(v);
        //计算2的n次方
        double pow = Math.pow(2, ceil);
        System.out.println("计算后的值"+pow);
    }
    输出结果:
    计算后的值16.0

3.2 源码的实现

不考虑计算精度问题,上面的代码逻辑还是比较清晰的,代码量也比较简洁,但是底层实现的指令肯定不会少。HashMap之所以用这个方法转换为2的幂,是为了减少哈希冲突,提高存取效率,这种显然不符合预期。我们看下HashMap的源码是如何实现的。

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

3.3 二进制数2的n次方

从二进制的角度来看,2的n次方的数,都是以某位为是1,其它为都是0的二进制数。那如何得到这样得数呢?
在这里插入图片描述
我们看源码最后执行得是n+1,也就是说我们需要获得低位全是1的数,最后加上1就可以得到2得整数幂了
在这里插入图片描述
接下来我们看如何获得低位全是1
首先最大容量为2的30次方,对应二进制如下:
在这里插入图片描述
首先我们这里随机拿到一个数:
在这里插入图片描述
其实我们只需要考虑这个数的最高位,先看

 n |= n >>> 1;

在这里插入图片描述

n |= n >>> 2;

在这里插入图片描述
以此类推:
右移1位,红色扩散成2
右移2位,红色扩散成4
右移4位,红色扩散成8
右移8位,红色扩散成16
右移16位,红色扩散成32
经过上面的处理所有位都会变成1,然后加上1就会变成我们需要的2的n次方了。
我们再看首先进行的是n-1,这是为了避免2,4,8这样本来就是2的n次方的值被扩大而进行的操作。

3.4 那为什么必须是2的n次方呢?

我们继续看下面的分析,你可能就有些顿悟了。

4 hash方法及数据如何分散到数组中的

4.1 数据如何分散到数组中-自己实现

假设我们有0,1,2,3,4,5,6,7,8,9一共10个数,要均匀的放到3个桶里,那如何计算呢?可以按3取模,模数相同的放到同一个桶里。结果如下:
在这里插入图片描述
假设现在我们将10这个数放进去,需要进行扩容,假设扩成5。
那我们需要遍历老的三个桶,把数据取出来重新按5计算模数
在这里插入图片描述
这种取模运算的效率还是比较低的,我们看下源码是如何实现的。

4.2 放入的数据如何分散到数组中-HashMap实现

我们先看如何将数据放入到数组中去的。先看下面代码,这里将键的hashCode值与上容量值减1,容量值减1可以看下第四节内容,高位全是0是低位全是1的一个二进制数。那么这里的与运算,相当于抛弃hashCode的高位,只有与容量大小的低位进行了运算。这里展现了为啥数组容量要使用2的n次方。

newTab[e.hash & (newCap - 1)]

如下图:
在这里插入图片描述

4.3 hash方法

代码逻辑很简单,那为什么要这么干呢?为什么不直接取hashCode的。

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

首先hashCode的范围为-(2 ^ 31)~(2 ^ 31 - 1),而hashMap的最大容量是2的30次方,并且再日常使用过程中基本上达不到这个数量级别。这样就会导致所有低位相同但是高位不同的数发生hash碰撞(按上面与运算的方式)。
如下:两个不同的hashCode,进行与运算后发生了hash碰撞。
在这里插入图片描述
日常使用中,由于大部分情况下(数组大小2得16次方65536已经满足了大部分日常使用)与运算都发生在低位,所以要将高位的数参与到hash值得计算中来,故源码将hashCode值和其高16位进行了异或运算。减少hash碰撞。
那为啥是异或运算呢?

  • 带符号右移的数和当前数异或运算保证了高位不变(当然或运算也可以做到)
  • 无论是与运算还是或运算都不利于低16位数据的离散。容易导致hash碰撞。实在不想画图了,自行脑补吧。
    说到这里也基本上弄清楚了hash方法这么设计得初衷了。hash碰撞只能尽可能得避免。

5 resize方法

5.1 容量计算

我们先来看前半部分的数组长度确定和容量计算。

		Node<K,V>[] oldTab = table;
		//计算旧数组长度,数组为空则为0,否则为旧数组长度
        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;
            }
            //扩容数组为原来的两倍,扩容后的值大于等于默认值8,直接扩容容量也为之前的两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //能走这里说明oldCap =0,也就是数组没初始化,但是容量有值,只有创建的时候设置值才会出现这种情况
        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);
        }
        //1. 创建时设置初始大小
        //2. 扩容后数组长度依然小于默认值8
        //3. 扩容后数组长度等于默认长度
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;

数组的变更无非就一下三种情况:总结画个图,更清晰一点:

  • 因添加了新数据原始容量不够进行的扩容
  • 指定大小创建HashMap的初始化
  • 使用默认大小创建HashMap的初始化
  • 容量为0进行的扩容,设置了过小的负载因子和过小的数组长度,导致计算出来的容量为0
    ![![在这里插入图片描述](https://img-blog.csdnimg.cn/111e3e74806a4034a9a223b21ce9909b.png](https://img-blog.csdnimg.cn/8340d868bfe948d7b95eff7bb5b8953f.png

oldCap >= DEFAULT_INITIAL_CAPACITY(16)这个判断的意义。想想还挺有意思的
设置的初始值是2,负载因子默认0.75,那数组长度是2,容量是1,扩容一次后变成数组长度为4,容量是3(需单独计算),扩容二次后变成数组长度为8,容量是6(需单独计算),扩容三次后变成数组长度为16,容量是12(需单独计算),往后再扩容就是同时乘2了
所以如果数据量多的情况下,最好指定比较大的初始容量,避免频繁的扩容。
过小的负载因子缺陷
设置的初始值是2,负载因子0.1,那数组长度是2,容量是0
这个时候是能够放数据到数组中的,放完之后会进行if (++size > threshold)判断,还会继续扩容。
这会导致每次put数据到Map中都会进行扩容。

5.2 旧链表数据如何拆分

简单点假如我们的旧数组的容量是2,扩容后的容量是4,从上面分析的数组中数据放入规则,我们知道这里旧数组的数据是要重新计算在新数组的位置的,而不是简单的复制过去。
旧数组的桶坐标:按对象hash值最后一位与上1,结果为0或者1分别放到桶中。
在这里插入图片描述
新数组的桶坐标:也就是说要把上面桶里的数重新分配到下面桶中
在这里插入图片描述
扩容前的容量2减1的二进制,扩容后的容量4减1的二进制(与运算的值),即代码:cap-1
在这里插入图片描述
这里可以发现当对象的hash值与上面两个数进行与运算的差别就是多了一个高位参与运算。如果对象hash的高一位的值是0的话,那计算后的值不变,如果对象的高一位的值是1的话那就相当于,原有坐标值加上2的1次方变成新得坐标。
而这里得2得1次方恰好是旧数组得长度。
也就是说旧桶中得对象按hash算法得高一位值,拆成2份:

  • 高一位0,放入新数组中得坐标不变
  • 高一位1,放入新数组中得坐标为,旧坐标值+旧数组大小
    这里桶得数量裂变成2倍,桶中得数据也拆成两份。这里再次体现了,为啥容量要用2的n次方了。

5.3 链表数据重新分配到新数组

当原数组中有数据,需要将原数组中的数据重新分配到新的数据组中。

	//旧数组不为空,变量数组数组
	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 { // preserve order
                    	//旧数组中是链表得处理
                    	//定义低位头节点和尾节点
                        Node<K,V> loHead = null, loTail = null;
                        //定义高位头节点和尾节点
                        Node<K,V> hiHead = null, hiTail = null;
                        //下个节点
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //判断对象的高一位是不是0,是0则将该对象放入低位链表,否则放入高位链表
                            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;
                        }
                    }
                }
            }
        }

链表中得数据是如何分散到高位和低位得。对应else中的逻辑代码。
假设我们取出数组中得某一个链表如下:第一位表示当前Node,第二位表示关联得下一个Node。这里取长度为5的链表,后面还有值就省略了。
在这里插入图片描述
我们这里只看低位得处理,高位得逻辑是相同得。希望大家看完下图有个清晰的理解。
在这里插入图片描述

6 put和putVal方法

put方法中调用的putVal方法,这里直接分析该方法的逻辑。

6.1往链表中添加数据

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;
        //当前hash所在的桶是否为空,为空直接放入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
        //说明当前hash值所在的桶中已有值
            Node<K,V> e; K k;
            //链表第一元素p的hash与当前hash相等,并且key值也相等,那就将e也指向p对象
            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) {
                	//一直遍历到尾节点,此e为null
                    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;
                    }
                    //如果链表中存在相同的key,那就跳出循环,此时e为null
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //e不为null,说明存在当前链表中存在相同的key值
            if (e != null) { // existing mapping for key
            	//取出链表中key重复的旧的value值
                V oldValue = e.value;
                //如果替换旧值,或者旧值为空,则将新值赋值给重复的key,并且返回旧值,这也就是往HashMap中插入重复key值
                //会覆盖旧值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //修改常量+1
        ++modCount;
        //Map的数据个数,是否超过容量,如果超过进行扩容
        if (++size > threshold)
            resize();
         //方法体为空,给linkedHashMap留的方法
        afterNodeInsertion(evict);
        return null;
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值