[HashMap 源码详解] 阿里面经: 不要再说 hashCode 默认实现是取内存地址了, 必挂!

本文无废话, 全干货, 由 hashCode 方法开始讲起, 带你完全重新认识 hashCode 方法, 并囊括 HashMap 所有可能的八股文知识, 附带源码详细解读;
然后将会讲解阿里面试问题, 进行知识提炼与提升, 希望大家都能认真看完;

hashCode

Object 的 hashCode 方法, JDK1.8 的默认实现是通过线程状态和移位异或的算法计算出来的, 并不是内存地址; 内存地址是老掉牙的版本的默认实现, 这个问题不要再踩坑了, 说内存地址必挂, 必挂, 必挂 !!!

Java 的哈希算法要求返回一个 32 位长的哈希码, 即一个 int;

Integer 的哈希算法, 返回的就是 Integer 底层的 value值;

Byte 和 Character 的 hashCode , 就是把底层的 value 强转成 int 返回;

Long 的 hashCode, 因为 long 的长度已经超过了32 , 所以截取出 32 位, 并通过移位异或的方式把高位数据带入到低位中, 增加扰动;

return (int)(value ^ (value >>> 32));

Float 和 Double 的 hashCode, 转成对应的 IEEE754 标准的二进制表示, 用 int 或 long 保存; 然后依据 Integer 和 Long 的规则再 hash; 例如 Double, 取二进制表示后 return (int)(value ^ (value >>> 32));

String 的 hashCode, 从最左边的字符到最右边的字符给不同的权值, 每一位的权值是 31 的若干次方, 最高位是 0 次方; 将每一位的权值乘以每一位字符的整型值, 相加得到最终结果 (空字符串的哈希值是0);

如果要用数组的 hashCode, 应该使用 Arrays.hashCode(), 而不是直接调用 数组对象的 hashCode 方法;

Arrays.hashCode(), 其原理和 String 的 hashCode 基本一致, 以 31^k 作为权值, 对每一个元素, 先调用该 hashCode方法, 再乘以每个元素各自的权值; 下标为 n - 1 的元素权值为1, 下标越小权值越大;

下标0的元素有特殊处理, = 权值 * ( 31 + 0号元素的hashCode )

HashMap

  • LinkedHashMap是HashMap的子类, 在HashMap的基础上, 维护了一个双向链表, 该双向链表在记录插入顺序和记录访问顺序之间二选一, 总之就是用来记录顺序, 实现按顺序的迭代; LinkedHashMap 返回的迭代器, 是在双向链表上移动的;
  • HashMap底层实现是Node数组 + 链表 + 红黑树TreeNode, Node是内部类, 实现了Map.Entry, 表示一个键值对;

Hash值

  • 每个Node有一个成员 int hash 来记录这个键值对的哈希值, 这个值由 key.hashCode()经过移位异或计算得出; 如果key是null, hash = 0;
