Map - HashSet & HashMap 源码解析

前置知识:

哈希桶数组(Node数组,节点数组):

在Java1.8中有一个非常重要的哈希桶数组Node<K,V>[] table;
jdk1.7中使用Entry来代表每个 HashMap 中的数据节点,jdk8 中使用 Node,基本没有区别,数组元素都是有 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。 我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的。
数组中存储元素的位置称为桶(bucket),每个 bucket 都有其指定索引,系统可以根据其索引快速访问该 bucket 里存储的元素。

一个桶只能存储一个Entry或者Node;
由于 Entry或者Node 对象可以包含一个引用变量(就是next)用于指向下一个Entry或者Node,因此可能出现的情况是:HashMap 的 bucket 中只有一个 Entry或者Node,但这个 Entry或者Node 指向另一个 Entry或者Node ——这就形成了一个 Entry或者Node 链。
image
table: 是数组,用来标记顺序的
slot:哈希槽,对应数组下标,需要把新添加的元素直接放在slot槽中,也就是数组下标,可以使新添加的元素在下次提取时更快的被访问到
table[bucketIndex]=new Entery<>(hash,key,value,e);
bucket: 是哈希桶,数组里存储元素的位置,存储键值对(JDK8中加入红黑树,如果存储的链表长度大于8时,会将链表转化成红黑树进行存储)

HashMap和Hashtable的区别:

HashMap和Hashtable都实现了Map接口。
主要的区别是:线程安全性,同步(synchronization),以及速度。

  • Hashtable的方法是同步的(synchronization)、线程安全的,在操作和迭代的时候,HashTable会锁住整个Map,所以效率较低;
  • HashMap的方法是没有加锁的,线程不安全,所以在单线程环境下,HashMap的速度高于HashTable;

一、HashMap与HashTable的区别:
1.HashMap是非线程安全的,HashTable是线程安全的,HashTable的方法都加了sysnchronized关键字的,确保了方法的同步;
2.HashMap可以接受空key和value,而HashTable不能接受空key和value;
3.由于HashMap是异步执行,而HashTable是同步执行,所以在单线程环境下,HashMap的速度高于HashTable;
4.HashMap的迭代器采用的是Iterator,Iterator是快速失败(Fail-Fast),在遍历过程中若有其他线程对该HashMap进行增加或者删除元素,则会抛出ConcurrentModificationException,因为快速失败的迭代器是操作的集合本身,
HashTable的迭代器是Enumeration,Enumeration是安全失败(Fail-Safe),在遍历过程中若有其他线程对该集合进行增加或则删除元素,不会抛出ConcurentModificationException,因为安全失败的迭代器操作的是原集合的一个拷贝。

哈希表原理:数组+链表
哈希表是一种根据关键字key来访问值value的一种数据结构。
它通过把key值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表

1、Hash主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128位的编码,这些编码值叫做Hash值. 也可以说,Hash就是找到一种数据内容和数据存放地址之间的映射关系。
2、查找:哈希表,又称为散列,是一种更加快捷的查找技术。我们之前的查找,都是这样一种思路:集合中拿出来一个元素,看看是否与我们要找的相等,如果不等,缩小范围,继续查找。而哈希表是完全另外一种思路:当我知道key值以后,我就可以直接计算出这个元素在集合中的位置,根本不需要一次又一次的查找!
3、Hash表在海量数据处理中有着广泛应用。

并发时用ConcurrentHashMap

用线程不安全的MAP并发时会导致数据覆盖、数据丢失;

  1. HashMap不是线程安全的,只能在单线程环境下使用,通过Collections.synchronizeMap(hashMap)实现HashMap同步,SynchronizedMap封装了HashMap的put方法,并加上互斥锁(synchronization)保证了安全性,和HashTable一样,当多线程并发的情况下,都要竞争同一把锁,导致效率极其低下;
  2. HashTable是锁住的整个MAP,同一时间只能执行一个方法,当多线程并发的情况下,都要竞争同一把锁,导致效率极其低下;
  3. ConcurrentHashMap在1.5采用segment分段锁,只锁住Map的一部分,在1.8改进采用CAS和synchronized来保证并发安全,ConcurrentHashMap在多线程环境下的性能更好。

HashSet

HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成HashMap方法。
HashSet实际上是一个HashMap的实例,只使用了Map集合中的Key,所以HashSet集合不允许存储重复元素。
如果用HashSet集合存储自定义的对象,想要保证元素唯一,也要重写hashCode()和equals()。
通过hashCode的哈希值和数组长度进行与(&)运用获取数组存储的位置,该位置如果有值发生哈希冲突,再通过equals判断哈希值和key判断是否相等,相等则不插入;

//HashSet是对HashMap的简单包装
public class HashSet<E>
{
	......
	private transient HashMap<E,Object> map;//HashSet里面有一个HashMap
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    public HashSet() {
        map = new HashMap<>();
    }
    ......
    public boolean add(E e) {//简单的方法转换
        return map.put(e, PRESENT)==null;
    }
    ......
}

Map接口:数组+链表+红黑树

image

Java7 HashMap

HashMap实现了Map接口,即允许放入key为null的元素,也允许插入value为null的元素;除该类未实现同步外,其余跟Hashtable大致相同;该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。
Java7 HashMap采用的是冲突链表方式:
image
从上图容易看出,如果选择合适的哈希函数,put()和get()方法可以在常数时间内完成。但在对HashMap进行迭代时,需要遍历整个table以及后面跟的冲突链表。因此对于迭代比较频繁的场景,不宜将HashMap的初始大小设的过大。
有两个参数可以影响HashMap的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
将对象放入到HashMap或HashSet中时,有两个方法需要特别关心: hashCode()和equals()。hashCode()方法决定了对象会被放到哪个bucket里,当多个对象的哈希值冲突时,equals()方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到HashMap或HashSet中,需要@Override hashCode()和equals()方法。

初始容量应该指的是哈希表中能存放的元素的数量,而并非是hashmap实例创建时哈希表中数组的长度。 数组中存放的键值对往往小于数组长度,通常不会放满,达到数组的75%就会扩容

JDK1.7版本:数组+链表
public class HashMap<K,V>
    extends AbstractMap<K,V> //【1】继承的AbstractMap中,已经实现了Map接口
        //【2】又实现了这个接口,多余,但是设计者觉得没有必要删除,就这么地了
    implements Map<K,V>, Cloneable, Serializable{
                
                
        //【3】后续会用到的重要属性:先粘贴过来:
    static final int DEFAULT_INITIAL_CAPACITY = 16;//哈希表主数组的默认长度
        //定义了一个float类型的变量,以后作为:默认的装填因子,加载因子是表示Hsah表中元素的填满的程度
        //太大容易引起哈西冲突,太小容易浪费  0.75是经过大量运算后得到的最好值
        //这个值其实可以自己改,但是不建议改,因为这个0.75是大量运算得到的
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
        transient Entry<K,V>[] table;//主数组,每个元素为Entry类型
        transient int size;
        int threshold;//数组扩容的界限值,门槛值   16*0.75=12 
        final float loadFactor;//用来接收装填因子的变量
        
        //【4】查看构造器:内部相当于:this(16,0.75f);调用了当前类中的带参构造器
        public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
        //【5】本类中带参数构造器:--》作用给一些数值进行初始化的!
        public HashMap(int initialCapacity, float loadFactor) {
        //【6】给capacity赋值,capacity的值一定是 大于你传进来的initialCapacity 的 最小的 2的倍数
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;
                //【7】给loadFactor赋值,将装填因子0.75赋值给loadFactor
        this.loadFactor = loadFactor;
                //【8】数组扩容的界限值,门槛值
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
                
                //【9】给table数组赋值,初始化数组长度为16
        table = new Entry[capacity];
                   
    }
        //【10】调用put方法:
        public V put(K key, V value) {
                //【11】对空值的判断
        if (key == null)
            return putForNullKey(value);
                //【12】调用hash方法,获取哈希码
        int hash = hash(key);
                //【14】得到key对应在数组中的位置
        int i = indexFor(hash, table.length);
                //【16】如果你放入的元素,在主数组那个位置上没有值,e==null  那么下面这个循环不走
                //当在同一个位置上放入元素的时候
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
                        //哈希值一样  并且  equals相比一样   
                        //(k = e.key) == key  如果是一个对象就不用比较equals了
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
                //【17】走addEntry添加这个节点的方法:
        addEntry(hash, key, value, i);
        return null;
    }
        
        //【13】hash方法返回这个key对应的哈希值,内部进行二次散列,为了尽量保证不同的key得到不同的哈希码!
        final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }
                //k.hashCode()函数调用的是key键值类型自带的哈希函数,
                //由于不同的对象其hashCode()有可能相同,所以需对hashCode()再次哈希,以降低相同率。
        h ^= k.hashCode();
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
                /*
                接下来的一串与运算和异或运算,称之为“扰动函数”,
                扰动的核心思想在于使计算出来的值在保留原有相关特性的基础上,
                增加其值的不确定性,从而降低冲突的概率。
                不同的版本实现的方式不一样,但其根本思想是一致的。
                往右移动的目的,就是为了将h的高位利用起来,减少哈西冲突
                */
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
        //【15】返回int类型数组的坐标
        static int indexFor(int h, int length) {
                //其实这个算法就是取模运算:h%length,取模效率不如位运算
        return h & (length-1);
    }
        //【18】调用addEntry
        void addEntry(int hash, K key, V value, int bucketIndex) {
                //【25】size的大小  大于 16*0.75=12的时候,比如你放入的是第13个,这第13个你打算放在没有元素的位置上的时候
        if ((size >= threshold) && (null != table[bucketIndex])) {
                        //【26】主数组扩容为2倍
            resize(2 * table.length);
                        //【30】重新调整当前元素的hash码
            hash = (null != key) ? hash(key) : 0;
                        //【31】重新计算元素位置
            bucketIndex = indexFor(hash, table.length);
        }
                //【19】将hash,key,value,bucketIndex位置  封装为一个Entry对象:
        createEntry(hash, key, value, bucketIndex);
    }
        //【20】
        void createEntry(int hash, K key, V value, int bucketIndex) {
                //【21】获取bucketIndex位置上的元素给e
        Entry<K,V> e = table[bucketIndex];
                //【22】然后将hash, key, value封装为一个对象,然后将下一个元素的指向为e (链表的头插法)
                //【23】将新的Entry放在table[bucketIndex]的位置上
        table[bucketIndex] = new Entry<>(hash, key, value, e);
                //【24】集合中加入一个元素 size+1
        size++;
    }
    //【27】
        void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
                //【28】创建长度为newCapacity的数组
        Entry[] newTable = new Entry[newCapacity];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
                //【28.5】转让方法:将老数组中的东西都重新放入新数组中
        transfer(newTable, rehash);
                //【29】老数组替换为新数组
        table = newTable;
                //【29.5】重新计算
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
        //【28.6】
        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;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                                //【28.7】将哈希值,和新的数组容量传进去,重新计算key在新数组中的位置
                int i = indexFor(e.hash, newCapacity);
                                //【28.8】头插法
                e.next = newTable[i];//获取链表上元素给e.next
                newTable[i] = e;//然后将e放在i位置 
                e = next;//e再指向下一个节点继续遍历
            }
        }
    }
}

