集合概述
Java 集合框架概述
Java 集合,也叫作容器,主要是由两大接口派生而来:
- 一个是
Collection接口,主要用于存放单一元素;- 对于
Collection接口,下面又有三个主要的子接口:List、Set、Queue。
- 对于
- 另一个是
Map接口,主要用于存放键值对。
如下是更详细的,带上了抽象类的
集合框架的体系如下
- 接口(Interfaces):定义集合的行为(如
Collection,List,Set,Queue,Map)。 - 实现类(Implementations):提供具体的数据结构(如
ArrayList,HashSet,LinkedList,HashMap)。 - 算法(Algorithms):如排序、查找等(通过
Collections工具类提供)。 - 迭代器(Iterator):用于遍历集合元素。
那么,在这里简单说一下 List, Set, Queue, Map 这四个主要集合接口的区别
List:
List继承Collection,元素严格保持插入顺序,允许重复元素,支持get(index)索引访问等索引操作,典型实现类有ArrayList,LinkedList,Vector,也就是说,它的含义是有序可重复列表Set:
Set继承Collection,除了LinkedHashSet/TreeSet有序,其他实现无序,而且不允许重复,底部基于equals()判断,不支持索引访问,一般用于去重和唯一校验,也就是说,它的含义是不可重复列表SortedSet是继承于 Set 用于保存有序的集合的接口。注意,自定义对象在Set中无法去重,这是因为没有重写
equals()和hashCode()方法。确保在将自定义对象存储到Set或Map中时,正确实现这两个方法。
Queue:
Queue继承Collection,实现均有序,按照 FIFO(先进先出)或按优先级排序, 一般允许重复,不支持索引访问,只能操作队首队尾,存储的元素可以重复,也就是说,它的含义是可重复有序队列Queue 的扩展接口为 Deque,它的含义是双端队列,也可以理解为堆栈,支持栈(LIFO)操作。
因为 Java 堆栈Stack类已经过时,Java官方推荐使用 Deque 替代 Stack 使用。所以 Deque 支持堆栈操作方法:push()、pop()、peek()等
Map:Map 是独立接口,代表存储键值对的映射,遵循 Key-Value 结构,每个 Key 唯一,映射最多一个 Value。Key 不可重复,Value 可重复,一般情况下无索引,除
LinkedHashMap/TreeMap均无序
而对于 Collection 接口,Collection 是最基本的集合接口,一个 Collection 代表一组 Object,即 Collection 的元素, Java不提供直接继承自 Collection 的类,只提供继承于 Collection 的子接口(如List和set)。Collection 接口存储一组不唯一,无序的对象。
集合框架常用类概述
我们需要根据他们的特点来选用集合,这里只做一个综述,详细内容针对每个实现类下面再说
所有集合从 Java 5 起支持泛型,避免类型转换错误
List
- ArrayList:底层使用动态数组,它有序,可重复,支持索引访问,初始容量在 JDK 8+ 中空参构造器初始为 0,首次添加时扩容至 10。扩容机制是1.5 倍扩容。对于查询是O(1),因为是索引访问,增删是O(n),因为需移动后续元素,非线程安全
- LinkedList:底层结构使用双向链表,首尾增删是O(1),随机访问是O(n),因为需遍历。非线程安全。对于
LinkedList,它同时实现List和Deque,可作栈或队列 - Vector:(遗留类,不推荐使用)底层结构类似
ArrayList,也是Object[]数组,而且线程安全,因为 所有方法加synchronized,所以性能差,扩容是 2 倍扩- 子类是
Stack后进先出栈,是使用数组实现的栈,不推荐使用
- 子类是
- CopyOnWriteArrayList:并发场景用的ArrayList,底层结构是不可变数组 + 写时复制,读操作无锁,直接读原数组。写操作加锁,复制整个数组修改后替换引用,所以有弱一致性
Set
HashSet:封装
HashMap,元素存于key,value为固定对象PRESENT,是无序的,而且允许一个null,增删查平均 O(1)LinkedHashSet:封装
LinkedHashMap,在HashSet基础上维护双向链表,所以他能保持插入顺序,性能略低于HashSet,当需去重且保留插入顺序,使用这个TreeSet:封装了
TreeMap,基于红黑树,它支持自然排序,因为元素实现了Comparable<T>,而且唯一,因为比较结果为 0 视为重复,不允许null,因无法比较,查询和增删都是O(log n),代表需排序的唯一集合CopyOnWriteArraySet:它的内部使用
CopyOnWriteArrayList,所以也是读无锁,写加锁复制,适合用于并发下的去重集合EnumSet:专用于枚举类型,它的底层使用位向量来进行实现,高性能、紧凑内存、但是线程不安全。
BitSet:一种特殊类型的数组来保存位值,底层使用位向量
Queue
- LinkedList:同时实现
List和Queue。针对它作为 Queue 的实现类这边的特点,就是支持 FIFO 队列操作offer(),poll(),peek()。 - PriorityQueue:底层结构使用二叉堆,其中二叉堆使用数组实现,排序基于自然顺序或
Comparator,而且队首始终是最小元素,不支持null,因为无法比较 - DelayQueue:延时队列,用于各种延时时调度、缓存过期场景,线程安全,基于优先队列
PriorityQueue实现,确保最快到期的元素在队列头部 - ReferenceQueue:引用队列,我就在学GC的时候用过两次,奇怪的容器太多了。。。。。
Deque
- ArrayDeque:底层结构是循环数组,所以无容量限制,自动扩容,在性能方面,有说是优于
Stack和LinkedList作栈/队列,不支持 null,JDK建议用它替代Stack
Map
- HashMap:JDK1.8后,底层结构变成了数组 + 链表 +
红黑树,使用了哈希优化,而且遵循 Key 唯一性,依赖
hashCode()和equals(),线程不安全,插入顺序不可靠 - LinkedHashMap:可以理解为保顺序的 HashMap,使用双向链表,维护元素插入顺序(默认)或访问顺序
- TreeMap:自动排序的 Map,因为 Key 实现
Comparable<T>,不允许nullKey - Hashtable:Hashtable 是个古老的 Map
实现类,貌似已经被淘汰掉了,不同于HashMap,Hashtable
是线程安全的,但是由于是全局锁,性能不是很好,而且不允许
null。一般情况下,我们使用 ConcurrentHashMap - ConcurrentHashMap:线程安全的 HashMap,支持高并发读,因为读无锁
,支持并发写,所以有弱一致性,因为会有比较细力度的锁,不允许
null - WeakHashMap:Key 可被 GC 回收,因为 Key 被包装为
WeakReference,当 Key 无强引用时,GC 可回收,并在下次操作时自动清理 Entry,所以适合用于缓存,避免内存泄漏(普通 Map 的 Key 永远不会被回收) - IdentityHashMap:基于对象地址比较,比较方式使用
==而非equals() - BitMap:类似 Redis 中的位图,用一个二进制位来标记某个元素的value,也就是说,哪个索引有东西,值为1就,而且查找是O(1)的,去重非常快
- Properties:Properties 类是 Hashtable 的子类,该对象用于处理属性文件
- EnumMap:针对枚举常量的 Map,线程安全
集合框架底层数据结构总结
List
ArrayList:
Object[]数组。也就是动态数组
LinkedList:双向链表,它也实现 List 和 Deque
可以看到是不循环的
Vector:同样也是Object[]数组
CopyOnWriteArrayList:使用
Object[]动态数组,而且通过 transient volatile 修饰Object[]保证线程安全。
Set
HashSet:无序,唯一,基于
HashMap实现的,底层采用HashMap来保存元素。
LinkedHashSet:
LinkedHashSet是HashSet的子类
并且其内部是通过
LinkedHashMap来实现的。
TreeSet:有序,唯一,内部使用 TreeMap,所以是使用红黑树(自平衡的排序二叉树)
CopyOnWriteArraySet:内部使用 CopyOnWriteArrayList 包装线程安全
EnumSet:首先说明,
EnumSet是一个抽象类,但是文档中明确指出,枚举集在内部以位向量表示,这种表示极其紧凑高效,真正的位向量实现在其两个包私有的子类中,所以我们使用EnumSet的适合就用EnumSet的静态工厂方法,EnumSet的静态工厂方法会根据枚举大小自动选择
其静态工厂方法
BitSet:BitSet底部使用动态位向量
每个
long占 64 位 ,所以可表示 64 个布尔值,位运算什么的也是O(1),非常快
Queue
PriorityQueue:使用
Object[]数组来实现小顶堆
其中,初始化就是初始化小顶堆
DelayQueue:内部使用 PriorityQueue
ArrayDeque:可扩容的动态双向数组
Map
HashMap:JDK1.8 之前
HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,
JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
HashMap并没有直接使用key.hashCode()作为最终的哈希值,而是通过一个内部的hash()方法进行了二次处理
LinkedHashMap:
LinkedHashMap继承自HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。
而且,
LinkedHashMap在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。
Hashtable:因为是古老的线程安全HashMap,所以结构和古老的HashMap差不多,也是数组+链表组成的,数组是
Hashtable的主体,链表则是主要为了解决哈希冲突而存在的。
TreeMap:红黑树,自平衡的排序二叉树
EnumMap:支持枚举常量类型的
Object[]
集合算法
集合框架定义了几种算法,可用于集合和映射。这些算法被定义为集合类的静态方法。
集合定义三个静态的变量:EMPTY_SET,EMPTY_LIST,EMPTY_MAP的。这些变量都不可改变。
List
List 接口概述
List 是 Java
集合框架(java.util)中有序、可重复的集合接口,继承自
Collection 接口,是日常开发中最常用的集合类型之一。
其核心特征是:元素按插入顺序排列、允许重复元素、支持通过索引访问元素
List 继承了 Collection
的所有方法,并扩展了索引相关的核心方法
| 方法 | 作用 |
|---|---|
E get(int index) |
获取指定索引的元素 |
E set(int index, E element) |
替换指定索引的元素,返回原元素 |
void add(int index, E element) |
在指定索引插入元素,后续元素后移 |
E remove(int index) |
删除指定索引的元素,返回被删除元素 |
int indexOf(Object o) |
返回元素首次出现的索引,不存在则返回 -1 |
int lastIndexOf(Object o) |
返回元素最后出现的索引,不存在则返回 -1 |
List<E> subList(int fromIndex, int toIndex) |
截取子列表(左闭右开),注意:子列表是原列表的视图,修改会同步 |
ListIterator<E> listIterator() |
返回支持双向遍历的迭代器(可向前 / 向后遍历,还能添加 / 修改元素) |
Java 提供了多个 List 实现类,适配不同的业务场景,核心有以下 4 种
ArrayList
ArrayList的基本特性
对于这个十个容器九个你的常用容器,我感觉我有必要大篇幅的细说一下
ArrayList实现了List接口,是顺序容器,所以 List 的特性有的他也有,所以它有序且允许重复,支持索引访问和操作
但是它线程不安全,它的所有方法都不是线程安全的,所以多线程情况下需要你自己进行同步
Collections.synchronizedList(new ArrayList<>())
来包装它,或者使用并发容器
源码注释明确指出:
Note that this implementation is not synchronized.如果多个线程同时访问一个ArrayList实例,并且至少有一个线程在结构上修改了它(添加或删除元素),那么必须在外部进行同步。
说一下有关基本方法的时间复杂度,size(),
isEmpty(),get(),set()方法均能在常数时间内完成,也就是O(1),add()方法的时间开销跟插入位置有关,因为它是尾部插入高效,在列表末尾添加元素
(add(E e)) 的摊还时间复杂度 (Amortized Time Complexity) 为
O(1)。虽然偶尔会触发 O(n) 的扩容操作,但平摊到每一次 add
操作上,成本是常数级别的。所以说,addAll()方法的时间开销跟添加元素的个数成正比。其余方法大都是线性时间
所以,它查询高效,增删相对低效
而且允许放入 null
元素,底层通过数组(Object[])实现。不过,不建议向ArrayList
中添加 null 值, null
值无意义,会让代码难以维护比如忘记做判空处理就会导致NPE
这里我说一个误区啊,所以说,生产环境中,不是非 Optional 不可,很多人包括我只是更喜欢在编写业务的时候使用 Optional,来处理可能为
null的值,因为Optional不是为了 消灭 null,而是为了规范化、可视化地处理 null,从 ArrayList 取出元素后直接操作是高频场景,Optional 包裹后,能强制你显式处理 null 情况,从语法层面规避 NPE,而且约定大约配置啊,Optional作为语义化标记能直接告诉大伙这个值可能为 null,需要特殊处理。更多情况,业务中如果不用 Optional,常需要写大量
if (xxx == null)或者默认值判断的重复代码,Optional 提供了orElse()、orElseGet()、orElseThrow()等方法,这谁不喜欢
虽然说多了,但是我感觉有必要说一下这个事情
而且,每个ArrayList都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量。当向容器中添加元素时,如果容量不足,容器会自动增大底层数组的大小。这就是下面会详细说的,扩容机制
ArrayList 在 JDK1.8 前后的实现,有一些区别:
- JDK1.7:像饿汉式,直接创建一个初始容量为10的数组
- JDK1.8:像懒汉式,一开始创建一个长度为0的数组,当添加add第一个元素时再创建一个初始容量为10的数组
ArrayList的结构分析
首先,ArrayList 继承于 AbstractList
,实现了 List, RandomAccess,
Cloneable, java.io.Serializable 这些接口。
这些都是什么意思
List: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。RandomAccess:这是一个标志接口,表明实现这个接口的List集合是支持 快速随机访问 的。在ArrayList中,我们就可以通过元素的序号快速获取元素对象,这就是快速随机访问。Cloneable:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便适合进行网络传输
ArrayList 的底层是数组队列,相当于动态数组。与 Java
中的数组相比,它的容量能动态增长。
1 | // 存储元素的底层数组 |
- 注意,存储元素的底层数组
elementData,它是transient的,这意味着在序列化时,JVM 不会自动序列化这个字段。ArrayList自己实现了writeObject和readObject方法来控制序列化过程,只序列化实际的元素(size个),而不是整个elementData数组,从而节省空间。 size:表示列表中实际元素的数量,也是下一个元素将要被插入的位置(索引)。size永远小于或等于elementData.length(即容量)。
其中,构造函数如下
无参构造
1
2
3public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}初始化一个空的
elementData,指向特殊的DEFAULTCAPACITY_EMPTY_ELEMENTDATA。此时容量为0,直到第一次add操作才会扩容到DEFAULT_CAPACITY (10)。指定初始容量
指定集合的元素的列表构造,用的非常非常少,本来都有容器了非要那ArrayList包装一下
常用操作分析
添加元素
这是 ArrayList 最核心的操作之一。以 add 方法为例子
1 | // 核心就这些步骤 |
- 对于
modCount,这是一个继承自AbstractList的字段,记录了列表结构被修改的次数。任何结构性修改(增、删、clear)都会增加它。迭代器在创建时会记录当时的modCount,在每次操作前检查是否一致,不一致则抛出ConcurrentModificationException。
对于添加元素,JDK21有addFirst(E) /
addLast(E),巨好用,让 ArrayList 在
API 上或者在语义上更像一个双端队列(Deque),尽管性能上
addFirst 依然是 O(n)。getFirst() /
getLast()也有
删除元素
以 remove(int index)
为例子说明了,来看这个方法
1 | public E remove(int index) { |
- 很明显,删除操作的时间复杂度是 O(n),因为需要移动被删除元素之后的所有元素
- 而且它会将原
size-1位置的元素显式设为null,可以防止内存泄漏
SubList 视图
subList(fromIndex, toIndex)
方法返回的是原列表的一个视图
(View),而不是一个全新的独立列表,像是浅拷贝,但是又有一定区别,因为,它是返回一个
SubList 内部类实例。
对
SubList的修改会直接反映到原ArrayList上,反之亦然,这是双向绑定它结构脆弱,不禁玩,如果在创建
SubList之后,对原列表进行了结构性修改,那么再对SubList进行任何操作都会抛出ConcurrentModificationException。原因其实很明显,就是
SubList内部持有对原列表 (root) 的引用,并且有自己的modCount。它的modCount只会在通过SubList自身进行修改时才更新。如果原列表被外部修改,root.modCount改变,但SubList的modCount没变,两者不一致就会抛异常。
所以说,它不怎么被使用,因为很鸡肋,就这么想,如果你持有一个巨大列表的很小一部分的
SubList,并且丢弃了对原列表的引用,但由于SubList内部仍然持有对原列表底层数组 (elementData) 的强引用,会导致整个巨大的数组无法被GC。
常用操作分析
| 操作 | 时间复杂度 | 源码关键点 | 备注 |
|---|---|---|---|
get(int index) |
O(1) | return elementData(index); |
直接数组访问,极快。 |
set(int index, E e) |
O(1) | elementData[index] = element; |
直接数组赋值。 |
add(E e) |
Amortized O(1) | 调用 grow() (偶尔 O(n)) |
尾部追加,摊还成本低。 |
add(int index, E e) |
O(n) | System.arraycopy(...) |
需要移动 index 之后的所有元素。 |
remove(int index) |
O(n) | fastRemove ->
System.arraycopy(...) |
需要移动 index 之后的所有元素,并清理末尾引用。 |
remove(Object o) |
O(n) | 先 indexOf (O(n)),再 fastRemove
(O(n)) |
最坏情况需要遍历两次。 |
contains(Object o) |
O(n) | 本质是 indexOf(o) >= 0 |
需要线性遍历查找。 |
indexOf(Object o) |
O(n) | indexOfRange 线性遍历 |
从头开始找第一个匹配项。 |
lastIndexOf(Object o) |
O(n) | lastIndexOfRange 从后往前遍历 |
从尾开始找最后一个匹配项。 |
扩容机制分析
以无参数构造方法创建 ArrayList
时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。
然后,在涉及到添加元素的时候,当容量不够了,也就是
size == elementData.length 时,就会触发扩容。
核心方法是grow(int minCapacity),而入口方法是
private Object[] grow(),它计算出最小需要的容量为
size + 1,并调用重载方法。
我们可以推断其行为,它的扩容有两种操作其大小的情况,其中,扩容的情况如下
常规扩容
数组已经存在(
oldCapacity > 0)或者不是由无参构造器创建的空数组。调用ArraysSupport.newLength工具方法推断扩容大小- 首选增长量:
oldCapacity >> 1,即oldCapacity / 2然后相加。所以常规情况下,新容量 = 1.5 * 旧容量 - *最小增长量:
minCapacity - oldCapacity。这确保了即使请求的增长量很大(比如addAll一个很大的集合),新容量也至少能满足需求。 - 最终容量**:
newCapacity = oldCapacity + max(preferredGrowth, minimumGrowth)。
对于其中
ArraysSupport.newLength内部逻辑,它会选择max(最小增长量, 首选增长量)作为实际的增长量。该方法还会处理整数溢出。
- 首选增长量:
首次添加元素
数组是通过无参构造器
new ArrayList<>()创建的,此时elementData指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA,上面也说了这个是空列表,且oldCapacity为0。直接创建一个大小为
DEFAULT_CAPACITY(10) 的新数组。这是一个优化,避免了在第一次add时只扩容到1,导致后续频繁扩容。
因为,扩容使用的是
Arrays.copyOf,它是一个拷贝和重引用,频繁扩容会很大的影响性能。
“当我们在
ArrayList中调用add方法添加元素时,它首先会检查当前的元素数量size是否等于底层数组的长度。如果相等,说明数组已满,就需要进行扩容。扩容的具体逻辑在
grow方法中。对于大多数情况(非首次添加),它会采用一种‘1.5倍’的扩容策略。更准确地说,它会计算一个‘首选增长量’(当前容量的一半)和一个‘最小增长量’(满足本次操作所需的最小空间),然后取两者中的较大值来确定新容量。这个计算是由 JDK 内部的ArraysSupport.newLength工具类完成的,它还负责处理整数溢出等边界情况。有一个特殊情况是,如果我们使用的是无参构造器创建的
ArrayList,那么在第一次添加元素时,它会直接扩容到默认的初始容量10,而不是1。最终,扩容通过
Arrays.copyOf方法完成,它会分配一个新的、更大的数组,并将旧数组中的所有元素拷贝过去,然后更新ArrayList的内部引用。这个过程的时间复杂度是 O(n),但由于是‘摊还’的,所以add操作的平均时间复杂度仍然是 O(1)。”
迭代器
ArrayList 的迭代器是 Fail-Fast 的。这是 ArrayList
并发安全的核心
首先,modCount是继承自
AbstractList的,就是记录结构修改次数。
1 | private class Itr implements Iterator<E> { |
在迭代过程中(
next(),remove()等),每次操作前都会调用checkForComodification()。这个方法就是根据 modCount 的情况做并发检查
迭代器在创建时会保存一份 modCount
的快照叫做expectedModCount。在迭代过程中,如果发现
modCount
发生了变化(意味着有其他线程或代码在外部修改了列表),就会立即抛出
ConcurrentModificationException,告诉你出现了并发修改,ArrayList
处理不好,直接爆了
这是一种快速失败的策略,旨在尽早暴露并发修改的 bug,而不是让程序在不确定的状态下继续运行,避免产生难以预料的结果。
为什么 ArrayList 在进行了这样的检查之后,他还是线程不安全的呢?因为,它是 Fail-Fast 是一种 bug检测机制,而非并发控制和并发实现的手段。它无法在多线程环境下提供绝对的安全性,因为检查和抛异常不是原子的。
换句话说,如果你单线程在迭代过程中直接修改了原列表,也会触发此异常。
所以,在这里产生了一个经典的面试题
1 | List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); |
LinkedList
基础特性
LinkedList 是一个基于双向链表实现的集合类,经常被拿来和
ArrayList 做比较。
关于 LinkedList
和ArrayList的详细对比,我们下面作为一个面试题说
但是,我是能不用LinkedList就不用,不仅需要用到
LinkedList 的场景几乎都可以使用 ArrayList
来代替,而且LinkedList的性能差点意思,而且就连
LinkedList 的作者约书亚 · 布洛克(Josh
Bloch)自己都说从来不会使用 LinkedList 。
另外,不要下意识地认为 LinkedList
作为链表就最适合高频增删,低频读的场景,但是LinkedList
仅仅在头尾插入或者删除元素的时候时间复杂度近似
O(1),其他情况增删元素的平均时间复杂度都是 O(n)
。这和ArrayList
差了不太多,顶多ArrayList只是尾加看似O(1)
但是,虽然LinkedList 实现了 List
接口,但它与 ArrayList
在性能上有天壤之别。关键在于它的底层结构是双向链表,所以不支持高效的随机访问,你看连
RandomAccess 接口都没有实现
说一下 LinkedList 对于 List 的一些基本特性,首先, LinkedList
是线程不安全的,需要Collections.synchronizedList(new LinkedList<String>()),但是并发场景下更推荐使用
java.util.concurrent.ConcurrentLinkedDeque线程安全的双端队列,它是无锁实现,并发性能远高于同步包装的
LinkedList。
而且 LinkedList 是有序的,它维护了元素的插入顺序,而且也支持重复元素,因为双向链表能做到,而且它支持所有的 List 操作,并允许所有的值,包括null。
对于 LinkedList,无需像 ArrayList 那样维护容量 和 扩容逻辑,链表节点按需创建销毁,理论上无容量上限,受 JVM 内存限制。
“
LinkedList是基于双向链表实现的List。它的核心优势在于头尾的插入和删除操作都是 O(1) 的常数时间,这得益于它维护了first和last两个指针。同时,因为它实现了Deque接口,所以也非常适合作为栈或队列来使用。然而,它的致命弱点是不支持高效的随机访问。像
get(int index)或set(int index, E e)这样的操作,都需要从头或尾开始线性遍历,时间复杂度是 O(n)。即使源码中做了‘从近端开始’的优化,也无法改变其线性时间的本质。因此,在选择
List实现时,如果应用场景是频繁在列表两端进行增删操作,或者需要当作栈/队列,LinkedList是很好的选择。但如果需要频繁通过索引访问元素,那么ArrayList会是更优的选择。此外,
LinkedList的迭代器也是 Fail-Fast 的,并且它有自己定制的序列化方法,只序列化有效元素,提高了效率。”
结构分析
这是理解 LinkedList
一切行为的基础。它的底层实现是双向列表
Node内部类: 链表的基本单元。1
2
3
4
5
6
7
8
9
10
11private static class Node<E> {
E item; // 存储的数据
Node<E> next; // 指向下一个节点的引用
Node<E> prev; // 指向前一个节点的引用
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}每个节点都持有前后两个邻居的引用,这使得在链表中向前或向后遍历都成为可能,也为高效的插入和删除操作奠定了基础。但是是经典的空间换时间
头尾指针:
LinkedList通过两个成员变量来管理整个链表。1
2
3transient int size = 0;
transient Node<E> first; // 指向链表的第一个节点
transient Node<E> last; // 指向链表的最后一个节点size: 记录当前元素数量,使得size()方法可以在 O(1) 时间内返回。transient: 这些字段不会被默认序列化机制处理,但是不是为了线程安全考虑的,因为LinkedList有自己定制的writeObject/readObject方法,以保证序列化的效率和正确性。
而且LinkedList 不仅是一个
List,还是一个功能完备的双端队列
(Deque),可以当作栈 (Stack) 或队列
(Queue) 使用,而且有对应的两套操作的方法
官方推荐用 LinkedList 代替过时的 Stack 类
- 栈操作:
push(E e): 等价于addFirst(e)pop(): 等价于removeFirst()
- 队列操作:
offer(E e): 等价于add(e)(尾部插入)poll(): 等价于removeFirst()(头部移除)
- 双端队列
deque.offerFirst("b");:头部添加元素deque.offerLast("c");:尾部添加元素deque.pollFirst();: 移除头部元素deque.pollLast();:移除尾部元素
对于其构造方法
为什么 LinkedList 不能实现 RandomAccess 接口0?
- 首先,
RandomAccess是一个标记接口,用来表明实现该接口的类支持随机访问,由于LinkedList底层数据结构是链表,内存地址不连续,只能通过指针来定位,底层结构决定了它不能支持随机快速访问,所以不能实现RandomAccess接口
常见操作分析
插入元素
对于插入元素,它的性能是 头/尾 O(1),中间 O(n)
以 尾部插入 (add(E e))为例子
对于其中 linkLast 尾插方法
1
2
3
4
5
6
7
8
9
10
11void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null) // 如果链表为空
first = newNode;
else
l.next = newNode; // 将原尾节点的 next 指向新节点
size++;
modCount++;
}由于
last指针的存在,尾部插入是 O(1) 操作。
对于任意位置插入add(int index, E e):这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除
1 | public void add(int index, E element) { |
node(index)本身是 O(n) 操作,找到节点后的链接操作是 O(1)。所以总的时间复杂度是 O(n)。
因为 LinkedList
我更倾向于把它看成一个队列,所以,我叫成了插入,LinkedList
除了实现了 List 接口相关方法,还实现了 Deque
接口的很多方法,所以我们有很多种方式插入元素。
获取元素
对于随机访问,也就是 get set
方法,它的性能较差,因为需要遍历到 O(n),这是 LinkedList
最显著的弱点
那么,类似的LinkedList获取元素相关的方法一共有 3
个:
getFirst():获取链表的第一个元素。
getLast():获取链表的最后一个元素。
get(int index):获取链表指定位置的元素。
它们的核心都于node(int index)
这个方法息息相关,get(int index) 或
remove(int index)
等方法内部都调用了该方法来获取对应的节点。
1 | /** |
从这个方法的源码可以看出,该方法通过比较索引值与链表 size 的一半大小来确定从链表头还是尾开始遍历。如果索引值小于 size 的一半,就从链表头开始遍历,反之从链表尾开始遍历。这样可以在较短的时间内找到目标节点,充分利用了双向链表的特性来提高效率
删除元素
LinkedList删除元素相关的方法一共有 5 个:
removeFirst():删除并返回链表的第一个元素。removeLast():删除并返回链表的最后一个元素。remove(E e):删除链表中首次出现的指定元素,如果不存在该元素则返回 false。remove(int index):删除指定索引处的元素,并返回该元素的值。void clear():移除此链表中的所有元素
以按索引删除 (remove(int index))为例子
可以看到它调用了一个检查下标是否合法的方法,然后就是断开链表的方法unlink,对于这个方法,我们进行如下分析
1 | E unlink(Node<E> x) { |
那么,这就是很标准的双向链表的删除方法,通过断开连接跳过这个节点,然后GC掉,如下图
迭代器
和 ArrayList 一样,LinkedList 的迭代器也是
Fail-Fast 的。而且 LinkedList
主要提供了两种迭代器实现:
正向迭代器(
ListIterator):通过listIterator()方法获取;
反向迭代器(
DescendingIterator):通过descendingIterator()方法获取。
正向迭代器
对于正向迭代器,ListItr 是 LinkedList
的内部私有类,实现了 ListIterator<E> 接口
1 | private class ListItr implements ListIterator<E> |
- 源码很多,总之,它支持:
- 正向遍历(
next()) - 反向遍历(
previous()) - 在遍历过程中插入(
add())、删除(remove())和修改(set())元素
- 正向遍历(
而且他也有 modCount的映射,所以,上面提到的 LinkedList
在迭代的时候不用迭代器中的方法进行修改的时候,也会爆ConcurrentModificationException()异常,反向正向都是,因为它线程不安全
由于 LinkedList 是双向链表,node(int index)
方法还是和上面说的一样,靠近哪边就从哪边走,这使得 ListItr
在随机访问时仍保持较高效率。
反向迭代器
对于反向迭代器
DescendingIterator 是另一个内部类,它委托给
ListItr 的 previous()
方法来实现反向遍历:
1 | private class DescendingIterator implements Iterator<E> { |
- 它本质上是一个适配器(Adapter),将
ListItr的反向操作在这个反向迭代器中重新实现一下需要反向的操作,暴露为标准的Iterator接口。 - 因此,它也具备 fail-fast 特性,和上面说的各种内容,不再细说
Spliterator 支持
LinkedList 还实现了 spliterator()
方法,返回一个 LLSpliterator,用于支持并行流
1 | public Spliterator<E> spliterator() { |
LLSpliterator是一个 late-binding(延迟绑定) 且 fail-fast 的分割迭代器。它支持有限的并行处理,通过
trySplit()将链表分段处理。同样检查
modCount,确保在并行遍历时若发生结构修改会抛出异常。
序列化
LinkedList 没有使用默认的序列化机制。
对于序列化writeObject
1 | private void writeObject(java.io.ObjectOutputStream s) { |
- 它自己实现序列化反序列化代码是有一定理由的,你看这个序列化的就是避免了序列化大量的
Node对象及其next/prev引用,只序列化有效数据,效率更高,生成的字节流也更小
反序列化方法readObject
1 | private void readObject(java.io.ObjectInputStream s) { |
Vector
对于 ArrayList 的古老的线程安全实现,与 ArrayList 类似,方法加了 synchronized 锁。所以我将不会再多说太多
Vector 的本质是线程安全的动态扩容数组,与 ArrayList 一样,所有元素存储在 Object 数组中
有构造方法四种:
无参构造,初始容量和 ArrayList 一样是10
指定初始容量构造
指定初始容量 + 扩容增量构造
基于集合构造
Vector 的扩容机制
和 ArrayList 差不多,调用
add()/addAll()时,若elementCount + 新增元素数 > elementData.length,触发扩容方法。
但是注意是扩容默认 2 倍,ArrayList 是 1.5 倍
最后,它基本被 CopyOnWriteArrayList 替代或者直接使用
ArrayList,仅兼容旧代码时使用。
CopyOnWriteArrayList
基本特性
CopyOnWriteArrayList 是 Java 并发包
(java.util.concurrent)
中一个非常重要的线程安全集合。它在特定的读多写少场景下表现优异。
CopyOnWriteArrayList 完全实现了
java.util.List 接口,因此它具备所有标准 List
的行为和方法,有序啊,可重复啊,可序列化可克隆啊什么的,但是它允许
null 值
- 在
indexOfRange和lastIndexOfRange等方法中,都对o == null的情况做了特殊处理,确保null值可以被正确地查找和比较。
而且
它实现了
RandomAccess标记接口,表明通过整数索引访问元素是非常高效的(O(1))。这得益于其底层的数组结构。下标流还是太超模了
关于 CopyOnWriteArrayList 的基本特性,它不仅支持 List 的基本特性,而且自身还有一些特殊的,关于并发方面的独特特性
线程安全
这是
CopyOnWriteArrayList最核心的特性。所有可变操作(mutative operations),如add,set,remove,clear等,都是线程安全的。它不使用传统的锁(如
synchronized块或ReentrantLock)来保护整个数据结构而是采用了一种称为 “写时复制” (Copy-On-Write, COW) 的策略。写时复制指的就是,资源被复制的时候不分配新内存先,而是在等到某一方尝试修改时才真正复制。
写时复制针对读操作无锁,也没必要啊就是我说,写操作加锁并复制,会获取一个独占锁
- 复制当前内部数组的完整副本。
- 在副本上执行修改操作。
- 将内部的引用(
volatile Object[] array)原子地指向这个新的副本。 - 释放锁。
而且内部的
array字段被声明为volatile,这保证了写操作的可见性。一旦写操作完成,所有后续的读操作都能立即看到最新的数组。
弱一致性
CopyOnWriteArrayList返回的迭代器(Iterator,ListIterator)是快照 (Snapshot) 式的,因为迭代器在创建时会持有一个对当时内部数组的引用。在迭代器的整个生命周期内,它遍历的都是这个创建时刻的快照。所以说,虽然它迭代过程中永远不会抛出ConcurrentModificationException,但是CopyOnWriteArrayList返回的迭代器无法感知在它创建之后发生的任何修改- 所以说,它适用于对内存一致性要求不是强实时的场景,你需要容忍它读到稍旧的数据
不支持通过迭代器修改:
源码中的
COWIterator类的remove(),set(),add()方法都直接抛出UnsupportedOperationException。这是由其快照特性决定的。
高级特性和行为分析
内部结构
那么,针对CopyOnWriteArrayList 的内部结构,它
volatile关键字是 COW 模式能正确工作的基石。它确保了当一个线程修改了array的引用后,其他线程能立刻看到这个新值。
写操作分析
对于其中的写操作,以 add 为例子
1 | public boolean add(E e) { |
它的步骤清除,加锁 -> 读取 -> 复制+修改 -> 替换引用 -> 解锁。
但是还是挺要命的有个位置,因为它每次写操作都需要
Arrays.copyOf,这是一个 O(n) 的操作。如果列表很大或者写操作非常频繁,性能开销会非常大。
读操作分析
对于其中的读操作,以 get 为例子
1 | public E get(int index) { |
- 整个过程没有任何同步代码块或锁。
- 而且直接通过索引访问数组元素,速度极快。O(1)
- 值得注意的是,array 是 volatile 的,读操作总是拿到的一个对于
CopyOnWriteArrayList的内部数组的一致的一个引用(人话就是可能是旧的(弱一致性),也可能是新的,但绝不会是部分更新的)
删除操作分析
对于其中的删除操作,它是如何实现线程安全的,以索引删除
remove(int index)为例子吧
在并发集合类中,
remove方法的线程安全实现依赖于底层同步机制。
1 | public E remove(int index) { |
- 可以发现,删除也是遵循 COW 的,加锁 -> 复制 -> 修改副本 -> 替换引用
但是它支持按对象删除,这时候就有人问了,上面说的CopyOnWriteArrayList
都是快照保存的,所以找的基准是快照,那么如何保证最终的一致性
再查一遍不就行了………….这对并发来说才算啥
1 | public boolean remove(Object o) { |
- 这个设计很精妙。它首先在不加锁的情况下获取一个快照并查找索引,这是一种乐观的尝试。只有当找到索引后,才去加锁。加锁后,它会再次检查数组是否还是原来的快照(
snapshot != current)。如果不是,说明有其他线程已经修改了列表,那么就需要在最新的current数组中重新查找o的位置,以避免删除错误的元素或基于过时信息操作。这体现了并发编程中的常见优化模式。
私货
而且 CopyOnWriteArrayList 还有一些值得注意的方法
addIfAbsent(E e):仅在元素不存在时才添加。它先检查快照中是否存在,如果不存在,再在加锁后再次检查(防止在检查和加锁之间有其他线程插入了相同元素),最后执行添加。这是一种典型的双重检查模式
subList(int, int): 返回一个COWSubList视图。这个子列表也是基于快照的,并且有自己的expectedArray字段来检测并发修改(checkForComodification)。如果原列表在子列表创建后被修改,对子列表的任何操作都会抛出ConcurrentModificationException。
reversed()(JDK 21+):返回一个反向视图,贼几把好用就是了
迭代器
emmmm,其实大部分并发容器的迭代器思路是差不多的
在高并发场景下,迭代器常采用弱一致性策略来平衡性能与数据可见性。该机制允许迭代过程中容忍一定程度的数据不一致,以避免全局锁带来的性能瓶颈。
迭代器的源码如下,只贴出部分了
1 | static final class COWIterator<E> implements ListIterator<E> { |
- 快照本质特性:
snapshot字段在迭代器创建时就被初始化为getArray()的返回值。之后无论原CopyOnWriteArrayList如何变化,snapshot都不会改变。 - fail-safe:因为操作的是独立副本,所以天然避免了
ConcurrentModificationException。
扩容机制
和其他 List 一样,当元素插入时,若当前容量不足,系统将触发自动扩容机制。
- 可以发现,每次添加元素前,
add方法会检查是否超出数组边界 - 一旦容量不足,调用
Arrays.copyOf创建新数组,大小是加一,注意它是浅复制,仅复制了引用地址,如果你在使用CopyOnWriteArrayList中存储复杂的类型,注意它们的更改情况,而且注意,复制操作阻塞写入,影响并发性能 CopyOnWriteArrayList中没有 grow 方法来进行扩容
所以说,我一直认为,严格来说,俺寻思之力,CopyOnWriteArrayList
没有传统意义上的扩容机制(如 ArrayList 的
ensureCapacity)。它的扩容是写操作的自然结果。
- 动态增长是必然的,
CopyOnWriteArrayList的底层数组大小完全等于其元素的数量 (size() == array.length)。所以上面看到的每次调用add或addAll等增加元素的方法时,都会精确地创建一个比当前数组大N的新数组。 - 我认为 add
操作就是扩容,就这行
Arrays.copyOf(es, len + 1)
CopyOnWriteArrayList
的“扩容”不是一个独立的、有策略的步骤,而是其 COW
写操作不可分割的一部分。不这样存不下你不炸了??
一些面试题
ArrayList 和 Array(数组)的区别
首先,ArrayList
是实现了List接口的一个容器类,可以动态地调整大小,它内部使用数组来存储元素;数组
Array 是 Java
语言内置的一个原生数据结构,用于存储相同类型的多个元素。它在内存中分配一块连续的空间来存储元素,通过索引访问每个元素。
由于 ArrayList
可以动态调整大小,因此非常适合在运行时添加、删除或修改元素的情况下使用,它还提供了一组方便的
API 来处理集合数据。而当我们提到Array
的时候,通常认为它是具有固定长度的,适用于已知元素数量且不会改变的情况,创建它通常需要指定大小,而且认为它只提供索引访问的能力,不具备动态添加、删除元素的能力,需依赖
java.util.Arrays 工具类
ArrayList
可以存储任何类型的对象,但是只能存储对象,包括基本数据类型的包装类和其他引用类;数组
Array 既可以存储基本类型数据,也可以存储对象。
ArrayList
允许你使用泛型来确保类型安全,Array
则不可以。而且对于类型安全,ArrayList是编译期间使用泛型检查,Array
是运行时类型检查。
而且对于基本类型的存储 Array 比 ArrayList<Integer>
快很多
ArrayList 可以添加 null 值吗?
ArrayList 中可以存储任何类型的对象,包括
null 值。不过,不建议向ArrayList 中添加
null 值, null
值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。
通常情况下,面对可能出现存储 null 的容器,我们都使用 Optional 来优雅的处理可能存在的 null,来避免在服务运行时候出现 NPE 的情况
ArrayList 插入和删除元素的时间复杂度?
对于插入:
- 头部插入:由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n)。
- 尾部插入:当
ArrayList的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1),因为它只需要在数组末尾添加一个元素即可;当容量已达到极限并且需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。 - 指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。
对于删除:
- 头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。
- 尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。
- 指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
LinkedList 插入和删除元素的时间复杂度?
头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,不过由于有头尾指针,可以从较近的指针出发,因此需要遍历平均 n/4 个元素,时间复杂度为 O(n)。
ArrayList 与 LinkedList 区别?
- 底层数据结构:
ArrayList底层使用的是Object数组;LinkedList底层使用的是 双向链表 数据结构 - 插入和删除是否受元素位置的影响:
ArrayList采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候,ArrayList会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的 (n-i) 个元素都要执行向后位/向前移一位的操作。LinkedList采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst()、removeLast()),时间复杂度为 O(1),如果是要在指定位置i插入和删除元素的话(add(int index, E element),remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。
- 是否支持快速随机访问:
LinkedList不支持高效的随机元素访问,而ArrayList(实现了RandomAccess接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 - 内存空间占用:
ArrayList的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间,因为要存放直接后继和直接前驱以及数据 - 所以说最后,它们的区别诞生了它们在不同的应用场景下使用他们,
ArrayList适合使用读多写少的常见,LinkedList适合于头尾插入删除多的场景,因为LinkedList其他情况增删元素的平均时间复杂度都是 O(n)
说一说 ArrayList 的扩容机制吧,还有LinkedList为什么不需要扩容
首先,ArrayList 扩容是因为 ArrayList
底层是基于 Object[] 数组实现的动态数组。
由于 Java 数组一旦创建,其长度就固定不变,而 ArrayList
要支持“动态增长”,就必须在容量不足时创建更大的新数组,并将旧数据复制过去,这就是扩容的本质。
当执行 add(E e)
这种添加元素的操作时,如果当前元素数量(size)加 1
超过了底层数组 elementData 的长度(即
capacity),就会触发扩容。
若使用无参构造器时,那么ArrayList的初始容量也不是10,根据elementData
初始是一个空数组({}),首次添加元素时才扩容到
10。
后续扩容策略是新容量 = 旧容量 + (旧容量 >> 1),即 1.5 倍扩容,这是最佳扩容大小,而 ArrayList 会计算一个最小扩容大小也就是扩容的目标大小 - 当前空间 = 最小扩容空间,如果最小扩容大小大于了最佳扩容大小,ArrayList 会选择最小扩容大小
LinkedList 不需要扩容的根本原因是 LinkedList
底层是双向链表,不是连续内存结构,此时,内存是按需动态分配的。因此,LinkedList
没有“容量”概念,也不存在“扩容”操作
所以,ArrayList 的扩容是为了在‘连续内存’限制下模拟动态增长,而 LinkedList 天然具备动态性,因为它基于链式存储。
集合中的 fail-fast 和 fail-safe 是什么?
fail-fast(快速失败)和
fail-safe(安全失败)是Java集合框架在处理并发修改问题时,两种截然不同的设计哲学和容错策略。
快速失败的思想即针对可能发生的异常进行提前表明故障并停止运行,通过尽早的发现和停止错误,降低故障系统级联的风险。
在java.util包下的大部分集合(如 ArrayList,
HashMap)是不支持线程安全的,为了能够提前发现并发操作导致线程安全风险,提出通过维护一个modCount记录修改的次数,迭代期间通过比对预期修改次数expectedModCount和modCount是否一致来判断是否存在并发操作,从而实现快速失败,由此保证在避免在异常时执行非必要的复杂代码。
而fail-safe也就是安全失败的含义,它旨在即使面对意外情况也能恢复并继续运行,这使得它特别适用于不确定或者不稳定的环境:
该思想常运用于并发容器,最经典的实现就是CopyOnWriteArrayList的实现,通过写时复制(Copy-On-Write)的思想保证在进行修改操作时复制出一份快照,基于这份快照完成添加或者删除操作后,将CopyOnWriteArrayList底层的数组引用指向这个新的数组空间,由此避免迭代时被并发修改所干扰所导致并发操作安全问题,当然这种做法也存在缺点,即进行遍历操作时无法获得实时结果






