HashMap全文详解

234 阅读13分钟

一、HashMap集合简介

  • HashMap是基于哈希表的 Map接口实现,以key-value结构存储,主要存放键值对。不是线程安全,key、value都可以为null,HashMap是无序的。
  • JDK7 HashMap由 数组 + 链表组成,Entry数组是HashMap的主体,链表主要是为了解决哈希冲突
  • JDK8 HashMap由 数组 + 链表 + 红黑树组成,主要由Node节点组成,链表转成红黑树的条件是 链表长度大于8并且数组长度大于等于64同时满足时,此时才会改为红黑树存储

哈希冲突:两个对象调用的 hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用)而存在的(“拉链法”解决冲突)

将链表转换成红黑树前会判断,即便链表长度大于 8,但是数组长度小于 64,此时并不会将链表变为红黑树,而是选择逬行数组扩容。

这样做的目的是因为数组比较小,尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要逬行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。具体可参考treeifyBin() 方法。链表长度小于6时则会从红黑树转回链表

二、HashMap 1.7源码分析

首先查看hashmap中重要属性:

static final int DEFAULT_INITIAL_CAPACITY = 16;//哈希表主数组的默认长度16
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认的负载因子0.75
transient Entry<K,V>[] table; //主数组,每个元素为Entry类型 transient int size; //接口中元素个数  默认0 int threshold;//数组扩容的界限值,门槛值   16*0.75=12 
final float loadFactor;//用来接收负载因子的变量

hashmap无参构造器

public HashMap() {         this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);  //this(16,0.75f)     }

hashmap带参构造器:对值进行初始化

public HashMap(int initialCapacity, float loadFactor) {

        //给capacity赋值,capacity的值一定是 大于你传进来的initialCapacity 的 最小的 2的n次幂
        int capacity = 1;

        while (capacity < initialCapacity)   //while循环跳出  capacity为 16 
            capacity <<= 1;
                //给loadFactor赋值,将装填因子0.75赋值给loadFactor
                this.loadFactor = loadFactor;
                //数组扩容的界限值,门槛值  12
                threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
                //给table数组赋值,初始化数组长度为16
        table = new Entry[capacity];
    }

put方法

public V put(K key, V value) {

        //对空值的判断   这里也证明hashmap key是可以为空的
        if (key == null)
            return putForNullKey(value);
            // 调用hash方法,获取哈希码
            int hash = hash(key);
            // 得到key对应在数组中的位置
            int i = indexFor(hash, table.length);
            //如果你放入的元素,在主数组那个位置上没有值,e==null  那么下面这个循环不走
            //当在同一个位置上放入元素的时候  e!= null  代表已经有元素(也就是发生哈希碰撞)
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //哈希值一样 (k = e.key) == key  如果是一个对象就不用比较equals了
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                //获取老value
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                //返回oldValue
                return oldValue;
            }
        }

        modCount++;

         // 走addEntry添加这个节点的方法:
        addEntry(hash, key, value, i);
        return null;
    }

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();

                /*

                接下来的一串与运算和异或运算,称之为“扰动函数”,

                扰动的核心思想在于使计算出来的值在保留原有相关特性的基础上,

                增加其值的不确定性,从而降低冲突的概率。

                不同的版本实现的方式不一样,但其根本思想是一致的。

                往右移动的目的,就是为了将h的高位利用起来,减少哈西冲突

                */

        h ^= (h >>> 20) ^ (h >>> 12);

        return h ^ (h >>> 7) ^ (h >>> 4);

    }

返回int类型数组的坐标(得到key在对应数组中的位置)

        static int indexFor(int h, int length) {

        //其实这个算法就是取模运算:h%length,但取模效率不如位运算
        return h & (length-1);
    }

调用addEntry方法

void addEntry(int hash, K key, V value, int bucketIndex) {

        //size的大小  大于 16*0.75=12的时候,比如你放入的是第13个,这第13个你打算放在没有元素的位置上的时候
        if ((size >= threshold) && (null != table[bucketIndex])) {

            //主数组扩容为2倍
            resize(2 * table.length);
            // 重新调整当前元素的hash码
            hash = (null != key) ? hash(key) : 0;
            // 重新计算元素位置
            bucketIndex = indexFor(hash, table.length);
        }

        //将hash,key,value,bucketIndex位置  封装为一个Entry对象:
        createEntry(hash, key, value, bucketIndex);
    }

        void createEntry(int hash, K key, V value, int bucketIndex) {

        // 获取bucketIndex位置上的元素给e
        Entry<K,V> e = table[bucketIndex];

        //然后将hash, key, value封装为一个对象,然后将下一个元素的指向为e (链表的头插法)
        // 将新的Entry放在table[bucketIndex]的位置上
        table[bucketIndex] = new Entry<>(hash, key, value, e);

        // 集合中加入一个元素 size+1
        size++;
    }