get()

get(Object key)方法根据指定的key值返回对应的value,该方法调用了getEntry(Object key)得到相应的entry,然后返回entry.getValue()。因此getEntry()是算法的核心。 算法思想是首先通过hash()函数得到对应bucket的下标,然后依次遍历冲突链表,通过hash是否相等,equals判断key是否相等方法来判断是否是要找的那个entry。
image

上图中hash(k)&(table.length-1)等价于hash(k)%table.length,原因是HashMap要求table.length必须是2的指数,因此table.length-1就是二进制低位全是1,跟hash(k)相与会将哈希值的高位全抹掉,剩下的就是余数了。

//getEntry()方法
final Entry<K,V> getEntry(Object key) {
	......
	int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[hash&(table.length-1)];//得到冲突链表
         e != null; e = e.next) {//依次遍历冲突链表中的每个entry
        Object k;
        //依据equals()方法判断是否相等
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

put()

put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式为头插法
image

//addEntry()
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;
        bucketIndex = hash & (table.length-1);//hash%table.length
    }
    //在冲突链表头部插入新的entry
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

插入链表数据为什么要使用头插法?

因为HashMap的编写人员认为新加入的数据更常用,在取数据的时候,能够更快的取到该数据

头插法导致链表成环?

在JDK1.7中HashMap使用头插法来添加同一位置上的节点,但是在并发的情况下使用HashMap,在进行resize()扩容的过程中,链表可能会形成环状,当在读取HashMap元素的时候会出现死循环,CPU占用飙高,服务器崩溃的问题。
会出现环就是因为头插在扩容的时候会反转原链表,使得有出现环的可能。换成尾插,原来是什么顺序,扩容之后还是什么顺序,就不会出现一个线程抢先之后 e1 指向 e0 的情况,依旧时 e0 指向 e1,就不会出现环。

