1. 什么是集合
2. 集合的特点
3. 集合和数组的区别
4. 使用集合框架的好处
5. 常用的集合类有哪些?
6. List,Set,Map三者的区别?
7. 集合框架底层数据结构
8. 哪些集合类是线程安全的?
9. Java集合的快速失败机制 “fail-fast”?
10. 怎么确保一个集合不能被修改?
11. 迭代器 Iterator 是什么?
12. Iterator 怎么使用?有什么特点?
13. 如何边遍历边移除 Collection 中的元素?
14. Iterator 和 ListIterator 有什么区别?
15. 遍历一个 List 有哪些不同的方式?每种方法的实现原理是什么?Java 中 List 遍历的最佳实践是什么?
16. 说一下 ArrayList 的优缺点
ArrayList 的扩容机制了解吗?
ArrayList 确切地说,应该叫做动态数组,因为它的底层是通过数组来实现的,当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量+1 超过数组长度,就会进行扩容。
扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。
17. 如何实现数组和 List 之间的转换?
18. ArrayList 和 LinkedList 的区别是什么?
19. ArrayList 和 Vector 的区别是什么?
20. 插入数据时,ArrayList、LinkedList、Vector谁速度较快?阐述
21. 多线程场景下如何使用 ArrayList?
22. 为什么 ArrayList 的 elementData 加上 transient 修饰?
ArrayList
是 Java 中常用的集合类之一。其内部使用了一个动态数组 elementData
来存储元素。
transient
关键字在 Java 中用于表示一个字段不应被序列化。
elementData
的定义
在 ArrayList
源代码中,可以看到 elementData
是用 transient
修饰的:
private transient Object[] elementData;
为什么使用 transient
-
优化序列化性能:
ArrayList
中的elementData
数组往往比实际存储的元素多。这是因为ArrayList
会预分配一些空间,以减少数组扩展的次数。如果直接序列化elementData
,会导致序列化数据中包含许多无用的空元素,浪费空间和带宽。通过将elementData
声明为transient
,可以避免序列化这些空元素。 -
自定义序列化逻辑:
ArrayList
提供了自定义的序列化和反序列化逻辑,通过实现writeObject
和readObject
方法来控制具体的序列化行为:
- writeObject:序列化时,只写入实际元素的数量和内容,而不是整个数组。
- readObject:反序列化时,重新分配数组并填充元素。
这种方式避免了序列化空元素,并且使得序列化数据更加紧凑和高效
23. List 和 Set 的区别
Set接口
24. 说一下 HashSet 的实现原理?
HashSet的实现原理总结如下:
①是基于HashMap实现的,放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
②当我们试图将类的对象放入 HashSet 中保存时,重写该类的equals(Object obj)方法和 hashCode() 方法很重要,而且这两个方法的返回值必须保持一致:当该类的两个的 hashCode() 返回值相同时,它们通过 equals() 方法比较也应该返回 true。
③HashSet的其他操作都是基于HashMap的
向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,同时还要结合equles 方法比较。
HashSet 中的add ()方法会使用HashMap 的put()方法。
HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap
的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V。所以不会重复(HashMap比较key是否相等是先比较hashcode 再比较equals )
25.==与equals的区别
26. HashSet与HashMap的区别
Map接口
27. 什么是Hash算法
28. 什么是链表
29. 说一下HashMap的实现原理?
其实HashMap在JDK1.7及以前是一个“链表散列”的数据结构,即数组 + 链表的结合体。JDK8优化为:数组+链表+红黑树。
我们常把数组中的每一个节点称为一个桶。当向桶中添加一个键值对时,首先计算键值对中key的hash值(hash(key)),以此确定插入数组中的位置(即哪个桶),但是可能存在同一hash值的元素已经被放在数组同一位置了,这种现象称为碰撞,这时按照尾插法(jdk1.7及以前为头插法)的方式添加key-value到同一hash值的元素的最后面,链表就这样形成了。
当链表长度超过8(TREEIFY_THRESHOLD - 阈值)时,链表就自行转为红黑树。
30. HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现
31. 什么是红黑树
32. HashMap的put方法的具体流程?
HashMap的数据结构在jdk1.8之前是数组+链表,为了解决数据量过大、链表过长是查询效率会降低的问题变成了数组+链表+红黑树的结构,利用的是红黑树自平衡的特点。
链表的平均查找时间复杂度是O(n),红黑树是O(log(n))。
HashMap中的put方法执行过程大体如下:
1、判断键值对数组table[i]是否为空(null)或者length=0,是的话就执行resize()方法进行扩容。
2、不是就根据键值key计算hash值得到插入的数组索引i。
3、判断table[i]==null,如果是true,直接新建节点进行添加,如果是false,判断table[i]的首个元素是否和key一样,一样就直接覆盖。
4、判断table[i]是否为treenode,即判断是否是红黑树,如果是红黑树,直接在树中插入键值对。
5、如果不是treenode,开始遍历链表,判断链表长度是否大于8,如果大于8就转成红黑树,在树中执行插入操作,如果不是大于8,就在链表中执行插入;在遍历过程中判断key是否存在,存在就直接覆盖对应的value值。
6、插入成功后,就需要判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过了,执行resize方法进行扩容。
33. HashMap的扩容操作是怎么实现的?
HashMap
的基本结构
HashMap
的底层实现主要依赖于一个数组和链表(在 Java 8 及之后还可能使用红黑树)。这个数组称为 table
,它的每个位置称为一个桶(bucket)。每个桶存放一个链表,链表中的每个节点存储一个键值对。
触发扩容的条件
HashMap
的扩容是由负载因子(load factor)和当前存储元素的数量决定的。默认情况下,HashMap
的负载因子为 0.75。当元素数量超过 capacity * loadFactor
时,HashMap
就会触发扩容操作。
扩容操作的步骤
-
计算新容量: 新容量通常是当前容量的两倍。新的容量需要是2的幂次,以便散列函数的性能和分布均匀性。
-
创建新表: 创建一个新的更大容量的数组来替代旧的数组。
-
重新散列旧表中的元素: 遍历旧表中的每个元素,并将其重新散列到新表中的相应位置。这个步骤是必要的,因为扩容后,元素在数组中的位置可能会改变。
34. HashMap是怎么解决哈希冲突的?
-
链地址法(Separate Chaining): 这是
HashMap
解决哈希冲突的主要方法。每个数组桶(bucket)存储的是一个链表(在 Java 8 及之后也可能是红黑树)。当多个键散列到同一个桶时,这些键值对会被存储在同一个链表中。 -
红黑树(Treeify): 在 Java 8 及之后,当单个桶中的链表长度超过一定阈值(默认是8)时,链表会转换成红黑树。这种转换能够显著提高查找、插入和删除操作的性能,从 O(n) 提高到 O(log n)。
35. 能否使用任何类作为 Map 的 key?
36. 为什么HashMap中String、Integer这样的包装类适合作为K?
37. 如果使用Object作为HashMap的Key,应该怎么办呢?
38. HashMap为什么不直接使用hashCode()处理后的哈希值直接作 为table的下标?
39. HashMap 的长度为什么是2的幂次方
这个算法应该如何设计呢?
那为什么是两次扰动呢?
40. HashMap 与 HashTable 有什么区别?
41. 什么是TreeMap 简介
42. 如何决定使用 HashMap 还是 TreeMap?
43. HashMap 和 ConcurrentHashMap 的区别
44. ConcurrentHashMap 和 Hashtable 的区别?
45. ConcurrentHashMap 底层具体实现知道吗?实现原理是什 么?
46. Array 和 ArrayList 有何区别?
47. comparable 和 comparator的区别?
48. Collection 和 Collections 有什么区别?
49. TreeMap 和 TreeSet 在排序时如何比较元素?
50 .Collections 工具类中的 sort()方法如何比较元素?
Collections工具类的sort方法有两种重载的形式,
第一种要求传入的待排序容器中存放的对象比较实现Comparable接口以实现元素的比较;
第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是Comparator接口的子类型(需要重写compare方法实现元素的比较),相当于一个临时定义的排序规则,
自定义比较器,Collections.sort(List list,Comparator compare),创建比较器类实现接口
1.说说有哪些常见的集合框架?。
Java 集合框架可以分为两条大的支线:
①、Collection,主要由 List、Set组成:
- List 代表有序、可重复的集合,典型代表就是封装了动态数组的 ArrayListopen in new window 和封装了链表的 LinkedListopen in new window;
- Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet;
②、Map,代表键值对的集合,典型代表就是 HashMapopen in new window。
List
#2.ArrayList 和 LinkedList 有什么区别?
- ArrayList 基于动态数组实现 LinkedList 基于链表实现
多数情况下,ArrayList 更利于查找,LinkedList 更利于增删
①、由于 ArrayList 是基于数组实现的,所以 get(int index)
可以直接通过数组下标获取元素,时间复杂度是 O(1);LinkedList 是基于链表实现的,get(int index)
需要遍历链表,时间复杂度是 O(n)。
②、ArrayList 如果增删的是数组的尾部,直接插入或者删除就可以了,时间复杂度是 O(1);如果 add 的时候涉及到扩容,时间复杂度会提升到 O(n)。
但如果插入的是中间的位置,就需要把插入位置后的元素向前或者向后移动,甚至还有可能触发扩容,效率就会低很多,O(n)。
LinkedList 因为是链表结构,插入和删除只需要改变前置节点、后置节点和插入节点的引用就行了,不需要移动元素。
注意,这里有个陷阱,LinkedList 更利于增删不是体现在时间复杂度上,因为二者增删的时间复杂度都是 O(n),都需要遍历列表;而是体现在增删的效率上,因为 LinkedList 的增删只需要改变引用,而 ArrayList 的增删可能需要移动元素。
#是否支持随机访问?
①、ArrayList 是基于数组的,也实现了 RandomAccess 接口,所以它支持随机访问,可以通过下标直接获取元素。
②、LinkedList 是基于链表的,所以它没法根据下标直接获取元素,不支持随机访问,所以它也没有实现 RandomAccess 接口。
#内存占用有何不同?
ArrayList 是基于数组的,是一块连续的内存空间,所以它的内存占用是比较紧凑的;但如果涉及到扩容,就会重新分配内存,空间是原来的 1.5 倍,存在一定的空间浪费。
LinkedList 是基于链表的,每个节点都有一个指向下一个节点和上一个节点的引用,于是每个节点占用的内存空间稍微大一点。
3.ArrayList 的扩容机制了解吗?
ArrayList 确切地说,应该叫做动态数组,因为它的底层是通过数组来实现的,当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量+1 超过数组长度,就会进行扩容。
扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。
4.ArrayList 怎么序列化的知道吗? 为什么用 transient 修饰数组?
ArrayList 的序列化不太一样,它使用transient
修饰存储元素的elementData
的数组,transient
关键字的作用是让被修饰的成员属性不被序列化。
为什么最 ArrayList 不直接序列化元素数组呢?
出于效率的考虑,数组可能长度 100,但实际只用了 50,剩下的 50 不用其实不用序列化,这样可以提高序列化和反序列化的效率,还可以节省内存空间。
那 ArrayList 怎么序列化呢?
ArrayList 通过两个方法readObject、writeObject自定义序列化和反序列化策略,实际直接使用两个流ObjectOutputStream
和ObjectInputStream
来进行序列化和反序列化。
5.快速失败(fail-fast)和安全失败(fail-safe)了解吗?
快速失败(fail—fast):快速失败是 Java 集合的一种错误检测机制
- 在用迭代器遍历一个集合对象时,如果线程 A 遍历过程中,线程 B 对集合对象的内容进行了修改(增加、删除、修改),则会抛出 Concurrent Modification Exception。
- 原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个
modCount
变量。集合在被遍历期间如果内容发生变化,就会改变modCount
的值。每当迭代器使用 hashNext()/next()遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。 - 场景:java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如 ArrayList 类。
安全失败(fail—safe)
- 采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
- 原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception。
- 缺点:基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
- 场景:java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如 CopyOnWriteArrayList 类。
6.有哪几种实现 ArrayList 线程安全的方法?
可以使用 Collections.synchronizedList()
方法,它将返回一个线程安全的 List。
SynchronizedList list = Collections.synchronizedList(new ArrayList());
内部是通过 synchronized 关键字open in new window加锁来实现的。
也可以直接使用 CopyOnWriteArrayListopen in new window,它是线程安全的,遵循写时复制的原则,每当对列表进行修改(例如添加、删除或更改元素)时,都会创建列表的一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然可以继续。
CopyOnWriteArrayList list = new CopyOnWriteArrayList();
通俗的讲,CopyOnWrite 就是当我们往一个容器添加元素的时候,不直接往容器中添加,而是先复制出一个新的容器,然后在新的容器里添加元素,添加完之后,再将原容器的引用指向新的容器。多个线程在读的时候,不需要加锁,因为当前容器不会添加任何元素。这样就实现了线程安全。
7.CopyOnWriteArrayList 了解多少?
CopyOnWriteArrayList 就是线程安全版本的 ArrayList。
它的名字叫CopyOnWrite
——写时复制,已经明示了它的原理。
CopyOnWriteArrayList 采用了一种读写分离的并发策略。CopyOnWriteArrayList 容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
Map
8.能说一下 HashMap 的底层数据结构吗?
推荐阅读:二哥的 Java 进阶之路:详解 HashMapopen in new window
JDK 8 中 HashMap 的数据结构是数组
+链表
+红黑树
。
HashMap 的核心是一个动态数组(Node[] table
),用于存储键值对。这个数组的每个元素称为一个“桶”(Bucket),每个桶的索引是通过对键的哈希值进行哈希函数处理得到的。
当多个键经哈希处理后得到相同的索引时,会发生哈希冲突。HashMap 通过链表来解决哈希冲突——即将具有相同索引的键值对通过链表连接起来。
不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。数组的查询效率是 O(1)。
当向 HashMap 中添加一个键值对时,会使用哈希函数计算键的哈希码,确定其在数组中的位置,哈希函数的目标是尽量减少哈希冲突,保证元素能够均匀地分布在数组的每个位置上。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
当向 HashMap 中添加元素时,如果该位置已有元素(发生哈希冲突),则新元素将被添加到链表的末尾或红黑树中。如果键已经存在,其对应的值将被旧值覆盖。
当从 HashMap 中获取元素时,也会使用哈希函数计算键的位置,然后根据位置在数组、链表或者红黑树中查找元素。
HashMap 的初始容量是 16,随着元素的不断添加,HashMap 的容量(也就是数组大小)可能不足,于是就需要进行扩容,阈值是capacity * loadFactor
,capacity 为容量,loadFactor 为负载因子,默认为 0.75。
扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中
9.你对红黑树了解多少?为什么不用二叉树/平衡树呢?
红黑树是一种自平衡的二叉查找树:
- 每个节点要么是红色,要么是黑色;
- 根节点永远是黑色;
- 所有的叶子节点都是是黑色的(下图中的 NULL 节点);
- 红色节点的子节点一定是黑色的;
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
#为什么不用二叉树?
二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)。
#为什么不用平衡二叉树?
平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,这在某些情况下可能导致更高的维护成本。
红黑树是一种折中的方案,它在保证了树平衡的同时,插入和删除操作的性能也得到了保证,查询效率是 O(logn)。
10.红黑树怎么保持平衡的?
红黑树有两种方式保持平衡:旋转
和染色
。
①、旋转:旋转分为两种,左旋和右旋
三分恶面渣逆袭:左旋 三分恶面渣逆袭:右旋②、染⾊:
11.HashMap 的 put 流程知道吗?
直接看流程图。
三分恶面渣逆袭:HashMap插入数据流程图第一步,通过 hash 方法计算 key 的哈希值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
第二步,数组进行第一次扩容。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
第三步,根据哈希值计算 key 在数组中的下标,如果对应下标正好没有存放数据,则直接插入。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
如果对应下标已经有数据了,就需要判断是否为相同的 key,是则覆盖 value,否则需要判断是否为树节点,是则向树中插入节点,否则向链表中插入数据。
注意,在链表中插入节点的时候,如果链表长度大于等于 8,则需要把链表转换为红黑树。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
所有元素处理完后,还需要判断是否超过阈值threshold
,超过则扩容。
if (++size > threshold)
resize();
#只重写 equals 没重写 hashcode,map put 的时候会发生什么?
如果只重写 equals 方法,没有重写 hashcode 方法,那么会导致 equals 相等的两个对象,hashcode 不相等,这样的话,这两个对象会被放到不同的桶中,这样就会导致 get 的时候,找不到对应的值。
12.HashMap 怎么查找元素的呢?
先看流程图:
HashMap查找流程图HashMap 的查找就简单很多:
- 使用扰动函数,获取新的哈希值
- 计算数组下标,获取节点
- 当前节点和 key 匹配,直接返回
- 否则,当前节点是否为树节点,查找红黑树
- 否则,遍历链表查找
13.HashMap 的 hash 函数是怎么设计的?
HashMap 的哈希函数是先拿到 key 的 hashcode,是一个 32 位的 int 类型的数值,然后让 hashcode 的高 16 位和低 16 位进行异或操作。这么设计是为了降低哈希碰撞的概率。
static final int hash(Object key) {
int h;
// key的hashCode和key的hashCode右移16位做异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
14.为什么 hash 函数能降哈希碰撞?
在 hash 函数中,先调用了 key 的hashCode()
方法,这将会返回一个32 位的 int 类型的哈希值
但是数组长度比较小,需要hash 算法,来避免发生哈希冲突,尽可能地让元素均匀地分布在数组当中。
第一个就是数组的长度必须是 2 的整数次幂,这样可以保证 hash & (n-1)
的结果能均匀地分布在数组中。&
操作的结果就是哈希值的高位全部归零,只保留 n 个低位,用来做数组下标访问
其作用就相当于 hash % n,n 为数组的长度
第二:
将哈希值无符号右移 16 位,意味着原哈希值的高 16 位被移到了低 16 位的位置。这样,原始哈希值的高 16 位和低 16 位都可以参与到最终用于索引计算的低位中。
选择 16 位是因为它是 32 位整数的一半,这样处理既考虑了高位的信息,又没有完全忽视低位原本的信息,尝试达到一个平衡状态。
15.为什么 HashMap 的容量是 2 的倍数呢?
HashMap 的容量是 2 的倍数,或者说是 2 的整数次幂,是为了快速定位元素的下标:
HashMap 在定位元素位置时,先通过 哈希函数hash(key) = (h = key.hashCode()) ^ (h >>> 16)
计算出哈希值,再通过 hash & (n-1)
来定位元素位置的,n 为数组的大小,也就是 HashMap 的容量。
因为(数组长度-1)正好相当于一个“低位掩码”——这个掩码的低位最好全是 1,这样 & 操作才有意义,否则结果就肯定是 0。
2 的整次幂(或者叫 2 的整数倍)刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,保证了 hash &(length-1)
的最后一位可能为 0,也可能为 1(取决于 hash 的值),即 & 运算后的结果可能为偶数,也可能为奇数,这样便可以保证哈希值的均匀分布。
换句话说,& 操作的结果就是将哈希值的高位全部归零,只保留低位值
18.解决哈希冲突有哪些方法呢?
解决哈希冲突的方法我知道的有 3 种:
①、再哈希法
准备两套哈希算法,当发生哈希冲突的时候,使用另外一种哈希算法,直到找到空槽为止。对哈希算法的设计要求比较高。
②、开放地址法
遇到哈希冲突的时候,就去寻找下一个空的槽。有 3 种方法:
- 线性探测:从冲突的位置开始,依次往后找,直到找到空槽。
- 二次探测:直到找到空槽。
- 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数。
③、拉链法
也就是所谓的链地址法,当发生哈希冲突的时候,使用链表将冲突的元素串起来。HashMap 采用的正是拉链法
21.那扩容机制了解吗?
扩容时,HashMap 会创建一个新的数组,其容量是原数组容量的两倍。然后将键值对放到新计算出的索引位置上。一部分索引不变,另一部分索引为“原索引+旧容量”。
22.JDK 8 对 HashMap 主要做了哪些优化呢?为什么?
相比较 JDK 7,JDK 8 的 HashMap 主要做了四点优化:
①、底层数据结构由数组 + 链表改成了数组 + 链表或红黑树的结构。
原因:如果多个键映射到了同一个哈希值,链表会变得很长,在最坏的情况下,当所有的键都映射到同一个桶中时,性能会退化到 O(n),而红黑树的时间复杂度是 O(logn)。
②、链表的插入方式由头插法改为了尾插法。
原因:头插法虽然简单快捷,但扩容后容易改变原来链表的顺序。
③、扩容的时机由插入时判断改为插入后判断。
原因:可以避免在每次插入时都进行不必要的扩容检查,因为有可能插入后仍然不需要扩容。
④、优化了哈希算法。
JDK 7 进行了多次移位和异或操作来计算元素的哈希值。
JDK 8 优化了这个算法,只进行了一次异或操作,但仍然能有效地减少冲突。
24.HashMap 是线程安全的吗?多线程下会有什么问题?
HashMap 不是线程安全的,主要有以下几个问题:
①、多线程下扩容会死循环。JDK1.7 中的 HashMap 使用的是头插法插入元素,在多线程的环境下,扩容的时候就有可能导致出现环形链表,造成死循环。
不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序。
②、多线程的 put 可能会导致元素的丢失。因为计算出来的位置可能会被其他线程的 put 覆盖。本来哈希冲突是应该用链表的,但多线程时由于没有加锁,相同位置的元素可能就被干掉了。
③、put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而导致出现扩容,线程 2 此时执行 get,就有可能出现这个问题。
25.有什么办法能解决 HashMap 线程不安全的问题呢?
①、HashTable 是直接在方法上加 synchronized 关键字,比较粗暴,不再推荐使用
②、Collections.synchronizedMap
返回的是 Collectionsopen in new window 工具类的内部类
③、ConcurrentHashMap 在 JDK 7 中使用分段锁,在 JKD 8 中使用了 CAS(Compare-And-Swap)open in new window+ synchronized 关键字,性能得到进一步提升。
26.HashMap 内部节点是有序的吗?
HashMap 是无序的,根据 hash 值随机插入。如果想使用有序的 Map,可以使用 LinkedHashMap 或者 TreeMap
27.讲讲 LinkedHashMap 怎么实现有序的?
LinkedHashMap 维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。
28.讲讲 TreeMap 怎么实现有序的?
TreeMap 通过 key 的比较器来决定元素的顺序,如果没有指定比较器,那么 key 必须实现 Comparable 接口
TreeMap 的底层是红黑树,红黑树是一种自平衡的二叉查找树,每个节点都大于其左子树中的任何节点,小于其右子节点树种的任何节点。
插入或者删除元素时通过旋转和着色来保持树的平衡。
查找的时候通过从根节点开始,利用二叉查找树的性质,逐步向左或者右子树递归查找,直到找到目标元素
29.TreeMap 和 HashMap 的区别
①、HashMap 是基于数组+链表+红黑树实现的,put 元素的时候会先计算 key 的哈希值,然后通过哈希值计算出数组的索引,然后将元素插入到数组中,如果发生哈希冲突,会使用链表来解决,如果链表长度大于 8,会转换为红黑树。
get 元素的时候同样会先计算 key 的哈希值,然后通过哈希值计算出数组的索引,如果遇到链表或者红黑树,会通过 key 的 equals 方法来判断是否是要找的元素。
②、TreeMap 是基于红黑树实现的,put 元素的时候会先判断根节点是否为空,如果为空,直接插入到根节点,如果不为空,会通过 key 的比较器来判断元素应该插入到左子树还是右子树。
get 元素的时候会通过 key 的比较器来判断元素的位置,然后递归查找。
由于 HashMap 是基于哈希表实现的,所以在没有发生哈希冲突的情况下,HashMap 的查找效率是 O(1)。适用于查找操作比较频繁的场景。
而 TreeMap 是基于红黑树实现的,所以 TreeMap 的查找效率是 O(logn)。并且保证了元素的顺序,因此适用于需要大量范围查找或者有序遍历的场景。
Set
#30.讲讲 HashSet 的底层实现?
HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作
实际开发中,HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么 ArrayList 和 LinkedList 可能更适合;如果我们需要存储键值对并根据键进行查找,那么 HashMap 可能更适合。
HashSet 主要用于去重,比如,我们需要统计一篇文章中有多少个不重复的单词,就可以使用 HashSet 来实现。
HashSet 怎么判断元素重复,重复了是否 put
HashSet 的 add 方法是通过调用 HashMap 的 put 方法实现的:所以 HashSet 判断元素重复的逻辑底层依然是 HashMap 的底层逻辑:
HashMap 在插入元素时,通常需要三步:
第一步,通过 hash 方法计算 key 的哈希值。
第二步,数组进行第一次扩容。
第三步,根据哈希值计算 key 在数组中的下标,如果对应下标正好没有存放数据,则直接插入。
如果对应下标已经有数据了,就需要判断是否为相同的 key,是则覆盖 value,否则需要判断是否为树节点,是则向树中插入节点,否则向链表中插入数据。