JDK-1.8 HashMap源码分析

  和之前的系列一样,我们先上HashMap的类继承关系图,如下:
《JDK-1.8 HashMap源码分析》
  一般说到HashMap,和它关联最大的应该就是ConcurrentHashMapHashTableTreeMap等。之前已经介绍了HashTable,这里通过继承关系图可以看到和HashTable不一样的是,HashMap是继承实现的AbstractMap,而HashTable则是继承实现自Dictionary类。
  接下来,我们继续分析HashMap的底层数据存储方式,对此几个主要变量如下:

    1. transient Node<K,V>[] table:底层存储数据用的数组
    1. transient int size:数组中已有的元素个数
    1. int threshold:扩容阀值
    1. final float loadFactor:负载因子
    1. static final int TREEIFY_THRESHOLD = 8:链表转红黑树的阀值
    1. static final int UNTREEIFY_THRESHOLD = 6:红黑树转链表的阀值
    1. static final int MIN_TREEIFY_CAPACITY = 64:需要进行链表转红黑树的table数组阀值

  以上变量可以得到的信息是HashMap底层的存储是一个Node数组,数组还和红黑树有关联,这里所谓的和红黑树的关联就是在JDK 1.8中HashMap中每个table链表数组中的链表在达到对应条件后,为了提高效率会把对应槽位的链表结构转化为一个红黑树的结构,从而提高了查询效率,因为后续分析过程中会发现其实HashMap中会涉及非常多的查询逻辑,这里指的查询不只是单纯的get方法的查询。在1.7以前的版本中,如果哈希函数设计的不合理,那么可能会导致非常多的哈希冲突发生,那么可能就会导致在某一个槽位的链表中出现非常多的元素,从而在查询的时候需要遍历一遍效率非常低,这也是引入红黑树的原因。
  接下来,我们先走进HashMap的构造函数,构造函数和HashTable一样有两大类四个构造函数,一种是没有数据只是进行简单初始化的构造,还有一种是带有map数据的构造,代码如下:

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

public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

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

  以上构造函数需要注意的是,我们常用的是无参构造和指定初始化长度的这两个,对于无参构造而言,在没有调用put方法之前,构造函数中只是给负载因子赋值0.75,而初始化长度和阀值等都是默认为0的。对于第一个两个参数都指定的构造函数有一点需要注意的是,在初始化的时候,做的事情只是给负载因子以及扩容阀值赋值,并且这里的扩容阀值的赋值还是和我们知道的threshold=size*loadFactor不一样,这里threshold=tableSizeFor(initialCapacity) = tableSize。对于tableSizeFor函数而言,作用就是找到最接近刚好大于等于initialCapacity2^n的值,即假设initialCapacity=13,那么tableSizeFor(initialCapacity)=16。一开始看到这里的时候自己也很纳闷为什么这里是这么赋值的,而且没有给table数组初始化,既然构造函数里都没有做这些事情,那么很显然唯一能做这些事情的只有在put初次往HashMap中插入数据的时候了。带着这个疑问,我们跳过另外一个构造函数的解析,因为实现都很简单,无需赘述,直接开始介绍关于HashMap的增删改查对应的方法实现。

