Java 集合
Java 集合
学习视频:
【韩顺平讲Java】Java集合专题 -ArrayList HashMap HashSet List Map TreeMap TreeSet等_哔哩哔哩_bilibili
1. 集合介绍
- 数组的不足:不够灵活
- 长度开始时必须指定,而且一旦指定,不能更改
- 保存的必须为同一类型的元素
- 使用数组进行增加元素的示意代码比较麻烦
1 | // 数组扩容示例代码 |
- 集合的好处:
- 可以动态保存任意多个对象,使用比较方便
- 提供了一系列方便的操作对象的方法(crud):add、remove、set、get 等
- 使用集合添加,删除新元素代码更加简洁
2. 集合的框架体系-背
- Collection 接口继承了 Iterable 接口
- List Set 接口继承了 Collection 接口
- List 接口常用的实现类有 ArrayList,LinkedList
- Set 接口常用的实现类有 HashSet,TreeSet
- Map 接口
- 常见实现类:HashMap、LinkedHashMap、TreeMap
- LinkedHashMap 继承了 HashMap 的同时实现了 Map 接口
- 集合主要有2组:
- 一组是单列集合(集合里面放的是单个对象)
- Collection 接口有 List 和 Set 这两个主要的接口,他们的实现类都是单列集合
- 另一组是双列集合(存储的往往是键值对形式的)
- Map 接口的实现子类是双列集合,存放 key 和 value 数据
- 一组是单列集合(集合里面放的是单个对象)
3. Collection
3.1 Collection 接口实现类的特点
- 接口声明:
Collection
是一个泛型接口,继承自Iterable
接口,允许其实现类的对象能被迭代
1 | public interface Collection<E> extends Iterable<E> |
- Collection 实现子类可以存放多个元素,每个元素可以是Object
- 不同的 Collection 实现具有不同的元素存储特性:
- 允许重复元素:例如
List
实现类(如ArrayList
,LinkedList
)允许插入重复元素。 - 不允许重复元素:
Set
实现类(如HashSet
,TreeSet
)保证元素唯一性。
- 允许重复元素:例如
- 集合是否有序取决于具体实现:
- 有序集合:
List
接口的实现类保持元素插入的顺序。 - 无序集合:
Set
接口的实现类通常不保持任何特定顺序(LinkedHashSet
除外,它按照插入顺序维护元素)。
- 有序集合:
- Collection 接口没有直接的实现子类,是通过它的子接口 Set 和 List 来实现的
3.2 Collection 接口常用方法
1 | /** |
3.3 Collection 接口遍历元素
遍历元素方式1—使用 Iterator 迭代器
迭代器是一个对象,这个对象实现了
Iterator
接口,主要用于遍历 Collection 集合中的元素,该接口包含几个方法:hasNext()
: 检查集合中是否还有元素可以遍历。如果有,返回true
;如果没有,返回false
。next()
: 迭代器将其内部游标向前移动到下一个元素的位置,同时返回游标所到达位置的元素。
在调用 next() 方法之前必须要调用 hasNext() 进行检测,若不调用且下一条记录无效,直接调用 next() 会抛出 NoSuchElementException 异常
remove()
: 删除最近通过next()
方法返回的元素。这个方法是可选的,集合本身可以不支持这个操作。
- 使用迭代器可以通过一个统一的接口遍历不同类型的集合(如
ArrayList
,HashSet
等),而不必关心它们内部是如何组织数据的 - 所有实现了Collection 接口的集合类都有一个 iterator() 方法,用于返回一个实现了 Iterator 接口的对象,即可以返回一个迭代器
Iterator 仅用于遍历集合,Iterator 本身不存放对象
代码示例:快捷键
itit
- (提示所有快捷键的快捷键:
ctrl + j
)
- (提示所有快捷键的快捷键:
1 | public class CollectionIterator { |
遍历元素方式2—使用 for 循环增强
- 增强 for循环,可以代替 iterator 迭代器,特点:增强 for 就是简化版的 iterator,本质一样。只能用于遍历集合或数组
- 基本语法:
- “元素类型”是集合或数组中包含的元素的数据类型
- “元素变量”是在循环体内部用于访问当前遍历元素的变量名
- “集合名或数组名”是要遍历的集合或数组的变量名
1 | for (元素类型 元素变量 : 集合对象或数组) { |
- 代码示例:快捷键
I
或者集合对象.for
- 补充:传统 for 循环的快捷键:
fori
- 补充:传统 for 循环的快捷键:
1 | // 使用增强 for 遍历集合 |
使用注意事项:
只读遍历:使用增强
for
循环时,不能修改正在遍历的集合的结构,例如添加或删除元素,这可能会导致ConcurrentModificationException
异常。如果需要在遍历过程中修改集合,应使用显式的迭代器并调用其remove()
方法遍历对象类型:增强
for
循环可以遍历任何实现了Iterable
接口的集合类以及所有类型的数组局限性:尽管增强
for
循环语法简单,但它不适合在需要使用元素索引或需要从集合中间开始遍历的场景。在这种情况下,使用传统的for
循环会更合适
3.4 练习题
编写程序 CollectionExercise
:
- 创建3个Dog{name,age}对象,放入到ArrayList中,赋给List使用
- 用迭代器和增强for循环两种方式来遍历
- 重写Dog的toString方法,输出name和age
1 | public class CollectionEx { |
4. List
4.1 List 接口基本介绍
List 接口是 Collection 接口的子接口,特点如下:
- 有序性:List 集合类中元素有序(即添加顺序和取出顺序一致)
- 元素重复:与 Set 接口不同,List 允许重复的元素,即同一个值可以出现多次
- 索引访问:List 集合支持索引,每个元素都有其对应的顺序索引,开始于0
- 整数型序号:List 容器中的元素都对应一个整数型的序号,记录其在容器中的位置,可以根据序号存取容器中的元素
- List 接口的常用实现类有:ArrayList、LinkedList 和 Vector
1 | public class List_ { |
4.2 List 接口常用方法
void add(int index, Object ele)
:在index位置插入ele元素,没有index,默认在最后插入boolean addAll(int index, Collection eles)
:从index位置开始将eles中的所有元素添加进来Object get(int index)
:获取指定index位置的元素int indexOf(Object obj)
:返回obj在集合中首次出现的位置int lastIndexOf(Object obj)
:返回obj在当前集合中末次出现的位置Object remove(int index)
:移除指定index位置的元素,并返回此元素Object set(int index, Object ele)
:设置指定index位置的元素为ele , 相当于是替换List subList(int fromIndex, int toIndex)
:返回从fromIndex到toIndex位置的子集合,注意返回的子集合 fromIndex <= subList < toIndex
1 | public class ListMethod { |
4.3 List 的三种遍历方式
ArrayList, LinkedList,Vector 三种遍历使用的方式一致
- 方式一:使用迭代器
- 方式二:增强for循环
- 方式三:使用普通for循环
1 | public static void main(String[] args) { |
- 练习:
1 | public static void main(String[] args) { |
4.4 List 接口实现类-ArrayList
注意事项
- 空元素支持:
ArrayList
允许包含任意数量的null
元素。这使得它在某些使用场景中非常灵活,例如在数据集中可能存在的缺失数据可以用null
表示 - 内部数据存储:
ArrayList
是基于动态数组实现的,这意味着它的元素存放在数组中。这也解释了为什么ArrayList
可以提供快速的随机访问能力 - 与
Vector
的比较:- 相似性:
ArrayList
和Vector
都是基于数组的实现,提供快速的随机访问和有序的元素存储 - 线程安全:
ArrayList
是非同步的,因此它是线程不安全的,而Vector
是同步的,因此是线程安全的。这使得ArrayList
在单线程环境下性能更优,但在多线程环境中可能需要外部同步措施
- 相似性:
- 对于需要线程安全的
ArrayList
替代方案:- 使用 Vector 容器
- 使用 Collections 的静态方法
synchronizedList(List< T> list)
- 采用 CopyOnWriteArrayList 容器
- 读多写少的情况下,推荐使用
CopyOnWriteArrayList
方式- 读少写多的情况下,推荐使用
Collections.synchronizedList()
的方式
底层结构和源码分析
- ArrayList 中维护了一个 Object 类型的数组 elementData
1 | // transient 表示瞬间,短暂的,表示该属性不能被序列化 |
在 Java 中,序列化通常指的是将对象转换为字节流,以便可以将其写入磁盘、通过网络等发送。反序列化是序列化的逆过程,它从字节流中重构对象。
序列化的目的是持久化存储/网络通信等等,使
elementData
成为transient
的原因:
- 空间效率:通常
elementData
的大小(容量)比实际存储的元素数量要大,如果elementData
被序列化,那么即使数组中有大量未使用的空间,这些空间也会被序列化,导致不必要的存储和传输开销。- 时间效率:序列化和反序列化一个包含许多
null
值的大数组会显著降低性能,尤其是在网络传输等场景中。
当创建 ArrayList 对象时,
- 如果使用的是无参构造器,则初始 elementData 容量为0,第1次添加,则扩容 elementData 为 10,如果需要再次扩容,则扩容 elementData 为1.5倍
1
2int newCapacity = oldCapacity + (oldCapacity >> 1); // 10 + 10/2 = 15 (即 10 的 1.5 倍)
// 0 => 10 => 15 => 22 => 33 => ...- 如果使用的是指定大小的构造器,则初始 elementData 容量为指定大小,如果需要扩容,则直接扩容 elementData 为 1.5 倍
1
2// 假设初始指定为 8, 则:
// 8 => 12(8+8/2) => 18 => 27 => ...
- 注意:Idea 默认情况下,Debug 显示的数据是简化后的(比如数组不会显示 null 的元素),如果希望看到完整的数据,需要做如下设置:
- 加断点分析源码:
1 | public static void main(String[] args) { |
使用无参构造器,创建和使用 ArrayList:
- 创建静态空数组
elementData
,DEFAULTCAPACITY_EMPTY_ELEMENTDATA
是一个静态的空数组实例
1
2
3
4
5public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};- 添加元素
- 调用
add(E e)
来添加一个元素 add
方法首先调用ensureCapacityInternal(size + 1)
来确保容量ensureCapacityInternal
调用calculateCapacity
来确定所需的容量,然后调用ensureExplicitCapacity
ensureExplicitCapacity
检查当前容量是否足够,如果不够,调用grow
来扩容- 添加元素到数组,增加
size
- 调用
1
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62public boolean add(E e) {
// 确保 ArrayList 有足够的空间来存储新添加的元素
ensureCapacityInternal(size + 1);
// 将新元素 e 存储在 elementData 数组的 size 索引位置, 赋值完成后增加 size 的值
elementData[size++] = e;
return true;
}
// 确保内部数组 elementData 有足够的容量
private void ensureCapacityInternal(int minCapacity) {
// 调用 calculateCapacity 来确定所需的容量
// 然后调用 ensureExplicitCapacity
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
// 计算并返回 ArrayList 所需的最小容量
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 检查是否是默认空数组,如果是,意味着这是首次添加元素,此将容量设为 DEFAULT_CAPACITY = 10 和 minCapacity 中的较大值,以避免过早的数组扩容
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity); // max(10, 1),返回 10
}
// 如果不是空数组,直接返回传入的 minCapacity
return minCapacity;
}
// ensureExplicitCapacity 检查当前容量是否足够,如果不够,调用 grow 来扩容
private void ensureExplicitCapacity(int minCapacity) {
modCount++; // 记录当前集合被修改的次数,防止多线程操作出现异常
// 如果所需最小容量大于当前数组长度,则需要扩容
// 10 - 0 > 0 不够的
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 扩容
private void grow(int minCapacity) { // 10
// 获取旧容量
int oldCapacity = elementData.length; // 0
// 新容量为旧容量的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1); // 首次 0 + 0/2 = 0
// 如果1.5倍仍不满足需求,则直接使用所需最小容量
if (newCapacity - minCapacity < 0) // 0-10<0 第一次由于初始容量为0,没法按照 1.5 倍机制扩容,所以新容量就是 newCapacity = 10
newCapacity = minCapacity;
// 如果新容量超出了最大数组大小,则调用 hugeCapacity 来处理
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 将 elementData 数组扩展到 newCapacity 指定的大小,并保留所有现有元素,新扩展的部分(如果有)将初始化为 null
elementData = Arrays.copyOf(elementData, newCapacity);
}
// 处理在数组需要扩容到非常大的尺寸时的特殊情况,,保证 ArrayList 不会因为数组大小请求不当而导致程序崩溃或安全问题
private static int hugeCapacity(int minCapacity) {
// 如果 minCapacity 小于 0,这通常是由于整数溢出导致的,抛出 OutOfMemoryError,表示内存溢出错误
if (minCapacity < 0)
throw new OutOfMemoryError();
// Java 的数组最大长度是 MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8,因为某些 JVM 实现通过使用数组的一部分作为数组头部
// 如果 minCapacity 要求的容量超过了 MAX_ARRAY_SIZE 但未溢出,方法返回 MAX_ARRAY_SIZE,即数组能达到的最大安全容量
// 如果 minCapacity 请求超过了 Integer.MAX_VALUE,则方法返回 Integer.MAX_VALUE
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}- 创建静态空数组
使用指定大小的构造器,创建和使用 ArrayList:
- 创建一个指定大小的 elementData 数组:传入的初始化容量 (
initialCapacity
) 大于 0,构造函数会创建一个新的Object
数组,其长度等于指定的initialCapacity
- 第一次扩容就按照elementData 的 1.5 倍扩容,其他步骤同无参构造器
1
2
3
4
5
6
7
8
9
10
11
12public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
// 如果传入的初始化容量为 0,则构造函数不分配任何数据存储空间,而是将 elementData 初始化为 EMPTY_ELEMENTDATA(一个预定义的空数组)
this.elementData = EMPTY_ELEMENTDATA;
} else {
// 如果传入的初始化容量小于 0,则构造函数抛出 IllegalArgumentException
throw new IllegalArgumentException("Illegal Capacity: " +
initialCapacity);
}
}- 创建一个指定大小的 elementData 数组:传入的初始化容量 (
4.5 List 接口实现类-Vector
基本介绍
- Vector 类的定义说明:
1 | public class Vector<E> |
- Vector 底层也是一个对象数组,
1 | protected Object[] elementData |
- Vector 是线程同步的,即线程安全,Vector 类的操作方法基本都带有 synchronized
1 | public synchronized boolean add(E e) { |
- 开发中一般用 JUC 包中绝对线程安全的集合,Vector 只是相对安全,add、remove 这些方法不是原子操作,所以还是别用 Vector
Vector VS. ArrayList
Vector()
无参构造器内部调用了另一个构造器Vector(int initialCapacity)
,并且传递了一个默认的初始容量值10
- 如果初始容量不是负数,这个构造器将创建一个新的对象数组
elementData
,数组的长度为initialCapacity
- 如果初始容量不是负数,这个构造器将创建一个新的对象数组
1 | public Vector() { |
ArrayList
使用的是一种称为延迟初始化的策略(Lazy Initialization)。这意味着除非真正需要(即首次添加元素),否则不会创建存储元素的内部数组在很多使用场景中,
ArrayList
可能仅仅是被实例化但并未使用,或者只添加了少量元素。使用延迟初始化可以避免在这些情况下分配不必要的内存,从而更高效地使用资源
Vector 的底层扩容结构
- **调用
add(E e)
**:开始添加元素。 - **调用
ensureCapacityHelper(int minCapacity)
**:确保有足够的容量。 - 调用
grow(int minCapacity)
(如果需要):进行实际的数组扩容。 - **完成
add(E e)
**:元素添加到数组,方法结束。
1 | // 添加一个元素 e 到 Vector 的末尾 |
4.6 List 接口实现类-LinkedList
底层结构
- LinkedList 实现了双向链表和双端队列特点
- 双向链表的结构
LinkedList
不仅支持普通的列表操作(如添加、删除、查找等),还支持双端队列的操作 - 双端队列(deque,全称“double-ended queue”)是一种允许从两端添加和删除元素的队列,意味着
LinkedList
可以作为栈(后进先出)和队列(先进先出)使用
- 双向链表的结构
- 可以包含任意元素:可以添加任意元素(元素可以重复),包括 null
- 线程不安全:没有实现同步
操作机制
- 双向链表结构:LinkedList 底层维护了一个双向链表,允许向前或向后遍历
- 首尾节点引用:LinkedList 中维护了两个属性 first 和 last 分别指向首节点和尾节点,是管理链表添加或删除元素时的关键
- 节点结构:每个节点(Node 对象)里又维护了 prev,next,item 三个属性,其中通过 prev 指向前一个,通过 next 指向后一个节点 ,最终实现双向链表
1 | private static class Node<E> { |
- 添加和删除元素:LinkedList 的元素的添加和删除,不是通过数组完成的,相对来说效率较高
LinkedList VS. ArrayList
如何选择 ArrayList和ListedList:
- 如果改查的操作多,选择ArrayList
- 如果增删的操作多,选择ListedList
- 一般在程序中,80-90% 都是查询,大部分情况下会选择ArrayList
- 在项目中,会根据业务灵活选择,可能一个模块用ArrayList,另一个用ListedList
LinkedList 底层 crud 方法 debug 分析
LinkedList
的无参构造器创建了一个空的LinkedList
,因为元素是通过节点 (Node
) 动态连接的。以下是在这个构造器中隐式初始化的成员:- **
size
**:追踪LinkedList
中元素的数量,初始值为0
- **
first
**:指向链表中的第一个节点,初始值为null
- **
last
**:指向链表中的最后一个节点,初始值为null
- **
这些属性是
transient
的,这意味着它们不是由序列化机制自动保存和恢复的,这主要是出于链表元素的序列化需要特殊处理的考虑
1 | transient int size = 0; |
- 实现双向链表添加元素的核心代码:
- 获取当前尾节点:将当前的尾节点引用存储在局部变量
l
中。这是链表中最后一个节点,如果链表为空,则l
为null
- 创建新节点:创建一个新的节点
newNode
,其数据域为e
,prev
指针指向当前的尾节点l
(如果存在),next
指针为null
(因为它将成为新的尾节点) - 更新尾节点引用:将
last
引用更新为新创建的节点newNode
,这样newNode
现在正式成为链表的尾节点 - 更新头节点引用(如果需要):如果
l
为null
,意味着链表之前是空的(没有节点),因此新节点也是链表的第一个节点。故将first
引用指向newNode
- 链接前一个尾节点到新节点:如果链表不为空(即
l
不是null
),将原来尾节点的next
指针指向新节点newNode
- 增加链表大小
- 更新修改计数:增加
modCount
,这是用于迭代器的快速失败机制(fail-fast behavior),即在迭代过程中如果检测到结构性修改,会抛出ConcurrentModificationException
- 获取当前尾节点:将当前的尾节点引用存储在局部变量
1 | // 向 LinkedList 的末尾添加一个新元素 e |
- 实现双向链表删除链表的第一个节点的核心代码:
- 获取和清除数据:
- 存储第一个节点的值到局部变量
element
。 - 将节点的
item
和next
设置为null
,帮助垃圾收集器进行回收。
- 存储第一个节点的值到局部变量
- 更新头部链接:
- 将
first
更新为next
,如果next
为null
(即原链表只有一个节点),则同时将last
设置为null
。 - 如果
next
不是null
,更新next.prev
为null
,断开它与原首节点的链接。
- 将
- 维护链表状态:
- 减小
size
。 - 增加
modCount
,支持迭代器的快速失败行为。
- 减小
- 获取和清除数据:
1 | public E remove() { |
5. Set
5.1 Set 接口基本介绍
特点:
无序性:
Set
集合中的元素没有固定的顺序,元素的存储位置由Set
的实现方式决定。添加和移除元素的顺序不保证一致性,也就是说,迭代Set
时元素的返回顺序不一定反映它们被添加的顺序唯一性:
Set
不允许重复的元素。每个元素在Set
中只能出现一次。如果尝试添加重复的元素,添加操作通常会被忽略有限的
null
支持:大多数Set
实现允许至少一个null
元素(如果允许的话),但添加多个null
会被视为重复
1 | // 以 Set 接口的实现类 HashSet 为例 |
JDK 中的 Set 接口实现类:
HashSet
:基于哈希表的Set
实现,提供快速的查询速度,不保证顺序TreeSet
:基于红黑树实现,元素会按照自然顺序或创建TreeSet
时提供的比较器进行排序LinkedHashSet
:类似于HashSet
,但维护了一个运行于所有条目的双重链接列表,保留了插入的顺序….
常用方法: 和 List 接口一样, Set 接口也是 Collection 的子接口,因此,常用方法和 Collection 接口一样
5.2 Set 接口的遍历方式
Set
集合的遍历方式与Collection
接口的其他实现相同,因为Set
是Collection
接口的一个子接口
- 使用迭代器
1 | // 方式1: 使用迭代器 |
- 增强 for 循环
1 | for (Object o : set) { // 快捷键: set.for |
不能使用索引的方式来获取
补充:Java 8 及更高版本的流
在 Java 8 引入的流(Streams)API 是一种高效处理集合(如
List
、Set
等)的方法,特别适用于对集合执行复杂的查询和转换操作。流 API 可以用来遍历、筛选、映射、归约以及更多其它操作List
或任何其他类型的Collection
- 简单遍历:
1
set.stream().forEach(System.out::println);
- 更复杂的操作举例 List:
1
2
3
4
5
6
7
8
9
10List<String> items = List.of("apple", "banana", "avocado", "cherry", "date");
// 使用流过滤、转换并收集结果
List<String> result = items.stream()
.filter(s -> s.startsWith("a")) // 只选择以"a"开头的元素
.map(String::toUpperCase) // 将每个元素转换为大写
.sorted() // 排序
.collect(Collectors.toList()); // 收集为列表
System.out.println(result); // 输出: [APPLE, AVOCADO]
5.3 Set 接口实现类-HashSet
基本介绍
- 实现 Set 接口:
HashSet
是Set
接口的一个实现,提供了集合的基本操作,如添加、删除元素以及查询等,同时确保集合中的元素不重复 - 底层使用 HashMap:
HashSet
内部实际上使用了一个HashMap
来存储其元素,每个插入HashSet
的元素都作为HashMap
的键存储,而其值则使用一个固定的对象(通常是一个预定义的私有静态的final
对象- 使用
HashMap
支持使得HashSet
在添加元素、删除元素和包含元素检查等操作上非常高效,时间复杂度为 O(1)
1 | public HashSet() { |
- 允许一个 null 值:
HashSet
允许包含一个null
元素,因为底层的HashMap
允许其中一个键为null
。这对于某些需要表示特定场景的空值非常有用 - 无序性:
HashSet
不保证集合中元素的顺序,元素的存储取决于它们的哈希码- 当元素被添加到
HashSet
中时,其位置是根据哈希码计算得到的,这意味着元素的遍历顺序可能与添加顺序不同
- 哈希冲突与重新哈希:当哈希表的负载因子达到一定程度(默认是 0.75)时,哈希表会进行扩容(通常扩容为原来的两倍),这个过程可能会改变现有元素的顺序
- 不允许重复元素:
HashSet
不允许重复元素。当尝试添加一个已存在的元素时,添加操作将不会改变集合,也不会抛出异常
代码示例
1 | HashSet set = new HashSet(); |
模拟哈希表(数组+链表)
- HashSet 的底层是 HashMap,HashMap的底层是 数组+链表/红黑树
1 | // 模拟一个 HashSet 的底层(其实也就是 HashMap 的底层结构) |
底层机制
- 分析HashSet的添加元素底层是如何实现:hash() + equals()
- HashSet 底层使用 HashMap 存储所有元素,在 HashSet 中添加一个元素实际上是将该元素作为键添加到内部的 HashMap 中
- 添加一个元素时,会得到 hash 值 -> 索引值
- 处理哈希碰撞:找到存储数据表table,看这个索引位置是否已经存放了元素
- 如果没有,直接将元素存储到该位置
- 如果有,调用
equals()
方法比较- 如果
equals()
返回true
,就放弃添加 - 如果
equals()
返回false
,则继续与链表中的下一个元素比较,直到找到相同的元素或链表结束
- 如果
- 链表与树化:
- 链表处理:如果一个索引位置上的冲突元素较多(但未达到树化阈值),则以链表形式存储
- 在 Java 8 及更高版本中,如果某个索引位置的链表长度
>= TREEIFY_THRESHOLD
(默认为8),并且整个HashMap
的容量>= MIN_TREEIFY_CAPACITY
(默认为64),则这个链表会转化为红黑树,以提高搜索效率
- 添加成功与否:
- 如果在整个链表或红黑树中没有找到相同的元素,则将新元素添加到链表的末尾或红黑树中
HashSet
的add()
方法返回一个boolean
值,表示元素是否被添加。如果元素因为已存在而未被添加,则返回false4
- 构造器:初始化一个
HashSet
实例时,内部实际上创建了一个HashMap
来存储集合中的元素- 默认负载因子:
HashMap
默认的负载因子是0.75
,这意味着当HashMap
的容量达到其容量的 75% 时,会自动扩容(重新调整内部数据结构的大小)
- 默认负载因子:
1 | // HashSet.java 代码 |
HashSet
的add
方法:HashSet
添加元素实际上是调用内部HashMap
的put
方法,将元素e
作为键存入。所有键共享同一个值PRESENT
,这只是一个占位符,没有实际意义
1 | // add 方法 |
HashMap
的put
方法:put
方法首先计算键的哈希值,然后调用putVal
方法处理实际的插入逻辑
1 | // HashMap.java 代码 |
- 计算哈希值(不等价于hashCode):通过将对象的
hashCode()
结果与其自身右移 16 位的结果进行异或操作,从而减少高位和低位的冲突,改进了哈希碰撞的问题:(h ^ (h >>> 16)
)- 异或:异1同0,用于混合原始哈希码的高位和低位,增加哈希码的随机性和均匀性
- **无符号右移 (
>>>
)**:将数值的二进制位向右移动指定的位数,右边超出的部分被丢弃,左边空出的位用 0 填充,用来混合哈希码,避免高位的信息在计算索引时被忽略
1 | // force step into 到 hash(key) 方法 |
putVal
方法:寻找插入位置:首先检查是否需要扩容。如果对应的桶(数组位置)是空的,则直接插入新节点
处理哈希碰撞:如果桶中已有元素,则通过链表或红黑树(当链表长度太长时转换为红黑树以优化搜索效率)处理碰撞
- 代码:既提高了性能(通过快速的引用比较),又保证了准确性(通过内容比较)
- 引用比较 (
==
) 是一种非常快速的操作,比调用任何方法都要快,因为它直接比较内存地址。如果两个引用指向同一个对象,那么它们肯定相等,不需要进一步使用equals
方法比较 - 即使两个对象的引用不同(它们位于内存中不同的位置),它们仍可能表示相同的数据。例如,两个不同的
String
对象可能包含相同的文本。通过equals
方法,可以确保逻辑上相等的对象能够被正确识别
- 引用比较 (
1
2
3if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;- 代码:既提高了性能(通过快速的引用比较),又保证了准确性(通过内容比较)
键的比较:使用
equals
方法检查键是否已存在。如果已存在,根据onlyIfAbsent
参数决定是否更新值
1 | transient Node<K,V>[] table; |
resize
扩容方法:初始化和条件检查:
- 保存旧数组:首先,旧数组
oldTab
被保存,用于之后的元素迁移。 - 检查旧容量:获取旧数组的长度
oldCap
。如果数组未初始化(即为null
),oldCap
设为0
。 - 旧阈值:保存旧的扩容阈值
oldThr
。
- 保存旧数组:首先,旧数组
判断是否需要扩容:
达到最大容量:如果旧容量已经等于最大可能容量
MAXIMUM_CAPACITY
(即1 << 30
),将阈值设置为Integer.MAX_VALUE
并直接返回旧表,不再扩容。一般扩容条件:如果旧容量小于最大容量且大于默认初始容量,新容量
newCap
设为旧容量的两倍,新阈值newThr
也相应翻倍。
处理特殊初始化条件:
初次初始化:如果是首次初始化(即旧阈值大于
0
且旧容量为0
),直接使用旧阈值作为新容量。默认初始化:如果旧容量和旧阈值都是
0
(未初始化状态),设置新容量为默认初始容量(16
),计算新阈值为容量与默认负载因子(0.75
)的乘积。
重新计算新阈值:
- 阈值计算:如果新阈值未设定(即
newThr == 0
),根据新容量和负载因子计算新阈值。如果计算结果接近最大容量,直接将阈值设置为Integer.MAX_VALUE
。
- 阈值计算:如果新阈值未设定(即
创建新数组并设置新表:
新数组创建:基于新容量创建新的节点数组
newTab
。更新哈希表:将
HashMap
的table
引用更新为新数组。
重新散列旧元素:
- 遍历旧数组:遍历每个索引位置的旧数组元素。
- 单节点直接移动:如果该位置只有一个节点,直接计算新位置并放入新数组。
- 处理树或链表:如果节点形成了链表或树:
- 树节点分割:对于树节点,执行分割操作,将节点根据当前的容量分散到新的索引位置。
- 链表重分布:对于链表,节点被分为两组,一组索引位置不变,另一组移动到
原索引 + oldCap
的位置。- 链表节点迁移:根据节点哈希值的高位决定分组。
- 遍历旧数组:遍历每个索引位置的旧数组元素。
返回新数组:返回新的哈希表数组
newTab
,完成扩容和重新散列过程。
1 | int threshold; // 默认为 0 |
treeifyBin
转红黑树方法
1 | final void treeifyBin(Node<K,V>[] tab, int hash) { |
- 扩容机制的触发?
- 一种是总的键值对数量超过了负载因子与当前容量的乘积所定义的阈值
- 初始设置:
HashMap
默认的初始容量是 16 (DEFAULT_INITIAL_CAPACITY
), 负载因子是 0.75 (DEFAULT_LOAD_FACTOR
)。 - 计算阈值:阈值 (
threshold
) 是触发扩容的点,计算为容量 * 负载因子
。初始情况下,阈值是16 * 0.75 = 12
。 - 扩容条件:每次向
HashMap
添加元素后,如果哈希表中的元素数量超过当前阈值,就会触发扩容。扩容通常将容量翻倍(例如从 16 增加到 32,阈值相应地从 12 增加到24
)。 - 扩容过程:在扩容过程中,旧数组中的元素会被重新计算索引并移动到新的数组中,这个过程称为重新散列(rehashing)。
- 初始设置:
- 另一种是单个哈希桶(链表或树)的结构因为太长而需要转换
- 链表长度阈值:如果
HashMap
中某个桶的链表长度达到TREEIFY_THRESHOLD
(默认为 8),则可能触发树化。但树化之前还有一层检查,即桶数组的总容量是否足够大。 - 最小树化容量:树化发生之前,桶数组的容量必须达到
MIN_TREEIFY_CAPACITY
(默认为 64)。这意味着如果容量小于这个值,即使链表长度达到了 8,系统也会选择先扩容而不是树化。 - 先扩容还是先树化:如果在链表长度达到 8 时容量已经达到或超过了 64,将直接进行树化;如果容量还不足 64,则会先进行扩容,容量通常翻倍,这有助于分散哈希碰撞并可能避免立即树化。
- 链表长度阈值:如果
- 一种是总的键值对数量超过了负载因子与当前容量的乘积所定义的阈值
练习
- 当一个对象作为哈希基础集合(如
HashSet
或HashMap
)的元素时,不仅该对象本身需要适当重写hashCode()
和equals()
方法,其内部属性对象(如果这些属性对等价性判断有影响)同样需要重写这些方法 - eg. 当一个对象(如
Emp
)的等价性判断依赖于另一个对象(如MyDate
)时,后者也必须正确实现hashCode()
和equals()
方法。这是因为前者的方法实现直接依赖于后者的等价性逻辑
1 | public class HashSetPractice01 { |
5.4 Set 接口实现类-LinkedHashSet
基本介绍
- 继承关系:LinkedHashSet 是 HashSet 的子类
1 | public class LinkedHashSet<E> |
- 底层由 LinkedHashMap 实现:LinkedHashSet 底层是一个 LinkedHashMap,内部结构包括一个哈希表(用于存储数据)和一个双向链表(用于维护插入顺序)
- 每个添加到
LinkedHashMap
中的元素都会被插入到双向链表的尾部
- 每个添加到
- 元素顺序:LinkedHashSet 根据元素 hashCode 来决定元素的存储位置,同时使用链表维护元素的次序,这使得元素看起来是以插入顺序保存的
- 不允许重复元素:同
HashSet
,LinkedHashSet
也是基于集合的性质,不允许添加重复的元素。它使用hashCode()
和equals()
方法来检测重复
底层机制
- 数据结构:
LinkedHashSet
内部实际上使用了LinkedHashMap
来存储元素。LinkedHashMap
本质上是HashMap
的一个子类,但它在每个节点中额外维护了两个指针,分别指向链表中的前一个和后一个节点
节点结构:
- 除了键值对信息外,每个节点还有
prev
(前驱)和next
(后继)两个属性,用于链接前一个和后一个节点,形成一个双向链表 - 这个链表通过头指针
head
和尾指针tail
维护,确保插入顺序
- 除了键值对信息外,每个节点还有
节点类型:
- 添加第一次时,直接将 数组 table 扩容到 16 ,存放的结点类型是
LinkedHashMap$Entry
- 数组 table 是
HashMap$Node[]
存放的元素/数据是LinkedHashMap$Entry
类型,(数组多态?), LinkedHashMap.Entry
类继承了HashMap.Node
类(静态内部类,因为是通过类名去访问的),不仅存储键值对(即数据部分),还包括两个额外的指针:before
,after
,用于维护节点的插入顺序
1
2
3
4
5
6static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}- 添加第一次时,直接将 数组 table 扩容到 16 ,存放的结点类型是
添加元素:
这里其实都类似,就是功能增强,增加的双指针没有破坏原来的结构,感觉就是多了一个有序的功能
- 当添加一个新元素时,首先计算该元素的哈希值,然后根据哈希值计算其在哈希表中的索引
- 如果元素是首次添加(即在哈希表中找不到对应的条目),该元素将被封装为一个新的节点,并添加到双向链表的末尾(如果已经存在,不添加[原则和hashset一样])
1
2
3tail.next= newElement; // 简单指定
newElement.pre tail;
tail newEelment;遍历顺序:由于元素是按照添加顺序连续链接的,遍历
LinkedHashSet
时,可以通过从head
开始,沿着next
指针顺序遍历,确保遍历顺序与插入顺序一致
6. Map
6.1 Map 接口和常用方法
JDK8 的 Map 接口实现类的特点
- 并列于 Collection:
Map
接口是与Collection
接口平行的一个独立接口。意味着Map
和Collection
处理的是不同类型的数据结构——Map
主要用于存储键值对,而Collection
用于存储一组元素 - 键值对存储:
Map
存储的是键值对,每个键映射到一个特定的值。key 和 value 可以是任何引用类型的数据,封装到HashMap$Node
对象中,HashMap$Node
是HashMap
中实现键值对存储的内部类,它封装了键(Key)和值(Value)
编译器使用
$
符号将外部类名和内部类名连接起来。例如,如果HashMap
类中有一个名为Node
的内部类,编译后的类名会是HashMap$Node
1 | static class Node<K,V> implements Map.Entry<K,V> { // HashMap$Node 实现了 Map$Entry |
键(Key)的特性:
- 唯一性:每个键在
Map
中必须是唯一的。如果尝试插入一个已存在的键,其对应的值将被新的值替换(除非操作特别指定不这么做) null
键:大多数Map
实现(如HashMap
)允许有一个键为null
的条目,这在使用时需要特别注意,因为不是所有的Map
实现都支持null
键(例如TreeMap
默认不支持null
键)
- 唯一性:每个键在
值(Value)的特性:
Map
中的值可以重复,即不同的键可以映射到相同的值使用场景:
字符串键:
String
类型是作为Map
的键非常常见的选择,主要是因为其不变性和已经有效重写的hashCode()
和equals()
方法,这使得String
作为键既安全又高效单向关联:每个键与一个值之间存在单向的一对一关系,通过键总能检索到一个确定的值,这是
Map
结构的核心特征
EntrySet、KeySet 和 ValueCollection:
- Map 中一对 k-v 最后是
HashMap$Node node = newNode(hash, key, value, null)
,又因为Node 实现了 Entry 接口,有些书上也说一对 k-v 就是一个 Entry - 为了方便 k-v 遍历,还会 创建 EntrySet 集合
1
2// HashMap
transient Set<Map.Entry<K,V>> entrySet;- EntrySet:
EntrySet
是HashMap
的一个视图,用于以Map.Entry
对象的形式访问HashMap
中的每一个键值对。每个Map.Entry
实际上是HashMap
内部节点Node
的表示,它包含键和值的引用(地址)- 遍历
EntrySet
时,实际上是在访问HashMap
内部存储的每个节点,每个节点都是Map.Entry
实例,它包含了键值对的信息
- KeySet:
KeySet
也是HashMap
的一个视图,它包含HashMap
中所有的键。它是从EntrySet
派生出来的,每个元素都是从EntrySet
中的每个Entry
(即Node
)中提取的键- 遍历
KeySet
或对其进行操作时,实际上是在间接操作那些存储在Node
中的键
- ValueCollection:
- 类似地,
ValueCollection
是HashMap
中所有值的集合。它也是从EntrySet
派生的,每个元素都是从EntrySet
中的Entry
(即Node
)中提取的值 ValueCollection
允许你遍历或查看所有值,但是和键不同,值是可以重复的
- 类似地,
- Map 中一对 k-v 最后是
1 | import java.util.HashMap; |
- 常用方法:
1 | public static void main(String[] args) { |
6.2 Map 六大遍历方式
entrySet:获取所有的关系K-V
KeySet:获取所有的键
values:获取所有的值
通过
keySet()
遍历键并获取对应的值增强 for 循环:直接遍历键集合,然后使用
map.get(key)
来获取每个键对应的值迭代器:使用迭代器来遍历键集合,同样使用
map.get(key)
获取值
通过
values()
遍历所有值:这种方法只关注值,不涉及键增强 for 循环:直接遍历值集合
迭代器:使用迭代器遍历值集合
通过
entrySet()
直接遍历键值对:增强 for 循环:遍历
Entry
集合,可以直接使用Map.Entry
接口的getKey()
和getValue()
方法访问键和值迭代器:使用迭代器遍历
Entry
集合,对于每个迭代出的对象,同样使用Map.Entry
的方法来获取键和值
1 | // 第一组: 先取出 所有的 Key, 通过 Key 取出对应的 Value |
- 使用
forEach()
方法(Java 8+):使用forEach()
方法和 Lambda 表达式来遍历Map
1 | map.forEach((key, value) -> { |
- 注意:
- 在遍历集合时尝试修改集合的结构(添加或删除元素)通常是不安全的,除非通过迭代器的
remove()
方法删除元素 - 修改集合中元素的内部状态(不是集合结构)通常是安全的,如更改对象的属性
- 增加对集合的任何操作,特别是在遍历过程中,都应该非常小心处理以避免运行时错误
- 在遍历集合时尝试修改集合的结构(添加或删除元素)通常是不安全的,除非通过迭代器的
1 | // 练习 |
6.3 Map 接口实现类-HashMap
基本介绍
HashMap 是 Map 接口使用频率最高的实现类
线程不安全:HashMap 没有实现同步
底层机制和源码
- 初始化和负载因子:
- 默认构造函数:在实例化
HashMap
时,内部数组table
是null
。直到第一次插入元素时,才进行实际的内存分配 - 负载因子:负载因子
loadFactor
默认为0.75
,这是空间和时间成本的折中选择。负载因子决定了HashMap
扩容的时间点
- 默认构造函数:在实例化
1 | public HashMap() { |
内部数组和节点:
HashMap
使用Node
类型的数组table
来存储键值对。Node
是HashMap
的一个内部静态类,每个Node
对象包含一个键、一个值、一个哈希值和指向下一个节点的引用(链表结构)添加元素的处理:
- 索引计算:通过键的哈希值经过处理后确定在数组
table
中的索引位置 - 无冲突时:如果计算得到的索引位置上没有元素,则直接添加
- 有冲突时:如果位置上已有元素(链表头),则使用链地址法解决冲突:
- 遍历链表,检查是否有相同的键:
- 如果找到相同的键,则替换其值
- 如果未找到相同的键,根据链表的长度决定是添加到链表末尾还是树化
- 遍历链表,检查是否有相同的键:
- 索引计算:通过键的哈希值经过处理后确定在数组
扩容与树化:
扩容:当
HashMap
的大小超过threshold
(容量 * 负载因子)时,进行扩容,通常是当前容量的两倍。在扩容时,元素需要重新计算索引并分配到新的数组中- 第一次添加,则需要扩容table容量为16,临界值(threshold)为12(16*0.75)
1
2
3
4
5
6
7
8
9
10final Node<K,V>[] resize() {
// ...
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// ...
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// ...
}- 以后再扩容,则需要扩容table容量为原来的的2倍(32),临界值为原来的2倍,即24,以此类推;
1
2
3
4
5
6
7
8
9if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}树化:在 Java 8 中,如果某个索引位置上的链表长度 >=
TREEIFY_THRESHOLD
(默认为 8),并且数组大小 >=MIN_TREEIFY_CAPACITY
(默认为 64),则链表转换为红黑树,以改善查找效率
6.4 Map 接口实现类-Hashtable
基本介绍
- 键值对存储:
Hashtable
是 Java 早期的一部分,用于存储键值对(K-V),每个键映射到一个特定的值。 - 空值限制:在
Hashtable
中,键(Key)和值(Value)都不能为null
,尝试使用null
作为键或值都会抛出NullPointerException
- 线程安全:
Hashtable
的每个方法几乎都是用synchronized
关键字同步的,因此它是线程安全的,但这也导致了性能上的开销,其他使用方法基本上和 HashMap 一样
1 | public class Hashtable<K,V> |
- 逐渐被
ConcurrentHashMap
替代:- 尽管
Hashtable
是线程安全的,但它的一个主要缺点是它锁定整个集合来同步不同的方法调用,这可能导致严重的性能瓶颈 - 现代的替代品,如
ConcurrentHashMap
,提供了更高的并发性能,因为它使用分段锁
- 尽管
扩容机制
- 初始容量和负载因子:
Hashtable
默认的初始容量是 11,而负载因子是 0.75。这意味着当Hashtable
中的条目数量达到容量和负载因子乘积的结果时(大约是 8),Hashtable
会进行扩容
1 | public Hashtable() { |
1 | // Hashtable 中的节点,存储每个键值对 |
- put 方法:
- 使用
synchronized
关键字声明,确保在多线程环境中的线程安全 - 检查传入的
value
是否为null
,因为Hashtable
不允许存储null
值。如果value
为null
,抛出 NPE - 使用键的
hashCode()
方法计算哈希值,并通过与数组长度的取模操作得到数组索引 - 如果在链表中没有找到相同的键,调用
addEntry
方法将新的键值对添加到链表的头部
- 使用
1 | public synchronized V put(K key, V value) { |
- 扩容过程:HashTable 用的头插,HashMap 用的尾插
- 添加新键值对(
addEntry()
方法)- 如果
Hashtable
中的元素数量达到了阈值(threshold
),则触发rehash()
方法进行扩容 - 扩容完成后,重新计算当前新元素键的哈希索引,以确定它在新表中的位置
- 在确定的索引位置,使用链表的头插法将新元素插入
- 如果
- 执行扩容操作(
rehash()
方法)- 计算新容量
- 根据新的容量创建一个新的
Entry
数组并重新哈希旧元素,更新threshold
为新容量与负载因子的乘积
- 添加新键值对(
1 | // addEntry() |
6.5 Map 接口实现类-Properties
基本介绍
- 继承和实现:
Properties
类继承自Hashtable
。因此,它本质上是一个Hashtable
,键和值不允许为null
1 | public class Properties extends Hashtable<Object,Object> { |
用途:
Properties
主要用于管理配置数据,这些数据存储在键值对形式的属性文件(.properties
文件)中文件操作:
Properties
提供了方便的方法来从文件加载数据 (load()
) 和向文件写入数据 (store()
),使其非常适合读取和存储配置文件功能特点:
- 加载和存储:
- 加载(
load()
方法):可以从一个输入流(如文件输入流)中读取属性列表(键和元素对)。支持从 XML 和普通属性文件格式加载 - 存储(
store()
方法):可以将Properties
对象中的数据写入到输出流,同样支持 XML 和普通属性文件格式
- 加载(
- 与系统属性的交互:
Properties
类可以与系统属性直接交互,使用System.getProperties()
获取当前系统属性
- 默认值机制:
Properties
可以指定一个包含默认值的Properties
对象。如果主Properties
对象中没有找到相应的键,那么将会搜索这个默认值Properties
- 加载和存储:
基本使用
1 | public static void main(String[] args) { |
使用
Properties
类加载和存储属性文件的示例:config.properties
示例:
1
2
3
4username=admin
password=secret
database=localhost
port=3306- 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.Properties;
public class PropertiesExample {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
// 加载属性文件
FileInputStream in = new FileInputStream("config.properties");
props.load(in);
in.close();
// 获取属性
String username = props.getProperty("username");
System.out.println("Username: " + username);
// 修改/添加属性
props.setProperty("username", "newUsername");
// 存储修改后的属性文件
FileOutputStream out = new FileOutputStream("config.properties");
props.store(out, "Updated username");
out.close();
}
}
7. TreeSet 和 TreeMap 源码分析
7.1 TreeSet 分析
TreeSet
是基于TreeMap
实现的,它利用TreeMap
来保持元素的有序性和唯一性。TreeMap
本质上是一个红黑树实现的排序映射表,TreeMap
有一个属性是comparator
1 | // TreeMap.java |
- TreeSet 实现了 Set 接口,与 HashSet 最大的区别是可以排序
TreeSet
继承自AbstractSet
,后者提供了Set
接口的骨架实现;TreeSet
实现了NavigableSet
接口,该接口扩展了SortedSet
并提供导航方法
构造函数:
- 无参构造器:使用无参构造器创建
TreeSet
时,默认情况下是基于自然排序的,需要存储在TreeSet
中的元素实现Comparable
接口
自然排序指的是基于元素自身的特性进行排序,而不需要外部指定排序规则
对于整数,自然排序就是按照数字大小进行排序;对于字符串,自然排序是按照字典顺序进行排序
对于字符串,String 类实现了 Comparable 接口,因此它支持自然排序。String 类的 compareTo 方法会按照字典顺序比较字符串的大小
- 带比较器构造器:如果提供了一个比较器(传入了匿名内部类并指定排序规则),
TreeSet
会使用这个比较器来创建一个TreeMap
,允许对元素进行自定义排序
- 无参构造器:使用无参构造器创建
1 | // TreeSet.java |
- 代码示例:
1 | // TreeSet treeSet = new TreeSet(); |
- String 类的 compareTo 方法:
1 | public final class String |
- 带比较器构造器调用过程:
- 构造器把传入的比较器对象,赋给了
TreeSet
底层的TreeMap
的属性this.comparator
- 构造器把传入的比较器对象,赋给了
1 | // TreeSet.java |
7.2 TreeMap 分析
TreeMap
是一个基于红黑树的有序映射,支持按照键的自然顺序或者根据指定的Comparator
进行排序- 插入元素的逻辑:
- 首先检查根节点是否为空,如果是,则简单地将新元素设置为根节点(第一次添加,把 k-v 封装到 Entry 对象,放入root)
- 如果根节点不为空,则使用比较器(如果存在)或键的自然顺序来找到适当的插入位置
- 涉及在树中逐级向下搜索正确的插入点,直到找到空位置插入新节点
- 如果找到相同键的节点,则更新该节点的值
- 插入新节点后,可能需要进行红黑树的调整操作以保持树的平衡
- 代码示例:
1 | TreeMap treeMap = new TreeMap(new Comparator() { |
8. 集合选型规则
开发中如何选择集合实现类?
主要取决于业务操作特点,然后根据集合实现类特性进行选择
判断存储的类型
一组对象[单列](使用
Collection
接口)允许重复:
List
接口:ArrayList
:适用于查找和修改操作频繁的场景(底层维护 Object 类型的可变数组)LinkedList
:适合于增加和删除操作频繁的场景(底层维护一个双向链表)
不允许重复:
以下的无序指存取的顺序不一致,但是取出的顺序是固定一致的
Set
接口:HashSet
:最常用的集合,提供快速访问,保持元素唯一,但元素是无序的。内部通过哈希表实现,包括数组、链表和红黑树(Java 8 及以上)TreeSet
:当需要一个有序集合时选择,它根据元素的自然顺序或构造器提供的Comparator
进行排序LinkedHashSet
:维护元素插入顺序,性能略低于HashSet
,但迭代访问所有元素时有更高的性能(维护哈希表+双向链表)- LinkedHashSet 底层是 LinkedHashMap;LinkedHashMap 的底层是 HashMap
一组键值对[双列](使用
Map
接口)HashMap
:是最常用的Map
实现,键无序,按键的哈希码存储数据(底层数组+链表/红黑树)TreeMap
:基于红黑树的Map
实现,它根据键的自然排序或构造器提供的Comparator
对键进行排序。适用于需要按自然顺序或特定顺序遍历键时LinkedHashMap
:保持插入顺序,通常比HashMap
慢一点,但在迭代访问时更快
特殊用途
Properties
:用于读取.properties
文件,适用于配置数据的加载和存储。它是Hashtable
的一个子类,其中键和值都是字符串
9. Collections 工具类
9.1 基本介绍
Collections
是 Java 中的一个工具类,专门用于操作集合,如Set
、List
和Map
等- 该类提供了多种静态方法,用于执行集合上的操作,如排序、查询、修改等
9.2 主要方法
static操作
1 | // 创建ArrayList 集合, 用于测试 |
- 排序和调整:
reverse(List)
: 反转List
中元素的顺序。shuffle(List)
: 对List
集合元素进行随机排序。sort(List)
: 根据元素的自然顺序对指定List
集合元素按升序排序。sort(List, Comparator)
: 根据指定的Comparator
定制的顺序对List
集合元素进行排序。swap(List, int, int)
: 将指定List
集合中的 i 处元素和 j 处元素进行交换。
1 | // reverse(List): 反转 List 中元素的顺序 |
- 查找和统计:
max(Collection)
: 根据元素的自然顺序,返回给定集合中的最大元素。max(Collection, Comparator)
: 根据Comparator
指定的顺序,返回给定集合中的最大元素。min(Collection)
: 返回集合中的最小元素,根据自然排序。min(Collection, Comparator)
: 根据指定的比较器返回集合中的最小元素。frequency(Collection, Object)
: 返回指定集合中指定元素的出现次数。
1 | // Object max(Collection): 根据元素的自然顺序,返回给定集合中的最大元素 |
复制和替换:
copy(List dest, List src)
: 将src
中的内容复制到dest
中,执行的是浅复制(如果列表中的元素是对象,那么两个列表中的相应位置将指向同一个对象)。replaceAll(List, Object oldVal, Object newVal)
: 使用新值替换List
对象中所有的旧值。
1 | // void copy(List dest, List src): 将 src 中的内容复制到 dest 中 |
10. 课后习题
10.1 Homework01
1 | public class Homework01 { |
10.2 Homework03
1 | public class Homework03 { |
10.3 Homework04
分析下 HashSet 和 TreeSet 分别如何去重?
HashSet 去重:
- 计算哈希值:通过调用元素的
hashCode()
方法计算其哈希值。 - 确定位置:使用哈希值来确定在哈希表(底层的数组)中的索引位置。
- 冲突处理:如果该位置已经有元素存在,则通过 equals() 方法与现有元素逐个比较:
- 如果
equals()
返回true
(表示找到相等的元素),则不将新元素添加到集合中。 - 如果
equals()
返回false
(即所有比较都不相等),则根据内部结构(如链表或红黑树)添加新元素以解决哈希冲突。
- 如果
- 计算哈希值:通过调用元素的
TreeSet 去重:
- 如果初始化时传入了 Comparator 匿名内部类对象,就使用实现的 compare 方法去重,如果方法返回0,就认为是相同的元素/数据,就不添加
- 如果没有传入 Comparator 匿名对象,则以添加的对象实现的 Compareable 接口的 compareTo 方法去重
Comparable
接口用于定义对象的自然排序方式。类实现此接口以表明其实例具有内在的排序顺序Comparator
接口用于定义一种外部的、可定制的排序策略。它允许开发者提供自定义的排序顺序
10.4 Homework05
- 下面代码运行会不会抛出异常,并从源码层面说明原因?[考察读源码+接口编程+动态绑定]
1 | TreeSet treeSet = new TreeSet(); |
- 代码在运行时会抛出异常,原因在于
TreeSet
的工作机制要求存储的元素必须具备可比较性,即元素应该实现Comparable
接口。而Person
类没有实现Comparable
接口,当TreeSet
尝试比较两个Person
实例来确定其排序位置时,会因为找不到比较方法而抛出ClassCastException
10.5 Homework06-坑!
分析下面代码的输出:
- 前提:Person 类重写了基于 id 和 name 的 equals 和 hashCode 方法
1 | HashSet<Person> set = new HashSet<>(); |
分析:
- 为什么
remove
没成功?- 当
p1
被添加到HashSet
时,它的哈希值是根据p1
的id
和name
计算得到的。此时,p1
的状态是id=1001
和name=AA
- 修改了
p1
的name
属性为"CC"
。这改变了p1
的内部状态,这意味着如果再次计算哈希值,将会得到一个不同的结果 - 使用
set.remove(p1)
时,HashSet
试图找到p1
的哈希值对应的桶。但是,由于p1
的哈希值已经因为内部状态改变而改变,所以remove
方法可能无法找到正确的桶。如果找到了桶,其内的元素通过equals
方法比较也可能失败,因为equals
和hashCode
都依赖于name
,而这已经被改变了
- 当
- 为什么再次添加
1001, "CC"
和1001, "AA"
都成功了?- 由于
p1
的哈希值在其被修改后没有正确地更新(即哈希表中的位置不正确),因此再次添加同样的id
和修改后的name
("CC"
) 时,会产生一个新的哈希值,并被存放在不同的位置。尽管p1
的当前状态与新对象相同,但新对象会被视为不同的元素并被成功添加 - 添加一个新的
Person(1001, "AA")
时,由于没有与此状态相同的Person
对象在HashSet
中(p1
的状态已经是1001, "CC"
),它同样被添加成功
- 由于
- 为什么
根本原因:问题的根本原因是对象的可变性与
HashSet
的工作原理不兼容。当HashSet
中的对象在添加后被修改,它的哈希值可能不再反映其在哈希表中的实际位置