即使 JDK8 改成 尾插,但是并发情况下,同时修改同一个 key 的值 或者 同时删除+修改同一个 key 等都会出现其他并发问题,所以并发情况下不要用 HashMap,建议用带锁的 ConcurrentHashMap。

在JDK1.8中HashMap已经采用了尾插法防止了环形链表的产生

remove()

remove(Object key)的作用是删除key值对应的entry,该方法的具体逻辑是在removeEntryForKey(Object key)里实现的。removeEntryForKey()方法会首先找到key值对应的entry,然后删除该entry(修改链表的相应引用)。查找过程跟getEntry()过程类似。
image

//removeEntryForKey()
final Entry<K,V> removeEntryForKey(Object key) {
	......
	int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);//hash&(table.length-1)
    Entry<K,V> prev = table[i];//得到冲突链表
    Entry<K,V> e = prev;
    while (e != null) {//遍历冲突链表
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {//找到要删除的entry
            modCount++; size--;
            if (prev == e) table[i] = next;//删除的是冲突链表的第一个entry
            else prev.next = next;
            return e;
        }
        prev = e; e = next;
    }
    return e;
}

Java8 HashMap

HashMap在jdk1.8后对其内部数据结构进行了优化,从以前的 数组+链表 的结构改为数组+链表+红黑树的结构。
在未发生哈希冲突时仅使用数组进行存储,但发生哈希冲突时,若key值不相同,则插入到冲突数组节点(HashMap的数组中存储数据为Node的节点,属于链表结构)的链表中,若链表长度大于8且数组的长度不小于64的时候通过 treeifyBin() 转化为红黑树提高查询效率。

根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。

为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

HashMap由数组(键值对entry组成的数组主干)+ 链表(元素太多时为解决哈希冲突数组的一个元素上多个entry组成的链表)+ 红黑树(当链表的元素个数达到8链表存储改为红黑树存储)进行数据的存储。
HashMap采用table数组存储Key-Value的,每一个键值对组成了一个Node节点(JDK1.7为Entry实体,因为jdk1.8加入了红黑树,所以改为Node)。Node节点实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Node节点,以此来解决Hash冲突的问题。

相比 jdk1.7 的 HashMap 而言,jdk1.8最重要的就是引入了红黑树的设计,红黑树除了插入操作慢其他操作都比链表快,当hash表的单一链表长度超过 8 个的时候,数组长度大于64,链表结构就会转为红黑树结构。当红黑树上的节点数量小于6个,会重新把红黑树变成单向链表数据结构。
为什么要这样设计呢?好处就是避免在最极端的情况下链表变得很长很长,在查询的时候,效率会非常慢。
红黑树是一种近似平衡的二叉查找树,其主要的优点就是“平衡“,即左右子树高度几乎一致,以此来防止树退化为链表,通过这种方式来保障查找的时间复杂度为 log(n)。

来一张图简单示意一下吧:
image
注意,上图是示意图,主要是描述结构,不会达到这个状态的,因为这么多数据的时候早就扩容了。

Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的。

put 过程分析

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

