HashMap源码分析(基于1.8版本)

终于要开篇写HashMap了,作为集合届的头把交椅,HashMap不可不谓为响当当的,其代码也让很多人望而生畏,但其实仔细琢磨一下,其复杂度并没有特别的让人害怕(起码对比ConcurrentHashMap而言),因此,让我们走近来近距离瞧一瞧这个大名鼎鼎的HashMap吧。

鉴于我本地安装的版本是1.8的,因此,分析1.8版本的是HashMap。最后会分析1.7和1.8的有什么区别。

首先,HashMap使用链地址法来解决hash冲突的问题,HashMap1.8使用的是数组+链表/红黑树。

注:虽然是源码解析,但是并不是所有的源码都会涉及到,只涉及到经常使用的那些。

首先看看HashMap的类关系图,了解一下它的继承关系和实现的接口。

再来看看一些一些常量和变量,以及构造方法和hash方法。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    // 默认的初始化容量,即默认的数组大小,为16.该值必须是2的次方。
    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;
    // 如果没达到这个容量,会先扩容,而不是树形化。这样避免调整大小和树形化冲突。
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    /**
    *   变量
   	**/
    // HashMap的数组定义
    transient Node<K,V>[] table;
    
    // 做遍历时使用。
    transient Set<Map.Entry<K,V>> entrySet;

    // HashMap大小。
    transient int size;

    // HashMap结构改变的次数。
    transient int modCount;
    
    // 表示size大于它的时候会进行扩容操作。
    int threshold;

    // 负载因子。
    final float loadFactor;
    
	// 参数为初始容量和负载因子的构造函数。
    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;
		// 将容量调整为大于参数的最小2次方
        this.threshold = tableSizeFor(initialCapacity);
    }
    // 参数为初始容量的构造方法。
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    // 无参构造函数,会将负载因子设为默认的。
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    // 参数为Map类型的构造函数
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
    
    // hashMap自带的hash函数。
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

首先讲解一些常量,可以看出来,HashMap容量大小默认是16,且必须是2的次方,这个原因后续会说。负载因子为0.75,也就是说HashMap中的元素达到容量的0.75就扩容,如16*0.75=12,那么容量使用达到12就会扩容。因此这个值太小了容易导致扩容频繁,非常消耗性能,太大了容易导致哈希冲突概率变大,链表变长,这样的话查找效率就低了。总之值的大小的优缺点是对立的,0.75是官方认为一个较为平衡的值。

至于变量,注释已经给出了相应的解释。

最后看下构造函数和hash方法,在所有的构造函数中,都会设置负载因子和初始化容量,如果用户没有给,那么就使用默认的,其中,初始化容量,即使用户给了非2的次方数,也会使用tableSize方法调整过来,比如传11,容量并不会就是11,而是16,传17,就会是32。始终保持2的次方。至于hash方法,又叫扰动函数,它能使hash的值分布的更随机,避免hash冲突太频繁。

常用API

这次就不列增删改查了,直接从方法出发。

put方法

	// 调用了下面的方法
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * 真正执行put的方法
    */
    // onlyIfAbsent为true时,不会改变已经存在的值,也就是说,只有key不存在时才会put。
    // evict不用关心
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 如果数组为null,或是长度为0,就进行扩容。
        // 这意味着第一次put时就会进行扩容。
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 如果key对应的那个位置为空,那么直接创建一个node放置便可。
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else { // 否则,说明有hash冲突了。
            Node<K,V> e; K k;
            // p是这个数组的第一个节点,如果p和要插入的数据是一个值,那么将p赋值给e
            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) {
               		// 如果没找到,就新建一个node节点,放在p后面。可以看出,这是尾插。
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 如果插入值之后是第八个节点,那么树形化。
                        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不为null,也就是说要插入的键值对中的key是存在。
            if (e != null) { // existing mapping for key
            	// 将旧的值取出
                V oldValue = e.value;
                // 如果onlyIfAbsent为false,或者旧值为null,将新的值覆盖
                // (有关onlyIfAbsent的地方,方法开头已经写了)
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 这个不重要。
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 如果新增了node节点,就会modCount自增
        ++modCount;
        // 同时由于新增node,size也会自增,自增后超过阈值,也需要扩容。
        if (++size > threshold)
            resize();
        // 这个也不重要。
        afterNodeInsertion(evict);
        return null;
    }

put方法还是比较好理解的。笼统概括一下。首先,如果是第一次put元素,那么就会先扩容,达到默认的16或者用户自定义的大小。然后使用(n - 1) & hash进行取模运算,这个与运算和hash % n的效果是一样的,同时由于是位操作,因此会比%快。取模运算后看key的hash值是在数组的哪个位置,如果该位置上没有元素,那么直接新建一个node元素放在该位置上。否则说明数组上已经有元素了,那么首先判断该key是不是头结点,如果是,将头节点赋给e。如果不是,且头节点是红黑树的节点,那么走红黑树的查找。否则是链表,遍历链表,如果找到了,将节点赋给e。如果遍历了还是没有,就新建node节点放在链表尾部,此时,如果新增的元素刚好是第8个节点,那么树形化。最后,如果e的值不为null,说明key已经在map中存在了,覆盖然后返回旧值就行了,当然,**onlyIfAbsent为true,且有旧值时是不能覆盖的。**否则,要插入的键值对是新增的,那么增加
modCount,同时增加size,如果大于阈值,就扩容。

注意点

我们现在看看树形化的代码,这里有一个需要引起注意的地方

	final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        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);
        }
    }

里面具体如何树形化的我就不解释了,但是可以看到该方法的开头, 如果数组为空,或者数组长度小于MIN_TREEIFY_CAPACITY(即64),那么都会先扩容。

