hashMap源码分析,知识点面试题大总结

一 版本声明

本文HashMap源码基于JDK8。
不同版本HashMap的变化还是比较大的,在1.8之前,HashMap没有引入红黑树,也就是说HashMap的桶(桶即hashmap数组的一个索引位置)单纯的采取链表存储。
这种结构虽然简单,但是当Hash冲突达到一定程度,链表长度过长,会导致时间复杂度无限向O(n)靠近。因为HashMap中一直插入hash值一样的元素,在HashMap的对应的桶中将形成一个链表。

为了解决这种简单的底层存储结构带来的性能问题,引入了红黑树。引入红黑树之后当桶中链表长度超过8且容量达到64将会树化即转为红黑树**(put触发)。当红黑树元素少于6会转为链表(remove触发)**。

为什么树化和链表化的阈值不一样
想一个极端情况,假设阈值都是8,一个桶中链表长度为8时,此时继续向该桶中put会进行树化,然后remove又会链表化。如果反复put和remove。每次都会进行极其耗时的数据结构转换。如果是两个阈值,将会形成一个缓冲带,减少这种极端情况发生的概率。
上面这种极端情况也被称之为复杂度震荡。

二 算法实现

以put方法为切入口,详解里面用到的算法

hash算法

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

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

可以看到hash算法计算分为三步

1.获得key的hash值
2.在1的基础上右移16位,即保留低16位,抹去高16位
3.1和2的结果进行异或运算(相同为0,不同为1)

hash()方法,只是key的hashCode的再散列,使key更加散列。而元素究竟存在哪个桶中。还是要看putVal方法中 (n - 1) & hash 结果决定的。

在这里插入图片描述

这里有两个疑问
1.为什么是hash&(n -1 )而不是hash&(n )?

因为n会被始终处理为2的n次幂。而偶数二进制中最后一位一定是0。那么 hash&(n ) 最终结果二进制中最后一位一定是0,也就意味着结果一定是偶数这会导致数组中只有偶数位被用了,而奇数位就白白浪费了。无形中浪费了内存,同样也增加了hash碰撞的概率。

2.既然要使用偶数,为啥偏偏是2的n次方?

2的n次方能保证(n - 1)低位都是1,能使hash低位的特征得以更好的保留,也就是说当hash低位相同时两个元素才能产生hash碰撞。换句话说就是使hash更散列。

3.为什么key的hash值要再散列?(h = key.hashCode()) ^ (h >>> 16)

如果只是(n-1)&key.hashCode()
假如有一类 hash,特点是低位都是 0,高位才有变化。
1000 0000 0000 0000 0000 0000 0000 0000
0000 0001 0000 0000 0000 0000 0000 0000
0000 0100 0000 0000 0000 0000 0000 0000
0000 1010 0000 0000 0000 0000 0000 0000
而此时hashMap的n为4,那么无论高位如何不一样,只要低四位一样,那么计算的结果都是0,就造成了很严重的hash碰撞,高位没有参与计算

为了避免上述这种特殊的情况,就需要高位也参与运算,这就是需要重新计算hash值的原因。
(h = key.hashCode()) ^ (h >>> 16) 将hash值无符号右移16位再与原来的hash值异或运算,可以使高位特征保留并参与到计算元素在桶中的具体位置结合高位体征和低位特征,降低哈希碰撞的概率

那么怎么保证n一定是2的n次幂呢

tableSizeFor方法

该方法作用是返回一个大于输入参数且最小的为2的n次幂的数

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

举例:当输入为13的时候,n等于12,转成二进制为1100,右移1位为0110,将1100与0110进行或("|")操作,得到1110。接下来右移两位得11,再进行或操作得1111,接下来操作n的值就不会变化了。最后返回的时候,返回n+1,也就是10000,十进制为16。按照这种逻辑得到2的n次幂的数。

分析算法,实现把从最高位开始第一个为1的位之后所有的位全部变成1此时返回n+1即可得到一个正好比原数大的最小的2的n次幂

还有一个问题,为什么要在前面减1即 n = cap - 1?
减一是为了传进来的本身就是2的幂次方整数这种情况不减一会返回本身的两倍,减一返回本身

三 重要成员变量和函数

