hashMap源码分析----彻底搞透

HashMap深入解析
本文详细剖析HashMap的内部结构,包括其存储机制、扩容策略及put操作流程。解释了HashMap如何通过链表和红黑树实现高效的数据存储与检索,以及如何避免哈希冲突。

HashMap采用key-value存储结构,每个key对应唯一的value,查询和修改的速度都很快,能达到O(1)的平均时间复杂度。它是非线程安全的,且不保证元素存储的顺序。HashMap是最常见的Map类型结构,那么它有哪些特殊之处呢,下面就来一步一步彻底揭开它的神秘面纱。(基于1.8版本)

一.存储结构

jdk1.8以前,HashMap采用的存储结构是一个Entry类型数组+单链表,其结构如下:
在这里插入图片描述
jdk1.8之后,HashMap采用的存储结构是一个Node类型数组+单链表或红黑树。其存储结构如下:

在这里插入图片描述

当链表上的节点数大于等于8时,链表会转换成红黑树形式,当节点数小于等于6时,红黑树结构会转换回链表结构。
为什么要这样设计呢?
当数据量很大的时候,因为链表的查询复杂度为O(n),所以它会转换成红黑树的结构,红黑树查删改的复杂度都为O(logn),但是,当数据很少的时候,修改红黑树上的数据会通过左旋右旋变色等操作来保持树的平衡,其带来的性能开销比用链表存储大。(那为何不直接用二叉查找树来直接存储呢?原因当然是因为二叉查找树时不平衡的,在特殊情况下会退化为单链表,查找速度缓慢)

table数组

    transient Node<K,V>[] table;

链表节点Node

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

二.几个概念

2.1 几个变量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//默认初始化容量16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量2的30次方
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认装载因子
static final int TREEIFY_THRESHOLD = 8;//链表长度大于等于8时转换成红黑树
static final int UNTREEIFY_THRESHOLD = 6;//红黑树节点小于等于6时转换成链表


transient int size;//元素的数量
int threshold;//扩容阈值
final float loadFactor;//装载因子
//还有我们所说的capacity即是数组的长度
//计算公式:threshold=capacity*loadFacotor
//当size>=threshold会进行扩容

2.2桶下标计算

桶下标计算相当于取模运算,即桶下标=hash%capacity
但是我们是用位移运算去实现的。因为capacity都是2的n次幂
capacity-1转换成二进制数即是n个1
所以hash%capacity=hash&(capacity-1)
因此我们又可以把capacity-1称为capacityMask

举个例子:
3%4=3;
3&3=11&11=3;

37%16=5;
37&15=10101&1111=101=5;

三.源码分析

3.1 构造函数

1. HashMap()

//全部使用默认值
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

2.HashMap(int initialCapacity)

//传入一个初始化capcity
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);//调用第三个构造函数
    }

3.public HashMap(int initialCapacity, float loadFactor)

//传入一个初始化capcity和装载因子,建议无特殊需求不要改装载因子
  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);
    }
//这个函数有点复杂,调用了Integer类的numberOfLeadingZeros(cap-1),具体没去
//研究,主要作用是初始化返回一个大于等于cap的最小的2的n次幂的数
    static final int tableSizeFor(int cap) {
        int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

我们可以看出上述构造函数,虽然有参构造传进去了capacity,并没有创建一个table数组,而是直接初始化了threshold。

3.2 put()操作

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);//先通过key计算hash值,然后调用putVal函数。
    }

我们看到put操作首先通过key计算了hash值,然后调用putVal函数,那么我们先来看看hash值是怎么计算的。

    static final int hash(Object key) {
        int h;//用来存储key的hashcode
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//h为key的hashcode,hash值的求法为h异或h右移16位
    }

由源码可知,通过key计算hash值通过用扰动函数,让hash值更为散列以此来减少碰撞。
举个例子:

任何一个Object类型的hashCode方法得到的Hash值是一个int型,Java中int型是
4*8=32位的,但是HashMap的capacity最大也就是31位(static final int MAXIMUM_CAPACITY = 1 << 30;),
也就是说capacityMask最大也就是1<<31-1等于301,而且,大多数情况下,hashMap的capacity也不会用到那么多位,正常初始化时capacity大小也就是16,也就是10000,
它的capacityMask就是1111。在计算桶下标时,只有 hashcode的低4位是有用的,无
论高位是什么,它计算出的桶下标都是一样的。例如:
// Hash碰撞示例:假设刚初始化capacity为16
H1: 00000000 00000000 00000000 00000101 & 1111 = 0101
H2: 00000000 11111111 00000000 00000101 & 1111 = 0101
为了减少这种碰撞,我们可以通过扰动函数,混合hashcode的高位和低位,让hash值不
仅仅受低位影响。
00000000 00000000 00000000 00000101 // H1
00000000 00000000 00000000 00000000 // H1 >>> 16
00000000 00000000 00000000 00000101 // hash = H1 ^ (H1 >>> 16) = 5

00000000 11111111 00000000 00000101 // H2
00000000 00000000 00000000 11111111 // H2 >>> 16
00000000 00000000 00000000 11111010 // hash = H2 ^ (H2 >>> 16) = 250


// 最终没有Hash碰撞
index1 = (n - 1) & H1 = (16 - 1) & 5 = 5
index2 = (n - 1) & H2 = (16 - 1) & 250 = 10