也就是说!!! 扩容的时机其实并不仅仅是数组大小大于阈值才会扩容,在树形化时如果数组大小没有达到64,也是会先扩容的!!!

总结一下put方法:

  1. 第一次put时,会导致扩容。
  2. 链表长为8时会树形化。
  3. 新增节点后,如果大于阈值,会导致扩容。
  4. 树形化时,如果数组大小没有达到默认的64,会先扩容,而不是树形化。

resize方法

	final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        // 旧的容量,如果是第一次扩容,旧容量就是0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 旧的阈值
        int oldThr = threshold;
        // 新的容量和阈值。
        int newCap, newThr = 0;
        if (oldCap > 0) {
        	// 这块就是数组扩容,不多说。
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 将数组扩容至2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                 // 阈值也翻倍。
                newThr = oldThr << 1; // double threshold
        }
        // 说明是第一次扩容,且使用了带参的构造方法,将阈值赋给新容量。
        else if (oldThr > 0) 
            newCap = oldThr;
        else { // 说明是第一次扩容,使用的是无参的构造方法,那么使用系统默认的值
            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;
        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;
                    // 如果该位置上只有一个元素,那么迁移这个元素就行了。
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    // 如果该位置是红黑树,提一句,迁移数据之后,长度小于UNTREEIFY_THRESHOLD(即6),那么就会转回为链表
                    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-while是将数组分为两个链表,一个是与旧容量相与为1,一个是为0
                        do {
                            next = e.next;
                            // 如果e的hash值与旧容量进行与运算后还是为0
                            if ((e.hash & oldCap) == 0) {
                            	// 如果loTail 为null,那么loHead指向e.
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else { // 如果进行与运算为1
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 如果相与为0,那么待在原位置。
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 如果为1,则迁去新位置,这个新位置是原位置+原容量。
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

扩容也是比较好理解的,记录旧的容量和旧的阈值。如果是第一次扩容,那么将容量设为默认的或者用户传来的。如果不是,将容量扩容至原来的两倍,同时,在这里可以看到阈值 = 负载因子 * 容量。

如果不是第一次扩容,需要进行元素迁移。如果是链表的迁移,在扩容中只用判断原来的 hash 值与原容量按位与操作是 0 或 1 就行,0 的话索引就不变,1 的话索引变成原索引加上扩容前数组,这里为什么是这样解释一下,新容量的大小是原大小的两倍,之前当key判断自己应该在数组中的哪个位置时,使用的是(n - 1) & hash,这里的n是指数组大小。那么元素迁移要判断自己位置时,也就是(新容量 - 1) & hash,新容量-1和旧容量-1在二进制中只是多了最高位上的1,而这个1就是旧容量上的1,因此只要与旧容量进行&运算就行。

可能文字解释的比较绕口。使用实例解释一下吧。

// 假设hash值是10101。
// oldSize是 10000.
// newSize就是 100000.
// (oldSize - 1) & hash = 01111 & 10101 = 00101.
// (newSize - 1) & hash = 011111 & 10101 = 10101.
// 可以看出newSize - 1比oldSize - 1只是多了1,而这个1的位置就是oldSize的1的位置,其他并没有变
// 因此在newSize是oldSize两倍的情况下,(newSize - 1) & hash与oldSize & hash的结果是一样的

get方法

	// 调用下面的方法
	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;
        // 如果数组不为null,且长度不为空,且key的hash对应的数组位置不为null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 如果第一个元素就是,直接返回第一个元素
            if (first.hash == hash && 
                ((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);
            }
        }
        // 没找到就返回null。
        return null;
    }

get方法对比put和resize方法还是很简单的。这里不解释了。

在这里插入代码片

与1.7版本的对比

由于不会写1.7版本的hashMap源码,因此这里说一下两者之间的区别。

  • 首先,是结构上的区别。1.7的是数组+链表,1.8是数组+链表/红黑树。
  • 其次,是数据上的区别。初始化时1.8是直接用resize方法的,1.7使用了额外的inflateTable方法。插入数据时,1.8使用的是尾插法,1.7是头插法。
  • 最后,是扩容时的区别。
    1. 1.8迁移元素使用的是hash值和原容量进行&操作,新的位置一般在原位置或者原位置+原容量的位置上,而1.7还是使用的原来的方法,即先进行扰动处理,再进行(n - 1) & hash,判断新位置在哪;
    2. 同时,1.8扩容还是使用的尾插法,而1.7扩容还是使用头插法,这样很容易导致环形链表死循环的情况;
    3. 1.8是先插入后判断是否需要扩容,1.7是先扩容再插入。

与HashTable的对比

HashTable其实现在很少用了,但还是提一下主要的区别吧。

  • HashTable继承自Dictionary类,而HashMap继承自Map。
  • HashMap允许key和value为null的,HashTable则不行。
  • HashTable的所有方法都是加上了sychronized的,性能因此会比较低,而HashMap不是。

总结一下(1.8版本)

  1. HashMap是基于数组+链表/红黑树的,HashMap有阈值,该阈值大小是由负载因子相乘容量大小决定的,一旦容量大于该阈值,就会导致扩容,同样的第一次put操作也会扩容。一旦链表长度达到8,就树形化为红黑树,如果此时数组长度大小小于默认的64,就会先扩容,而不是树形化。
  2. HashMap在添加元素和扩容时都是使用尾插法,而且是先插值后判断是否需要扩容。
  3. HashMap是线程不安全的,在多线程并发访问时需要同步,可以使用替代的ConcurrentHashMap或者使用 Collections.synchronizedMap()修饰。

以上,是关于HashMap 1.8的全部内容。

谢谢各位的观看。本人才疏学浅,如有错误之处,欢迎指正,共同进步。

猜你喜欢

转载自blog.csdn.net/RebelHero/article/details/89459359