HashMap底层数据结构源码解析(基于JDK1.8)

HashMap应该是每个Java程序员日常开发都很熟悉的集合类型,使用也很简单,不赘述。

import java.util.HashMap;

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<Integer,String> user = new HashMap<>();
        //往HashMap里面存放数据
        user.put(1,"张三");
        user.put(2,"李四");
        user.put(3,"王五");
        
        //从HashMap里面取数据
        String name = user.get(1);

        System.out.println(name);
    }
}

点进put方法里面看看HashMap都做了啥。

这里有两点需要关注的,一个是HashMap的put方法原来是有返回值的 。上面的注解告诉我们,返回值是key关联的前一个值,怎么理解呢?来一个测试代码测了一下。

import java.util.HashMap;

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<Integer,String> user = new HashMap<>();
        //往HashMap里面存放数据
        System.out.println(user.put(1, "张三"));
        System.out.println(user.put(1, "李四"));
        System.out.println(user.put(1, "王五"));
        
        System.out.println(user.get(1));
    }
}

输出结果: 

null
张三
李四
王五

我put了三个同样的key,第一次输出结果是null,第二次输出的是第一次put进去的value,第三次输出第二次put进去的value。结论:如果put进去的key跟HashMap原有的Key发生重复,那么对应的值会被替换,被替换的值会在返回值中返回。

第二个关注点是hash(key)这个方法。这里是一个三目运算符,如果key为null返回0,否则返回(key的哈希值h) XOR (h逻辑右移16位)。Java中的<< 和 >> 和 >>> 详细分析 这里计算出来的hash用来确定新put进来的对象要放到哪个位置,这个后面会讲。

那么来看看key的hash算法是怎么算的吧~然后一脸期待地点进去发现,你就给我看这个??

所以这个算法估计是在更底层实现的,已经不是java这层关心的事情的,那就先跳过吧,知道这里是个哈希算法就行了。然后回过头来看看key的哈希值有了,那么怎么把value的值放到HashMap里面呢?点进putVal()里面:

 

到这里就能先画个图了,首先这是个数组tab,然后数组tab里面装的Node是链表。就这酱紫啦!但是这个图并不完整,实际上当链表的长度超过8时,链表会变身成为红黑树。

实际上应该是这样子的。

继续往下,如果tab是空的话,就会调resize()这个方法去初始化数组。resize()这个方法特别关键。

你一定很好奇为啥我们能往Map里面放很多很多数据,是因为这个数组一开始就设置了很大的数值吗?还是链表可以允许无限长呢?很明显都不是,读懂resize(),你就会发现这里的设计有多巧妙。

tab数组的初始化并不是在创建HashMap的时候完成的,而是在往HashMap里面Put第一个值的时候调用resize()方法进行初始化的。这样做的好处就是减少不必要的空间浪费,因为每次new一个对象就要从堆中开辟一块新的空间。

这里有几个重要的概念:

1.capacity(容量):记录数组的长度,其值一定是2的n次幂,初始化时数组容量为DEFAULT_INITIAL_CAPACITY=16

2.loadfactor(负载因子):控制数组到达某个长度进行扩容,默认值是DEFAULT_LOAD_FACTOR = 0.75f,默认情况下,数组到达0.75*16=12时,数组扩容为原来的2倍,即32;到达0.75*32=24时会再次扩容为64,以此类推。可以在有参构造函数public HashMap(int initialCapacity, float loadFactor)传入负载因子的值。负载因子巧妙地控制着数组的伸缩,从而达到空间合理分配的目的。

3.threshold(阈值):前面说到数组达到某个临界值会自动进行扩容,threshold就是这个临界值。threshold=capacity*loadfactor。

上面这段主要是处理扩容之后把原数组的数据拷贝到新数组,这里的算法也特别巧妙。要看懂这里,需要回到putVal()方法理解前面说的hash值是怎么跟数组的位置关联起来的。

i = (n - 1) & hash计算数组的下标。举个例子,假设前面经过resize()初始化数组,当前数组长度是16,转换成二进制就是10000,那么n-1=1111,假设hash=110110,i的计算如下图:

计算结果其实就是取二进制hash值的后面4位,运算结果i能取得的最大值是1111[二进制]=15[十进制], 这样做的目的有2个:

    1.控制数组下标不越界,因为数组长度是10000[二进制],计算结果i只取hash后四位,i绝对会落在数组长度范围内。

    2.位运算效率更高。上面的算法用取模运算同样可以实现,但是效率没有位运算高。

明白了hash和数组下标的关联关系,再来理解扩容时原数组是怎么拷贝到新数组的。还是刚才的例子,因为新数组的长度变为了原来的2倍,newCap.length=100000[二进制],计算下标i取的是hash的后5位,这就导致数组下标的位置会有可能发生改变,但是无非就2种情况:

情况1:hash值的右边第5位是0,那么新数组下标i与原数组计算结果一致,也就是该节点还是放在原来的位置。

情况2:hash值的右边第5位是1,那么新数组下标i就是原数组下标值+原数组长度。

 

 

上面是推理过程,结果其实没那么复杂。简单来说就是,数组的下标就是取哈希值的后N位得来的,扩容需要向前多取一位,这一位的值是0还是1,决定了该节点拷贝到新数组的下标是否发生改变。是0下标不变,是1下标变为原下标+oldCap(原数组容量)。

总的再来看一下putVal()这个方法。

 /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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; //resize初始化数组
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);  //把put进来的对象放到数组某个位置(这个位置跟二进制hash的后N位有关)
        else { //数组对应下标已经有节点
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k)))) //原来这个位置上节点的key刚好跟当前要放进去对象的key一样
                e = p; //新put节点替换原来节点
            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) // 链表长度大于8,把链表转换成红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k)))) //key相同替换节点
                        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; //替换相同key的节点之后返回旧节点的值,一开始写的那段代码测put方法的返回值,根源在这里
            }
        }
        ++modCount; //修改次数计数器
        if (++size > threshold) //大于阈值需要扩容
            resize();
        afterNodeInsertion(evict); //插入后的回调,用于扩展,例如自己写了一种Map继承了HashMap,需要在出完插入之后做一些其他操作,重写这个方法即可。官方在LikedHashMap也用到了。
        return null;
    }

【注:本篇全程是按照阅读顺序以一种流水账的方式写的,没有进行梳理和归纳,适合打开源码跟读。红黑树也没有展开说,就写到这吧,后面梳理一下再写一篇完整的】 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值