面试-HashMap源码

区别小结:

1JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法

2、扩容后数据存储位置的计算方式也不一样:

3JDK1.7的时候使用的是数组+ 单链表的数据结构。JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构

HashMap<String,String> objectObjectHashMap = new HashMap<String,String>();

流程

1、put操作:第一次进来,空的创建HashMap,根据加载因子,计算到达多少扩容=容量*加载因子
2、如果hash对应的table[hash…]=null,那么直接存进去。 3、如果hash里面对应的table!=null
,那么就要判断key是否一样,如果一样就替换。 4、接着3,如果key不一样就拿到next
,判断next等于null,直接追加在后面,如果next不等于null,就拿到这个next的key对比,如果key相同,就把value替换。
5、如果这个链表的数量超过大于等于8,那么就要判断,table的length的长度是否小于64,这个长度是指tab纵向的长度,也就是16、32,不包含链表上的,数组是空的也会算进去。
6、如果小于64,触发扩容,也就是扩容的第二个2个条件。一个是链表总个数超过加载因子对应的扩容量,单个链表长度等于或者超过8个,并且length小于64也会扩容。
7、如果tab也就是数组的长度等于或者超过64,,并且单个链表上元素个数大于等于8,才会把链表的里面的元素转成TreeNode。

扩容

1、首先是长度*2
2、当前tab[i]不为空,判断这个元素的next是否等于null,如果等于,直接拿元素的【hash & 32减1】其实就是重行计算下标。然后存到里面去
3、如果next不是null, 取next这个元素,判断是否等于0还是等于1,如果等于0挨个的放到loHead里面,如果等于1挨个的放到hiHead,再去next的next元素,依次放到loHead的next和hiHead.next里面去,最后把2个新的链表,loHead和hiHead,分别存到新的数组的新的位置,那么新的位置怎么摆放。newTab[原来该链表所在的下标] = loHead 这个是等于0的; newTab[原来的下标+16] = hiHead 这个是等于1的;
4、如果next不是空的且当前这个链表是一个红黑树。也是使用相同的方式,判断是否等于0和等于1,只不过红黑树的node是TreeNode类型,他多了左节点和右节点的属性而已。

注意

1、oldCap << 1 表示乘以2的意思。
2、当【e.hash&oldCap】等于0时,e在新旧数组中的索引位置不变:【(2oldCap-1)& e.hash】 = 【(oldCap -1) & e.hash】 【e.hash&oldCap】不等于0的元素,e在新数组中的索引位置,是其在旧数组中索引位置的基础上,再加上旧数组长度个偏移量: 【(2oldCap-1)& e.hash】 = 【(oldCap -1) & e.hash + oldCap】
3、oldCap表示tab的长度,16、32、64.。。。
4、一开始计算下标是这样计算的 [hash & 16-1],当扩容的时候,如果之前的元素的next=null的时候,那么这个老元素在新的tab的位置是,[hash & 32-1]
当next不是空的时候,那么拿到这个next元素,判断(e.hash & 16) == 0 这表示什么? 表示这个元素是否在数组中的索引位置不变。 注意这里的16没有减1,如果等于0

为什么是8才转红黑树

性能优化:
在链表中查找元素的时间复杂度是O(n),其中n是链表长度。
红黑树的查找性能是O(log n),相对于链表更为高效,特别是在元素数量较多时。
将链表转换为红黑树,可以在查找时提高性能,尤其是对于大型的 HashMap。

避免过度优化:
转换为红黑树是一种相对较为复杂的操作,包括旋转等。因此,只有在链表长度足够大的情况下才值得进行这样的优化。对于小型的HashMap,
链表的性能可能更好,因为转换为红黑树带来的额外开销可能超过了它的好处

首先是在时间复杂度上,树形查找相比于链表的线性查找,具有更好的性能那么为什么不直接转成二叉树呢?太长转二叉树也不太好,因为太长,
就要涉及到数的旋转,较为复杂。
如果在链表的长度不够长,直接转红黑树,那就需要更多的空间。因为作为链表,只需要next元素的指针就可以,但是作为红黑树,
他就需要next和head节点。