// 16 默认初始容量(这个容量不是说map能装多少个元素,而是桶的个数)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量值
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化阈值 一个桶链表长度超过 8 进行树化
static final int TREEIFY_THRESHOLD = 8;
//链表化阈值 一个桶中红黑树元素少于 6 从红黑树变成链表
static final int UNTREEIFY_THRESHOLD = 6;
//最小树化容量,当容量未达到64,即使链表长度>8,也不会树化,而是进行扩容。
static final int MIN_TREEIFY_CAPACITY = 64;
//桶数组,bucket. 这个也就是hashmap的底层结构。
transient Node<K,V>[] table;
//数量,即hashmap中的元素数量
transient int size;
//hashmap进行扩容的阈值。 (这个表示的元素多少,可不是桶被用了多少哦,比如阈值是16,当有16个元素就进行扩容,而不是说当桶被用了16个)
int threshold;
//当前负载因子,默认是 DEFAULT_LOAD_FACTOR=0.75
final float loadFactor;
/************************************三个构造方法***************************************/
public HashMap(int initialCapacity, float loadFactor) {//1,初始化容量2,负载因子
    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);//总要保持 初始容量为 2的整数次幂
}
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

这里需要注意
1.当容量未达到64,即使链表长度>8,也不会树化,而是进行扩容。
2.一个桶中红黑树元素少于 6 从红黑树变成链表
3.默认初始容量DEFAULT_INITIAL_CAPACITY不是说map能装多少个元素,而是桶的个数
4.threshold表示hashmap进行扩容的阈值。 (这个表示的元素多少,可不是桶被用了多少哦,比如阈值是16,当有16个元素就进行扩容,而不是说当桶被用了16个)

四 put过程源码分析

熟悉了以上算法和成员变量,我们就可以看一下重要方法的源码了

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //put1,懒加载,第一次put的时候初始化table(node数组)
    if ((tab = table) == null || (n = tab.length) == 0)
     //如果table为null或者长度为0,hashmap数组初始化
        n = (tab = resize()).length;
        //计算下标,返回null代表没有hash碰撞
    if ((p = tab[i = (n - 1) & hash]) == null)
    //new一个Node放入数组中
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //如过命中第一个节点,覆盖旧值
        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) {
            //在链表尾部追加
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //树化(转化成红黑树)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //如果key已经存在,覆盖旧值
                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;
        }
    }
    ++modCount;
    //当size > threshold,进行扩容。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

可以看到put方法中调用putVal方法进行元素的添加,hash(key)方法获得二次hash后的hash值作为putVal的入参
1.判断当前table是否为空,hashMap将初始化操作放在第一次put的时候
2.计算hash及桶下标。
3.判断是否发生hash碰撞
3.1 没有发生碰撞,new一个node直接放入桶中
3.2 发生碰撞
(1) 如过命中第一个节点,直接覆盖节点并返回旧值
(2)如果是红黑树,插入到红黑树中。
(3)如果是链表,存在两种情况,超过阈值转换成红黑树,否则直接在链表后面追加,(当数组长度小于64时,进行扩容而不是树化)
4.根据上述步骤找到的key覆盖旧节点并返回旧值。
5.如果size > threshold。进行扩容。

看一下treeifyBin方法

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果容量 < 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);
    }
}

这里为什么链表长度大于8了还要满足元素个数不小于64才会进行扩容呢?

假如容量为16,然而插入了9个元素,它们都在同一个桶里面,如果这时进行树化,树化本身就是一个耗时的过程。时间复杂度会增加,性能下降,不如直接进行扩容,空间换时间。

五 扩容

在put方法里,我们不得不对扩容方法resize进行深入的探究

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)
            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);
    }
    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];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //只有一个元素,直接移到新的桶中(为什么不先判断是不是TreeNode?注意TreeNode没有next节点,同样返回为null
                if (e.next == null)
                //使用newCap计算桶下标
                    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;
                        //判断成立,说明该元素不用移动
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        //判断不成立,说明该元素要移位到 (j + oldCap) 位置
                        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;
                        //j + oldCap即newIndex
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

程总结:
1.先确定newCap和newThr
2.创建一个两倍于原来(oldTab)容量的数组(newTab)
3.遍历oldTab
3.1,如果当前桶没有元素直接跳过。
3.2,如果当前桶只有一个元素,直接移动到newTab中的索引位。(e.hash & (newCap - 1))
3.3,如果当前桶为红黑树,在split()方法中进行元素的移动。
3.4,如果当前桶为链表,执行链表的元素移动逻辑。

此处的疑问应该是为什么e.hash & oldCap) == 0,则说明元素不需要移动呢?