// Object的hashCode方法对equals返回true的对象的哈希值也是一样的
// HashMap定义的hash方法
static final int hash(Object key) {
    int h;
    // Object的hashCode 异或 其无符号右移16位, 为了减少特征损失
    // 例如, 散列时是根据hash值%数组长度, 这样的话相当于只有低若干位真正起到了区分效果
    // 所以异或右移后的结果, 将高位信息带入一些到低位中
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 多加一步移位异或, 是为了增加扰动;
  • 计算下标时, 本应通过取模来做, 但考虑到效率较低, 用取与代替, 并且将数组长度设置为2的整数次幂, 来保证取模和 n - 1 取与的结果一致; 另外长度为 2 的整数次幂, 在扩容时, 50% 的结点不需要移动位置, 后面讲低位树会讲到;

HashMap的成员

 // 底层数组, 初始为null
transient Node<K,V>[] table;
// 记录修改数, 防止并发修改
transient int modCount; 
// 用于扩容
int threshold; 
// 装填因子, 默认值0.75
final float loadFactor; 

// 红黑树相关
static final int TREEIFY_THRESHOLD = 8; // 大于8且容量大于等于64时树化
static final int UNTREEIFY_THRESHOLD = 6; // 小于等于6链表化
static final int MIN_TREEIFY_CAPACITY = 64;

遍历

  • 三种遍历方式: entrySet(), values(), keySet(), 和 ArrayList 类似, 这三个方法所返回的集合上的 Iterator 的移动都是直接在原本的HashMap对象上进行的;

这里可能有同学会看不懂, 关于 ArrayList 和 Iterator, 后面会再出专门的文章讲解;

  • 这仨方法的返回值, 并不重要, 重要的是在返回值上取到的 Iterator, 为什么三种方式的遍历结果有的能拿到value, 有的能拿到整个 Entry ? 归根结底在于 Iterator 的 next 方法的返回值不同; 这仨的 iterator 都继承自HashIterator:

    abstract class HashIterator {
        Node<K,V> next;        // next entry to return, 相当于cursor
        Node<K,V> current;     // current entry, 相当于lastRet
        int expectedModCount;  // for fast-fail, 并发修改异常, 和ArrayList一样
        int index;             // current slot, 记录当前Node数组下标的位置
    
  • entrySet() 返回一个Set<Entry>, 实际上也就是<Node>, set.iterator() 返回一个迭代器, 该迭代器利用Node的next指针, 遍历一个下标位置的链表, 指向null了就 table[slot++] 遍历下一个链表, 从而完整遍历Node数组; 其next方法返回Node结点的引用;

  • values原理一样的, 只不过它的iterator.next方法返回的是node.value

  • KeySet也是一样, 它的iterator.next方法返回的是node.key

  • 这三个视图的iterator都有expectedModCount, 作用原理和ArrayList一样

扩容

  • 初始容量:

    除直接从集合构造外, 其余构造函数不会分配实际内存空间, 都是在首次添加元素的时候分配空间;

    无参构造, threshold = 0; 分配空间时容量设为16;

    有参构造指定了 initialCap 时, 最终会调用 tableSizeFor方法, 取一个 >= initialCap 的 2 ^ n 作为容量, 最大为 1<<30;

    从集合 c 构造, 由 c.size() 除以自己的 loadFactor 装填因子, 再向上取整, 算一下容量最小min是多少, 然后调用tableSizeFor 返回一个 >=min 的 2^n, 然后立即分配空间(并调用putVal方法将源集合 c 中的元素复制过来)

  • 扩容机制: 首次扩容时, 按存到 threshold 里的容量扩容, 并更新 threshold 为阈值;

    其它时候, 当元素个数大于 threshold 时扩容, 直接double原来的阈值threshold; 逻辑上, capacity也一并double

  • HashMap底层数组由len扩容到2*len的时候, 原本在旧数组x位置的元素: 要么重新散列到x位置, 要么在len+x的位置; 原理后面会提到;

HashMap源码解读

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// HashMap支持的最大容量
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时不会树化,而是直接扩容原表
static final int MIN_TREEIFY_CAPACITY = 64;

//--------------------------------成员-------------------------------------

// 底层数组
transient Node<K,V>[] table;

// 实际上是EntrySet对象
transient Set<Map.Entry<K,V>> entrySet;

// 元素个数
transient int size;

// 用于value() 和entrySet() 上的interator
transient int modCount;

// = 当前容量(刚构造完时可能出现还没分配的情况) * loadFactor
int threshold;

// 装填因子
final float loadFactor;

// 无参构造,参数都用默认值, 注意此时初始容量threashold = 0;
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 并没有真正分配空间,只是确定参数
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;
    // 重点,刚构造还没分配空间时, threshold先用作capacity
    // 以后threshold = capacity * 装填因子loadFactor, 当元素个数超过threshold的时候扩容
    this.threshold = tableSizeFor(initialCapacity);
}

// 初始容量确定
// 和ArrayDeque类似,不过先减了1,这样最终结果是 >= cap 的2的整数次幂(power of two)
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    // 假设 n 的最高位是第3位, 即 1xx;
    // 那么右移以后变 1x
    // 取或以后得 11x;
    // 也就是说, 经过这一步, 原本最高位的1, 它的低一位无论以前是多少, 一定会变成1;
    n |= n >>> 1;
    // 同理, 最高位以及后面的 3 位一定会为全1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    // 最高位以及后面的 31 位一定会为全1; 也就是说, 无论 n 是多少, 最终都会变成若干位全1;
    // 最后返回的时候再 + 1, 就变成了 10000000 这种形式, 即 >= cap 的 2^n
    n |= n >>> 16;
    // 注意这时还没+1
    // 如果给的n是0或者负数,左全改成1后小于零,返回1
    // 如果给的n >= 1<<30, 那么现在仍 >= 1<<30, 此时返回 1<<30
    // 否则返回n+1, 也就是 >= 给定值的2的整数次幂
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

// 添加方法最终都会到这来
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // n = table.length
    // 1.首次添加时先resize
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. i = %数组长度, 也就是要放到的位置, 如果这个位置为null, 直接放到table[i]
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        
        Node<K,V> e; K k;
        // 3.如果table[i]已经有元素了, 这时p指向它
        // 3.1如果 p和要加入的Node的一样,认为完全重复,不会加入新结点,直接更新p的value
        //    并将原本的value作为返回值返回
        //    可以看到只有两个Node的hash值一样并且 (key== 或者equals)时, 才认为两个Node一样
        if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 3.2如果这里已经是红黑树了,将新节点尝试加入红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 3.3如果这里不是红黑树, 尝试加入并检查是否需要树化
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    // 当某一轮p指向第8个节点, 此时binCount=7, 如果这时p的下一个结点为null, 
                    // 那么将新节点放到p.next, 此时共有9个结点
                    p.next = newNode(hash, key, value, null);
                    // binCount=7, 树化, 也就是说拉链长度为9时树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果在拉链中发现了完全一致的结点, 结束循环, 用新value更换旧value
                if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // p = e用于遍历链表
                p = e;
            }
        }
        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;
}

