HashMap全面总结

一、HashMap概述:

HashMap是java中一种用来存储键值对key-value的集合,实现了Serializable(可被序列化),Cloneable(可被克隆),Map接口,继承了AbstractMap,其keyvalue都可以为null,但是key的位置只能存在一个null。其底层数据结构如下:

  • 1.7及之前采用数组+链表实现,遇到哈希冲突时通过拉链法解决。数组中的每一个元素,在1.7中称为Entry,在1.8中称为Node。

  • 1.8及之后采用数组+链表+红黑树实现。当链表长度大于8时,会先进行数组扩容(减短链表长度),当数组长度大于64时,会将链表转换为红黑树。

说明:本文的源码基于jdk1.8,在进行1.8和1.7对比时会加以说明。


二、HashMap的基本属性

//序列化版本号
private static final long serialVersionUID = 362498820763181265L;

//HashMap的初始化容量(必须是 2 的 n 次幂)默认的初始容量为16
//  1 << 4 相当于 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//最大的容量为2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的装载因子
loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//树化阈值,当一个桶中的元素个数大于等于8时进行树化
static final int TREEIFY_THRESHOLD = 8;

//树降级为链表的阈值,当一个桶中的元素个数小于等于6时把树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 当桶的个数达到64的时候才进行树化

static final int MIN_TREEIFY_CAPACITY = 64;

// Node数组,又叫作桶(bucket)
transient Node<K,V>[] table;

// 作为entrySet()的缓存
transient Set<Map.Entry<K,V>> entrySet;

//元素的数量
transient int size;

//修改次数,用于在迭代的时候执行快速失败策略
transient int modCount;

//扩容阈值,threshold = capacity * loadFactor
int threshold;

//装载因子
final float loadFactor;


三、构造方法

1.HashMap()

构造一个空的HashMap,默认初始容量(16)和默认负载因子(0.75)。