看完了hash值计算的过程,我们继续进入到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;
//由3.1构造函数可知,构造时没有对table进行初始化,所以,第一次put操作必定会
//调用resize(),resize操作后面详讲            
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
//下面if语句先计算桶下标,如果该下标没有内容,则直接新建一个节点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
//否则进行以下处理
            Node<K,V> e; K k;
//快速插入:如果该桶链上第一个节点的hash值和要put进去key的hash值相同并且key
//也相同,则先用变量e把该节点暂时保存,以便后续修改这个节点的值。
            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) {//binCout用来记录链表中节点的个数
//如果遍历完链表依旧没有找到key相同的节点,则需要在链表尾端插入一个新节点。
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果插入后链表长度大于等于8,转换成红黑树结构
                            treeifyBin(tab, hash);
                        break;
                    }
//如果遍历途中找到了key相同的节点,则用e把该节点占时保存(上面执行了)
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
//如果e不为空,即是在链表找到了key相同的节点,只需要改变值即可
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
//没有找到的话,上面循环里面往链表里面插入了数据,需要判断此时size是否大于阈
//值。如果大于阈值需要进行扩容
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

到此为止HashMap的put操作就此结束,总结一下其大概过程
a.通过key计算hash值(需要用到扰动函数)
b.通过hash值计算桶下标
c.判断桶是否为空,如果为空先resize()
d.0判断桶第一个节点的key是否和插入的key相同,如果相同,用e把该节点展示保存,以便后续修改值
d.1如果不相同,则需要遍历链表
d.1.0如果遍历过程中找到了key相同的节点,用e暂时保存以便后续修改
d.1.1如果遍历过程中没有找到,则需要在链表尾端插入新节点(jdk1.8之前是头插),插入完后,还需判断链表长度是否大于等于8,如果是的话需要转换成树结构
e.判断e是否为空,不为空的话,说明链表中存在key相同的节点,只需要修改值就好了,如果为空,说明没有key相同的节点,而且循环中已经插入了新节点,所以此时需要考虑此时size是否会超过阈值,超过需要resize().

3.3 resize()操作

HashMap的put过程是不是很简单,上面出现了两次resize()操作,下面我们再去resize()函数看看有没有什么别样的风景。

 final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
//由3.1构造函数可知,构造时没有对table进行初始化,所以,第一次put操作必定会
//调用resize()       
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧容量
        int oldThr = threshold;//旧阈值
        int newCap, newThr = 0;
        if (oldCap > 0) {//如果旧容量大于0
//如果旧容量大于等于最大值,则不再扩容        
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
// 如果旧容量的两倍小于最大容量并且旧容量大于默认初始容量(16),则容量扩大为
//两倍,扩容阈值也扩大为两倍
//如果没有capacity没有达到16,则这里只扩大capacity,后面再计算threshold,
//设计的着实巧妙            
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
//如果旧的阈值大于0,显然是通过构造函数传入capacity时初始化了threshold,这
//个时候初始化是capacity=threshold=大于等于传入cap的最小的2的n次幂的一个数        
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
//这里相当于无参构造函数第一次put操作,全部使用默认值
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
//新的扩容阈值为0,则需要计算即可(为什么会出现这种情况?使用有参构造时,初始
//化了阈值,resize里面通过阈值初始化capacity的时候,没有更新newWhr,为了就
//是在这里调整阈值,HashMap设计者果然是个大师,设计的如此巧妙。
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
 //上面主要更新了新的capacity和threshold,下面主要时更新table里面的内容了
        @SuppressWarnings({"rawtypes","unchecked"})
//新建一个大小为newCap的Node数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//把table设为新的table
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
//遍历旧table的所有桶,                
                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
//如果这个链不止一个节点,但是又不是树结构,就会将一条链分化成两条链
//一个叫低位链表,一个叫高位链表
//e.hash & oldCap) == 0则放在低位链,否则放在高位链      
//举个例子,假如原来的capacity是4,扩容之后变成8
//hash1为3&4=11&100=0放在3号桶
//hash2为7&4=111&100=1则放在7号桶,而没有扩容时它是放在3号桶的
                        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则放在低位链              
                            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;
    }

如果你看到了这里,那么你真的很有毅力,因为读源码确实是一件枯燥乏味的过程,但是你做到了,下面来总结一下resize()的大概过程
resize()总结
resize主要分两个过程
1.初始化新的capacity和threshold,有三种不同情况
1.1table里面有数据时的扩容,即size大于0。分三种可能:

oldCap >= MAXIMUM_CAPACITY不扩容//第一种不扩容
(newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//第二种,capacity和threshold都扩大两倍,第三种不满足该条件只扩容capacity,后面再计算threshold

1.2带参数构造函数第一次put操作会来到这里,根据threshold进行扩容
1.3无参构造函数第一次扩容会来到这里,使用默认值。

2.修改table里面的数据
分红黑树形式和链表形式,本文主要讲的时链表形式, 一个链会分化成两个链,节点处于哪个链通过e.hash & oldCap) == 0是否为真来判断,为真则在低链,为假则在高链。

码了近7000字,剩下的方法有时间再敲,如果觉得这篇文章对你很有帮助,那就麻烦慷慨的点个赞吧😁

参考文章
1.详解HashMap中的Hash算法(扰动函数)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值