// 扩容机制
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // 首次分配空间时, oldCapacity = 0; 
    // 2.其余时间 oldCap = 原表长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 1.有参构造首次分配时oldThr = 应该分配的空间
    // 2.无参构造首次添加时, oldThr = threshold = 0
    // 2.其余时间 oldThr就是原本的阈值
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 2.首次扩容以外, 如果已经到最大容量了, 不会再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 2.如果没有达到最大容量, 并且capacity*2后小于最大容量, 并且原容量>=16时, double容量阈值
        // 3.如果原容量 < 16, 例如有参构造先分配了8, 进行判断时执行newCap = oldCap << 1
        // 3.newCap = 16 , newThr = 0; 相当于这里只double了容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 1.有参构造首次分配空间时直接走到这
    else if (oldThr > 0)
        // 1.新容量 = 构造时计算出的容量, 应该分配的空间
        newCap = oldThr;
    else { // 4.无参构造时oldThr = 0, 走到这, 按容量16初始化, 跳过下一个if, 直接开始分配
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // 有参构造首次分配时newTHr == 0,会进入这个if
    if (newThr == 0) {
        // 3.这里double阈值, 好像没什么区别
        float ft = (float)newCap * loadFactor;
        // 1.现在已经分配空间了, threshold不再用作容量, 而是 = cap * 装填因子
        // 2.如果新容量 >= 1<<30了, 那么threshold = int最大值, 也就是说不会再扩容了
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    // newCap的值作为新的总容量, 按其分配空间
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 复制原本的table
    if (oldTab != null) {
        .......
    }
    return newTab;
}

面试: 红黑树转化的阈值是多少?

  • 单个量表长度 大于等于 9 时转化为红黑树 (仅当桶数 大于等于 64时, 因为当数组长度过小的时候, 在添加数据的过程中, 数组会反复扩容, 导致红黑树拆开又变为链表, 反复在红黑树和链表间转化效率不高/意义不大)

  • 记住是 > 8, 也就是 >= 9;

  • 为什么是8?

    这是在时间和空间上的一个权衡 (因为虽然红黑树在结点多的时候查找更快, 但红黑树结点的大小约为链表结点的两倍); HashCode 算法设计理想时, 不同长度链表的出现概率满足泊松分布, 当 Load Factor 为 0.75 时, 链表长度为 9 的可能小于千万分之一, 几乎是不可能发生的;

    因此, 在 hash 算法设计良好的哈希表中, 很少会有红黑树;

  • 扩容时, 中序遍历原本的红黑树中的结点, 将他们重新散列到两个位置, 拆分后新构建的红黑树节点总数小于等于6时转化为链表, 两个阈值不一样是为了避免频繁的转化;

  • 以容量从 4 -> 8 为例, 原本在下标2的一棵树, 里面的hash肯定都是xxxx xx10的形式, 扩容后, 新下标由低三位决定, 原本下标为2的 entry 的 Hash 值有两种情况, xxxx x110 和xxxx x0110, 第三位的值有两种情况, 0或1, 如果是0, 说明在新数组的下标没变(低位树), 是1说明新下标为4+2(高位树)

  • 不仅拆分高低树时会发生反树化, 删除结点时, 如果红黑树根节点为null, 或root.left为null, 或root.right为null, 或root.left.left为null, 都会转回链表

// 添加方法最终都会到这来
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. i = 模数组长度, 也就是要放到的位置, 如果这个位置为null, 直接放到table[i]
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        
        Node<K,V> e; K k;
        // 3.如果table[i]已经有元素了, 这时p指向它
        // 3.1如果 p和要加入的Node的key和hash值都一样,认为完全重复,不会加入新结点,直接更新p的value
        //    并将原本的value作为返回值返回
        if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 3.2如果这里已经是红黑树了,将新节点尝试加入红黑树
        else if (p instanceof TreeNode)
            // 如果和根节点完全重复, 不做操作, 把企图加入的节点直接返回
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 3.3如果这里不是红黑树, 尝试加入并检查是否需要树化
            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;
                }
                if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
                    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;
        }
    }
    // 添加完成后size++并检查元素个数是否超过了阈值, 是则扩容
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

