面试题学习: HashMap

目的

面试准备, 方便后续复习方便.

资源

B站的一个讲高频面试题的一个学习视频

核心知识点

底层数据结构

  1. 1.7: 数组 + 链表
  2. 1.8: 数组 + (链表/红黑树)
	// 链表
    static class Node<K,V> implements Map.Entry<K,V> {
    
    ...}
    // 红黑树
	static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    
    ...}

树化与退树化

为何要用红黑树

1.8为了解决链表过长, 而影响查询效率的问题, 引入了红黑树.

为何一上来不树化

  1. 红黑树用来避免DoS攻击, 防止链表超长时新能下降, 树化应当是偶然情况. (可以认为是对1.7的一次优化, 不用1.8不不会有多大的影响, 毕竟1.7多用了好多年了)
    1.1 没有必要一上来就树化, 用链表应该也可以, 链表的元素也不多, 没必要用红黑树. 红黑树查找/更新的时间复杂度是O(logn), TreeNode占用空间比普通Node, 有着更多的成员变量, 更复杂, 如果非必要, 建议使用链表.
    TreeNode 里面的代码很多, 其实还是非常复杂的, 元素旋转啥的.

树化阈值为何是8

hash值如果足够随机, 则在hash表内按泊松分布, 在负载因子0.75的情况下, 长度超过8的链表出现的概率是0.00000006, 选择8就是为了让树化的概率足够小.

何时树化

  1. 链表长度超过阈值8
				// put 元素, 若果是链表的一段源码逻辑
                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;
                }
  1. 数组容量>=64
    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
    
    
        int n, index; Node<K,V> e;
        // 如果容量不够, 会先扩容, 不会去树化
        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);
        }
    }

何时退化为链表

  1. 在扩容时如果拆分树时, 树元素个数<=6, 则会退化为链表
// 红黑树元素少于6, 退化
if (lc <= UNTREEIFY_THRESHOLD)
                    tab[index] = loHead.untreeify(map);
  1. remove树节点时, 如果root, root.left, root.right, root.left.left 有一个为null, 也会退化为链表
// 节点元素情况校验, 看是否满足退化条件
            if (root == null || root.right == null ||
                (rl = root.left) == null || rl.left == null) {
    
    
                tab[index] = first.untreeify(map);  // too small
                return;
            }

几个成员变量

// 负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化的其中一个条件
static final int TREEIFY_THRESHOLD = 8;
// 退树化的一种情况
static final int UNTREEIFY_THRESHOLD = 6;
// 树化: 最小的容量
static final int MIN_TREEIFY_CAPACITY = 64;

索引如何计算

  1. 计算对线的hashCode()
  2. 再调用HashMap的hash()方法进行二次哈希
    static final int hash(Object key) {
    
    
        int h;
        // (对象原始code) 异或 (对象原始code高16位)
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  1. 最后 & (capacity - 1) 得到索引
    索引: i = (n - 1) & hash

hashCode都有了, 为何还要提供hash()方法?

二次hash()是为了综合高位数据, 让哈希分布更加均匀

数组容量为何是2的N次幂

计算索引时, 如果是2的N次幂, 可以使用位与运算代替取模, 效率更高; 扩容时 hash & oldCap == 0 的元素留在原来的位置, 否则新位置 = 旧位置 + oldCap.
1 取代取模的运算
2.扩容时, 元素迁移效率更高.

put方法流程

  1. HashMap是懒惰创建数组的, 首次使用才创建数组
  2. 计算索引(桶下标)
  3. 如果桶下标还没有占用, 创建Node占位返回
  4. 如果桶下标已经有人占用
    4.1 已经是TreeNode走红黑树的添加或更新逻辑
    4.2 是普通Node, 走链表的添加或更新逻辑
    4.3 如果链表长度超过树化阈值, 走树化逻辑
  5. 返回前检查容量是否超过阈值, 一旦超过进行扩容
    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. HashMap是懒惰创建数组的, 首次使用才创建数组
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 2. 计算索引(桶下标): (n - 1) & hash
        if ((p = tab[i = (n - 1) & hash]) == null)
        	// 3. 如果桶下标还没有占用, 创建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)
            	// 4.1 已经是TreeNode走红黑树的添加或更新逻辑
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
    
    
            	// 4.2 是普通Node, 走链表的添加或更新逻辑
                for (int binCount = 0; ; ++binCount) {
    
    
                    if ((e = p.next) == null) {
    
    
                        p.next = newNode(hash, key, value, null);
                        // 4.3 如果链表长度超过树化阈值, 走树化逻辑
                        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;
            }
        }
        ++modCount;
        // 5. 返回前检查容量是否超过阈值, 一旦超过进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

1.7与1.8有何不同

  1. 链表插入节点时, 1.7是头插法, 1.8是尾插法
  2. 1.7是>= 阈值 && 没有空位才扩容, 而1.8是大于阈值就扩容
  3. 1.8 在扩容计算Node索引时, 会优化

加载因子为何默认是0.75f

  1. 在空间占用与查询时间之间取得比较好的权衡
  2. 大于这个值, 空间节省了, 但链表就会比较长影响性能
  3. 小于这个值, 冲突减少了, 但会频繁扩容, 空间占用多

多线程下会有啥问题

  1. 扩容死链(1.7)
  2. 数据错乱(1.7, 1.8)

key能否为null, 作为key的对象有什么要求

  1. HashMap 的key可以为null, 但是其他Map的其他实现则不然
  2. 作为key的对象, 必须实现 hashCode 和 equals, 并且 key 的内容不能修改. (不可变)

String对象的 hashCode 如果设计的, 为啥每次乘的是31(了解)

    public int hashCode() {
    
    
        int h = hash;
        if (h == 0 && value.length > 0) {
    
    
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
    
    
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }
  • 目标是达到较为均匀的散列效果, 每个字符串的hashCode足够独特
    在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/xiaozhengN/article/details/127194681