/**
hash:使用hash方法计算key的哈希值
key:传入的key值
value:传入的value值
onlyIfAbsent(默认为false):意义为当key值相同时,为false说明采用新值覆盖旧值的原则,  如果是 true那么只有在不存在该 key 时才会进行 put 操作
evict:是否为创建模式,此处默认为true。
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 第一次 put 值的时候,会触发下面的 resize()
    // 第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

   /** 使用HashMap的长度与hash进行 &操作(HashMap插入的位置是无序的,仅根据hash值与长度n的&操作决定),找到具体的数组下标,判断该位置是否存在元素,若不存在则直接插入Node节点,若存在则说明发生了哈希冲突。(哈希冲突:不同的key进行哈希运算后得到相同的哈希值,代表发生了哈希冲突)
*/
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    else {// 数组该位置有数据,存储方法put()的主要内容在于发生哈希冲突后的逻辑处理
        Node<K,V> e; K k;

        // 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
        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) {

                // 判断当前节点链表中是否还有后续节点,若没有则插入到链表尾部 (Java7 是插入到链表的最前面)
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);

                    /**判断链表长度是否 >= 8(因为binCount从0开始,所以TREEIFY_THRESHOLD需要减1),若大于等于8则调用treeifyBin()将链表转化为红黑树的存储结构。*/
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }

                // 但对当前节点的链表进行遍历时,发现其中节点的hash值、key值与插入节点相同,直接break结束遍历。
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
                    break;
                p = e;
            }
        }
         /**当 e != null 时,说明插入key值成功了,若 onlyIfAbsent 为false说明采用新值覆盖旧值的原则,或旧值为null时,新值覆盖旧值。
         e!=null 说明存在旧值的key与要插入的key"相等"
        对于我们分析的put操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值 */
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
	//modCount记录HashMap的修改次数。
    ++modCount;
    // 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
    if (++size > threshold)
        resize();

    //afterNodeAccess()用于LinkedHashMap。
    afterNodeInsertion(evict);
    return null;
}
[此处参考文章](https://blog.csdn.net/zjklyl/article/details/118088635 "此处参考文章")
  1. 首先将k,v封装到Node对象当中(节点)。
  2. 然后它的底层会调用K的hashCode()方法得出hash值。
  3. 通过哈希表函数/哈希算法,将hash值转换成数组的下标,下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。
    数组和链表组合成的链表散列结构,通过hash算法,尽量将数组中的数据分布均匀,如果hashcode相同表示哈希冲突再比较equals方法,如果equals方法返回false,那么就将数据以链表的形式存储在数组的对应位置,并将之前在该位置的数据往链表的后面移动,并记录一个next属性,来指示后移的那个数据。注意1.8数组中保存的是node,其中保存的是键值.链表大于等于8会转化成红黑树
    以数组存储元素,如有hash相同的元素,在数组结构中,创建链表结构,再把hash相同的元素放到链表的下一个节点
    HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,然后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。

put执行流程:
在创建集合对象的时候,在jdk8前,在构造方法中创建一个长度为16的Entry[] table数组用来存储键值对数据,在jdk8以后不是在HashMap构造方法底层创建数组了,是在第一次调用put方法时创建的数组Node[] table
不在构造方法中创建的原因是:创建散列表需要耗费内存,而有些时候我们只是创建hashMap,并不向其中put元素
image

  1. 判断数组长度是否为0,为0进行第一次扩容,数组从 null 初始化到默认的 16 或自定义的初始容量
  2. 判断这个位置有没有元素,没有就创建一个node元素;
  3. 使用HashMap的长度与hash进行 &操作(HashMap插入的位置是无序的,仅根据hash值与长度n的&操作决定),找到具体的数组下标,判断该位置是否存在元素,若不存在则直接插入Node节点,若存在则说明发生了哈希冲突。(哈希冲突:不同的key进行哈希运算后得到相同的哈希值,代表发生了哈希冲突);
  4. 数组该位置有数据,发生哈希冲突,首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点,如果该节点是代表红黑树的节点,调用红黑树的插值方法,当不属于前两种情况时,说明数组该位置上是一个链表,则需对该节点的链表进行操作,对当前节点的链表进行遍历。
  5. 判断当前节点链表中是否还有后续节点,若没有则插入到链表尾部,若链表大于等于8则调用treeifyBin()将链表转化为红黑树的存储结构。但对当前节点的链表进行遍历时,发现其中节点的hash值、key值与插入节点相同,直接break结束遍历。
  6. 如果存在旧值的key与要插入的key"相等",要进行 "value值覆盖",然后返回旧值;
  7. 最后判断 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容;

和 Java7 稍微有点不一样的地方就是,Java7 是先扩容后插入新值的,Java8 先插值再扩容,不过这个不重要。
JDK1.8之前HashMap由数组+链表组成。数组是HshMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希值一致导致计算的数组索引值相同(哈希寻址算法[hash&length-1]))而存在的(“拉链法”解决冲突)
JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8,且数组中桶的数量大于64,此时此索引位置上的所有数据改为使用红黑树存储)

为什么引入红黑树?

解决一个链表过长,遍历的时间复杂度退化成O(n)
JDK1.8以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放在同一个桶中时,这个桶下有一条长长的链表,此时HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就从O(1)退化成O(n),完全失去了它的优势,针对此种情况,JDK1.8中引入了红黑树(查找的时间复杂度为O(logn))来优化这种问题。

为啥HashMap中初始化大小为什么是16呢?

数组的长度若是2的幂次方,那么(n-1)与hash进行位运算不仅效率很高,而且能均匀的散列,如果为奇数进行位运算后发生哈希冲突的概率很大;初始长度太大浪费空间,太小发生扩容又频繁;

