HashMap 的重要属性
HashMap的重要属性(如上图):
1、size
属性:是HashMap中实际存在的键值对数量。注意,size
与 table数组的长度length是不一样的。
2、散列表
transient Node<K,V>[] table
:table桶数组(也叫 哈希数组、哈希槽位 数组、散列表) 中的一个 元素,常常被称之为 一个 槽位 slot。Node
类作为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的原因是:
- 负载因子 越大,则说明对空间利用越充分。但与此同时发生哈希碰撞几率越大(因为数组容量就这么多,元素越多碰撞可能性越大),查找效率越低;
- 负载因子 越小,哈希碰撞几率减小,但空间利用率就下降了,查找效率越高。
- 默认选择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()
方法内部会判断modCount
和expectedModCount
是否一致,如果不一致则抛出异常。
解决办法:所以迭代器遍历时, 如果想删除元素, 需要通过迭代器的删除方法进行删除, 这样下一次迭代操作,才不会出现异常。
- 这是因为通过迭代器进行
remove
操作时,会重新赋值expectedModCount
。 这样下一次迭代操作,才不会抛出 并发修改的异常ConcurrentModificationException
:
hashmap的 put()
方法流程
- 计算哈希值
(hash(key) & (n - 1))
:对传入的键进行哈希运算。HashMap
内部会调用一个hash()
方法,基于键的hashCode()
方法进行进一步处理。这个步骤的目的是为了减少哈希冲突,同时分布哈希值。 - 查找桶的位置(索引)
int index = (n - 1) & hash;
(n 是散列表数组的大小):通过计算出的哈希值定位到哈希表中的某个“桶”(bucket),桶就是数组的某个索引位置。 - 检查该位置是否有元素:如果该位置为空,说明这是一个新位置,可以直接插入新的键值对。如果该位置已经有元素存在,说明发生了哈希冲突。
- 如果此时桶中是链表,那么遍历该链表。①如果找到一个节点的键与传入的键相等(通过
equals()
方法),则更新该节点的值。②如果链表中没有相同的键,则将新节点添加到链表的末尾。 - 如果此时桶中是红黑树,则在红黑树中查找该key是否存在。①已存在,则只用更新该key对应的value。②如果不存在,则插入该键值对。
- 如果此时桶中是链表,那么遍历该链表。①如果找到一个节点的键与传入的键相等(通过
- 扩容的检测:每次插入新元素后,
HashMap
会检查当前元素的数量是否超过了threshold
(扩容阈值,等于容量乘以负载因子)。如果超过阈值,HashMap
会进行扩容,将哈希表的容量增加到原来的两倍,并重新计算所有键的哈希值和位置。(如果链表长度超过一定阈值(通常为8),将链表转换为红黑树) - 更新大小和修改计数:更新hashmap的
size
属性、modCount
属性自增+1。
hashmap的 remove()
方法流程?
- 计算键的哈希值
int hash = hash(key=hashCode());
:首先,调用hash()
方法对传入的键计算哈希值。这个哈希值基于键的hashCode()
,目的是确定该键在哪个桶(bucket)中。hash()
方法会对键的hashCode()
值进行进一步处理,以减少哈希冲突。 - 确定索引,定位到哈希表中的桶
int index = (n - 1) & hash
:根据计算出的哈希值,确定该键应该存储在哪个桶中。桶的位置是通过哈希值与哈希表数组的长度取模计算得出。(n
是哈希表的大小(即table.length
),index
是计算出的数组下标) - 根据索引,查找并移除键值对:根据索引查找对应的链表或红黑树。
- 如果是链表:遍历链表中的每个节点。对于每个节点,比较哈希值和键是否与要删除的键匹配。如果找到匹配的节点,则从链表中移除该节点。
- 如果是红黑树:如果桶中存储的是红黑树结构,则会通过红黑树的查找和删除机制找到匹配的节点,然后删除该节点。删除操作会遵循红黑树的调整规则,以保持平衡。
- 更新大小、修改计数:更新的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
。
- 如果是第一次调用
put()
方法:会按照构造函数传入的参数(如果没传就使用默认值)来构建table数组。 - 如果不是第一次扩容,则容量(capacity)变为原来的2倍,阈值threshold 也变为原来的2倍。
HashTable不允许key和value为null?
HashTable和HashMap的实现原理几乎一样,差别无非是:HashTable不允许key和value为null,且hashtable是线程安全的。下面说说它的特点:
- 是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是
synchronized
的,这相当于给整个哈希表加了一把大锁。 - HashTable不允许key和value为null的原因:在多线程环境下,如果允许
null
作为键或值,可能会导致难以追踪的错误。例如,当一个线程尝试获取与null
键相关的值时,可能会引发NullPointerException
,这会使错误的排查变得困难。因此也是为了简化实现。
ConcurrentHashMap
JDK1.7中
在JDK1.7中,它的实现方式是:segment分片锁数组+HashEntry数组 + 链表
。
并发控制方式:segment分片锁继承于ReentrantLock,来保证它的线程安全 的,它每次只会对一段锁进行加锁,从而提高它的并发度。
当线程占用锁访问某段数据时,其他线程可以并发的访问其他段的数据。只要不是并发访问同一段数据,就不会出现锁竞争的情况。
- segment数组中每一个位置代表一个分片锁,一个分片锁管理一段hashEntry数组(hashEntry就是 hashmap中的Node);
JDK1.8中
在JDK1.8中,摒弃了Segment分段锁,是直接使用 **Nodes数组+链表+红黑树
**的形式实现(和在JDK1.8中的HashMap )。
并发控制方式:使用**Synchronized锁 + CAS
机制**。
- 改进点:在JDK1.8中,是在链表或红黑树的**节点级别(table数组的一个节点)**上使用 Synchronized 锁,因此只有当多个线程同时访问同一个节点时,才会通过 Synchronized 锁进行同步。
Synchronized锁 + CAS
机制 协同工作的具体细节:
-
初始化阶段:无需同步。当 ConcurrentHashMap 第一次被使用时,它的数组初始状态为空,此时不需要任何同步机制。各个线程可以独立地判断数组是否为空,并决定是否进行初始化操作。这种无锁的初始化方式避免了在一开始就引入同步开销,提高了并发性能。
-
插入操作:
- 优先无锁尝试(CAS):当一个线程要插入一个新的键值对时,首先通过哈希函数确定其在数组中的位置。如果该位置为空,线程会尝试使用 CAS 操作将新的节点直接设置为该位置的头节点。
- 同步处理竞争(Synchronized):如果多个线程同时尝试插入到同一个位置,并且 CAS 操作失败,说明存在竞争。此时,线程会获取该位置对应的链表或红黑树的头节点的 Synchronized 锁。拥有锁的线程就可以安全的遍历 链表或红黑树,找到合适的位置插入新节点。
-
更新操作:
- 优先使用 CAS:当一个线程要更新一个已存在的节点的值时,首先会尝试使用 CAS 操作直接更新节点的引用。如果 CAS 操作成功,说明没有其他线程同时修改该节点,更新完成。
- 同步处理竞争(Synchronized):如果 CAS 操作失败,说明可能有其他线程正在修改该节点或者链表 / 红黑树的结构发生了变化。此时,线程会获取该节点的 Synchronized 锁。拥有锁的线程可以安全地进行更新操作。