面试完整篇——HashMap

前言: 本文讨论的是JDK1.8
参考:面试题

一、HashMap的概述

在JDK1.8之前,HashMap采用数组+链表来实现,一个Table数组中存放一个Entry,Entry是一个链表(本质是一个key,value键值对映射),就像是一个桶。

  • 使用链地址法来处理hash冲突,同一个hash值得节点都存储在一个链表里。当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key一次查找的效率较低。
  • 在JDK1.8中,HashMap采用数组+链表+红黑树的实现,当链表长度超过阈值8时,将链表转换为红黑树,这样大大减少了查找时间。

下图中代表Jdk1.8之前的hashmap结构,左边部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。

在这里插入图片描述JDK1.8之前的hashmap都采用上图的结构,都是基于一个数组和多个单链表,hash值冲突的时候,就将对应节点以链表的形式存储。如果在一个链表中查找其中一个节点时,将会花费O(n)的查找时间,会有很大的性能损失。

到了jdk1.8,当同一个hash值的节点数不小于8时,不再采用单链表形式存储,而是采用红黑树,如下图所示。
在这里插入图片描述

二、涉及到的数据结构:处理hash冲突的链表和红黑树以及位桶

HashMap中Table[i]中的Entry链表的实现如下:

  • Node是HashMap的一个内部类,实现了Map.Entry接口,本质就是一个映射(键值对),关键是那个next
static class Node<K,V> implements Map.Entry<K,V> {
    
    
        final int hash;//哈希值
        final K key;//key
        V value;//value
        Node<K,V> next;//链表后置节点
 
