HashMap底层源码透彻解析

HashMap源码解析

HashMap简介

HashMap在底层数据结构上采用了数组+链表+红黑树,通过散列映射来存储键值对数据因为在查询上使用散列码(通过键生成一个数字作为数组下标,这个数字就是hashcode)所以在查询上的访问速度比较快,HashMap最多允许一对键值对的Key为Null,允许多对键值对的value为Null。它的线程不是安全的,在排序上面是无序的。

img

HashMap继承关系

在这里插入图片描述

HashMap主要成员变量

    	//Node可以看做就是一个节点,多个Node节点构成链表,当链表长度大于8的时候转换为红黑树。
    	static class Node<K,V> implements Map.Entry<K,V> {
            final int hash;
            final K key;
            V value;
            Node<K,V> next;
        //默认初始容量
        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;
    	//树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD - 1时,会将该链表换成红黑树
        static final int TREEIFY_THRESHOLD = 8;
    	//解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表
        static final int UNTREEIFY_THRESHOLD = 6;
    	//最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组
        static final int MIN_TREEIFY_CAPACITY = 64;
        //hashmap的内部数组,Node则是链表节点对象
        transient Node<K,V>[] table;
        //容器类成员
        transient Set<Map.Entry<K,V>> entrySet;
    	//元素个数
        transient int size;
    	//容器结构的修改次数
        transient int modCount;
    	//阈值,超过这个值时扩充数组。threshold = capacity * load factor
        int threshold;
    	//负载因子
        final float loadFactor;

HashMap的源码分析

    	//默认数组初始容量为16,负载因子为0.75f
    	public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }
    	
    	//指定具体的初始容量
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    	//指定具体的初始容量和负载因子
        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;
            //tableSizeFor方法返回一个比给定整数大且最接近的2的幂次方整数
            this.threshold = tableSizeFor(initialCapacity);
        }
    
    	//进入tableSizeFor方法
        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;
        }
    
           /**原理实际上就是补位,将原本为0的空位填补为1,最后加1时,最高有效位进1,其余变为0
            * int n = cat - 1就是防止cap已经是2的幂,执行后面无符号操作时,返回的capacity是这个cap		    * 的两倍。
           */
    		
    		//假设传进来的cap值是10,n = cap -1 = 9
    		// 0000 1001 >>> 1 右移运算为 0000 0100 最后与运算 0000 1101
    		// 0000 1101 >>> 2 右移运算为 0000 0011 最后与运算 0000 1111
    		// 0000 1111 >>> 4 右移运算为 0000 0000 最后与运算 0000 1111
    		// 0000 1111 >>> 8 右移运算为 0000 0000 最后与运算 0000 1111
    		// 0000 1111 >>> 16 右移运算为 0000 0000 最后与运算 0000 1111
    		// n = n + 1 = 16
    		
    		/**
    		*得到的这个capacity却赋值给了threshold。
    		*但按实际应该这么写:this.threshold=tableSizeFor(initialCapacity)*this.loadFactor;
    		*但是在构造方法中,并没有对table这个成员变量进行初始化,
    		*反而被推迟到了put方法中,在put方法中对threshold重新计算。
    		**/
    	//构造一个和指定map有相同mappings的HashMap
        public HashMap(Map<? extends K, ? extends V> m) {
            this.loadFactor = DEFAULT_LOAD_FACTOR;
            //将m中所有的元素存入HashMap中
            putMapEntries(m, false);
        }
    	
    	//进入putMapEntries方法
        final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
            //获取m中元素的个数
            int s = m.size();
            //当m中有元素时
            if (s > 0) {
                //判断table是否已经初始化,如果为初始化则先初始化一些变量
                if (table == null) { // pre-size
                    //得到最小应设置的容量
                    float ft = ((float)s / loadFactor) + 1.0F;
                    //判断是否超过最大容量
                    int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                             (int)ft : MAXIMUM_CAPACITY);
                    //把要创建的HashMap的容量存放到threshold中
                    if (t > threshold)
                        threshold = tableSizeFor(t);
                }
                //如果已经初始化,判断map的size是否大于threshold,如果大于则进行resize()方法扩容
                else if (s > threshold)
                    resize();
                //遍历map中的元素
                for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                    K key = e.getKey();
                    V value = e.getValue();
                    //元素插入
                    putVal(hash(key), key, value, false, evict);
                }
            }
        }
        //进入扩容方法resize
        final Node<K,V>[] resize() {
            //保存当前table
            Node<K,V>[] oldTab = table;
            //保存当前table的容量
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            //保存当前阈值
            int oldThr = threshold;
            //定义新的table的容量和阈值
            int newCap, newThr = 0;
            //判断原table的容量是否大于0,若大于0则代表原来的table表非空
            if (oldCap > 0) {
                //若旧table的容量大于最大容量,更新阈值为Integer.MAX_VALUE,这样以后不会自动扩容
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                //容量翻倍,使用左移效率更高
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    //阈值翻倍
                    newThr = oldThr << 1; // double threshold
            }
            //如果数组还没创建,但是已经指定了threshold(这种情况是带参构造创建的对象),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);
            }
            //可能是上面newThr = oldThr << 1时,最高位被移除了,变为0
            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"})
            //初始化table
                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;
                        //如果该节点是红黑树,执行split方法,和链表类似的处理
                        else if (e instanceof TreeNode)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        //此时节点是链表
                        else { // preserve order
                            // loHead,loTail为原链表的节点,索引不变
                            Node<K,V> loHead = null, loTail = null;
                            // hiHeadm, hiTail为新链表节点,原索引 + 原数组长度
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;
                            // 遍历链表
                            do {
                                next = e.next;
                                //最高位==0,这是索引不变的链表
                                if ((e.hash & oldCap) == 0) {
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                                //最高位==1,这是索引发生改变的链表
                                else {
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);
                            // 原链表存回原索引位
                            if (loTail != null) {
                                // 链表最后得有个null
                                loTail.next = null;
                                // 链表头指针放在新桶的相同下标(j)处
                                newTab[j] = loHead;
                            }
                            //新链表存到:原索引位 + 原数组长度
                            if (hiTail != null) {
                                hiTail.next = null;
                                //后节点新的位置一定为原来基础上加上oldCap
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }

img

    	//元素插入putval方法
    	// onlyIfAbsent:当存入键值对时,如果该key已存在,是否覆盖它的value。false为覆盖,true为不覆盖。
    	// evict:用于子类LinkedHashMap。
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
           	// tab:内部数组
        	// p:hash对应的索引位中的首节点
        	// n:内部数组的长度
        	// i:hash对应的索引位
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            // 首次put时,内部数组为空,扩充数组。
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            // 计算数组索引,获取该索引位置的首节点,如果为null,添加一个新的节点
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K,V> e; K k;
                // 如果首节点的key和要存入的key相同,那么直接覆盖value的值
                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);
            	// 此时首节点为链表,如果链表中存在该键值对,直接覆盖value。
            	// 如果不存在,则在末端插入键值对。然后判断链表是否大于等于7,尝试转换成红黑树。
            	// 注意此处使用“尝试”,因为在treeifyBin方法中还会判断当前数组容量是否到达64,
            	// 否则会放弃次此转换,优先扩充数组容量。
                else {
                    // 走到这里,hash碰撞了。检查链表中是否包含key,或将键值对添加到链
                    for (int binCount = 0; ; ++binCount) {
                        // 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;
                    }
                }
                // 覆盖value的方法。
                if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            ++modCount;
            // 如果元素个数大于阈值,扩充数组。
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
        }

总结来说就是以下几个步骤:

1.检查数组是否为空,执行resize()扩充;
2.通过hash值计算数组索引,获取该索引位的首节点。
3.如果首节点为null,直接添加节点到该索引位。
4.如果首节点不为null,那么有3种情况
① key和首节点的key相同,覆盖value;否则执行②或③
② 如果首节点是红黑树节点(TreeNode),将键值对添加到红黑树。
③ 如果首节点是链表,将键值对添加到链表。添加之后会判断链表长度是否到达TREEIFY_THRESHOLD - 1这个阈值,“尝试”将链表转换成红黑树。
5.最后判断当前元素个数是否大于threshold,扩充数组。

    	static final int hash(Object key) {
        	int h;
        	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    	}
    	
    	//hash方法的作用是将hashCode进一步的混淆,增加其“随机度”,试图减少插入hashmap时的hash冲突,换句更专业的话来说就是提高离散性能。
    	
    	//在putVal方法中(不仅仅只在putVal中),有这么一行代码
    	if ((p = tab[i = (n - 1) & hash]) == null)
            	tab[i] = newNode(hash, key, value, null);
    	/**
    	*i = (n - 1) & hash,n是数组长度,hash就是通过hash()方法进行高低位异或运算得出来的hash		
    	*值。这个表达式就是hash值的取模运算,上面已经说过当除数为2的次方时,可以用与运算提高性			
    	*能。那么我们想想,大多数情况下,内部数组的容量一般都不会很大,基本分布在16~256之间。所以		
    	*一个32位的hashCode,一直都用最低的4到8位进行与运算,而高位几乎没有参与。所以通过hash()		 
    	*方法,将hashCode高16位与低16位进行异或运算,能有效的提高离散性能。
    	**/

HashMap的put方法

img

JDK1.7和1.8HashMap的不同点

  1. 最重要的一点是底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构;
  2. jdk1.7中当哈希表为空时,会先调用inflateTable()初始化一个数组;而1.8则是直接调用resize()扩容;
  3. 插入键值对的put方法的区别,1.8中会将节点插入到链表尾部,而1.7中是采用头插;
  4. jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀;
  5. 扩容时1.8会保持原链表的顺序,而1.7会颠倒链表的顺序;而且1.8是在元素插入后检测是否需要扩容,1.7则是在元素插入前;
  6. jdk1.8是扩容时通过hash&cap==0将链表分散,无需改变hash值,而1.7是通过更新hashSeed来修改hash值达到分散的目的;
  7. 扩容策略:1.7中是只要不小于阈值就直接扩容2倍;而1.8的扩容策略会更优化,当数组容量未达到64时,以2倍进行扩容,超过64之后若桶中元素个数不小于7就将链表转换为红黑树,但如果红黑树中的元素个数小于6就会还原为链表,当红黑树中元素不小于32的时候才会再次扩容。

HashMap经典面试题

HashMap线程安全方面会出现的问题

1、put的时候导致的多线程数据不一致。
这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

2、另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%)。

