HashMap继续解读

HashMap大家应该都知道,有什么特点,怎么存储数据,底层数据结构,JDK1.7数组+链表,JDK1.8数组+链表+红黑树。今天来深刻学习一下HashMap源码。

HashMap中的数组(桶)

看一下源码中怎么定义它的

    Node数组也叫做桶
    transient Node<K,V>[] table;

定义了一个Node类型的数组对象。

再看一下它的构造方法

创建桶
    public 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);
    }
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    static final int MAXIMUM_CAPACITY = 1 << 30;
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

一般情况下我们在new HashMap对象时,没有传参,那么这里的initialCapacity就有了一个默认的值,loadFactor也一样。观察源码发现默认容量为1<<4即16,那么我们得到一个结论HashMap的默认大小是16,默认负载因子0.75f;

桶的大小

下面来看一下tableSzieFor()方法
假如我们给定了一个初始容量桶的大小(12),这段代码就会把这个值转换成2的幂
,就会将桶的值变化到16;

    static final int tableSizeFor(int cap) {
        int n = cap - 1;     n=12
        n |= n >>> 1;       n=0001100|0000110=0001110   
        n |= n >>> 2;       n=0001110|0000111=0001111
        n |= n >>> 4;       
        n |= n >>> 8;
        n |= n >>> 16;
        如果n大于0并且n没有到最大大小,就返回n+1
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

此时就知道了桶的容量最大为 MAXIMUM_CAPACITY=1<<30 即2的30次幂
在HashMap中,桶的个数总是2的n次幂。

桶的扩容
    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) {
            if (oldCap >= MAXIMUM_CAPACITY) {
            	如果旧容量达到了最大容量,则不再进行扩容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                如果旧容量没有达到最大容量,且大于默认初始容量(16)就继续进行移位运算向左移动一位,即之后扩容阈值扩大为之前的2倍
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
        	如果旧容量为0且旧容量扩容门槛大于0,则把旧门槛赋值给辛容量
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
        	调用默认构造方法时,旧容量,旧扩容门槛都是0,将使用默认值
            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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
...
    }

根据桶扩容的代码,我们可以知道

  1. 如果使用的是默认构造方法,初始化对象时,会将桶的容量赋值为16,即默认初始容量16,负载因子设置为0.75,扩容时,扩容门槛也会创建一个值来保存即threshold=容量*负载因子(12)
  2. 如果使用的是非默认构造方法,则初始化容量等于扩容门槛,扩容门槛在构造方法里等于传入容量向上最近的2的那次幂
  3. 如果旧容量大于0,则新容量等于旧容量的2倍,最大不超过2的30次幂,新扩容门槛为旧门槛的2倍。
HashMap中的链表
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    }

可以看到这个链表就是以单链表的形式存放的数据,每一个节点都存放了<K/V>,链表是存放在桶中的,然后这里有一个hash值, 思考这个hash值到底干了什么?猜想,这里的hash值是为了帮助链表放入桶中的位置

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

可以看到key的高16位hashCode值与整个hashCode异或,这样做是为了是hash值更加分散。
我们知道hashMap的查找时间复杂度为O(1),为什么那么快?
那先看一下hashMap是怎么添加的数据,看一下put具体实现的方法。

put方法具体的实现,putVal()方法
    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)
        	当桶中没有数据时,或者桶的长度等于0时,就先初始化一下容量。
            n = (tab = resize()).length;
        如果这个桶[(n - 1) & hash]中没有元素
        if ((p = tab[i = (n - 1) & hash]) == null)
        	新建一个节点放在桶的第一个位置
            tab[i] = newNode(hash, key, value, null);

(n-1)&hash确定链表在第几个桶,hash=(key.hashCode()^key.hashCode()>>>16),
当前容量-1与异或运算得到的hash值与运算确定。假如我们桶的长度为16。

n-1=10000-1=01111   之前异或运算的值为1010
01111&1010=00101   5那么确定这个元素存放在数组第5号位置

实际上这个算法就是取模运算,使用位运算的原因是因为位运算比直接取模运算快得多
HashMap在确定Node存放的数组位置是通过取模运算来确定桶的位置,桶的容量一直为2的n次幂保证了位与运算的准确性。
可知key不允许重复,计算出的hash值重复了怎么办。

