Java集合
引言
1 说说有哪些常见的集合框架?

Java中集合类主要两大类:Collection和Map,Collection只能装一种,Map装两种类型
1.Collection 接口:最基本的集合框架表示方式,提供了添加、删除、清空等基本操作,它主要有三个子接口:
- List:一个有序的集合,可以包含重复的元素。实现类包括 ArrayList、LinkedList 等。
- Set:一个不包含重复元素的集合。实现类包括 HashSet、LinkedHashSet、TreeSet 等。
- Queue:一个用于保持元素队列的集合。实现类包括 PriorityQueue、ArrayDeque 等。
2.Map 接口:表示键值对的集合,一个键映射到一个值。键不能重复,每个键只能对应一个值。Map 接口的实现类包括 HashMap、LinkedHashMap、TreeMap 等。
(1)集合框架有哪几个常用工具类?Arrays和Collections
两个工具类:Collections和Arrays
-
Collections:提供了一些对集合进行排序、二分查找、同步的静态方法。
-
Arrays:提供了一些对数组进行排序、打印、和 List 进行转换的静态方法。
(2)简单介绍一下队列
先进先出的数据结构,Queue接口,实现类双端队列、优先队列
java中的队列主要有两个接口实现:Queue接口和并发包下的BlockingQueue接口
优先队列PriorityQueue是一个无界队列,它的元素是按照自然顺序排序或者 Comparator 比较器进行排序。
java默认通过小顶堆进行实现的,数字越小越靠前
PriorityQueue自定义排序,主要看比较器的实现
java
// 核心构造函数
PriorityQueue(Comparator<? super E> comparator)
1.大顶堆:
默认是 1, 2, 3... 出队。如果你想让 9, 8, 7... 出队
java
// 写法 1:使用 Lambda 表达式 (推荐,最简洁)
// (o1, o2) -> o2 - o1 表示降序
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
// 写法 2:使用 Collections.reverseOrder()
PriorityQueue<Integer> maxHeap2 = new PriorityQueue<>(Collections.reverseOrder());
2.自定义对象排序
java
// 方式 1:经典 Lambda 写法
PriorityQueue<Student> pq = new PriorityQueue<>((s1, s2) -> {
// 降序:后减前 (s2 - s1)
// 升序:前减后 (s1 - s2)
return s2.score - s1.score;
});
// 方式 2:使用 Comparator.comparing (更现代,防溢出)
// reversed() 表示反转,即从大到小
PriorityQueue<Student> pq = new PriorityQueue<>(
Comparator.comparingInt(Student::getScore).reversed()
);
3.多条件排序:
java
PriorityQueue<Student> pq = new PriorityQueue<>((s1, s2) -> {
if (s1.score != s2.score) {
return s2.score - s1.score; // 分数降序
} else {
return s1.name.compareTo(s2.name); // 名字升序 (String默认是升序)
}
});

双端队列
双端队列 ArrayDeque 是一个基于数组的,可以在两端插入和删除元素的队列。

LinkedList实现了Deque也可以当最哦双端队列来使用

(3)用过哪些集合类,它们的优劣?
常用的集合类有ArrayList,HashSet,LinkedList、LinkedHashMap
-
ArrayList 可以看作是一个动态数组,可以在需要时动态扩容数组的容量,只不过需要复制元素到新的数组。**优点是访问速度快,可以通过索引直接查找到元素。**缺点是插入和删除元素可能需要移动或者复制元素。
-
LinkedList 是一个双向链表,适合频繁的插入和删除操作。优点是插入和删除元素的时候只需要改变节点的前后指针,缺点是访问元素时需要遍历链表。
-
HashMap 是一个基于哈希表的键值对集合 。优点是可以根据键的哈希值快速查找到值 ,但有可能会发生哈希冲突,并且不保留键值对的插入顺序。
-
LinkedHashMap 在 HashMap 的基础上增加了一个双向链表来保持键值对的插入顺序。
(4)队列和栈的区别了解吗?
队列先进先出,栈先进后出
- 队列是一种先进先出(FIFO, First-In-First-Out)的数据结构,第一个加入队列的元素会成为第一个被移除的元素,适用于需要按顺序处理任务的场景,比如消息队列、任务调度等。
- 栈是一种后进先出(LIFO, Last-In-First-Out)的数据结构,最后一个加入栈的元素会成为第一个被移除的元素,适用于需要回溯的场景,比如函数调用栈、浏览器历史记录等。
(5)哪些是线程安全的容器?
| 容器类型 | 非线程安全 (普通) | 线程安全 (推荐 JUC) | 线程安全 (不推荐/古董) | 特点/适用场景 |
|---|---|---|---|---|
| Map | HashMap | ConcurrentHashMap | Hashtable | 锁粒度细,高并发首选 |
| List | ArrayList | CopyOnWriteArrayList | Vector | 读多写少,写时复制 |
| Set | HashSet | CopyOnWriteArraySet | - | 底层基于 CopyOnWriteArrayList |
| Queue | LinkedList | ArrayBlockingQueue/LinkedBlockingQueue | - | 生产者消费者模型 |
!NOTE
第一代:上古时代的"独占锁" (不推荐)
这代容器的特点是:笨重 。 不管你是读还是写,它直接在一个方法上加
synchronized,相当于把整个容器锁死。一个线程在操作,其他线程全部排队。
Vector:线程安全的 ArrayList。Hashtable:线程安全的 HashMap。
- 缺点:性能太差。多线程环境下,竞争激烈时简直就是灾难。
第二代:包装类 (过渡方案)
通过
Collections工具类把普通的容器"包装"成线程安全的。
Collections.synchronizedList(new ArrayList<>())Collections.synchronizedMap(new HashMap<>())
- 特点:本质上还是第一代的思路,加了一层粗粒度的锁(Mutex),性能提升有限。
第三代:JUC 并发容器 (面试核心,重点掌握)
这才是现代 Java 高并发的基石,主要采用了 CAS (无锁算法) 、分段锁 或 写时复制 等高级机制。
1. Map 家族:
ConcurrentHashMap(最重要)它是 HashMap 的线程安全版,但性能吊打 Hashtable。
- 原理 (简单版) :
- Hashtable 是一把大锁锁住整个哈希表(锁全家)。
- ConcurrentHashMap (JDK8) 是**"锁细化"**。它只锁住哈希桶的头节点(Node)。也就是说,如果两个线程操作的是不同的 Key(比如一个操作 Key="A",一个操作 Key="Z"),它们互不影响,可以并行操作!
- 适用场景:绝大多数需要线程安全 Map 的场景。
- List 家族:
CopyOnWriteArrayList它是 ArrayList 的线程安全版。
- 原理 (读写分离) :
- 读 (Read):完全不加锁,随便读,速度极快。
- 写 (Write) :当要修改数据时,它不直接改原数组,而是把原数组复制 (Copy) 一份新的,在新的上面修改,改完后再把引用指过去。
- 缺点:写操作很贵(要复制内存),且有数据延迟(读的时候可能还没写完)。
- 适用场景 :读多写少(比如黑白名单、配置列表)。
- Queue 家族:阻塞队列 (BlockingQueue)
这是生产者-消费者模型 的神器。它们不仅线程安全,还有一个特性:队列满了会自动阻塞生产者,队列空了会自动阻塞消费者(不用你自己写 wait/notify)。
ArrayBlockingQueue:基于数组,有界(必须指定大小)。LinkedBlockingQueue:基于链表,可选有界(默认是 Integer.MAX_VALUE,容易 OOM,要注意)。
(6)Collection 继承了哪些接口?
Iterable接口,必须实现iterator方法,这意味着所有实现 Collection 接口的类都必须实现 iterator() 方法,之后就可以使用增强型 for 循环遍历集合中的元素了。

