HashMap原理及常见考点

JDK源码阅读系列

  • HashMap源码解析

文章目录

  • JDK源码阅读系列
  • 前言
  • 一、整体架构
    • 1.Map接口
      • 1.1Map做什么了
      • 1.2AbstractMap做什么了
    • 2.HashMap的整体架构
  • 二、聚焦HashMap源码
    • 1.对数组的处理
      • 1.1一些初始化参数
        • 1.1.1数组大小的控制
    • 2.单向链表
    • 3.红黑树
  • 总结


前言

HashMap是非常常用的一种集合,是Key,Value键值对的方式存储数据的。虽然用过无数次,对它的认识只是停留在表面,现在想静下心来阅读一下源码。

网上公布的一些互联网大厂的面试题,有很多问题都涉及到集合的内容,而HashMap由于结构比较复杂,更是重中之重,问的几率很高。因此分析一下源码还是很有必要的。


一、整体架构

1.Map接口

看一下HashMap类的定义会发现,它继承了AbstractMap抽象类,并且实现了Map接口

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

看一下UML图:
HashMap原理及常见考点_第1张图片

1.1Map做什么了

我们打开Map.java源码,发现它是一个接口,并且使用了泛型,来定义Key,Value的类型。
既然它是一个接口,并且是一个顶层接口,是一个高度的抽象。那它就要负责把Map体系具备的共性操作、结构进行抽象定义。
因此我们可以看到下面的类结构中,我们常见的方法都能看到。
HashMap原理及常见考点_第2张图片
除了常用的方法定义,我们关注一下Entry这个内部接口。它是做什么的呢?
Map内部对于数据的组织是用Entry这个结构定义的,也就是说我们平时传入的Key,Value值实际是被组织成Entry这个类型存储在Map底层的数组当中的,Entry是Map中存储数据的最小数据单元。Set> entrySet();从这个定义就知道了
而对于Map的不同实现,内部的数据结构也是不同的,因此不同的实现在自己的内部都会实现这个Entry接口,来定义自己的数据单元结构。
如下图的LinkedHashMap、ConcurrentHashMap等,都有自己对Entry接口的实现。
HashMap原理及常见考点_第3张图片

1.2AbstractMap做什么了

可以看到它是一个抽象类,实现了Map接口。从这个结构我们就能猜出,它把一些通用逻辑都抽取上来,形成一个抽象类,这样节省了开发量,同时易于维护。

public abstract class AbstractMap<K,V> implements Map<K,V> {
	...
}

对于无法抽象,需要实现类具体实现的方法,内部抛出一个异常,这样强制实现,否则就会抛出异常,比如常用的put方法

/**
  * {@inheritDoc}
  *
  * @implSpec
  * This implementation always throws an
  * UnsupportedOperationException.
  *
  * @throws UnsupportedOperationException {@inheritDoc}
  * @throws ClassCastException            {@inheritDoc}
  * @throws NullPointerException          {@inheritDoc}
  * @throws IllegalArgumentException      {@inheritDoc}
  */
 public V put(K key, V value) {
     throw new UnsupportedOperationException();
 }

这种模式,我们在编写业务代码的时候,也是要借鉴的,很好的思想。
类结构如下:
HashMap原理及常见考点_第4张图片

2.HashMap的整体架构

HashMap 是由 数组+链表+红黑树组成的
HashMap原理及常见考点_第5张图片

二、聚焦HashMap源码

1.对数组的处理

上一章我们提到了HashMap的底层是 数组+链表+红黑树,我们先对红黑树的代码进行一下分析。

1.1一些初始化参数

先看一组HashMap中定义的常量,这组常量和数组大小的设置相关

/**
  * 数组容量默认值 16
  * 这里使用了位运算符,效率高
  */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
  * 数组的最大容量
  */
 static final int MAXIMUM_CAPACITY = 1 << 30;

/**
  * 数组扩容因子的默认值
  * 
  */
 static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
  * 这个就是底层的数组,可以看到是Node类型的,它是对Map.Entry的实现
  * 在第一次使用时初始化,并根据需要调整大小。当分配时,长度总是2的整数倍。
  */
 transient Node<K,V>[] table;

/**
  * 触发数组扩容的阈值,大小等于 table的长度*数组扩容因子
  * 初始化是0,可以通过带参数的构造方法,设置初始值
  * 当数组的容量大于threshold的时候就会触发扩容操作resize()
  */
 int threshold;