img

我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,需要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,我们发现这三个entry都落到了第二个桶里面。
假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。

如果在取链表的时候从头开始取(现在是从尾部开始取)的话,则可以保证节点之间的顺序,那样就不存在这样的问题了。

为什么HashMap的底层数组长度为何总是2的n次方

这里我觉得可以用逆向思维来解释这个问题,我们计算桶的位置完全可以使用h % length,如果这个length是随便设定值的话当然也可以,但是如果你对它进行研究,设计一个合理的值得话,那么将对HashMap的性能发生翻天覆地的变化。

没错,JDK源码作者就发现了,那就是当length为2的N次方的时候。

第一:当length为2的N次方的时候,h & (length-1) = h % length

为什么&效率更高呢?因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高

第二:当length为2的N次方的时候,数据分布均匀,减少冲突

在进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以,当 length=2^n 时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

jdk1.8中扩容逻辑为什么更简单

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”

//通过e.hash & oldCap 来计算原来的hash值新增的那个bit是1还是0
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;
}

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,JDK1.8不会倒置。

解决 hash 冲突的常见方法

针对哈希表直接定址可能存在hash冲突,举一个简单的例子,例如:
第一个键值对A进来,通过计算其key的hash得到的index=0。记做:Entry[0] = A。
第二个键值对B,通过计算其index也等于0, HashMap会将B.next =A,Entry[0] =B,
第三个键值对C,通过计算其index也等于0,那么C.next = B,Entry[0] = C;
这样我们发现index=0的地方事实上存取了A,B,C三个键值对,它们通过next这个属性链接在一起。 对于不同的元素,可能计算出了相同的函数值,这样就产生了hash 冲突,那要解决冲突,又有哪些方法呢?具体如下:

a. 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。

b. 开放定址法:即发生冲突时,去寻找下一个空的哈希地址。只要哈希表足够大,总能找到空的哈希地址。

c. 再哈希法:即发生冲突时,由其他的函数再计算一次哈希值。

d. 建立公共溢出区:将哈希表分为基本表和溢出表,发生冲突时,将冲突的元素放入溢出表。

HashMap采用哪种方法解决冲突的呢?

HashMap 就是使用链地址法来解决冲突的(jdk8中采用平衡树来替代链表存储冲突的元素,但hash() 方法原理相同)。当两个对象的hashcode相同时,它们的bucket位置相同,碰撞就会发生。此时,可以将 put 进来的 K- V 对象插入到链表的尾部。对于储存在同一个bucket位置的链表对象,可通过键对象的equals()方法用来找到键值对。

为什么要先高16位异或低16位再取模运算

i = (n - 1) & hash,n是数组长度,hash就是通过hash()方法进行高低位异或运算得出来的hash值。这个表达式就是hash值的取模运算,上面已经说过当除数为2的次方时,可以用与运算提高性能。那么我们想想,大多数情况下,内部数组的容量一般都不会很大,基本分布在16~256之间。所以一个32位的hashCode,一直都用最低的4到8位进行与运算,而高位几乎没有参与。所以通过hash() 方法,将hashCode高16位与低16位进行异或运算,能有效的提高离散性能。

说说String中hashcode的实现
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位字符的ASCII只进行计算,用自然溢出来等效取模。

哈希计算公式可以记为 hash = s[0]*31(n-1)+s[1]*31(n-2)+…+s[n-1]

主要原因是因为31是一个奇素数,所以31*i=32*i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算块很多。

当链表转为红黑树后,什么时候退化为链表

为6的时候退转为链表。中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

你一般用什么作为HashMap的key

一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。

(1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
(2)因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。


版权声明:本文为Apandam原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。