List
2 ArrayList 和 LinkedList 有什么区别?
ArrayList 是基于动态数组实现的,LinkedList 是基于双向链表实现的。

(1)ArrayList 和 LinkedList 的用途有什么不同?
多数情况下,ArrayList 更利于查找,LinkedList 更利于增删。按照道理来说
1.由于 ArrayList 是基于数组实现的,所以 get(int index) 可以直接通过数组下标获取,时间复杂度是 O(1);LinkedList 是基于链表实现的,get(int index) 需要遍历链表,时间复杂度是 O(n)。
当然,get(E element) 这种查找,两种集合都需要遍历通过 equals 比较获取元素,所以时间复杂度都是 O(n)。
2.ArrayList 如果增删的是数组的尾部,时间复杂度是 O(1);如果 add 的时候涉及到扩容,时间复杂度会上升到 O(n)。
但如果插入的是中间的位置,就需要把插入位置后的元素向前或者向后移动,甚至还有可能触发扩容,效率就会低很多,变成 O(n)。
(2)ArrayList 和 LinkedList 是否支持随机访问?
ArrayList 是基于数组的,也实现了 RandomAccess 接口,所以它支持随机访问,可以通过下标直接获取元素。
LinkedList 是基于链表的,所以它没法根据下标直接获取元素,不支持随机访问。
(3)ArrayList 和 LinkedList 内存占用有何不同?
-
ArrayList基于数组,是一块连续的内存空间,所以它的内存占用是比较紧凑的;但如果涉及到扩容,就会重新分配内存,空间是原来的 1.5 倍。
-
LinkedList 是基于链表的,每个节点都有一个指向下一个节点和上一个节点的引用,于是每个节点占用的内存空间比 ArrayList 稍微大一点。
(4)ArrayList 和 LinkedList 的使用场景有什么不同?
按道理来说查询多修改少用ArrayList,修改多查询少的情况下用LinkedList
。但是实际情况下百分之90的情况都会使用ArrayList,这是因为计算机组成原理CPU缓存的命中率的原因。连续内存更容易命中,LinkedList比较分散不容易命中,并且数组搬用移动使用System.arraycopy()的原生方法,非常快
3 ArrayList 的扩容机制了解吗?
如果元素+1会超出容量就会进行1.5倍扩容。然后再把原数组的值拷贝到新数组中。

4 ArrayList 怎么序列化的知道吗?
arrayList的序列化,是使用writeObject和readObject方法的,并且eleement数组是使用transient去修饰的,因为数组的容量一般是用不完大于实际元素的容量,这样就可以自定义的序列化有用的数据。
(1)为什么 ArrayList 不直接序列化元素数组呢?
出于效率的考虑,数组可能长度 100,但实际只用了 50,剩下的 50 没用到,也就不需要序列化。
java
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// 将当前 ArrayList 的结构进行序列化
int expectedModCount = modCount;
s.defaultWriteObject(); // 序列化非 transient 字段
// 序列化数组的大小
s.writeInt(size);
// 序列化每个元素
for (int i = 0; i < size; i++) {
s.writeObject(elementData[i]);
}
// 检查是否在序列化期间发生了并发修改
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
5 快速失败fail-fast了解吗?
(1)什么是安全失败(fail---safe)呢?
6 有哪几种实现 ArrayList 线程安全的方法?
线程安全就是要实现能在多线程下使用,保持原子性+可见性+有序性
两种方法让ArrayList实现线程安全:使用synchronizedList和CopyOnWriteArrayList'
一般情况下读多写少使用CopyOnwriteArrayList,写多了要么干脆就使用ConcurrentLinkedList
- synchronizedList
可以使用 Collections.synchronizedList() 方法,它可以返回一个线程安全的 List。
java
SynchronizedList list = Collections.synchronizedList(new ArrayList());
- SynchronizedList通过内部加锁实现
CopyOnWriteArrayList它是线程安全的 ArrayList,遵循写时复制的原则,每当对列表进行修改时,都会创建一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然在原有的列表上进行。感觉很像mvcc的实现
通俗的讲,CopyOnWrite 就是当我们往一个容器添加元素的时候,不直接往容器中添加,而是先复制出一个新的容器,然后在新的容器里添加元素,添加完之后,再将原容器的引用指向新的容器。多个线程在读的时候,不需要加锁,因为当前容器不会添加任何元素。这样就实现了线程安全。


