深入理解HashMap原理

1,090 阅读8分钟

深入理解HashMap原理

什么是HashMap

是一种靠hash值来进行分配存储的Map结构,Map就是用来储存键值对的集合类

为什么我们需要了解HashMap底层原理

  1. HashMap是面试时必考的一道面试题
  2. HashMap也是工作中最常用的集合之一,所以很有必要了解底层原理,进而在出现问题时能快速定位问题
  3. HashMap里面涉及了很多的知识点,可以全面考察面试者的基本功,想要拿到一个好offer,这是一个迈不过去的坎,接下来我用最通俗易懂的语言带着大家揭开HashMap的神秘面纱

常用的Map集合有哪些

常用的有HashMap、TreeMap、LinkedMap等,这里着重讲解一下HashMap,因为HashMap最常用,面试也是最常问

HashMap底层数据结构

这里向大家介绍的是jdk8的HashMap,因为jdk8对HashMap进行了进一步的优化,当数组长度和链表长度达到阀值时会把链表转化为红黑树,这样就进一步优化了查询的时间复杂度,这里也会涉及更多的知识点,所以面试会很常问

HashMap采用的是数组+链表+红黑树的结构

HashMap源码分析

我们可以思考一下我们应该从哪里入手了解HashMap呢?

首先从HashMap的类头入手

// 继承了AbstractMap,AbstractMap里面实现了一部分Map的基本方法
// 实现了Map接口,里面一些方法需要子类去根据自己的特性去实现
// 实现了Cloneable、Serializable,可以实现克隆和序列化
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    // 序列化版本号
    private static final long serialVersionUID = 362498820763181265L;

    // 默认初始化大小
    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;

    // 链表转化为红黑树的阀值(链表长度达到8)
    static final int TREEIFY_THRESHOLD = 8;

    // 红黑树退化为链表的阀值 (节点个数为6)
    static final int UNTREEIFY_THRESHOLD = 6;

    // 最小转化为红黑树的数组容量
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    // 存放链表的数组
    transient Node<K,V>[] table;

    // 可以用作获取key、value
    transient Set<Map.Entry<K,V>> entrySet;

    // 键值映射数
    transient int size;

    // hashMap被修改的次数,这个参数用于判断快速失败
    transient int modCount;

    // 键值对数达到多少进行扩容
    int threshold;

    // 负载因子
    final float loadFactor;
}

其实观察到上面的成员属性,这里面回答了特别常问的几道面试题

  1. HashMap中的链表转化为红黑树的条件是什么?

    链表的长度达到8并且HashMap中数组的长度达到64,这样才会进行红黑树的转化,否则只会进行数组的扩容

  2. 成员属性table被transient修饰说明不参与序列化但是实际上是可以进行序列化的,为什么这么做?

    HashMap的设计者不想整个数组都被序列化,而是在序列化的时候把其中的内容进行了序列化

接下来我们分析HashMap的构造函数

    // HashMap有很多重载的构造函数,但是最全最基本的就是这个
    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;
      // 如果传入的initialCapacity不是2的整数倍转化为2的整数倍,这个在后面解释
      this.threshold = tableSizeFor(initialCapacity);
    }

接下来就开始揭开put()方法真实的面纱啦!!

    // 最常用的put方法,首先我们需要知道hashMap是通过key的hash值去找到数组中某一个位置的
    public V put(K key, V value) {
      return putVal(hash(key), key, value, false, true);
    }

    // 如果key不是空的,通过把key的hashcode()值高16位和低16位进行异或获得结果,为什么这么做后面解释
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    // put方法的具体实现方法
    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值和数组长度-1进行&操作,得到要存放在数组的具体位置
      	// 如果数组所在位置为null,直接构建成Node节点存放(这个也就是构建的链表节点)
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
      	// 如果数组所在位置有值存在
        else {
            Node<K,V> e; K k;
            // 如果数组中的头节点的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 {
              	// 依次遍历链表中的每个节点
                for (int binCount = 0; ; ++binCount) {
                    // 如果当前节点的下一个null,则直接把新数据插入到尾部
                    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相同,则直接break
                    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;
    }
接下来看下数组扩容的过程
    // 扩容方法
    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) {
            // 如果老数组的容量已经大于最大的容量了,扩容阀值直接赋值成最大的Integer值后直接返回
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
          	// 否则判断如果把老数组容量左移1位(就是容量*2)为什么采用左移这个操作后面解释
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
              	// 扩容阀值也是左移1位
                newThr = oldThr << 1; // double threshold
        }
      	// 如果老数组的容量是0,说明正在做数组初始化操作,新容量就是老扩容阀值的值,也就是我们传进去的值
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
      	// 如果在构造函数未指定值,则采用默认值16
        else {               // zero initial threshold signifies using defaults
            // 新数组容量是默认16
            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;
      	// 如果数组非null
        if (oldTab != null) {
            // 循环老数组的每个位置
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
              	// 头节点非null
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    // 如果头节点的下一个节点是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;
    }

这里回答下上面分析源码时未具体展开的问题

  1. hash值是通过key的hashcode()值高16位和低16位进行异或获得结果

    其实是为了减少碰撞,进一步降低hash冲突的几率,右移16位异或可以同时保留高16位于低16位的特征,如果是&或者|都会失去一定的特征,这样更能减少碰撞

  2. 为什么在传入非2的整数倍会自动变为2的整数倍

    因为当数组的长度是2的倍数时,当计算key所在数组的位置时(p = tab[i = (n - 1) & hash)这里当n-1用二进制表示他的尾数都是1

    举例:如果数组长度是16,n-1为15二进制表示是:0000 1111

    这样在对hash值做&运算时具体的值是由hash值决定的

    举例:如果有两个待插入的键值对,A的hash值:0000 1001 B的hash值:0000 1101,这样当hash值对n-1&时,他俩的结果就是不同的,但是如果n-1其中尾数有非1 比如 0000 1001,这样A和B对n-1进行&就会产生相同的结果,就会出现碰撞的情况

    为什么采用&,如果在扩容时采用*在key寻找位置的用%的方式可以不?

    当然可以,但是为了性能的提升,所以都采用了位运算,这样就能带来性能的提升

  3. 为什么扩容是采用左移的方式,而不是直接*2

    在上一个答案中已经解答因为位运算更快,能提高性能

缺点

上面介绍了HashMap的底层原理,当然我们都知道HashMap是线程不安全的,那么当出现并发的场景我们应该怎么选择呢?下期将给大家揭开ConcurrentHashMap的面纱!

总结

到这我们已经了解了HashMap的底层结构、put方法的具体实现和作者在一些实现中的优化点,也能学习到作者在一些地方的代码思路

其实我知道看源码会很枯燥,很乏味,但是我觉得还是应该坚持下去,这样能做到知其然,知其所以然,这些看源码的能力会潜移默化的提升你的能力,当你做开发时会不由自主的想到作者的一些思路,从而提升你的代码能力,我希望每个开发者都能坚持下来,一起加油!

奥利给

最后我想用一句话结束这篇文章,努力加油吧,多年以后,你一定会感谢曾经那么努力的自己!

我是爱写代码的何同学,我们下期再见!