HashMap、HashSet底层原理分析

HashMap

在这里插入图片描述

  • HashMap是基于哈希表对map接口的实现,HashMap具有较快的访问速度,但是遍历顺序确实不确定的。
  • HashMap并非线程安全的,当存在多个线程同时写入HashMap时,可能会导致数据的不一致性

jdk1.7和1.8的区别

  1. jdk7是数组+链表、jdk8是数组+链表(单向、双向)+红黑树
  2. jdk8会将链表转变为红黑树
  3. 新结点插入顺序不同(jdk7采用头插法、jdk8因为要遍历链表变为红黑树所以采用尾插法)
  4. jdk8hash算法优化
  5. resize逻辑修改(jdk7会出现死循环、jdk8不会)

高频问题

1、为什么必须为2的幂次? 详细见【put方法–>putVal方法–>说明一】
2、为什么8采用尾插法? 链表长度大于(新插入的数据为第九条)8时,链表转换为红黑树,怎么能知道链表长度,只能循环列表,新节点插入尾部后,判断是否树化。
3、什么时候链表会变成树? 链表大于8(新插入的数据为第九条)并且数组长度大于等于64时才会触发树化
4、什么时候扩容?
条件一:当hashmap中的元素个数超过数组大小*loadFactor(加载因子0.75)时,就会进行数组扩容,扩容会扩大一倍
条件二:数组容量小于64且链表长度大于8时,会进行数组扩容
5、为什么链表有用到单向和双向?
a、查看源码,链表为单向链表,node节点只有next属性。红黑树采用了双向链表,TreeNode除了parent、left、 right外还有prev属性,TreeNode本身有继承了LinkedHashMap.Entry,LinkedHashMap.Entry继承了HashMap.Node,HashMap.Node是有next属性的,所以TreeNode是拥有prev和next属性。
b、树化treeifyBin方法、往树节点插入值putTreeVal方法中,对prev和next属性均有赋值
6、初始化为多少?什么时候map初始化大小的? 答:无参构造时初始化大小为16,若为有参构造时,则会修改为大于等于初始化值的2的倍数。第一次put时才会初始化(Hashmap源码中putval方法第三行调用了resize扩容方法)。注:HashMap(Map<? extends K, ? extends V> m)是比较特殊,可以debug看一下

jdk1.8源码分析

初始化hashmap

在这里插入图片描述

HashMap<String,Object> h1 = new HashMap<>();	// 无参
HashMap<String,Object> h2 = new HashMap<>(10);	// 指定初始容量(数组长度) 实际为16 2的四次方
HashMap<String,Object> h3 = new HashMap<>(10,0.75f);	// 指定初始容量(实际为16 2的四次方)、负载因子
HashMap<String,Object> h4 = new HashMap<>(h1);	//	map,这个是一个特例,需要debug看一下

前三种new HashMap均是在第一次put时做的初始化。
第四种比较特殊,若存入Map有值,则会直接初始化,若无值则在第一次put时初始化

hashmap参数说明

// 默认初始化大小 16,在第一次put的时候才会初始化大小
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;
// 链表长度大于(新插入的数据为第九条)8时,链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 链表长度降低到6(小于等于6)时,转为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 树化最小的阈值,链表大于8(新插入的数据为第九条)并且数组长度大于等于64时才会触发树化
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
// 存放元素的个数
transient int size;
// 被修改的次数fast-fail机制
transient int modCount;
// 阈值 当实际大小(容量*填充比)超过临界值时,会进行扩容
int threshold;
// 负载因子
final float loadFactor;

put方法(key存在则覆盖)、putIfAbsent(key存则不覆盖)

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

put、putIfAbsent方法是存在返回值的:
测试一(put):
HashMap<String,String> h1 = new HashMap<>();
String put = h1.put(“k”, “v”);
System.out.println(put); // null
System.out.println(h1.get(“k”)); // v
String put1 = h1.put(“k”, “v1”);
System.out.println(put1); // v
System.out.println(h1.get(“k”)); // v1
测试二(putIfAbsent):
HashMap<String,String> h1 = new HashMap<>();
String put = h1.putIfAbsent(“k”, “v”);
System.out.println(put); // null
System.out.println(h1.get(“k”)); // v
String put1 = h1.putIfAbsent(“k”, “v1”);
System.out.println(put1); // v
System.out.println(h1.get(“k”)); // v
解释: 详细查看putval源码,搜索return,可查到return null 或者 return oldValue,return oldValue就是返回覆盖前的value。