(1)ArrayList 和 Vector 的区别?
Vector 属于 JDK 1.0 时期的遗留类,不推荐使用,仍然保留着是因为 Java 希望向后兼容。
ArrayList 是在 JDK 1.2 时引入的,用于替代 Vector 作为主要的非同步动态数组实现。因为 Vector 所有的方法都使用了 synchronized 关键字进行同步,所以单线程环境下效率较低。

7 CopyOnWriteArrayList 了解多少?
写时复制,就是写的时候克隆新副本,在新副本上执行写操作,然后指向新的容器。
CopyOnWriteArrayList 就是线程安全版本的 ArrayList。
CopyOnWriteArrayList 采用了一种读写分离的并发策略 。CopyOnWriteArrayList 容器允许并发读,读操作是无锁的。至于写操作,比如说向容器中添加一个元素,首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。

Map
Map 中最重要的就是 HashMap 了,面试基本被问出包浆了,一定要好好准备。
8 能说一下 HashMap 的底层数据结构吗?
JDK 8 中 HashMap 的数据结构是数组+链表+红黑树。

键值对,键怎么得到
当多个键经过哈希处理后得到相同的索引时,需要通过链表来解决哈希冲突------将具有相同索引的键值对通过链表存储起来。
不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树 。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。
hash() 方法的目标是尽量减少哈希冲突,保证元素能够均匀地分布在数组的每个位置上。
java
// 扰动函数,进一步处理哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
如果键的哈希值已经在数组中存在,其对应的值将被新值覆盖。
HashMap 的初始容量是 16 ,随着元素的不断添加,HashMap 就需要进行扩容,阈值是capacity * loadFactor,capacity 为容量,loadFactor 为负载因子,默认为 0.75。
扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中。
(1)负载因子干什么用的?
负载因子(load factor)是一个介于 0 和 1 之间的数值,用于衡量哈希表的填充程度。它表示哈希表中已存储的元素数量与哈希表容量之间的比例。
- 负载因子过高(接近 1)会导致哈希冲突增加,影响查找、插入和删除操作的效率。
- 负载因子过低(接近 0)会浪费内存,因为哈希表中有大量未使用的空间。
默认的负载因子是 0.75,这个值在时间和空间效率之间提供了一个良好的平衡。
9 你对红黑树了解多少?
红黑树本质上是一种自平衡的二叉查找树,它通过以下 5 条铁律来保证平衡:
- 颜色属性 :每个节点要么是红色 ,要么是黑色。
- 根属性 :根节点 必须是黑色。
- 叶子属性 :所有的叶子节点(NIL 节点,即空节点)都是黑色的。
- 红色属性(不红红):不能有两个连续的红色节点(即红色节点的子节点必须是黑色)。
- 黑色属性(黑高相等) :从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。

(1)为什么不用二叉树?
这里应该是说的二叉搜索树BST,基本数据结构,每一个节点最多有两个子节点,比父节点小的子节点都放在左,比父节点的大的都放在右,然后查询的时候在极端情况下会,比如插入节点完全有序(1,2,3,4,5)的情况下就会退化成链表,查询效率变O(n)
(2)为什么不用平衡二叉树?
平衡二叉树AVL要求高,左右子树高度差不会超过1,平衡可以保证极佳的查找效率,但是进行插入、删除的情况就有极高的维护成本。
(3)为什么用红黑树?
红黑树是一种折中方案,链表的查找时间复杂度是 O(n),当链表长度较长时,查找性能会下降 。红黑树是一种折中的方案 ,查找、插入、删除的时间复杂度都是 O(log n)。
之所以选择红黑树,是因为它是一种**'弱平衡'二叉树。 相比于普通 BST,它解决了退化成链表的问题,保证了查询效率。 相比于 AVL 树,它牺牲了微小的查询性能(不再是绝对平衡),换取了 更少的旋转操作**,从而大幅提升了插入和删除的效率。 在需要频繁插入删除的实际工程场景(如 HashMap)中,红黑树的综合性能是最好的。
10 红黑树怎么保持平衡的?
通过旋转和染色保持平衡
1.通过左右旋转避免一侧树的节点过深


2.染色,修复红黑该规则,从而保证树的高度不会失衡。

红黑树插入删除规则
参考:https://blog.csdn.net/m0_52383454/article/details/126393163
1. 插入 (主要看叔叔)
- 红叔叔:父叔变黑,祖变红,递归。
- 黑叔叔:旋转(LL右旋/RR左旋),父变黑,祖变红。
2. 删除 (主要看兄弟)
- 删红节点:直接删。
- 删黑节点(看兄弟):
- 红兄弟:转成黑兄弟。
- 黑兄弟 + 黑侄子:兄弟变红,矛盾上移(找爸爸麻烦)。
- 黑兄弟 + 红侄子:旋转+变色,借个节点过来,彻底解决。
11 HashMap 的 put 流程知道吗?【*】
哈希寻址 → 处理哈希冲突(链表还是红黑树)→ 判断是否需要扩容 → 插入/覆盖节点。(看懂下图差不多)

详细流程:
1.获得哈希扰动哈希值,可以用来减少哈希冲突
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2.第一次扩容,然后还要计算查找索引位置,该数组位置没有元素就直接放进去
java
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
如果当前位置为空,直接将键值对插入该位置;否则判断当前位置的第一个节点是否与新节点的 key 相同,如果相同直接覆盖 value,如果不同,说明发生哈希冲突。
3.发生哈希冲突解决哈希冲突,比较key,key同就完全覆盖的,不同才有冲突解决,链表和红黑树的事
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 table 为空,先进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算索引位置,并找到对应的桶
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 如果桶为空,直接插入
else {
Node<K,V> e; K k;
// 检查第一个节点是否匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 覆盖
// 如果是树节点,放入树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果是链表,遍历插入到尾部
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果链表长度达到阈值,转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; // 覆盖
p = e;
}
}
if (e != null) { // 如果找到匹配的 key,则覆盖旧值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // 修改计数器
if (++size > threshold)
resize(); // 检查是否需要扩容
afterNodeInsertion(evict);
return null;
}
每次插入新元素后,检查是否需要扩容 ,如果当前元素个数大于阈值(capacity * loadFactor),则进行扩容,扩容后的数组大小是原来的 2 倍;并且重新计算每个节点的索引,进行数据重新分布。负载因子默认0.75
(1)只重写元素的 equals 方法没重写 hashCode,put 的时候会发生什么?
内容相等的两个对象,但是hashCode不同,这样两个对象会被放在数组的不同位置,进行get的时候就会出错
12 HashMap 怎么查找元素的呢?
计算索引-》找到桶-》在桶里比较key的内容,然后取得节点获得值
通过哈希值定位索引 → 定位桶 → 检查第一个节点 → 遍历链表或红黑树查找 → 返回结果。

