HashMap 与 ConcurrentHashMap

HashMap 的重要属性

HashMap的重要属性(如上图):

1、size属性:是HashMap中实际存在的键值对数量。注意,size 与 table数组的长度length是不一样的。

2、散列表

transient Node<K,V>[] tabletable桶数组(也叫 哈希数组、哈希槽位 数组、散列表) 中的一个 元素,常常被称之为 一个 槽位 slotNode类作为HashMap中的一个内部类,每个 Node 包含了一个 key-value 键值对。其中的next属性就是在哈希冲突之后,使用链地址法解决哈希冲突。

如下图所示,散列表(table桶数组)中的每个元素就是一个Node

散列表(table桶数组)的长度length(Capacity)默认值是16

注意:

3、负载因子(loadFactor)和边界值(threshold)

Node[] table的初始化长度length(默认值是16), loadFactor 为负载因子(默认值是0.75)(也可以叫做 加载因子), threshold是HashMap所能容纳的最大数据量的Node 个数。

三者之间的关系:

threshold = length * Load factor,默认情况threshold = 16 * 0.75 =12

注意,hashmap 在扩容时,主要看的是threshold边界值,它是允许哈希数组能存放元素的最大限制。而不是由length决定的

4、modCount属性

在集合类的源码里,像HashMap、TreeMap、ArrayList、LinkedList等都有modCount 属性,字面意思就是修改次数。这里的修改次数只包括:新增元素、删除元素,而不包括修改元素的value。

问答题

问:为什么负载因子(loadFactor)默认是 0.75?

答:首先要知道,负载因子(loadFactor)默认是 0.75,它是可以调整的(通过hashmap的构造函数传参)。它的值含义是:决定什么时候开始扩容,比如设置为0.75时,则说明现有元素个数 达到 table数组长度length的 3/4时,就需要扩容了。

默认为0.75的原因是:

  1. 负载因子 越大,则说明对空间利用越充分。但与此同时发生哈希碰撞几率越大(因为数组容量就这么多,元素越多碰撞可能性越大),查找效率越低;
  2. 负载因子 越小,哈希碰撞几率减小,但空间利用率就下降了,查找效率越高。
  3. 默认选择0.75是一个平衡的选择,并且HashMap 的容量(length/capacity)总是2的幂,这样可以使容量乘以 0.75 后仍能得到一个整数,简化了容量管理,并且有助于优化哈希算法中的位运算。

为什么hashmap的容量(length/capacity)必须是2的幂?

答:主要是为了加快计算元素的索引位置

  • 当hashmap放入一个元素时,计算其索引位置的公式为:index = hashCode % capacity%是取模运算)。而取模运算是一个除法运算,过程比较复杂耗时。
  • 而二进制的与运算,是直接在二进制级别操作,要比取模运算速度快。
  • 而当我们要对某个数字进行取模运算,且模数是 2^n 的幂次方 时,可以使用位运算代替取模运算。这是因为取模运算的结果是余数,而二进制与运算只会计算最低的n位,而 2^n 的取模结果只与这些位有关。

为什么使用迭代器遍历hashmap时,删除元素会报异常?

当用迭代器遍历HashMap的时候,调用HashMap.remove()方法时, 会产并发修改的异常ConcurrentModificationException。

分析:

  • 这是因为hashmap的remove()方法改变了的元素个数,此时hashmap的modCount属性的值会自增+1。

  • 在迭代器中的的next()方法内部会判断modCountexpectedModCount是否一致,如果不一致则抛出异常。

解决办法:所以迭代器遍历时, 如果想删除元素, 需要通过迭代器的删除方法进行删除, 这样下一次迭代操作,才不会出现异常。

  • 这是因为通过迭代器进行remove操作时,会重新赋值expectedModCount。 这样下一次迭代操作,才不会抛出 并发修改的异常ConcurrentModificationException

hashmap的 put() 方法流程

  1. 计算哈希值(hash(key) & (n - 1)):对传入的键进行哈希运算。HashMap内部会调用一个hash()方法,基于键的hashCode()方法进行进一步处理。这个步骤的目的是为了减少哈希冲突,同时分布哈希值
  2. 查找桶的位置(索引)int index = (n - 1) & hash;(n 是散列表数组的大小):通过计算出的哈希值定位到哈希表中的某个“桶”(bucket),桶就是数组的某个索引位置。
  3. 检查该位置是否有元素:如果该位置为空,说明这是一个新位置,可以直接插入新的键值对。如果该位置已经有元素存在,说明发生了哈希冲突
    1. 如果此时桶中是链表,那么遍历该链表。①如果找到一个节点的键与传入的键相等(通过equals()方法),则更新该节点的值。②如果链表中没有相同的键,则将新节点添加到链表的末尾。
    2. 如果此时桶中是红黑树,则在红黑树中查找该key是否存在。①已存在,则只用更新该key对应的value。②如果不存在,则插入该键值对。
  4. 扩容的检测:每次插入新元素后,HashMap会检查当前元素的数量是否超过了threshold(扩容阈值,等于容量乘以负载因子)。如果超过阈值,HashMap会进行扩容,将哈希表的容量增加到原来的两倍,并重新计算所有键的哈希值和位置。(如果链表长度超过一定阈值(通常为8),将链表转换为红黑树)
  5. 更新大小和修改计数:更新hashmap的size属性、modCount属性自增+1。