        Node(int hash, K key, V value, Node<K,V> next) {
    
    
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
 
        public final K getKey()        {
    
     return key; }
        public final V getValue()      {
    
     return value; }
        public final String toString() {
    
     return key + "=" + value; }
 
        //每一个节点的hash值,是将key的hashCode 和 value的hashCode 亦或得到的。
        public final int hashCode() {
    
    
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
        //设置新的value 同时返回旧value
        public final V setValue(V newValue) {
    
    
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
 
        public final boolean equals(Object o) {
    
     
            if (o == this)             
                return true;
            if (o instanceof Map.Entry) {
    
    
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

可以看到,node中包含一个next变量,这个就是链表的关键点,hash结果相同的元素就是通过这个next进行关联的。

  • 红黑树
    TreeNode是Map中的内部类,比链表多了四个变量,parent父节点、left左节点,right右节点、prev上一个同级节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    
    
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
    
    
            super(hash, key, val, next);
        }
        /**
         * Returns root of tree containing this node.
         */
        final TreeNode<K,V> root() {
    
    
            for (TreeNode<K,V> r = this, p;;) {
    
    
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }
  • 位桶
transient Node<K,V>[] table;

用来存储 Entry<k,v>,类似于Hash表的一个实现

三、HashMap源码分析

类的继承关系

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

继承至AbstractMap,实现了Map、Cloneable, Serializable接口。Map接口定义了一组通用的操作;Cloneable接口表示可以进行拷贝,在HashMap中,实现的是浅层次拷贝,即对拷贝对象的改变会影响被拷贝的对象;Serializable接口表示HashMap实现了序列化,即可以将HashMap对象保存至本地,之后可以恢复状态。

类的属性

 public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    
    
    // 序列号,serialVersionUID适用于java序列化机制。简单来说,JAVA序列化的机制是通过判断类的 
    //serialVersionUID来验证的版本一致的。
    private static final long serialVersionUID = 362498820763181265L; 
    
    //默认的初始容量为16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 
    //数组的最大容量为2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;
 
    //默认的填充因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
    //当桶上的节点数大于这个数的时候转成红黑树
    static final int TREEIFY_THRESHOLD = 8;
 
    //当桶上的节点数小于这个数的时候会转成链表
    static final int UNTREEIFY_THRESHOLD = 6;
 
    //桶中结构转化为红黑树对应的table的最小大小
    static final int MIN_TREEIFY_CAPACITY = 64;
 
    //存储元素的数组,总是2的n次幂
    transient Node<K,V>[] table;
 
    //存放具体元素的集
    transient Set<Map.Entry<K,V>> entrySet;
 
    //存放元素的个数,注意这个不等于数组长度
    transient int size;
 
    //每次扩容和更改map结构的计数器
    transient int modCount;
 
    //临界值,当实际大小(容量*填充因子)超过临界值时,会进行扩容
    int threshold;
 
    //填充因子
    final float loadFactor;

构造函数

public HashMap(int initialCapacity, float loadFactor) {
    
    
    //初始容量不能小于0    
    if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
    //初始容量不能大于最大值,否则为最大值    
    if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //填充因子不能不能小于或等于0,不能为非数字
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        //初始化填充因子
        this.loadFactor = loadFactor;
        //初始化threshold的大小
        this.threshold = tableSizeFor(initialCapacity);//设置阈值为>=初始化容量的2的n次方的值
    }

tableSizeFor(initialCapacity)返回大于initialCapacity的最小二次幂函数。>>>表示无符号右移,高位取0

/*
 * Returns a power of two size for the given target capacity.
*/
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;, 
 }

附:本人也没看懂那个return
这一系列位运算就是为了把一个数 转为一个大于它的一个最小2的幂方数。比如(6->8)

  • HashMap<Map<? extends K>型构造函数
public HashMap(Map<? extends K, ? extends V> m) {
    
    
        //初始化填充因子
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        //将所有元素添加至HashMap中
        putMapEntries(m, false);
    }
    
  //将另一个Map的所有元素加入表中,参数evict初始化时为false,其他情况为true
  final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    
    
        //拿到m的元素数量
        int s = m.size();
        //如果数量大于0
        if (s > 0) {
    
    
            //如果当前表是空的
            if (table == null) {
    
     // pre-size
                //根据m的元素数量和当前表的加载因子,计算出阈值
                float ft = ((float)s / loadFactor) + 1.0F;
                //修正阈值的边界 不能超过MAXIMUM_CAPACITY
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                //如果新的阈值大于当前阈值
                if (t > threshold)
                    //返回一个 >=新的阈值的 满足2的n次方的阈值
                    threshold = tableSizeFor(t);
            }
          //如果当前元素表不是空的,但是m的元素数量大于阈值,说明一定要扩容。
            else if (s > threshold)
                resize();
            //遍历 m 依次将元素加入当前表中。
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
    
    
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

附: ①比如用HashMap进行一个含有Map对象的初始化,加入要进行put一个map的话,这里提供有一个putMapEntries()方法。②看上面代码注释

  • hash算法
static final int hash(Object key) {
    
    
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

①首先获取对象的hashcode()值,可以看出hashcode()是一个本地方法,java将调用本地方法来生成对象的hashcode。
②然后将hashcode的值右移16位,然后将右移后的值与原来的值做异或运算,返回结构。(其中h>>>16,在JDK1.8中,优化了高位运算的算法,>>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0)

//将h逻辑右移16位,再与原值异或,进行扰动,使得分布更均匀。

扩容函数:resize()

  • 为什么扩容呢?很明显就是当前容量不够,也就是put了太多的元素。为此我们还是先给出一个流程图,再来进行分析。
    在这里插入图片描述
    代码(恶心):
    在这里插入图片描述
final Node<K,V>[] resize() {
    
    
        //oldTab 为当前表的哈希桶
        Node<K,V>[] oldTab = table;
        //当前哈希桶的容量 length
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //当前的阈值
        int oldThr = threshold;
        //初始化新的容量和阈值为0
        int newCap, newThr = 0;
        //如果当前容量大于0
        if (oldCap > 0) {
    
    
            //如果当前容量已经到达上限
            if (oldCap >= MAXIMUM_CAPACITY) {
    
    
                //则设置阈值是2的31次方-1
                threshold = Integer.MAX_VALUE;
                //同时返回当前的哈希桶,不再扩容
                return oldTab;
            }//否则新的容量为旧的容量的两倍。 
            
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)//如果旧的容量大于等于默认初始容量16
                //那么新的阈值也等于旧的阈值的两倍
                newThr = oldThr << 1; // double threshold
        }//如果当前表是空的,但是有阈值。代表是初始化时指定了容量、阈值的情况
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;//那么新表的容量就等于旧的阈值
        else {
    
    }//如果当前表是空的,而且也没有阈值。代表是初始化时没有任何容量/阈值参数的情况               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;//此时新表的容量为默认的容量 16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//新的阈值为默认容量16 * 默认加载因子0.75f = 12
        }
        if (newThr == 0) {
    
    //如果新的阈值是0,对应的是  当前表是空的,但是有阈值的情况
            float ft = (float)newCap * loadFactor;//根据新表容量 和 加载因子 求出新的阈值
            //进行越界修复
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //更新阈值 
        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) {
    
    
                //取出当前的节点 e
                Node<K,V> e;
                //如果当前桶中有元素,则将链表赋值给e
                if ((e = oldTab[j]) != null) {
    
    
                    //将原哈希桶置空以便GC
                    oldTab[j] = null;
                    //如果当前链表中就一个元素,(没有发生哈希碰撞)
                    if (e.next == null)
                        //直接将这个元素放置在新的哈希桶里。
                        //注意这里取下标 是用 哈希值 与 桶的长度-1 。 由于桶的长度是2的n次方,这么做其实是等于 一个模运算。但是效率更高
                        newTab[e.hash & (newCap - 1)] = e;
                        //如果发生过哈希碰撞 ,而且是节点数超过8个,转化成了红黑树(暂且不谈 避免过于复杂, 后续专门研究一下红黑树)
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //如果发生过哈希碰撞,节点数小于8个。则要根据链表上每个节点的哈希值,依次放入新哈希桶对应下标位置。
                    else {
    
     // preserve order
                        //因为扩容是容量翻倍,所以原链表上的每个节点,现在可能存放在原来的下标,即low位, 或者扩容后的下标,即high位。 high位=  low位+原哈希桶容量
                        //低位链表的头结点、尾节点
                        Node<K,V> loHead = null, loTail = null;
                        //高位链表的头节点、尾节点
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;//临时节点 存放e的下一个节点
                        do {
    
    
                            next = e.next;
                            //这里又是一个利用位运算 代替常规运算的高效点: 利用哈希值 与 旧的容量,可以得到哈希值去模后,是大于等于oldCap还是小于oldCap,等于0代表小于oldCap,应该存放在低位,否则存放在高位
                            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);
                        //将低位链表存放在原index处,
                        if (loTail != null) {
    
    
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //将高位链表存放在新index处
                        if (hiTail != null) {
    
    
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

:就是把旧数据复制到新数组里面。这里面需要注意的有下面几种情况:

A:扩容后,若hash值新增参与运算的位=0,那么元素在扩容后的位置=原始位置

B:扩容后,若hash值新增参与运算的位=1,那么元素在扩容后的位置=原始位置+扩容后的旧位置。

hash值新增参与运算的位是什么呢?我们把hash值转变成二进制数字,新增参与运算的位就是倒数第五位。

这里面有一个非常好的设计理念,扩容后长度为原hash表的2倍,于是把hash表分为两半,分为低位和高位,如果能把原链表的键值对,
一半放在低位,一半放在高位,而且是通过e.hash & oldCap == 0来判断,这个判断有什么优点呢?

举个例子:n = 16,二进制为10000,第5位为1,e.hash & oldCap 是否等于0就取决于e.hash第5 位是0还是1,这就相当于有50%的概率放在新hash表低位,50%的概率放在新hash表高位。

面试题:扩容机制优化?

put值函数putVal()

  • putVal方法执行流程在这里插入图片描述

  • 往哈希表里插入一个节点的putVal函数,如果参数onlyIfAbsent是true,那么不会覆盖相同key的值value。如果evict是false。那么表示是在初始化时调用的
    数组下标index是通过:哈希值 & 哈希桶的长度-1,替代模运算
    因为(n-1)&hash就等价于hash%n。因为&运算的效率高于%运算。

然后来个面试题:为什么HashMap的桶数组长度总是2的次幂?

  • 为了实现一个尽量分布均匀的hash函数,利用的是Key值的HashCode来做某种运算。因此问题来了,如何进行计算,才能让这个hash函数尽量分布均匀呢?

  • 首先想到的就是将Key值的HashCode值与HashMap的长度进行取模运算,即 index =
    HashCode(Key)%hashMap.length,但是,但是!这种取模方式运算固然简单,然而它的效率是很低的,浪费性能。所以jdk用了位运算的方式
    index =HashCode(Key) & (hashMap.length - 1);

  • 然后因为通常声明map时不会指定大小,或者初始化的时候就创建一个很大的map对象,所以通过容量大小与hash值运算的时候最开始只会在低位运算,虽然容量位2进制高位一开始都是0,但是key的2进制高位通常是有值的,因此先在hash方法中将key的hashCode右移16位在与自身异或,使得高位也可以参与hash,更大程度上减少了碰撞率。(理解:table容量不会很大,高位都为0.但是hash的高位有信息,为了利用高位信息,将hash右移16位进行异或,可以减小碰撞率
    在这里插入图片描述
    附:https://blog.csdn.net/qq_40378034/article/details/88220732

onlyIfAbsent  如果当前位置已存在一个值,是否替换,false是替换,true是不替换
evict  表是否在创建模式,如果为false,则表是在创建模式

  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    
    
        //tab存放 当前的哈希桶, p用作临时链表节点  
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果当前哈希表是空的,代表是初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            //那么直接去扩容哈希表,并且将扩容后的哈希桶长度赋值给n
            n = (tab = resize()).length;
        //如果当前index的节点是空的,表示没有发生哈希碰撞。 直接构建一个新节点Node,挂载在index处即可。
        //这里再啰嗦一下,index 是利用 哈希值 & 哈希桶的长度-1,替代模运算
        if ((p = tab[i = (n - 1) & hash]) == null) //线程不安全的地方,如果没有hash碰撞则直接插入元素
            tab[i] = newNode(hash, key, value, null);
        else {
    
    //否则 发生了哈希冲突。
            //e
            Node<K,V> e; K k;
            //这里针对哈希表的首个节点,如果哈希值相等,key也相等,则是覆盖value操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;//将当前节点引用赋值给e
            else if (p instanceof TreeNode)//红黑树暂且不谈
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
    
    //不是覆盖操作,则插入一个普通链表节点或者遍历是否有相同的节点
                //遍历链表
                for (int binCount = 0; ; ++binCount) {
    
    
                    if ((e = p.next) == null) {
    
    //遍历到尾部,追加新节点到尾部
                        p.next = newNode(hash, key, value, null);
                        //这里的触发条件是:如果追加节点后,链表数量>=8,则转化为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    
                    //如果找到了要覆盖的节点,这直接break,退出这个for循环,因为e已经记录了链表的位置,所以在下面的if判断里面去实现覆盖
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果e不是null,说明有需要覆盖的节点,
            if (e != null) {
    
     // existing mapping for key
                //则覆盖节点值,并返回原oldValue
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                //这是一个空实现的函数,用作LinkedHashMap重写使用。
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //如果执行到了这里,说明插入了一个新的节点,所以会修改modCount,以及返回null。
        //修改modCount
        ++modCount;
        //更新size,并判断是否需要扩容。
        if (++size > threshold)
            resize();
        //这是一个空实现的函数,用作LinkedHashMap重写使用。
        afterNodeInsertion(evict);
        return null;
    }

面试题:这里引出HashMap线程不安全的缘故?
JDK1.7的线程不安全问题
这里有分析

JDK1.8的HashMap线程主要体现在 在并发执行put操作时会发生数据覆盖的情况。注意看

if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
  • 这行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

  • 除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。

然后再其中有一个treeifyBin(tab, hash)方法回答了

  • 链表会转化为红黑树的两个条件是?
    //将链表转为红黑树
    final void treeifyBin(Node<K,V>[] tab, int hash) {
    
    
        int n, index; Node<K,V> e;
        //这里判断数组为空或者HashMap底层使用的table数组长度length达到64
        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);
        }
    }

注:这里代码暂不分析

小结:
  • 运算尽量都用位运算代替,更高效。

  • 对于扩容导致需要新建数组存放更多元素时,除了要将老数组中的元素迁移过来,也记得将老数组中的引用置null,以便GC

  • 取下标 是用 哈希值 与运算 (桶的长度-1) i = (n - 1) & hash。 由于桶的长度是2的n次方,这么做其实是等于一个模运算。但是效率更高

  • 扩容时,如果发生过哈希碰撞,节点数小于8个。则要根据链表上每个节点的哈希值,依次放入新哈希桶对应下标位置。

  • 因为扩容是容量翻倍,所以原链表上的每个节点,现在可能存放在原来的下标,即low位, 或者扩容后的下标,即high位。 high位=
    low位+原哈希桶容量

  • 利用哈希值 与运算 旧的容量 ,if ((e.hash & oldCap) ==0),可以得到哈希值去模后,是大于等于oldCap还是小于oldCap,等于0代表小于oldCap,应该存放在低位,否则存放在高位。这里又是一个利用位运算代替常规运算的高效点

  • 如果追加节点后,链表节点数量>=8,则触发转为红黑树的方法,在那个方法里又加以判断是否数组长度满足大于当前数组最小容量时才进行转为红黑树,否则进行扩容

  • 插入节点操作时,有一些空实现的函数,用作LinkedHashMap重写使用。

四、面试题

  • HashMap底层实现(JDK1.7使用数组+链表;JDK1.8使用数组+链表+红黑树)
  • HashMap为什么要引进红黑树?为什么不用其他的平衡二叉树之类的?红黑树的优势在哪里?(AVL树的旋转比红黑树的旋转更加难以平衡和调试,需要更高的旋转次数
  • 链表会转化为红黑树的两个条件是?(①链表的长度达到8;②HashMap底层使用的table数组长度length达到64;如果不满足后者,将会触发扩容方法
  • HashMap的长度为什么必须是2的次幂?
    答案

为了实现一个尽量分布均匀的hash函数,利用的是Key值的HashCode来做某种运算。因此问题来了,如何进行计算,才能让这个hash函数尽量分布均匀呢?
首先想到的就是将Key值的HashCode值与HashMap的长度进行取模运算,即 index = HashCode(Key) %
hashMap.length,但是,但是!这种取模方式运算固然简单,然而它的效率是很低的,浪费性能。所以jdk用了位运算的方式index =
HashCode(Key) & (hashMap.length - 1);

举例: “abc”十进制hashcode为96354,二进制为‭1 0111 1000 0110
0010‬,HashMap的默认长度为16,与15进的二进制1111位运算, 得到index = 2;
可以看出来,hash算法得到的index值完全取决与Key的HashCode的最后几位。这样做不但效果上等同于取模运算,而且大大提高了效率。

如果不是会怎样呢? 我们假设HaspMap的初始长度为10,要与9的二进制1001进行位运算。得到0, 但是 如果 hashCode为1
0111 1000 0110 1001 或者1 0111 1000 0110 1011这样会造成
有些索引出的值挤压太多。无法更好的分布均匀。

  • 链表长度大于8转化为红黑树,小于6红黑树转化为链表;为什么不直接设置成大于8转化成红黑树,小于8转化成链表;(中间有个差值7进行过渡是为了避免链表和树频繁转换,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低

  • 把链表转化为红黑树的阈值是8,为什么不设置成其他值?(遵循泊松分布,链表长度超过8的概率非常小)

**意思就是:**理想情况下使用随机的哈希码,容器中节点分布在hash桶中的频率遵循泊松分布。按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为8时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了8,是根据概率统计而选择的。

  • HashMap扩容机制,即resize方法?(JDK 1.7 会重新计算每个元素的哈希值,JDK1.8是通过高位运算(e.hash &oldCap)来确定元素是否需要移动,如果运算结果值为0,那么元素扩容后位置不变,结果值为1表示元素在扩容时位置发生了变化,新的下标位置等于原下标位置+ 原数组长度)

  • HashMap添加元素的步骤(put方法)、计算集合元素个数(size方法)

  • HashMap为什么是线程不安全的?(同时新增元素、同时扩容导致数据丢失,jdk1.7头部倒序插入出现死循环导致CPU占用100%)

  • HashMap默认的加载因子是0.75,为什么不设置成1或者0.5(从容量和性能考虑)

加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;

加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。

在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,减少扩容操作。

选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择

负载因子,默认值是0.75。负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。
所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成烂费,但是此时索引效率高。

  • HashMap发生哈希冲突,新节点是插入到链表头部还是链表的尾部,头部倒序插入死循环是怎么产生的?(jdk1.7采用头部倒序插入,会导致死循环;jdk1.8使用尾部正序插入)

  • Hashtable怎么控制key value 不能为null?

    • 当调用put方法时,首先会判断value是否为null,为null的话直接抛出空指针异常;对于key,由于Hashtable计算hash值是int hash = key.hashCode();直接取对象的hashcode,key为null就会报空指针异常;
  • 而HashMap计算hash值是return (key == null) ? 0 : (h = key.hashCode()) ^ (h>>> 16),key为null则hash值为0)

  • HashSet的底层实现?

    • 基于HashMap来实现的,new 一个HashSet对象底层实际就是new了一个HashMap,并且使用默认的初始容量16和默认的加载因子0.75;
    • 当我们往HashSet里面添加一个元素其实就是往HashMap里面put了一个元素,并且是以key存在的,HashMap的value值都是一样的,是一个静态常量PRESENT,源码为:private static final Object PRESENT = new Object(); )
    • 但是HashSet是保证唯一性的?①如果hash码值相同,且equles判断相等,说明元素已经存在,不存②如果hash码值相同,且equles判断不相等,说明元素不存在,存;③如果hash码值不相同,说明是一个新元素,存
    • 哈希值相等,对象值一定相等
 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
 e = p;
  • 与HashTable的区别
    • 与之相比HashTable是线程安全的,且不允许key、value是null。
    • HashTable默认容量是11。
    • HashTable是直接使用key的hashCode(key.hashCode())作为hash值,不像HashMap内部使用static
      final int hash(Object key)扰动函数对key的hashCode进行扰动后作为hash值。
    • HashTable取哈希桶下标是直接用模运算%.(因为其默认容量也不是2的n次方。所以也无法用位运算替代模运算)
    • 扩容时,新容量是原来的2倍+1。int newCapacity = (oldCapacity << 1) + 1;

猜你喜欢

转载自blog.csdn.net/weixin_42754971/article/details/114048212
今日推荐