13 HashMap 的 hash 函数是怎么设计的?
是一个扰动函数,然哈希值更加均匀的分布,需要与异或^ 一个(h>>>16)
先拿到 key 的哈希值,是一个 32 位的 int 类型数值,然后再让哈希值的高 16 位和低 16 位进行异或操作,这样能保证哈希分布均匀。
java
static final int hash(Object key) {
int h;
// 如果 key 为 null,返回 0;否则,使用 hashCode 并进行扰动
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
h >>> 16是什么意思:
>>>是 Java 中的无符号右移运算符。
- 含义:把一个数的二进制位全部向右移动 16 位。
- 规则 :不管这个数是正数还是负数,高位(左边)空出来的地方永远补 0。
- 对比 :
>>(带符号右移) 会在左边补符号位(正数补0,负数补1),而>>>无论如何都补 0。
h >>> 16的作用就是把高 16 位的信息"移动"到低 16 位去。HashMap 计算下标的公式是:
index = (n - 1) & hash。
- 绝大多数情况下,HashMap 的数组长度
n不会很大(比如初始是 16)。- 当
n = 16时,n - 1是 15 (0000 ... 0000 1111)。- 做按位与 (
&) 运算时,只有 hash 值的最后 4 位有效,前面的 28 位全被忽略了!后果 : 如果两个 Key 的哈希值,高位不同,但低位相同 (比如 A 是
11110000...0101,B 是00001111...0101),它们算出来的下标完全一样,导致哈希冲突。高位的信息被白白浪费了高位参与运算
(h = key.hashCode()) ^ (h >>> 16)
h >>> 16的目的是:让高位的信息参与到低位的运算中 。 这样即使数组长度很小(只看低位),高位的变化也能影响到最终的下标,从而让哈希分布得更均匀,减少冲突。
14 为什么 hash 函数能减少哈希冲突?
就是让hashCode()的值的高16位参与索引左边的按位与运算
快速回答:哈希表的索引是通过 h & (n-1) 计算的,**n 是底层数组的容量;n-1 和某个哈希值做 & 运算,相当于截取了最低的四位。**如果数组的容量很小,只取 h 的低位很容易导致哈希冲突。n就是很小的用二进制表示就几位,直接按位与很容易哈希冲突
通过异或操作将 h 的高位引入低位,可以增加哈希值的随机性,从而减少哈希冲突。
示例:说
以初始长度 16 为例,16-1=15。2 进制表示是0000 0000 0000 0000 0000 0000 0000 1111。只取最后 4 位相等于哈希值的高位都丢弃了。

比如说 1111 1111 1111 1111 1111 1111 1111 1111,取最后 4 位,也就是 1111。
1110 1111 1111 1111 1111 1111 1111 1111,取最后 4 位,也是 1111。
不就发生哈希冲突了吗?
这时候 hash 函数 (h = key.hashCode()) ^ (h >>> 16) 就派上用场了。

哈希值无符号右移 16 位,意味着原哈希值的高 16 位被移到了低 16 位的位置。这样,原始哈希值的高 16 位和低 16 位就可以参与到最终用于索引计算的低位中。
选择 16 位是因为它是 32 位整数的一半,这样处理既考虑了高位的信息,又没有完全忽视低位原本的信息,从而达到了一种微妙的平衡状态。(为什么选16,)
举个例子(数组长度为 16)。
- 第一个键值对的键:h1 = 0001 0010 0011 0100 0101 0110 0111 1000
- 第二个键值对的键:h2 = 0001 0010 0011 0101 0101 0110 0111 1000
如果没有 hash 函数,直接取低 4 位,那么 h1 和 h2 的低 4 位都是 1000,也就是说两个键值对都会放在数组的第 8 个位置。
来看一下 hash 函数的处理过程。
对于第一个键h1的计算:
java
原始: 0001 0010 0011 0100 0101 0110 0111 1000
右移: 0000 0000 0000 0000 0001 0010 0011 0100
异或: ---------------------------------------
结果: 0001 0010 0011 0100 0100 0100 0100 1100
对于第二个键h2的计算:
java
原始: 0001 0010 0011 0101 0101 0110 0111 1000
右移: 0000 0000 0000 0000 0001 0010 0011 0101
异或: ---------------------------------------
结果: 0001 0010 0011 0101 0100 0100 0100 1101
通过上述计算,我们可以看到h1和h2经过h ^ (h >>> 16)操作后得到了不同的结果。
现在,考虑数组长度为 16 时(需要最低 4 位来确定索引):
- 对于
h1的最低 4 位是1100(十进制中为 12) - 对于
h2的最低 4 位是1101(十进制中为 13)
这样,h1和h2就会被分别放在数组的第 12 个位置和第 13 个位置上,从而避免了哈希冲突。
15 为什么 HashMap 的容量是 2 的幂次方?
是为了快速定位元素在底层数组中的下标。
HashMap 是通过 hash & (n-1) 来定位元素下标的,n 为数组的大小,也就是 HashMap 底层数组的容量。
数组长度-1 正好相当于一个"低位掩码"------掩码的低位最好全是 1,这样 & 运算才有意义,否则结果一定是 0。
2 幂次方刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,也就保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1(取决于 hash 的值),这样可以保证哈希值的均匀分布。
换句话说**,& 操作的结果就是将哈希值的高位全部归零,只保留低位值。**
示例:
已知 HashMap 的初始长度为 16,16-1=15,二进制是 00000000 00000000 00001111(高位用 0 来补齐):
java
10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101
因为 15 的高位全部是 0,所以 & 运算后的高位结果肯定也是 0,只剩下 4 个低位 0101,也就是十进制的 5。
这样,哈希值为 10100101 11000100 00100101 的键就会放在数组的第 5 个位置上。
(1)对数组长度取模定位数组下标,这块有没有优化策略?
将取模运算转化成位运算
快速回答:HashMap 的策略是将取模运算 hash % table.length 优化为位运算 hash & (length - 1)
因为当数组的长度是 2 的 N 次幂时,hash & (length - 1) = hash % length。这也是数组长度 2 的 N 次幂设置的原因
比如说 9 % 4 = 1,9 的二进制是 1001,4 - 1 = 3,3 的二进制是 0011,9 & 3 = 1001 & 0011 = 0001 = 1。
再比如说 10 % 4 = 2,10 的二进制是 1010,4 - 1 = 3,3 的二进制是 0011,10 & 3 = 1010 & 0011 = 0010 = 2。
当数组的长度不是 2 的 n 次方时,hash % length 和 hash & (length - 1) 的结果就不一致了。
比如说 7 % 3 = 1,7 的二进制是 0111,3 - 1 = 2,2 的二进制是 0010,7 & 2 = 0111 & 0010 = 0010 = 2。
从二进制角度来看,hash / length = hash / 2n = hash >> n,即把 hash 右移 n 位,此时得到了 hash / 2n 的商。
(2)说说什么是取模运算?
Java 中,通常使用 % 运算符来表示取余,用 Math.floorMod() 来表示取模。
当操作数都是正数的话,取模运算和取余运算的结果是一样的;只有操作数出现负数的情况下,结果才会不同。
取模运算的商向负无穷靠近;取余运算的商向 0 靠近。这是导致它们两个在处理有负数情况下,结果不同的根本原因。
对于 HashMap 来说,它需要通过 hash % table.length 来确定元素在数组中的位置。
16 如果初始化 HashMap,传一个 17 的容量,它会怎么处理?
17是一个非2的n次方,HashMap 会将容量调整到大于等于 17 的最小的 2 的幂次方,也就是 32,会自己调整
还会获取最近2的倍数去初始化,向上去

这是因为哈希表的大小最好是 2 的 N 次幂,这样可以通过 (n - 1) & hash 高效计算出索引值。
这样的原有有一个阈值,阀值 threshold 会通过⽅法 tableSizeFor() 进⾏计算。
java
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 找到大于或等于 cap 的最小的 2 的幂次方。
static final int tableSizeFor(int cap) {
int n = cap - 1; // 减 1 的原因:为了防止传入的数本身就是 2 的幂次方时,结果翻倍。
// 移位逻辑:利用二进制的最高位传播特性,通过不断右移并按位或,把最高位 1 后面的所有位都填成 1,最后加 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;
}
面试怎么答?
如果面试官问这段代码,你只需要回答三个点,他就会觉得你很懂:
- 目的:为了将任意整数转换为大于等于它的最小的 2 的幂次方。
- 减 1 的原因:为了防止传入的数本身就是 2 的幂次方时,结果翻倍。
- 移位逻辑 :利用二进制的最高位传播特性,通过不断右移并按位或,把最高位 1 后面的所有位都填成 1,最后加 1 得到目标值。
(1)初始化 HashMap 的时候需要传入容量吗?
如果预先知道就是需要指定的。如果预先知道 Map 将存储大量键值对,提前指定一个足够大的初始容量可以减少因扩容导致的重哈希操作。
resize()会重新分配时间,操作耗时,因为每次扩容时,HashMap 需要将现有的元素插入到新的数组中,这个过程相对耗时,尤其是当 Map 中已有大量数据时。
当然了,过大的初始容量会浪费内存,特别是当实际存储的元素远少于初始容量时。如果不指定初始容量,HashMap 将使用默认的初始容量 16。
17 你还知道哪些哈希函数的构造方法呢?
5种方法:除留取余法、直接定址、取平方、数字分析取数、折叠取和
- 除留取余法 :
H(key)=key%p(p<=N),关键字除以一个不大于哈希表长度的正整数 p,所得余数为地址,当然 HashMap 里进行了优化改造,效率更高,散列也更均衡。 - 除此之外,还有这几种常见的哈希函数构造方法:
- 直接定址法 :直接根据
key来映射到对应的数组位置,例如 1232 放到下标 1232 的位置。 - 数字分析法 :取
key的某些数字(例如十位和百位)作为映射的位置 - 平方取中法 :取
key平方的中间几位作为映射的位置 - 将
key分割成位数相同的几段,然后把它们的叠加和作为映射的位置。

18 解决哈希冲突有哪些方法?
再哈希法、开放地址法、拉链法
1.再哈希法:使用两个哈希算法函数,发生冲突,再使用第二个哈希算法计算,直到没有冲突,核心思想是多重哈希
2.开放地址法:发生冲突就直接放再数组的下一个空位置
3.拉链法:就是现在使用的接拉链,接红黑树的形式
什么是再哈希法?
准备两套哈希算法,当发生哈希冲突的时候,使用另外一种哈希算法,直到找到空槽为止。对哈希算法的设计要求比较高。
什么是开放地址法?
遇到哈希冲突的时候,就去寻找下一个空的槽。有 3 种方法:
- 线性探测:从冲突的位置开始,依次往后找,直到找到空槽。
- 二次探测:从冲突的位置 x 开始,第一次增加 12 个位置,第二次增加 22,直到找到空槽。
- 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数。
什么是拉链法?
也就是链地址法,当发生哈希冲突的时候,使用链表将冲突的元素串起来。HashMap 采用的正是拉链法。
(1)怎么判断key相等?
使用hashCode()与equals(),流程就是先hashCode判断相等,然后判断内容相等,中间还提前用==判断引用是否相等,相等了就要直接覆盖原值
java
// 先hashcode再equals
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
hashCode() :使用key的hashCode()方法计算key的哈希码。
equals() :当两个key的哈希码相同时,HashMap还会调用key的equals()方法进行精确比较。只有当equals()方法返回true时,两个key才被认为是完全相同的。
如果两个key的引用指向了同一个对象,那么它们的hashCode()和equals()方法都会返回true,所以在 equals 判断之前可以先使用==运算符判断一次。
19 为什么 HashMap 链表转红黑树的阈值为 8 呢?
直接回答:当数组长度大于等于64,链表长度大于等于8的情况下
概率统计 :根据泊松分布,在哈希函数正常的情况下,链表长度达到 8 的概率只有千万分之六。所以 8 基本上是一个'不可能发生'的事件,选择这个数字是为了让红黑树只在极端情况(哈希攻击或哈希函数极差)下才生效。
空间成本 :红黑树节点的大小依然是链表节点的 2 倍 。在节点少时,红黑树不仅浪费空间,且查找优势不明显。只有当长度达到 8 时,红黑树的 O ( log n ) O(\log n) O(logn) 优势才能抵消其空间和维护成本。
防抖动:树化阈值是 8,退化阈值是 6,中间留有缓冲,是为了防止在临界点频繁插入删除导致的数据结构反复转换。"
为什么是8?
和统计学有关。理想情况下,使用随机哈希码,链表里的节点符合泊松分布,出现节点个数的概率是递减的,节点个数为 8 的情况,发生概率仅为 0.00000006。
也就是说,在正常情况下,链表长度达到 8 是个小概率事件。
**8 是一个平衡点。**当链表长度小于 8 时,即使是 O(n) 的查找,由于 n 比较小,实际性能还是可以接受的,而且链表的内存开销小。当链表长度达到 8 时,查找性能已经比较差了,这时候转换为红黑树的收益就比较明显了,因为红黑树的查找、插入、删除操作的时间复杂度都是 O(log n)。
20 HashMap扩容发生在什么时候呢?
发生在数组的实际容量超过数组长度*负载因子
(1)默认的负载因子是多少?
0.75
(2)初始容量是多少?
16
java
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
无论 HashMap 是否扩容,其底层的数组长度都应该是 2 的幂次方,因为这样可以通过位运算快速计算出元素的索引。
(3)为什么是0.75
1.太大容易发生哈希冲突
2.太小浪费空间
这是一个经验值。如果设置得太低,如 0.5,会浪费空间;如果设置得太高,如 0.9,会增加哈希冲突。
0.75 是 JDK 作者经过大量验证后得出的最优解,能够最大限度减少 rehash 的次数。
21 HashMap的扩容机制了解吗?
rehashing()比较耗时的操作,要重新分配冲突元素的位置,详细来说:
扩容时,HashMap 会创建一个新的数组,其容量是原来的两倍。然后遍历旧哈希表中的元素,将其重新分配到新的哈希表中。
如果当前桶是红黑树,那么会调用 split() 方法分裂树节点,以保证树的平衡。
如果当前桶是链表,会通过旧键的哈希值与旧的数组大小取模 (e.hash & oldCap) == 0 来作为判断条件,如果条件为真,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。rehashing
(1)JDK 7 扩容的时候有什么问题?
JDK 7 在扩容的时候使用头插法来重新插入链表节点,这样会导致链表无法保持原有的顺序。
JDK 7 是通过哈希值与数组大小-1 进行与运算确定元素下标的。
java
static int indexFor(int h, int length) {
return h & (length-1);
}
我们来假设:
- 数组 table 的长度为 2
- 键的哈希值为 3、7、5
取模运算后,键发生了哈希冲突,它们都需要放到 table[1] 的桶上。那么扩容前就是这个样子:

假设负载因子 loadFactor 为 1,也就是当元素的个数大于 table 的长度时进行扩容。
扩容后的数组容量为 4。
- key 3 取模(3%4)后是 3,放在
table[3]上。 - key 7 取模(7%4)后是 3,放在
table[3]上的链表头部。 - key 5 取模(5%4)后是 1,放在
table[1]上。

可以看到,由于 JDK 采用的是头插法,7 跑到 3 的前面了,原来的顺序是 3、7、5,7 在 3 的后面。最好的情况就是,扩容后的 7 还在 3 的后面,保持原来的顺序。
(2)JDK 8 是怎么解决这个问题的?
改用尾插法,并且使用e.hash&oldCap来判断是否要保留原索引的位置。JDK 8 改用了尾插法,并且当 (e.hash & oldCap) == 0 时,元素保留在原索引的位置;否则元素移动到原索引 + 旧数组大小的位置。
java
// 源码分析
Node<K,V> loHead = null, loTail = null;// 低位,保留
Node<K,V> hiHead = null, hiTail = null;// 高位,移动位置
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 进行判断,
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loHead != null)
newTab[j] = loHead;
if (hiHead != null)
newTab[j + oldCap] = hiHead; // 高位重新移动位置
示例:
原索引
index = (n - 1) & hash,扩容后的新索引就是index = (2n - 1) & hash。也就是说,如果
(e.hash & oldCap) == 0,元素在新数组中的位置与旧位置相同;否则,元素在新数组中的位置是旧位置 + 旧数组大小。假设扩容前的数组长度为 16(n-1 也就是二进制的 0000 1111,1X 2 0 {2^0} 20+1X 2 1 {2^1} 21+1X 2 2 {2^2} 22+1X 2 3 {2^3} 23=1+2+4+8=15),key1 为 5(二进制为 0000 0101),key2 为 21(二进制为 0001 0101)。
- key1 和 n-1 做 & 运算后为 0000 0101,也就是 5;
- key2 和 n-1 做 & 运算后为 0000 0101,也就是 5。
- 此时哈希冲突了,用拉链法来解决哈希冲突。
现在,HashMap 进行了扩容,容量为原来的 2 倍,也就是 32(n-1 也就是二进制的 0001 1111,1X 2 0 {2^0} 20+1X 2 1 {2^1} 21+1X 2 2 {2^2} 22+1X 2 3 {2^3} 23+1X 2 4 {2^4} 24=1+2+4+8+16=31)。
- key1 和 n-1 做 & 运算后为 0000 0101,也就是 5;
- key2 和 n-1 做 & 运算后为 0001 0101,也就是 21=5+16,就是数组扩容前的位置+原数组的长度。
这样可以避免重新计算所有元素的哈希值,只需检查高位的某一位,就可以快速确定新位置。(直接看高位)
(3)扩容的时候每个节点都要重新计算哈希值吗?
会经过e.hash & oldCap来判断需不需要进行移动,需要移动才会重新计算位置
不需要。HashMap 会通过 (e.hash & oldCap) 来判断节点是否需要移动,0 的话保留原索引;1 才需要移动到新索引(原索引 + oldCap)。
这样就避免了 hashCode 的重新计算,大大提升了扩容的性能。
所以,哪怕有几十万条数据,可能只有一半的数据才需要移动到新位置。另外,位运算的计算速度非常快,因此,尽管扩容操作涉及到遍历整个哈希表并对每个节点进行判断,但这部分操作的计算成本是相对较低的。
!IMPORTANT
主要就是高位判断
(e.hash & oldCap) == 0
22 JDK 8 对 HashMap 做了哪些优化呢?
主要做了4方面优化:加入红黑树、优化链表插入方式头插变尾插、优化扰动函数、扩容时机
底层数据结构由数组 + 链表改成了数组 + 链表或红黑树的结构。
链表的插入方式由头插法改为了尾插法。头插法在扩容后容易改变原来链表的顺序。
1.7:

1.8:

哈希扰动算法也进行了优化。JDK 7 是通过多次移位和异或运算来实现的
1.7:

JDK 8 让 hash 值的高 16 位和低 16 位进行了异或运算,让高位的信息也能参与到低位的计算中,这样可以极大程度上减少哈希碰撞。

23 你能自己设计实现一个 HashMap 吗?
这个背一下,算法,写数组加链表版本
24 HashMap 是线程安全的吗?【*】
不是,要使用concurrentHashMap,在多线程环境下会出现数据丢失和数据覆盖的问题,在jdk8之前的场景还会出现死锁的情况
1.两个线程同时进行同样两个节点的插入,jdk7种就会把两个节点锁住。JDK7 中的 HashMap 使用的是头插法来处理链表,在多线程环境下扩容会出现环形链表,造成死循环。不过,JDK 8 时通过尾插法修复了这个问题,扩容时会保持链表原来的顺序。
2.数据覆盖,两个线程同时进行put操作,对同一个key进行修改。多线程在进行 put 元素的时候,可能会导致元素丢失。因为计算出来的位置可能会被其他线程覆盖掉,比如说一个县城 put 3 的时候,另外一个线程 put 了 7,就把 3 给弄丢了。
3.数据丢失:put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而扩容,线程 2 此时执行 get,就有可能出现这个问题。因为扩容机制因为线程 1 执行完 table = newTab 之后,线程 2 中的 table 已经发生了改变,比如说索引 3 的键值对移动到了索引 7 的位置,此时线程 2 去 get 索引 3 的元素就 get 不到了。
25 怎么解决 HashMap 线程不安全的问题呢?【*】
早期,使用HashTable来在多线程的环境下进行使用,HashTable在每一个方法上synchronized关键词进行加锁。
现在,现在建议使用并发包下的concurrentHashMap,使用CAS+synchronzied关键词保证线程安全