hashmap的 remove() 方法流程?

  1. 计算键的哈希值int hash = hash(key=hashCode());:首先,调用hash()方法对传入的键计算哈希值。这个哈希值基于键的hashCode(),目的是确定该键在哪个桶(bucket)中。hash()方法会对键的hashCode()值进行进一步处理,以减少哈希冲突。
  2. 确定索引,定位到哈希表中的桶int index = (n - 1) & hash:根据计算出的哈希值,确定该键应该存储在哪个桶中。桶的位置是通过哈希值与哈希表数组的长度取模计算得出。(n是哈希表的大小(即table.length),index是计算出的数组下标)
  3. 根据索引,查找并移除键值对:根据索引查找对应的链表或红黑树。
    1. 如果是链表:遍历链表中的每个节点。对于每个节点,比较哈希值和键是否与要删除的键匹配。如果找到匹配的节点,则从链表中移除该节点。
    2. 如果是红黑树:如果桶中存储的是红黑树结构,则会通过红黑树的查找和删除机制找到匹配的节点,然后删除该节点。删除操作会遵循红黑树的调整规则,以保持平衡。
  4. 更新大小、修改计数:更新的hashmap的size属性、修改计数modCount属性。

HashMap 的扩容机制是怎样的?

扩容时机:当元素数量超过阈值threshold时会触发扩容,每次扩容的容量都是之前容量的2倍。

  • 使用空参构造函数,会是默认值:默认容量capacity=16、默认负载因子loadFactor=0.75、默认阈值threshold=12。
  • hashmap实例化时(new HashMap()),其散列表(table数组)是没有被创建的。使用的是懒加载方式,在第一次调用put()时才会创建该数组

但是hashmap的容量(capacity)是有上限的,必须小于1<<30。如果容量超出了这个数,则不再增长,且阈值会被设置为 Integer.MAX_VALUE

  1. 如果是第一次调用put()方法:会按照构造函数传入的参数(如果没传就使用默认值)来构建table数组。
  2. 如果不是第一次扩容,则容量(capacity)变为原来的2倍,阈值threshold 也变为原来的2倍。

HashTable不允许key和value为null?

HashTable和HashMap的实现原理几乎一样,差别无非是:HashTable不允许key和value为null,且hashtable是线程安全的。下面说说它的特点:

  1. 是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是 synchronized的,这相当于给整个哈希表加了一把大锁。
  2. HashTable不允许key和value为null的原因:在多线程环境下,如果允许null作为键或值,可能会导致难以追踪的错误。例如,当一个线程尝试获取与null键相关的值时,可能会引发NullPointerException,这会使错误的排查变得困难。因此也是为了简化实现。

ConcurrentHashMap

JDK1.7中

JDK1.7中,它的实现方式是:segment分片锁数组+HashEntry数组 + 链表

并发控制方式:segment分片锁继承于ReentrantLock,来保证它的线程安全 的,它每次只会对一段锁进行加锁,从而提高它的并发度。

当线程占用锁访问某段数据时,其他线程可以并发的访问其他段的数据。只要不是并发访问同一段数据,就不会出现锁竞争的情况。

jdk1.7 ConcurrentHashMap结构

  • segment数组中每一个位置代表一个分片锁,一个分片锁管理一段hashEntry数组(hashEntry就是 hashmap中的Node);

JDK1.8中

JDK1.8中,摒弃了Segment分段锁,是直接使用 **Nodes数组+链表+红黑树**的形式实现(和在JDK1.8中的HashMap )。

并发控制方式:使用**Synchronized锁 + CAS机制**。
在这里插入图片描述

  • 改进点:在JDK1.8中,是在链表或红黑树的**节点级别(table数组的一个节点)**上使用 Synchronized 锁,因此只有当多个线程同时访问同一个节点时,才会通过 Synchronized 锁进行同步。

Synchronized锁 + CAS机制 协同工作的具体细节:

  1. 初始化阶段:无需同步。当 ConcurrentHashMap 第一次被使用时,它的数组初始状态为空,此时不需要任何同步机制。各个线程可以独立地判断数组是否为空,并决定是否进行初始化操作。这种无锁的初始化方式避免了在一开始就引入同步开销,提高了并发性能

  2. 插入操作

    1. 优先无锁尝试(CAS):当一个线程要插入一个新的键值对时,首先通过哈希函数确定其在数组中的位置。如果该位置为空,线程会尝试使用 CAS 操作将新的节点直接设置为该位置的头节点。
    2. 同步处理竞争(Synchronized):如果多个线程同时尝试插入到同一个位置,并且 CAS 操作失败,说明存在竞争。此时,线程会获取该位置对应的链表或红黑树的头节点的 Synchronized 锁。拥有锁的线程就可以安全的遍历 链表或红黑树,找到合适的位置插入新节点。
  3. 更新操作:

    1. 优先使用 CAS:当一个线程要更新一个已存在的节点的值时,首先会尝试使用 CAS 操作直接更新节点的引用。如果 CAS 操作成功,说明没有其他线程同时修改该节点,更新完成。
    2. 同步处理竞争(Synchronized):如果 CAS 操作失败,说明可能有其他线程正在修改该节点或者链表 / 红黑树的结构发生了变化。此时,线程会获取该节点的 Synchronized 锁。拥有锁的线程可以安全地进行更新操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值