吊打面试官系列之 hashmap(面向面试吐血整理)

hashmap复习笔记(面向面试吐血整理)

本文结合优秀博客,jdk 源码,在源码上进行逐行注释,全面整体分析 hashmap1.7,1.8,以及ConcurrentHashMap 1.7,1.8,给出其代码的框图已经整体的代码流程总结,在最后还会结合大厂真实面试题进行总结,对比1.7,1.8版本的区别,一文打通容器,秒杀面试官,总体目录如下

文章目录

  • 1. java7 hashmap
    • 1.1 基本属性以及整体概括
    • 1.2 put 过程分析
      • 1.2.1 put 过程大致流程图
      • 1.2.2 初始化过程
      • 1.2.3 计算 key 对应 index
      • 1.2.4 添加 节点到链表中
      • 1.2.5 数组扩容
    • 1.3 get 过程
  • 2. java8 hashmap
    • 2.1基本属性以及整体概括
    • 2.2 put 过程分析
      • 2.2.1 put 大致流程图
      • 2.2.2 初始化过程
      • 2.2.3 计算key 对应的 hash 值以及索引 index
      • 2.2.4 数组扩容 resize 方法
    • 2.3 get方法分析
  • 3. hashmap 面试题总结
    • 3.1 底层原理和结构 ?
    • 3.2 put 过程?
    • 3.3 hash 函数是如何实现的?
    • 3.4 为什么hashmap 中的& 位必须是(length - 1)(奇数)?
    • 3.5 什么是 hash 冲突,如何解决 hash 冲突?
    • 3.6 hashmap 线程安全性问题?如何解决?
    • 3.7 hashmap 底层红黑树五大特征是什么?
    • 3.8 红黑树和 AVL(平衡二叉查找树)比较
    • 3.9 Hashmap,LinkedHashMap,TreeMap 的区别
    • 3.10 HashMap扩容(resize)的优化是否会重复算 hash

1. java7 hashmap

1.1 基本属性以及整体概括

吊打面试官系列之 hashmap(面向面试吐血整理)_第1张图片

  • 1.7hashmap 是链表 + 数组的形式,里面是一个 Entry
  • capacity:始终为2^n,扩容后为当前数组2倍
  • loadFactor:0.75
  • threshold:扩容的阈值,等于 capacity * loadFactor

1.2 put 过程分析

public V put(K key, V value) {
     
    // 当插入第一个元素的时候,需要先初始化数组大小
    if (table == EMPTY_TABLE) {
     
        inflateTable(threshold);
    }
    // 如果 key 为 null,感兴趣的可以往里看,最终会将这个 entry 放到 table[0] 中
    if (key == null)
        return putForNullKey(value);
    // 1. 求 key 的 hash 值
    int hash = hash(key);
    // 2. 找到对应的数组下标
    int i = indexFor(hash, table.length);
    // 3. 遍历一下对应下标处的链表,看是否有重复的 key 已经存在,
    //    如果有,直接覆盖,put 方法返回旧值就结束了
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
     
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
     
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 4. 不存在重复的 key,将此 entry 添加到链表中,细节后面说
    addEntry(hash, key, value, i);
    return null;
}

1.2.1 put 过程大致流程图

吊打面试官系列之 hashmap(面向面试吐血整理)_第2张图片

面试的整体思路如上

1.2.2 初始化过程

private void inflateTable(int toSize) {
     
    // 保证数组大小一定是 2 的 n 次方。
    // 比如这样初始化:new HashMap(20),那么处理成初始数组大小是 32
    int capacity = roundUpToPowerOf2(toSize);
    // 计算扩容阈值:capacity * loadFactor
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    // 算是初始化数组吧
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity); //ignore
}

答题思路:初始化保证数组是大于当前数值的一个2^n的一个数值,例如 15 -> 16

1.2.3 计算 key 对应 index

static int indexFor(int hash, int length) {
     
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return hash & (length-1);
}