hash相等时怎么办

我们继续看putval()方法是怎么做的

else {
			桶中已经有元素存在了
            Node<K,V> e; K k;
            如果桶中第一个元素的key与待插入元素的key相同,保存到e中用于后续修改value值
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
            如果第一个元素是树节点,则调用树节点的putTreeVal插入元素
                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,则判断是否需要树化,因为第一个元素没有添加到binCount中,所以这里-1
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        	树化方法
                            treeifyBin(tab, hash);
                        break;
                    }
                    如果hey相等则退出循环
                     if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            如果找到了对应key的元素
            if (e != null) { // existing mapping for key
            	记录旧值
                V oldValue = e.value;
                判断是否需要替换旧值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }

根据上面的代码可以得出结论,当Key相等时,覆盖value,当hash值相等时,采用尾插法插入链表。在JDK1.8以前,插入算法是头插法,使用头插法插入数据,每次新插入的node都需要移位。JDK1.8对链表过长做了一个优化,都知道链表太长之后查询速度比较慢,所以将链表树化了,这里我们重新讨论一下HashMap扩容。

HashMap扩容时,存储的数据会发生怎样的变化
        table = newTab;
        如果旧数组不为空
        if (oldTab != null) {
        	遍历旧数组
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
              	如果旧数组位置不为空,赋值给e
                if ((e = oldTab[j]) != null) {
                	清空旧桶,便于GC
                    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
                    	如果这个链表不止一个元素且不是一棵树
                    	则分化成两条链表插入到新桶中去
                    	比如原来容量为4   261014这四个元素都在2号桶中
                    	现在扩容成8210还是在2号桶,615就要搬移到6号桶中去
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            (e.hash&oldCap)==0的元素放到低位链表中
                            如2&4==0
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                            	(e.hash&oldCap)!=0的元素放在高位链表中
                                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;
  }

链表的迁移到此为止就全部完成了,每次扩容都需要遍历所有元素,在桶只有一个元素,迁移到新桶的位置不变,桶中是链表时,对链表进行分割,分割成两条链,满足低位链的条件元素的hash值与旧容量进行与操作等于0放到低位链,不等于0放到高位链,低位链直接放到原来桶的位置,高位链放到原位置+旧容量位置。

HashMap中的红黑树

红黑树是JDK1.8新加入的,当桶的数量大于64且单个桶中的元素大于8,树化,已经树化的,元素个数小于6,才会还原成链表

红黑树特点

红黑树本质上就是一颗二叉树,更确切的说,红黑树是一颗平衡二叉树。
红黑树的5种性质

  1. 节点是红色或者黑色
  2. 根节点是黑色的
  3. 每个叶子节点(空节点,NIL节点)是黑色的
  4. 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
  5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。(确保没有一条路径会比其他路径长出2倍。)
    红黑树是相对接近平衡的二叉树
    时间复杂度为O(log n)与树的高度成正比
    红黑树在查找元素效率比链表高,但增删元素不如链表。
HashMap中红黑树定义
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        默认为黑
        boolean red;

上面已经知道什么时候树化,我们去putVal()方法查看发现树化的方法是treeifyBin()

树化
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        如果桶为空桶或者桶的大小小于MIN_TREEIFY_CAPACITY = 64,就扩容,不进行树化
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        如果插入元素桶的位置不为空,就把当前桶的所有节点全部换成树节点
        else if ((e = tab[index = (n - 1) & 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);
        }
    }

到这里为止树化大致上也完成了。
。。。。。。。。。。。。。。。
。。。。。。。。。。。。。。。

总结

在JKD1.8,HashMap的底层实现就是数组+链表+红黑树的结构
Node数组
HashMap默认初始容量为1<<4 ,默认负载因子为0.75f,容量总是2的n次幂;
HashMap扩容是键值对数=容量*负载因子时扩容,2倍容量
链表
HashMap在链表添加元素是采用尾插法
HashMap链表的扩容是采用高低位分割链表一次性迁移到新桶
红黑树
HashMap桶数量需要大于64单个桶中链表元素个数超过8才会树化
HashMap树化之后,树的元素个数小于6才会反树化

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值