JDK7 HashMap基本原理总结

在hashmap中底层主数组是Entry[]类型数组,长度为16,默认负载因子是0.75,(取值0.75与泊松分布有关,空间和时间平衡) 数组扩容的界限值 为16*0.75 = 12,扩容新数组的长度为原长度的2倍 put方法过程中,会首先通过hash方法获取它的哈希码 (hash方法中返回key对应的哈希值,内部进行了二次散列,并保证不同的key得到不同的哈希码,再者不同对象的hashcode有可能相同,所以需对hashcode进行再次哈希,以降低相同率,内部使用一串与运算和异或运算(也被称之为扰动函数,也就是增加值得不确定性,从而来降低冲突的概率)), 然后要得到key在对应数组中的位置(内部通过位运算实现),后面添加元素到集合中

三、HashMap 1.8源码分析

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //认初始容量 - 必须是 2 的幂,默认16
static final int MAXIMUM_CAPACITY = 1 << 30;	//最大容量(2的幂按位计算,就是1后跟0)
static final float DEFAULT_LOAD_FACTOR = 0.75f;	 //构造函数中未指定负载因子时,默认使用的负载因子。
static final int TREEIFY_THRESHOLD = 8; 	//链表元素超过8个,才使用红黑树结构
static final int MIN_TREEIFY_CAPACITY = 64;	//数组大于等于64个,才使用红黑树结构
static final int UNTREEIFY_THRESHOLD = 6;	//红黑树元素小于6个,就转化为链表
transient Node<K,V>[] table;		//表在第一次使用时初始化,并根据需要调整大小。(jdk8之前是Entry对象)
transient int size;					//键值映射数
transient int modCount;				//HashMap 被结构修改的次数
final float loadFactor;				//哈希表的加载因子。
int threshold;						//整大小的下一个大小值(容量 * 负载因子)。

image-20230216170939950

put方法

  1. 把k,v键值对封装到Node节点中
  2. 底层会调用k的hashcode方法来得出hash值
  3. 通过hash算法将hash值转换为数组的下标, 下标的位置上如果没有任何元素, 就会把Node 添加到这个位置上 。 如果下标对应的位置上存在链表的话, 此时就会拿着k和链表上每个节点的k进行equals比较, 如果equals方法比较返回是false, 那么新的节点将会添加到链表的末尾。如果其中有一个equals返回了true, 那么这个节点的value将会被覆盖

get方法

  1. 先调用k的hashcode方法算出hash值,然后通过哈希算法算出数组的下标
  2. 获取到数组的下标之后,再通过下标快速定位到某个位置上 如果位置上什么都没有, 则返回null
  3. 如果这个位置上有单向链表, 此时会拿着k和单向链表上的每一个节点的k进行equals, 如果所有的equals方法都返回false的话, get方法则返回null
  4. 如果其中一个节点的k和参数k进行equals返回true, 那么此时直接返回该节点的value

扩容机制

  1. HashMap 刚new出来时还没有put元素进去, 没有真正分配存储空间被初始化, 调用resize()函数进行初始化
  2. 原table中的元素个数达到了capacity * loadFactor这个上限后, 就需要扩容。 此时调用 resize(), 会new一个两倍长度的新Node数组, 并将容器指针(table) 指向新数组 ,并返回 hashmap初始化首次插入数据时, 先resize扩容在插入数据, 之后每当插入的数据个数达到threshold**(阈值)**时就会发生resize,此时是先插入数据再resize。 每次扩容长度为原长度的2倍 扩容方法
