Map接口
# Map 集合详解
# HashMap 和 Hashtable 的区别
线程安全性:
HashMap
是非线程安全的,Hashtable
是线程安全的,因为 Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
吧!);
效率:
因为线程安全的问题,HashMap
要比 Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它;
对 null 键 和 null 值 的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
。
初始容量大小和每次扩充容量大小的不同 :
① 创建时如果不指定容量初始值,Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
② 创建时如果给定了容量初始值,那么 Hashtable
会直接使用你给定的大小,而 HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证,利用右位移运算)。也就是说 HashMap
总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
底层数据结构:
JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。
Hashtable
没有这样的机制。
# HashMap 和 HashSet 区别
用途:
- HashMap:用于存储键值对(Key-Value)映射关系的数据结构,其中每个键唯一对应一个值。
- HashSet:用于存储不重复元素的集合,它基于 HashMap 实现,只存储元素而没有键值对的映射关系。
存储方式:
- HashMap:存储键值对,通过键查找值。键可以是任何非空对象,值可以是任何对象。
- HashSet:存储不重复的元素,通过元素本身来进行查找和判重。
内部实现:
- HashMap:内部使用数组和链表(或红黑树)的组合来实现,通过哈希函数将键映射到数组的索引位置,以提高键的查找效率。
- HashSet:基于 HashMap 实现,它的元素就是 HashMap 的键,值则是一个固定的常量(一直是一个 Object 对象)。
操作和用法:
- HashMap:适用于需要存储键值对关系的情况,例如缓存、映射关系等。
- HashSet:适用于存储不重复元素的情况,例如需要快速判断某个元素是否存在等。
性能:
- HashMap:相对于 HashSet,HashMap 需要存储键值对,因此额外占用一些内存,但可以存储更多的信息。
- HashSet:相对于 HashMap,HashSet 只需要存储元素,占用的内存较少,但不能存储键值对关系。
总之,HashMap 适用于存储键值对关系,而 HashSet 适用于存储不重复元素的集合。实际上,HashSet 在内部使用了 HashMap 来实现,它们之间存在一定的关联和相似性。
# hashCode 源码
hashCode()
方法返回的值是一个 int
类型的数字,用于表示对象的哈希值。这个哈希值不一定是对象的地址,也不一定是唯一的。在计算哈希值时,一般会使用对象的属性来计算。例如,如果一个 Person
类具有 name
和 age
两个属性,那么可以将它们的哈希值合并起来计算:
public int hashCode() {
int result = 17;
result = 31 * result + name.hashCode();
result = 31 * result + age;
return result;
}
2
3
4
5
6
这段代码中使用了一个经典的哈希算法,称为“31 哈希法”。这个算法将初始值设为一个质数 17,然后将属性的哈希值依次乘以 31 并相加,得到最终的哈希值。由于 31 是一个奇素数,可以保证乘法过程不会产生哈希冲突。
需要注意的是,虽然哈希值不一定是地址,但是在 Java 中,如果没有为对象指定
hashCode()
方法的实现,那么默认情况下,它的hashCode()
方法会返回对象的地址,因此在这种情况下,两个对象的哈希值可能会相同,但这并不是一个好的哈希算法。因此,在实现自定义的哈希算法时,需要保证哈希值的分布尽可能均匀,并且不容易产生哈希冲突。
# 为什么哈希函数能降低哈希碰撞?
因为好的哈希函数可以将输入的数据均匀、随机地映射到哈希空间,降低了碰撞的可能性,从而提高了哈希表等数据结构的性能和稳定性。
# HashMap 和 TreeMap 区别
TreeMap
和HashMap
都继承自AbstractMap
,但是需要注意的是TreeMap
它还实现了NavigableMap
接口和SortedMap
接口。
实现 NavigableMap
接口让 TreeMap
有了对集合内元素的搜索的能力。
实现SortedMap
接口让 TreeMap
有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:
public class Person {
private Integer age;
public Person(Integer age) {
this.age = age;
}
public Integer getAge() {
return age;
}
public static void main(String[] args) {
TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
@Override
public int compare(Person person1, Person person2) {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
}
});
treeMap.put(new Person(3), "person1");
treeMap.put(new Person(18), "person2");
treeMap.put(new Person(35), "person3");
treeMap.put(new Person(16), "person4");
treeMap.entrySet().stream().forEach(personStringEntry -> {
System.out.println(personStringEntry.getValue());
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
可以看出,TreeMap
中的元素已经是按照 Person
的 age 字段的升序来排列了。
上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:
TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
});
2
3
4
综上,相比于HashMap
来说 TreeMap
主要多了对集合中的元素根据键排序的能力(SortedMap
)以及对集合内元素的搜索的能力(NavigableMap
)。
# HashSet 如何检查重复?
当你把对象加入
HashSet
时,HashSet
会先计算对象的hashcode
值来判断对象加入的位置,同时也会与其他加入的对象的hashcode
值作比较,如果没有相符的hashcode
,HashSet
会假设对象没有重复出现。但是如果发现有相同hashcode
值的对象,这时会调用equals()
方法来检查hashcode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让加入操作成功。
直接看一下HashSet
中的源码:
在 JDK1.8 中,实际上无论 HashSet 中是否已经存在了某元素,HashSet 都会直接插入,只是会在 add()
方法的返回值处告诉我们插入前是否存在相同元素
// Returns: true if this set did not already contain the specified element
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
2
3
4
5
HashSet 通过 HashMap 的键的唯一性来实现对元素的重复检查。在 HashSet 中,元素被存储为 HashMap 的键,而值都是固定的一个常量对象。当我们向 HashSet 添加元素时,实际上是将这个元素作为键,常量对象作为对应的值存储在 HashMap 中。
在 HashMap 中,键是唯一的,这意味着当我们尝试将相同的元素作为键添加到 HashMap 中时,新的元素会覆盖掉旧的元素。因此,当我们向 HashSet 添加元素时,实际上是在利用 HashMap 的去重特性来保证 HashSet 中的元素唯一性。
例如,考虑以下代码:
HashSet<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(3);
set.add(1); // 尝试添加重复元素
System.out.println(set.size()); // 输出为 3,因为重复元素被去重
2
3
4
5
6
在上述示例中,尝试向 HashSet 中添加重复的元素 1 时,并没有导致 HashSet 中出现重复元素,这是因为 HashSet 利用了 HashMap 的键唯一性来进行去重。
因此,HashSet 能够自动检查并防止重复元素的存在。
# HashMap的底层实现
# JDK1.8 之前
JDK1.8 之前 HashMap
底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode
经过扰动函数(hash方法)处理过后得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法(数组+链表)解决冲突。
# JDK1.8 之后
相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
# JDK1.7和JDK1.8的hash方法源码对比:
- JDK 1.8 的 hash 方法 (运用了三目运算符)相比于 JDK 1.7 hash 方法更加简化,但是原理不变;
- JDK 1.7 的 hash 方法的性能会稍差一点点,因为扰动了 4 次
使用扰动函数(hash方法)之后可以减少碰撞
# HashMap 的长度为什么是 2 的幂次方
这是因为 HashMap 在计算 key 的哈希值后,需要通过哈希值和数组长度计算出该 key 在数组中的位置。
具体的计算方式是 (n - 1) & hash
(数组的长度减一的差和 key 的哈希值相与),其中 n
是数组的长度,hash
是 key 的哈希值。
这个计算方式的作用是将哈希值的高位和低位进行一次异或运算,得到 key 在数组中的位置。 如果数组的长度不是 2 的幂次方,那么在计算 (n - 1) & hash
时,得到的结果可能会存在一些哈希值无法均匀分布到数组中的位置的情况,从而导致某些位置上的链表或红黑树会变得过长,影响 HashMap 的性能。因此,为了避免这种情况的发生,HashMap 的长度总是保持为 2 的幂次方。 另外,对于长度为 2 的幂次方的数组,计算 (n - 1) & hash
时,等价于对数组长度取模,这种计算方式的效率比较高,而且可以保证均匀分布。因此,HashMap 采用长度为 2 的幂次方的数组,可以提高 HashMap 的性能和效率。
# HashMap 多线程操作导致死循环问题
在多线程环境下,如果多个线程同时对 HashMap 进行操作,可能会导致 HashMap 的链表或红黑树出现环形,从而导致死循环问题。这种问题通常发生在以下情况下:
- 多个线程同时调用
put()
方法,导致链表或红黑树的结构发生变化,从而导致链表或红黑树出现环形。 - 多个线程同时调用
resize()
方法,导致数组扩容时链表或红黑树的结构发生变化,从而导致链表或红黑树出现环形。 当出现链表或红黑树环形时,会导致 HashMap 的遍历操作进入死循环,从而影响程序的性能和可靠性。
为了解决这个问题,可以采用以下几种方式:
- 使用线程安全的 ConcurrentHashMap,它是线程安全的 HashMap,可以安全地在多线程环境下进行操作。
- 对于需要在多线程环境下使用 HashMap 的情况,可以使用锁来保证同一时间只有一个线程能够对 HashMap 进行操作。
- 避免多个线程同时对 HashMap 进行操作,可以将对 HashMap 的操作放在同步块中,确保同一时间只有一个线程能够对 HashMap 进行修改。
- 通过使用线程安全的并发数据结构,例如 ConcurrentLinkedQueue、ConcurrentHashMap 等,避免在多线程环境下使用 HashMap。
# HashMap 有哪几种常见的遍历方式?
HashMap 的 7 种遍历方式与性能分析! (opens new window)
# ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap
和 Hashtable
都是线程安全的集合类,但是它们在实现和性能上有一些区别:
- 底层数据结构:
- JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。 Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
- JDK1.7 的
- 实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表 / 红黑树的数据结构来实现,并发控制使用synchronized
和CAS
来操作。(JDK1.6 以后synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable
(同一把锁): 使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
对 null 值的支持不同:
Hashtable
不允许key
为null
,- 而
ConcurrentHashMap
则**( JDK7 时)**允许key
和 value 均为null
,JDK8 后不再允许(因为使用了 CAS -- 保证线程安全的,对null
值的支持发生了变化)。
扩容机制不同:
Hashtable
在扩容时使用的是原来容量的 2 倍加 1,而ConcurrentHashMap
在扩容时采用的则是分段锁技术,当一个段需要进行扩容时,只需要锁定这个段,而不需要锁定整个哈希表。ConcurrentHashMap
在设计上更加注重并发性能,通过分段锁来减小锁粒度,而不是仅对一个段进行扩容。
# ConcurrentHashMap 线程安全底层具体实现
# JDK1.8 之前
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。
Segment
继承了 ReentrantLock
,所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据。
一个 ConcurrentHashMap
里包含一个 Segment
数组,Segment
的个数一旦初始化就不能改变。 Segment
数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
Segment
的结构和 HashMap
类似,是一种数组和链表结构,一个 Segment
包含一个 HashEntry
数组,每个 HashEntry
是一个链表结构的元素,每个 Segment
守护着一个 HashEntry
数组里的元素,当对 HashEntry
数组的数据进行修改时,必须首先获得对应的 Segment
的锁。也就是说,对同一 Segment
的并发写入会被阻塞,不同 Segment
的写入是可以并发执行的。
# JDK1.8 之后
- Java 8 几乎完全重写了
ConcurrentHashMap
,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。 ConcurrentHashMap
取消了Segment
分段锁,采用Node + CAS + synchronized
来保证并发安全。数据结构跟HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。- Java 8 中,锁粒度更细,
synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
# 锁粒度解析
锁粒度指的是锁定的范围大小,通常分为粗粒度锁和细粒度锁两种类型。
- 粗粒度锁 粗粒度锁指的是锁定范围较大的锁,通常是对整个对象或整个方法进行加锁。粗粒度锁的优点是实现简单,易于控制,缺点是锁定范围太大,会导致并发性能下降,降低程序的并发度。
- 细粒度锁 细粒度锁指的是锁定范围较小的锁,通常是对对象中的某个属性或某一段代码进行加锁。细粒度锁的优点是锁定范围小,可以提高程序的并发度,缺点是实现复杂,容易出现死锁等问题。
在实际开发中,应该根据实际情况选择合适的锁粒度。如果锁的范围太大,会导致并发性能下降,如果锁的范围太小,会增加锁的竞争,降低程序的并发度。可以通过压力测试等方式来确定合适的锁粒度。
# JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
线程安全实现方式 :
- JDK 1.7 采用
Segment
分段锁来保证安全,Segment
是继承自ReentrantLock
。 - JDK1.8 放弃了
Segment
分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。
Hash 碰撞解决方法 :
- JDK 1.7 采用拉链法,
- JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
并发度 :
- JDK 1.7 最大并发度是 Segment 的个数,默认是 16。
- JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
# 为什么 HashMap 链表转红黑树的阈值为 8 呢?
这是一个经验性的设定。这个设定是为了在哈希表中维护合适的性能和空间开销之间找到平衡。
选择 8 是基于一系列性能测试和实际应用场景得出的。当链表长度超过这个阈值时,链表的查找性能可能会下降,而转换为红黑树可以提高性能。然而,如果链表长度较短,转换为红黑树可能会带来额外的开销,所以选择一个合适的阈值是一个权衡。
# HashMap 的扩容机制了解吗?
了解。当 HashMap 中的元素数量达到一定阈值时,就会触发扩容操作,以保持哈希桶的负载因子在一个合适的范围内,从而减少哈希冲突,提高查找、插入和删除的性能。
HashMap 的扩容机制主要包括以下 3 个步骤:
- 创建新的哈希桶数组:当 HashMap 中的元素数量达到阈值时,创建一个新的数组,其大小是原数组的两倍。
- 将旧数据转移到新数组:遍历旧的哈希桶数组中的每个元素,将其重新计算哈希值后放入新的哈希桶数组中。这是一个耗时操作,但只需要进行一次。
- 更新引用:将 HashMap 的哈希桶数组引用指向新的数组,同时更新阈值等信息。
扩容操作会在 HashMap 的插入操作中触发,具体的触发时机是当元素数量超过了负载因子乘以当前数组容量时。默认情况下,负载因子为 0.75,即当元素数量超过当前数组容量的 75% 时,会触发扩容。
# 扩容在什么时候呢?为什么扩容因子是 0.75?
HashMap 在进行扩容的时候,通常是当当前容器中的元素数量超过了容器大小的 75% 时触发扩容操作。
这个扩容因子(load factor)的选择是为了在空间和时间上达到一个平衡,从而保证 HashMap 在不至于浪费过多内存的情况下,仍能保持较低的哈希冲突,提供高效的查找、插入和删除操作。
为什么扩容因子选择 0.75 呢?
这是一个折中的选择,考虑了时间和空间的平衡。如果扩容因子设置得太小,会导致哈希冲突过多,影响了 HashMap 的性能;如果设置得太大,虽然哈希冲突可能减少,但是会导致 HashMap 占用更多的内存空间。
# 为什么是 2 倍?
使用 2 的次幂作为数组的容量可以确保 hashCode 的高位和低位能够更好地均匀分布在数组的索引位置上。这样一来,在计算元素存放位置时,只需要进行位运算,而不需要使用取模等耗时的操作,提高了计算效率。
另外,使用 2 的次幂作为容量也方便进行扩容操作,因为 2 的次幂的二进制表示只有一个 1,这样在扩容时只需要将高位多出的 1 变为 0,就可以得到新的容量。这种设计可以减少内存空间的浪费。
总的来说就是:
- 这样设计在计算元素存放位置时可以提高计算效率;
- 在进行扩容时可以减少内存空间的浪费。
- 既提高了效率又减少了时间。
高位是在左边