【所以结合下来】:
1、一个是数据太小时,链表是比二叉树要快的,但是使用二叉树在费空间。可以去看下他们的线形图,O(n)Log(n)是有一个交际,后面越来越远。
2、如果数据太大,链表查询性能是没有二叉树好的,那么二叉树有很多,比如二叉搜索树,平衡二叉树,红黑树,为什么要用红黑树,
这是因为二叉搜索树的极端情况成线性,平衡二叉树需要旋转。
3、选择8作为阈值,可以在空间和时间之间做出平衡。


红黑树是一种自平衡的二叉搜索树,它的查找、插入和删除操作的时间复杂度是O(log n),其中n是树的节点数,默认是链表长度达到 8 就转成红黑树,
而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想.
中间有个差值7可以防止链表和树之间频繁的转换

一、HashMap属性

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    transient Node<K,V>[] table; 
    transient int size;
    transient int modCount;
    int threshold;
    final float loadFactor;
}

static class Node<K,V>{
	final int hash;
	final K key;
	V value;
	Node<K,V> next;
	//next只有遇到冲突的时候,才会有值。没有冲突的时候,都是存在key和value里面的。可以看newNode方法在赋值的时候,next首次给的是null
}

Node(int hash, K key, V value, Node<K,V> next) {
    this.hash = hash;
    this.key = key;
    this.value = value;
    this.next = next;
}

注意:判断是否变成红黑树的时候,是判断tab[i] == 红黑树 不是tab[i].next == 红黑树

二、put

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

//put
final V putVal(K key, V value) {
    if (tab==null || tab.length == 0)
        tab = resize();//扩容或者创建tab
    if (tab[hash(key)]==null)//通过hash计算i的下标
        tab[hash(key)] = newNode(hash, key, value, null);  //把Node加入到Tab里面去
    else {
    	//这里是处理Hash碰撞问题

    	Node p = tab[hash(key)]
        where(true){
        	p = p.next();
	        //判断这个key和原来存进去的key是不是一个key,注意这里是equals,面试会问为什么重写hashCode和equals
	    	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 {
	        	//走到这说明,key不相等,且也不是树结构,那就转成树
	        	Node e = p.next;//注意这里取的是链表
	        	if(e==null){
					p.next = newNode(hash, key, value, null);break; //找到目标桶Node,追加到该Node的链表中
	        	}else if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){
	        		break;//已经在链表中
	        	}else{
	        		p = e;//继续遍历p
	        	}
	        }
        }
    
    }
    if (++size > threshold){
        resize();//扩容或者创建tab
    }
}


三、扩容或者创建tab

final Node<K,V>[] resize() {
    省略部分代码.....
    //newCap是int,表示需要创建hashMap的大小,第一次进入是16,第二次是32依次乘2
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

    //第一次进来oldTab是null,第二次进来oldTab就是第一次进来创建的tab[16]
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {

            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;       //并且会把老的桶置空
                if (e.next == null){
                    newTab[e.hash & (newCap - 1)] = e;//如果next下面没有值,说明没有冲突,值就只存在key和value上面。
                }
                else if (e instanceof TreeNode){//说明Node是树
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);    
                }else {//说明e.next是有值,就需要把next后面的链表放到新的Node.next下面去。
                    do {
                        Node next = e.next;
                        ....
                    } while (next != null);
                }
            }
        }
    }
}

四、获取Get

final Node<K,V> getNode(int hash, Object key) {
    
    if (tab != null && tab.length > 0 && first != null) {
        if (first.hash == hash && (first.key == key || key.equals(k))){
            return first;
        }
        //说明上面没找到,再从链表里面找
        if (first.next != null) {
            if (first instanceof TreeNode){//如果是红黑树结构
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            }
            do {
                if (e.hash == hash && (e.key == key || (key != null && key.equals(k)))){
                    return e;
                }
            } while (e.next != null);
        }
    }
    return null;
}

五、扩容区别

Jdk1.7和Jdk1.8扩容的区别