还可以通过 Collections.synchronizedMap 方法返回一个线程安全的 Map,内部是通过 synchronized 对象锁来保证线程安全的,比在方法上直接加 synchronized 关键字更轻量级。

CAS:乐观锁,全程compare and switch,比较并交换,是一种无锁的原子操作。
在 CAS 中,有这样三个值:
- V:要更新的变量(var)
- E:预期值(expected)
- N:新值(new)
比较并交换的过程如下:
判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。
这里的预期值 E 本质上指的是"旧值"。
我们以一个简单的例子来解释这个过程:
- 如果有一个多个线程共享的变量
i原本等于 5,我现在在线程 A 中,想把它设置为新的值 6;- 我们使用 CAS 来做这个事情;
- 首先我们用 i 去与 5 对比,发现它等于 5,说明没有被其它线程改过,那我就把它设置为新的值 6,此次 CAS 成功,
i的值被设置成了 6;- 如果不等于 5,说明
i被其它线程改过了(比如现在i的值为 2),那么我就什么也不做,此次 CAS 失败,i的值仍然为 2。在这个例子中,
i就是 V,5 就是 E,6 就是 N。那有没有可能我在判断了
i为 5 之后,正准备更新它的新值的时候,被其它线程更改了i的值呢?不会的。因为 CAS 是一种原子操作,它是一种系统原语,是一条 CPU 的原子指令,从 CPU 层面已经保证它的原子性。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但
synchronized:
26 HashMap 内部节点是有序的吗?
无序的,根据 hash 值随机插入。
27 讲讲 LinkedHashMap 怎么实现有序的?
在HashMap的基础上,为插入的节点同时维持一个LinkList,并且是双向链表