loHead和loTail分别对应经过rehash后下标保持不变的元素形成的链表头和尾。
hiHead和hiTail分别对应经过rehash后下标变为原来(n + oldIndex)后的链表头和尾。
经过上面变量,我们不难发现,桶中的数据只有两个去向。(oldIndex和 n + oldIndex)

我们假设初始容量为256 = 28,那么扩容后为512 = 29
根据hash & (n- 1):假设hashCode为:
0000 0101 0000 0101 0000 0000 0000 0100
那么indexOld = hashCode & 255
0000 0101 0000 0101 0000 0000 0000 0100
&
0000 0000 0000 0000 0000 0000 1111 1111

0000 0000 0000 0000 0000 0000 0000 0100
那么indexNew = hashCode & 511
0000 0101 0000 0101 0000 0000 0000 0100
&
0000 0000 0000 0000 0000 0001 1111 1111

0000 0000 0000 0000 0000 0000 0000 0100
此时发现两次结果是一致的
如果把原来的hashCode
0000 0101 0000 0101 0000 0000 0000 0100
低第九位改为1即(为什么是低第九位因为扩容后是512 = 29
0000 0101 0000 0101 0000 0001 0000 0100
oldCap为 0000 0000 0000 0000 0000 0001 0000 0000
oldIndex 为0000 0000 0000 0000 0000 0000 0000 0100
newIndex为0000 0000 0000 0000 0000 0001 0000 0100
==仔细观察 发现:newIndex = oldCap + oldIndex ==

得出结论
低第n位(n位以而为底newCap的对数)
为1时:newIndex = oldCap + oldIndex,
为0时:newIndex = oldIndex

回到最初的疑问:e.hash & oldCap) == 0
即0000 0101 0000 0101 0000 0001 0000 0100
& 0000 0000 0000 0000 0000 0001 0000 0000
发现结仍然果取决于低第九位,为0时结果也为0,而此时代表的是newIndex = oldIndex,即不需要移动

六 get源码分析

public V get(Object key) {
    Node<K,V> e;
    //计算哈希,调用getNode方法获得Node
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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) {
        //获取桶的头节点,如果头结点key等于目标key直接返回。
        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;
}

gert方法较为简单,不再赘述

七 版本优化

jdk7的死循环问题

jdk8之前采用头插法,因为作者认为新插入的数据被使用的概率更大,但是有一个弊端就是并发情况会造成链表闭环,get时死循环,主要发生在扩容方法的transfer 方法

void transfer (Entry[]newTable,boolean rehash){
    int newCapacity = newTable.length;
    for (Entry<K, V> e : table) {
        while (null != e) { // 遍历链表放入新表
            Entry<K, V> next = e.next; //最严重的部分,先保留了next此时已经被另一个线程更新
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);                 // 插入到桶中链头
            e.next = newTable[i];
            newTable[i] = e; // 放入桶中链表头
            e = next;        // 链向原表头
        } // while
    }
}

首先明白这段代码:
1.记录当前节点的next节点
2.indexFor计算桶位置
3.当前节点的next指向桶内链表头结点
4.当前节点放入原表头完成头插
5.e指向第一步记录的next节点,若不为null循环执行逻辑

为什么会造成闭环呢?
现在假设某一条链表e1.next = e2,并且重新hash后还在同一个桶里面

(1).线程A拿到next = e1.next即已经拿到下一个节点e2后挂起。此时e = e1,next = e2
(2).线程B对链表扩容成功此时e2在链头,e2.next = e1.
(3).此时线程A继续执行,此时e = e1,next = e2,头插法会把e即e1插入链头,因为next = e2 赋值给e!=null,则while不退出,再次执行逻辑,此时e =e2而e2.next = e1(线程B造成的结果)导致执行到第1步时next = e1第3步会将e2.next指向链头的e1,第4步执行完毕此时桶中e2在链头,e2.next = e1,所以第5步e=next即又指向了e1,因为e!=null,需要继续指向逻辑
(4)因为next=e1.next =null,循环结束,但是会把e1放入链头,并且桶中e1.next = e2,但(3)中e2.next = e1,这样变成了闭环

当执行get方法遍历链表时就会造成死循环

jdk8采用尾插法很好的避免了这个问题,那么jdk8就是线程安全的吗?不是的

jdk8的数据丢失问题

回想putVal方法

if ((p = tab[i = (n - 1) & hash]) == null)

这段代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

除此之前,++size操作和resize等操作都会因为原子性问题造成数据丢失或者覆盖,本文不再赘述,有兴趣的读者可以再行研究
推荐阅读:《并发高级之详解volatile——你看到了多少假象》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值