答题思路 : 简单说就是取 hash 值的低 n 位。也是为什么数组长度必须是 2 的幂次的原因

1.2.4 添加 节点到链表中

void addEntry(int hash, K key, V value, int bucketIndex) {
     
    // 如果当前 HashMap 大小已经达到了阈值,并且新值要插入的数组位置已经有元素了,那么要扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
     
        // 扩容,后面会介绍一下
        resize(2 * table.length);
        // 扩容以后,重新计算 hash 值
        hash = (null != key) ? hash(key) : 0;
        // 重新计算扩容后的新的下标
        bucketIndex = indexFor(hash, table.length);
    }
    // 往下看
    createEntry(hash, key, value, bucketIndex);
}
// 这个很简单,其实就是将新值放到链表的表头,然后 size++
void createEntry(int hash, K key, V value, int bucketIndex) {
     
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

答题思路 : 注意是头插法,扩容在下面主要介绍

1.2.5 数组扩容

void addEntry(int hash, K key, V value, int bucketIndex) {
     
    // 如果当前 HashMap 大小已经达到了阈值,并且新值要插入的数组位置已经有元素了,那么要扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
     
        // 扩容,后面会介绍一下
        resize(2 * table.length);
        // 扩容以后,重新计算 hash 值
        hash = (null != key) ? hash(key) : 0;
        // 重新计算扩容后的新的下标
        bucketIndex = indexFor(hash, table.length);
    }
    // 往下看
    createEntry(hash, key, value, bucketIndex);
}
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);
}

答题思路:

  • 扩容条件:如果当前的 size 已经达到了阈值,并且要插入的数组位置上已经有元素,扩容2倍

  • 迁移过程中,会将原来 table[i] 中的链表的所有节点,分拆到新的数组的 newTable[i] 和 newTable[i + oldLength] 位置上。如原来数组长度是 16,那么扩容后,原来 table[0] 处的链表中的所有元素会被分配到新数组中 newTable[0] 和 newTable[16] 这两个位置。代码比较简单,这里就不展开了。仍然使用头插法

1.3 get 过程