public HashMap() {
   // 将默认的负载因子0.75赋值给loadFactor,并没有创建数组
   this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

2.HashMap(int initialCapacity)

构造一个具有指定的初始容量和默认负载因子(0.75)HashMap 。

// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

3.HashMap(int initialCapacity,float loadFactor)

构造一个具有指定的初始容量和负载因子的 HashMap。

/*
	 指定“容量大小”和“负载因子”的构造函数
	 initialCapacity:指定的容量
	 loadFactor:指定的负载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
    	// 判断初始化容量initialCapacity是否小于0
        if (initialCapacity < 0)
            // 如果小于0,则抛出非法的参数异常
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    	// 判断初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            // 如果超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity
            initialCapacity = MAXIMUM_CAPACITY;
    	// 判断负载因子loadFactor是否小于等于0或者是否是一个非数值
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            // 如果满足上述其中之一,则抛出非法的参数异常
            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
     	// 将指定的负载因子赋值给HashMap成员变量的负载因子loadFactor
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
	// 最后调用了tableSizeFor,返回比指定cap容量大的最小2的n次幂数
    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;
    }

说明:对于this.threshold = tableSizeFor(initialCapacity);

tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量大的最小的2的n次幂。

但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,并且直接赋值给threshold边界值了。有些人会觉得这里是一个bug,应该这样书写:

this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;

这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)

但是在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算。


四、常用成员方法

1.put(K key, V value)方法

在1.8之前,创建HashMap对象的时候就会从构造方法生成一个默认大小为16的Entry[] table数组。而在1.8之后,当第一次调用put(putVal)方法时才会创建一个Node[] table数组。

执行步骤

1)计算key的hash值

2)进入putVal方法,判断数组是否为空,若为空则初始化数组。

3)计算出索引并判断该位置是否为空,若为空则创建一个新的Node节点加入桶中。

4)若不为空

​ a.判断两个key的值是否相等(==和equals),如果相等则直接覆盖value

​ b.若不相等,判断该节点是否为树节点。如果是,则通过putTreeVal方法加入元素。

​ c.若不是树节点,则该节点为链表节点。遍历链表,如果包含key相等的元素,覆盖。如果不包含,则在链表尾部加入该元素,加入后判断链表大小是否超过阈值8,超过则调用treeifyBin方法进行树化。

(注:在treeifyBin方法中会判断,如果数组大小超过64才会真正树化!)

5)覆盖过程:判断是否需要替换旧值,并直接返回旧值。

6)插入元素成功,数组大小+1,判断是否需要进行扩容。

注:两个对象的hashcode值相等不一定为同一对象(hash碰撞),但如果两个对象的hashcode值不相等则一定为不同对象!


put源码分析

public V put(K key, V value) {
    // 调用hash(key)计算出key的hash值
    return putVal(hash(key), key, value, false, true);
}

//hash函数,计算key的hashcode
static final int hash(Object key) {
    int h;
    // 如果key是null 则hash值为0,否则调用key的hashCode()方法,并让高16位参与整个hash异或 
    // 这样可以使计算出的结果更分散,不容易产生哈希冲突
    // 寻址公式:(length - 1) & hash
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

解释一下为什么(h = key.hashCode()) ^ (h >>> 16)进行hash运算

将key原本的hashcode右移16位,然后与原hashcode进行异或,本质上还是为了减小碰撞

虽然HashMap使用拉链法作为hash碰撞的解决方案,但是仍可以在hash函数的设置上去进行一定程度的优化,来减少碰撞的可能性。

  • 如果直接使用key.hashCode()作为hash值的话,存在一些问题。 举例说明,HashMap的默认长度(length)为16,并且是通过(table.length - 1) & hash的方式得到key在table中的下标。

    key1.hashCode()=1661580827(二进制为0110,0011,0000,1001,1011,0110,0001,1011), key2.hashCode()=1661711899(二进制为0110,0011,0000,1011,1011,0110,0001,1011)

    在与掩码(length-1)进行与的过程中,只有后4位起作用,导致得到的下标值均为11,导致高位完全失效,加大了冲突的可能性。

  • 如果通过高位向低位异或传播的话,高位同样参与到key在table中下标的运算,减少了碰撞的可能性 。key1.hashCode() ^ (key1.hashCode() >>>16)=1661588754

    (二进制为0110,0011,0000,1001,1101,0101,0001,0010)

    key2.hashCode() ^ (key2.hashCode() >>>16)=1661719824

    (二进制为0110,0011,0000,1011,1101,0101,0001,0000)

    再于掩码进行与操作得到的下标分别为2和0,减少了冲突的可能性。

HashMap只提供了 put 用于添加元素,而put方法的逻辑实现实际在putval()方法中。

putVal源码分析

hash:key 的 hash 值

key:原始 key

value:要存放的值

onlyIfAbsent:如果 true 代表不更改现有的值

evict:如果为false表示 table 为创建状态

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /*
    	1)(tab = table) == null 表示将table赋值给tab,然后判断tab是否等于null,第一次肯定是null。
    	2)(n = tab.length) == 0 表示将数组的长度赋值给n,然后判断n是否等于0,n等于0,进行数组初始化,并将初始化好的数组长度赋值给n。
    	3)执行完n = (tab = resize()).length,数组tab每个空间都是null。
    */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    /*
    	1)i = (n - 1) & hash 表示计算数组的索引赋值给i,即确定元素存放在哪个桶中。
    	2)p = tab[i = (n - 1) & hash]获取索引为i的数据赋值给p
    	3) (p = tab[i = (n - 1) & hash]) == null 判断结点位置是否等于null;
    	    如果为null,则创建新的结点放入该位置的桶中。
    */ 
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 创建一个新的结点存入到桶中
        tab[i] = newNode(hash, key, value, null);
    else {
         // 执行else表示这个位置已经有值了
        Node<K,V> e; K k;
        /*
        	比较桶中第一个元素(数组中的结点)的hash值和key是否相等
        1)p.hash表示原来存在数据的hash值;hash表示传进来key的hash值;判断二者是否相等
        2)(k = p.key) == key :
        	p.key获取原来数据的key赋值给k;比较两个key的地址值是否相等。
        3)key != null && key.equals(k):
        	如果两个key的地址值不相等,那么先判断后添加的key是否为null,不为null再调用equals方法判断两个key的内容是否相等。
        */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                /*
                	两个元素哈希值相等,并且key的值也相等,
                	将旧的元素整体对象赋值给e,用e来记录
                */ 
                e = p;
        // hash值不相等或者key不相等,判断p是否为红黑树结点
        else if (p instanceof TreeNode)
            // 通过putTreeVal方法放入树中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 说明是链表结点
        else {
            /*
            	1)如果是链表的话需要遍历到最后结点然后插入
            	2)采用循环遍历的方式,判断链表中是否有重复的key
            */
            for (int binCount = 0; ; ++binCount) {
                /*
                	1)e = p.next 获取p的下一个元素赋值给e。
                	2)(e = p.next) == null 判断p.next是否等于null,为null说明此时到达了链表的尾部,还没有找到重复的key,将该键值对插入链表中。
                */
                if ((e = p.next) == null) {
                    /*
                    	1)创建一个新的结点插入到尾部
                    	 p.next = newNode(hash, key, value, null);
                    	 Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
                                return new Node<>(hash, key, value, next);
                         }
                         注意第四个参数next是null,因为当前元素插入到链表末尾了,那么下一个结点肯定是null。
                         2)这种添加方式也满足链表数据结构的特点,每次向后添加新的元素。
                    */
                    p.next = newNode(hash, key, value, null);
                    //结点添加完成之后判断此时结点个数是否大于TREEIFY_THRESHOLD临界值8
                    //binCount表示除去桶中第一个头节点元素后,链表的元素,如果binCount>=7,此时除去桶中第一个元素的链表有8个元素,再加上第一个元素,此时共有9个元素,超过临界值8
                    //将链表转换为红黑树。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 转换为红黑树
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                 
                /*
                	执行到这里说明e = p.next 不是null,不是最后一个元素。继续判断链表中结点的key值与插入的元素的key值是否相等。
                */
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    /*
                		要添加的元素和链表中的存在的元素的key相等了,则跳出for循环。不用再继续比较了
                		直接执行下面的if语句去替换去 if (e != null) 
                	*/
                    break;
                /*
                	说明新添加的元素和当前结点不相等,继续查找下一个结点。
                	用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                */
                p = e;
            }
        }
        /*
        	表示在桶中找到key值、hash值与插入元素相等的结点
        	也就是说通过上面的操作找到了重复的键,所以这里就是把该键的值变为新的值,并返回旧值
        	这里完成了put方法的修改功能
        */
        if (e != null) { 
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                // 用新值替换旧值
                // e.value 表示旧值  value表示新值 
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 修改记录次数
    ++modCount;
    // 判断实际大小是否大于threshold阈值,如果超过则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

2.扩容方法 resize()

扩容概述

HashMap每次进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,非常耗时。

HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n - 1) & hash 的结果相比,只是多了一个 bit 位,所以结点要么就在原来的位置,要么就被分配到 “原位置 + 旧容量” 这个位置。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tBFsxSIz-1661936683765)(E:/Blog/lansg/source/img/image-20220403115012871.png)]

由于hash位上的0.1是随机的,所以保证了随机性。

源码分析

/*
 * 为了解决哈希冲突导致的链化影响查询效率问题,扩容会缓解该问题
 */
final Node<K,V>[] resize() {
    // oldTab:表示扩容前的哈希表数组
    Node<K,V>[] oldTab = table;
    // oldCap:表示扩容之前table数组长度
    // 如果当前哈希表数组等于null 长度返回0,否则返回当前哈希表数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // oldThr:表示扩容之前的阀值(触发本次扩容的阈值) 默认是12(16*0.75)
    int oldThr = threshold;
    // newCap:扩容之后的table散列表数组长度
    // newThr: 扩容之后,下次再出发扩容的条件(新的扩容阈值)
    int newCap, newThr = 0;
    
    // 如果老的哈希表数组长度oldCap > 0
    // 如果该条件成立,说明hashMap 中的散列表数组已经初始化过了,是一次正常扩容
    // 开始计算扩容后的大小
    if (oldCap > 0) {
        // 扩容之前的table数组大小已经达到 最大阈值后,则不再扩容
        // 且设置扩容条件为:int的最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 修改阈值为int的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        // 扩容之前的table数组大小没超过最大值,则扩充为原来的2倍
        // (newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量
        // oldCap >= DEFAULT_INITIAL_CAPACITY 原哈希表数组长度大于等于数组初始化长度16
        // 如果oldCap 小于默认初始容量16,比如传入的默认容量为8,则不执行下面代码
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 新的扩容阈值扩大一倍
            newThr = oldThr << 1; // double threshold
    }
    // 如果老的哈希表数组长度oldCap == 0
    // 说明hashMap中的散列表还没有初始化,这时候是null
    // 如果老阈值oldThr大于0 直接赋值
    /*
    	以下三种情况会直接进入该判断:(即,这时候oldThr扩容阈值已存在)
    	1.new HashMap(initCap,loadFactor);
    	2.new HashMap(initCap);
    	3.new HashMap(Map);// 这个传入的map中已经有数据
    */
    else if (oldThr > 0) // 老阈值赋值给新的数组长度
        newCap = oldThr;
    // 如果老的哈希表数组长度oldCap == 0
    // 说明hashMap中的散列表还没有初始化,这时候是null
    // 此时,老扩容阈值oldThr == 0
    else { // 直接使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;//16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
    }
    // 如果执行到这个位置新的扩容阈值newThr还没有得到赋值,则
    // 需要计算新的resize最大上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 将新的阀值newThr赋值给threshold
    threshold = newThr;
    
    @SuppressWarnings({"rawtypes","unchecked"})
    // 创建新的散列表
    // newCap是新的数组长度---> 32
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    
    // 说明:hashMap本次扩容之前,table不为null
    if (oldTab != null) {
        // 把每个bucket桶的数据都移动到新的散列表中
        // 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
        for (int j = 0; j < oldCap; ++j) {
            // 当前node节点
            Node<K,V> e;
            // 说明:此时的当前桶位中有数据,但是数据具体是 
            // 1.单个数据 、 2.还是链表 、 3.还是红黑树 并不能确定
            if ((e = oldTab[j]) != null) {
                // 原来的数据赋值为null 便于GC回收
                oldTab[j] = null;
                // 第一种情况:判断数组是否有下一个引用(是否是单个数据)
                if (e.next == null)
                    // 没有下一个引用,说明不是链表,
                    // 当前桶上只有单个数据的键值对,
                    // 可以将数据直接放入新的散列表中
                    // e.hash & (newCap - 1) 寻址公式得到的索引结果有两种:
                    // 1.和原来旧散列表中的索引位置相同,
                    // 2.原来旧散列表中的索引位置i + 旧容量oldCap
                    newTab[e.hash & (newCap - 1)] = e;
                //第二种情况:桶位已经形成红黑树
                else if (e instanceof TreeNode)
                    // 说明是红黑树来处理冲突的,则调用相关方法把树分开
                    // 红黑树这块,我会单独写一篇博客给大家详细分析一下
                    // 红黑树相关可以先跳过
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 第三种情况:桶位已经形成链表
                else { // 采用链表处理冲突
                    // 低位链表:
                    // 扩容之后数组的下标位置,与当前数组的下标位置一致 时使用
                    Node<K,V> loHead = null, loTail = null;
                    // 高位链表:扩容之后数组的下标位置等于
                    // 当前数组下标位置 + 扩容之前数组的长度oldCap 时使用
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    
                    // 通过上述讲解的原理来计算结点的新位置
                    do {
                        // 原索引
                        next = e.next;
                     	// 这里来判断如果等于true 
                        // e这个结点在resize之后不需要移动位置
                        // 举例:
                        // 假如hash1     -> ...... 0 1111
                        // 假如oldCap=16 -> ...... 1 0000
                        // e.hash & oldCap 结果为0,则
                        // 扩容之后数组的下标位置j,与当前数组的下标位置一致
                        // 使用低位链表
                        //(e.hash & oldCap) == 0,说明新增的一位hash值为0,此时(e.hash & newCap-1) 这一位也为0,所以在原下标。用旧长度代替新长度-1.
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 举例:
                        // 假如hash2     -> ...... 1 1111
                        // 假如oldCap=16 -> ...... 1 0000
                        // e.hash & oldCap 结果不为0,则
                        // 扩容之后数组的下标位置为:
                        // 当前数组下标位置j + 扩容之前数组的长度oldCap
                        // 使用高位链表
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    
                    // 将低位链表放到bucket桶里
                    if (loTail != null) {
                        loTail.next = null;
                        // 索引位置=当前数组下标位置j
                        newTab[j] = loHead;
                    }
                    // 将高位链表放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 索引位置=当前数组下标位置j + 扩容之前数组的长度oldCap
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    // 返回新散列表
    return newTab;
}

步骤总结

1)如果使用是默认构造方法,则第一次插入元素时初始化为默认值,容量为16,扩容门槛为12;

2)如果使用的是非默认构造方法,则第一次插入元素时初始化容量等于扩容门槛,扩容门槛在构造方法里等于传入容量向上最近的2的n次方;

3)如果旧容量大于0,则新容量等于旧容量的2倍,但不超过最大容量2的30次方,新扩容门槛为旧扩容门槛的2倍;

4)创建一个新容量的桶;

5)搬移元素,原链表分化成两个链表,低位链表存储在原来桶的位置,高位链表搬移到原来桶的位置加旧容量的位置


3.删除方法remove()

方法概述

删除方法就是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于 6 的时候要转链表。


remove方法源码

// remove方法的具体实现在removeNode方法中,所以我们重点看下removeNode方法
// 根据key删除
public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
// 根据key,value 删除
@Override
public boolean remove(Object key, Object value) {
    return removeNode(hash(key), key, value, true, true) != null;
}


removeNode() 方法源码

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    // 参数:
    // matchValue 当根据 key和value 删除的时候该参数为true
    // movable 可以先不用考虑这个参数 
    
    // tab:引用当前haashMap中的散列表
    // p:当前node元素
    // n:当前散列表数组长度
    // index:表示寻址结果
    Node<K,V>[] tab; Node<K,V> p; int n, index;
	// 根据hash找到位置 
	// 如果当前key映射到的桶不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        // 进入这个if判断内部,说明桶位是有数据的,需要进行查询操作,并且执行删除
        // node:通过查找得到的要删除的元素
        // e:表示当前node的下一个元素
        // k,v 键 值
        Node<K,V> node = null, e; K k; V v;
        
        // 第一种情况:当前桶位中的元素 即为我们要删除的元素
        // 如果桶上的结点就是要找的key,则将node指向该结点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        // 如果桶位中的头一个元素不是我们要找的元素,且桶位中的e = p.next不为null
        // 说明该桶位中的节点存在下一个节点
        else if ((e = p.next) != null) {
            // 说明:当前桶位,要么是 链表,要么是 红黑树
            
            // 第二种情况:判断桶位中是否已经形成了红黑树
            if (p instanceof TreeNode)
                // 说明是以红黑树来处理的冲突,则获取红黑树要删除的结点
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            // 第三种情况:桶位中已经形成链表
            else {
                // 判断是否以链表方式处理hash冲突
                // 是的话则通过遍历链表来寻找要删除的结点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 比较找到的key的value和要删除的是否匹配
        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的下一个元素设置为 要删除元素的 下一个元素
                p.next = node.next;
            // 记录修改次数
            ++modCount;
            // 变动的数量
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}


五、HashMap常见问题总结

为什么初始化容量的值必须是 2 的 n 次幂?如果输入值不是 2 的幂比如 10 会怎么样?

为了获取下标,我们需要对数组进行取模运算,为了提高效率将hash % length 替换为了 hash & ( length - 1) ,而这个替换的前提就是length 是 2 的 n 次幂。另外,这样还可以使数据均匀分布,减少hash冲突。

如果将长度初始化为10:

HashMap<String, Integer> hashMap = new HashMap(10);

此时HashMap双参构造函数会通过tableSizeFor(initialCapacity)方法,得到一个最接近length且大于length的2的n次幂数(比如最接近10且大于10的2的n次幂数是16)。因为当在实例化 HashMap 实例时,如果给定了 initialCapacity,由于 HashMap 的 capacity 必须是 2 的幂,因此这个方法用于找到大于等于 initialCapacity 的最小的 2 的幂。

static final int tableSizeFor(int cap) {
  int n = cap - 1;
  //cap-1是为了防止 cap 已经是 2 的幂。如果 cap 已经是 2 的幂,又没有这个减 1 操作,则执行完后面的几条无符号操作之后,返回的 capacity 将是这个cap的2倍
  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;
  //为什么最后n+1?
  //如果 n 这时为 0 了(经过了cap - 1后),则经过后面的几次无符号右移依然是 0,返回0是肯定不行的,所以最后返回n+1最终得到的 capacity 是1。
}

解决Hash冲突的方法有那些?HashMap用的哪种?

解决Hash冲突方法有:开放定址法再哈希法拉链法

  • 开放地址法(再散列法):如果p=H(key)出现冲突时,则以p为基础,用同一种hash函数再次hash,p1=H(p),如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。
  • 再哈希法(多重散列):提供多个不同的hash函数R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。
  • 拉链法:将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。

为什么要先将数组扩容为64后,再转为红黑树?

如果在数组比较小时转为红黑树结构,反而会降低效率,因为红黑树需要进行左旋右旋,变色,这些操作来保持平衡。在数组长度小于64时,搜索时间相对要快些。


为什么桶中的元素大于8之后,转为红黑树?元素个数小于6时,红黑树转为链表?

在随机哈希代码下,桶中的节点频率遵循泊松分布,桶的长度超过8的概率非常非常小(千万分之一),所以选择8作为阈值。

红黑树的平均查找长度是logn,链表的平均查找长度为n/2。当长度为6时红黑树退化为链表是因为logn=log6约等于2.6,而n/2=6/2=3,两者相差不大,而红黑树节点占用更多的内存空间。


hash函数是如何实现的?为什么要这样实现?

将key原本的hashcode右移16位,然后与原hashcode进行异或本质上还是为了减小碰撞

虽然HashMap使用拉链法作为hash碰撞的解决方案,但是仍可以在hash函数的设置上去进行一定程度的优化,来减少碰撞的可能性。


六、HashMap的线程安全问题

在多线程下,HashMap有什么问题?我们从jdk1.7和jdk1.8对比来看。

jdk1.7中的扩容机制(头插法)

调用resize方法时,如果原有table长度已经达到了上限,就不再扩容了。如果还未达到上限,则创建一个新的table,并调用transfer方法。

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //达到了最大长度
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}


void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;              //注释1
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity); //注释2
            e.next = newTable[i];                  //注释3
            newTable[i] = e;                       //注释4
            e = next;                              //注释5
        }
    }
}

transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。

假设原有table记录的某个链表,比如table[1]=3,链表为3–>5–>7,那么头插法处理流程如下:

1.注释1:记录e.next的值。开始时e是table[1],所以e3,e.next5,那么此时next==5。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y28wCbdO-1661936683768)(E:/Blog/lansg/source/img/image-20220815151434999.png)]

2.注释2,计算e在newTable中的节点。为了展示头插法的倒序结果,这里假设e再次散列到了newTable[1]的链表中。

3.注释3,把newTable [1]赋值给e.next。因为newTable是新建的,所以newTable[1]null,所以此时3.nextnull。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ai9Nmo5y-1661936683769)(E:/Blog/lansg/source/img/image-20220815151528278.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4gfiV58E-1661936683770)(E:/Blog/lansg/source/img/image-20220815151703847.png)]

4.注释4,e赋值给newTable[1]。此时newTable[1]=3。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-heeIhWXh-1661936683771)(E:/Blog/lansg/source/img/image-20220815152004827.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qv9KEYhL-1661936683772)(E:/Blog/lansg/source/img/image-20220815152029558.png)]

5.注释5,next赋值给e。此时e==5。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vDI2NUnp-1661936683774)(E:/Blog/lansg/source/img/image-20220815152143002.png)]

此时newTable[1]中添加了第一个Node节点3,下面进入第二次循环,第二次循环开始时e==5。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2QAyU2uu-1661936683775)(E:/Blog/lansg/source/img/image-20220815152154590.png)]

