HashMap散列表

HashMap是Java中常用的数据结构,它结合动态链表和红黑树,利用散列函数处理冲突。HashMap在冲突时可能会转化为红黑树,当链表长度超过8时。它的扩容机制在1.7和1.8中有显著不同,1.8采用了尾插法减少并发问题。此外,HashMap依赖equals和hashCode方法判断对象是否相同,注意覆盖这两个方法时要保持一致性。HashMap非线程安全,可以使用ConcurrentHashMap解决这个问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

HashMap散列表

数据结构

HashMap是由动态链表和红黑树的结构组成,而组成这些结构的是一个个桶结构;当map中链表长度小于64并且深度小于6的时候map将保持动态链表的结构,当不满足就会转化为红黑树的结构。

散列表处理冲突的方法

  1. 开放地址法:如果根据key计算出的hash出现了冲突,则去寻找下一个空的桶位置去存放。

    • 优缺点

      • 优点:简单,表不满的时候性能好
      • 缺点:邻插槽会形成“集群”,当这些簇填满阵列的时候性能会严重下降,因为是穷举搜索
    • 线性探测法

    • 二次探测法

      • 可以将原本冲突时存放hash+1改为hash*hash,也就是平方探测法,这样可以防止聚集(可是这种方法对于后面插入的元素,探测时间会延长,也就是所谓“二次聚集”)
    • 随机探测法

  2. 再散列函数法:二次hash,需要寻找合适的函数

  3. 链地址法:这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。

    • 优点:简单
    • 缺点:当链表中存放大量元素的时候,查询效率下降,所以需要对数组进行扩容。
  4. 建立一个公共溢出区

线程安全的map

  1. Hashtable线程安全,但效率低,因为是Hashtable是使用synchronized的,所有线程竞争同一把锁;
  2. ConcurrentHashMap不仅线程安全而且效率高,因为它包含一个segment数组,将数据分段存储,给每一段数据配一把锁,也就是所谓的锁分段技术。

equals和hashCode

  • equals是Object类的方法:主要是比较地址值
    1. 对于String字符串来说equals是比较字符串中的内容是否相同,而“==”是比较两个对象在内存中的地址值。另外StringBuffer中没有重新定义equals方法,所以它是来自Object类的。
  • hashCode是用来计算对象在内存中的hashCode值
    1. 存对象到集合中是怎么判断一个对象是否已经在集合中呢?首先会用计算hashCode如果hashCode发生冲突了,则判断集中已经有这个对象(内存相同),如果hashCode没有相同,则再用equals判断是否是同一个对象,如果不是,才会添加到集合中。
    2. 也就是说两个对象,hashCode相同,那么他们指向的地址一定相同,并且equals也相同;如果hashCode不相同,也不能说明他们就是不同对象,要根据equals判断。
    3. 需要注意的是当equals()方法被override时,hashCode()也要被override。按照一般hashCode()方法的实现来说,相等的对象,它们的hash code一定相等。

HashMap

Hash是什么

  • 通过对象的内部地址(也就是物理地址)转换成一个整数,然后该整数通过hash函数的算法就得到了hashcode,所以,hashcode是什么呢?就是在hash表中对应的位置。

Hash的计算

  • 为什么右移 16 位,为什么要使用 ^ 位异或

  • HashMap 如何根据 hash 值找到数组中的对象

    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 != && key.equals(k))))
                        return e;
                }while((e = e.next) != null)
            }
        }
        return null;
    }
    
    • 我们看看代码中注释下方的一行代码:first = tab[(n - 1) & hash])。

      使用数组长度减一 与运算 hash 值。这行代码就是为什么要让前面的 hash 方法移位并异或。

      我们分析一下:

      首先,假设有一种情况,对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。

      如果数组长度是16,也就是 15 与运算这两个数, 你会发现结果都是0。这样的散列结果太让人失望了。很明显不是一个好的散列算法。

      但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。

      总的来说,使用位移 16 位和 异或 就是防止这种极端情况。但是,该方法在一些极端情况下还是有问题,比如:10000000000000000000000000 和 10000000001000000000000000 这两个数,如果数组长度是16,那么即使右移16位,在异或,hash 值还是会重复。但是为了性能,对这种极端情况,JDK 的作者选择了性能。毕竟这是少数情况,为了这种情况去增加 hash 时间,性价比不高。

HashMap链表转红黑树后会不会再转会链表,阈值是多少,为什么要设置这个阈值

  • 黑树是一个特殊的平衡二叉树,查找的时间复杂度是 O(logn) ;而链表查找元素的时间复杂度为 O(n),远远大于红黑树的 O(logn),尤其是在节点越来越多的情况下,O(logn) 体现出的优势会更加明显;简而言之就是为了提升查询的效率。
  • 单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes,而是否足够多就是由 TREEIFY_THRESHOLD 的值(默认值8)决定的。而当桶中节点数由于移除或者 resize 变少后,又会变回普通的链表的形式,以便节省空间,这个阈值是 UNTREEIFY_THRESHOLD(默认值6)。

HashMap的扩容机制

1.7

  • 1.7中整个扩容过程就是一个取出数组元素(实际数组索引位置上的每个元素是每个独立单向链表的头部,也就是发生 Hash 冲突后最后放入的冲突元素)然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标然后进行交换(即原来 hash 冲突的单向链表尾部变成了扩容后单向链表的头部)。