无序唯一

HashMap中的映射不是有序的,HashMap会通过hashCode和equals方法判断key是否相等,保持唯一;

HashMap求桶的位置一共分为三个过程:

1)求key的hashcode
2)将hashcode的高16位和低16位进行异或操作。h = key.hashCode()) ^ (h >>> 16
3)(n - 1) & hash ,将hash值与length-1进行与操作,求桶的位置

hashcode方法

哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?
底层采用的是key的hashCode方法的值结合数组长度进行无符号右移(>>>),按位异或(^),按位与(&)计算出索引。
当两个对象的hashCode相等时会怎么样?
会产生hash碰撞,若key值内容相等则替换旧的value,反之则连接到链表后面,链表长度超过阈值8就转换成红黑树存储

何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?
只要两个元素的key计算的hash码值相同就会发生hash碰撞,jdk8前使用链表解决哈希碰撞,jdk8之后使用链表+红黑树解决哈希碰撞

如果两个键的hashcode相同,如何存储键值对
hashcode相同则会调用equals比较内容是否相同,如果相同则会用新value值覆盖老value值,如果不相同则会连接到链表后面

Hash值的计算哈希算法减少哈希碰撞
Hash值=(h = key.hashCode()) ^ (h >>> 16);
Hashcode予hashcode自己向右位移16位的异或运算。这样可以确保算出来的值足够随机。因为进行hash计算的时候足够分散,以便于计算数组下标的时候算的值足够分散。前面说过hashmap的底层是由数组组成,数组默认大小是16,那么数组下标是怎么计算出来的呢,那就是:
数组下标:(n - 1) & hash = hash%n
假设n=16,对哈市计算得到的hash进行16的求余,得到一个16的位数,比如说是1到15之间的一个数,hashmap会与hash值和15进行予运算。这样可以效率会更高。计算机中会容易识别这种向右位移,向左位移。
Hash冲突
不同的对象算出来的数组下标是相同的这样就会产生hash冲突。
Hash冲突会产生单线链表
当单线链表达到一定长度后效率会非常低,那么在jdk1.8以后的话,加入了红黑树,也就是说单线列表达到一定长度后就会变成一个红黑树

为什么主数组的长度为2的倍数?
2的倍数,在计算key的数组下标时优化取模为&位运算,直接与内存交互避免二进制与十进制转换,提交运算效率

当集合中存放元素超过threshold阈值之后,Hashmap会扩容,且扩容是2的n次幂。这样扩容的目的是保持capacity是2的倍数,在计算key的数组下标时优化取模为&位运算,直接与内存交互避免二进制与十进制转换,提交运算效率。

h%length 和 h&(length-1) 等效的前提就是 length必须是2的整数倍
如果不是2的整数倍,那么 哈西碰撞 哈西冲突的概率就高了很多
位运算就涉及到length是不是2的整数倍
与比取模的效率更高

为什么高低位异或运算可以减少哈希碰撞?
异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率当然就更小了。这就是为什么要对一个hash值进行高低位混合,并且选择异或运算来混合的原因。

数组扩容resize()

  1. 初始数组长度为16,若链表节点数量小于等于8时,会将节点连接到链表的下一个位置
  2. 若某个链表上阈值大于8时,会根据cap(容量)进行判断,若此时数组容量小于64,则会进行数组扩容(从32扩容为64)
  3. 若链表节阈值大于8,且数组容量大于64,则链表进化为红黑树
    问题:为什么要在数组长度大于64之后,链表才会进化为红黑树?
    ● 在数组比较小时如果出现红黑树结构,反而会降低效率,因为红黑树需要进行左旋右旋,变色这些操作来保持平衡,同时数组长度小于64时,搜索时间相对要快些,所以综上所述为了提高性能和减少搜索时间。变成红黑树的目的是为了高效的查询

resize() 方法用于初始化数组或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移。
初始容量为16.达到阀值扩容,阀值等于最大扩容*负载因子,扩容每次2倍,总是2的n次方。
扩容机制:
每次扩容为初始容量的2倍
为了防止数据过多,导致线性查询,效率变低,扩容使得桶数变多,每条链上数据变少,查询更快
使用一个容量更大的数组来代替已有容量小的数组,transfer()方法将原来的Entry数组的元素拷贝到新的Entry中,java1.7中重新计算每个元素在数组里的位置,java1.8中不是重新计算,而是用了更为巧妙的方法。
JDK7还是JDK8扩容区别:
无论是JDK7还是JDK8,HashMap的扩容都是每次扩容为原来的两倍,即会产生一个新的数组newtable,我们需要把原来数组中的元素全部放到新的只不过元素求桶的位置的方法不太一样。
在JDK7中就是按照我上述写的三个步骤重新对元素求桶的位置,但是第三步与的值是新的数组的长度-1,也就是newCap-1。

