学了这么长时间的HashMap,不撸下源码怎么对得起面试官?

写在前面: 关于hashmap我目前能想出这些,希望大家看到也能提提意见,我有遗漏的或者错误的地方欢迎补充和指出,我会继续学习,哈哈。

文章目录

  • HashMap
    • 1、简介
    • 2、底层数据结构
    • 3、HashMap中都有哪些字段?都是干什么的
    • 4、看完字段属性,我们来看下内部类
    • 5、再来看看构造器
    • 6、几个需要讲出的方法
      • 1、tableSizeFor()方法
      • 2、put()方法
      • 3、get()方法
      • 4、resize()方法
    • 7、为什么要扩容为原来的2倍,为什么容量一定是2的幂次方数
    • 8、线程不安全的场景

HashMap

1、简介

当面试官让你说下HashMap的时候,我觉得需要从以下几个方面来进行解答

  • 底层数据结构,说出jdk1.7和jdk.1.8的区别
  • 说下构造器都有哪些
  • 说下有哪些重要字段,都是什么作用
  • 解释下几个常见方法的运行逻辑,如put()、get()、resize()、hash()、看自己能力说等等
  • 为什么扩容需要为原来的2倍,并且容量为什么一定要是2的幂次方倍
  • 线程不安全的场景
  • 想到我再加上来,哈哈

2、底层数据结构

在jdk1.7的时候,它的底层数据结构数组+链表,在极端情况下,有可能会使查询复杂度变为O(n),而在jdk1.8之后,发生了变化,引入了红黑树,大大提高了查询效率。这时候面试官面试官可能会问了,那为什么要采用红黑树,而不采用二叉查找树呢?这时候,你就需要了解下红黑树这个数据结构了

红黑树是一种自平衡的二叉查找树,它具有自旋的特性,能使整个树变的平衡,而不导致极端情况下,又出现线性存储的情况

3、HashMap中都有哪些字段?都是干什么的

我们来看下源码

//这个字段主要是在序列化机制中的,它主要作用是在反序列化的时候,判断版本号一致性的问题,一致的话,表示正常,可以进行反序列化,不一致的话会报InvalidCastException异常
private static final long serialVersionUID = 362498820763181265L;
//HashMap的默认初始容量,必须是2的幂次方数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//HashMap的最大容量,一般达不到这种容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//HashMap在构造器中没有给定参数指定加载因子,默认为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//HashMap的树化值,也就是在一个桶中,链表长度大于8的时候,需要将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//HashMap的非树化值,也就是从红黑树转化为链表得值,也就是当红黑树容量到6的时候,就考虑将红黑树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//HashMap的最小树化容量,也就是说当一个链表的长度达到8的时候,它还会考虑到整个数组的容量是否达到了64,如果达到了就将该链表转化为红黑树,如果未达到,就考虑数组扩容
static final int MIN_TREEIFY_CAPACITY = 64;

4、看完字段属性,我们来看下内部类