1.8

/**
     * Implements Map.put and related methods
     *
     * @param hash key值计算传来的下标
     * @param key
     * @param value
     * @param onlyIfAbsent true只是在值为空的时候存储数据,false都存储数据
     * @param evict
     * @return 返回被覆盖的值,如果没有覆盖则返回null
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // 申明entry数组对象tab[]:当前Entry[]对象
        Node<K,V>[] tab;
        // 申明entry对象p:这里表示存放的单个节点
        Node<K,V> p;
        // n:为当前Entry对象长度      // i:为当前存放对象节点的位置下标
        int n, i;

        /**
         * 流程判断
         * 1、如果当前Node数组(tab)为空,则直接创建(通过resize()创建),并将当前创建后的长度设置给n
         * 2、如果要存放对象所在位置的Node节点为空,则直接将对象存放位置创建新Node,并将值直接存入
         * 3、存放的Node数组不为空,且存放的下标节点Node不为空(该Node节点为链表的首节点)
         *   1)比较链表的首节点存放的对象和当前存放对象是否为同一个对象,如果是则直接覆盖并将原来的值返回
         *   2)如果不是分两种情况
         *      (1)存储处节点为红黑树node结构,调用方法putTreeVal()直接将数据插入
         *      (2)不是红黑树,则表示为链表,则进行遍历
         *          A.如果存入的链表下一个位置为空,则先将值直接存入,存入后检查当前存入位置是否已经大于链表的第8个位置
         *              a.如果大于,调用treeifyBin方法判断是扩容 还是 需要将该链表转红黑树(大于8且总数据量大于64则转红黑色,否则对数组进行扩容)
         *              b.当前存入位置链表长度没有大于8,则存入成功,终端循环操作。
         *          B.如果存入链表的下一个位置有值,且该值和存入对象“一样”,则直接覆盖,并将原来的值返回
         *          上面AB两种情况执行完成后,判断返回的原对象是否为空,如果不为空,则将原对象的原始value返回
         * 上面123三种情况下,如果没有覆盖原值,则表示新增存入数据,存储数据完成后,size+1,然后判断当前数据量是否大于阈值,
         * 如果大于阈值,则进行扩容。
         */
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);//该方法判断是扩容还是需要将该链表转红黑树
                        break;
                    }
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 如果不是替换数据存入,而是新增位置存入后,则将map的size进行加1,然后判断容量是否超过阈值,超过则扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
resize
.1 如果table == null, 则为HashMap的初始化, 生成空table返回即可;
.2 如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(, 如果原oldLength已经到了上限, 则newLength = oldLength);
.3 遍历oldTable:
.3.2 首节点为空, 本次循环结束;
.3.1 无后续节点, 重新计算hash位, 本次循环结束;
.3.2 当前是红黑树, 走红黑树的重定位;
.3.3 当前是链表, JAVA7时还需要重新计算hash位, 但是JAVA8做了优化, 通过(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;

Hashmap为什么大小是2的幂次

因为在计算元素该存放的位置的时候,用到的算法是将元素的hashcode与当前map长度-1进行与运算。源码:

static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

如果map长度为2的幂次,那长度-1的二进制一定为11111…这种形式,进行与运算就看元素的hashcode,但是如果map的长度不是2的幂次,比如为15,那长度-1就是14,二进制为1110,无论与谁相与最后一位一定是0,0001,0011,0101,1001,1011,0111,1101这几个位置就永远都不能存放元素了,空间浪费相当大。也增加了添加元素是发生碰撞的机会。减慢了查询效率。所以Hashmap的大小是2的幂次。

为什么HashMap不是线程安全:因为它没有实现锁机制,在多线程环境下,多个线程同时对同一个HashMap进行修改或者读写时就发发送数据错误。

## hashmap扩容时的头插法和尾插法的区别,为什么头插法导致循环链表?

​ 头插法就是在HashMap在扩容时将数据从原来的map插入到新的map途中从链表的头部插入,而尾插法就是从直接添加到链表的尾部。
​ 头插法会颠倒原来一个散列桶里链表的顺序。在并发的时候原来的顺序被另外一个线程a颠倒,而被挂起的线程b恢复后拿扩容前的节点和顺序继续完成第一次循环后,又遵循a线程扩容后的链表循序重新排列链表中的循序,最终形成环,其原因主要是因为空值的丢失。
​ 如果采用尾插法的话null不会丢失,就算是多线程导致扩展也不会导致死循环,,最多只是丢失一部分数据。

HashMap什么时候转为红黑树:

img

HashMap插入的原理

在这里插入图片描述

HashMap函数式怎么设计的:hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作。

为什么这样操作:

  1. 一定要尽可能降低hash碰撞,越分散越好;

  2. 算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

​ 1.8的优化

  1. 数组+链表改成了数组+链表或红黑树;

  2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;

  3. 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;

  4. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

为什么这样优化
防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;

如何解决线程不安全问题:

Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。
HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;
ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

ConcurrentHashMap分段锁实现原理

	ConcurrentHashMap成员变量使用volatile修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。
	CAS(Compare and swap):独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。CAS为保存三个值(变量的地址值,变量的值(copy),要更新的值)如果线程更新变量的时候发现变量对应的地址值和保存下来的变量的值不一致,就会将地址值对应的值替换copy的值然后进行更新,然后再与地址值对比。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值