hash方法:
1、 查看hash方法中,key等于null时,返回的hash值均为0,所以Hashmap只允许存在一个key为null的值
2、 (h = key.hashCode()) ^ (h >>> 16)
>>> 右移,舍弃低位,高位移动到低位,例如:hash值 0111 0101,舍弃低位0101,将高位移动到低位0000 0111
^ :异或,不同为1,相同为0
h:                0111 0101
h >>> 16:    0000 0111
^异或结果:  0111 0010

为什么要右移(>>>)?
答:putVal方法中说明一,计算下标采用&(与),会舍弃高位,>>>(位移)可以让key的hash值的高位也参与运算

// 计算key的Hash值
static final int hash(Object key) {
    int h;
    // 将key.hashCode()赋值给h   h位运算符右移16位  然后key.hashCode() 和 右移16位后的结果做异或运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

putVal方法:
说明一【源码对应putVal中说明一】、数组下标,为什么数组必须是2的幂次?
tab[i = (n - 1) & hash],初始化默认数组容量为16
n-1=15 15的二进制为0000 1111,&(与)均为1是为1,否则为0
二进制运算过程:
15:            0000 1111
hash:        0100 0101
&(与)结果:0000 0101 结果下标为5
&(与)的结果就是舍弃高位,保留低位,最终结果为 0000 0000至0000 1111(结果为0-15),可以保证均匀散列

假设为16:
二进制运算过程:
16:             0001 0000
hash:         0100 0101
&(与)结果: 0000 0000 结果下标为5
&(与)的最终结果只能是0000 0000 或者 0001 0000,所有值只能分布在下标为15 和 16上

为什么数组必须是2的幂次?
答、只有2的幂次,才能保证二进制数只有一位是1,n-1后才能保证高位均是0,低位均是1,&(与)舍弃高位,保留低位,保证数据均匀散列

说明二【源码对应putVal中说明二】:参数onlyIfAbsent
put方法onlyIfAbsent为false,key已经存在则会覆盖
putIfAbsent方法onlyIfAbsent为true,key已经存在不会覆盖

// hashmap插入值的put方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // 参数申明
    // tab为当前数组、p表示链表的root节点
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判断tab是否存在值
    if ((tab = table) == null || (n = tab.length) == 0)
     // 不存在,则会初始化(jdk1.8将扩容和初始化合在一起),第一次put的时候才会初始化数组容量为16
        n = (tab = resize()).length;
	***************初始化**************

    // 判断hash后数组该下标下是否存在值,【说明一】
    if ((p = tab[i = (n - 1) & hash]) == null)
    	********0000*******下标没有值--开始**************
    	// 不存在直接赋值
        tab[i] = newNode(hash, key, value, null);
       	*******0000********下标没有值--结束**************
    else {
    	********1111*******下标有值--开始**************
    	// e表示key值存在时的node节点
        Node<K,V> e; K k;
        // 判断新插入key值和链表root节点key值是否一致
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            ********2222*******下标有值--和root key相等--开始**************
            // key值一致
            e = p;
            *********2222******下标有值--和root key相等--结束**************
        // 判断是否为树(jdk8链表会转换为红黑树)
        else if (p instanceof TreeNode)
             ********3333*******下标有值--节点为红黑树--开始**************
        	// 红黑树插入节点、并校验是否存在key值一样的节点
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            *********3333******下标有值--节点为红黑树--结束**************
        else {
	        ********4444*******下标有值--节点为链表--开始**************
        	// 循环链表(jdk1.8尾插法)
            for (int binCount = 0; ; ++binCount) {
            	********5555*******下标有值--节点为链表--子节点为null--开始**************
            	// 链表子节点为空时,key不存在
                if ((e = p.next) == null) {
                	// 直接插入链表尾部
                    p.next = newNode(hash, key, value, null);
                    // 插入新值之后,链表是否大于等于(8-1)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    	// 链表转换为树
                    	// 树化时还有一个校验,不是链表大于8则会树化
                    	// 还需要满足存储元素的数组(table)大于等于64,否则会先扩容
                    	// if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 来源于treeifyBin方法
                        treeifyBin(tab, hash);
                    break;
                }
                ********5555*******下标有值--节点为链表--子节点为null--结束**************
                ********6666*******下标有值--节点为链表--某一节点key一致--开始**************
                // 判断hash和key是否相等,相等会直接跳出此循环,已存在key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
                ********6666*******下标有值--节点为链表--某一节点key一致--结束**************
            }
    	    ********4444*******下标有值--节点为链表--结束**************
            ********1111*******下标有值--结束**************
        }
         ***************put和putIfAbsent--开始**************
        // key值已经存在,则会覆盖原数据,put方法会返回值,返回值为覆盖前的value
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // put和putIfAbsent方法,请求的onlyIfAbsent参数不同  【说明二】
            // put时onlyIfAbsent为false,key已经存在则会覆盖
            // putIfAbsent时onlyIfAbsent为true,key已经存在不会覆盖
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
        ***************put和putIfAbsent--结束**************
    }
    ++modCount;
    // 当hashmap中的元素个数超过数组大小*loadFactor(加载因子0.75)时,就会进行数组扩容,扩容会扩大一倍
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

treeifyBin方法【树化】

treeifyBin方法【树化】:
说明一:数组长度小于MIN_TREEIFY_CAPACITY(64)时,会先扩容,大于等于64后才会树化

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)
	    // 数组长度小于64时,会扩容
        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);
    }
}

