容器之HashMap源码解析

HashMap源码解析

关于HashMap的源码解析网上貌似不少了,因此不根据知识点进行汇总,而是根据可能问到的问题一步一步深入吧,这样也便于八股哈哈。

版本的区别

JDK1.7和JDK1.8中HashMap的区别

简单答:

JDK1.7JDK1.8
数据结构数组+链表数组+链表/红黑树
链表插入方法头插尾插
数组初始化调用inflateTable()初始化一个数组直接在第一次插入时resize
扩容时机插入前扩容插入后扩容
扩容策略2倍+rehash数组容量小于64则2倍,如果数组大小>64且链表长度>7则转为红黑树
hash计算直接使用key的hashcodehashcode(key)^hashcode(key)>>>16

为啥引入红黑树?

哈希碰撞难以避免,极端情况下HashMap的查找时间复杂度也是O(n),如果采用红黑树可以降到O(logn)

与其他容器的区别

HashMap和HashTable的异同

同: 都是key-value

异:

  1. HashMap可以保存key为null的键值对,始终存在数组下标为0的地方,HashTable存不了key为null的键值对
  2. HashMap是线程不安全的,HashTable对每个方法加上了synchronized
  3. HashMap实现的是AbstractMap接口,而HashTable实现的是Dictionary接口
  4. 迭代器:HashMap使用的是fail-fast的迭代器,通过比较modCount来判断该HashMap有没有在迭代的时候被修改,如果modCount不一致则抛出异常;HashTable不是fail-fast的,因为线程安全
  5. 初始值和扩容方式:HashMap初始大小为16,每次扩容2倍;HashTable初始大小11,每次扩容为2n+1
  6. key-value的hash算法不同,hashmap是自定义算法,HashTable是采用key自带的hashcode()。

和ConcurrentHashMap的异同

这个见ConcurrentHashMap的源码分析部分吧,有点复杂

源码细节

简单讲讲插入过程

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1.判断数组是否初始化,未初始化则直接resize
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2.数组对应位置为空,直接赋值
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    	// 如果最终找到了相同的key的node,则由e保存
        Node<K,V> e; K k;
        // 3.数组对应位置存在链表,首先比较头结点
        // 3.1. 首先比较hash,hash不等则key一定不会相等,hash相同key可能相同
        // 3.2. hash相同再使用equals进行比较
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4. 头结点的key不相同,开始对链表进行查找
        // 4.1. 如果已经树化了,则采用红黑树的插入方式
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 4.2. 还是链表,则逐个开始匹配
        else {
        	// binCount用来计数
            for (int binCount = 0; ; ++binCount) {
            	// 4.2.1 遍历到链表末尾还没找到,则直接进行尾插,并检查是否需要树化
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 4.2.2 对链表中的每一个节点进行匹配,规则与之前一致
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 5. 如果找到了key相同的节点,修改节点的值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 6. 修改结束后进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

大致流程如注释,较为简单,记住关键点:

  1. 使用的是尾插:避免多线程下的循环链表问题
  2. 第一次插入的时候进行数组的初始化
  3. 判断key相同的顺序:hash->equals,本质就是理解hashcode()和equals()之间的关系
  4. treeify的条件:数组容量>64且链表长度>=8,注意到数组容量是在treeifyBin方法中检查的,如果小于64同样是调用resize方法
  5. 插入后判断扩容:如果是插入之前就扩容,扩容的部分可能会不使用,因此产生浪费

为啥JDK1.8计算数组索引时是用按位与?

为啥HashMap数组大小是2的n次幂?

答:
本质上来说,HashMap计算数组索引的方法是使用取模的方法,即MOD。但是取模具有如下的缺点:

  1. 计算慢
  2. 负数取模是负数
    但是很巧的是对于 2 n 2^n 2n进行取模运算有如下性质:

x % 2 n = = x ∧ ( 2 n − 1 ) x \% 2^n == x \land (2^n-1) x%2n==x(2n1)

这个公示巧妙的将模运算变成了按位与运算,解决了模运算的缺点,因此最终计算下标的公式就是hash ^ (n-1),n也因此一般取2的n次幂。

get方法是如何实现的?

和插入其实差不多,本质也是找相同的key的Node,找不到返回null,略略略

讲讲扩容?

JDK1.7
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
 * Transfers all entries from current table to newTable.
 */
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); //重新计算该节点的hash,并重新计算下标
            e.next = newTable[i];                  //进行头插
            newTable[i] = e;                       
            e = next;                              //移动到下一个节点
        }
    }
}

可见JDK1.7中较为简单,就是根据新的容量创建一个新的数组,然后将原数组中的所有entry重新计算一遍hash然后插入进去。

但注意的是:

  • 使用头插法的结果:插入新的数组的顺序和原来数组上链表的顺序相反。
  • 注意:这种逆序的扩容方式在多线程时有可能出现环形链表,出现环形链表的原因大概是这样的:线程1准备处理节点,线程二把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。
JDK1.8

本质是一样的,但是实现方法略微复杂,略过

为啥JDK1.8变成尾插法?

JDK1.7及之前使用的是头插法,这在并发的时候会产生循环链表的问题,具体是在resize()的时候进行rehash()从而发生的。具体原因如下:

状态①: 线程A、B同时对HashMap进行扩容,且同时获取了节点1准备进行rehash的计算
在这里插入图片描述

状态②: 线程A率先完成了rehash的计算,假设节点1和2巧合地又计算出相同的索引,很明显根据头插法,此时2应该作为第一个节点。注意线程B获取到的还是节点1,即他等下先对节点1进行rehash的计算。
在这里插入图片描述
状态③: 线程B进行如下操作,首先rehash,得到的索引肯定还是相同的,因此进行头插,头插的步骤如下:

e.next = newTable[i];                  //进行头插
newTable[i] = e;                       
e = next;                              //移动到下一个节点

因此这一步的结果应该如下:
在这里插入图片描述
很明显此时形成了环形链表,在get的时候因为要对链表进行遍历,可能会造成死循环。尾插法的话则不会有这个问题。

平时在使用HashMap时一般使用什么类型的元素作为Key?

一般采用不可变类型,如Integer,String。如果对该类型进行了修改一般是重新new一个对象,此时hashcode也会相应的变化。不可变类型天然就是线程安全的,=。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值