常见的 List 集合(非线程安全):
常见的 List 集合(线程安全):
总结:
在 Java 中,List 是否能够在遍历的同时修改元素,取决于使用的遍历方法和修改的方式。以下是几种常见遍历方式的分析:
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
for (String item : list) {
item = item + " modified"; // 无法修改原始集合中的元素
}
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]
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]
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]
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
list.forEach(item -> item = item + " modified"); // 无法修改原始集合中的元素
System.out.println(list); // 输出:[A, B, C]
在 Java 中,List 删除指定下标元素最直接的方式是使用 remove(int index)
方法。不过,不同的 List 实现类在删除操作上的性能表现差异很大,让我详细说明一下:
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
// 删除下标为 1 的元素
list.remove(1); // 删除 "B"
时间复杂度:O(n)
List<String> list = new LinkedList<>();
list.add("A");
list.add("B");
list.add("C");
// 删除下标为 1 的元素
list.remove(1); // 删除 "B"
时间复杂度:O(n)
方案一:从后往前删除(适用于 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
(适合读多写少的场景)remove(index)
前要确保 index 在有效范围内(0 到 size()-1)list.remove()
会抛出 ConcurrentModificationException
总的来说,list.remove(index)
是最简单直接的方法,但要根据具体的 List 实现类和使用场景来评估性能影响。
关于 ArrayList 和 LinkedList 的区别,我主要从以下几个维度来说明:
首先从底层实现来看,ArrayList 基于动态数组实现,它在内存中是一块连续的存储空间。而 LinkedList 则是基于双向链表实现的,每个节点包含数据和前后指针。
在随机访问方面,ArrayList 明显占优。因为数组支持下标访问,所以 get(index) 操作的时间复杂度是 O(1)。而 LinkedList 需要从头或尾开始遍历,时间复杂度是 O(n)。
在插入删除操作上,情况要分情况讨论:
从内存角度看,ArrayList 需要预留连续空间,可能存在空间浪费。比如默认容量是 10,扩容时会增长 50%。而 LinkedList 每个节点除了存储数据,还要存储两个指针,单个元素占用的内存更大,但整体上更灵活。
基于这些特点,我通常这样选择:
需要特别注意的是,ArrayList 和 LinkedList 都不是线程安全的。如果需要线程安全的 List,可以考虑:
在实际开发中,我更倾向于使用 CopyOnWriteArrayList 或者通过外部同步机制来保证线程安全,而不是直接使用 Vector。
面试官您好。关于 ArrayList 和 LinkedList 的应用场景,我通常会从它们底层数据结构带来的特性去考虑。
ArrayList,它的底层是动态数组。这使得它在随机访问(也就是通过下标 get(index)
)方面表现非常出色,时间复杂度是 O(1)。所以,如果我的业务场景中,读取和遍历操作远多于插入和删除,并且对元素的访问大多是基于索引的,那么 ArrayList 通常是首选。比如,我们经常用它来存储一些配置信息,或者查询结果集,这些数据一旦加载进来,主要就是读取。
另外,因为它是数组,元素在内存中是连续存储的,这也有利于 CPU 缓存的命中,所以在遍历性能上,如果数据量不大,或者遍历本身很频繁,ArrayList 也可能有优势。不过,需要注意的是,当 ArrayList 容量不足需要扩容时,会涉及到创建新数组和数据拷贝,这是一个相对耗时的操作。因此,如果能预估到大概的数据量,在初始化时指定一个合适的容量,可以减少扩容带来的性能开销。
LinkedList,它底层是双向链表。这让它在插入和删除操作上具有天然的优势。因为对于链表来说,插入或删除一个元素,只需要修改目标位置前后节点的指针即可,时间复杂度是 O(1)(如果操作的是头尾节点的话;如果是在中间插入/删除,还需要先遍历定位到那个节点,定位本身是 O(n))。所以,如果业务场景中,元素的插入和删除非常频繁,尤其是在列表的头部或尾部进行操作,那么 LinkedList 会更合适。比如,用它来实现栈 (Stack) 或者队列 (Queue) 的功能,就很自然。
另外,LinkedList 不需要像 ArrayList 那样连续的内存空间,它的大小可以非常灵活地动态变化,每次增删都只是节点的变化,没有 ArrayList 扩容那样的集中开销。
总结一下就是:
当然,在实际选择时,我们还会考虑数据量的大小。如果数据量非常小,它们之间的性能差异可能并不明显。但当数据量较大时,这些特性差异就会体现出来。在一些特定场景,比如需要频繁在列表的任意位置插入删除,即使 LinkedList 插入删除本身快,但定位到那个位置的开销(O(n))也需要考虑进去,这时候可能 ArrayList 的整体表现(虽然插入删除慢,但可能通过索引能更快定位)或者其他数据结构反而更好。所以,具体问题具体分析也很重要。
ArrayList 本身不是线程安全的。当多个线程同时对 ArrayList 进行结构性修改(如 add、remove)时,可能会导致数据不一致、死循环或抛出 ConcurrentModificationException 等问题。
让我详细说说将 ArrayList 变成线程安全的几种常用方法:
这是最简单直接的方式:
List<String> list = Collections.synchronizedList(new ArrayList<>());
它的原理是返回一个 SynchronizedList 包装类,内部通过 synchronized 同步代码块来保证线程安全。不过要注意,遍历时仍需要手动同步:
synchronized (list) {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
这种方式的缺点是性能开销较大,因为每个方法都需要获取锁。
这是我在实际项目中经常使用的方案,特别适合读多写少的场景:
List<String> list = new CopyOnWriteArrayList<>();
它的实现原理是写操作时复制整个数组,在新数组上修改,然后将引用指向新数组。读操作不需要加锁,所以读性能很高。但写操作开销较大,而且可能存在短暂的数据不一致。
Vector 是 Java 早期提供的线程安全集合:
List<String> list = new Vector<>();
Vector 的所有方法都用 synchronized 修饰,但现在已经不推荐使用了,主要是因为:
根据实际需求,我们可以使用更细粒度的锁控制:
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();
}
}
这种方式灵活性最高,可以根据业务需求优化锁的粒度。
如果不一定需要 List 的有序性,可以考虑使用其他并发集合:
在实际开发中,我通常这样选择:
另外要注意,即使使用了线程安全的集合,复合操作仍可能需要额外同步。比如 “先检查再执行” 这类操作:
// 即使 list 是线程安全的,这个操作整体上仍不是原子的
if (!list.contains(element)) {
list.add(element);
}
这种情况下还是需要外部同步或使用 ConcurrentHashMap 等提供原子操作的集合。
好的,关于 ArrayList 为什么不是线程安全的,以及具体在哪些地方体现了不安全,我可以从以下几个方面来解释:
ArrayList
之所以不是线程安全的,根本原因在于它的所有方法都没有进行同步处理。这意味着在多线程并发访问和修改 ArrayList
时,如果没有外部的同步措施,就可能会导致数据不一致、抛出异常等问题。
具体来说,不安全主要体现在以下几个关键操作上:
add(E e)
) 时的不安全:
add
方法的核心步骤通常是:
ensureCapacityInternal
)。elementData[size]
位置存放元素。size++
。size++
** 非原子性**:size++
实际上是 size = size + 1
,这个操作在底层会被分解为读-改-写三个步骤。如果两个线程同时执行 add
:
size
(例如为 N)。size
(也为 N)。elementData[N]
,然后 size
更新为 N+1。elementData[N]
(覆盖了线程 A 的数据),然后 size
更新为 N+1。size
却看似正确地增加了(如果两个 size++
没有互相干扰的话)。如果 size++
操作本身也交错执行,size
的最终值也可能不正确(例如只增加1,而不是2)。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 中添加元素时,例如调用 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
。
minCapacity
至少会是 DEFAULT_CAPACITY
(默认是 10)。newCapacity
还会与 ArrayList
定义的一个内部常量 MAX_ARRAY_SIZE
(通常是 Integer.MAX_VALUE - 8
)进行比较。如果 newCapacity
超过了这个 MAX_ARRAY_SIZE
,会调用 hugeCapacity()
方法进行处理,尝试分配一个非常大的容量(如 Integer.MAX_VALUE
),但如果连 minCapacity
都大于 MAX_ARRAY_SIZE
,那就会抛出 OutOfMemoryError
。Arrays.copyOf(elementData, newCapacity)
方法创建一个新的、更大容量的数组,并将旧数组中的所有元素完整地复制到这个新数组中。elementData
数组引用会指向这个新创建的数组。旧的数组如果没有其他引用,就会在后续的 GC过程中被回收。扩容因子为什么是 1.5 倍,这确实是一个经典的问题。我认为主要有以下几点考虑:
oldCapacity >> 1
这种位运算非常高效,避免了浮点数运算。最后,由于扩容操作涉及到数组的重新分配和元素的整体复制,这是一个成本相对较高的操作。因此,在实际开发中,如果我们能预估到 ArrayList 大致会存储多少元素,强烈建议在初始化 ArrayList 时就通过构造函数 new ArrayList<>(initialCapacity)
指定一个合适的初始容量,或者在添加大量元素之前调用 ensureCapacity()
方法主动进行一次性扩容,这样可以有效地减少不必要的自动扩容次数,从而提升整体性能。
在 ArrayList list = new ArrayList(10)
中,list 扩容 0 次。
当你使用 new ArrayList(10)
创建 ArrayList 时:
size = 0
(实际元素个数),capacity = 10
(数组容量)ArrayList 只有在以下情况才会扩容:
size + 1 > capacity
,才会触发扩容ArrayList list = new ArrayList(10); // capacity = 10, size = 0
// 添加 1-10 个元素都不会扩容
for(int i = 0; i < 10; i++) {
list.add(i); // 不扩容
}
// 此时 size = 10, capacity = 10
list.add(10); // 添加第11个元素时,才会第一次扩容
// 扩容后 capacity 通常变为 15
因此,仅仅创建 ArrayList list = new ArrayList(10)
时,没有进行任何扩容操作。
好的,关于 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
的,一旦它被修改为指向新数组,其他线程就能立即看到这个变化,后续的读操作就会访问到这个新数组。CopyOnWriteArrayList
的迭代器是一个非常重要的特性。当你调用 iterator()
方法时,它会获取到创建迭代器那一刻的数组快照。CopyOnWriteArrayList
被其他线程修改(添加、删除元素),迭代器也不会抛出 ConcurrentModificationException
。因为它遍历的是创建它时的那个旧的、不变的数组副本。CopyOnWriteArrayList
的迭代器的 remove()
、set()
、add()
方法会抛出 UnsupportedOperationException
,因为修改这个快照没有意义,也不会影响到主列表的当前状态。总结一下 CopyOnWriteArrayList
实现线程安全的关键点:
array
引用使用 volatile
修饰,确保当一个写线程修改了数组引用后,其他线程能够立即看到这个更新。适用场景与优缺点:
ArrayList list = new ArrayList(10)
中,list 扩容 0 次。当你使用 new ArrayList(10)
创建 ArrayList 时:
size = 0
(实际元素个数),capacity = 10
(数组容量)ArrayList 只有在以下情况才会扩容:
size + 1 > capacity
,才会触发扩容ArrayList list = new ArrayList(10); // capacity = 10, size = 0
// 添加 1-10 个元素都不会扩容
for(int i = 0; i < 10; i++) {
list.add(i); // 不扩容
}
// 此时 size = 10, capacity = 10
list.add(10); // 添加第11个元素时,才会第一次扩容
// 扩容后 capacity 通常变为 15
因此,仅仅创建 ArrayList list = new ArrayList(10)
时,没有进行任何扩容操作。
- fail-safe 的,不会抛出 `ConcurrentModificationException`。
因此,在选择使用 CopyOnWriteArrayList
时,需要仔细评估应用的读写比例和对数据实时一致性的要求。如果写操作非常频繁,或者对内存占用非常敏感,那么 Collections.synchronizedList(new ArrayList<>())
或者 ConcurrentLinkedQueue
(如果是队列场景) 以及其他并发集合可能是更好的选择。
面试官您好,在 Java 中实现数组和 List
之间的转换是很常见的需求。
将数组转换为 List
,主要有以下几种方式:
Arrays.asList(array)
:这是最快捷的方式,但需要注意它返回的是一个固定大小的 List
(Arrays.ArrayList
),不支持 add
或 remove
操作,并且它与原数组是视图关系,修改一方会影响另一方。对于基本类型数组,它会把整个数组当作一个元素。new ArrayList<>(Arrays.asList(array))
:通过 ArrayList
的构造函数,可以将 Arrays.asList()
返回的固定大小 List
转换为一个可修改的 java.util.ArrayList
。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
中的元素填充进去。这是类型安全且推荐的做法。
list.toArray(T[]::new)
这种更简洁的写法。在选择转换方式时,需要考虑返回的 List
是否需要修改,以及处理的是对象数组还是基本类型数组,从而选择最合适的方法。
面试官您好,fail-fast 和 fail-safe 是 Java 集合框架中迭代器在面对并发修改时两种不同的错误检测和处理机制。
一、Fail-Fast (快速失败)
remove()
方法进行的修改),迭代器会立即抛出 ConcurrentModificationException
(CME)。ArrayList
, HashMap
, HashSet
, LinkedList
等)的迭代器都采用了 fail-fast 机制。modCount
)。每当集合的结构发生变化(例如,通过集合的 add()
, remove()
, clear()
方法,或者 HashMap
的 put()
导致扩容等),这个 modCount
就会自增。modCount
值记录下来(通常存为 expectedModCount
)。next()
、hasNext()
或 remove()
(如果是迭代器自身的 remove()
) 方法时,迭代器会**比较它自己记录的 expectedModCount
与当前集合的实际 **modCount
。ConcurrentModificationException
。ConcurrentModificationException
仅用于 bug 检测,不应该在程序中捕获并尝试恢复。它表明代码逻辑存在并发问题,需要修复代码。remove()
方法而不是迭代器的 remove()
)或未正确同步的多线程并发修改。二、Fail-Safe (安全失败)
ConcurrentModificationException
,因为它操作的是一个与原始集合在迭代开始(或某个时间点)隔离的副本。java.util.concurrent
包下的并发集合类中,例如:
CopyOnWriteArrayList
CopyOnWriteArraySet
ConcurrentHashMap
(其键、值、条目的视图返回的迭代器也是 fail-safe 或弱一致性的)CopyOnWriteArrayList
** / **CopyOnWriteArraySet
:
add
, remove
等)都会创建一个全新的数组副本,修改在这个副本上进行,然后原子性地将集合内部的数组引用指向这个新副本。ConcurrentHashMap
** 的迭代器**:
ConcurrentModificationException
。CopyOnWriteArrayList
这样的写时复制集合,每次写操作都涉及到数组的完整复制,如果写操作频繁或集合非常大,会导致较高的内存开销和性能损耗。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