resize方法【扩容】

resize包含了初始化、扩容两套逻辑

1.7扩容机制:两层循环,循环数组、循环链表,一个一个移动到新的数组中。

说明一:低位链表、高位链表
数组长度由16扩容到32:
相同hash,结果对比一
15:           0000 1111
hash:       0100 0101
&(与)结果:0000 0101 结果下标为5

31:           0001 1111
hash:       0100 0101
&(与)结果:0000 0101 结果下标为老数组下标5

相同hash,结果对比二:
15:            0000 1111
hash:        0101 0101
&(与)结果:0000 0101

31:           0001 1111
hash:       0101 0101
&(与)结果:0001 0101 结果下标为21 = 老数组长度16+老数组下标5

对比发现,hash值不变的情况下,只有31高位0001 1111中的第一个1会影响&(与)操作结果,&(与)结果低位是不会变化的,老数组的同一条链表移动到新数组只会存在两个位置,和老数组下标一致,或者为老数组长度+老数组下标。
所以链表在扩容时会生成两条新的链表,一条为低位链表(和老数组下标一致),一条为高位链表(老数组长度+老数组下标)

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;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // newCap newThr 新数组长度、阈值 都翻一倍
            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);
    }
    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"})
    // 生成新的数组 初始化大小为newCap(新数组大小)
    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)
	                // 该下标数组值没有后指针   只有一个值
	                // hash 于 新数组长度减一  重新计算新数组下标,计算下标逻辑详细见【putVal方法--说明一】
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
	                // 该下标数组值类型为红黑树 详细见【resize方法【扩容】--红黑树处理逻辑】
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
	                // 该下标数组值有后指针   链表
	                // 低位链表、高位链表 详细说明见【resize方法--说明一】
	                // 头节点用于直接将整个链表移动到新数组,尾节点用于新的node节点插入到链表尾部,若没有尾节点则需要循环链表找到尾节点进行插入
	                // 低位链表头节点 低位链表尾节点
                    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
	                            // 低位链表尾节点的后指针指向新的node节点
                                loTail.next = e;
                            // 新的node节点定义为低位链表尾节点
                            loTail = e;
                        }
                        // 高位链表
                        else {
                            if (hiTail == null)
	                            // 头节点
                                hiHead = e;
                            else
	                            // 高位链表尾节点的后指针指向新的node节点
                                hiTail.next = e;
                            // 新的node节点定义为高位链表尾节点
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    **********循环列表结束********
                    // 低位链表尾节点有值 说明存在低位链表
                    if (loTail != null) {
	                    // 将尾节点的下一个节点指向null  在上面循环中,并没有处理每一个节点的尾指针,若不操作,可能会指向别的节点
                        loTail.next = null;
                        // 将低位链表头节点放到新数组中(下标为老数组下标)
                        newTab[j] = loHead;
                    }
                    // 高位链表尾节点有值 说明存在高位链表
                    if (hiTail != null) {
	                    // 将尾节点的下一个节点指向null
                        hiTail.next = null;
                        // 将高位链表头节点放到新数组中(下标为老数组下标+老数组容量)
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize方法【扩容】–红黑树处理逻辑

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    // 低位TreeNode(类型为树,实际只记录了前后指针) 高位TreeNode(类型为树,实际只记录了前后指针)
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    // 低位TreeNode长度 高位TreeNode长度 用于判断是否链化
    int lc = 0, hc = 0;
    // 循环数 操作逻辑同链表移动一样,将节点从新&(于)操作,计算新数组下标,结果同样是两个位置
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
	// 低位头节点有值
    if (loHead != null) {
        if (lc <= UNTREEIFY_THRESHOLD)
         	// lc <= 6  untreeify 链化,并将结果(链表)放到新数组中(下标为老数组下标)
         	// untreeify 链化:循环低位TreeNode,将每一个节点都转化为node,并将尾指针指向下一个节点
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            // 若高位为null,说明整个树都在低位,则不会重新生成红黑树
            if (hiHead != null) // (else is already treeified)
	            // 重新生成红黑树
                loHead.treeify(tab);
        }
    }
    // 高位头节点有值(操作同低位)
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

jdk1.8源码总结

  1. jdk8是数组+链表+红黑树
  2. jdk8采用尾插法
  3. 只有在链表大于8(插入第九条数据)且数组大于等于64时才会将链表转换为红黑树,反之优先扩容
  4. 当hashmap中的元素个数超过数组大小*loadFactor(加载因子0.75)时,就会进行数组扩容,扩容会扩大一倍
  5. 红黑树的数量小于等于6 就开始链化,为什么不是7?如果是7的话,频繁进行插入删除的话会导致容错空间,超过8树化,小于8 链化频繁切换浪费效率。
  6. 允许存在一个key为null的值
  7. key值一致的情况下会覆盖原数据

测试题

假设有一个对象user,有两个属性name、age,只要name和age相等的情况下,视为同一个人,Hashmap中只存放一个。

public class HashmapTest {
    public static void main(String[] args) {
        HashMap<User,User> hashMap = new HashMap<>();
        hashMap.put(new User("张三", 18),new User("张三", 18));
        hashMap.put(new User("李四", 18),new User("李四", 18));
        hashMap.put(new User("张三", 18),new User("张三", 18));
        System.out.println(hashMap);
    }
}

class User{
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
	@Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

结果为:
若debug时显示的Hashmap没有table、size等元素时,可查看【此博客】第19条。
在这里插入图片描述
解释:
1、根据源码可知道【 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))】,只有在hash值相等的情况下,然后比较两个对象地址是否相等 或 值是否相等,然后才会认为是同一个对象。
2、new User 生成的对象是不同的,最终hashcode值也是不同的,假设Hashmap中计算的hash值相等,后面的==和equals返回的也是false,所以最终结果 Hashmap中还是会存放了三个元素。
解决
原理:
1、重新user对象的hashcode方法,保证name、age一致的情况下,hashcode是相等的,这样hash值也会相等
2、new user生成的地址是不同的,所以需要重写equals方法,保证name、age一致的情况下,
两个对象相等。
代码:

class User{
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

	@Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }

	// 重新equals方法,保证name、age一致时返回true
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return age == user.age && Objects.equals(name, user.name);
    }

	// 重写hashcode方法,保证Hashcode一致,Hashmap内部Hash算法得到的hash值就是一致的
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

重新执行main方法,得到的Hashmap里面只有两个元素,张三 18 只添加了一次
在这里插入图片描述

HashSet

HashSet底层实际就是一个Hashmap,HashSet实际就是利用了Hashmap中key不可重复的特点。

// new HashSet实际就是new了一个Hashmap
public HashSet() {
    map = new HashMap<>();
}
private static final Object PRESENT = new Object();

// add的值实际就是map的key
// value实际是一个Object,用于占位
// map的put会返回修改前参数,若put返回值为null则是第一次添加,add返回则为true
public boolean add(E e) {
     return map.put(e, PRESENT)==null;
 }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值