面试: 什么时候认为两个键值对重复?

  • 两个键值对的 key 满足
    (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))

  • 需要注意Node有四个成员, hash, key, value, next; 当往哈希表中存放key-value时, 存放时刻key的hash值被保存在Node的hash成员中, 以后再比较时并不会去计算Node中key的hash值, 而是直接用Node.hash

    假设我们的key是一个Student类对象student, 并且重写了hashCode和equals方法, 那么把student作为key放到表中以后再修改student的某一字段, 就可能导致student的hashCode变化, 这时hash(student) != Node.hash, 就没办法找到我们以前插入的key-value了

  • 归根结底, 哈希表是用来查找的, 不要存放可能改变的key

面试: 为什么JDK1.8之后往链表插入新键值对的时候改为尾插法

面试: 都知道 HashMap线程不安全, 你能举些例子吗?

覆盖

  • 或者说是元素丢失问题
  • 两个线程同时调用put方法, 计算得到了相同的index, 两个线程又同时通过了这个位置为null的判断, 就会发生覆盖问题; 本来应该拉链法解决冲突的, 现在位置上只有一个元素;

异常

  • 当链表长度 > 8时, 将链表转为红黑树, 结点类型由Node转为TreeNode
  • 线程一插入的时候按Node普通链表去遍历查找插入位置
  • 线程二插入完成导致树化
  • 切回线程一, 本来按Node处理, 但是现在已经变成了TreeNode的结构, 抛出异常

扩容时问题

  • resize方法扩容后复制元素时

    Node<K,V>[] oldTab = table;
    // ....
    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;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
    
  • 假设两个线程同时调用put方法, 最终都进入了resize方法, 线程1先让table = newTab1;

  • 此时切换到线程2, 执行resize方法一开始的 oldTab = table, 现在线程2的 oldTab 直接没有元素了, 复制个寂寞

本文全原创, 创作不易, 如果感觉对您有帮助请点个赞鼓励一下作者;

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值