List
、Set
和 Map
是 Java 集合框架中的三种不同类型的接口,它们在数据存储和访问方面有不同的特点和用法。以下是它们之间的主要区别:
List
-
接口:
java.util.List
-
特点:
- 有序:
List
维护元素的插入顺序,因此可以通过索引访问元素。 - 允许重复元素:
List
允许存储重复的元素,即同一个元素可以多次出现在列表中。 - 访问方式: 可以通过索引(例如
list.get(index)
)访问元素。
- 有序:
-
常用实现类:
ArrayList
: 基于动态数组实现,随机访问快,适合频繁的读操作。- LinkedList: 基于双向链表实现,插入和删除操作快,适合频繁的增删操作。
Vector
: 类似ArrayList
,但线程安全(已不常用)。
-
示例:
List<String> list = new ArrayList<>(); list.add("Apple"); list.add("Banana"); list.add("Apple"); // 允许重复元素 System.out.println(list.get(0)); // 通过索引访问元素
ArrayList
的扩容机制
ArrayList内部基于动态数组实现,根据实际存储的元素动态地扩容或缩容。
ArrayList
的扩容机制是为了应对其底层数组容量不足以存储新增元素的情况。它的扩容机制设计得比较灵活,以平衡性能和内存使用。
(1)初始容量
当创建一个 ArrayList
时,如果使用无参构造函数,ArrayList
会初始化为一个空的数组,当第一次添加元素时,数组容量会扩展到==默认初始容量 ==10。如果在构造时指定了容量,那么 ArrayList
会直接初始化为指定的容量。
(2) 扩容触发条件
每当向 ArrayList
中添加新元素时,如果元素数量超过了当前底层数组的容量,ArrayList
就会触发扩容。
(3)扩容过程
当 ArrayList
需要扩容时,它会执行以下步骤:
-
新建一个更大的数组:新数组的容量通常是原来容量的 1.5 倍(具体来说,新容量 = 原容量 + (原容量 >> 1)),即在原容量的基础上增加一半。这种扩容方式能够平衡数组容量的增长速度和内存的使用效率。
- 例如,当前容量为 10,扩容后的新容量将是 15。
-
复制旧数组中的元素到新数组:所有现有的元素会被复制到新数组中,旧数组会被垃圾回收机制回收。
-
添加新元素:在新数组中放入新添加的元素。
(4)性能影响
- 扩容成本:扩容操作是一个耗时的过程,因为它需要分配一个更大的数组,并将旧数组的内容复制到新数组中。因此,如果频繁发生扩容,性能可能会受到影响。
- 建议:如果可以预见
ArrayList
中会存储大量元素,建议在创建ArrayList
时指定一个较大的初始容量,以减少扩容次数,从而提高性能。
总结
ArrayList
的扩容机制通过增加数组容量来动态调整数组的大小,默认扩容是原容量的 1.5 倍。虽然扩容操作可能会影响性能,但通过适当地设置初始容量,可以减少扩容的次数,从而提高性能。
Set
-
接口:
java.util.Set
-
特点:
- 无序:
Set
不保证元素的插入顺序(除非使用有序的Set
实现)。 - 不允许重复元素:
Set
中的每个元素都是唯一的,不能包含重复元素。 - 无索引访问: 由于没有顺序概念,
Set
不提供通过索引访问元素的方法。
- 无序:
-
常用实现类:
HashSet
: 基于哈希表实现,元素无序,常用于需要快速查找的场景。LinkedHashSet
: 保持元素的插入顺序,并且基于哈希表实现。TreeSet
: 基于红黑树实现,元素按自然顺序排序或使用指定的比较器排序。
-
示例:
Set<String> set = new HashSet<>(); set.add("Apple"); set.add("Banana"); set.add("Apple"); // 不允许重复元素,此操作将不会添加新元素 System.out.println(set.size()); // 输出 2
Map
-
接口:
java.util.Map
-
特点:
- 键值对存储:
Map
用来存储键值对(key-value pair),每个键唯一地映射到一个值。 - 键不能重复:
Map
中的键是唯一的,但值可以重复。 - 无索引访问:
Map
不提供基于索引的访问,而是通过键来访问对应的值。
- 键值对存储:
-
常用实现类:
HashMap
: 基于哈希表实现,键无序,常用于快速查找键值对。LinkedHashMap
: 保持键的插入顺序,基于哈希表实现。TreeMap
: 基于红黑树实现,键按自然顺序排序或使用指定的比较器排序。
-
示例:
Map<String, Integer> map = new HashMap<>(); map.put("Apple", 10); map.put("Banana", 20); map.put("Apple", 15); // "Apple" 键对应的值被更新为 15 System.out.println(map.get("Apple")); // 通过键访问值,输出 15
补充:
HashMap
HashMap
是基于哈希表(Hash Table)实现的。它的底层是一个数组,数组中的每个元素被称为桶(Bucket)。为了应对哈希冲突(即不同的键有相同的哈希值),每个桶实际上是一个链表或树。
(1).哈希表的工作原理
-
哈希函数:当你将一个键值对存储在
HashMap
中时,首先会对键调用hashCode()
方法。这会生成一个整数值,这个值通常是对象的内存地址的一个散列结果。 -
数组索引:然后通过对
hashCode()
结果进行处理(通常是取模运算或与运算),将其转化为数组的索引位置,这个索引指示了键值对应该存储在数组的哪个位置(即桶中)。(n-1)&hash -
存储数据:在计算出数组索引后,
HashMap
会将键值对存储在该位置的桶中。如果该位置为空,键值对就直接放入;如果已有数据,则通过链表或红黑树来处理冲突。
(2)处理哈希冲突
哈希冲突 是指多个键有相同的哈希值导致它们映射到同一个桶中的情况。HashMap
使用以下两种方式来处理冲突:
-
链表法:如果冲突发生,
HashMap
会将新元素作为链表节点追加到对应桶的链表中。多个具有相同哈希值的键值对将以链表的形式存储在同一个桶中。 -
红黑树:从 Java 8 开始,当单个桶中的链表长度超过一定阈值==(默认为8)==时,
HashMap
将链表转换为红黑树,以提高查找效率。
(3)查找过程
查找某个键对应的值时,HashMap
会根据键的 hashCode
计算出桶的位置,然后遍历桶中的链表或树以查找匹配的键。如果找到匹配的键,返回相应的值;如果没有找到,返回 null
。
(4)HashMap的扩容机制
HashMap
的扩容机制是为了在存储的键值对数量增加时,确保查找、插入和删除操作的性能不显著下降。扩容是通过增加底层数组的大小(即增加桶的数量)来减少哈希冲突,从而保持较好的操作效率。
HashMap
的底层数组有一个初始容量,当存储的元素数量超过一定比例(即负载因子,默认值为 0.75)时,HashMap
会进行扩容。扩容时,它会创建一个新的、更大的数组,然后将所有现有的键值对重新哈希并存储到新的数组中。这一过程被称为 rehashing。
1. 初始容量与负载因子
- 初始容量:
HashMap
的初始容量是底层数组的大小,默认值为 16。容量是 2 的幂。 - 负载因子: 负载因子是一个用于衡量
HashMap
何时需要扩容的指标。默认的负载因子是 0.75。这意味着当HashMap
中存储的键值对数量达到当前容量的 75% 时,HashMap
会触发扩容操作。
2. 扩容的过程
-
新数组的创建: 当触发扩容时,
HashMap
会创建一个容量为当前容量两倍的新数组(即newCapacity = 2 * oldCapacity
)。 -
重新计算哈希值: 在新数组中重新计算每个键的哈希值,并将键值对重新分配到新的桶中。
- 在 Java 8 及以后,扩容时不会对所有键值对的哈希值重新计算,而是利用扩容前后的桶数量关系,通过简单的二进制操作来判断键值对是在原位置还是移动到新位置。
- 假设原有数组的大小为
16
时,一个元素的位置是由哈希值的低 4 位决定的。现在数组大小变为32
后,哈希值的第 5 位就会参与计算新位置。 - 如果哈希值的第 5 位是
0
,那么元素会保留在原来的桶位置上。如果第 5 位是1
,那么元素会被重新分配到新数组中的新位置(通常是原位置加上新数组的大小的一半)。
-
迁移键值对:
HashMap
会将原数组中的所有键值对按照重新计算的哈希值进行迁移,放入新数组中。
(5) 性能影响
-
扩容成本: 扩容是一项耗时操作,因为它涉及到创建新数组并重新分配所有现有的键值对。为了减小扩容带来的性能影响,
HashMap
在设计时选择了较为保守的负载因子(默认是 0.75),以平衡空间利用率和性能。 -
容量的二次幂: 为了优化哈希值的计算和取模操作,
HashMap
总是将容量设置为 2 的幂。这样可以使用位运算来代替取模运算,从而提高效率。为什么HashMap的长度是2的幂次方:
HashMap
的容量总是选择为 2 的幂(如 16、32、64 等),而不是任意数字。这种设计有几个关键原因,主要是为了优化哈希值的计算和提高性能。-
哈希值的计算:
在HashMap
中,每个键值对需要存放在底层数组中的某个位置(称为桶),这个位置是通过键的哈希值决定的。通常,哈希值会很大,无法直接作为数组的索引,所以需要通过取模运算(hash % capacity
)将哈希值映射到数组的索引范围内。 -
为什么选择 2 的幂:
如果容量(capacity
)是 2 的幂(比如 16, 32, 64),取模运算可以简化为位运算。这是因为在二进制表示中,2 的幂总是形如1000...0
,即只有一位为 1,其他位为 0。比如,16 是 2^4,它的二进制形式是10000
。- 当容量为 2 的幂时,
hash % capacity
可以通过hash & (capacity - 1)
来实现,这里的&
是按位与操作。 - 例如,如果容量为 16(2^4),
hash & (16 - 1)
相当于hash & 15
,这个操作仅保留哈希值的低 4 位(因为 15 的二进制是1111
),有效地将哈希值映射到数组索引 0 到 15 之间。
- 当容量为 2 的幂时,
-
性能优化:
取模运算通常比按位与操作要慢,尤其是在大型系统中,频繁的取模运算可能会影响性能。通过选择 2 的幂作为容量,HashMap
== 可以用更快的位运算来代替取模运算,从而提高哈希表的性能。==
-
(6)总结
HashMap
的核心是一个数组,数组中的每个元素是一个桶。- 通过键的
hashCode
计算出数组索引,将键值对存储到对应的桶中。 - 当出现哈希冲突时,
HashMap
使用链表或红黑树来存储多个键值对。 - 扩容操作通过重新哈希来重新分布键值对,以保持效率。
ConcurrentHashMap
并发环境推荐使用ConcurrentHashMap
ConcurrentHashMap
允许多个线程并发地读取和写入,而不会发生数据竞争(Race Condition)。它通过使用分段锁(Segmented Locking)或自 JDK 8 以来的 CAS(Compare-And-Swap)机制来实现线程安全。
JDK1.8取消了Segment分段锁,采用CAS(Compare-And-Swap)和synchronized来保证并发安全, synchronized只锁定当前链表或红黑树的首节点,这样只要hash不冲突,就不会产生并发。
总结:
List
: 有序、允许重复、基于索引访问。适合存储需要频繁访问和操作的有序列表数据。Set
: 无序、不允许重复、基于哈希或树结构实现。适合存储不重复的元素集合,特别是需要快速查找的场景。Map
: 键值对存储、键唯一、通过键访问。适合存储和查找基于键的值,特别是需要根据键快速查找值的场景。
补充:CAS
CAS(Compare-And-Swap,比较并交换)是一种用于实现多线程并发控制的原子操作。它能够帮助避免传统锁机制带来的性能开销,是实现无锁算法的基础。CAS 操作通常在底层硬件支持下直接实现,在 Java 中通过 sun.misc.Unsafe
类提供。
CAS 的基本原理
CAS 操作需要三个操作数:
- 内存位置(V):要操作的变量的内存地址。
- 预期值(E):该变量的期望值,表示希望变量当前的值是什么。
- 新值(N):希望将该变量更新为的新值。
CAS 操作的工作流程如下:
- 比较:检查内存位置
V
的当前值是否与预期值E
相等。- 如果相等,则将内存位置
V
的值更新为新值N
。 - 如果不相等,则说明该内存位置
V
的值在操作过程中被其他线程修改过,CAS 操作不执行更新,而是返回当前值,通常表示更新失败。
- 如果相等,则将内存位置
这种机制可以确保在多线程环境下,只有一个线程能够成功更新变量值,而其他线程需要重新尝试。
CAS 的优点
- 无锁并发:CAS 操作不需要锁,可以避免由于锁竞争导致的线程上下文切换和性能瓶颈。
- 高性能:由于没有锁的开销,CAS 能在高并发情况下提供更好的性能表现。
- 原子性:CAS 操作是一个原子操作,能够确保操作的完整性和一致性。
CAS 的缺点
-
ABA 问题:
- 在 CAS 操作中,可能出现“ABA”问题,即一个值从
A
变成B
,然后又变回A
,导致 CAS 操作无法察觉这个变化。 - 为了解决 ABA 问题,可以引入版本号或使用
AtomicStampedReference
这样的带有标记的原子引用。
- 在 CAS 操作中,可能出现“ABA”问题,即一个值从
-
自旋等待:
- 如果一个线程多次尝试 CAS 操作失败(因为其他线程在不断修改目标变量),可能会导致自旋等待(spin-wait),消耗 CPU 资源。
-
只能操作单个变量:
- CAS 操作只能针对单个内存位置进行,无法直接对多个变量进行原子操作。因此,在需要多个变量同时修改的场景下,仍然需要使用锁机制或更复杂的无锁算法。
CAS 在 Java 中的应用
在 Java 中,CAS 主要应用于 java.util.concurrent
包下的原子类(如 AtomicInteger
、AtomicLong
等)和无锁数据结构(如 ConcurrentHashMap
、ConcurrentLinkedQueue
等)中。
示例:使用 AtomicInteger
进行 CAS 操作
import java.util.concurrent.atomic.AtomicInteger;
public class CASExample {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
// 假设有多个线程在同时执行以下操作
int expectedValue = atomicInteger.get(); // 期望值
int newValue = expectedValue + 1; // 新值
// CAS 操作:尝试将值从 expectedValue 更新为 newValue
boolean success = atomicInteger.compareAndSet(expectedValue, newValue);
if (success) {
System.out.println("CAS 操作成功,值已更新为: " + atomicInteger.get());
} else {
System.out.println("CAS 操作失败,当前值为: " + atomicInteger.get());
}
}
}
在这个示例中,compareAndSet()
方法就是一个典型的 CAS 操作。如果 atomicInteger
的当前值与 expectedValue
相等,则更新为 newValue
,否则操作失败。
CAS 的硬件支持
CAS 操作通常由 CPU 提供硬件级支持。许多现代处理器都提供了原子性的 CAS 指令,例如在 x86 架构中有 CMPXCHG
指令。这些硬件指令是 CAS 操作高效实现的基础。
总的来说,CAS 是并发编程中一种重要的机制,能够在无锁的情况下实现线程安全的数据操作,并且在高并发场景下性能表现良好。