从而实现插入的顺序或访问顺序。

28 讲讲 TreeMap 怎么实现有序的?
TreeMap的底层就是红黑树
TreeMap 通过 key 的比较器来决定元素的顺序,如果没有指定比较器,那么 key 必须实现 Comparable 接口。
TreeMap 的底层是红黑树,红黑树是一种自平衡的二叉查找树,每个节点都大于其左子树中的任何节点,小于其右子节点树种的任何节点。
基本上使用
TreeMap 底层维护了一棵红黑树 。它依据 Key 实现的
Comparable接口或者构造时传入的Comparator比较器来决定节点在树中的位置(左小右大)。当我们需要遍历时,它利用中序遍历算法,从而输出一个有序的 Key 集合。使用场景:
当你需要 Key 始终保持有序,或者需要进行范围查找(如"查找 10 到 20 之间的所有用户")时使用。
默认排序升序:
javaimport java.util.TreeMap; public class BasicDemo { public static void main(String[] args) { // 1. 创建 TreeMap (默认按 Key 升序) TreeMap<Integer, String> map = new TreeMap<>(); // 2. 乱序插入数据 map.put(3, "张三"); map.put(1, "李四"); map.put(5, "王五"); map.put(2, "赵六"); // 3. 输出直接是有序的! // 结果: {1=李四, 2=赵六, 3=张三, 5=王五} System.out.println(map); // 4. 获取元素 System.out.println(map.get(3)); // 输出: 张三 } }自定义降序:
javaimport java.util.Comparator; import java.util.TreeMap; public class ComparatorDemo { public static void main(String[] args) { // 使用 Lambda 表达式定义比较规则:(o1, o2) -> o2 - o1 表示降序 TreeMap<Integer, String> map = new TreeMap<>((k1, k2) -> k2 - k1); map.put(1, "A"); map.put(3, "B"); map.put(2, "C"); // 结果: {3=B, 2=C, 1=A} (从大到小排好了) System.out.println(map); } }这是
TreeMap区别于HashMap最重要的地方。因为它是有序的,所以它能做很多HashMap做不到的事情。假设我们要处理学生的成绩:
javaTreeMap<Integer, String> scores = new TreeMap<>(); scores.put(98, "A同学"); scores.put(60, "B同学"); scores.put(85, "C同学"); scores.put(45, "D同学"); // 当前顺序: {45=D, 60=B, 85=C, 98=A} // --- 1. 获取极值 --- System.out.println(scores.firstKey()); // 45 (最低分) System.out.println(scores.lastKey()); // 98 (最高分) // --- 2. 查找最近的值 (非常有用!) --- // ceilingKey(80): 返回 >= 80 的最小 Key (天花板) System.out.println(scores.ceilingKey(80)); // 85 (C同学) // floorKey(80): 返回 <= 80 的最大 Key (地板) System.out.println(scores.floorKey(80)); // 60 (B同学) // --- 3. 截取子 Map (范围查询) --- // subMap(from, to): 左闭右开 [60, 90) // 获取 60 分到 90 分之间的所有学生 System.out.println(scores.subMap(60, 90)); // {60=B同学, 85=C同学} // headMap(to): 小于 to 的所有数据 System.out.println(scores.headMap(60)); // {45=D同学} (不及格的) // tailMap(from): 大于等于 from 的所有数据 System.out.println(scores.tailMap(85)); // {85=C同学, 98=A同学} (优秀的)
29 TreeMap 和 HashMap 的区别
- HashMap 是基于数组+链表+红黑树实现的,put 元素的时候会先计算 key 的哈希值,然后通过哈希值计算出元素在数组中的存放下标,然后将元素插入到指定的位置,如果发生哈希冲突,会使用链表来解决,如果链表长度大于 8,会转换为红黑树。
- TreeMap 是基于红黑树实现的,put 元素的时候会先判断根节点是否为空,如果为空,直接插入到根节点,如果不为空,会通过 key 的比较器来判断元素应该插入到左子树还是右子树。
在没有发生哈希冲突的情况下,HashMap 的查找效率是 O(1)。适用于查找操作比较频繁的场景。
TreeMap 的查找效率是 O(logn)。并且保证了元素的顺序,因此适用于需要大量范围查找或者有序遍历的场景。
| 特性 | HashMap | LinkedHashMap | TreeMap |
|---|---|---|---|
| 底层结构 | 数组+链表+红黑树 | HashMap + 双向链表 | 红黑树 |
| 有序性 | 无序 | 插入顺序 (或访问顺序) | Key 的大小顺序 |
| 实现原理 | Hash 算法 | 链表记录前后关系 | 二叉树的中序遍历 |
| 应用场景 | 绝大多数场景 | 需要记录缓存LRU/插入序 | 需要按 Key 排序/范围查找 |
Set
30 讲讲 HashSet 的底层实现?
使用HashMap实现,只用key,不使用value,value会使用Object填充。HashSet 是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。
java
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
static final long serialVersionUID = -5024744406713321676L;
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
// ......
}
实际开发中,HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么 ArrayList 和 LinkedList 更适合;如果我们需要存储键值对并根据键进行查找,那么 HashMap 可能更适合。
HashSet用于去重,比如,我们需要统计一篇文章中有多少个不重复的单词,就可以使用 HashSet 来实现。
java
// 创建一个 HashSet 对象
HashSet<String> set = new HashSet<>();
// 添加元素
set.add("沉默");
set.add("王二");
set.add("陈清扬");
set.add("沉默");
// 输出 HashSet 的元素个数
System.out.println("HashSet size: " + set.size()); // output: 3
// 遍历 HashSet
for (String s : set) {
System.out.println(s);
}
HashSet 会自动去重,因为它是用 HashMap 实现的,HashMap 的键是唯一的,相同键会覆盖掉原来的键,于是第二次 add 一个相同键的元素会直接覆盖掉第一次的键。
(1)HashSet 和 ArrayList 的区别
- 底层实现:ArrayList 是基于动态数组实现的,HashSet 是基于 HashMap 实现的。
- 元素唯一性:ArrayList 允许重复元素和 null 值,可以有多个相同的元素;HashSet 保证每个元素唯一,不允许重复元素,基于元素的 hashCode 和 equals 方法来确定元素的唯一性。
- 有序性:rrayList 保持元素的插入顺序,可以通过索引访问元素;HashSet 不保证元素的顺序,元素的存储顺序依赖于哈希算法,并且可能随着元素的添加或删除而改变。
(2)HashSet 怎么判断元素重复,重复了是否 put
HashSet 的 add 方法是通过调用 HashMap 的 put 方法实现的:
java
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
也就是说,HashSet 通过元素的哈希值来判断元素是否重复,如果重复了,会覆盖原来的值。
重复了还是要put


