HashMap常见问题

一. 问题背景

上一篇HashMap源码分析大概了解了HashMap的数据结构,以及实现原理。对常见的问题作一下总结。本文仅供笔者自己参考,如有不正确地方请指正,谢谢大家。

二. 常见的问题

2.1 HashMap如何解决hash冲突,为什么HashMap中的链表需要转成红黑树?

  1. hashmap采用链地址法链接拥有形同hash值的数据。链表的最坏查询情况是O(n),而红黑树是一颗二叉查找树,最坏的查询情况是O(logN)。当桶的链表变长,查询性能也跟着下降。而使用红黑树,查询性能能大大提高。对于增删操作,因为红黑树不是绝对平衡,它允许局部平衡,省去了很多没必要的左旋右旋的平衡操作,所以增删操作也比平衡树要高。
  2. 当hashmap中的键值对大于64,且桶的链表元素达到8个,且继续向该链表添加元素时,才会将链表转成红黑树来提高查询性能。
  3. 对hash值进行2次扰动(hash的高16位与低16位进行异或运算) 来降低hash冲突的概率,使数据均匀分布

总结:

  1. 使用链地址法(使用散列表)来链接拥有相同hash值的数据;
  2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
  3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;

2.2 HashMap什么时候会触发扩容?

putVal()方法中有这样一段代码:

if (++size > threshold)
    resize();

而size变量是这样的:

/**
 * The number of key-value mappings contained in this map.
 */
transient int size;

上面注释中说“size是hashmap中键值对的个数”。由此得出:当hashmap中的键值对的个数>阈值threshold时,会进行扩容。 这个阈值是用负载因子loadFactor*capacity获得的。比如负载因子是0.75,容量值为16,则hashmap中有0.75*16=12个键值对时,会进行扩容,扩大的容量为旧容量的2倍(旧容量左移一位)。

resize()首先判断旧容量是否已经达到默认的数组容量最大值(即2^30),如果达到则设置阈值为int的最大值,以后就不会再进行扩容了。

jdk1.7中重新插入到新数组的元素,如果原来一条链上的元素又被分配到同一条链上那么他们的顺序会发生倒置这个和1.8也不一样

在进行存键值对的时候,putVal()有下面这个代码:

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);

treeifyBin中有这样一段:

if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();

由此得出:当存键值对的时候,假如发生hash冲突,会检查桶的链表中的长度是不是为8且是不是在进行添加键值对,两者都为是 则 调用treeifyBin()方法。而treeifyBin()方法则会进行判断。当map实例的table数组为null或者table数组长度<MIN_TREEIFY_CAPACITY(默认为64),则会进行扩容。

2.3 HashMap的数组长度为什么要保证是2的幂?

  1. jdk1.7中有一个indexForxxx()方法用来计算键值对在数组中的下标。jdk1.8中没有这个方法,但也使用了表达式(n-1) & hash(其中n为数组的长度)代替这个方法。实现的功能和indexForxxx()方法一样。
  2. 假如数组长度为2的幂,那么(n-1)的二进制表示则是一个低位全为1,其余位补0的二进制串(如'0000111')。该串与hash做&运算,结果将取决于hash的低位值。hash的低位值若为1,则结果对应的位上就为1,否则为0(这是&运算的特点:两者为1才为1)。假如数组长度不为2的幂,那么(n-1)的二进制串的低位中肯定会有0,因此,不论hash低位上的值是0还是1,结果都会为0。这样就造成了一个性能问题:存在不同的hash值会得出相同的下标。也就是数组上的某些位置经常被拿来用,而某些位置则可能永远没被用。hashmap数组上的元素分布不均匀。
  3. 假如数组长度为2的幂,那么(n-1)的二进制表示则是一个全为1的二进制串。当低位全为1,其余位补0的二进制串(如'0000111')与另一个数做&运算时,只有当另一数的二进制位上为1,得到的结果的二进制位上才能为1。所以计算得出的下标结果最大也不会超过(n-1)的二进制,也就不会出现下标越界。
  4. hash算法怎么设计?首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