/**
  * 数组中包含的Node个数,注意与数组的长度不是一个概念,size<=数组长度.
  */
transient int size;

1.1.1数组大小的控制

先看一个代码片段:逻辑很简单,就是使用默认的构造器创建一个HashMap对象,然后put值进去

Map data = new HashMap<>();
data.put("name","zhangsan");

这再简单不过的两行代码,后面隐藏的逻辑还是比较多的,我们逐一分析一下:

1.无参构造方法
什么也没做,只是将扩容因子设置成了默认的0.75(不明白继续往下看),此时HashMap底层的数组是null

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

另外还提供 public HashMap(int initialCapacity)public HashMap(int initialCapacity, float loadFactor),分别可以指定初始化的数组大小,和扩容因子。

2.put方法

/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 *
 * @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.)
 */
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 * 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;
    // 如果 数组为空,也就是第一次添加值得时候,会调调用重新设置数组长度的方法
    // 【①】将数组长度定义为默认的16,且扩容阈值为12                      
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 【②】当新放入一个值到HashMap的数组中时,应该放在数组的哪个位置呢?按照下面的算法
    // i=通过将Key取Hash值与数组长度进行取模。i也就是要添加的元素应该放入数组中的哪个位置
    // 如果tab[i]上没有元素是null,那么就创建一个Node节点,并放入tab[i]
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 【③】如果tab[i]这个位置上有值,那么判断要放入的值得key与当前位置上的值得key是否相同
        // 判断标准就是 key的hash相等并且equals也返回true
        // 如果相等,那么就是同一个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 {
            // 如果tab[i]这个位置上是单向链表,那么顺着链表方向向后找
            // 1. 直到找到链表的末尾,把要放入的值放到链表末尾,并且判断链表是否需要转红黑树
            // 2. 或者找到一个链表上的节点,key与要放入的值得key相同,那么就把这个节点记录下来
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 【④】链表长度如果>=7那么就将链表转为红黑树
                    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;
            }
        }
        // 如果e不是空,说明在数组中,与要放入的值得Key相等的Node
        // 【⑥】这时 判断onlyIfAbsent,false代表可以用传入的值覆盖原有的值,否则不可以
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 【⑤】当数组的长度+1大于扩容阈值的时候,那么进行扩容
    if (++size > threshold)
        resize();
    // 这里提供了钩子方法,HashMap的实现类,可以通过实现这个方法,在节点插入后做一些事情
    afterNodeInsertion(evict);
    return null;
}

通过阅读以上代码得出以下几点:
【①】处:
HashMap初始化的时候,底层的数组是Null。
只有当第一次向HashMap中放入值得时候,数组才会初始化,初始化长度是16. 扩容的阈值threshold的大小是 16*0.75=12
【②】处:
当向HashMap中新增一个值的时候,程序如何判断应该放在数组的什么位置?答案就是用 key的hash值与数组的长度取模,结果作为数组的下标,就是要放入的位置。
【③】处:
代码【②】处,当发现要放入的位置有节点的时候,且key与已有节点的key相等的时候(hashcode与equals都相等认为是相等),单个的Node会转换为单项链表。
这就是为什么重写equals就要重写hashcode的原因,与我的另一篇博客equals方法知多少中也提到了这点。如果不能保证equals相等hashcode也相等的逻辑,当HashMap中使用对象作为Key的时候,就会出现逻辑混乱,明明是一致的对象,会变为不一致
【④】处:
当链表的长度大于8的时候,会转为红黑树。
【⑤】处:
HashMap底层的数组是动态扩容的,当数组的长度大于threshold扩容阈值的时候,就会触发扩容操作。
【⑥】处:
onlyIfAbsent这个开关的使用,要放入的Key与已有的节点的Key相同的会后,是否可以覆盖原有的Value。默认是可以的覆盖的。与默认实现相对应的public V putIfAbsent(K key, V value),如果调用这个方法新增数据,当Key相同的时候,不会覆盖已有的Value。

进一步思考一下,既然HashMap底层数组是动态扩容的,那么如果我们通过默认的构造器声明的一个HashMap对象,数组大小默认是16,如果我们有大量的数据要存储到HashMap对象中,那么数组就会不断的扩容。这样就会造成性能问题,因此会有一个说法,如果能预先估算出放入HashMap数据量的大小,那么创建HashMap对象的时候就要调用HashMap(int initialCapacity)构造方法创建对象,避免反复的扩容操作。

