第1章:ArrayList深度剖析
1.1 动态数组实现原理
1.1.1 核心数据结构深度解析
ArrayList是Java集合框架中最常用的List实现类,它基于动态数组实现,提供了高效的随机访问能力。理解ArrayList的底层实现对于编写高性能代码至关重要。
核心字段详解
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 序列化版本号
private static final long serialVersionUID = 8683452581122892189L;
// 默认初始容量
private static final int DEFAULT_CAPACITY = 10;
// 空数组常量(用于空实例)
private static final Object[] EMPTY_ELEMENTDATA = {};
// 默认空数组常量(用于默认大小的实例)
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 核心:存储元素的数组
// transient修饰:不参与默认序列化,ArrayList自定义了序列化逻辑
transient Object[] elementData;
// 实际元素数量
private int size;
// 继承自AbstractList,用于fail-fast机制
protected transient int modCount = 0;
}
关键设计点深度解析
1. 为什么elementData用transient修饰?
transient关键字表示该字段不参与默认的序列化过程。ArrayList自定义了序列化逻辑,原因如下:
- 节省空间: 只序列化实际存储的元素,不序列化整个数组(包括空位置)
- 性能优化: 减少序列化数据量,提高序列化/反序列化速度
- 精确控制: 可以根据size精确控制序列化的元素数量
2. 为什么使用Object[]而不是E[]?
这是Java泛型擦除机制的限制:
- 泛型擦除: Java的泛型在编译后会被擦除,运行时无法创建泛型数组
- 类型安全: 使用Object[]可以避免类型检查问题,通过类型转换保证类型安全
- 兼容性: 与Java的类型系统兼容
// 错误:无法创建泛型数组
// E[] elementData = new E[capacity]; // 编译错误
// 正确:使用Object[],运行时转换
Object[] elementData = new Object[capacity];
E element = (E) elementData[index]; // 类型转换
3. 为什么维护size字段?
size字段记录实际元素数量,具有以下优势:
- O(1)获取大小:
size()方法直接返回size,不需要遍历数组 - 快速判断空:
isEmpty()方法只需判断size == 0 - 精确控制: 可以精确控制数组的使用范围,避免访问空位置
public int size() {
return size; // O(1)时间复杂度
}
public boolean isEmpty() {
return size == 0; // O(1)时间复杂度
}
内存布局详细分析
ArrayList对象内存布局(64位JVM,压缩指针开启):
对象头(12字节)
├── Mark Word(8字节)
│ ├── 哈希码(25位)
│ ├── 分代年龄(4位)
│ ├── 偏向锁标志(1位)
│ └── 锁标志位(2位)
└── Klass Pointer(4字节):指向类元数据
实例数据(24字节)
├── size字段(4字节):实际元素数量
├── modCount字段(4字节):修改次数
└── elementData引用(8字节):指向数组对象
└── 对齐填充(8字节):对象对齐到8字节边界
总计:36字节(对象本身)
数组对象内存布局:
数组对象头(16字节)
├── Mark Word(8字节)
├── Klass Pointer(4字节)
└── 数组长度(4字节)
数组数据(n × 8字节,n为容量)
├── 引用槽0(8字节) → 元素0对象
├── 引用槽1(8字节) → 元素1对象
├── ...
└── 引用槽n-1(8字节) → 元素n-1对象(可能为null)
内存占用计算示例:
存储1000个Integer对象的内存估算:
ArrayList对象:36字节
数组对象:16 + 1000 × 8 = 8016字节
Integer对象:1000 × 16 = 16000字节(假设每个Integer对象16字节)
总计:约24052字节(约23.5KB)
注意:
- 如果容量是1500(1.5倍扩容),数组对象是12016字节
- 空间浪费:约33%(1500个槽位,只用了1000个)
1.1.2 构造器实现策略深度分析
ArrayList提供三种构造器,采用延迟初始化策略优化内存使用:
构造器1:无参构造器(延迟初始化)
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; // 共享空数组
}
设计要点:
- 延迟初始化: 不立即分配10个容量的数组,而是使用共享空数组
- 节省内存: 如果ArrayList创建后不使用,不会浪费内存
- 首次添加时分配: 第一次调用
add()时才分配默认容量10的数组
延迟初始化的优势:
// 场景:创建大量ArrayList,但可能不使用
List<String> list1 = new ArrayList<>(); // 不分配内存
List<String> list2 = new ArrayList<>(); // 不分配内存
// ... 创建1000个ArrayList,都不分配内存
// 只有实际使用时才分配
list1.add("item"); // 此时才分配10个容量的数组
构造器2:指定容量构造器(精确分配)
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA; // 共享空数组
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}
使用场景:
- 已知元素数量: 可以精确分配容量,避免扩容
- 性能优化: 减少扩容次数,提升性能
- 内存控制: 可以精确控制初始内存占用
实战建议:
// 场景1:从数据库查询,已知结果数量
int count = getCountFromDatabase();
List<Product> products = new ArrayList<>(count); // 精确分配
// 场景2:批量处理,预估大小
int estimatedSize = calculateEstimatedSize();
List<Data> dataList = new ArrayList<>(estimatedSize);
// 场景3:内存敏感场景,精确控制
int maxSize = 1000;
List<String> limitedList = new ArrayList<>(maxSize);
构造器3:集合参数构造器(批量初始化)
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// 如果c是ArrayList,直接使用其数组
if (c.getClass() == ArrayList.class) {
elementData = c.toArray();
} else {
// 否则复制数组,确保类型正确
elementData = Arrays.copyOf(elementData, size, Object[].class);
}
} else {
elementData = EMPTY_ELEMENTDATA;
}
}
优化点:
- 类型检查优化: 如果源集合是ArrayList,直接使用其数组(避免不必要的复制)
- 类型转换: 确保数组类型是Object[],兼容泛型擦除
使用场景:
// 场景1:从其他集合创建
Set<String> set = new HashSet<>();
set.add("A");
set.add("B");
List<String> list = new ArrayList<>(set); // 批量初始化
// 场景2:集合转换
List<Integer> intList = Arrays.asList(1, 2, 3);
List<Integer> arrayList = new ArrayList<>(intList);
// 场景3:集合复制
List<String> original = new ArrayList<>();
List<String> copy = new ArrayList<>(original); // 深拷贝(元素引用)
1.1.3 核心方法实现原理
add方法实现详解
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 确保容量足够
elementData[size++] = e; // 在末尾添加元素
return true;
}
执行流程:
- 容量检查: 调用
ensureCapacityInternal(size + 1)确保容量足够 - 添加元素: 在数组末尾(size位置)添加元素
- 更新size: size自增
- 返回true: ArrayList的add方法总是返回true(允许重复元素)
时间复杂度分析:
- 最好情况: O(1) - 容量足够,直接添加
- 平均情况: O(1)摊销 - 考虑扩容,分摊时间复杂度是O(1)
- 最坏情况: O(n) - 需要扩容,复制所有元素
add(int index, E element)方法实现详解
public void add(int index, E element) {
rangeCheckForAdd(index); // 检查索引范围
ensureCapacityInternal(size + 1); // 确保容量足够
// 将index及其后面的元素后移一位
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element; // 在index位置插入元素
size++; // 更新size
}
执行流程:
- 索引检查: 检查index是否在[0, size]范围内
- 容量检查: 确保容量足够
- 元素后移: 使用
System.arraycopy将index及其后面的元素后移一位 - 插入元素: 在index位置插入新元素
- 更新size: size自增
时间复杂度: O(n) - 需要移动n-index个元素
性能分析:
// 场景1:在头部插入
list.add(0, "new"); // 需要移动所有元素,O(n)
// 场景2:在尾部插入
list.add(list.size(), "new"); // 等价于add("new"),O(1)摊销
// 场景3:在中间插入
list.add(size/2, "new"); // 需要移动一半元素,O(n)
remove方法实现详解
public E remove(int index) {
rangeCheck(index); // 检查索引范围
modCount++; // 修改计数增加
E oldValue = elementData(index); // 获取要删除的元素
int numMoved = size - index - 1; // 需要移动的元素数量
if (numMoved > 0) {
// 将后面的元素前移
System.arraycopy(elementData, index + 1, elementData, index, numMoved);
}
elementData[--size] = null; // 将最后一个位置置null,帮助GC
return oldValue;
}
关键设计点:
- modCount增加: 这是结构性修改,modCount必须增加
- 元素前移: 使用
System.arraycopy高效移动元素 - 置null帮助GC: 将最后一个位置置null,断开引用,帮助垃圾回收
为什么将最后一个位置置null?
elementData[--size] = null; // 帮助GC
// 如果不置null:
// - 数组仍然持有对该对象的引用
// - 即使对象不再使用,也无法被GC回收
// - 可能导致内存泄漏
get和set方法实现
public E get(int index) {
rangeCheck(index); // 检查索引范围
return elementData(index); // 直接数组访问
}
E elementData(int index) {
return (E) elementData[index]; // 类型转换
}
public E set(int index, E element) {
rangeCheck(index); // 检查索引范围
E oldValue = elementData(index); // 获取旧值
elementData[index] = element; // 直接数组赋值
return oldValue; // 返回旧值
}
特点:
- O(1)时间复杂度: 直接数组索引访问,常量时间
- 不修改modCount: set操作不改变列表大小,不是结构性修改
- 类型转换: 从Object[]转换为E,由泛型擦除机制保证类型安全
1.2 扩容机制深度详解
1.2.1 扩容触发条件与完整流程
ArrayList的扩容机制是其核心特性之一,理解扩容过程对于性能优化至关重要。
扩容触发条件
扩容在以下情况下触发:
- 添加元素时:
add(E e)或add(int index, E e) - 批量添加时:
addAll(Collection<? extends E> c) - 容量不足时:
size + 1 > elementData.length
完整扩容流程
// 1. 添加元素时的入口
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 确保容量足够
elementData[size++] = e;
return true;
}
// 2. 内部容量检查
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 第一次添加元素,使用默认容量10
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
// 3. 显式容量检查
private void ensureExplicitCapacity(int minCapacity) {
modCount++; // 修改计数增加
// 如果所需容量大于当前容量,需要扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity); // 执行扩容
}
// 4. 核心扩容算法
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 计算新容量:1.5倍增长
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果1.5倍仍不足,使用minCapacity
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 处理超大容量情况
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 创建新数组并复制元素
elementData = Arrays.copyOf(elementData, newCapacity);
}
// 5. 处理超大容量
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // 溢出检查
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
扩容序列示例
初始状态:elementData.length = 0(延迟初始化)
添加第1个元素:
- minCapacity = 1
- 第一次添加,使用默认容量10
- elementData.length = 10
添加第11个元素:
- minCapacity = 11
- oldCapacity = 10
- newCapacity = 10 + (10 >> 1) = 15
- elementData.length = 15
添加第16个元素:
- minCapacity = 16
- oldCapacity = 15
- newCapacity = 15 + (15 >> 1) = 22
- elementData.length = 22
添加第23个元素:
- minCapacity = 23
- oldCapacity = 22
- newCapacity = 22 + (22 >> 1) = 33
- elementData.length = 33
... 以此类推
1.2.2 为什么选择1.5倍扩容因子?
数学分析
1. 空间利用率分析:
假设从容量10增长到最终容量N,分析不同扩容因子的空间浪费:
1.5倍增长:
- 平均负载因子:约0.75
- 空间浪费:约33%
- 扩容次数:log₁.₅(N/10) ≈ 8次(N=1000时)
2倍增长:
- 平均负载因子:约0.67
- 空间浪费:约50%
- 扩容次数:log₂(N/10) ≈ 7次(N=1000时)
1.2倍增长:
- 平均负载因子:约0.83
- 空间浪费:约17%
- 扩容次数:log₁.₂(N/10) ≈ 20次(N=1000时)
结论: 1.5倍在空间利用率和扩容频率之间取得最佳平衡。
性能影响分析
扩容成本:
- 时间成本: O(n) - 需要复制所有元素
- 空间成本: O(n) - 新旧数组同时存在
- GC压力: 旧数组成为垃圾,增加GC压力
不同扩容因子的性能对比:
| 扩容因子 | 扩容次数(到1000) | 总复制元素数 | 空间浪费 | 综合评价 |
|---|---|---|---|---|
| 1.2倍 | 20次 | 约20000 | 17% | 扩容频繁,性能差 |
| 1.5倍 | 8次 | 约8000 | 33% | 平衡最佳 |
| 2倍 | 7次 | 约7000 | 50% | 空间浪费大 |
实际测试数据
// 测试:添加100万个元素
// 1.5倍扩容:约20次扩容,总复制约2000万个元素
// 2倍扩容:约20次扩容,总复制约2000万个元素
// 但1.5倍的空间利用率更高
// 性能测试结果(添加100万元素):
// 1.5倍:约150ms
// 2倍:约145ms(稍快,但空间浪费大)
// 1.2倍:约200ms(频繁扩容,性能差)
1.2.3 扩容优化策略
优化1:预分配容量(最重要)
// ❌ 错误做法:频繁扩容
List<Integer> badList = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
badList.add(i); // 多次扩容:10→15→22→33→...
// 扩容次数:约20次
// 总复制元素数:约2000万个
// 耗时:约200ms
}
// ✅ 正确做法:预分配容量
List<Integer> goodList = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
goodList.add(i); // 无扩容开销
// 扩容次数:0次
// 总复制元素数:0个
// 耗时:约50ms
}
// 性能提升:约4倍
优化2:批量操作使用addAll
// ❌ 错误做法:逐个添加
List<String> target = new ArrayList<>();
for (String item : source) {
target.add(item); // 可能多次触发扩容
// 如果source有1000个元素,可能扩容多次
}
// ✅ 正确做法:批量添加
List<String> target = new ArrayList<>(source.size());
target.addAll(source); // 一次扩容,一次复制
// 性能提升:约2-5倍
优化3:使用ensureCapacity预扩容
List<String> list = new ArrayList<>();
list.ensureCapacity(1000000); // 手动预扩容
for (int i = 0; i < 1000000; i++) {
list.add("item" + i); // 无扩容开销
}
优化4:适时使用trimToSize释放空间
// 场景:添加大量数据后删除大部分
List<String> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add("data" + i);
}
// 此时容量可能是1500000
// 删除大部分数据
list.subList(1000, list.size()).clear();
// 此时size=1000,但容量仍是1500000
// 释放多余空间
list.trimToSize(); // 容量调整为1000,释放约12MB内存
1.3 modCount与fail-fast机制完全解析
1.3.1 modCount的设计目的与作用
modCount是什么?
modCount(modification count)是ArrayList继承自AbstractList的字段,用于记录集合被结构性修改的次数。
// 继承自AbstractList
protected transient int modCount = 0;
modCount的作用
1. 实现fail-fast机制
modCount是fail-fast机制的核心,用于检测并发修改:
// 迭代器创建时记录modCount
int expectedModCount = modCount;
// 每次操作前检查
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
2. 区分结构性修改和非结构性修改
结构性修改(modCount增加):
add(E e)- 添加元素add(int index, E element)- 在指定位置添加remove(int index)- 删除指定位置元素remove(Object o)- 删除指定元素clear()- 清空集合addAll(Collection<? extends E> c)- 批量添加removeAll(Collection<?> c)- 批量删除
非结构性修改(modCount不变):
set(int index, E element)- 设置元素(不改变大小)get(int index)- 获取元素(只读操作)contains(Object o)- 查找元素(只读操作)indexOf(Object o)- 查找索引(只读操作)
代码示例:
List<String> list = new ArrayList<>();
list.add("A");
System.out.println(list.modCount); // 1
list.add("B");
System.out.println(list.modCount); // 2
list.set(0, "C"); // 非结构性修改
System.out.println(list.modCount); // 2(不变)
list.remove(0);
System.out.println(list.modCount); // 3
为什么modCount用transient修饰?
transient表示该字段不参与序列化:
- 运行时状态: modCount是运行时状态,序列化后恢复没有意义
- 序列化优化: 不序列化可以节省空间
- 反序列化重置: 反序列化时modCount会重置为0
modCount的溢出问题
modCount是int类型,范围是-2³¹到2³¹-1(约21亿)。
溢出处理:
// modCount可能溢出回绕
modCount = Integer.MAX_VALUE;
modCount++; // 溢出,变为Integer.MIN_VALUE
// 但使用!=比较仍能正确工作
if (modCount != expectedModCount) // 即使溢出也能检测到变化
throw new ConcurrentModificationException();
为什么使用!=而不是>?
- 即使modCount溢出回绕,
!=比较仍能正确检测到变化 - 如果使用
>,溢出后可能无法检测到修改
1.3.2 fail-fast机制完全解析
fail-fast是什么?
**fail-fast(快速失败)**是Java集合框架的重要特性,它是一种错误检测机制:
- 设计目的: 在检测到并发修改时立即抛出异常,而不是继续执行可能产生错误结果的操作
- 核心思想: 快速失败,避免数据不一致
- 实现方式: 通过modCount检测并发修改
fail-fast的实现原理
1. 迭代器创建时记录modCount
private class Itr implements Iterator<E> {
int cursor; // 下一个要返回的元素的索引
int lastRet = -1; // 最近返回的元素的索引
// 关键:记录迭代器创建时的modCount
int expectedModCount = modCount;
// 构造方法(隐式)
Itr() {
expectedModCount = modCount; // 记录创建时的modCount
}
}
2. 每次操作前检查modCount
public E next() {
checkForComodification(); // 检查并发修改
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
3. 迭代器的remove方法更新expectedModCount
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification(); // 检查并发修改
try {
ArrayList.this.remove(lastRet); // 调用集合的remove方法
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount; // 关键:更新expectedModCount
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
关键点: 迭代器的remove方法会更新expectedModCount,使其与modCount保持一致,所以不会抛出异常。
fail-fast触发场景详解
场景1:单线程迭代时修改集合
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
// ❌ 错误:在迭代时修改集合
for (String item : list) {
if ("B".equals(item)) {
list.remove(item); // 抛出ConcurrentModificationException
// 原因:
// 1. 迭代器创建时:expectedModCount = 0
// 2. list.remove()执行:modCount = 1
// 3. 下一次next()调用:modCount(1) != expectedModCount(0)
// 4. 抛出ConcurrentModificationException
}
}
场景2:多线程并发修改
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add(i);
}
// 线程1:迭代列表
Thread reader = new Thread(() -> {
try {
for (Integer num : list) {
System.out.println("读取: " + num);
Thread.sleep(1);
}
} catch (ConcurrentModificationException e) {
System.out.println("捕获到并发修改异常!");
}
});
// 线程2:修改列表
Thread writer = new Thread(() -> {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 100; i++) {
list.add(1000 + i); // 修改modCount
list.remove(0);
}
});
reader.start();
writer.start();
// 很可能抛出ConcurrentModificationException
场景3:使用多个迭代器
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
// 创建两个迭代器
Iterator<String> it1 = list.iterator();
Iterator<String> it2 = list.iterator();
// 第一个迭代器删除元素
it1.next();
it1.remove(); // modCount = 1
// 第二个迭代器继续操作
try {
it2.next(); // 抛出ConcurrentModificationException
// 原因:it2的expectedModCount = 0,但modCount = 1
} catch (ConcurrentModificationException e) {
System.out.println("多个迭代器间的并发修改检测");
}
fail-fast的正确处理方式
方式1:使用迭代器的remove方法(推荐)
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
if ("B".equals(item)) {
it.remove(); // ✅ 安全删除,会更新expectedModCount
}
}
方式2:使用Java 8的removeIf(最简洁)
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
list.removeIf(item -> "B".equals(item)); // ✅ 简洁安全
方式3:收集要删除的元素,最后批量删除
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
List<String> toRemove = new ArrayList<>();
for (String item : list) {
if (shouldRemove(item)) {
toRemove.add(item);
}
}
list.removeAll(toRemove); // ✅ 批量删除
方式4:倒序遍历删除
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
for (int i = list.size() - 1; i >= 0; i--) {
if (shouldRemove(list.get(i))) {
list.remove(i); // ✅ 倒序删除,不会影响未遍历的索引
}
}
方式5:使用Stream API过滤
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
List<String> filtered = list.stream()
.filter(item -> !shouldRemove(item))
.collect(Collectors.toList());
list.clear();
list.addAll(filtered);
fail-fast的局限性
1. 非原子性检查
fail-fast检查发生在操作之前,但不保证检查后不被修改:
Iterator<String> it = list.iterator();
if (it.hasNext()) {
// 在这里,另一个线程可能修改了list
String item = it.next(); // 可能成功,也可能失败,不一致
}
2. 不保证100%检测
某些极端并发情况下可能错过检测:
// 线程A:检查modCount
if (modCount != expectedModCount) { // 检查通过
// 线程B:修改list(modCount++)
// 线程A:继续执行(可能看到不一致的数据)
}
3. 性能开销
每次迭代操作都要检查,有一定性能开销:
public E next() {
checkForComodification(); // 每次都要检查
// ...
}
4. 误报可能
某些合法操作也可能触发异常:
// 使用多个迭代器
Iterator<String> it1 = list.iterator();
Iterator<String> it2 = list.iterator();
it1.next();
it1.remove(); // 合法操作
// it2认为这是并发修改
it2.next(); // 抛出ConcurrentModificationException(误报)
fail-fast vs fail-safe
fail-fast(快速失败):
- 检测到并发修改立即抛出异常
- 实现:ArrayList、HashMap、HashSet等
- 优点:能及时发现错误
- 缺点:可能误报,性能有开销
fail-safe(安全失败):
- 允许并发修改,迭代器基于快照工作
- 实现:CopyOnWriteArrayList、ConcurrentLinkedQueue等
- 优点:不会抛出异常
- 缺点:可能看到过期数据(弱一致性)
对比示例:
// fail-fast:ArrayList
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
Iterator<String> it = list.iterator();
list.add("D"); // 修改集合
it.next(); // 抛出ConcurrentModificationException
// fail-safe:CopyOnWriteArrayList
List<String> cowList = new CopyOnWriteArrayList<>(Arrays.asList("A", "B", "C"));
Iterator<String> it2 = cowList.iterator();
cowList.add("D"); // 修改集合
it2.next(); // 正常,不会抛出异常(基于快照)
1.4 序列化机制深度解析
1.4.1 为什么elementData用transient修饰?
ArrayList的elementData字段用transient修饰,这意味着它不参与默认的序列化过程。ArrayList自定义了序列化逻辑,原因如下:
原因1:节省序列化空间
默认序列化的问题:
// 如果使用默认序列化
ArrayList<String> list = new ArrayList<>(1000);
list.add("A");
list.add("B");
// 实际只有2个元素,但数组容量是1000
// 默认序列化会序列化整个数组(包括998个null)
// 序列化数据:1000个槽位(大部分是null)
自定义序列化的优势:
// 自定义序列化只序列化实际元素
// 序列化数据:2个元素
// 空间节省:约99.8%
原因2:提高序列化性能
性能对比:
- 默认序列化: 需要序列化1000个槽位(包括null)
- 自定义序列化: 只序列化2个实际元素
- 性能提升: 序列化速度提升约500倍,反序列化速度提升约500倍
原因3:精确控制序列化内容
自定义序列化可以精确控制序列化的内容,只序列化实际存储的元素。
1.4.2 自定义序列化实现
writeObject方法(序列化)
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// 写入非transient和非static字段(size、modCount等)
s.defaultWriteObject();
// 写入size(实际元素数量)
s.writeInt(size);
// 只写入实际元素,不写入空位置
for (int i = 0; i < size; i++) {
s.writeObject(elementData[i]);
}
}
执行流程:
- 调用
defaultWriteObject()写入非transient字段(size等) - 写入size(实际元素数量)
- 遍历数组,只写入实际元素(0到size-1)
readObject方法(反序列化)
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// 初始化elementData为空数组
elementData = EMPTY_ELEMENTDATA;
// 读取非transient和非static字段
s.defaultReadObject();
// 读取size
s.readInt(); // 读取size,但这里似乎有问题,应该是size = s.readInt()
if (size > 0) {
// 根据size重新分配数组
elementData = new Object[size];
// 读取元素
for (int i = 0; i < size; i++) {
elementData[i] = s.readObject();
}
}
}
执行流程:
- 初始化elementData为空数组
- 调用
defaultReadObject()读取非transient字段 - 读取size
- 根据size重新分配数组(精确大小,无浪费)
- 读取元素并填充数组
优势:
- 反序列化后的数组大小正好等于实际元素数量
- 没有空间浪费
- 性能优异
1.5 性能特征全面分析
1.5.1 时间复杂度完整表
| 操作 | 最好情况 | 平均情况 | 最坏情况 | 详细说明 |
|---|---|---|---|---|
| add(E e) | O(1) | O(1)摊销 | O(n) | 尾部添加,分摊O(1)(考虑扩容) |
| add(int index, E e) | O(n) | O(n) | O(n) | 需要移动n-index个元素 |
| remove(int index) | O(n) | O(n) | O(n) | 需要移动size-index-1个元素 |
| remove(Object o) | O(1) | O(n) | O(n) | 可能很快找到(第一个),也可能遍历全部 |
| get(int index) | O(1) | O(1) | O(1) | 直接数组索引,常量时间 |
| set(int index, E e) | O(1) | O(1) | O(1) | 直接数组赋值,常量时间 |
| contains(Object o) | O(1) | O(n) | O(n) | 需要遍历,最好情况是第一个元素 |
| indexOf(Object o) | O(1) | O(n) | O(n) | 需要遍历查找 |
| lastIndexOf(Object o) | O(1) | O(n) | O(n) | 从后往前遍历查找 |
| clear() | O(n) | O(n) | O(n) | 需要将所有引用置null,帮助GC |
| isEmpty() | O(1) | O(1) | O(1) | 直接判断size == 0 |
| size() | O(1) | O(1) | O(1) | 直接返回size字段 |
1.5.2 空间复杂度分析
空间复杂度: O(n)
- n个元素需要O(n)空间
- 考虑扩容,实际空间可能是1.5n(平均负载因子0.75)
空间浪费:
- 平均空间浪费:约33%(1.5倍扩容)
- 可以通过
trimToSize()释放多余空间
1.5.3 内存访问模式优势
ArrayList内存连续,CPU缓存友好,这是其性能优势的重要来源。
CPU缓存层次结构
L1缓存:32KB,访问延迟约1ns
L2缓存:256KB,访问延迟约3ns
L3缓存:8MB,访问延迟约12ns
主内存:访问延迟约100ns
ArrayList的缓存优势
// ArrayList:顺序访问,缓存命中率高
for (int i = 0; i < list.size(); i++) {
Object obj = list.get(i);
// CPU缓存预取机制有效
// 连续内存访问,缓存命中率高
// 性能优异
}
缓存预取机制:
- CPU会预取连续内存的数据到缓存
- ArrayList顺序访问时,后续数据很可能已经在缓存中
- 缓存命中率:通常>90%
对比LinkedList的缓存劣势
// LinkedList:随机内存访问,缓存不友好
for (Object obj : linkedList) {
// 每个节点访问都可能缓存缺失
// 节点分散在堆内存中
// 缓存命中率:通常<50%
}
性能差异:
- ArrayList顺序访问:缓存命中率>90%,性能优异
- LinkedList顺序访问:缓存命中率<50%,性能较差
- 实际性能差异:ArrayList快30-50%
1.5.4 实际性能测试
测试1:尾部添加性能
public class ArrayListPerformanceTest {
public static void main(String[] args) {
int size = 1000000;
// 测试1:默认初始化
long start = System.nanoTime();
List<Integer> list1 = new ArrayList<>();
for (int i = 0; i < size; i++) {
list1.add(i);
}
long time1 = System.nanoTime() - start;
System.out.println("默认初始化: " + time1 / 1_000_000 + "ms");
// 测试2:预分配容量
start = System.nanoTime();
List<Integer> list2 = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
list2.add(i);
}
long time2 = System.nanoTime() - start;
System.out.println("预分配容量: " + time2 / 1_000_000 + "ms");
System.out.println("性能提升: " + (time1 - time2) * 100.0 / time1 + "%");
}
}
// 典型输出:
// 默认初始化: 150ms
// 预分配容量: 50ms
// 性能提升: 66.7%
测试2:随机访问性能
// ArrayList随机访问:O(1)
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add(i);
}
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
int value = list.get(i); // O(1)
}
long time = System.nanoTime() - start;
System.out.println("随机访问100万次: " + time / 1_000_000 + "ms");
// 典型输出:约10ms(极快)
测试3:头部插入性能
// ArrayList头部插入:O(n)
List<Integer> list = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
list.add(0, i); // 头部插入,需要移动所有元素
}
long time = System.nanoTime() - start;
System.out.println("头部插入10000次: " + time / 1_000_000 + "ms");
// 典型输出:约500ms(较慢)
1.6 ArrayList的常见陷阱与最佳实践
1.6.1 常见陷阱
陷阱1:在迭代时修改集合
// ❌ 错误:抛出ConcurrentModificationException
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
for (String item : list) {
if ("B".equals(item)) {
list.remove(item); // 抛出异常
}
}
// ✅ 正确:使用迭代器的remove方法
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
if ("B".equals(item)) {
it.remove(); // 安全删除
}
}
陷阱2:频繁扩容导致性能下降
// ❌ 错误:频繁扩容
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add(i); // 多次扩容,性能差
}
// ✅ 正确:预分配容量
List<Integer> list = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
list.add(i); // 无扩容开销
}
陷阱3:使用subList后修改原列表
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List<Integer> subList = list.subList(1, 4); // [2, 3, 4]
// 修改原列表
list.add(6); // 结构性修改
// 访问子列表会抛出异常
try {
subList.get(0); // 抛出ConcurrentModificationException
} catch (ConcurrentModificationException e) {
System.out.println("原列表修改使子列表失效");
}
// ✅ 正确:如果需要独立副本
List<Integer> independentCopy = new ArrayList<>(list.subList(1, 4));
list.add(7); // 不影响independentCopy
陷阱4:忽略null元素
List<String> list = new ArrayList<>();
list.add("A");
list.add(null); // ArrayList允许null
list.add("B");
// 注意:contains(null)返回true
System.out.println(list.contains(null)); // true
// 注意:indexOf(null)返回第一个null的索引
System.out.println(list.indexOf(null)); // 1
1.6.2 最佳实践
实践1:预分配容量
// 已知元素数量时,预分配容量
int expectedSize = 10000;
List<String> list = new ArrayList<>(expectedSize);
实践2:批量操作使用addAll
// 批量添加,一次扩容
list.addAll(anotherList);
实践3:适时使用trimToSize
// 如果列表大小固定不再变化
list.trimToSize(); // 释放多余空间
实践4:选择合适的遍历方式
// ArrayList三种方式性能接近
// 方式1:普通for循环(需要索引时)
for (int i = 0; i < list.size(); i++) {
String item = list.get(i);
}
// 方式2:增强for循环(推荐,简洁)
for (String item : list) {
// 处理item
}
// 方式3:forEach方法(Java 8+,函数式风格)
list.forEach(item -> {
// 处理item
});
实践5:使用Stream API进行复杂操作
// 过滤、转换、聚合等操作
List<String> result = list.stream()
.filter(s -> s != null && s.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
📊 总结
核心要点
- 数据结构: 动态数组,内存连续分配
- 扩容机制: 1.5倍增长,平衡性能和空间
- fail-fast: 通过modCount检测并发修改
- 序列化: 自定义序列化,只序列化实际元素
- 性能: 随机访问O(1),尾部添加O(1)摊销
第2章:LinkedList深度剖析
2.1 双向链表数据结构深度解析
2.1.1 节点结构设计详解
LinkedList是Java集合框架中唯一基于链表实现的List,它使用双向链表结构,支持高效的插入和删除操作。
节点类定义
private 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;
}
}
为什么使用双向链表而不是单向链表?
1. 支持双向遍历
双向链表可以从两个方向遍历,这对于实现ListIterator的前向和后向遍历至关重要:
// 前向遍历
Node<E> x = first;
while (x != null) {
// 处理x.item
x = x.next;
}
// 后向遍历
Node<E> x = last;
while (x != null) {
// 处理x.item
x = x.prev;
}
2. 高效删除节点
删除节点时,可以直接通过prev找到前驱节点,无需从头遍历:
// 双向链表删除:O(1)
void unlink(Node<E> x) {
x.prev.next = x.next; // 直接通过prev找到前驱
x.next.prev = x.prev; // 直接通过next找到后继
}
// 单向链表删除:需要O(n)找到前驱
void remove(Node<E> x) {
// 需要从头遍历找到x的前驱节点
Node<E> prev = first;
while (prev.next != x) {
prev = prev.next; // O(n)时间复杂度
}
prev.next = x.next;
}
3. ListIterator支持
ListIterator需要支持前向和后向遍历,双向链表是必要条件:
public ListIterator<E> listIterator(int index) {
checkPositionIndex(index);
return new ListItr(index);
}
private class ListItr implements ListIterator<E> {
private Node<E> lastReturned;
private Node<E> next;
private int nextIndex;
// 支持前向遍历
public E next() {
// ...
next = next.next;
return lastReturned.item;
}
// 支持后向遍历
public E previous() {
// ...
next = next.prev;
return lastReturned.item;
}
}
4. Deque接口实现
双端队列(Deque)需要在两端进行操作,双向链表是理想选择:
// 头部操作
public void addFirst(E e) {
linkFirst(e); // 需要修改first和原first的prev
}
// 尾部操作
public void addLast(E e) {
linkLast(e); // 需要修改last和原last的next
}
节点内存布局详细分析
Node对象内存布局(64位JVM,压缩指针开启):
对象头(12字节)
├── Mark Word(8字节)
│ ├── 哈希码(25位)
│ ├── 分代年龄(4位)
│ ├── 偏向锁标志(1位)
│ └── 锁标志位(2位)
└── Klass Pointer(4字节):指向类元数据
实例数据(12字节)
├── item引用(4字节) → 实际元素对象
├── next引用(4字节) → 下一个Node对象
├── prev引用(4字节) → 上一个Node对象
对齐填充(4字节)
总计:28字节(加上实际元素对象的内存)
内存占用计算示例:
存储1000个Integer对象的内存估算:
LinkedList对象:36字节
Node对象:1000 × 28 = 28000字节
Integer对象:1000 × 16 = 16000字节(假设每个Integer对象16字节)
总计:约44036字节(约43KB)
对比ArrayList:
- ArrayList:约24052字节(约23.5KB)
- LinkedList:约44036字节(约43KB)
- 内存差异:LinkedList是ArrayList的1.83倍
链表结构状态管理
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
transient int size = 0; // 链表大小
transient Node<E> first; // 头节点
transient Node<E> last; // 尾节点
// 构造方法
public LinkedList() { } // 空链表
public LinkedList(Collection<? extends E> c) {
this();
addAll(c); // 批量添加
}
}
链表状态详解:
1. 空链表:
first = null
last = null
size = 0
2. 单节点链表:
first = last = node
node.prev = null
node.next = null
3. 多节点链表:
first → Node1 ↔ Node2 ↔ Node3 ↔ ... ↔ NodeN ← last
↑ ↑ ↑ ↑
prev=null prev prev next=null
2.1.2 链表操作的核心方法
linkLast方法(尾部添加)
void 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++; // 修改计数增加
}
执行流程详解:
- 保存当前尾节点引用(l)
- 创建新节点,prev指向原尾节点,next为null
- 更新last引用指向新节点
- 判断原链表是否为空:
- 如果为空(l == null),新节点也是头节点,更新first
- 如果不为空,原尾节点的next指向新节点
- 更新size和modCount
边界情况处理:
- 空链表:first和last都指向新节点
- 非空链表:只更新last和原尾节点的next
linkFirst方法(头部添加)
private void linkFirst(E e) {
final Node<E> f = first; // 保存原头节点
final Node<E> newNode = new Node<>(null, e, f); // 创建新节点
first = newNode; // 更新头节点为新节点
if (f == null) { // 如果原链表为空
last = newNode; // 新节点也是尾节点
} else {
f.prev = newNode; // 原头节点的prev指向新节点
}
size++;
modCount++;
}
执行流程:
- 保存当前头节点引用(f)
- 创建新节点,prev为null,next指向原头节点
- 更新first引用指向新节点
- 判断原链表是否为空:
- 如果为空,新节点也是尾节点,更新last
- 如果不为空,原头节点的prev指向新节点
- 更新size和modCount
linkBefore方法(指定节点前插入)
void linkBefore(E e, Node<E> succ) {
// 在非空节点succ前插入元素e
final Node<E> pred = succ.prev; // succ的前驱节点
final Node<E> newNode = new Node<>(pred, e, succ); // 创建新节点
succ.prev = newNode; // succ的前驱改为新节点
if (pred == null) { // 如果succ原是头节点
first = newNode; // 新节点成为头节点
} else {
pred.next = newNode; // 原前驱节点的next指向新节点
}
size++;
modCount++;
}
执行流程:
- 获取succ的前驱节点(pred)
- 创建新节点,prev指向pred,next指向succ
- 更新succ的prev指向新节点
- 判断pred是否为空:
- 如果为空(succ原是头节点),新节点成为头节点
- 如果不为空,pred的next指向新节点
- 更新size和modCount
unlink方法(删除节点)
E unlink(Node<E> x) {
// 删除非空节点x
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) { // x是头节点
first = next;
} else {
prev.next = next;
x.prev = null; // 帮助GC
}
if (next == null) { // x是尾节点
last = prev;
} else {
next.prev = prev;
x.next = null; // 帮助GC
}
x.item = null; // 帮助GC
size--;
modCount++;
return element;
}
执行流程:
- 保存节点的元素、前驱和后继
- 处理前驱节点:
- 如果prev为null(x是头节点),更新first指向next
- 否则,prev的next指向next,断开x的prev引用
- 处理后继节点:
- 如果next为null(x是尾节点),更新last指向prev
- 否则,next的prev指向prev,断开x的next引用
- 将x的item、prev、next都置null,帮助GC
- 更新size和modCount
为什么将引用置null?
- 断开节点与链表的连接
- 帮助垃圾回收,避免内存泄漏
- 即使节点对象还在,也不会影响链表结构
2.2 核心操作实现原理
2.2.1 添加操作详解
add(E e)方法
public boolean add(E e) {
linkLast(e);
return true;
}
特点:
- 时间复杂度:O(1)
- 在链表尾部添加
- 总是返回true(允许重复元素)
add(int index, E element)方法
public void add(int index, E element) {
checkPositionIndex(index); // 检查索引范围[0, size]
if (index == size) {
linkLast(element); // 尾部插入
} else {
linkBefore(element, node(index)); // 在指定节点前插入
}
}
执行流程:
- 检查索引范围:[0, size]
- 如果index == size(尾部插入),调用linkLast
- 否则,先找到index位置的节点,然后在该节点前插入
时间复杂度:
- 最好情况:O(1) - 尾部插入
- 平均情况:O(n) - 需要找到插入位置
- 最坏情况:O(n) - 头部插入
addFirst(E e)和addLast(E e)方法
public void addFirst(E e) {
linkFirst(e);
}
public void addLast(E e) {
linkLast(e);
}
特点:
- 时间复杂度:O(1)
- 直接操作头尾节点,无需遍历
- 性能优异
2.2.2 删除操作详解
remove(int index)方法
public E remove(int index) {
checkElementIndex(index); // 检查索引范围[0, size)
return unlink(node(index)); // 找到节点并删除
}
执行流程:
- 检查索引范围:[0, size)
- 调用node(index)找到要删除的节点
- 调用unlink删除节点
时间复杂度: O(n) - 需要找到节点
remove(Object o)方法
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
特点:
- 时间复杂度:O(n) - 需要遍历查找
- 删除第一个匹配的元素
- 区分null元素(使用==)和非null元素(使用equals)
removeFirst()和removeLast()方法
public E removeFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return unlinkFirst(f);
}
public E removeLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return unlinkLast(l);
}
特点:
- 时间复杂度:O(1)
- 直接操作头尾节点,性能优异
- 空链表时抛出NoSuchElementException
2.2.3 查找操作详解
get(int index)方法
public E get(int index) {
checkElementIndex(index); // 检查索引范围[0, size)
return node(index).item; // 获取节点后返回元素
}
关键: 需要调用node(index)找到节点,这是性能瓶颈。
node方法(优化版)
Node<E> node(int index) {
// 根据索引位置决定从头还是从尾开始遍历
if (index < (size >> 1)) { // 索引小于size/2
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
优化效果:
- 未优化: 总是从头开始遍历,最坏情况O(n)
- 优化后: 根据索引位置选择遍历方向,平均O(n/2)
- 性能提升: 对于中间位置的元素,性能提升约50%
优化原理:
- 如果索引在前半部分(index < size/2),从头开始遍历
- 如果索引在后半部分(index >= size/2),从尾开始遍历
- 这样最多只需要遍历一半的节点
contains(Object o)方法
public boolean contains(Object o) {
return indexOf(o) != -1;
}
public int indexOf(Object o) {
int index = 0;
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null)
return index;
index++;
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
}
return -1;
}
特点:
- 时间复杂度:O(n)
- 从前往后查找第一个匹配的元素
- 区分null元素和非null元素
2.2.4 修改操作详解
set(int index, E element)方法
public E set(int index, E element) {
checkElementIndex(index); // 检查索引范围[0, size)
Node<E> x = node(index); // 找到节点
E oldVal = x.item;
x.item = element; // 修改节点的item
return oldVal;
}
特点:
- 时间复杂度:O(n) - 需要找到节点
- 不修改modCount(非结构性修改)
- 返回旧值
2.3 多角色使用特性详解
LinkedList实现了List、Deque、Queue等多个接口,可以扮演不同角色。这是LinkedList相比ArrayList的一个重要优势。
2.3.1 作为List使用
基本List操作
// 创建LinkedList作为List
List<String> list = new LinkedList<>();
// 添加元素
list.add("A");
list.add("B");
list.add("C");
// 随机访问(效率低,O(n))
String element = list.get(1); // 需要遍历到索引1
// 索引插入(需要先找到位置,O(n))
list.add(1, "B1");
// 索引删除(需要先找到节点,O(n))
list.remove(1);
// 遍历(推荐使用迭代器)
for (String item : list) {
System.out.println(item);
}
// ❌ 错误:使用普通for循环+get(i)(性能极差,O(n²))
for (int i = 0; i < list.size(); i++) {
String item = list.get(i); // 每次get都是O(n)
}
List接口的核心方法
// 基本操作
boolean add(E e); // 添加元素
E get(int index); // 获取指定位置元素(O(n))
E set(int index, E element); // 设置指定位置元素(O(n))
E remove(int index); // 删除指定位置元素(O(n))
int size(); // 获取大小
boolean isEmpty(); // 判断是否为空
// 查找操作
int indexOf(Object o); // 查找元素首次出现位置(O(n))
int lastIndexOf(Object o); // 查找元素最后出现位置(O(n))
boolean contains(Object o); // 判断是否包含元素(O(n))
// 视图操作
List<E> subList(int fromIndex, int toIndex); // 获取子列表视图
2.3.2 作为Queue使用(FIFO)
Queue接口操作
// 创建LinkedList作为Queue
Queue<String> queue = new LinkedList<>();
// 入队(添加到队尾)
queue.offer("A"); // 队列满时返回false(LinkedList不会满)
queue.add("B"); // 队列满时抛异常(LinkedList不会满)
// 查看队头(不删除)
String head1 = queue.peek(); // 队列空时返回null
String head2 = queue.element(); // 队列空时抛异常
// 出队(移除队头)
String removed1 = queue.poll(); // 队列空时返回null
String removed2 = queue.remove(); // 队列空时抛异常
// 队列大小
int size = queue.size();
boolean isEmpty = queue.isEmpty();
// 检查元素
boolean contains = queue.contains("A");
Queue操作的特点
| 操作 | 方法 | 队列空/满时的行为 | 时间复杂度 |
|---|---|---|---|
| 入队 | offer/add | add抛异常,offer返回false | O(1) |
| 出队 | poll/remove | remove抛异常,poll返回null | O(1) |
| 查看队头 | peek/element | element抛异常,peek返回null | O(1) |
2.3.3 作为Deque使用(双端队列)
Deque接口操作
// 创建LinkedList作为Deque
Deque<String> deque = new LinkedList<>();
// 添加到队头
deque.addFirst("A");
deque.offerFirst("B");
// 添加到队尾
deque.addLast("C");
deque.offerLast("D");
// 查看队头
String first1 = deque.getFirst();
String first2 = deque.peekFirst();
// 查看队尾
String last1 = deque.getLast();
String last2 = deque.peekLast();
// 移除队头
String removedFirst1 = deque.removeFirst();
String removedFirst2 = deque.pollFirst();
// 移除队尾
String removedLast1 = deque.removeLast();
String removedLast2 = deque.pollLast();
// 其他操作
boolean empty = deque.isEmpty();
int size = deque.size();
boolean contains = deque.contains("A");
Deque操作的特点
| 操作类型 | 方法 | 时间复杂度 | 说明 |
|---|---|---|---|
| 头部添加 | addFirst/offerFirst | O(1) | 直接修改头节点 |
| 尾部添加 | addLast/offerLast | O(1) | 直接修改尾节点 |
| 头部移除 | removeFirst/pollFirst | O(1) | 直接修改头节点 |
| 尾部移除 | removeLast/pollLast | O(1) | 直接修改尾节点 |
| 查看头部 | getFirst/peekFirst | O(1) | 直接返回头节点 |
| 查看尾部 | getLast/peekLast | O(1) | 直接返回尾节点 |
2.3.4 作为Stack使用(LIFO)
Stack操作
// 创建LinkedList作为Stack(推荐代替Stack类)
Deque<String> stack = new LinkedList<>();
// 入栈
stack.push("A"); // 等价于addFirst
stack.push("B");
// 查看栈顶
String top = stack.peek(); // 等价于peekFirst
// 出栈
String popped = stack.pop(); // 等价于removeFirst
// 栈大小
int size = stack.size();
boolean empty = stack.isEmpty();
// 搜索元素(从栈顶开始,1-based)
int position = 0;
for (String item : stack) {
position++;
if ("A".equals(item)) {
break;
}
}
// 注意:LinkedList没有专门的search方法,需要自己遍历
与传统Stack类的对比
// 传统Stack类(不推荐)
Stack<String> oldStack = new Stack<>();
oldStack.push("A");
oldStack.pop();
// 问题:
// 1. Stack继承自Vector,所有方法都同步,性能差
// 2. 设计不佳,违反单一职责原则
// 3. 官方推荐使用Deque
// 现代Stack实现(推荐)
Deque<String> newStack = new LinkedList<>(); // 或ArrayDeque
newStack.push("A");
newStack.pop();
⚠️ 重要提示: Java官方推荐使用Deque的实现类(如LinkedList、ArrayDeque)代替旧的Stack类,因为Stack是基于Vector实现的,有性能问题且设计不佳。
2.4 性能特征全面分析
2.4.1 时间复杂度完整表
| 操作 | 时间复杂度 | 详细说明 |
|---|---|---|
| add(E e) | O(1) | 添加到链表尾部,直接修改last引用 |
| add(int index, E element) | O(n) | 需要先找到插入位置,平均O(n/2) |
| addFirst(E e) | O(1) | 直接修改头节点,常量时间 |
| addLast(E e) | O(1) | 直接修改尾节点,常量时间 |
| remove(int index) | O(n) | 需要先找到节点,然后修改引用 |
| remove(Object o) | O(n) | 需要遍历找到元素 |
| removeFirst() | O(1) | 直接修改头节点引用 |
| removeLast() | O(1) | 直接修改尾节点引用 |
| get(int index) | O(n) | 需要遍历到指定位置,平均O(n/2) |
| set(int index, E element) | O(n) | 需要先找到节点,然后修改item |
| contains(Object o) | O(n) | 需要遍历查找 |
| indexOf(Object o) | O(n) | 需要遍历查找 |
| clear() | O(n) | 需要遍历所有节点置null |
| size() | O(1) | 直接返回size字段 |
| isEmpty() | O(1) | 直接判断size == 0 |
2.4.2 与ArrayList性能全面对比
| 操作 | ArrayList | LinkedList | 性能差异 | 选择建议 |
|---|---|---|---|---|
| 随机访问(get/set) | O(1) | O(n) | ArrayList快100-1000倍 | 频繁随机访问选ArrayList |
| 头部插入删除 | O(n) | O(1) | LinkedList快100-1000倍 | 频繁头部操作选LinkedList |
| 尾部插入删除 | O(1)摊销 | O(1) | 两者接近,ArrayList稍快 | 都可以,ArrayList更省内存 |
| 中间插入删除 | O(n) | O(n) | 都需要找到位置,但常数因子不同 | 根据实际测试选择 |
| 内存占用 | 较小 | 较大 | LinkedList是ArrayList的2-5倍 | 内存紧张选ArrayList |
| 遍历性能 | 快 | 较慢 | ArrayList缓存友好,快30-50% | 频繁遍历选ArrayList |
| 缓存友好性 | 高 | 低 | ArrayList顺序访问,缓存命中率高 | 需要缓存友好选ArrayList |
2.4.3 遍历方式性能对比
❌ 绝对错误:使用普通for循环
// 时间复杂度O(n²)!
List<String> linkedList = new LinkedList<>();
for (int i = 0; i < 10000; i++) {
linkedList.add("item" + i);
}
long start = System.nanoTime();
for (int i = 0; i < linkedList.size(); i++) {
String item = linkedList.get(i); // 每次get都是O(n)
}
long time = System.nanoTime() - start;
System.out.println("普通for循环: " + time / 1_000_000 + "ms");
// 典型输出:约500ms(极慢)
性能分析:
- get(0):遍历0个节点
- get(1):遍历1个节点
- get(2):遍历2个节点
- ...
- get(n-1):遍历n-1个节点
- 总时间复杂度: 0 + 1 + 2 + ... + (n-1) = n(n-1)/2 = O(n²)
✅ 正确方式:使用迭代器或增强for循环
// 时间复杂度O(n)
long start = System.nanoTime();
for (String item : linkedList) {
// 使用迭代器,每次next()是O(1)
}
long time = System.nanoTime() - start;
System.out.println("增强for循环: " + time / 1_000_000 + "ms");
// 典型输出:约5ms(快100倍)
性能分析:
- 迭代器创建:O(1)
- 每次next():O(1)
- 总时间复杂度: O(n)
性能差异实测:
- 10000个元素,普通for循环:约500ms
- 10000个元素,迭代器遍历:约5ms
- 性能差异:约100倍!
2.4.4 内存占用详细分析
内存占用对比
存储1000个Integer对象的内存估算:
ArrayList:
ArrayList对象:36字节
数组对象:16 + 1000 × 8 = 8016字节
Integer对象:1000 × 16 = 16000字节
总计:约24052字节(约23.5KB)
LinkedList:
LinkedList对象:36字节
Node对象:1000 × 28 = 28000字节
Integer对象:1000 × 16 = 16000字节
总计:约44036字节(约43KB)
内存差异: LinkedList是ArrayList的1.83倍
影响因素
- 元素数量: 元素越多,差距越大
- 元素大小: 存储的对象越大,相对差距越小
- JVM配置: 64位JVM开启指针压缩可以减小差距
- 内存碎片: LinkedList节点分散,可能产生更多内存碎片
2.4.5 CPU缓存影响分析
缓存层次结构
L1缓存:32KB,访问延迟约1ns
L2缓存:256KB,访问延迟约3ns
L3缓存:8MB,访问延迟约12ns
主内存:访问延迟约100ns
LinkedList的缓存劣势
// LinkedList:随机内存访问,缓存不友好
for (Object obj : linkedList) {
// 每个节点访问都可能缓存缺失
// 节点分散在堆内存中
// 缓存命中率:通常<50%
}
缓存访问模式:
- 节点1在内存地址0x1000
- 节点2在内存地址0x5000(不连续)
- 节点3在内存地址0x2000(不连续)
- 每次访问都可能缓存缺失,需要从主内存加载
性能影响:
- 缓存命中率:<50%
- 平均访问延迟:约50ns(考虑缓存缺失)
- 性能较差
对比ArrayList的缓存优势
// ArrayList:顺序访问,缓存友好
for (int i = 0; i < list.size(); i++) {
Object obj = list.get(i);
// CPU缓存预取机制有效
// 连续内存访问,缓存命中率高
// 缓存命中率:通常>90%
}
缓存访问模式:
- 元素0在内存地址0x1000
- 元素1在内存地址0x1008(连续)
- 元素2在内存地址0x1010(连续)
- CPU可以预取后续数据到缓存
性能影响:
- 缓存命中率:>90%
- 平均访问延迟:约5ns(大部分缓存命中)
- 性能优异
性能差异: ArrayList遍历比LinkedList快30-50%
2.5 LinkedList的常见陷阱与最佳实践
2.5.1 常见陷阱
陷阱1:使用普通for循环遍历(最严重)
// ❌ 绝对错误:O(n²)时间复杂度
List<String> list = new LinkedList<>();
for (int i = 0; i < list.size(); i++) {
String item = list.get(i); // 每次get都是O(n)
// 对于10000个元素,需要约5000万次操作
}
// ✅ 正确:使用迭代器或增强for循环
for (String item : list) {
// O(n)时间复杂度
}
性能影响:
- 1000个元素:普通for循环约5ms,迭代器约0.5ms(10倍差异)
- 10000个元素:普通for循环约500ms,迭代器约5ms(100倍差异)
- 100000个元素:普通for循环约50000ms,迭代器约50ms(1000倍差异)
陷阱2:频繁随机访问
// ❌ 错误:频繁使用get(index)
List<String> list = new LinkedList<>();
for (int i = 0; i < 1000; i++) {
String item = list.get(i); // 每次都是O(n)
}
// ✅ 正确:使用迭代器
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next(); // O(1)
}
陷阱3:在迭代时修改集合
// ❌ 错误:在迭代时修改集合
List<String> list = new LinkedList<>(Arrays.asList("A", "B", "C"));
for (String item : list) {
if ("B".equals(item)) {
list.remove(item); // 抛出ConcurrentModificationException
}
}
// ✅ 正确:使用迭代器的remove方法
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
if ("B".equals(item)) {
it.remove(); // 安全删除
}
}
陷阱4:忽略内存占用
// ❌ 错误:大数据量时使用LinkedList
List<BigObject> bigList = new LinkedList<>();
for (int i = 0; i < 1000000; i++) {
bigList.add(new BigObject()); // 内存占用是ArrayList的2-5倍
}
// ✅ 正确:根据场景选择
// 如果内存紧张,即使需要频繁插入删除,也要考虑ArrayList
List<BigObject> list = new ArrayList<>(1000000);
2.5.2 最佳实践
实践1:绝对避免随机访问
// ❌ 绝对错误:O(n²)
for (int i = 0; i < list.size(); i++) {
list.get(i);
}
// ✅ 正确:O(n)
for (String item : list) {
// 处理item
}
实践2:利用ListIterator进行高效操作
// 场景:在遍历过程中插入元素
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
String item = it.next();
if (needInsertBefore(item)) {
it.add("new item"); // O(1),在当前位置插入
}
}
// 场景:双向遍历
ListIterator<String> it = list.listIterator(list.size());
while (it.hasPrevious()) {
String item = it.previous(); // 从后往前遍历
}
实践3:选择合适的头部/尾部操作
// 正确使用头部操作
public void useAsStack() {
// 栈:后进先出
list.push("item1"); // addFirst
list.push("item2");
String top = list.pop(); // removeFirst
}
// 正确使用尾部操作
public void useAsQueue() {
// 队列:先进先出
list.offer("item1"); // addLast
list.offer("item2");
String first = list.poll(); // removeFirst
}
// 双端队列
public void useAsDeque() {
// 可以在两端操作
list.addFirst("urgent");
list.addLast("normal");
String first = list.removeFirst();
String last = list.removeLast();
}
实践4:根据场景选择合适的数据结构
// 场景1:需要频繁随机访问
List<Product> catalog = new ArrayList<>(); // 正确选择
Product p = catalog.get(1234); // O(1)
// 场景2:需要频繁在头尾操作
Deque<Message> messageQueue = new LinkedList<>(); // 正确选择
messageQueue.addFirst(newMessage); // O(1)
Message m = messageQueue.pollLast(); // O(1)
// 场景3:需要实现栈
Deque<StackFrame> stack = new LinkedList<>(); // 正确选择
stack.push(newFrame); // O(1)
StackFrame f = stack.pop(); // O(1)
实践5:使用Java 8的Stream API
// 过滤和转换
List<String> result = linkedList.stream()
.filter(s -> s != null && s.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
// 并行处理(大数据量)
linkedList.parallelStream()
.forEach(item -> process(item));
📊 总结
核心要点
- 数据结构: 双向链表,内存分散分配
- 核心优势: 头尾操作O(1),支持多角色使用
- 核心劣势: 随机访问O(n),内存占用大
- 遍历方式: 必须使用迭代器,不能用普通for循环
- 适用场景: 频繁头尾操作,需要队列/栈功能
性能要点
- 随机访问: O(n),性能差,应避免
- 头尾操作: O(1),性能优异
- 遍历: O(n),但必须用迭代器
- 内存: 是ArrayList的2-5倍
第3章:List集合对比与选择
3.1 ArrayList vs LinkedList全面深度对比
3.1.1 底层数据结构本质差异
数据结构对比
| 维度 | ArrayList | LinkedList |
|---|---|---|
| 数据结构 | 动态数组 | 双向链表 |
| 内存布局 | 连续分配 | 分散分配 |
| 节点开销 | 无额外开销 | 每个元素约28字节额外开销 |
| 缓存友好性 | 高(顺序访问) | 低(随机访问) |
| 内存碎片 | 少 | 多 |
| GC压力 | 小 | 大 |
内存结构详细对比
ArrayList内存布局:
内存地址:0x1000 - [引用1][引用2][引用3]...(连续数组)
0x2000 - 实际对象1
0x3000 - 实际对象2
0x4000 - 实际对象3
...
LinkedList内存布局:
内存地址:0x1000 - Node1 (item, prev, next)
0x2000 - 实际对象1
0x3000 - Node2 (item, prev, next)
0x4000 - 实际对象2
0x5000 - Node3 (item, prev, next)
...(节点分散在堆内存中)
关键差异:
- ArrayList:内存连续,CPU缓存预取有效
- LinkedList:内存分散,每次访问都可能缓存缺失
3.1.2 性能对比详细分析
时间复杂度完整对比表
| 操作 | ArrayList | LinkedList | 差异分析 | 实际性能差异 |
|---|---|---|---|---|
| add(E e) | O(1)摊销 | O(1) | ArrayList可能触发扩容 | ArrayList稍快(无对象创建) |
| add(int index, E e) | O(n) | O(n) | ArrayList需要移动元素,LinkedList需要遍历 | 两者接近,但常数因子不同 |
| addFirst(E e) | O(n) | O(1) | ArrayList需要移动所有元素 | LinkedList快100-1000倍 |
| addLast(E e) | O(1)摊销 | O(1) | 两者都是O(1) | ArrayList稍快(无对象创建) |
| get(int index) | O(1) | O(n) | ArrayList直接索引访问 | ArrayList快100-1000倍 |
| set(int index, E e) | O(1) | O(n) | ArrayList直接数组赋值 | ArrayList快100-1000倍 |
| remove(int index) | O(n) | O(n) | ArrayList需要移动元素,LinkedList需要遍历 | 两者接近,但ArrayList缓存友好 |
| removeFirst() | O(n) | O(1) | ArrayList需要移动所有元素 | LinkedList快100-1000倍 |
| removeLast() | O(1) | O(1) | 两者都是O(1) | ArrayList稍快 |
| contains(Object) | O(n) | O(n) | 都需要遍历 | ArrayList稍快(缓存友好) |
| indexOf(Object) | O(n) | O(n) | 都需要遍历 | ArrayList稍快(缓存友好) |
| 遍历 | O(n) | O(n) | 都是O(n) | ArrayList快30-50%(缓存友好) |
实际性能测试数据
测试环境: JDK 11, Intel i7, 16GB RAM
测试1:尾部添加100万个元素
// ArrayList(预分配容量)
List<Integer> arrayList = new ArrayList<>(1000000);
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
arrayList.add(i);
}
long time1 = System.nanoTime() - start;
// 结果:约50ms
// LinkedList
List<Integer> linkedList = new LinkedList<>();
start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
linkedList.add(i);
}
long time2 = System.nanoTime() - start;
// 结果:约80ms
// 结论:ArrayList稍快(无对象创建开销)
测试2:头部添加10000个元素
// ArrayList
List<Integer> arrayList = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
arrayList.add(0, i); // 头部插入
}
long time1 = System.nanoTime() - start;
// 结果:约500ms
// LinkedList
List<Integer> linkedList = new LinkedList<>();
start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
linkedList.addFirst(i); // 头部插入
}
long time2 = System.nanoTime() - start;
// 结果:约5ms
// 结论:LinkedList快100倍
测试3:随机访问1000次
// ArrayList
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
arrayList.add(i);
}
Random random = new Random();
long start = System.nanoTime();
for (int i = 0; i < 1000; i++) {
int index = random.nextInt(100000);
int value = arrayList.get(index);
}
long time1 = System.nanoTime() - start;
// 结果:约0.1ms
// LinkedList
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 100000; i++) {
linkedList.add(i);
}
start = System.nanoTime();
for (int i = 0; i < 1000; i++) {
int index = random.nextInt(100000);
int value = linkedList.get(index);
}
long time2 = System.nanoTime() - start;
// 结果:约100ms
// 结论:ArrayList快1000倍
测试4:遍历100万个元素
// ArrayList
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
arrayList.add(i);
}
long start = System.nanoTime();
for (Integer num : arrayList) {
// 空循环
}
long time1 = System.nanoTime() - start;
// 结果:约10ms
// LinkedList
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 1000000; i++) {
linkedList.add(i);
}
start = System.nanoTime();
for (Integer num : linkedList) {
// 空循环
}
long time2 = System.nanoTime() - start;
// 结果:约15ms
// 结论:ArrayList快50%(缓存友好)
3.1.3 内存占用详细对比
内存占用计算
存储1000个Integer对象的内存估算:
ArrayList:
ArrayList对象:36字节
数组对象:16 + 1000 × 8 = 8016字节
Integer对象:1000 × 16 = 16000字节(假设每个Integer对象16字节)
总计:约24052字节(约23.5KB)
如果容量是1500(1.5倍扩容):
数组对象:16 + 1500 × 8 = 12016字节
空间浪费:约33%(1500个槽位,只用了1000个)
LinkedList:
LinkedList对象:36字节
Node对象:1000 × 28 = 28000字节
Integer对象:1000 × 16 = 16000字节
总计:约44036字节(约43KB)
内存差异: LinkedList是ArrayList的1.83倍
影响因素分析
1. 元素数量:
- 元素越多,差距越大
- 1000个元素:差异1.83倍
- 10000个元素:差异约2倍
- 100000个元素:差异约2.5倍
2. 元素大小:
- 存储的对象越大,相对差距越小
- 存储Integer:差异约2倍
- 存储大对象(1KB):差异约1.1倍
3. JVM配置:
- 64位JVM开启指针压缩:可以减小差距
- 指针压缩:引用从8字节变为4字节
- Node对象:从28字节变为24字节
4. 内存碎片:
- LinkedList节点分散,可能产生更多内存碎片
- ArrayList内存连续,碎片较少
3.1.4 CPU缓存影响深度分析
缓存层次结构
L1缓存:32KB,访问延迟约1ns
L2缓存:256KB,访问延迟约3ns
L3缓存:8MB,访问延迟约12ns
主内存:访问延迟约100ns
ArrayList的缓存优势
// ArrayList:顺序访问,缓存命中率高
for (int i = 0; i < list.size(); i++) {
Object obj = list.get(i);
// CPU缓存预取机制有效
// 连续内存访问,缓存命中率高
// 缓存命中率:通常>90%
}
缓存访问模式:
- 元素0在内存地址0x1000
- 元素1在内存地址0x1008(连续)
- 元素2在内存地址0x1010(连续)
- CPU可以预取后续数据到缓存
性能影响:
- 缓存命中率:>90%
- 平均访问延迟:约5ns(大部分缓存命中)
- 性能优异
LinkedList的缓存劣势
// LinkedList:随机内存访问,缓存不友好
for (Object obj : linkedList) {
// 每个节点访问都可能缓存缺失
// 节点分散在堆内存中
// 缓存命中率:通常<50%
}
缓存访问模式:
- 节点1在内存地址0x1000
- 节点2在内存地址0x5000(不连续)
- 节点3在内存地址0x2000(不连续)
- 每次访问都可能缓存缺失
性能影响:
- 缓存命中率:<50%
- 平均访问延迟:约50ns(考虑缓存缺失)
- 性能较差
性能差异: ArrayList遍历比LinkedList快30-50%
3.1.5 GC压力对比
GC影响分析
ArrayList:
- 对象数量少:只有1个数组对象
- 内存连续:GC效率高
- GC压力:小
LinkedList:
- 对象数量多:n个Node对象
- 内存分散:可能产生碎片
- GC压力:大
实际测试:
// 创建100万个元素的集合
// ArrayList:产生1个数组对象
// LinkedList:产生100万个Node对象
// GC时间对比(Full GC):
// ArrayList:约50ms
// LinkedList:约200ms(4倍差异)
3.2 使用场景选择详细指南
3.2.1 选择ArrayList的场景详解
场景1:频繁随机访问
特征: 需要频繁使用get(index)或set(index, element)
选择理由:
- ArrayList的get/set是O(1),性能优异
- LinkedList的get/set是O(n),性能差
- 性能差异:100-1000倍
场景2:遍历操作为主
特征: 主要进行顺序遍历,很少随机访问
选择理由:
- ArrayList顺序访问,缓存命中率高
- LinkedList随机内存访问,缓存命中率低
- 性能差异:30-50%
场景3:内存敏感的应用
特征: 内存有限,需要节省内存
选择理由:
- ArrayList内存占用小
- LinkedList内存占用大(2-5倍)
- 内存差异:对于1000个元素,差异约20KB
场景4:数据基本不变或只在尾部添加
特征: 数据创建后很少修改,或只在尾部追加
选择理由:
- 尾部添加,两者都是O(1)
- ArrayList更省内存
- 如果需要随机访问,ArrayList优势明显
场景5:需要subList操作
特征: 需要频繁使用subList方法
选择理由:
- ArrayList的subList是O(1)视图
- LinkedList的subList需要遍历,性能差
3.2.2 选择LinkedList的场景详解
场景1:频繁在头部插入删除
特征: 需要频繁使用addFirst、removeFirst等操作
选择理由:
- LinkedList头部操作是O(1)
- ArrayList头部操作是O(n)
- 性能差异:100-1000倍
场景2:需要实现队列或双端队列
特征: 需要FIFO或LIFO功能
选择理由:
- LinkedList实现了Deque接口
- 头尾操作都是O(1)
- 功能丰富,使用方便
场景3:频繁在中间位置插入(如果有节点引用)
特征: 使用ListIterator在遍历过程中插入
选择理由:
- ListIterator的add/remove是O(1)
- 在遍历过程中插入,性能优异
- ArrayList的ListIterator插入需要移动元素
场景4:不确定元素数量且需要频繁插入删除
特征: 元素数量不确定,需要频繁在任意位置插入删除
选择理由:
- 不需要预分配容量
- 插入删除只需修改引用,不需要移动元素
- 适合动态数据
场景5:需要栈功能
特征: 需要LIFO(后进先出)功能
选择理由:
- LinkedList实现了Deque接口
- push/pop都是O(1)
- 比Stack类性能更好
3.3 选择决策树
选择ArrayList的关键因素:
- ✅ 频繁随机访问
- ✅ 频繁遍历
- ✅ 内存紧张
- ✅ 数据规模大
- ✅ 需要subList操作
- ✅ 关注GC和缓存性能
选择LinkedList的关键因素:
- ✅ 频繁在头尾插入删除
- ✅ 需要队列/栈功能
- ✅ 频繁在中间插入(有迭代器引用)
- ✅ 内存充足
- ✅ 数据规模小到中等
默认选择: ArrayList(大多数场景)
3.4 性能优化最佳实践
3.4.1 ArrayList优化技巧详解
技巧1:预分配初始容量(最重要)
// ❌ 错误:频繁扩容,性能差
List<Integer> badList = new ArrayList<>(); // 默认容量10
for (int i = 0; i < 1000000; i++) {
badList.add(i); // 多次扩容:10→15→22→33→...
// 扩容次数:约20次
// 总复制元素数:约2000万个
// 耗时:约200ms
}
// ✅ 正确:预分配,性能好
List<Integer> goodList = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
goodList.add(i); // 无扩容开销
// 扩容次数:0次
// 总复制元素数:0个
// 耗时:约50ms
}
// 性能提升:约4倍
优化建议:
// 方法1:根据预估大小预分配
int expectedSize = 10000;
List<String> list = new ArrayList<>(expectedSize);
// 方法2:考虑负载因子,预留33%空间
int optimalCapacity = (int)(expectedSize / 0.75f) + 1;
List<String> list = new ArrayList<>(optimalCapacity);
// 方法3:使用ensureCapacity预扩容
List<String> list = new ArrayList<>();
list.ensureCapacity(1000000);
技巧2:批量操作使用addAll
// ❌ 错误:逐个添加
List<String> target = new ArrayList<>();
for (String item : source) {
target.add(item); // 可能多次触发扩容
// 如果source有1000个元素,可能扩容多次
}
// ✅ 正确:批量添加
List<String> target = new ArrayList<>(source.size());
target.addAll(source); // 一次扩容,一次复制
// 性能提升:约2-5倍
优化建议:
// 场景1:合并多个集合
List<String> result = new ArrayList<>(list1.size() + list2.size());
result.addAll(list1);
result.addAll(list2);
// 场景2:从数组创建
String[] array = {"A", "B", "C"};
List<String> list = new ArrayList<>(Arrays.asList(array));
// 场景3:从Stream创建
List<String> list = stream.collect(Collectors.toCollection(
() -> new ArrayList<>(estimatedSize)));
技巧3:适时使用trimToSize
// 场景:添加大量数据后删除大部分
List<String> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add("data" + i);
}
// 此时容量可能是1500000
// 删除大部分数据
list.subList(1000, list.size()).clear();
// 此时size=1000,但容量仍是1500000
// 释放多余空间
list.trimToSize(); // 容量调整为1000,释放约12MB内存
使用场景:
- 列表大小固定不再变化
- 内存紧张,需要释放空间
- 批量删除后,容量远大于size
3.4.2 LinkedList优化技巧详解
技巧1:绝对避免随机访问
// ❌ 绝对错误:O(n²)时间复杂度
List<String> list = new LinkedList<>();
for (int i = 0; i < list.size(); i++) {
String item = list.get(i); // 每次get都是O(n)
// 对于10000个元素,需要约5000万次操作
}
// ✅ 正确:O(n)时间复杂度
for (String item : list) {
// 使用迭代器,每次next()是O(1)
// 对于10000个元素,只需要10000次操作
}
// 性能提升:约5000倍!
技巧2:利用ListIterator进行高效操作
// 场景1:在遍历过程中插入元素
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
String item = it.next();
if (needInsertBefore(item)) {
it.add("new item"); // O(1),在当前位置插入
}
}
// 场景2:双向遍历
ListIterator<String> it = list.listIterator(list.size());
while (it.hasPrevious()) {
String item = it.previous(); // 从后往前遍历
}
// 场景3:在指定位置操作
ListIterator<String> it = list.listIterator(index);
it.add("new item"); // 在指定位置插入,O(1)
技巧3:选择合适的头部/尾部操作
// 正确使用头部操作
public void useAsStack() {
// 栈:后进先出
list.push("item1"); // addFirst
list.push("item2");
String top = list.pop(); // removeFirst
}
// 正确使用尾部操作
public void useAsQueue() {
// 队列:先进先出
list.offer("item1"); // addLast
list.offer("item2");
String first = list.poll(); // removeFirst
}
// 双端队列
public void useAsDeque() {
// 可以在两端操作
list.addFirst("urgent");
list.addLast("normal");
String first = list.removeFirst();
String last = list.removeLast();
}
3.4.3 通用优化技巧
技巧1:避免不必要的装箱拆箱
// ❌ 错误:使用包装类型
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add(i); // 自动装箱,性能开销
}
// ✅ 正确:使用原始数组(如果可能)
int[] array = new int[1000000];
for (int i = 0; i < 1000000; i++) {
array[i] = i; // 无装箱开销
}
// 或者使用第三方库(如Trove、FastUtil)
TIntArrayList list = new TIntArrayList();
技巧2:使用Stream API进行复杂操作
// 传统方式
List<String> result = new ArrayList<>();
for (String s : list) {
if (s != null && s.length() > 3) {
result.add(s.toUpperCase());
}
}
// Stream API方式(更简洁,可能更高效)
List<String> result = list.stream()
.filter(s -> s != null && s.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
技巧3:并行处理(大数据量)
// 串行处理
list.stream()
.forEach(item -> process(item));
// 并行处理(CPU密集型,大数据量)
list.parallelStream()
.forEach(item -> process(item));
// 注意:并行有开销,小数据量可能更慢
// 通常数据量>10000才考虑并行
第4章:线程安全List实现
4.1 Vector的线程安全实现(已过时,了解即可)
Vector的基本特性
Vector是JDK 1.0就存在的集合类,已不推荐在新代码中使用。
核心特点:
- 基于动态数组实现,类似ArrayList
- 所有公共方法都使用
synchronized关键字实现线程安全 - 由于性能问题和设计过时,已被更好的替代方案取代
同步机制详解
// Vector的所有公共方法都使用synchronized
public synchronized boolean add(E e) { ... }
public synchronized E get(int index) { ... }
public synchronized int size() { ... }
public synchronized boolean isEmpty() { ... }
锁竞争问题:
// 每次操作都需要获取锁,读操作也要锁
Vector<Integer> vector = new Vector<>();
vector.add(1); // 获取锁
int value = vector.get(0); // 获取锁(读操作也要锁!)
// 问题:读操作之间无法并发,性能差
性能问题分析:
- 所有方法都加锁,锁粒度太粗
- 读操作也要加锁,无法并发读取
- 锁竞争严重,高并发场景性能急剧下降
- 复合操作仍需外部同步
4.2 CopyOnWriteArrayList原理深度解析
核心设计思想
Copy-On-Write(写时复制)模式:
- 读取操作: 无锁,直接访问当前数组,性能极高
- 写入操作: 加锁,复制新数组,修改新数组,替换引用
适用场景: 读多写少,读频率远大于写频率(通常≥10:1)
核心数据结构
public class CopyOnWriteArrayList<E> {
// 核心:volatile保证可见性
private transient volatile Object[] array;
final Object[] getArray() {
return array; // volatile读,保证看到最新值
}
final void setArray(Object[] a) {
array = a; // volatile写,保证对其他线程可见
}
}
写操作实现原理详解
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 1. 加锁(写操作互斥)
try {
Object[] elements = getArray(); // 获取当前数组
int len = elements.length;
// 2. 创建新数组(长度+1)
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 3. 在新数组上修改
newElements[len] = e;
// 4. 替换引用(volatile写,保证可见性)
setArray(newElements);
return true;
} finally {
lock.unlock(); // 5. 解锁
}
}
写操作特点:
- ✅ 加锁:所有写操作都使用ReentrantLock加锁,保证互斥
- ✅ 复制数组:每次写操作都复制整个数组(写时复制)
- ✅ 原子性:写操作是原子的,读者要么看到旧数组,要么看到新数组
- ⚠️ 开销大:复制整个数组,时间复杂度O(n),空间复杂度O(n)
读操作实现原理详解
// 所有读操作都不需要加锁
public E get(int index) {
return get(getArray(), index); // 直接访问数组
}
public int size() {
return getArray().length; // 直接返回数组长度
}
public boolean contains(Object o) {
Object[] elements = getArray();
return indexOf(o, elements, 0, elements.length) >= 0;
}
读操作特点:
- ✅ 完全无锁:不需要任何同步机制,性能极高
- ✅ 直接内存访问:通过数组索引直接访问,O(1)时间复杂度
- ✅ volatile保证可见性:通过volatile读获取最新的array引用
- ⚠️ 弱一致性:读操作可能看到过期的数据(但不会看到不一致的数据)
迭代器特性详解
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
// 迭代器创建时复制当前数组的快照
private final Object[] snapshot;
private int cursor;
private COWIterator(Object[] elements, int initialCursor) {
snapshot = elements; // 保存快照,不会反映后续修改
cursor = initialCursor;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
public E next() {
if (!hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
// 不支持修改操作
public void remove() {
throw new UnsupportedOperationException();
}
}
迭代器特点:
- ✅ 基于快照:迭代器创建时复制数组快照
- ✅ 弱一致性:不反映创建后的修改
- ✅ 不会抛出ConcurrentModificationException
- ❌ 不支持修改操作:remove、set、add都抛出UnsupportedOperationException
- ⚠️ 内存开销:每个迭代器持有快照引用,可能阻止旧数组被GC
适用场景深度分析
✅ 适合使用CopyOnWriteArrayList的场景:
- 读多写少的配置信息
private CopyOnWriteArrayList<ConfigListener> listeners =
new CopyOnWriteArrayList<>();
public void notifyConfigChange(ConfigEvent event) {
// 无锁遍历,高性能
for (ConfigListener listener : listeners) {
listener.onConfigChanged(event);
}
}
public void addListener(ConfigListener listener) {
listeners.add(listener); // 写操作少,开销可接受
}
- 事件监听器列表
private CopyOnWriteArrayList<EventListener> listeners =
new CopyOnWriteArrayList<>();
public void fireEvent(Event event) {
// 事件触发时,监听器列表通常不变
for (EventListener listener : listeners) {
listener.onEvent(event); // 无锁读取,性能优异
}
}
❌ 不适合使用的场景:
- ❌ 写多读少:频繁写入会导致大量数组复制,性能急剧下降
- ❌ 大数据量:存储大量数据,复制开销不可接受
- ❌ 需要强一致性:需要立即看到最新数据的场景
- ❌ 内存敏感:内存有限,不能接受数据重复
4.3 Collections.synchronizedList详解
实现原理
public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
static class SynchronizedList<E> implements List<E> {
final List<E> list;
final Object mutex; // 同步对象
public boolean add(E e) {
synchronized (mutex) { return list.add(e); }
}
public E get(int index) {
synchronized (mutex) { return list.get(index); }
}
}
特点:
- 所有方法都使用synchronized同步
- 使用mutex对象作为锁
- 复合操作仍需外部同步
使用示例:
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// 单个操作是线程安全的
syncList.add("item");
// 复合操作需要外部同步
synchronized (syncList) {
for (String item : syncList) {
// 安全遍历
}
}
4.4 线程安全List性能对比
性能对比总结
| 实现类 | 读性能 | 写性能 | 适用场景 | 内存占用 | 一致性 |
|---|---|---|---|---|---|
| CopyOnWriteArrayList | ⭐⭐⭐⭐⭐ | ⭐⭐ | 读多写少 | 较大 | 弱一致性 |
| Vector | ⭐⭐ | ⭐⭐⭐ | 已过时,不推荐 | 正常 | 强一致性 |
| Collections.synchronizedList | ⭐⭐ | ⭐⭐⭐ | 低并发场景 | 正常 | 强一致性 |
| ReadWriteLockList | ⭐⭐⭐⭐ | ⭐⭐⭐ | 读写均衡 | 正常 | 强一致性 |
关键结论:
- 读性能: CopyOnWriteArrayList >> ReadWriteLockList > synchronizedList ≈ Vector
- 写性能: synchronizedList ≈ Vector > ReadWriteLockList > CopyOnWriteArrayList
- 内存占用: CopyOnWriteArrayList > 其他
- 一致性: Vector ≈ synchronizedList > ReadWriteLockList > CopyOnWriteArrayList
选择建议
读多写少(读/写比例≥10:1):
- 推荐:CopyOnWriteArrayList
- 原因:读操作无锁,性能极高
读写均衡:
- 推荐:Collections.synchronizedList或ReadWriteLockList
- 原因:性能平衡,强一致性
低并发场景:
- 推荐:Collections.synchronizedList
- 原因:简单易用,性能足够
高并发写入:
- 推荐:分段锁实现
- 原因:减少锁竞争,提升并发性能
第5章:高频面试题精选
面试题1:ArrayList和LinkedList的区别是什么?如何选择?
核心区别:
| 维度 | ArrayList | LinkedList |
|---|---|---|
| 数据结构 | 动态数组 | 双向链表 |
| 随机访问 | O(1) | O(n) |
| 头部插入删除 | O(n) | O(1) |
| 尾部插入删除 | O(1)摊销 | O(1) |
| 内存占用 | 较小 | 较大(2-5倍) |
| 缓存友好性 | 高 | 低 |
选择策略:
选择ArrayList:
- 频繁随机访问(get/set)
- 频繁遍历操作
- 内存敏感的应用
- 数据主要在尾部添加
选择LinkedList:
- 频繁在头部插入删除
- 需要实现队列或双端队列
- 频繁在中间位置插入(有迭代器引用)
- 内存充足,对随机访问要求不高
决策树:
需要频繁随机访问吗?
├── 是 → ArrayList
└── 否 → 需要频繁在头尾操作吗?
├── 是 → LinkedList
└── 否 → 默认选择ArrayList
面试题2:ArrayList的底层实现原理是什么?请详细说明。
ArrayList基于动态数组实现,内部使用Object[] elementData存储元素。
- 数据结构: 动态数组,内存连续分配
- 核心字段:
transient Object[] elementData;
private int size;
protected transient int modCount = 0;
-
默认容量: 10(但无参构造器延迟初始化,第一次add时才分配)
-
扩容机制: 1.5倍增长(
newCapacity = oldCapacity + (oldCapacity >> 1)) -
扩容触发: 当
size + 1 > elementData.length时触发 -
扩容过程: 创建新数组 → 复制元素 → 替换引用
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
}
深入理解:
- 为什么用transient修饰elementData?自定义序列化,只序列化实际元素
面试题3:ArrayList和LinkedList在什么场景下性能差异最大?
性能差异最大的场景:
-
频繁随机访问(ArrayList绝对优势)
// ArrayList:O(1) for (int i = 0; i < 10000; i++) { list.get(i); // 直接数组索引,极快 } // LinkedList:O(n²) for (int i = 0; i < 10000; i++) { list.get(i); // 每次都要遍历,极慢 } // 性能差异:约1000倍 -
频繁头部插入删除(LinkedList绝对优势)
// ArrayList:O(n) for (int i = 0; i < 10000; i++) { list.add(0, i); // 需要移动所有元素,极慢 } // LinkedList:O(1) for (int i = 0; i < 10000; i++) { list.addFirst(i); // 直接修改头节点,极快 } // 性能差异:约100倍 -
遍历操作(ArrayList稍快)
// ArrayList:缓存友好,稍快 for (String item : list) { } // LinkedList:缓存不友好,稍慢 for (String item : list) { } // 性能差异:约30-50%
面试题4:ArrayList的扩容机制是怎样的?为什么选择1.5倍?
- 触发条件:
size + 1 > elementData.length - 计算新容量:
newCapacity = oldCapacity + (oldCapacity >> 1)(1.5倍) - 特殊情况处理: 如果1.5倍仍不足,使用
minCapacity - 创建新数组并复制元素:
Arrays.copyOf(elementData, newCapacity) - 替换引用:
elementData = newElements
为什么选择1.5倍?
1.5倍在扩容频率和空间利用率之间取得最佳平衡。 深入理解:
- 扩容的时间复杂度:单次O(n),但分摊到每次add是O(1)
面试题5:如何优化ArrayList和LinkedList的性能?
标准答案:
ArrayList优化:
-
预分配初始容量(最重要)
List<Integer> list = new ArrayList<>(expectedSize); -
批量操作使用addAll
list.addAll(anotherList); // 一次扩容 -
适时使用trimToSize
list.trimToSize(); // 释放多余空间
LinkedList优化:
-
绝对避免随机访问
// 错误:O(n²) for (int i = 0; i < list.size(); i++) { list.get(i); } // 正确:O(n) for (String item : list) { } -
利用ListIterator
ListIterator<String> it = list.listIterator(); it.add("new item"); // O(1)插入
面试题6:ArrayList的fail-fast机制是什么?如何实现的?
**fail-fast(快速失败)**是Java集合框架的重要特性,当检测到并发修改时立即抛出ConcurrentModificationException。
- modCount字段: 记录集合被结构性修改的次数
- expectedModCount: 迭代器创建时记录当前的modCount
- 检查机制: 每次迭代操作前检查
modCount != expectedModCount
源码实现:
private class Itr implements Iterator<E> {
int expectedModCount = modCount;
public E next() {
checkForComodification();
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
正确做法:
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
if ("B".equals(item)) {
it.remove(); }
}
局限性: 非原子性检查,不保证100%检测并发修改。
深入理解:
- fail-fast vs fail-safe的区别
扩展问题:
- 为什么迭代器的remove方法不会抛出异常?
面试题7:ArrayList和数组有什么区别?
代码对比:
// 数组:固定大小
String[] array = new String[10];
array[0] = "item";
// ArrayList:动态扩容
List<String> list = new ArrayList<>();
list.add("item");
list.add("another");
深入理解:
- ArrayList本质上就是数组的封装
面试题8:LinkedList为什么使用双向链表而不是单向链表?
原因:
- 支持双向遍历: 可以从头到尾,也可以从尾到头
- 高效删除: 删除节点时,可以直接通过prev找到前驱节点,O(1)时间
- ListIterator支持: 支持前向和后向遍历,实现ListIterator接口
- Deque接口实现: 双端队列需要双向链表支持
对比单向链表:
- 单向链表删除需要O(n)找到前驱
- 双向链表删除只需要O(1)
面试题9:如何优化ArrayList的性能?
- 预分配初始容量(最重要)
List<Integer> list = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
list.add(i);
}
- 批量操作使用addAll
target.addAll(source);
- 适时使用trimToSize()
list.trimToSize();
- 选择合适的遍历方式
for (String item : list) { } // 推荐
面试题10:LinkedList的底层实现是什么?为什么使用双向链表?
LinkedList基于双向链表实现,每个节点包含:
E item:存储的元素Node<E> next:指向下一个节点Node<E> prev:指向上一个节点
private 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;
}
}
为什么使用双向链表?
- 支持双向遍历: 可以从头到尾,也可以从尾到头
- 高效删除: 删除节点时,可以直接通过prev找到前驱节点,O(1)时间
- ListIterator支持: 支持前向和后向遍历,实现ListIterator接口
- Deque接口实现: 双端队列需要双向链表支持
对比单向链表:
// 双向链表删除:O(1)
void unlink(Node<E> x) {
x.prev.next = x.next;
x.next.prev = x.prev;
}
// 单向链表删除:需要O(n)找到前驱
void remove(Node<E> x) {
Node<E> prev = first;
while (prev.next != x) {
prev = prev.next;
}
prev.next = x.next;
}
深入理解:
- 双向链表的节点开销:每个节点约28字节(64位JVM压缩指针)
面试题11:LinkedList实现了哪些接口?为什么可以当作队列和栈使用?
LinkedList实现了以下接口:
List:列表接口,提供有序可重复的集合Deque:双端队列接口(继承自Queue)Cloneable:支持克隆Serializable:支持序列化
为什么可以当作队列和栈使用?
因为LinkedList实现了Deque接口,而Deque同时定义了队列和栈的操作:
使用示例:
// 作为队列
Queue<String> queue = new LinkedList<>();
queue.offer("A");
queue.poll();
// 作为栈
Deque<String> stack = new LinkedList<>();
stack.push("A");
stack.pop();
// 作为双端队列
Deque<String> deque = new LinkedList<>();
deque.addFirst("A");
deque.addLast("B");
⚠️ 重要提示: Java官方推荐使用Deque的实现类(如LinkedList、ArrayDeque)代替旧的Stack类,因为Stack基于Vector实现,性能差且设计不佳。
深入理解:
- Stack类的问题:基于Vector,所有方法都同步,性能差
面试题12:LinkedList的get方法为什么性能差?如何优化?
LinkedList的get方法需要遍历链表找到指定索引的节点:
public E get(int index) {
checkElementIndex(index);
return node(index).item; }
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
时间复杂度:O(n),最坏情况需要遍历一半的节点
- 使用迭代器遍历(最重要)
for (int i = 0; i < list.size(); i++) {
list.get(i); }
for (String item : list) {
``` }
- 使用ListIterator在指定位置操作
ListIterator<String> it = list.listIterator(index);
it.add("new item");
it.add("new item");
深入理解:
- 为什么node方法要优化?将最坏情况O(n)优化为平均O(n/2)
面试题13:如何实现一个线程安全的List?
方案1:使用同步包装器
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
syncList.add("item");
synchronized (syncList) {
for (String item : syncList) {
}
}
方案2:使用CopyOnWriteArrayList
List<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("item");
String item = cowList.get(0);
方案3:使用显式锁
public class ThreadSafeList<E> {
private final List<E> list = new ArrayList<>();
private final ReentrantLock lock = new ReentrantLock();
public void add(E element) {
lock.lock();
try {
list.add(element);
} finally {
lock.unlock();
}
}
}
方案4:使用读写锁
public class ReadWriteList<E> {
private final List<E> list = new ArrayList<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public void add(E element) {
lock.writeLock().lock();
try {
list.add(element);
} finally {
lock.writeLock().unlock();
}
}
public E get(int index) {
}
}
- 读多写少:CopyOnWriteArrayList
- 读写均衡:Collections.synchronizedList或读写锁
- 需要精细控制:显式锁
- 低并发:Collections.synchronizedList
面试题14:Vector为什么被淘汰?有哪些替代方案?
Vector被淘汰的原因:
- 性能问题: 所有方法都加synchronized,锁粒度太粗
- 设计过时: JDK 1.0的设计,API设计不够现代化
- 读操作也要锁: 无法并发读取,性能差
- 有更好的替代方案: Collections.synchronizedList、CopyOnWriteArrayList等
替代方案:
List<String> list1 = new ArrayList<>();
List<String> list2 = Collections.synchronizedList(new ArrayList<>());
List<String> list3 = new CopyOnWriteArrayList<>();
深入理解:
- Vector的历史:JDK 1.0就存在,是Java最早的集合类
扩展问题:
- Vector和ArrayList的区别是什么?
面试题15:CopyOnWriteArrayList的实现原理是什么?适用什么场景?
CopyOnWriteArrayList采用**写时复制(Copy-On-Write)**模式:
- 读操作: 无锁,直接访问当前数组,性能极高
- 写操作: 加锁,复制新数组,修改新数组,替换引用
核心代码:
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
Object[] newElements = Arrays.copyOf(elements, elements.length + 1);
newElements[elements.length] = e;
setArray(newElements); return true;
} finally {
}
}
适用场景:
✅ 适合:
- 读多写少(读/写比例≥10:1)
- 配置信息存储
- 事件监听器列表
- 白名单/黑名单
❌ 不适合:
- 写多读少
- 大数据量
- 需要强一致性
- 内存敏感
深入理解:
- 迭代器基于快照,不会抛出ConcurrentModificationException
面试题16:请分析ArrayList的add方法实现,包括扩容过程。
add方法完整流程:
public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}
详细分析:
- ensureCapacityInternal方法:
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
- ensureExplicitCapacity方法:
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
- grow方法(核心扩容算法):
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
}
扩容过程详解:
- 计算新容量(1.5倍)
- 检查特殊情况(不足或超大)
- 创建新数组
- 复制所有元素到新数组(O(n))
- 替换引用
时间复杂度分析:
- 单次扩容:O(n)
- 分摊到每次add:O(1)
- 预分配容量:O(1)
深入理解:
- 为什么选择1.5倍?平衡扩容频率和空间利用率
面试题17:请分析LinkedList的node方法实现,说明其优化原理。
node方法实现:
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
优化原理:
- 未优化版本: 总是从头开始遍历,最坏情况O(n)
- 优化版本: 根据索引位置选择遍历方向,平均O(n/2)
-
索引在前半部分:从头开始遍历
-
索引在后半部分:从尾开始遍历
-
最多只需要遍历一半的节点
-
最坏情况:O(n) → O(n/2)
-
平均情况:O(n/2) → O(n/4)
-
性能提升:约50%
面试题18:如何优化List的性能?有哪些最佳实践?
ArrayList优化:
- 预分配初始容量(最重要)
List<Integer> list = new ArrayList<>(expectedSize);
- 批量操作使用addAll
list.addAll(anotherList);
- 适时使用trimToSize()
list.trimToSize();
- 选择合适的遍历方式
for (String item : list) { } // 推荐
LinkedList优化:
- 绝对避免随机访问
for (int i = 0; i < list.size(); i++) {
list.get(i);
}
``` for (String item : list) { }
- 利用ListIterator
ListIterator<String> it = list.listIterator();
it.add("new item");
通用优化:
- 根据场景选择合适的数据结构
- 避免不必要的装箱拆箱
- 使用Stream API进行批量操作
- 适时使用并行流(大数据量)
性能测试方法:
@Benchmark
public void testArrayList() {
List<Integer> list = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
list.add(i);
}
}
面试题19:ArrayList的扩容会影响性能吗?如何优化?
扩容对性能的影响:
-
时间成本: 单次扩容O(n),需要复制所有元素
-
空间成本: 新旧数组同时存在,内存占用翻倍
-
GC压力: 旧数组成为垃圾,增加GC压力
-
预分配容量(最重要)
int expectedSize = 10000;
List<String> list = new ArrayList<>(expectedSize);
- 使用ensureCapacity预扩容
List<String> list = new ArrayList<>();
list.ensureCapacity(1000000);
- 批量操作使用addAll
list.addAll(anotherList);
- 适时使用trimToSize
list.trimToSize();
面试题20:ArrayList的subList方法返回的是什么?有什么注意事项?
subList返回的是视图(View),不是独立的副本
注意事项:
- 视图特性: subList是原列表的视图,修改会影响原列表
- 失效问题: 原列表的结构性修改会使子列表失效
- 需要独立副本: 如果需要独立副本,使用
new ArrayList<>(list.subList(from, to))
正确使用:
List<Integer> independentCopy = new ArrayList<>(list.subList(1, 4));
list.add(7);
independentCopy.get(0);
面试题21:ArrayList的contains方法如何实现?时间复杂度是多少?
1. 源码实现
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i] == null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
2. 实现原理
- contains 调用 indexOf,判断返回值是否 >= 0
- indexOf 顺序遍历数组查找匹配元素
- null 处理:使用 == 比较
- 非 null 处理:使用 equals() 比较
- 返回第一个匹配元素的索引,未找到返回 -1
3. 时间复杂度
- 最好情况:O(1) — 第一个元素即匹配
- 平均情况:O(n) — 平均遍历 n/2 个元素
- 最坏情况:O(n) — 遍历所有元素或未找到
4. 为什么是O(n)?
ArrayList 基于数组,不支持按值直接定位,只能顺序查找。
5. 性能优化建议
如果频繁调用 contains,可考虑:
方案1:使用HashSet(不保持顺序)
Set<String> set = new HashSet<>(list);
boolean contains = set.contains("item"); // O(1)
方案2:使用LinkedHashSet(保持顺序)
Set<String> linkedSet = new LinkedHashSet<>(list);
boolean contains = linkedSet.contains("item"); // O(1)
面试题22:LinkedList的addAll方法如何实现?性能如何?
1. 源码实现
LinkedList的addAll方法有两个重载版本:
在尾部添加
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
在指定位置插入
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
// 1. 将集合转换为数组
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
// 2. 找到插入位置的前驱和后继节点
Node<E> pred, succ;
if (index == size) {
// 在尾部插入
succ = null;
pred = last;
} else {
// 在中间插入,需要找到index位置的节点
succ = node(index);
pred = succ.prev;
}
// 3. 批量创建节点并链接
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
// 4. 链接到原链表
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
size += numNew;
modCount++;
return true;
}
2. 实现原理
- 集合转数组:使用c.toArray()将集合转为数组,便于遍历
- 定位插入点:根据index找到前驱节点pred和后继节点succ
- 批量创建节点:遍历数组,为每个元素创建新节点并链接
- 链接到链表:将新节点链连接到原链表
3. 性能分析
时间复杂度:
- 最好情况:O(m) — 在尾部插入(index == size),无需查找位置
- 平均情况:O(n/2 + m) — 需要找到插入位置(平均n/2),然后插入m个元素
- 最坏情况:O(n + m) — 在头部插入,需要遍历整个链表找到位置 空间复杂度:O(m) — 需要创建m个新节点
4. 性能优化
相比逐个调用add():
- 只需定位一次插入位置(O(n)),而不是m次
- 批量创建节点,减少链表遍历次数
- 一次性更新size和modCount
性能对比示例:
方式1:逐个add(低效)
for (String item : source) {
list.add(item); // 每次都要遍历找到尾部,O(n) × m
}
方式2:使用addAll(高效)
list.addAll(source); // 只找一次位置,O(n) + O(m)
5. 使用建议
- 批量添加时优先使用addAll,而不是循环调用add()
- 在尾部添加时性能最好(O(m))
- 在头部或中间插入时,性能取决于链表大小和插入位置 总结:addAll通过批量操作和单次定位,显著提升了批量添加的效率,是LinkedList批量操作的首选方法。
面试题23:ArrayList的clear方法如何实现?为什么要遍历置null?
public void clear() {
modCount++;
for (int i = 0; i < size; i++)
elementData[i] = null;
size = 0;
}
为什么要遍历置null?
原因:帮助垃圾回收,防止内存泄漏
内存泄漏示例:
List<BigObject> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add(new BigObject());
}
list.clear();
- 时间复杂度: O(n) - 需要遍历所有元素
- 空间复杂度: O(1) - 不创建新对象
优化建议:
list = new ArrayList<>();
面试题24:LinkedList的size()方法时间复杂度是多少?为什么?
时间复杂度:O(1)
public int size() {
return size;
}
为什么是O(1)?
LinkedList内部维护了size字段,记录链表的元素数量:
transient int size = 0;
void linkLast(E e) {
size++;
}
E unlink(Node<E> x) {
size--;
}
对比:如果不维护size字段
public int size() {
int count = 0;
Node<E> x = first;
while (x != null) {
count++;
x = x.next;
}
return count;
}
优势:
- O(1)时间复杂度,性能优异
- 快速判断是否为空:
size == 0 - 快速获取大小,无需遍历
面试题25:ArrayList的toArray方法有几种重载?有什么区别?
两种重载方法
1. toArray() - 返回Object[]
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}
特点:
- 返回类型:Object[]
- 总是创建新数组
- 需要强制类型转换才能使用
2. toArray(T[] a) - 返回指定类型数组
public <T> T[] toArray(T[] a) {
if (a.length < size)
// 数组太小,创建新数组
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
// 数组足够大,直接复制到传入的数组
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null; // 标记结束位置
return a;
}
特点:
- 返回类型:T[](泛型数组)
- 根据传入数组大小决定是否创建新数组
- 不需要强制类型转换
详细对比
| 维度 | toArray() | toArray(T[] a) |
|---|---|---|
| 返回类型 | Object[] | T[](泛型数组) |
| 类型安全 | 需要强制转换 | 类型安全 |
| 数组创建 | 总是创建新数组 | 可能复用传入数组 |
| 性能 | 一般 | 可优化(传入精确大小) |
| 使用场景 | 简单场景 | 需要类型安全或性能优化 |
使用示例
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
// 方式1:使用toArray()
Object[] array1 = list.toArray();
// String[] strArray1 = (String[]) array1; // 需要强制转换,但会抛出ClassCastException
// 方式2:使用toArray(T[] a) - 传入空数组
String[] array2 = list.toArray(new String[0]); // 自动创建新数组
// 方式3:使用toArray(T[] a) - 传入精确大小的数组(推荐)
String[] array3 = list.toArray(new String[list.size()]); // 复用数组,性能最好
// 方式4:使用Stream API
String[] array4 = list.stream().toArray(String[]::new);
实现原理
toArray(T[] a)的检查逻辑:
- 如果数组太小(a.length < size):
- 创建新数组,避免数组越界
- 使用Arrays.copyOf创建指定类型的新数组
- 如果数组足够大(a.length >= size):
- 直接使用传入的数组,避免创建新数组
- 使用System.arraycopy复制元素
- 如果数组太大(a.length > size):
- 在size位置置null,标记结束位置
- 帮助调用者识别实际元素数量
注意事项
- 类型转换:toArray()返回Object[],不能直接转换为具体类型数组
- 数组大小:toArray(T[] a)如果传入数组太小,会创建新数组
- 性能考虑:传入精确大小的数组可以避免数组复制,提升性能
- null标记:如果传入数组太大,会在size位置置null作为结束标记
最佳实践
// ✅ 推荐:传入精确大小的数组
String[] array = list.toArray(new String[list.size()]);
// ✅ 也可以:传入空数组(Java 6+优化后性能接近)
String[] array = list.toArray(new String[0]);
// ❌ 不推荐:使用toArray()需要强制转换
Object[] array = list.toArray();
总结:优先使用toArray(T[] a),传入精确大小的数组以获得最佳性能和类型安全。
面试题26:LinkedList的remove方法有几种重载?有什么区别?
两种重载方法
1. remove(int index) - 按索引删除
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
特点:
- 参数:int index(索引位置)
- 返回类型:E(被删除的元素)
- 删除指定索引位置的元素
- 如果索引越界,抛出IndexOutOfBoundsException
2. remove(Object o) - 按对象删除
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
特点:
- 参数:Object o(要删除的对象)
- 返回类型:boolean(是否删除成功)
- 删除第一个匹配的元素
- 使用equals()方法比较(null使用==)
- 如果元素不存在,返回false,不抛异常
详细对比
| 维度 | remove(int index) | remove(Object o) |
|---|---|---|
| 参数类型 | int(索引) | Object(对象) |
| 返回值 | E(被删除的元素) | boolean(是否成功) |
| 删除策略 | 按位置删除 | 按值删除(第一个匹配) |
| 异常处理 | 索引越界抛异常 | 元素不存在返回false |
| null处理 | 不涉及 | null使用==比较 |
| 时间复杂度 | O(n) | O(n) |
| 查找方式 | 通过node(index)查找 | 遍历查找匹配元素 |
使用示例
List<String> list = new LinkedList<>(Arrays.asList("A", "B", "C", "B", "D"));
// 方式1:按索引删除
String removed1 = list.remove(1); // 删除索引1的元素"B"
// 结果:removed1 = "B", list = ["A", "C", "B", "D"]
// 方式2:按对象删除
boolean removed2 = list.remove("B"); // 删除第一个"B"
// 结果:removed2 = true, list = ["A", "C", "D"]
// 删除不存在的元素
boolean removed3 = list.remove("X"); // 元素不存在
// 结果:removed3 = false, list不变
// 索引越界
// String removed4 = list.remove(10); // 抛出IndexOutOfBoundsException
性能分析
时间复杂度
两者都是O(n):
- remove(int index):需要先通过node(index)找到节点(O(n)),然后unlink删除(O(1))
- remove(Object o):需要遍历链表查找匹配元素(O(n)),然后unlink删除(O(1))
性能对比
| 场景 | remove(int index) | remove(Object o) |
|---|---|---|
| 头部删除 | O(1) | O(1) |
| 中间删除 | O(n) | O(n) |
| 尾部删除 | O(1) | O(n) |
| 元素不存在 | 抛异常 | 返回false |
实现原理
remove(int index)流程
- 检查索引:checkElementIndex(index)
- 查找节点:node(index) - 根据索引位置选择从头部或尾部遍历
- 删除节点:unlink(node) - 断开节点连接
remove(Object o)流程
- null处理:如果对象为null,使用==比较
- 非null处理:使用equals()方法比较
- 遍历查找:从first开始遍历,找到第一个匹配的节点
- 删除节点:unlink(node) - 断开节点连接
- 返回结果:找到并删除返回true,否则返回false
注意事项
1. 只删除第一个匹配的元素
List<String> list = new LinkedList<>(Arrays.asList("A", "B", "C", "B", "A"));
list.remove("B"); // 只删除第一个"B"
// 结果:["A", "C", "B", "A"]
2. null值的处理
List<String> list = new LinkedList<>(Arrays.asList("A", null, "B", null));
list.remove(null); // 删除第一个null
// 结果:["A", "B", null]
3. 索引越界
List<String> list = new LinkedList<>(Arrays.asList("A", "B", "C"));
// list.remove(10); // 抛出IndexOutOfBoundsException
4. 如何删除所有匹配的元素
// 方式1:使用removeIf(推荐)
list.removeIf(item -> item.equals("B"));
// 方式2:循环删除
while (list.remove("B")) {
// 继续删除
}
// 方式3:使用迭代器
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if ("B".equals(it.next())) {
it.remove();
}
}
最佳实践
推荐做法
// ✅ 如果知道索引,使用remove(int index)
String removed = list.remove(0);
// ✅ 如果知道对象,使用remove(Object o)
boolean success = list.remove("target");
// ✅ 删除所有匹配元素,使用removeIf
list.removeIf(item -> item.equals("target"));
### 不推荐做法
// ❌ 不要循环调用remove(Object o)删除所有匹配元素
while (list.remove("target")) {} // 效率低
// ❌ 不要使用索引删除时不做边界检查
if (index < list.size()) {
list.remove(index);
}
总结
- remove(int index):按位置删除,返回被删除的元素,索引越界抛异常
- remove(Object o):按值删除第一个匹配,返回是否成功,元素不存在返回false
- 两者时间复杂度都是O(n),但适用场景不同
- 删除所有匹配元素时,优先使用removeIf方法
面试题27:ArrayList的clone方法如何实现?是深拷贝还是浅拷贝?
clone方法实现
public Object clone() {
try {
ArrayList<?> v = (ArrayList<?>) super.clone();
v.elementData = Arrays.copyOf(elementData, size);
v.modCount = 0;
return v;
} catch (CloneNotS
实现步骤
- 调用super.clone():创建ArrayList对象的浅拷贝
- 复制数组:使用Arrays.copyOf(elementData, size)创建新数组,只复制实际元素(根据size)
- 重置modCount:将modCount设为0
- 返回新对象
拷贝类型:浅拷贝(Shallow Copy)
ArrayList的clone()是浅拷贝,原因如下:
浅拷贝的特点
- 复制ArrayList对象本身
- 复制数组引用指向的新数组
- 不复制数组中的元素对象
- 原列表和克隆列表的元素引用指向同一对象
验证示例
示例1:基本类型(String等不可变对象)
List<String> original = new ArrayList<>(Arrays.asList("A", "B", "C"));
List<String> cloned = (ArrayList<String>) ((ArrayList<String>) original).clone();
// 修改原列表
original.set(0, "X");
// 克隆列表不受影响(因为String是不可变的)
System.out.println(cloned.get(0)); // 输出:"A"
注意:String不可变,所以看起来像深拷贝,实际仍是浅拷贝。
示例2:可变对象(证明是浅拷贝)
List<List<String>> original = new ArrayList<>();
original.add(new ArrayList<>(Arrays.asList("A")));
List<List<String>> cloned = (ArrayList<List<String>>) ((ArrayList<List<String>>) original).clone();
// 修改原列表中的内部列表
original.get(0).add("B");
// 克隆列表也被影响(共享同一个内部列表对象)
System.out.println(cloned.get(0)); // 输出:[A, B]
浅拷贝 vs 深拷贝对比
| 维度 | 浅拷贝(Shallow Copy) | 深拷贝(Deep Copy) |
|---|---|---|
| 对象本身 | 创建新对象 | 创建新对象 |
| 数组 | 创建新数组 | 创建新数组 |
| 元素对象 | 共享引用(同一对象) | 创建新对象 |
| 修改影响 | 修改元素会影响两个列表 | 修改元素互不影响 |
| 内存占用 | 较少 | 较多 |
| 性能 | 较快 | 较慢 |
为什么是浅拷贝?
- 性能考虑:深拷贝需要递归复制所有元素,开销大
- 通用性:元素类型未知,无法确定如何深拷贝
- 设计原则:默认浅拷贝,需要深拷贝时由开发者实现
如何实现深拷贝?
方案1:手动深拷贝(推荐)
public static <T> List<T> deepCopy(List<T> original) {
List<T> copy = new ArrayList<>();
for (T item : original) {
copy.add(deepCopyItem(item));
}
return copy;
}
private static <T> T deepCopyItem(T item) {
// 根据实际类型实现深拷贝
if (item instanceof Cloneable) {
// 如果元素实现了Cloneable,可以调用clone
return (T) ((Cloneable) item).clone();
}
// 其他情况需要根据具体类型处理
return item; // 如果无法深拷贝,返回原对象
}
方案2:序列化深拷贝(通用但性能较低)
public static <T extends Serializable> List<T> deepCopyBySerialization(List<T> original) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(original);
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
return (List<T>) ois.readObject();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
使用建议
浅拷贝适用场景
- 元素是不可变对象(如String、Integer)
- 不需要修改元素对象
- 只需要独立的列表结构
深拷贝适用场景
- 元素是可变对象(如自定义对象、集合等)
- 需要完全独立的副本
- 修改元素不应影响原列表
注意事项
- 类型转换:clone()返回Object,需要强制类型转换
- 元素共享:浅拷贝时,修改可变元素会影响两个列表
- 性能考虑:深拷贝开销大,仅在必要时使用
- 元素类型:确保元素类型支持深拷贝(实现Cloneable或Serializable)
总结
- ArrayList的clone()是浅拷贝
- 复制了ArrayList对象和数组,但元素对象是共享的
- 对于不可变对象(如String),行为类似深拷贝
- 对于可变对象,修改会影响两个列表
- 需要深拷贝时,需要手动实现或使用序列化等方式
面试题28:ArrayList的isEmpty和size() == 0有什么区别?
源码实现对比
isEmpty()方法
public boolean isEmpty() {
return size == 0;
}
size()方法
public int size() {
return size;
}
详细对比
| 维度 | isEmpty() | size() == 0 |
|---|---|---|
| 方法调用 | 1次方法调用 | 1次方法调用 + 1次比较 |
| 返回值类型 | boolean | boolean(通过比较得到) |
| 语义清晰度 | 语义明确,表达"是否为空" | 语义间接,表达"大小是否为0" |
| 代码可读性 | 更好,意图明确 | 较差,需要理解size的含义 |
| 性能 | O(1) | O(1) |
| 代码规范 | 符合Java编码规范 | 不符合最佳实践 |
| IDE提示 | 通常推荐使用 | 可能提示使用isEmpty() |
性能分析
时间复杂度
两者都是O(1):
- isEmpty():直接返回size == 0的结果
- size() == 0:先调用size()返回size值,再与0比较
性能差异
实际性能几乎相同:
- 都是直接访问size字段
- JVM优化后,两者性能差异可忽略
- 主要区别在于代码可读性和规范
使用示例
List<String> list = new ArrayList<>();
// 方式1:使用isEmpty()(推荐)
if (list.isEmpty()) {
System.out.println("列表为空");
}
// 方式2:使用size() == 0(不推荐)
if (list.size() == 0) {
System.out.println("列表为空");
}
为什么推荐isEmpty()?
1. 可读性更好
// ✅ 语义清晰,表达意图明确
if (list.isEmpty()) {
// 处理空列表
}
// ❌ 需要理解size的含义
if (list.size() == 0) {
// 处理空列表
}
2. 符合Java编码规范
- Java官方文档推荐使用isEmpty()
- 大多数Java代码规范(如Google Java Style Guide)推荐使用isEmpty()
- IDE(如IntelliJ IDEA、Eclipse)通常会提示使用isEmpty()
3. 通用性更好
- isEmpty()适用于所有Collection接口的实现
- size() == 0 只适用于有size()方法的集合
总结
结论:虽然两者功能相同,但isEmpty()更符合Java编码规范和最佳实践,推荐使用isEmpty()。
面试题29:ArrayList的默认容量为什么是10?可以修改吗?
为什么是10?
- 经验值:经过大量测试,10是一个平衡点
- 太小:频繁扩容,性能差
- 太大:浪费内存,初始化慢
- 10是一个折中的选择
可以修改吗?
- 不能直接修改DEFAULT_CAPACITY常量(final)
- 但可以通过构造器指定初始容量
- 可以通过ensureCapacity预扩容
实际使用建议:
// 如果知道大概大小,预分配容量
List<String> list = new ArrayList<>(expectedSize);
// 如果不知道大小,使用默认构造器
List<String> list = new ArrayList<>();
面试题30:ArrayList和LinkedList的内存占用差异有多大?
内存占用对比(存储1000个Integer对象):
ArrayList:
- ArrayList对象:24字节
- 数组对象:16 + 1000 × 8 = 8016字节
- Integer对象:1000 × 16 = 16000字节
- 总计:约24040字节(约23.5KB)
LinkedList:
- LinkedList对象:24字节
- Node对象:1000 × 28 = 28000字节
- Integer对象:1000 × 16 = 16000字节
- 总计:约44024字节(约43KB)
差异:LinkedList是ArrayList的1.83倍
影响因素:
- 元素数量:越多差异越大
- 元素大小:元素越大,相对差异越小
- JVM配置:指针压缩可以减小差距
面试题31:ArrayList的equals方法如何实现?
equals方法源码
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof List))
return false;
ListIterator<E> e1 = listIterator();
ListIterator<?> e2 = ((List<?>) o).listIterator();
while (e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
return !(e1.hasNext() || e2.hasNext());
}
实现步骤
1. 引用相等检查
if (o == this)
return true;
- 如果引用相同,直接返回true
- 避免不必要的遍历
2. 类型检查
if (!(o instanceof List))
return false;
- 检查是否为List类型
- 不是List则返回false
3. 获取迭代器
ListIterator<E> e1 = listIterator();
ListIterator<?> e2 = ((List<?>) o).listIterator();
- 获取两个列表的ListIterator
- 使用迭代器逐个比较元素
4. 逐个比较元素
while (e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
- 同时遍历两个列表
- 对每个元素:
- 如果都为null,认为相等
- 如果都不为null,使用equals()比较
- 如果不等,返回false
5. 长度检查
return !(e1.hasNext() || e2.hasNext());
- 检查是否还有剩余元素
- 如果任一列表还有元素,说明长度不同,返回false
- 否则返回true
关键点
- 先检查引用相等:o == this
- 检查类型:必须是List类型
- 逐个比较元素:使用equals()方法(不是==)
- null处理:两个null视为相等
- 长度检查:确保两个列表长度相同
时间复杂度
- 时间复杂度:O(n)
- 需要遍历所有元素进行比较
- 最好情况:O(1)(引用相等或类型不匹配)
- 最坏情况:O(n)(需要比较所有元素)
equals方法的特性
1. 自反性(Reflexive)
list.equals(list); // 总是返回true
2. 对称性(Symmetric)
list1.equals(list2) == list2.equals(list1); // 总是相等
3. 传递性(Transitive)
// 如果list1.equals(list2) && list2.equals(list3)
// 那么list1.equals(list3)也为true
4. 一致性(Consistent)
// 多次调用equals(),结果应该一致
list1.equals(list2); // true
list1.equals(list2); // true(结果相同)
5. 非空性(Non-nullity)
list.equals(null); // 总是返回false
与hashCode的关系
根据Java规范,如果两个对象equals()返回true,它们的hashCode()也必须相同:
List<String> list1 = new ArrayList<>(Arrays.asList("A", "B"));
List<String> list2 = new ArrayList<>(Arrays.asList("A", "B"));
if (list1.equals(list2)) {
// hashCode必须相同
assert list1.hashCode() == list2.hashCode();
}
总结
- ArrayList的equals()方法实现了完整的相等性比较
- 先检查引用相等,再检查类型,然后逐个比较元素
- 使用equals()比较元素(不是==),正确处理null值
- 时间复杂度为O(n),需要遍历所有元素
- 符合Java的equals()方法规范(自反性、对称性、传递性等)
面试题32:ArrayList的hashCode方法如何实现?
hashCode方法实现:
public int hashCode() {
int hashCode = 1;
for (E e : this)
hashCode = 31 * hashCode + (e == null ? 0 : e.hashCode());
return hashCode;
}
关键点:
- 使用31作为乘数(经验值,31是质数,31×i可以用位移优化)
- 对每个元素调用hashCode方法
- null元素的hashCode为0
- 累加所有元素的hashCode
时间复杂度:O(n)
- 需要遍历所有元素
为什么用31?
- 31是质数,减少哈希冲突
- 31×i可以优化为(i << 5) - i,性能好
- 经验值,经过大量测试验证
面试题33:LinkedList的spliterator方法是什么?如何使用?
spliterator:可分割迭代器,用于并行处理
Spliterator<String> spliterator = list.spliterator();
// 顺序处理
spliterator.forEachRemaining(item -> {
System.out.println(item);
});
// 并行处理
Spliterator<String> spliterator2 = list.spliterator();
Spliterator<String> spliterator3 = spliterator2.trySplit();
// 可以在不同线程中处理spliterator2和spliterator3
特点:
- 支持并行处理
- 可以分割成多个部分
- 用于Stream API的并行流
使用场景:
- 大数据量处理
- 需要并行计算的场景
- Stream API的底层实现
面试题34:ArrayList的sort方法如何实现?时间复杂度是多少?
sort方法实现:
public void sort(Comparator<? super E> c) {
final int expectedModCount = modCount;
Arrays.sort((E[]) elementData, 0, size, c);
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
modCount++;
}
时间复杂度:O(n log n)
- 使用Arrays.sort,底层是TimSort或Dual-Pivot QuickSort
- 平均情况:O(n log n)
- 最好情况:O(n)(已排序)
- 最坏情况:O(n log n)
使用示例:
List<Integer> list = new ArrayList<>(Arrays.asList(3, 1, 4, 1, 5));
list.sort(Comparator.naturalOrder());
// 结果:[1, 1, 3, 4, 5]
注意事项:
- 会检查并发修改
- 使用指定的Comparator排序
- 原地排序,不创建新列表
面试题35:ArrayList的parallelStream和stream有什么区别?
stream:串行流
list.stream()
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
parallelStream:并行流
list.parallelStream()
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
区别:
| 维度 | stream | parallelStream |
|---|---|---|
| 执行方式 | 串行 | 并行 |
| 线程安全 | 不需要 | 需要保证线程安全 |
| 性能 | 小数据量快 | 大数据量快 |
| 适用场景 | 一般场景 | CPU密集型,大数据量 |
注意事项:
- 并行流有开销,小数据量可能更慢
- 需要保证操作是线程安全的
- 通常数据量>10000才考虑并行