2.4 HashMap扩容时每个entry需要再计算一次hash吗?

jdk1.7的hashmap扩容时需要重新计算。jdk1.8的resize()中部分代码如下:
在这里插入图片描述

从上面可以看到jdk1.8中如果该桶没有链表只有一个元素,则和jdk1.7一样直接计算下标放置元素到新数组中;如果该元素是一个树节点则使用split()方法进行拆分。如果该桶有链表则采用如下的优化算法进行拆分。

在这里插入图片描述

因为扩容的方法是旧容量左移1位,即旧容量*2,即2次幂的扩展,所以元素要么在原来的位置,要么在原位置移动2次幂的位置(因为index=(n-1)&hash,所以n扩大为原来的2倍,则n-1也会扩大为原来的(n-1)两倍,即n-1的高位会多一个1)。(n-1)的高位多出的1,将会与hash进行运算,如果hash该位上为1,则&运算得出的结果也会在对应的位上位1,即比原来的下标移动了2次幂。原理如上图所示。

所以,如果该桶有链表,jdk1.8的源码中没有重新计算hash,而是使用(e.hash & oldCap) == 0来判断hash对应新增的(n-1)的二进制位上的值是1还是0。如下:

if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}

在这里插入图片描述

2.5 为什么HashMap桶中的个数超过8才转为红黑树?

首先要明白为什么要转换? HashMap桶中的元素初始化是用链表保存的,而链表的查询性能为O(n),红黑树能将查询性能提升到O(logN)。当链表的长度很小的时候,即使遍历,速度也是非常快的。但当链表长度不断增大的时候,查询性能肯定会受到一定的影响(下降)。所以才需要转为红黑树。那么为什么链表长度的阈值是8呢?

首先定义链表长度为8的阈值如下:

/**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

上面这里并没有解释为什么设置为8,再看看其他注释,HashMap开头有一段Implementation notes.如下:

     This map usually acts as a binned (bucketed) hash table, but
     when bins get too large, they are transformed into bins of
     TreeNodes, each structured similarly to those in
     java.util.TreeMap.

意思大概是当bin变得很大的时候,就会被转为TreeNodes中的bin,其结构和TreeMap相似,也就是红黑树。

Implementation notes.继续往下看有如下这一段:

      Because TreeNodes are about twice the size of regular nodes, we
      use them only when bins contain enough nodes to warrant use
      (see TREEIFY_THRESHOLD). And when they become too small (due to
      removal or resizing) they are converted back to plain bins.

意思就是 由于TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多则由TREEIFY_THRESHOLD决定。 当bin中的节点变少时,又会转成普通的bin。链表长度达到8继续往其链上一个节点时就转成红黑树,当树的节点数降回到6(由UNTREEIFY_THRESHOLD决定)又转回普通的链表。

这样就解释了为什么不是一开始就是将其转换为TreeNodes,而是需要一定的节点数才转为TreeNodes,也就是需要trade-off,空间和时间的权衡。Implementation notes.中有如下这样一段解释:

     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

意思就是说 因为TreeNodes的占用空间是普通节点的2倍,仅当有足够的节点时才会适当地将普通节点转为TreeNodes。当桶的元素变得很少时又转回普通的Node。当hashCode离散型很好的时候,树型bin很少概率被用到。因为数据均匀分布在每个桶中,几乎不会有bin中链表长度达到阈值。但是在随机的hashCode的情况下,离散型可能会变差,然而jdk又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。理想的情况下,随机hashCode算法下所有bin中的节点分布频率会遵循泊松分布,从上面数据中可以看到,一个bin中的链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以选8是根据概率统计决定的。

2.6 jdk1.8之前并发操作HashMap时为什么会有死循环的问题?

参考自:

综合参考上面2篇文章,写下此笔记,仅供自己参考,如有错误请指正。

首先造成死循环,肯定和链表有关,只有链表才有指针。

出现死循环的起源是使用了hashmap的get()方法,那么既然是get()方法出现死循环,一定是与put进去元素的位置有关。所以从put()方法看起,如下:

在这里插入图片描述

从上面的put()方法可以看到,除了addEntry()方法不知道具体实现是怎么样的,其他都没涉及指针,所以我们进入addEntry()方法看看,如下:

在这里插入图片描述

从上面看到,有resize()方法和createEntry()方法不知道具体实现,点进去createEntry()方法看看,如下:

在这里插入图片描述

createEntry()方法没有涉及指针,则resize()方法很有可能涉及指针,如下:

在这里插入图片描述

从上面看到transfer()有可能涉及指针,点进去看看,如下:

在这里插入图片描述

可以看到,上面transfer()方法确实涉及了指针,在代码602行~604行。分析得出 transfer()方法是用来copy旧数组到新数组的。 在jdk1.8之前,当数组上的某个桶的位置是链表时,在copy的时候使用的是头插法,即旧数组的某个链表是1->2->3,那么新数组的链表将会倒置,变成3->2->1。 死锁的问题不就是1指向2(即1->2)的 同时 2指向1(即2->1)造成的吗?

2.7 出现死锁的情况

储备知识:什么是线程不安全?
答:线程操作数据时先从主存copy一份到线程的局部存储中,后续的操作都是对本地存储进行,只在必要时才会刷新主存,这样多个线程并发操作时就会出现数据不一致问题。

假设oldTable数组的情况是a,b,c的hash都是一样的,如下:
在这里插入图片描述

现有线程1和线程2,它们在自己的线程本地存储区里都有属于自己的newTable,如下:

在这里插入图片描述

为了方便对比,再拿出transfer()方法来分析,如下:

在这里插入图片描述

线程2开始执行,执行到595行代码,此时e = a;执行到597next = e.next;next = a.next;next = b;

此时线程2的cpu时间片用完了,轮到线程1执行,线程1把链表全都遍历执行完了。此时newTable[i]的元素都是旧数组链表的倒置。如下:

在这里插入图片描述

此时线程1的cpu时间片用完了,内部的newTable还没有设置成table(hashmap的全局成员变量),此时两个线程的情况如下:

在这里插入图片描述

线程2开始执行,刚才线程2情况是596行e = a;执行到597next = e.next;next = a.next;next = b;线程2开始执行剩余的循环体,如下:

    Entry<K,V> next = e.next;
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;

即b指向newTable[i],newTable[i]指向a;情况如下:

在这里插入图片描述

从上图看到,变量e指向了b,因为e不是null,所以继续执行transfer()方法里面的while循环。

    Entry<K,V> next = e.next;
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;

即a指向了newTable[i],newTable[i]指向了b;情况如下:

在这里插入图片描述

从上图看到,变量e又指向了a,a不是null,继续执行循环体。分析过程见下面代码的注释部分

    Entry<K,V> next = e.next;//目前a节点没有next,所以next指向null
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];//从上图看到线程2的newTable[i]指向b,
                         //所以a的next指向newTable[i]就是a的next指向b,这样a和b就互相引用,
                         //形成了一个环
    newTable[i] = e;//把节点a放到了数组newTable[i]的位置
    e = next;//因为第一部next就指向了null,所以e指向了null,所以退出while循环

最终的引用关系如下:

在这里插入图片描述

从上图看到,a和b形成了环,当使用hashmap实例在该数组位置get寻找对应的key时,就发生了死循环。

另外如果线程2把newTable设置成hashmap的table时,节点c的数据被丢了。有数据遗失的问题。 要用并发就使用ConcurrentHashMap。

2.8 HashMap是否线程安全

首先HashMap是线程不安全的,其主要体现:

  1. 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。

  2. 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

2.9 hashCode()与equals()的相关规定:

  1. 如果两个对象相等,则hashcode一定也是相同的
  2. 两个对象相等,对两个equals方法返回true
  3. 两个对象有相同的hashcode值,它们也不一定是相等的

综上,equals方法被覆盖过,则hashCode方法也必须被覆盖hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。

2.10 HashMap与HashTable

  1. 线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  3. 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同: ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。

2.11 ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

结构如下:
在这里插入图片描述

  • 2
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值