先放源码

	/**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
     //上面是jdk给我们的解释,大概就是,这个子类是Hash容器的最基本的节点,当一个entry有多个元素的时候,可以用它来存储元素
    static class Node<K,V> implements Map.Entry<K,V> {
    	//有一个hash值,定位数组索引下标
        final int hash;
        //存储的键
        final K key;
        //存储的值
        V 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;
        }
		//实现Map.Entry的接口
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
		//重写hashcode方法,减少hash碰撞率
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
		//重写equals方法
        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;
        }
    }

源码其实很简单易懂,主要作用就是,声明用于存值的链表,这里就解释下为什么要重写hashcode方法和equals方法,重写equals方法是为了比较这个对象的内容,而不仅是比较地址,但是重写了equals方法的必须要覆盖hashcode()方法
学了这么长时间的HashMap,不撸下源码怎么对得起面试官?_第1张图片
学了这么长时间的HashMap,不撸下源码怎么对得起面试官?_第2张图片
如果重写了equals方法的时候,没有覆盖hashcode方法会产生什么影响呢?
当我们有两个实例的时候,有可能在逻辑上是相等的,但是这两个是完全不相干的实例,那么默认hashcode会产生两个不同的随机值,这就违背了hashcode合同第二条
红黑树就先不带大家看了哈,因为我也还很菜哈哈

5、再来看看构造器

上源码,哈哈

/**
     * Constructs an empty HashMap with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    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);
    }

    /**
     * Constructs an empty HashMap with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty HashMap with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    /**
     * Constructs a new HashMap with the same mappings as the
     * specified Map.  The HashMap is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified Map.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

HashMap提供了四种构造器

  • 默认构造器,所有的值都是默认值
  • 带参构造器:
    • 1、可以指定初始容量,加载因子
    • 2、指定初始容量
    • 3、指定一个map

6、几个需要讲出的方法

1、tableSizeFor()方法

虽然在上面构造器中我们可以任意指定容量,但是最终的容量还是2的幂次方数,具体实现方法是tableSizeFor()方法
我们点进去看看它的实现逻辑:

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

经过上述运算,最终会得到比给定cap容量大的最近的一个2的幂次方数来作为HashMap的容量

2、put()方法

继续源码

/**
     * Implements Map.put and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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是否为空,如果为空的话,就先进行初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //根据长度和hash码进行与运算定位相应的数组索引下标,判断当前桶中是否有值,没有值的话,就直接进行插入,(这里也是hashmap在jdk1.8线程不安全的场景),如果有值进行下步操作
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //判断当前桶中的元素的key是否和待插入的key相等,相等的话进行覆盖
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //不相等的话,判断节点实例
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
          		//维护一个bincount,记录链表长度
                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;
                    }
                    //遍历过程中,如果找到有key和待插入的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;
            }
        }
        //在一个新桶中插入元素,数组容量+1,然后判断是否扩容
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

我们把源码分析了一遍,接下来做个总结:

  1. 当插入一个元素的时候,首先先判断HashMap有没有被初始化,如果没有被初始化,就调用resize()方法对HashMap进行初始化
  2. 根据数组长度和返回的hash码进行与运算,计算出相应的数组下标,判断当前桶中的元素是否有元素,没有元素的话就将当前元素插入其中,然后进行第四步
  3. 如果当前桶中有元素,判断当前元素的key和待插入的key是否相等,如果相等的话,就将元素进行覆盖,如果不相等的话,判断当前元素的实例是树实例还是链表实例,然后进行相应的操作,链表的话,会维护一个bincount来决定是否进行树化,如果bincount达到7的时候,就调用treeifyBin的方法,判断一下数组容量是否达到了最小树化容量,来决定是扩容还是树化,插入元素的时候,也是判断key是否相等,相等覆盖,不相等就插入到尾部,最后判断e是否为空,不为空的话,就返回旧值
  4. 最后判断数组的容量是否达到了需要扩容的容量,然后决定是否扩容

3、get()方法

get方法的逻辑就很简单了,看下源码

/**
     * Implements Map.get and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //根据key的hash值和数组长度定位tab的下标
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //先判断下头结点是否符合条件
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //第一个节点不符合,并且它有多个节点
            if ((e = first.next) != null) {
            	//判断节点实例是树节点还是链表节点,然后进行相应的取值,进行返回
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        //没找到,返回为空
        return null;
    }

简单的做下总结:

  1. 各种限制条件判断:表是否为空?长度是否大于0?然后再定位数组索引下标
  2. 总是先判断头结点实例,如果key相等,就返回,不相等就进行下一步
  3. 判断这个桶中不止头结点一个实例,然后判断头结点实例是树节点还是链表节点,然后进行相应的操作进行取值并返回
  4. 如果没找到,就返回为空

4、resize()方法

它有两个作用,一个是对HashMap进行初始化,一个是扩容
上源码,哈哈

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
    	//存储扩容前的表
        Node<K,V>[] oldTab = table;
        // 记录老表的长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 记录老表的阈值
        int oldThr = threshold;
        // 声明两个变量,表示新表的容量的阈值
        int newCap, newThr = 0;
        // 判断老表的容量,来判断这个resize方法是用来扩容的还是初始化的
        if (oldCap > 0) {
        	// 判断老表容量是否大于最大值,如果大于,就不管了,任由hash碰撞吧
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 否则将扩容为原来的2倍,并且老表容量必须大于等于默认初始容量
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //阈值也扩容为原来的2倍
                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;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 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;
        // 接下来就是扩容的过程了,哈哈,老长的代码了,总结一下就是,将老表中的元素进行一次重新hash,然后根据尾插法插入到新表中
        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;
                    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;
    }

总结一下:

  1. 判断是否调取该方法是用于扩容还是用于初始化,判定标准就是老表的容量,大于零就是扩容,否则就是初始化
  2. 初始化的话,需要几次赋值的过程,新表的容量、新表的阈值
  3. 扩容的话,大致逻辑就是将老表中元素进行一次重新hash,然后根据尾插法插入到新表中。为了使扩容后,元素分布的均匀,在扩容逻辑中,声明了loTail、loHead、hiTail、hiHead四个指针,将元素分配的新表的高低两个档中,这里的高低一档也就是指原来表的大小。

7、为什么要扩容为原来的2倍,为什么容量一定是2的幂次方数

我们先用反例解释一下,如果容量不是2的幂次方数的话,那么在进行数组定位下标的时候,最终返回的下标一定是偶数,这极大地造成了空间资源的浪费,也增加了hash碰撞的概率,如果容量是2的幂次方的话,length-1也就是这样的形式:11111***1111,这使得与hash做与远算的时候可以充分的散列,使得元素均匀分布,减少hash碰撞的概率。所以容量需要是2的幂次方数。
学了这么长时间的HashMap,不撸下源码怎么对得起面试官?_第3张图片
那为什么扩容为原来的2倍呢?
因为要求容量是2的幂次方,所以扩容也采用2倍扩容。
小结:
采用这样的方式,可以使元素均匀的分布在hash数组上,减少hash碰撞的概率,避免形成链表的结构,导致查询效率降低。

8、线程不安全的场景

在jdk1.7,主要体现在扩容时,它采用的是头插法,因为要改变链表的顺序,所以可能会造成链表回路的情况,造成死循环。
在jdk1.8时,主要体现在put逻辑上,检查头结点元素的时候,两个线程都认为这个桶中没有元素的时候,造成的数据覆盖。

你可能感兴趣的:(秋招之路)