1.注释1:记录e.next的值。5.next是7,所以next==7。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fGBFg6j9-1661936683776)(E:/Blog/lansg/source/img/image-20220815152323050.png)]

2.注释2,计算e在newTable中的节点。为了展示头插法的倒序结果,这里假设e再次散列到了newTable[1]的链表中。

3.注释3,把newTable [1]赋值给e.next。因为newTable[1]是3(参见上一次循环的注释4),e是5,所以5.next==3。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NoN1ywQl-1661936683777)(E:/Blog/lansg/source/img/image-20220815152422748.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EhKq7xWJ-1661936683778)(E:/Blog/lansg/source/img/image-20220815152441907.png)]

4.注释4,e赋值给newTable[1]。此时newTable[1]==5。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rvxPHwA9-1661936683778)(E:/Blog/lansg/source/img/image-20220815152505956.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IONQevMW-1661936683779)(E:/Blog/lansg/source/img/image-20220815152526423.png)]

5.注释5,next赋值给e。此时e==7。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mzJVqviA-1661936683779)(E:/Blog/lansg/source/img/image-20220815152600068.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m7SokQrM-1661936683780)(E:/Blog/lansg/source/img/image-20220815152616282.png)]

此时newTable[1]是5,链表顺序是5–>3。

总结一下:遍历数组,然后遍历数组链表,从链表头到尾,保留next = e.next;先计算出节点在新hashmp的数组位置i,然后用头插法将节点插入到新数组的头结点(e.next = new B)。e = next; do while(e != null)之后的一样。

单线程是没问题的,如果是多线程的话,那么可能 next = e.next;被干扰的话 next = B;造成死循环,影响扩容。

jdk1.8存在的问题

尽管在jdk1.8中,将扩容时迁移元素的方法由头插法改为了尾插法,但也并不是就说是线程安全的。当多个线程同时调用put方法添加元素时,如果产生哈希碰撞,导致两个线程得到同样的bucketIndex去存储,就可能会发生元素覆盖丢失的情况


七、遍历HashMap的方式

1.分别遍历Key和Values

for (String key : map.keySet()) {
	System.out.println(key);
}
for (Object vlaue : map.values() {
	System.out.println(value);
}

2.使用迭代器

Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Object> mapEntry = iterator.next();
    System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue());
}

3.for-each迭代Entries

Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for(Map.Entry<Integer, Integer> entry : map.entrySet()){
	System.out.println("key = " + entry.getKey() + ", value = " + entry.getValue())
}

4.通过get方式(二次迭代不建议使用)

Set<String> keySet = map.keySet();
for (String str : keySet) {
	System.out.println(str + "---" + map.get(str));
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值