if (e.next == null)
     newTab[e.hash & (newCap - 1)] = e;//插入新值

但是JDK8中就不是和newCap,而是直接与oldCap,也就是与旧数组的长度(oldCap)进行与操作。下面的是伪代码:

if ((e.hash & oldCap) == 0) {
newTab[j] = loHead;
}else{

newTab[j + oldCap] = hiHead;
}

与oldCap与的结果如果是0,那么就代表当前元素的桶位置不变。
如果结果为1,那么桶的位置就是原位置+原数组长度(oldCap)
注意: hashMap扩容的时候会判断当前的桶的位置有没有链表或者红黑树,如果没有链表或者红黑树,那么当前元素还是和JDK1.7中的求法一样,求新的桶的位置。如果有链表,那么链表的元素会按照我刚刚所说的求法去求新的桶的位置。如果是红黑树,则会调用split方法,将红黑树切分为两个链表,之后进行扩容操作

扩容为2倍的原因是:
以二次幂展开,容器的元素要么保持原来的索引,要么以二次幂的偏移量出现在新表中。也就是说hashmap采用2倍扩容,可以尽可能的减少元素位置的移动。
数组初始长度为2的幂次方,随后以2倍扩容的方式扩容,元素在新表中的位置要么不动,要么有规律的出现在新表中(二的幂次方偏移量),这样会使扩容的效率大大提高。
hashmap采用二倍扩容还有另外一个好处:可以使元素均匀的散布hashmap中,减少hash碰撞。
n若是2的幂次方,则n-1的二进制就是00000***111这样的形式,低位有效位是1,那么(n-1)与hash进行位运算不仅效率很高,而且能均匀的散列

  • 减少元素位置的移动。
  • 使元素均匀的散布hashmap中,减少hash碰撞。

初始容量为2的幂幂数,扩容后的容量也是2的幂数,则元素在新表中的位置要么不动,要么满足新位置=原长度+原位置。
是否移位,由扩容后表示的最高位是否1为所决定,并且移动的方向只有一个,即向高位移动。因此,可以根据对最高位进行检测的结果来决定是否移位,从而可以优化性能,不用每一个元素都进行移位,因为为0说明刚好在移位完之后的位置,为1说明不是需要移动oldCop,这也是其为什么要按照2倍方式扩容的第二个原因。

容量为2的幂数则可以使元素均匀的散布hashmap中,减少hash碰撞。

image
image
存放最低的8位有效位的字节被称为最低有效位字节或低位字节,而存放最高的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;
        }
        // 将数组大小扩大一倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 将阈值扩大一倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // 对应使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
        newCap = oldThr;
    else {// 对应使用 new HashMap() 初始化后,第一次 put 的时候
        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; // 如果是初始化数组,到这里就结束了,返回 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;
                // 如果是红黑树,具体我们就不展开了
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    // 这块是处理链表的情况,
                    // 需要将此链表拆成两个链表,放到新的数组中,并且保留原来的先后顺序
                    // loHead、loTail 对应一条链表,hiHead、hiTail 对应另一条链表,代码还是比较简单的
                    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;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        // 第一条链表
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 第二条链表的新的位置是 j + oldCap,这个很好理解
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

再散列rehash过程

当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,这时,需要创建一张新表,将原表的映射到新表中。

get 过程分析

  1. 先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
  2. 通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上。如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
    相对于 put 来说,get 真的太简单了。
  • 计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1)
  • 判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步
  • 判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步
  • 遍历链表,直到找到相等(==或equals)的 key
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;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 判断第一个节点是不是就是需要的
        if (first.hash == hash && // always check first node
            ((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);
        }
    }
    return null;
}

负载因子(load factor)

  1. 负载因子的大小决定了HashMap的数据密度。
  2. 负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
  3. 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内容空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间。
  4. 按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。

HashMap的负载因子初始值为什么是0.75?
负载因子相当于是一个扩容机制的阈值。当超过了这个阈值,就会触发扩容机制。HashMap源码已经为我们默认指定了负载因子是0.75。此时的空间和时间利用率是最合适的
HashMap只是一个数据结构,既然是数据结构最主要的就是节省时间和空间。负载因子的作用肯定也是节省时间和空间。
我们考虑两种极端情况。

