HashMap 硬核 10 连问

HashTable vs HashMap vs TreeMap vs LinkedHashMap

Map 接口主要有四个常用的实现类,分别是 HashMapHashtableLinkedHashMapTreeMap,类继承关系如下图所示:

在这里插入图片描述下面针对各个实现类的特点做一些说明:

  • HashMap:
    • 它根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。
    • HashMap 最多只允许一条记录的键为null,允许多条记录的值为 null
    • HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。
    • 如果需要满足线程安全,可以用 CollectionssynchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap
  • Hashtable:
    • Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary
    • 并且 Hashtable 是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap ,因为ConcurrentHashMap 引入了分段锁。
    • 不建议在新代码中使用 Hashtable,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
  • LinkedHashMap:
    • LinkedHashMap 是 HashMap 的一个子类
    • LinkedHashMap 保存了记录的插入顺序,在用 Iterator 遍历 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
  • TreeMap:
    • TreeMap 实现 SortedMap 接口;
    • TreeMap 能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,
    • 当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap。
    • 在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造TreeMap 传入自定义的 Comparator,否则会在运行时抛出java.lang.ClassCastException 类型的异常。

HashMap 的底层原理

HashMap 基于 hashing 原理。

  • JDK 8 后采用数组+链表+红黑树的数据结构。数组中的每个元素由 Node 内部类实现。
  • 我们通过 put()get() 方法储存和获取对象。

存储对象时,将 K/V 键值传给 put() 方法:

  1. 计算关于 K 的 hash 值(与 K.hashCode 的高 16 位做异或运算)
  2. HashMap 使用的是懒加载方式,散列表为空时,调用 resize() 初始化散列表
  3. 如果没有发生碰撞,直接添加元素到散列表中去
  4. 如果发生了碰撞(hashCode值相同),进行三种判断
    • 若 key 地址相同或者 equals 后内容相同,则替换旧值
    • 如果是红黑树结构,就调用树的插入方法
    • 链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阈值为 8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。
  5. 如果桶满了大于阀值,则 resize 进行扩容。

为什么使用链表?

要知道为什么使用链表,首先要知道哈希冲突是如何来的。

大家都知道数组的长度是有限的,在有限的长度里面使用哈希函数计算数组下标时,很有可能插入的 k 值不同,但所产生的数组下标是相同的(也叫做哈希碰撞),这也就是哈希函数存在一定的概率性。此时我们就可以使用链表来对其进行链式的存放。当出现 hash 值一样的情形,就在数组上的对应位置形成一条链表。

这种解决哈希碰撞的方法也叫做链地址法

hash 冲突你还知道哪些解决办法?

  • 开放定址法
  • 链地址法
  • 再哈希法
  • 公共溢出区域法

为什么 HashMap 在链表元素数量超过 8 时候改为红黑树?

我们知道 Java 8 后,当链表长度大于或等于阈值 TREEIFY_THRESHOLD(默认为 8)的时候,如果同时还满足容量(数组的长度)大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。

同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 以后,又会恢复为链表形态。

首先要知道为什么要转换为红黑树?

每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。

红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))。最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。

所以为了提升查找性能,需要把链表转化为红黑树的形式。

那为什么不一开始就用红黑树,反而要经历一个转换的过程呢?

JDK 的源码注释中已经对这个问题作了解释:

在这里插入图片描述
大意是:因为树节点(TreeNodes)所占的空间是普通节点的两倍,所以我们只有在桶中包含足够的节点时才使用树节点(请参阅TREEIFY_THRESHOLD)(只有在同一个哈希桶中的节点数量大于等于TREEIFY_THRESHOLD 时,才会将该桶中原来的链式存储的节点转化为红黑树的树节点)。并且当桶中的节点数过少时 (由于移除或调整),树节点又会被转换回普通节点(当桶中的节点数量过少时,原来的红黑树树节点又会转化为链式存储的普通节点),以便节省空间。