/** 初始化或增加表大小。 如果为空,则根据字段阈值中保持的初始容量目标进行分配.
* 否则,因为我们使用的是2的幂,所以每个bin中的元素必须保持相同的索引,或者在新表中以2的幂偏移。
* @return the table
*/
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//首次初始化后table为Null
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//获取原哈希表容量  如果哈希表为空则容量为0 ,否则为原哈希表长度
        int oldThr = threshold;//获取原哈希表扩容门槛,默认构造器的情况下为0
        int newCap, newThr = 0;//初始化新容量和新扩容门槛为0
        //如果原容量大于 0,这个if语句中计算进行扩容后的容量及新的负载门槛
        if (oldCap > 0) {
        	//判断原容量是否大于等于HashMap允许的容量最大值 2的30次幂
            if (oldCap >= MAXIMUM_CAPACITY) { 
                threshold = Integer.MAX_VALUE;//满足则把当前HashMap的扩容门槛设置为Integer允许的最大值
                return oldTab;//不再扩容直接返回
            }
            /**
             * newCap = oldCap << 1 ;  类似 newCap = oldCap * 2 移位操作更加高效
             * 表示把原容量的二进制位向左移动一位,
             * 扩大为原来的2倍,同样还是2的n次幂
             * 如果新的数组容量<HashMap允许的容量最大值2的30次幂
             * 并且原数组容量>=默认的初始化数组容量2的4次幂 =16
              */
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; //新的扩容门槛为原来的扩容门槛的2倍,同样二进制左移操作
        }  
        /**
        * 如果原数组容量小于等于零并且原负载门槛大于0,则新数组容量为原负载门槛大小
         */    
        else if (oldThr > 0)  
            newCap = oldThr;
        /**
		* 在默认构造器下进行扩容:初始化默认容量和默认负载门槛
    	* 如果原数组容量小于等于0并且原负载门槛也小于等于0,则新数组容量为默认初始化容量16 
     	* 新负载门槛为默认负载因子(0.75f) * 16=12;
     	*/
        else {             
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
       /**
        * 如果新负载门槛为 0  则开始使用新的数组容量进行计算
        */
        if (newThr == 0) {  
            float ft = (float)newCap * loadFactor;// 新的数组容量 * 负载因子
            //如果新数组容量小于HashMap允许的最大容量(2的30次幂)并且新计算的负载门槛小于HashMap允许的最大容量(2的30次幂),则新的负载门槛为计算后的值的最大整型,否则新的负载门槛为Integer.MAX_VALUE
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;//设置全局负载门槛为计算后的新的负载门槛
        /**
        * 根据新的数组容量创建新的哈希桶 赋值给newTab
        */
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;//把新创建的哈希桶赋值给全局table
        
        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)
                       // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
                       // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                     	// 当前index对应的节点为红黑树
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 把当前index对应的链表分成两个链表,减少扩容的迁移量
                        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;   // help gc
                            newTab[j + oldCap] = hiHead; // 扩容长度为当前index位置+旧的容量
                        }
                    }
                }
            }
        }
        return newTab;
    }

面试题

HashMap的底层数据结构?

1.7 数组 + 链表 1.8 数组 + 链表 + 红黑树

如果两个对象的hashCode相同会怎么样?

  1. 调用equals方法,进行key值比较是否相等,key相等则新值会把旧值覆盖
  2. 如果key值不相等,则会以链表形式存储

HashMap什么情况下转红黑树,什么情况转链表?

  1. 链表长度大于8,并且数组长度大于等于64时,才会由链表转成红黑树。如果链表过长,首先是扩容数组,而不是转换树
  2. 当树中的元素经过删除或者其他原因调整了大小,当小于等于 6 后,将会导致树退化成链表,中间有个过渡值 7,可以防止频繁的树化与退化。

HashMap在7和8分别使用什么插法?

JDK7使用头插法(多线程环境下可能引起死循环),JDK8使用尾插法(虽然解决死循环,但是由于没有给get和put加同步锁,所以可能出现值不一样的情况),多线程环境强烈建议使用ConCurrentHashMap

HashMap的扩容机制?

默认情况下,数组大小为16,负载因子为0.75,扩容阈值为 16 * 0.75 = 12,也就是说原数组中个数达到了 12 后,就需要扩容,调用resize(),然后new一个两倍长度的新Node数组,更换指针指向新数组,扩容长度为原数组长度的2倍。

HashMap指定容量初始化

通过HashMap(int initialCapacity)设置初始容量的时候,HashMap并不一定会直接采用我们传入的数值,而是经过计算,得到一个新值,根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的2的幂并返回

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;

为什么主数组的长度要为2的倍数?

原因是为了减少hash碰撞,尽量使hash算法的结果均匀

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)

Image.png Image.png

如果不是2的整数倍时,那么哈希碰撞 哈希冲突的概率会大大增加

解决hash冲突的办法有哪些?HashMap用的哪种?

  • 开放地址法
  • 再哈希法
  • 链地址法 HashMap采用的是链地址法

HashMap与HashTable的区别?

  1. hashmap线程不安全,效率比较高 hashtable线程安全 效率低
  2. hashmap key 和 value可以为空 hashtable都不可以为空