HashMap经典面试题+源码分析

1、HashMap 经典面试题

谈一下HashMap的底层原理是什么?

基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hashCode()的计算来得到它在bucket数组中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。

谈一下HashMap中put是如何实现的?

1.计算关于key的hashcode值(与Key.hashCode的高16位做异或运算)

2.如果散列表为空时,调用resize()初始化散列表

3.如果没有发生碰撞,直接添加元素到散列表中去

4.如果发生了碰撞(hashCode值相同),进行三种判断

​ 4.1:若key地址相同或者equals后内容相同,则替换旧值

​ 4.2:如果是红黑树结构,就调用树的插入方法

​ 4.3:链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阙值8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。

5.如果桶满了大于阀值,则resize进行扩容

谈一下HashMap中什么时候需要进行扩容,扩容resize()又是如何实现的?

调用场景:

1.初始化数组table

2.当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中

实现过程:(细讲)

1.通过判断旧数组的容量是否大于0来判断数组是否初始化过

否:进行初始化

判断是否调用无参构造器,

​ 是:使用默认的大小和阙值

​ 否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数

​ 是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中

概括的讲:扩容需要重新分配一个新数组,新数组是老数组的2倍长,然后遍历整个老结构,把所有的元素挨个重新hash分配到新结构中去。

PS:可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作

谈一下HashMap中get是如何实现的?

对key的hashCode进行hashing,与运算计算下标获取bucket位置,如果在桶的首位上就可以找到就直接返回,否则在树中找或者链表中遍历找,如果有hash冲突,则利用equals方法去遍历链表查找节点。

为什么不直接将key作为哈希值而是与高16位做异或运算?

​ 因为数组位置的确定用的是与运算,仅仅最后四位有效,设计者将key的哈希值与高16为做异或运算使得在做&运算确定数组的插入位置时,此时的低位实际是高位与低位的结合,增加了随机性,减少了哈希碰撞的次数。

为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样?

HashMap默认初始化长度为16,并且每次自动扩展或者是手动初始化容量时,必须是2的幂。

1.为了数据的均匀分布,减少哈希碰撞。因为确定数组位置是用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。(PS:其实若不考虑效率,求余也可以就不用位运算了也不用长度必需为2的幂次)

2.输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字

谈一下当两个对象的hashCode相等时会怎么样?

​ 会产生哈希碰撞,若key值相同则替换旧值,不然链接到链表后面,链表长度超过阙值8就转为红黑树存储

请解释一下HashMap的参数loadFactor,它的作用是什么?

loadFactor表示HashMap的拥挤程度,影响hash操作到同一个数组位置的概率。默认loadFactor等于0.75,当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,在HashMap的构造器中可以定制loadFactor。

如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

超过阙值会进行扩容操作,概括的讲就是扩容后的数组大小是原数组的2倍,将原来的元素重新hashing放入到新的散列表中去。

传统HashMap的缺点(为什么引入红黑树?):

​ JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。

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

​ 择Integer,String这种不可变的类型,像对String的一切操作都是新建一个String对象,对新的对象进行拼接分割等,这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的。

2、HashMap 源码分析

使用无参创建 HashMap(默认容量为16), loadFactor 为加载因子, 是扩容的一个重要参数, 此时并未真正创建创建数组, 而是在 put 第一次添加元素的时候初始化数组

static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
    
    
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

创建 map 中有一个小细节, 也就是我们使用了 int 类型的构造器创建一个指定初始容量的 HashMap

// 创建了一个指定初始容量为 17 的 map
Map<String, Object> map = new HashMap<>(17);

通过 this 调用另外外一个构造函数传入初始容量和默认加载因子, 如果初始容量小于0, 则直接抛出 IllegalArgumentException 非法参数异常, 注意的是此时的是初始容量并不等于 17 , 而是 大于等于 2 的幂次方最小值, 也就是 32, 阈值也会基于 32 进行从新计算 32 * 0.75 等于 24, 也就是本次扩容的阈值为 24

// 通过唯一运算从新计算扩容阈值并返回
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;
}
public HashMap(int initialCapacity) {
    
    
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

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;
    // 从新计算出来的阈值
    this.threshold = tableSizeFor(initialCapacity);
}

调用普通 put 方法添加元素, 值得注意的是:

public V put(K key, V value)  
    return putVal(hash(key), key, value, false, true);
}
  • 调用了真正添加元素的方法 putVal , 下面仔细讲解
  • putVal 第一个参数是 hash 方法 通过 key 的 hashCode 值 进行右移异或运算返回 int 类型 hash 值; 如果 key = null 则直接返回 0
  • putVal 第四个参数主要用于 hash 碰撞时且 key 相同是否需替换之前的旧值, 默认是 false 即 key 相同时 替换旧值并返回旧值
    • 如果调用 putIfAbsent 当发生 hash 碰撞时且 key 相同则既不会替换旧值也不会添加