新增&修改put

  关于put方法延伸开来,需要介绍的地方有很多,我们先看源码和备注如下:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

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为空,说明这是第一次调用put方法,需要进行数组初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        // 如果在tab位置的对应槽位没有数据,说明本次插入运气好,找到了一个空的位置,直接新建一个节点插入到对应槽位即可。
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 发现这个槽位已经有数据了,也即产生了哈希冲突了,进一步进行判断如何解决这个问题
        else {
            // 这里的e是用来标记本次put操作是更新了已有key的数据还是插入了一个新节点
            Node<K,V> e; K k;
            // 发现刚好当前链表的头结点就是需要插入数据的key
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 头结点不是我们所需,且发现这个槽位中存储的其实是一个红黑树,则需要往红黑树中执行put操作
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 到这里就发现头结点不是我们找的节点,且p不是红黑树,那么就只有去遍历链表p来进行put操作了
            else {
                // binCount是用来记录当前遍历了多少个节点的
                for (int binCount = 0; ; ++binCount) {
                    // 满足这个条件说明已经遍历到了链表p的尾节点还没找到key,那么需要做的事情就是在最后
                    // 插入新的节点数据,这里需要注意如果是尾节点,那么会导致e=p.next=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是否就是我们寻找的那个她,如果是就掳走!
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // e如果不为空,说明是找到了那个满足条件的她,而不是插入了一个新节点,那么我们需要做的
            // 就是把e的值修改为本次put的value,并返回oldValue。
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 执行到这里,说明本次put操作是新建了一个节点插入到链表中。
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

  以上putVal方法中备注部分已经把执行逻辑基本介绍清楚,但是其中涉及到resize和红黑树相关内容,这里我们暂时忽略红黑树,继续介绍resize扩容的操作,代码如下:

final Node<K,V>[] resize() {
        // 备份原始table,保留犯罪现场。
        Node<K,V>[] oldTab = table;
        // 初次调用put时,之前说过在构造函数中不会初始化table,所以这里肯定table==null成立,
        // 即oldCap=0,当然如果不是初次调用就是oldCap=oldTab.length
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 这里记住之前构造函数中,在构造函数中threshold取值其实是初始化的数组长度!!
        int oldThr = threshold;
        int newCap, newThr = 0;
        // 满足这个条件,说明原始的table中已经初始化过了,说明肯定有数据。
        if (oldCap > 0) {
            // table长度都达到人生巅峰了,不需要再扩容了!
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 执行到这里说明数组有数据,这种情况下先给newCap赋值为oldCap*2,然后判断条件是否满足,
            // 如果条件满足,则给新的阀值赋值为原始阀值的两倍。
            // 这里有一种情况就是构造函数加入调用的是 new HashMap<>(8)这种,此时初始化数组长度是8,
            // 在没有进行扩容前,执行到这里会发现由于oldCap>=16不成立而跳过,为啥要这样,
            // 我们先带着这个疑问继续往下看。
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // 执行到这里,说明oldCap==0,如果oldThr>0,说明用的构造函数是带有初始化数组长度的。
        else if (oldThr > 0) 
            // 之前介绍过,对于带有初始化长度的构造函数,阀值就是数组初始化长度,果然这里用到了
            newCap = oldThr;
        else { // 执行到这块逻辑说明调用的是无参构造创建的,才会有阀值和数组长度都为0。
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 执行满足条件说明是第一个if或者第二个else if中没有设置newThr,在这里统一设置
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 更新扩容阀值,这里对于初次构造函数中设置的"错误"的阀值在此处就回归正轨了
        threshold = newThr;
        // 创建新的Node数组,来容纳之前的数据
        @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) {
                Node<K,V> e;
                // 当前槽位链表不为空,需要进行迁移
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null; // 由于e中已经保留了备份,这里值为空值便于GC
                    // 当前槽位只有一个节点,直接挪到新表中即可。
                    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
                        // 到这里就意味着,当前槽位是一个没有转化为红黑树的链表,由于数组长度是2^n
                        // 扩容后变为了2^(n+1),所以对于模运算来说,之前在同一个槽位中的节点,
                        // 扩容后只会分配到两个位置,一个是新数组中当前下班low处,另一个是新数组中的新下标high处
                        // 为了提高插入新数组的效率,先遍历一遍链表,然后得到两个独立的链表,分别代表low和high
                        // 对于这两个链表分别插入到新数组中的两个位置。
                        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);
                        // 分别将loTail和hiTail插入新数组中。
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

  以上就是关于HashMap中对于put方法插入数据时的相关内容,为了提高效率,内部进行的优化有:

    1. 尽量用各种位运算,提高效率
    1. 为了解决1.7以前某个槽位链表过程导致查询耗时过长问题,引入红黑树,当链表长度超过8时,将链表转换为红黑树,提高检索效率。
    1. 在进行扩容时,先遍历链表中数据,分别得到low和high两个新链表,分别插入到新数组中。
    1. 还有就是遍历过程中,用完槽位的数据立马设置为null,便于GC。
    1. 由于扩容是原始长度*2,所以对于获取新数组中的槽位index的时候,只会分别对应两个值,为啥这么说呢,这是因为同一个链表中的所有key,他们对于之前老的数组模除假设为m,这里假设哈希值为hash,数组长度为l,那么可以知道hash = l*t + m,这里t是大于等于0的整数,只有这样才有hash%l=m。那么,这里当t为偶数的时候,假设t=2*k,用扩容后的新数组n=2*l来模运算,则有nhash = (l*2*k+m)%(2*l)=m,即如果t为偶数时,在新数组中当前这个key的槽位下标是不变的,还是在m处。同理,很显然,当t为基数的时候,t=2*k+1可以得到nhash=(l*2*k+l+m)%(2*l)=l+m,所以对于这类节点,它们新的归属就是新数组中的l+m位置,这也是为啥resize中挪动链表时会出现两个loTailhiTail的原因,分为两个链表后,可以保留之前的顺序,并且在新数组中只需要执行两次插入操作即可。

  当然了,这其中在putVal中最后有几个方法是预留给LinkedHashMap方法实现用的,因为LinkedHashMap就是升级版本或者说改造版本的HashMap,这个在之后再进行分析。

删除remove

  对于remove方法,和之前写的系列文章说的一样,在弄明白put方法是如何插入数据后,其它的删除,修改,查找方法的逻辑都是不难理解的,因为put方法中基本会涉及大家都会用到的检索逻辑,只要把检索节点的逻辑搞清楚了,那么后续其它的所有方法都是很好理解的了。我们直接上remove的方法如下:

public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

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;
        // 下面这部分的逻辑和之前`put`中遍历节点是完全一样的,先找到匹配key的节点,记录在node变量中
        // 然后遍历完后,判断node是否为空,如果不为空说明找到了需要删除的节点,进行删除操作就可以了
        // 由于这里面涉及的删除操作是链表的删除,也不需要和数组删除一样移动元素,实现十分简单,
        // 在此不再进行赘述,只是说如果发现没有匹配到key,返回null则说明删除失败。
        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;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                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);
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

  删除的逻辑和put检索用到的逻辑完全一致,分析看备注中内容即可。另外,还有其它的删除需要匹配value的方法,就是在检索的时候如果找到key满足条件,则进行删除的时候多加一条逻辑判断value是否匹配即可。

查询get

  和remove一样,先直接上代码:

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
         // 判断数组是否为空 && 对应槽位是否为空
        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;
    }

  对于get而言,其实逻辑比remove更简单,因为对于remove还需要考虑如果删除成功后,节点个数少于6个则需要把红黑树转换为链表的情况。而get就只是需要检索,找到匹配的key就可以返回了。
  以上就是HashMap的主要介绍,当然了一般会把HashMapHashTable进行比较,这里简单列一下他们的区别:

    1. HashTable是线程安全的,都是通过synchronized关键字加锁实现的。
    1. HashTable在构造函数中是会初始化table数组的,初始化默认长度为11,HashMap则默认是16,并且在构造函数中不会初始化。
    1. HashTable中的value是不能为null的,而HashMap对此不加限制。
    1. HashTable中扩容的时候,由于底层存储的数组长度不一定是2^n,而且扩容是用的newCap = (oldCap<<1)+1,新数组长度也不是倍数关系,所以之前HashMap推导新数组中节点槽位的方法这里不适用,从而在扩容时,必须一一遍历数组中的节点,然后依次插入到新的数组中,效率十分低。
    1. HashMapHashTable继承的子类也不一样,HashMap继承自AbstractMapHashTable继承自Dictionary
    原文作者:KLordy
    原文地址: https://blog.csdn.net/klordy_123/article/details/88198090
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