public V get(Object key) {
     
    // 之前说过,key 为 null 的话,会被放到 table[0],所以只要遍历下 table[0] 处的链表就可以了
    if (key == null)
        return getForNullKey();
    // 
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
     
    if (size == 0) {
     
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    // 确定数组下标,然后从头开始遍历链表,直到找到为止
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
     
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}
  1. 根据 key 计算 hash 值。
  2. 找到相应的数组下标:hash & (length - 1)。
  3. 遍历该数组位置处的链表,直到找到相等(==或equals)的 key

2. java8 hashmap

2.1基本属性以及整体概括

java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。

吊打面试官系列之 hashmap(面向面试吐血整理)_第3张图片

  • DEFAULT_INITIAL_CAPACITY 默认容量:16
  • DEFAULT_LOAD_FACTOR 扩容因子 0.75
  • TREEIFY_THRESHOLD 链表转换为二叉树的阈值 8
  • MIN_TREEIFY_CAPACITY 树化的 table 的最小值 64 两个条件都要满足
  • UNTREEIFY_THRESHOLD 树转链表的阈值小于等于6

2.2 put 过程分析

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
     
        // tab表示当前hashmap的table
        // p表示table的元素
        // n表示散列表的长度
        // i表示路由寻址结果
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        
        // 延迟初始化逻辑,第一次调用putval()方法的时候才进行初始化hashmap中最耗内存的talbe
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        
        // 1.最简单的一种情况,寻找到的桶位,刚好是null,这个时候直接构建Node节点放进去就行了
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
     
            // e,如果key不为null,并且找到了当前要插入的key一致的node元素,就保存在e中
            // k表示一个临时的key
            Node<K,V> e; K k;
            
            // 2.表示该桶位中的第一个元素与你当前插入的node元素的key一致,表示后序要进行替换操作
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            
            // 3.表示当前桶位已经树化了
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            
            // 4.当前捅位是一个链表
            else {
     
                for (int binCount = 0; ; ++binCount) {
     
                    // 4.1 迭代到最后一个元素了也没有找到要插入的key一致的node
                    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.1 找到了与要插入的key一致的node元素
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 如果找到了与要插入的key一致的node元素,那么进行替换
            if (e != null) {
      // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // nodeCount表示散列表table结构的修改次数,替换Node元素的value不算
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

2.2.1 put 大致流程图

吊打面试官系列之 hashmap(面向面试吐血整理)_第4张图片

2.2.2 初始化过程

略 此处初始化同1.7

2.2.3 计算key 对应的 hash 值以及索引 index

    /**
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with key, or
     *         null if there was no mapping for key.
     *         (A null return can also indicate that the map
     *         previously associated null with key.)
     *         返回先前key对应的value值(如果value为null,也返回null),如果先前不存在这个key,那么返回的就是null;
     */
    public V put(K key, V value) {
     
        return putVal(hash(key), key, value, false, true);
    }
    /
    * 在往haspmap中插入一个元素的时候,由元素的hashcode经过一个扰动函数之后再与table的长度进行与运算才找到插入位置,下面的这个hash()方法就是所谓的扰动函数
     * 作用:让key的hashCode值的高16位参与运算,hash()方法返回的值的低十六位是有hashCode的高低16位共同的特征的
     * 举例
     * hashCode = 0b 0010 0101 1010 1100  0011 1111 0010 1110
     * 
     *     0b 0010 0101 1010 1100  0011 1111 0010 1110  ^ 
     *     0b 0000 0000 0000 0000  0010 0101 1010 1100 
     *     0b 0010 0101 1010 1100  0001 1010 1000 0010
     */
    static final int hash(Object key) {
     
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 计算 hash 的目的是让高16位也加入计算

2.2.4 数组扩容 resize 方法

当在table长度位16中的元素移到table长度位32的table中的时候;我们可以知道,原来在15这个槽位的元素的hash()值的后四位一定是1111(因为跟1111即table长度-1 进行与运算得到了1111)。所以所以当table长度变为32的时候,原来在15这个槽位的元素要么还在15这个槽位,要么在31这个操作(因为原来15这个槽位的元素后五位一定是11111或者01111,跟 11111即table新长度-1 进行与运算一定得到 01111或者11111)

/**
 * 对table进行初始化或者扩容。
 * 如果table为null,则对table进行初始化
 * 如果对table扩容,因为每次扩容都是翻倍,与原来计算(n-1)&hash的结果相比,节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置。
 */
    final Node<K,V>[] resize() {
     
      	oldTab引用扩容前的 hash 表
        Node<K,V>[] oldTab = table;
        // oldCap表示扩容之前table数组的长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // oldThr表示本次扩容之前的阈值,触发本次扩容操作的阈值
        int oldThr = threshold;
        // newCap:表示扩容之后table数组的大小; newThr表示扩容之后,下次触发扩容的条件
        int newCap, newThr = 0;
        //===================给newCap和newThr赋值start=============================
        // oldCap大于零,说明之前已经初始化过了(hashmap中的散列表不是null),要进行正常的扩容操作
        if (oldCap > 0) {
     
            // 已经最大值了,不再扩容了
            if (oldCap >= MAXIMUM_CAPACITY) {
     
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // (1)进行翻倍扩容(假如旧的oldCap为8, < DEFAULT_INITIAL_CAPACITY,那么此条件不成立newThr将不会赋值)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // (2)
        // oldCap == 0(说明hashmap中的散列表是null)且oldThr > 0 ;下面几种情况都会出现oldCap == 0,oldThr > 0
        // 1.public HashMap(int initialCapacity);
        // 2.public HashMap(Map m);并且这个map有数据
        // 3.public HashMap(int initialCapacity, float loadFactor);
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        // oldCap == 0, oldThr == 0
        // public HashMap();
        else {
                    // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }

        // 对应上面(1)不成立或者(2)成立的情况
        if (newThr == 0) {
     
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
        }
        //===================给newCap和newThr赋值end=============================
        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) {
     
                    // 将对应的桶位指向null,方便jvm回收
                    oldTab[j] = null;

                    // 1.如果只有一个节点
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;

                    // 2.树化了
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                    // 3.还是链表
                    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;

                            // 比如e.hash只能为两种可能  1 1111 或者 0 1111 , oldCap 为 10000

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

2.3 get方法分析

   public V get(Object key) {
     
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

   final Node<K,V> getNode(int hash, Object key) {
     
        // tab:引用当前hashmap的table
        // first:桶位中的头元素
        // n:table的长度
        // e:是临时Node元素
        // k:是key的临时变量
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
       
       // 1.如果哈希表为空,或key对应的桶为空,返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
     
            
            // 2.这个桶的头元素就是想要找的
            if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            
            // 说明当前桶位不止一个元素,可能是链表,也可能是红黑树
            if ((e = first.next) != null) {
     
                // 3.树化了
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
                // 4.链表
                do {
     
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

3. hashmap 面试题总结

3.1 底层原理和结构 ?

阅读了源码这个问题就是太轻松了

答题思路:对比1.7 和1.8 给出结构不同(数组+ 链表 + 红黑树)以及 查询的复杂度的(o(n)/o(logn)),以及初始化的过程

3.2 put 过程?

答题思路:按照下图说即可,基本下面的过程非常详细,在回答中说出与1.7的区别(主要在头插和是否有红黑树)
吊打面试官系列之 hashmap(面向面试吐血整理)_第5张图片

3.3 hash 函数是如何实现的?

答题思路: 公式:(h = key.hashCode()) ^ (h >>> 16) 高16位保持不变,然后高16位和低16位进行异或操作,然后 hash &(length - 1)得到下标

3.4 为什么hashmap 中的& 位必须是(length - 1)(奇数)?

答题思路: length 是 2的幂次方(初始化保证的 15 -> 16) -> index 获取的的结果就是后几位的值 -> 实现了求余运算 -> 效率 高

3.5 什么是 hash 冲突,如何解决 hash 冲突?

答题思路:相同的 hashcode不同的 key就是 hash 冲突 -> 链表放在同一个 bin(桶 ) 里面

3.6 hashmap 线程安全性问题?如何解决?

  1. put 过程安全性问题: 多个线程同时 put -> 假设出现 hash 冲突 -> 发生覆盖 -> 其中一个数据丢失
  2. 扩容过程安全性问题: 多个线程同时检测到超出阈值 ->多个线程同时对数组扩容 -> 只有一个可以线程扩容成功
  3. 如何解决: 转 conncurrentHahmap

3.7 hashmap 底层红黑树五大特征是什么?

  1. 节点要么是黑色,要么是红色
  2. 根节点和叶子节点都是黑色
  3. 每个红色节点的左右孩子是黑色(一红二黑)
  4. 从任意节点到叶子节点的所有路径都包含相同的黑色节点

3.8 红黑树和 AVL(平衡二叉查找树)比较

  1. 红黑树不追求完全平衡,AVL 追求完全平衡
  2. 查找的次数大于插入和删除使用平衡二叉树(平衡二叉树为了保持平衡,插入的时候会旋转多次)

3.9 Hashmap,LinkedHashMap,TreeMap 的区别

  1. linkedHashmap有hashmap 的所有属性,由于维护了一个双向链表,所以是按照插入顺序排序的

  2. TreeMap 底层是红黑树,按照 key 的大小排序

3.10 HashMap扩容(resize)的优化是否会重复算 hash

  1. 1.7会重新计算 有冗余
  2. 不需要重新计算,只需要计算 hash (11111、01111)& length(10000)是在原来的地方或者 old + length 即可

你可能感兴趣的:(java,hashmap,面试)