从链表转化为红黑树的阈值为什么是 8?

通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想。

在源码中也对选择 8 这个数字做了说明,原文如下:

在这里插入图片描述大意是,如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。

在理想情况下,桶(bins)中的节点数概率(链表长度)符合泊松分布,当桶中节点数(链表长度)为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以一般情况下,并不会发生从链表向红黑树的转换。

但是,HashMap 决定某一个元素落到哪一个桶里,是和这个对象的 hashCode 有关的,JDK 并不能阻止我们用户实现自己的哈希算法,如果我们故意把哈希算法变得不均匀,例如:

在这里插入图片描述
综上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

知道 hash 的实现吗?为什么要这样实现?

在 Java 1.8 的实现中,是通过对 key 对象的 hashCode 进行扰动,即使用 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16)

扰动是为了让 hashCode 的随机性更高,第二步取模就不会让所以的 key 都聚集在一起,提高散列均匀度,同时不会有太大的开销。​

谈一下hashMap什么时候需要进行扩容,以及扩容的实现

调用场景

  • 初始化数组 table;
  • 当数组 table 的 size 达到阈值时即 ++size > loadfactor * capacity 时,也是在 putVal 函数中。

实现过程

通过判断「旧数组的容量」是否「大于 0」 来判断数组是否初始化过。

  • 否:进行初始化

    • 判断是否调用无参构造器
      • 是:使用默认的大小和阈值
      • 否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor 计算后的 2 的次幂数
  • 是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中。

概括的讲:扩容需要重新分配一个新数组,新数组是老数组的 2 倍长,然后遍历整个老结构,把所有的元素挨个重新 hash 分配到新结构中去。

可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作。

为什么 HashMap 加载因子是 0.75?

从上文我们知道,HashMap 的底层其实也是哈希表(散列表),而解决冲突的方式是链地址法。

HashMap 的初始容量大小默认是 16,为了减少冲突发生的概率,当HashMap 的数组长度到达一个临界值的时候,就会触发扩容,把所有元素rehash 之后再放在扩容后的容器中,这是一个相当耗时的操作。

而这个临界值就是由加载因子和当前容器的容量大小来确定的:

临界值 = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR

即默认情况下是 16x0.75=12 时,就会触发扩容操作。

那么为什么选择了 0.75 作为 HashMap 的加载因子呢?

通常,加载因子需要在时间和空间成本上寻求一种折衷。

加载因子是表示 Hash 表中元素的填满的程度。加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。

反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。

冲突的机会越大,则查找的成本越高。反之,查找的成本越小。

因此,必须在冲突的机会与空间利用率之间寻找一种平衡与折衷。

为什么HashMap的数组长度为2的整数次幂,又是怎样实现的?

修改数组长度有两种情况:

  • 初始化时指定的长度
  • 扩容时的长度增量

先看第一种情况。默认情况下,如未在 HashMap 构造器中指定长度,则初始长度为16。16 是一个较为合适的经验值,他是 2 的整数次幂,同时太小会频繁触发扩容、太大会浪费空间。如果指定一个非2 的整数次幂,会自动转化成大于该指定数的最小 2 的整数次幂。如指定 6 则转化为8,指定 11 则转化为 16。

第二种改变数组长度的情况是扩容。HashMap 每次扩容的大小都是原来的两倍,控制了数组大小一定是 2 的整数次幂。

为什么HashMap的数组长度为 2 的整数次幂?

先说结论:为了加快哈希计算以及减少哈希冲突。

为什么可以加快计算?

我们都知道为了找到 k 的位置在哈希表的哪个槽里面,需要计算 hash(k) % 数组长度。但是 % 计算比 & 慢很多。当 length 为 2 的次幂时,num & (length - 1) = num % length 等式成立。具体原理可以看下一文弄懂 HashMap 中的位运算

为什么可以减少冲突?

以 length 为 8 为例(默认 HashMap 初始数组长度是16),那 8-1 转成二进制的话,就是 0111