@Override
public V putIfAbsent(K key, V value) {
    
    
    return putVal(hash(key), key, value, true, true);
}

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;
    	// 如果老数组的容量大于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;
    	// 初始化数组操作, 新数组的容量(长度)采用默认长度 16, 数组的阈值使用默认加载因子(0.75) * 默认初始容量(16) 为 12
        else {
    
                   // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
    
    
            // 数组扩容之后, 使用新数组的容量 * 0.75 计算出新数组的阈值
            // 假如扩容之后新数组的长度为 32, 那么新数组的阈值: 32 * 0.75 = 24 	
            float ft = (float)newCap * loadFactor;
            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;
        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)
                        // 通过 hash 取模运算计算出新数组的索引位置, 直接存储到新数组
                        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;
                            }
                            // 加入高位链表
                            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;
    }

再来看看 putVal 是怎么添加的元素, 由于 putVal 方法相对于要复杂一点, 分为两部分来分析 里面其实就干了两件事: 初始化数组以及添加替换元素

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    
    
      //------------------初始化数组的大小-------------------------
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判断 table 数组中是否有元素, 第一次添加的时候 table 等于 null, 这里调用 resize 的作用主要是 创建一个 Node 类型的数	   // 组, Node 中一四个重要的属性: hash、key、value、next
    // hash 就是经过 hash 运算返回的值, key 和 value 见名知意就不多解释了, next 就是链表中指向下一个节点的指针
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 通过 hash 计算出索引, 再判断 table[index] 是否有值, 如果没有值就直接创建一个 Node 节点将 key、value、hash设置进
    // 去, 由于新创建的这个节点没有下一个节点, 因此 next 就赋值为 null
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //------------------后面为添加元素的逻辑, 此处省略,下面细讲-------------------------
}

putVal方法后面的主要添加逻辑是 基于链表或红黑树的方式添加

    //	如果该下标位置存在元素
	else {
    
    
        Node<K,V> e; K k;
        // 判断 put 进来元素的 key 是否等于已存在元素的 key, 如果等于且不等于 null, 再去判断两个 key 是否相等, 相等就替换之前的的旧值并返回替换之前的值; 如果 key 不相等执行后面的逻辑, 最终使用尾插法将元素追加到链表的末尾  
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 判断当前元素是否是 treeNode 的实例, 是则使用红黑树的方式添加元素
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 走到这一步, 就只剩下一种情况了, 该位置是一个链表, 需要遍历整个链表判断有没有相同的 key, 
        // key 相同就替换, 不同就尾插
        else {
    
    
            // binCount 是判断是否走树化的一个关键
            for (int binCount = 0; ; ++binCount) {
    
    
                // 运行到这里, 此时 binCount = 0, 链表中有 1 个元素
                if ((e = p.next) == null) {
    
    
                    p.next = newNode(hash, key, value, null); // 运行到这一步时, binCount = 0, 此时链表中有两个元素
                    // 这一步很关键, 当前循环次数 >= 阈值 - 1, 也就是大于等于 7 的时候, 改造为红黑树, 但是我们平时说的达到 阈值 8 的时候才树化, 为什么这里是等于 7 的时候转化成红黑树呢, 注意的是上面遍历是从 0 开始, 也就是 binCount 等于 0 时原始链表有 1 个节点, 此时 put 进来的元素还没有插入到链表中, 循环结束还没正式进入下一轮循环之前, put 进来的元素插入到了链表的最后, 也就是说 bincount = 0, 此时链表中有两个元素, 以此类推, 当 binCount 等于 7 时, 链表中有 9 个元素, 链表就会改造为红黑树的方法, 那么也不对呀, 不是链表长度为 8 的时候树化吗, 怎么这里 链表长度为 9 才树化, 这里有个小细节点, 它是拿每次 追加元素之前的原始链表长度作为的判断条件, 也就是原始长度为 8 改造为红黑树, 把新元素追加到链表中(中间有个执行时机), 转换城红黑树时真正有 9 个元素
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        // 具体的树化方法后面再讲, 只需要知道在这一步即将转换成红黑树
                        treeifyBin(tab, hash);
                    break;
                }
                // 遍历链表, 如果有相同的 key 则直接退出当前循环, 进行替换旧数据
                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;
	// 判断当前的元素个数是否达到扩容条件
    if (++size > threshold)
        // 如果达到扩容条件, 调用 resize 方法进行扩容
        resize();
    afterNodeInsertion(evict);
	// 如果在当前数组中没有相同的key, 也就没有替换操作, 就返回一个 null
    return null;

转为红黑树的方法

// 转化成红黑树时散列表最小的树化容量
static final int MIN_TREEIFY_CAPACITY = 64;


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)
        // 当 table 数组为空时调用 resize 方法进行初始化数组
        // 当 散列表的长度小于 树化容量 64, 此时链表的长度已经等于 8, 一旦链表长度大于 8 则链表的查询效率很低(0n), 为了解决这个问题, 就调用 resize 方法进行扩容, 将当前的链表截半转成一个高位链表和一个低位链表, 再通过 hash 运算计算出存放新数组的索引, 直接将, 老数组上面的元素迁移到新数组上面
        resize();
    // 真正转化成红黑树的逻辑
    else if ((e = tab[index = (n - 1) & hash]) != null) {
    
    
        // 将链表改造成双向链表, 将原始的 Node 节点 转换成 TreeNode 节点
        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);
    }
}

猜你喜欢

转载自blog.csdn.net/weixin_49137820/article/details/128489807