另外,还有三个方法void afterNodeAccess(Node p)void afterNodeInsertion(boolean evict)void afterNodeRemoval(Node p)也需要注意一下,这三个方法是钩子方法。这种思想也是值得借鉴的在这里插入代码片

上面①~⑤处的代码,经常出现在面试题中,值得反复推敲

3.数组扩容方法resize()

final Node<K,V>[] resize() {
    // 获取HashMap底层的Node类型数组
    Node<K,V>[] oldTab = table;
    // 获取原有数组 的容量,如果为空,长度为0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 获得输入扩容的阈值
    int oldThr = threshold;
    // 定义数组扩容后的容量,和扩容后新的扩容阈值
    int newCap, newThr = 0;
    // 如果原有数组长度大于0
    if (oldCap > 0) {
        // 【①】如果数组的容量大于等于最大值了,那么就不会触发扩容操作。也就是数组的最大容量是MAXIMUM_CAPACITY
        if (oldCap >= MAXIMUM_CAPACITY) {
            //设置扩容阈值为Integer最大值  MAXIMUM_CAPACITY*2-1
            threshold = Integer.MAX_VALUE;
            //返回原有数组,数组没有扩容
            return oldTab;
        }
        // 【②】如果数组的容量大于16 且 小于 MAXIMUM_CAPACITY最大容量的一半,那么设置扩容后的容量大小为原大小的二倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //新阈值 设置为 原阈值的2倍
            newThr = oldThr << 1; // double threshold
    }
    // 【③】如果数组长度是0,且设置了扩容阈值。那么扩容后的容量等于设置的扩容阈值。
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 【④】通过无参构造方法创建的HashMap对象,第一次添加值得时候,那么扩容后的容量为16,扩容阈值为12
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新的扩容阈值==0,新
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 设置HashMap底层数组的扩容阈值
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    // 按照扩容后的容量大小,创建一个新的数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 【⑤】将HashMap底层的数组引用指向新创建的数组
    table = newTab;
    // 如果HashMap原数组等于空,什么都不做
    // 如果不等于空,那么进行内容的复制,将原数组中的元素复制到新数组中
    if (oldTab != null) {
        // 遍历原数组
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果当前元素是null,那么不处理
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //如果当前元素是Node节点,且不是链表结构。那么把当前元素放到HashMap的数组中
                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;
}

通过阅读以上代码得出以下几点:
【①】处:
可以知道,HashMap底层的数组是有最大长度的,最大长度=MAXIMUM_CAPACITY=int类型最大值/2+1。
【②】处:
扩容后的大小为原大小的2倍。
【③】处:
如果创建HashMap对象的时候,使用有参构造方法,那么数组大小等于传入的扩容阈值参数大小。也就是说创建HashMap的时候可以指定数组大小。
【④】处:
如果创建HashMap对象的时候,使用无参构造方法,那么数组容量会初始化为16
【⑤】处:
可以知道,扩容不是在原有数组上扩容,而是创建新的数组,把原有数组的内容复制到新数组。

4.删除操作

/**
  * Implements Map.remove and related methods
  *
  * @param hash key对应的Hash值
  * @param key 要删除的Node的Key
  * @param value 要删除Node的Value
  * @param matchValue 在寻找要删除的Node的时候,除了匹配Key,是否还要匹配Value
  * @param movable if false do not move other nodes while removing
  * @return the node, or null if none
  */
 final Node<K,V> removeNode(int hash, Object key, Object value,
                            boolean matchValue, boolean movable) {
     Node<K,V>[] tab; Node<K,V> p; int n, index;
     // 数组不为空,且根据key计算的数组下标上存在Node节点
     if ((tab = table) != null && (n = tab.length) > 0 &&
         (p = tab[index = (n - 1) & hash]) != null) {
         Node<K,V> node = null, e; K k; V v;
         //【①】如果 当前位置 上的Node的Key==要删除的Key
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             node = p;
         else if ((e = p.next) != null) {
             // 如果当前位置上的Node是红黑树
             if (p instanceof TreeNode)
                 //在红黑树上查找要删除的Node节点
                 node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
             // 如果当前位置上的Node节点是链表,找到链表上的节点
             else {
                 // 【②】从链表的头逐项向下找,直到找到为止
                 do {
                     if (e.hash == hash &&
                         ((k = e.key) == key ||
                          (key != null && key.equals(k)))) {
                         node = e;
                         break;
                     }
                     p = e;
                 } while ((e = e.next) != null);
             }
         }
         // 如果找到了删除的节点
         if (node != null && (!matchValue || (v = node.value) == value ||
                              (value != null && value.equals(v)))) {
             // 如果找到的节点是红黑树对象,那么执行红黑树的删除方法
             if (node instanceof TreeNode)
                 ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
             // 如果找到的节点就是Node,非单项链表,非红黑树,那么将数组元素设置为null
             else if (node == p)
                 tab[index] = node.next;
             // 如果是单项链表,将找到的节点的前一个节点的next指向找到节点的next,也就是删除了这个节点
             else
                 p.next = node.next;
             // 修改次数加1
             ++modCount;
             // 数组内的Node个数减一
             --size;             // 钩子方法,提供删除节点的回调
             afterNodeRemoval(node);
             return node;
         }
     }
     return null;
 }

以上代码值得注意的地方
【①】处:
这里又涉及到了之前反复提到的一个准则,如果重写了equals那么也必须保证重写hashcode,保证如果两个对象的equals相等,那么他们的hashcode也要相等。
【②】处
可以看到单项链表的查找效率是比较低的,当要在单项链表中查找数据的时候,是从到尾逐项比对的。而红黑树是遵循了左大又小的规则建立的,查找效率是要高于单项链表的,所以会出现前文提到的,单项链表达到一定程度的时候,会转化为红黑树,就是出于对性能的考虑。

另外还有个重载的方法:public boolean remove(Object key, Object value)意思是根据key和value两项去查找对应的Node节点进行删除。

2.单向链表

上一节中提到,当调用put(K key,V value)向HashMap中添加数据的时候,会根据Key的hash值与数组的长度取模,作为数组的下标,把数据放在数组的这个位置。如果有多个待添加的数据的Key,经过取下标算法计算后的值都是一样的,也就是出现了hash冲突,这时候存储在该位置的单个Node就会转换为单项链表。

HashMap.Node的源码:

/**
  * Basic hash bin node, used for most entries.  (See below for
  * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
  */
 static class Node<K,V> implements Map.Entry<K,V> {
     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;
     }

     public final K getKey()        { return key; }
     public final V getValue()      { return value; }
     public final String toString() { return key + "=" + value; }

     public final int hashCode() {
         return Objects.hashCode(key) ^ Objects.hashCode(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;
     }
 }

可以看到除了正常的Key 和Value数据外,还有一个指针next,指向下一个变量,因此通过这种结构,Node之间就可以组成单项链表的结构,类似下图
HashMap原理及常见考点_第6张图片
这种结构有如下特点:

  1. 查询慢。当想要查询某一个节点的时候,需要从头逐项查找,知道找到结果为止。当链表长度比较长的时候,效率是比较慢的。
  2. 新增或者删除块。当需要新增或者删除节点的之后只需要通过设置next这个指针就可以完成。效率还是比较高的。
  3. 结合以上的这两个特点,为了提高HashMap的性能,就产生了链表转红黑树的事情。

3.红黑树

当单项链表的长度超出限制的时候就会转换为红黑树,红黑树是由TreeNode组成的,我们先看一下它的结构

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

红黑树的结构大概如下图:
HashMap原理及常见考点_第7张图片
它有如下的几个特点:

  1. 每个TreeNode都有左节点、右节点、父节点,及前一个节点
  2. 左侧节点的Key的Hash值一定是比自己小的,右侧的一定是比自己大的
  3. 这种结构就非常适合查找节点操作,只需要根据要查找节点的Key的Hash值按照左大又小的规则进行查找就能快速定位,因此它的查询性能是很高的。

总结

HashMap内部结构相对比较复杂,掌握底层的原理是很重要的,一个是在面试中能够为自己加分。再有再实际工作中,也可以借鉴源码中很多比较好的思想,同时也可以编写出更合理的代码,比如初始化HashMap的时候指定大小能够防止频繁的扩容带来的性能问题。

你可能感兴趣的:(源码阅读,java,map,java)