HashMap

HashMap

HashMap的数据结构

JDK1.7的数据结构是数组+链表,JDK1.8之后数据结构就是数组+链表+红黑树,默认大小是16,加载因子是0.75

Java1.8之后,HashMap中的每一个节点都是用Node表示,Node是一个内部类

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
 }

在这里插入图片描述

其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询效率。

  • 数据元素通过映射关系,也就是hash,映射到桶数组对应索引的位置
  • 如果发生冲突,从冲突的位置会生成一个链表,插入冲突的元素
  • 链表长度>8并且数组长度>=64,链表转为红黑树
  • 红黑树节点个数小于<=6,红黑树转为链表

HashMap的put流程

在这里插入图片描述

  1. 首先进行哈希值的扰动,获取一个新的哈希值
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  1. 判断 tab 是否位空或者长度为 0,如果是则进行扩容操作
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  1. 根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  1. 判断 tab[i] 是否为树节点,否则向链表中插入数据,是则向树中插入节点
  2. 如果链表中插入节点的时候,链表长度大于等于 8,则需要把链表转换为红黑树
treeifyBin(tab, hash);
  1. 最后所有元素处理完成后,判断是否超过阈值threshold,超过则扩容

HashMap如何查找元素?

在这里插入图片描述

  1. 使用扰动函数,获取新的哈希值
  2. 计算数组下标,获取节点
  3. 当前节点和 key 匹配,直接返回
  4. 否则,当前节点是否为树节点,查找红黑树
  5. 否则,遍历链表查找

HashMap的哈希/扰动函数是如何设计的

HashMap 的哈希函数是先拿到 key 的 hashcode,是一个 32 位的 int 类型的数值,然后让 hashcode 的高 16 位和低 16 位进行异或操作。(>>>无符号右移,^异或)

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

设计目的:降低哈希碰撞的概率

为什么哈希/扰动函数能降低hash碰撞?

因为 key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。int 值范围为 -2147483648~2147483647,加起来大概 40 亿的映射空间。

只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。

假如 HashMap 数组的初始大小才 16,就需要用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

static int indexFor(int h, int length) {
     return h & (length-1);
}

为什么HashMap的容量是2的倍数呢?

  • 方便哈希取余:

将元素放在 table 数组上面,是用 取模(hash 值 % 数组大小)定位位置,而 HashMap 是用 hash 值 &(数组大小 - 1),却能和前面达到一样的效果,这就得益于 HashMap 的大小是 2 的倍数,2
的倍数意味着该数的二进制位只有一位为 1,而该数 - 1 就可以得到二进制位上 1 变成 0,后面的 0 变成 1,再通过 & 运算,就可以得到和 % 一样的效果,并且位运算比 % 的效率高得多

HashMap 的容量是 2 的 n 次幂时,(n-1) 的 2 进制也就是 1111111***111 这样形式的,这样与添加元素的 hash 值进行位运算时,能够充分的散列,使得添加的元素均匀分布在 HashMap 的每个位置上,减少
hash 碰撞。

  • 在扩容时,利用扩容后的大小也是 2 的倍数,将已经产生 hash 碰撞的元素完美的转移到新的 table 中去
if (++size > threshold)
    resize();

如果初始化HashMap,传入一个17的值,HashMap会如何处理?

简单来说,就是初始化时,传的不是 2 的倍数时,HashMap 会向上寻找离得最近的2的N次方,所以传入 17,但 HashMap 的实际容量是 32。

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    //负载因子
    this.loadFactor = loadFactor;
    //阀值 threshold ,通过⽅法tableSizeFor 进⾏计算,是根据初始化传的参数来计算的
    this.threshold = tableSizeFor(initialCapacity);
}

tableSizeFor用来计算正确的threshold

static final int tableSizeFor(int cap) {
    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;
}

HashMap扩容机制

HashMap 是基于数组 + 链表和红黑树实现的,但用于存放 key 值的桶数组的长度是固定的,由初始化参数确定。

那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是 jdk1.8 中的优化操作,可以不需要再重新计算每一个元素的哈希值。

img

所以在扩容时,只需要看原来的 hash 值新增的那一位是 0 还是 1 就行了,是 0 的话索引没变,是 1 的化变成原索引+oldCap

JDK1.8对HashMap主要做了哪些优化?

  1. 数据结构:数组 + 链表改成了数组 + 链表或红黑树

    原因:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)降为O(logn)

  2. 链表插入方式:链表的插入方式从头插法改成了尾插法

    简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。

    原因:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。

  3. 扩容 rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。

    原因:提高扩容的效率,更快地扩容。

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

  5. 散列函数:1.7 做了四次移位和四次异或,jdk1.8 只做一次。

    原因:做 4 次的话,边际效用也不大,改为一次,提升效率。

HashMap是线程安全的嘛?多线程下会有什么问题?

  • 多线程下扩容死循环。

    原因:JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。

  • 多线程的 put 可能导致元素的丢失。

  • put 和 get 并发时,可能导致 get 为 null

如何解决HashMap线程不安全的问题?

  • HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个 table 数组,粒度比较大;

  • Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;

  • ConcurrentHashMap 在 jdk1.7 中使用分段锁,在 jdk1.8 中使用 CAS+synchronized。

HashMap 内部节点是有序的吗?

HashMap 是无序的,根据 hash 值随机插入。如果想使用有序的 Map,可以使用 LinkedHashMap 或者 TreeMap。

那为什么转回链表节点是用的6而不是复用8?

如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。

为什么负载因子是0.75而不是其他?

如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。
链表节点是用的6而不是复用8?

如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。

为什么负载因子是0.75而不是其他?

如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员小赵OvO

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值