那我们举一个随便的 hashCode 值 1010 1001,与 0111 进行与运算看看结果如何:

1010 1001
&
0000 0111
=
0000 0001

这样得到的数,就会完整的得到原 hashcode 值的低位值,不会受到与运算对数据的变化影响。

如过当 length = 15 时,转换为二进制为 1111length - 1 = 1110length - 1 的二进制数最后一位为 0,因此它与任何数进行与操作的结果,最后一位也必然是 0,也即结果只能是偶数,不可能是单数,这样的话单数桶的空间就浪费掉了。

同理:length = 12,二进制为 1100length - 1 的二进制则为 1011,那么它与任何数进行与操作的结果,右边第 2 位必然是 0,这样同样会浪费一些桶空间。

因此,length 取 2 的整数次幂,是为了使不同 hash 值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

线程安全问题

我们都知道 HashMap 是线程不安全的,在多线程环境中不建议使用,但是其线程不安全主要体现在什么地方呢。

jdk1.7 扩容导致的死循环问题

在 jdk1.8 中对 HashMap 做了很多优化,这里先分析在 jdk1.7 中的问题,相信大家都知道在 jdk1.7 多线程环境下 HashMap 容易出现死循环,这里我们先用代码来模拟出现死循环的情况:

public class HashMapTest {

    public static void main(String[] args) {
        HashMapThread thread0 = new HashMapThread();
        HashMapThread thread1 = new HashMapThread();
        HashMapThread thread2 = new HashMapThread();
        HashMapThread thread3 = new HashMapThread();
        HashMapThread thread4 = new HashMapThread();
        thread0.start();
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

class HashMapThread extends Thread {
    private static AtomicInteger ai = new AtomicInteger();
    private static Map<Integer, Integer> map = new HashMap<>();

    @Override
    public void run() {
        while (ai.get() < 1000000) {
            map.put(ai.get(), ai.get());
            ai.incrementAndGet();
        }
    }
}

上述代码比较简单,就是开多个线程不断进行 put 操作,并且 HashMap 与 AtomicInteger 都是全局共享的。在多运行几次该代码后,出现如下死循环情形:

在这里插入图片描述

其中有几次还会出现数组越界的情况:

在这里插入图片描述
这里我们着重分析为什么会出现死循环的情况,通过 jps 和 jstack 命名查看死循环情况,结果如下:

在这里插入图片描述

从堆栈信息中可以看到出现死循环的位置,通过该信息可明确知道死循环发生在 HashMap 的扩容函数中,根源在 transfer 函数中,jdk1.7 中 HashMap 的 transfer 函数如下:

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);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

总结下该函数的主要作用:

在对 table 进行扩容到 newTable 后,需要将原来数据转移到 newTable 中,注意 10-12 行代码,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。

具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。

数据覆盖问题

两个线程执行 put() 操作时,可能导致数据覆盖。JDK1.7 版本和 JDK1.8 版本的都存在此问题,这里以JDK1.8 为例。

JDK1.8 中,由于多线程对 HashMap 进行 put 操作,调用了 HashMap#putVal()

具体原因:假设两个线程 A、B 都在进行 put 操作,并且 hash 函数计算出的插入下标是相同的。

在这里插入图片描述

  • 当线程 A 执行完上图蓝框中的代码后,由于时间片耗尽导致被挂起。
  • 而线程B 得到时间片后在该下标处插入了元素,完成了正常的插入;
  • 然后线程 A 获得时间片,由于之前已经进行了hash 碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程 B 插入的数据被线程 A 覆盖了,从而线程不安全。

总结来说,HashMap线程不安全的体现:

  • JDK1.7 HashMap 线程不安全体现在:死循环、数据丢失
  • JDK1.8 HashMap 线程不安全体现在:数据覆盖。

如何规避 HashMap 的线程不安全?

  • 使用 Collections.SynchronizedMap()
  • 使用 ConcurrentHashMap
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值