Java八股文——集合「List篇」
List
常见的 List 集合(非线程安全):
- ArrayList:
- 实现原理:基于动态数组实现。
- 特点:
- 支持快速的随机访问,即通过索引访问元素的时间复杂度为 O(1)。
- 在添加和删除元素时,如果操作位置不是列表末尾,可能需要移动大量元素,导致性能相对较低。
- 适用场景:
- 适用于需要频繁进行随机访问的场景,如数据查询和展示等。
- 不太适合频繁进行插入和删除操作,尤其是在列表中间或前端插入删除。
- LinkedList:
- 实现原理:基于双向链表实现。
- 特点:
- 在插入和删除元素时,只需要修改链表的指针,时间复杂度为 O(1)。
- 随机访问元素时需要从链表头或尾遍历,时间复杂度为 O(n)。
- 适用场景:
- 适合频繁进行插入和删除操作,尤其是队列、栈等数据结构的实现。
- 适用于需要频繁在列表中间进行插入和删除操作的场景。
常见的 List 集合(线程安全):
- Vector:
- 实现原理:基于动态数组实现,类似于 ArrayList。
- 特点:
- Vector 中的方法大多是同步的,能够保证多线程环境下的数据一致性。
- 由于同步操作,单线程环境下性能略低于 ArrayList。
- 适用场景:
- 在多线程环境中,需要线程安全的 List 实现时使用 Vector。
- 在单线程环境下,由于同步带来的开销,通常更推荐使用 ArrayList。
- CopyOnWriteArrayList:
- 实现原理:基于数组实现,每次修改(如添加、删除元素)都会创建一个新的底层数组,将修改应用到新数组上,读操作在原数组上进行。
- 特点:
- 通过读写分离的机制,提高了并发性能,避免了读操作被写操作阻塞。
- 适用于读操作远多于写操作的场景,如事件监听列表等。
- 适用场景:
- 适用于读操作远多于写操作的并发场景,如事件监听、缓存系统等。
- 在高并发环境下,避免了锁竞争,提高系统的性能和响应速度。
总结:
- 非线程安全的集合:ArrayList 适用于频繁随机访问,而 LinkedList 适合频繁插入和删除。
- 线程安全的集合:Vector 适用于多线程环境,但性能较低;CopyOnWriteArrayList 适用于读多写少的场景,能够有效提升并发性能。
list可以一边遍历一边修改元素吗?
在 Java 中,List 是否能够在遍历的同时修改元素,取决于使用的遍历方法和修改的方式。以下是几种常见遍历方式的分析:
1. 使用 for-each 循环
- 不能修改元素:
- 在 for-each 循环中,你只能访问集合的元素,并不能修改集合中元素的值。
- 这类循环是读取元素的 “只读” 方式,修改会导致编译错误。
- 示例:
List<String> list = new ArrayList<>(List.of("A", "B", "C"));for (String item : list) {item = item + " modified"; // 无法修改原始集合中的元素
}
2. 使用 Iterator
- 可以修改元素:
- Iterator 提供了 set() 方法,可以在遍历时修改元素。此方法会修改当前遍历到的元素的值。
- 需要注意的是,Iterator 不支持通过 remove() 方法以外的方式直接删除元素。
- 示例:
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
Iterator<String> iterator = list.iterator();while (iterator.hasNext()) {String item = iterator.next();iterator.set(item + " modified"); // 使用 set() 方法修改元素
}
System.out.println(list); // 输出:[A modified, B modified, C modified]
3. 使用传统的 for 循环(通过索引)
- 可以修改元素:
- 通过索引访问元素时,可以直接通过 set() 方法修改指定位置的元素。
- 这种方法可以在遍历过程中修改集合的元素。
- 示例:
List<String> list = new ArrayList<>(List.of("A", "B", "C"));for (int i = 0; i < list.size(); i++) {list.set(i, list.get(i) + " modified"); // 使用 set() 方法修改元素
}
System.out.println(list); // 输出:[A modified, B modified, C modified]
4. 使用 ListIterator
- 可以修改元素:
- ListIterator 支持双向遍历,并且通过 set() 方法在遍历过程中修改元素。
- 它提供的 add() 方法可以在遍历时添加元素,remove() 方法可以删除元素。
- 示例:
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
ListIterator<String> listIterator = list.listIterator();while (listIterator.hasNext()) {String item = listIterator.next();listIterator.set(item + " modified"); // 使用 set() 方法修改元素
}
System.out.println(list); // 输出:[A modified, B modified, C modified]
5. 使用 forEach 和 Lambda 表达式
- 不能修改原始集合中的元素:
- 在 forEach 方法中,Lambda 表达式通常用于只读操作,无法直接修改原始集合中的元素。
- 如果要修改元素,你需要在 forEach 内部创建新的对象或在某些情况下通过 set() 方法间接修改。
- 示例:
List<String> list = new ArrayList<>(List.of("A", "B", "C"));list.forEach(item -> item = item + " modified"); // 无法修改原始集合中的元素
System.out.println(list); // 输出:[A, B, C]
总结:
- 可以在遍历过程中修改元素的方式:
- 使用 Iterator 的 set() 方法。
- 使用传统的 for 循环,通过索引的 set() 方法。
- 使用 ListIterator 的 set() 方法,支持双向遍历。
- 不能在遍历过程中直接修改元素的方式:
- 使用 for-each 循环,因为无法直接修改元素。
- 使用 forEach 和 Lambda 表达式,因为它们不支持修改原始集合中的元素。
list如何快速删除某个指定下标的元素?
在 Java 中,List 删除指定下标元素最直接的方式是使用 remove(int index)
方法。不过,不同的 List 实现类在删除操作上的性能表现差异很大,让我详细说明一下:
1. ArrayList 的删除操作
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");// 删除下标为 1 的元素
list.remove(1); // 删除 "B"
时间复杂度:O(n)
- ArrayList 底层是数组,删除元素后需要将后续所有元素向前移动一位
- 如果删除的是最后一个元素,时间复杂度为 O(1)
2. LinkedList 的删除操作
List<String> list = new LinkedList<>();
list.add("A");
list.add("B");
list.add("C");// 删除下标为 1 的元素
list.remove(1); // 删除 "B"
时间复杂度:O(n)
- 虽然 LinkedList 删除节点本身是 O(1),但需要先遍历到指定位置,所以整体还是 O(n)
- 如果删除的是头部或尾部元素,时间复杂度为 O(1)
3. 性能优化建议
如果需要频繁删除操作:
方案一:从后往前删除(适用于 ArrayList)
// 如果要删除多个元素,从后往前删可以避免重复移动
for (int i = list.size() - 1; i >= 0; i--) {if (shouldRemove(list.get(i))) {list.remove(i);}
}
方案二:使用 Iterator 删除
Iterator<String> iterator = list.iterator();
int currentIndex = 0;
while (iterator.hasNext()) {iterator.next();if (currentIndex == targetIndex) {iterator.remove();break;}currentIndex++;
}
方案三:考虑使用其他数据结构
- 如果删除操作非常频繁,可以考虑使用
LinkedList
(删除头尾元素) - 或者使用
CopyOnWriteArrayList
(适合读多写少的场景) - 或者自己维护一个标记删除的机制,批量处理
4. 注意事项
- 索引越界:调用
remove(index)
前要确保 index 在有效范围内(0 到 size()-1) - 并发修改:在遍历时直接调用
list.remove()
会抛出ConcurrentModificationException
- null 值处理:List 可以包含 null 值,删除时不会有特殊问题
总的来说,list.remove(index)
是最简单直接的方法,但要根据具体的 List 实现类和使用场景来评估性能影响。
ArrayList和LinkedList的区别,哪个集合是线程安全的?
关于 ArrayList 和 LinkedList 的区别,我主要从以下几个维度来说明:
底层数据结构的差异
首先从底层实现来看,ArrayList 基于动态数组实现,它在内存中是一块连续的存储空间。而 LinkedList 则是基于双向链表实现的,每个节点包含数据和前后指针。
性能特点对比
在随机访问方面,ArrayList 明显占优。因为数组支持下标访问,所以 get(index) 操作的时间复杂度是 O(1)。而 LinkedList 需要从头或尾开始遍历,时间复杂度是 O(n)。
在插入删除操作上,情况要分情况讨论:
- ArrayList 在尾部添加元素效率很高,通常是 O(1),但如果涉及扩容就是 O(n)。在中间位置插入或删除需要移动后续所有元素,时间复杂度是 O(n)。
- LinkedList 理论上在已知节点位置时,插入删除是 O(1)。但实际使用中,除了头尾操作,我们通常需要先遍历找到位置,所以实际上也是 O(n)。这也是为什么实际项目中 LinkedList 使用较少的原因之一。
内存占用
从内存角度看,ArrayList 需要预留连续空间,可能存在空间浪费。比如默认容量是 10,扩容时会增长 50%。而 LinkedList 每个节点除了存储数据,还要存储两个指针,单个元素占用的内存更大,但整体上更灵活。
使用场景选择
基于这些特点,我通常这样选择:
- 如果需要频繁随机访问,或者主要在尾部进行操作,我会选择 ArrayList
- 如果频繁在头部进行插入删除操作,可以考虑 LinkedList
- 实际上,LinkedList的作者也不使用LinkedList,大多数场景下 ArrayList 的性能都足够好,这也是为什么它使用更广泛
线程安全性
需要特别注意的是,ArrayList 和 LinkedList 都不是线程安全的。如果需要线程安全的 List,可以考虑:
- Vector:内部方法都加了 synchronized,但性能较差
- Collections.synchronizedList():将普通 List 包装成线程安全的
- CopyOnWriteArrayList:适合读多写少的场景,写操作时会复制整个数组
在实际开发中,我更倾向于使用 CopyOnWriteArrayList 或者通过外部同步机制来保证线程安全,而不是直接使用 Vector。
ArrayList 和 LinkedList 的应用场景?
面试官您好。关于 ArrayList 和 LinkedList 的应用场景,我通常会从它们底层数据结构带来的特性去考虑。
ArrayList,它的底层是动态数组。这使得它在随机访问(也就是通过下标 get(index)
)方面表现非常出色,时间复杂度是 O(1)。所以,如果我的业务场景中,读取和遍历操作远多于插入和删除,并且对元素的访问大多是基于索引的,那么 ArrayList 通常是首选。比如,我们经常用它来存储一些配置信息,或者查询结果集,这些数据一旦加载进来,主要就是读取。
另外,因为它是数组,元素在内存中是连续存储的,这也有利于 CPU 缓存的命中,所以在遍历性能上,如果数据量不大,或者遍历本身很频繁,ArrayList 也可能有优势。不过,需要注意的是,当 ArrayList 容量不足需要扩容时,会涉及到创建新数组和数据拷贝,这是一个相对耗时的操作。因此,如果能预估到大概的数据量,在初始化时指定一个合适的容量,可以减少扩容带来的性能开销。
LinkedList,它底层是双向链表。这让它在插入和删除操作上具有天然的优势。因为对于链表来说,插入或删除一个元素,只需要修改目标位置前后节点的指针即可,时间复杂度是 O(1)(如果操作的是头尾节点的话;如果是在中间插入/删除,还需要先遍历定位到那个节点,定位本身是 O(n))。所以,如果业务场景中,元素的插入和删除非常频繁,尤其是在列表的头部或尾部进行操作,那么 LinkedList 会更合适。比如,用它来实现栈 (Stack) 或者队列 (Queue) 的功能,就很自然。
另外,LinkedList 不需要像 ArrayList 那样连续的内存空间,它的大小可以非常灵活地动态变化,每次增删都只是节点的变化,没有 ArrayList 扩容那样的集中开销。
总结一下就是:
- 读多写少,特别是随机访问多,用 ArrayList。
- 写多读少,特别是头尾增删多,用 LinkedList。
当然,在实际选择时,我们还会考虑数据量的大小。如果数据量非常小,它们之间的性能差异可能并不明显。但当数据量较大时,这些特性差异就会体现出来。在一些特定场景,比如需要频繁在列表的任意位置插入删除,即使 LinkedList 插入删除本身快,但定位到那个位置的开销(O(n))也需要考虑进去,这时候可能 ArrayList 的整体表现(虽然插入删除慢,但可能通过索引能更快定位)或者其他数据结构反而更好。所以,具体问题具体分析也很重要。
ArrayList线程安全吗?把ArrayList变成线程安全有哪些方法?
ArrayList 本身不是线程安全的。当多个线程同时对 ArrayList 进行结构性修改(如 add、remove)时,可能会导致数据不一致、死循环或抛出 ConcurrentModificationException 等问题。
让我详细说说将 ArrayList 变成线程安全的几种常用方法:
1. 使用 Collections.synchronizedList()
这是最简单直接的方式:
List<String> list = Collections.synchronizedList(new ArrayList<>());
它的原理是返回一个 SynchronizedList 包装类,内部通过 synchronized 同步代码块来保证线程安全。不过要注意,遍历时仍需要手动同步:
synchronized (list) {Iterator<String> it = list.iterator();while (it.hasNext()) {System.out.println(it.next());}
}
这种方式的缺点是性能开销较大,因为每个方法都需要获取锁。
2. 使用 CopyOnWriteArrayList
这是我在实际项目中经常使用的方案,特别适合读多写少的场景:
List<String> list = new CopyOnWriteArrayList<>();
它的实现原理是写操作时复制整个数组,在新数组上修改,然后将引用指向新数组。读操作不需要加锁,所以读性能很高。但写操作开销较大,而且可能存在短暂的数据不一致。
3. 使用 Vector
Vector 是 Java 早期提供的线程安全集合:
List<String> list = new Vector<>();
Vector 的所有方法都用 synchronized 修饰,但现在已经不推荐使用了,主要是因为:
- 同步粒度太粗,性能较差
- API 设计比较老旧
4. 手动加锁
根据实际需求,我们可以使用更细粒度的锁控制:
private final List<String> list = new ArrayList<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();// 读操作
public String get(int index) {lock.readLock().lock();try {return list.get(index);} finally {lock.readLock().unlock();}
}// 写操作
public void add(String element) {lock.writeLock().lock();try {list.add(element);} finally {lock.writeLock().unlock();}
}
这种方式灵活性最高,可以根据业务需求优化锁的粒度。
5. 使用并发集合
如果不一定需要 List 的有序性,可以考虑使用其他并发集合:
- ConcurrentLinkedQueue:适合队列场景
- ConcurrentSkipListSet:适合需要排序的场景
实际选择建议
在实际开发中,我通常这样选择:
- 读多写少:首选 CopyOnWriteArrayList
- 写操作频繁:考虑 Collections.synchronizedList() 或手动加锁
- 性能要求高:使用 ReadWriteLock 实现读写分离
- 特定场景:考虑是否真的需要 List,可能 Queue 或其他数据结构更合适
另外要注意,即使使用了线程安全的集合,复合操作仍可能需要额外同步。比如 “先检查再执行” 这类操作:
// 即使 list 是线程安全的,这个操作整体上仍不是原子的
if (!list.contains(element)) {list.add(element);
}
这种情况下还是需要外部同步或使用 ConcurrentHashMap 等提供原子操作的集合。
为什么ArrayList不是线程安全的,具体来说是哪里不安全?
好的,关于 ArrayList 为什么不是线程安全的,以及具体在哪些地方体现了不安全,我可以从以下几个方面来解释:
ArrayList
之所以不是线程安全的,根本原因在于它的所有方法都没有进行同步处理。这意味着在多线程并发访问和修改 ArrayList
时,如果没有外部的同步措施,就可能会导致数据不一致、抛出异常等问题。
具体来说,不安全主要体现在以下几个关键操作上:
- 添加元素 (
add(E e)
) 时的不安全:add
方法的核心步骤通常是:- 检查是否需要扩容(
ensureCapacityInternal
)。 - 在
elementData[size]
位置存放元素。 size++
。
- 检查是否需要扩容(
- 不安全点:
size++
** 非原子性**:size++
实际上是size = size + 1
,这个操作在底层会被分解为读-改-写三个步骤。如果两个线程同时执行add
:- 线程 A 读取
size
(例如为 N)。 - 线程 B 读取
size
(也为 N)。 - 线程 A 将元素放在
elementData[N]
,然后size
更新为 N+1。 - 线程 B 也将元素放在
elementData[N]
(覆盖了线程 A 的数据),然后size
更新为 N+1。
最终结果是,一个元素丢失了,但size
却看似正确地增加了(如果两个size++
没有互相干扰的话)。如果size++
操作本身也交错执行,size
的最终值也可能不正确(例如只增加1,而不是2)。
- 线程 A 读取
- 并发扩容问题:当多个线程同时检测到容量不足并尝试扩容时,
grow()
方法(内部创建新数组并拷贝数据)可能会被多次调用,或者在数据拷贝过程中,其他线程可能访问到不一致的中间状态(例如,一个线程看到的是旧数组,另一个线程可能已经把elementData
指向了新数组但数据还没拷贝完)。
- 删除元素 (
remove(int index)
** 或remove(Object o)
) 时的不安全**:remove
操作通常涉及:- 找到元素。
- 移动后续元素填补空位(
System.arraycopy
)。 size--
。
- 不安全点:
- 元素移动与
size--
的非原子性:与add
类似,如果一个线程正在删除元素并移动其他元素,另一个线程可能同时在读取或修改这些正在被移动的元素,导致读到脏数据或操作了错误位置的数据。size--
也不是原子操作,并发修改可能导致size
值不正确。 - 并发删除同一元素或相邻元素:可能导致
ArrayIndexOutOfBoundsException
或非预期的行为。
- 元素移动与
- 获取元素 (
get(int index)
) 与修改操作并发时的不安全:- 虽然
get
操作本身只是读取,但如果它与add
或remove
并发执行,也可能出问题。 - 不安全点:
- 一个线程调用
get(i)
,此时i
是一个有效索引。 - 另一个线程执行
remove(j)
(j <= i),导致i
位置的元素被前移或者i
已经超出了新的size
范围。 - 此时线程一的
get(i)
可能会获取到错误的元素,或者因为rangeCheck
失败(如果size
先被修改)而抛出ArrayIndexOutOfBoundsException
。
- 一个线程调用
- 虽然
- 迭代 (
Iterator
) 过程中的不安全 (Fail-Fast机制):ArrayList
的迭代器是快速失败 (fail-fast) 的。如果在迭代过程中,有其他线程修改了ArrayList
的结构(增、删元素,而不是仅仅修改元素内容),迭代器会尝试抛出ConcurrentModificationException
。- 不安全点:虽然这是为了尽早暴露问题,但它本身也说明了并发修改是不被允许的。
modCount
变量用于检测并发修改,它的检查和更新也不是原子操作,极端情况下可能检测不到并发修改。
总结来说,ArrayList
为了追求性能,完全没有内置任何锁或同步机制来保护其内部状态(如 elementData
数组、size
变量、modCount
变量)在多线程环境下的原子性和可见性。因此,任何依赖于这些状态正确性的操作,在并发下都可能出现问题。
如果需要在线程安全的环境下使用列表,可以考虑:
- 使用
Collections.synchronizedList(new ArrayList<>())
,它会返回一个包装后的线程安全的List
,其每个方法都通过synchronized
关键字进行同步。 - 使用
java.util.concurrent.CopyOnWriteArrayList
,它在写操作时复制底层数组,适合读多写少的场景。
ArrayList的扩容机制说一下
面试官您好,关于 ArrayList 的扩容机制,我的理解是这样的:
ArrayList 的底层是基于动态数组实现的。当我们向 ArrayList 中添加元素时,例如调用 add(E e)
方法,它会首先通过 ensureCapacityInternal()
方法来检查当前数组的容量是否足够。如果现有容量(即 elementData.length
)不足以容纳新添加的元素(即 size + 1
超过了当前容量),就会触发扩容,这个核心逻辑主要在 grow()
方法中。
grow()
方法的扩容步骤大致如下:
- 计算新容量:它会先获取当前的旧容量
oldCapacity
。新的容量newCapacity
通常会按照oldCapacity + (oldCapacity >> 1)
来计算,也就是原容量的 1.5 倍。oldCapacity >> 1
就是利用位运算右移一位,相当于除以2,效率很高。 - 与最小需求容量比较:计算出的
newCapacity
还会和实际需要的最小容量minCapacity
(通常是当前size + 1
)进行比较。如果newCapacity
比minCapacity
还小(比如初始化时旧容量是0,或者扩容后仍然不够),那么newCapacity
就会被更新为minCapacity
。- 这里有一个特殊情况:如果 ArrayList 是通过无参构造函数创建的,并且这是第一次添加元素,
minCapacity
至少会是DEFAULT_CAPACITY
(默认是 10)。
- 这里有一个特殊情况:如果 ArrayList 是通过无参构造函数创建的,并且这是第一次添加元素,
- 检查是否超过最大容量:计算得到的
newCapacity
还会与ArrayList
定义的一个内部常量MAX_ARRAY_SIZE
(通常是Integer.MAX_VALUE - 8
)进行比较。如果newCapacity
超过了这个MAX_ARRAY_SIZE
,会调用hugeCapacity()
方法进行处理,尝试分配一个非常大的容量(如Integer.MAX_VALUE
),但如果连minCapacity
都大于MAX_ARRAY_SIZE
,那就会抛出OutOfMemoryError
。 - 创建新数组并复制元素:确定了最终的新容量后,ArrayList 会通过
Arrays.copyOf(elementData, newCapacity)
方法创建一个新的、更大容量的数组,并将旧数组中的所有元素完整地复制到这个新数组中。 - 更新数组引用:最后,ArrayList 内部维护核心数据的
elementData
数组引用会指向这个新创建的数组。旧的数组如果没有其他引用,就会在后续的 GC过程中被回收。
扩容因子为什么是 1.5 倍,这确实是一个经典的问题。我认为主要有以下几点考虑:
- 效率:
oldCapacity >> 1
这种位运算非常高效,避免了浮点数运算。 - 空间与时间的权衡:1.5 倍被认为是一个在空间利用率和扩容频率之间比较好的折中方案。如果扩容因子太小(比如每次只增加固定大小或者很小的比例),会导致扩容操作过于频繁,而数组拷贝是比较耗时的。如果扩容因子太大(比如直接2倍),虽然能减少扩容次数,但在某些场景下可能会造成更大的内存空间浪费,尤其是在列表的实际元素数量远未达到容量上限时。1.5 倍可以在一定程度上平滑这种开销和浪费。
最后,由于扩容操作涉及到数组的重新分配和元素的整体复制,这是一个成本相对较高的操作。因此,在实际开发中,如果我们能预估到 ArrayList 大致会存储多少元素,强烈建议在初始化 ArrayList 时就通过构造函数 new ArrayList<>(initialCapacity)
指定一个合适的初始容量,或者在添加大量元素之前调用 ensureCapacity()
方法主动进行一次性扩容,这样可以有效地减少不必要的自动扩容次数,从而提升整体性能。
ArrayList list=new ArrayList(10)中的list扩容几次
在 ArrayList list = new ArrayList(10)
中,list 扩容 0 次。
原因分析
初始容量设置
当你使用 new ArrayList(10)
创建 ArrayList 时:
- 直接指定了初始容量为 10
- 内部数组立即分配了长度为 10 的空间
- 此时
size = 0
(实际元素个数),capacity = 10
(数组容量)
扩容机制
ArrayList 只有在以下情况才会扩容:
- 当添加元素时,如果
size + 1 > capacity
,才会触发扩容 - 扩容时通常会将容量增加到原来的 1.5 倍(具体实现可能有差异)
示例说明
ArrayList list = new ArrayList(10); // capacity = 10, size = 0
// 添加 1-10 个元素都不会扩容
for(int i = 0; i < 10; i++) {list.add(i); // 不扩容
}
// 此时 size = 10, capacity = 10list.add(10); // 添加第11个元素时,才会第一次扩容
// 扩容后 capacity 通常变为 15
因此,仅仅创建 ArrayList list = new ArrayList(10)
时,没有进行任何扩容操作。
线程安全的 List, CopyonWriteArraylist是如何实现线程安全的
好的,关于 CopyOnWriteArrayList
(简称 COW ArrayList) 是如何实现线程安全的,我可以从以下几个方面来解释:
CopyOnWriteArrayList
是 Java JUC 包下提供的一个线程安全的 List
实现,它实现线程安全的核心思想正如其名——“写时复制”(Copy-On-Write)。
具体来说,它是这样工作的:
- 数据存储与访问:
CopyOnWriteArrayList
内部持有一个volatile
修饰的数组 (Object[] array
) 来存储元素。volatile
关键字确保了该数组引用在多线程间的可见性。- 读操作:当进行读操作时,比如
get(index)
、iterator()
、size()
等,线程会直接访问当前这个volatile
数组。因为它们读取的是一个不可变的快照(或者说是一个特定时间点的数组副本),所以不需要任何加锁,非常高效。这也是CopyOnWriteArrayList
读多写少场景下性能优秀的关键。
- 写操作(关键机制):
- 当进行写操作时,比如
add(E e)
、set(int index, E element)
、remove(int index)
等,CopyOnWriteArrayList
会执行以下步骤:- 加锁:首先,它会获取一个全局的独占锁(通常是
ReentrantLock
)。这个锁确保了在任何时刻只有一个线程可以执行写操作,避免了并发写导致的数据不一致。 - 复制数组:获取锁之后,线程会创建一个当前内部数组的全新副本。
- 在新副本上修改:所有的修改操作(添加、设置、删除元素)都是在这个新的副本数组上进行的。原来的旧数组在此期间保持不变,仍然可供其他读线程访问。
- 替换数组引用:当修改在新副本上完成后,内部的
volatile
数组引用会原子性地指向这个新的、修改后的数组副本。 - 释放锁:最后,释放锁。
- 加锁:首先,它会获取一个全局的独占锁(通常是
- 由于数组引用是
volatile
的,一旦它被修改为指向新数组,其他线程就能立即看到这个变化,后续的读操作就会访问到这个新数组。
- 当进行写操作时,比如
- 迭代器(Iterator)的特性:
CopyOnWriteArrayList
的迭代器是一个非常重要的特性。当你调用iterator()
方法时,它会获取到创建迭代器那一刻的数组快照。- Fail-Safe(故障安全):这个迭代器是所谓的“fail-safe”的。即使在迭代过程中,原始的
CopyOnWriteArrayList
被其他线程修改(添加、删除元素),迭代器也不会抛出ConcurrentModificationException
。因为它遍历的是创建它时的那个旧的、不变的数组副本。 - 数据一致性:迭代器看到的数据是它创建时的快照,它不会反映迭代器创建之后列表发生的任何修改。这是一种“弱一致性”或“快照一致性”。
- 不支持修改:通常,
CopyOnWriteArrayList
的迭代器的remove()
、set()
、add()
方法会抛出UnsupportedOperationException
,因为修改这个快照没有意义,也不会影响到主列表的当前状态。
总结一下 CopyOnWriteArrayList
实现线程安全的关键点:
- 读写分离:读操作无锁,直接访问当前数组;写操作加锁,并在副本上进行。
- 数据不变性:对于读线程来说,它们访问的数组在它们访问期间是不会改变的,这消除了数据竞争。
- volatile 保证可见性:
array
引用使用volatile
修饰,确保当一个写线程修改了数组引用后,其他线程能够立即看到这个更新。 - ReentrantLock 保证写操作原子性:确保同一时间只有一个线程能修改数组。
适用场景与优缺点:
- 优点:
- 非常适合读多写少的并发场景。读操作性能极高,因为无锁。
- 迭代器是在
ArrayList list = new ArrayList(10)
中,list 扩容 0 次。
原因分析
初始容量设置
当你使用 new ArrayList(10)
创建 ArrayList 时:
- 直接指定了初始容量为 10
- 内部数组立即分配了长度为 10 的空间
- 此时
size = 0
(实际元素个数),capacity = 10
(数组容量)
扩容机制
ArrayList 只有在以下情况才会扩容:
- 当添加元素时,如果
size + 1 > capacity
,才会触发扩容 - 扩容时通常会将容量增加到原来的 1.5 倍(具体实现可能有差异)
示例说明
ArrayList list = new ArrayList(10); // capacity = 10, size = 0
// 添加 1-10 个元素都不会扩容
for(int i = 0; i < 10; i++) {list.add(i); // 不扩容
}
// 此时 size = 10, capacity = 10list.add(10); // 添加第11个元素时,才会第一次扩容
// 扩容后 capacity 通常变为 15
因此,仅仅创建 ArrayList list = new ArrayList(10)
时,没有进行任何扩容操作。
- fail-safe 的,不会抛出 `ConcurrentModificationException`。
- 缺点:
- 写操作成本高:每次写操作都需要复制整个底层数组,如果数组很大或者写操作频繁,会导致性能下降和较高的内存开销。
- 内存占用:在写操作时,会同时存在新旧两个数组,短时间内内存占用会翻倍。
- 数据一致性:读操作可能读取到的是“旧”数据。它保证的是最终一致性,而不是实时一致性。例如,一个线程刚写入数据,另一个线程立即读取,可能读不到最新的数据,要等到数组引用切换完成。
因此,在选择使用 CopyOnWriteArrayList
时,需要仔细评估应用的读写比例和对数据实时一致性的要求。如果写操作非常频繁,或者对内存占用非常敏感,那么 Collections.synchronizedList(new ArrayList<>())
或者 ConcurrentLinkedQueue
(如果是队列场景) 以及其他并发集合可能是更好的选择。
如何实现数组和List之间的转换
面试官您好,在 Java 中实现数组和 List
之间的转换是很常见的需求。
将数组转换为 List
,主要有以下几种方式:
Arrays.asList(array)
:这是最快捷的方式,但需要注意它返回的是一个固定大小的List
(Arrays.ArrayList
),不支持add
或remove
操作,并且它与原数组是视图关系,修改一方会影响另一方。对于基本类型数组,它会把整个数组当作一个元素。new ArrayList<>(Arrays.asList(array))
:通过ArrayList
的构造函数,可以将Arrays.asList()
返回的固定大小List
转换为一个可修改的java.util.ArrayList
。- Java 8 Stream API (
Arrays.stream(array).collect(Collectors.toList())
):这种方式非常灵活,返回的是一个可修改的List
(通常是ArrayList
),并且能很好地处理基本类型数组的转换(通过.boxed()
方法)。
将 List
转换为数组,主要使用 List
接口的 toArray()
方法:
list.toArray()
** (无参)**:返回一个Object[]
数组,需要手动进行类型转换。list.toArray(new T[0])
** (有参,推荐)**:传入一个长度为0的目标类型数组(例如new String[0]
),这个方法会创建一个正确大小和类型的新数组,并将List
中的元素填充进去。这是类型安全且推荐的做法。- (可选补充) 在 Java 11 及以后,还可以使用
list.toArray(T[]::new)
这种更简洁的写法。
- (可选补充) 在 Java 11 及以后,还可以使用
在选择转换方式时,需要考虑返回的 List
是否需要修改,以及处理的是对象数组还是基本类型数组,从而选择最合适的方法。
说说集合中的 fail-fast 和 fail-safe 是什么
面试官您好,fail-fast 和 fail-safe 是 Java 集合框架中迭代器在面对并发修改时两种不同的错误检测和处理机制。
一、Fail-Fast (快速失败)
- 定义与行为:
- fail-fast 机制的核心思想是:一旦检测到在迭代过程中,集合的结构被意外修改(即非通过迭代器自身的
remove()
方法进行的修改),迭代器会立即抛出ConcurrentModificationException
(CME)。 - 这种“快速失败”的策略是为了尽早地暴露并发修改可能导致的问题,而不是让程序在后续操作中基于一个可能已损坏或不一致的集合状态继续执行,从而可能导致更隐蔽的错误或不可预测的行为。
- fail-fast 机制的核心思想是:一旦检测到在迭代过程中,集合的结构被意外修改(即非通过迭代器自身的
- 实现原理:
- 大多数非线程安全的集合类(如
ArrayList
,HashMap
,HashSet
,LinkedList
等)的迭代器都采用了 fail-fast 机制。 - 这些集合内部通常会维护一个修改计数器(
modCount
)。每当集合的结构发生变化(例如,通过集合的add()
,remove()
,clear()
方法,或者HashMap
的put()
导致扩容等),这个modCount
就会自增。 - 当创建迭代器时,迭代器会将当前集合的
modCount
值记录下来(通常存为expectedModCount
)。 - 在迭代过程中,每次调用迭代器的
next()
、hasNext()
或remove()
(如果是迭代器自身的remove()
) 方法时,迭代器会**比较它自己记录的expectedModCount
与当前集合的实际 **modCount
。 - 如果两者不相等,说明在迭代器创建之后,集合被其他方式修改了,此时迭代器就会立即抛出
ConcurrentModificationException
。
- 大多数非线程安全的集合类(如
- 优点:
- 能够尽早地发现并发修改问题,帮助开发者快速定位和修复 bug。
- 避免了在不一致的数据上继续操作可能导致的更严重后果。
- 缺点/注意事项:
ConcurrentModificationException
仅用于 bug 检测,不应该在程序中捕获并尝试恢复。它表明代码逻辑存在并发问题,需要修复代码。- fail-fast 机制并不能完全保证在所有并发修改下都会抛出 CME。它是一种“尽力而为”的检测。在某些极端或特定的并发场景下,可能无法检测到修改,或者检测到修改的时机有延迟。因此,不能完全依赖它来保证线程安全。
- 它主要针对的是单线程中意外修改(例如,在 for-each 循环内部直接调用集合的
remove()
方法而不是迭代器的remove()
)或未正确同步的多线程并发修改。
二、Fail-Safe (安全失败)
- 定义与行为:
- fail-safe 机制的核心思想是:迭代器在迭代时,操作的是集合的一个快照 (snapshot) 或者一个写时复制 (copy-on-write) 的副本,而不是直接在原始集合上进行迭代。
- 因此,即使在迭代过程中原始集合被其他线程修改,迭代器本身也不会抛出
ConcurrentModificationException
,因为它操作的是一个与原始集合在迭代开始(或某个时间点)隔离的副本。 - 迭代器遍历的是创建它时或某个特定时间点的数据视图,它不会反映迭代器创建之后对原始集合所做的任何修改。
- 实现原理:
- 主要存在于
java.util.concurrent
包下的并发集合类中,例如:CopyOnWriteArrayList
CopyOnWriteArraySet
ConcurrentHashMap
(其键、值、条目的视图返回的迭代器也是 fail-safe 或弱一致性的)
CopyOnWriteArrayList
** / **CopyOnWriteArraySet
:- 当创建迭代器时,迭代器会获取对当前底层数组的引用。
- 任何对集合的修改操作(
add
,remove
等)都会创建一个全新的数组副本,修改在这个副本上进行,然后原子性地将集合内部的数组引用指向这个新副本。 - 迭代器仍然遍历的是它创建时引用的那个旧的、未被修改的数组。
ConcurrentHashMap
** 的迭代器**:- 它们的迭代器通常是弱一致性 (weakly consistent) 的,这也可以看作是 fail-safe 行为的一种。它们不会抛出 CME,可以容忍并发修改,并且尽力反映迭代期间的更新,但可能不反映迭代器创建后的所有修改。它们操作的可能也是某种形式的快照或延迟更新的视图。
- 主要存在于
- 优点:
- 高并发性:允许多个线程同时读取和修改集合,而不会因为迭代而抛出异常,提高了并发环境下的可用性。
- **不会抛出 **
ConcurrentModificationException
。
- 缺点/注意事项:
- 数据一致性:迭代器看到的数据可能不是最新的,它只是某个时间点的快照。这是一种“弱一致性”。如果业务逻辑强依赖于迭代时看到的是绝对最新的数据,那么 fail-safe 可能不适用。
- 内存开销:对于像
CopyOnWriteArrayList
这样的写时复制集合,每次写操作都涉及到数组的完整复制,如果写操作频繁或集合非常大,会导致较高的内存开销和性能损耗。 - 迭代器不支持修改:通常,fail-safe 集合的迭代器的
remove()
、set()
、add()
方法会抛出UnsupportedOperationException
,因为修改这个快照副本没有意义,也不会影响到主集合的当前状态。
总结对比:
特性 | Fail-Fast | Fail-Safe |
---|---|---|
并发修改时行为 | 抛出 ConcurrentModificationException | 不抛出异常,继续在副本/快照上迭代 |
数据视图 | 尝试在原始数据上操作,检测到不一致即失败 | 操作的是创建时或某个时间点的副本/快照,非最新数据 |
迭代器修改操作 | 通常支持 remove() (修改原始集合) | 通常不支持 remove() , set() , add() (抛 UnsupportedOperationException ) |
主要应用集合 | ArrayList , HashMap , HashSet 等 (非线程安全) | CopyOnWriteArrayList , ConcurrentHashMap 等 (并发包) |
内存/性能开销 | 迭代本身开销小,但并发修改处理成本高(需修复) | 迭代本身可能开销小,但写时复制等机制可能导致写操作成本高 |
一致性 | 如果不抛 CME,则认为是一致的(但可能有隐藏问题) | 弱一致性 (Stale reads possible) |
目的 | 尽早暴露并发问题 (Bug detection) | 提高并发可用性,容忍并发修改 |
选择哪种机制取决于具体的应用场景:如果是在单线程环境或者能严格控制并发修改,fail-fast 可以帮助发现问题;如果在高并发读多写少的场景,或者需要迭代时能容忍数据不是最新的,fail-safe 的并发集合可能是更好的选择。
参考小林coding和JavaGuide