区别小结

1JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的
纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能
够避免出现逆序且链表死循环的问题。

2、扩容后数据存储位置的计算方式也不一样:1.7是重行Hash一次,JDK1.8判断Hash值的参与运算的位是0还是1迅速
计算出了扩容后的储存位置

3JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构,
注意转红黑树的2个条件。桶的长度超过8的概率非常非常小



1、扩容条件

1.已经存在的key-value mappings的个数大于等于阈值
2.底层数组的bucketIndex坐标处不等于null

参数说明

hash-当前key生成的hashcode
key-要添加到HashMap的key
value-要添加到 HashMap 的value
bucketIndex-桶,也就是这个要添加 HashMap里的这个数据对应到数组的位置下标

void addEntry(int hash, K key, V value, int bucketIndex) {  
    
    if ((size >= threshold) && (null != table[bucketIndex])) {  
        resize(2 * table.length);//扩容之后,数组长度变了  
        hash = (null != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢?  
        bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。  
    }  
    createEntry(hash, key, value, bucketIndex);  
}
/** 
 * 这地方就是链表出现的地方,有2种情况 
 * 1,原来的桶bucketIndex处是没值的,那么就不会有链表出来啦 
 * 2,原来这地方有值,那么根据Entry的构造函数,把新传进来的key-value mapping放在数组上,原来的就挂在这个新来的next属性上了 
 */  
void createEntry(int hash, K key, V value, int bucketIndex) {  
    HashMap.Entry<K, V> e = table[bucketIndex];  
    table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e);  
    size++;  
}

2、扩容:

先看JDK1.7

参数说明

newCapacity-新的数组最大可以装的数量
threshold-扩容的阈值
loadFactor-加载因子一般都是12/16也就是0.75

void resize(int newCapacity) {   //传入新的容量
    Entry[] oldTable = table;    //引用扩容前的Entry数组
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
        threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
        return;
    }

    Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
    transfer(newTable);                         //!!将数据转移到新的Entry数组里
    table = newTable;                           //HashMap的table属性引用新的Entry数组
    threshold = (int) (newCapacity * loadFactor);//修改阈值
}

transfer(newTable) 使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

void transfer(Entry[] newTable) {
    Entry[] src = table;                   //src引用了旧的Entry数组
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
        Entry<K, V> e = src[j];             //取得旧Entry数组的每个元素
        if (e != null) {
            src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
            do {
                Entry<K, V> next = e.next;
                int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                e.next = newTable[i]; //标记[1]
                newTable[i] = e;      //将元素放在数组上
                e = next;             //访问下一个Entry链上的元素
            } while (e != null);
        }
    }
}
static int indexFor(int h, int length) {
    return h & (length - 1);
}

大致流程:

  1. 遍历数组Entry里面所有的元素,每个元素可能是单个元素也可能是链表
  2. 如果是链表循环链表里的所有元素,分别重行计算新的下标位置
  3. 链表中头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)
  4. 在旧数组中的所有的Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上

说一下头插入方式,比如下面代码:
e.next = newTable[i];newTable[i] = e;新链表已有的元素newTable[i] 放在新元素的后面e.next

3、扩容过程

扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。

JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    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) // 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);
     }
     // 计算新的resize上限
     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;
    if (oldTab != null) {
        // 把每个bucket都移动到新的buckets中
        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;
                else if (e instanceof TreeNode) //如果是红黑树节点
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 链表优化重hash的代码块
                    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;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

先略过红黑树的情况,描述下简单流程,在JDK1.8中发生hashmap扩容时,遍历hashmap每个bucket里的链表,每个链表可能会被拆分成两个链表,不需要移动的元素置入loHead为首的链表,需要移动的元素置入hiHead为首的链表,然后分别分配给老的buket和新的buket。
HashMap扩容时红黑树的表现
扩容时,如果节点是红黑树节点,就会调用TreeNode的split方法对当前节点作为跟节点的红黑树进行修剪

参考:https://blog.csdn.net/pange1991/article/details/82347284

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

信仰_273993243

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值