如果装填因子是1, 那么数组满了再扩容,可以做到 最大的空间利用率
但是这是一个理想状态,元素不可能完全的均匀分布,很可能就哈西碰撞产生链表了。产生链表的话 查询时间就长了。
---》空间好,时间不好
那么有人说 ,把装填因子搞小一点,0.5, 如果是0.5的话,就浪费空间,但是可以做到 到0.5就扩容 ,然后哈西碰撞就少,
不产生链表的话,那么查询效率很高
---》时间好,空间不好
所以在空间和时间中,取中间值,平衡这个因素 就取值为 0.75

1、负载因子是1.0
我们先看HashMap的底层数据结构
image
我们的数据一开始是保存在数组里面的,当发生了Hash碰撞的时候,就是在这个数据节点上,生出一个链表,当链表长度达到一定长度的时候,就会把链表转化为红黑树。
当负载因子是1.0的时候,也就意味着,只有当数组的8个值(这个图表示了8个)全部填充了,才会发生扩容。这就带来了很大的问题,因为Hash冲突时避免不了的。当负载因子是1.0的时候,意味着会出现大量的Hash的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。
因此一句话总结就是负载因子过大,虽然空间利用率上去了,但是时间效率降低了。
2、负载因子是0.5
负载因子是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。
这时候空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。
一句话总结就是负载因子太小,虽然时间效率提升了,但是空间利用率降低了。
3、负载因子0.75
经过前面的分析,基本上为什么是0.75的答案也就出来了,这是时间和空间的权衡。
负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。

负载因子是和扩容机制有关的,意思是如果当前容器的容量,达到了我们设定的最大值,就要开始执行扩容操作。负载因子越小,就越容易触发扩容。
比如说当前的容器容量是16,负载因子是0.75,16*0.75=12,也就是说,当容量达到了12的时候就会进行扩容操作。

影响hashMap性能的关键因素有哪些?

HashMap 的实例有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。在Java编程语言中,加载因子默认值为0.75,默认哈希表元为101。

为什么我们重写equals方法的时候需要重写hashCode方法呢?

重写equals方法时重写hashCode方法的目的是保证相同(equals返回为true)内容的key返回相同的hashCode
当类放在HashTable,HashMap,HashSet等Hash结构的集合时,才会重载hashcode
hashcode方法用来定义索引位置,以提高效率的同时可能会发生效率冲突,当出现hash冲突时,我们就得通过equals()方法来判断冲突的对象是否相等
如果只重写hashcode方法,当发生hash冲突时,即使两个对象相等,也不会判断为重复,进而导致数组里会重写一大堆重复对象
如果只重写equals方法,那两个相等的对象,内存地址未必相等,这样也会造成重复元素的问题,所以需要同时重写
hashcode方法用来在最快时间内,判断两个对象是否相等。
equals方法用来判断两个对象是否绝对相等
hashcode方法用来保证性能,equals方法用来减少误差

自定义hashMap容量多少合适?

.【推荐】集合初始化时,指定集合初始值大小。
说明:HashMap 使用构造方法 HashMap(int initialCapacity) 进行初始化时,如果暂时无法确定集合大小,那么指
定默认值(16)即可。
正例:initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loaderfactor)默认为 0.75,如果
暂时无法确定初始值大小,请设置为 16(即默认值)。
反例:HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素增加而被迫不断扩容,resize() 方法
总共会调用 8 次,反复重建哈希表和数据迁移。当放置的集合元素个数达千万级时会影响程序性能。

使用 entrySet 遍历 Map 类集合 KV,而不是 keySet 方式进行遍历。
说明:keySet 其实是遍历了 2 次,一次是转为 Iterator 对象,另一次是从 hashMap 中取出 key 所对应的 value。而
entrySet 只是遍历了一次就把 key 和 value 都放到了 entry 中,效率更高。如果是 JDK8,使用 Map.forEach 方法。
正例:values() 返回的是 V 值集合,是一个 list 集合对象;keySet() 返回的是 K 值集合,是一个 Set 集合对象;
entrySet() 返回的是 K-V 值组合的 Set 集合。

posted @ 2023-04-17 08:10  Jimmyhus  阅读(38)  评论(0编辑  收藏  举报