引言
书接上文,在你真的了解 ArrayList 吗?(上)中,我们对ArrayList
的成员变量和关键方法的源码进行了分析。今天我们继续通过分析源码来看下ArrayList
的序列化以及 Fail-Fast 机制。希望通过这两篇文章,让大家更好地认识ArrayList
。
ArrayList 的序列化
先来回顾下,上篇文章中提到的序列化相关的内容:
序列化是指将对象转换为字节流的过程,以便将其保存到文件或在网络上传输,它允许我们在不同进程之间传递复杂的数据结构。
ArrayList
实现序列化除了其本身实现Serializable
接口外,被添加到ArrayList
的元素类型也实现了Serializable
接口时,整个ArrayList
才能成功序列化。
transient 关键字
transient
关键字在 Java 中的作用是防止某些成员变量被序列化 。当对象被序列化时,被transient
修饰的成员变量不会被包含在序列化的字节流中,因此在反序列化时这些变量的值会被初始化为默认值。
那么在ArrayList
中有这样的成员变量吗?有的。在ArrayList
的核心成员变量中,实际存储元素的数组elementData
声明如下:
transient Object[] elementData;
到这里,大家会不会有疑问呢?
elementData
数组被transient
修饰了,那么表明它不会自动参与ArrayList
的序列化过程当中,可是该数组中存储的元素就是集合实际存储的元素,如果它不参与序列化过程的话,那么当我们在进行反序列化的时候,之前存储的数据不就丢失了吗?带着这个疑问,我们看下ArrayList
序列化相关源码。
源码分析
序列化写入:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
// 记录当前的修改次数,用于检测并发修改
int expectedModCount = modCount;
/**
* 写入非 transient 修饰的字段
* ObjectOutputStream 类中该方法,用来处理非 transient 字段写入数据流
*/
s.defaultWriteObject();
// 由于 elementData 是一个数组,直接保存其长度是没有意义的,这里保存的是数组中实际使用的元素数量。
s.writeInt(size);
// 遍历 elementData 数组,逐个写入实际使用的元素
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
// 检查在序列化过程中是否有其他线程修改了列表结构
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
从源码中我们可以看出,ArrayList
在进行序列化写入的时候,对elementData
数组进行了单独的处理,它并没有将整个elementData
数组进行写入,而是按照数组中实际存储的元素进行逐个写入的,那么ArrayList
为什么要这样做呢?
节省空间: ArrayList
在存储元素时,可能会有扩容操作,其容量elementData.length
通常大于实际使用的元素数量size
,如果直接序列化整个elementData
数组,会将所有未使用的空位也保存下来,导致不必要的空间浪费。假设一个
ArrayList
当前有 5 个元素,但它的底层数组elementData
的容量是 10。如果直接序列化整个数组,将会保存 10 个对象引用,其中 5 个是null
或者默认值。而实际上,我们只需要保存 5 个实际使用的元素即可。提高效率:在序列化过程中,每个对象都需要通过 I/O 流进行写入。通过只保存实际使用的部分,减少了需要处理的数据量,减少数据量意味着减少了 I/O 操作的次数,提高了性能。
序列化读取:
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
// 初始化为空数组
elementData = EMPTY_ELEMENTDATA;
// 默认读取非 transient 修饰字段
s.defaultReadObject();
/**
* 读取序列化时保存的容量信息,源码中注释将其标记为 ignored
* 为什么要忽略这个容量信息呢?因为容量信息要根据实际元素数量 (size) 动态调整,
* 而不是依赖于序列化时保存的容量信息。这样可以确保反序列化后的 ArrayList
* 只分配足够的空间来存储实际使用的元素,避免内存浪费。
*/
s.readInt(); // ignored
if (size > 0) {
// 根据 size 计算所需的容量
int capacity = calculateCapacity(elementData, size);
SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
// 确保有足够的空间存储所有反序列化后的元素
ensureCapacityInternal(size);
Object[] a = elementData;
// 逐个读取并填充元素到数组中
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
readObject
方法通过读取对象流中的数据,重新构建了ArrayList
对象。它并没有直接使用序列化时保存的集合容量信息,而是根据实际元素数量动态调整了容量,这样可以确保反序列化后的ArrayList
对象与其最初状态一致。
总结:
序列化写入: ArrayList
在序列化时不会直接保存整个elementData
数组,而是通过自定义的writeObject
方法逐个保存实际使用的元素,因此elementData
数组被transient
关键字修饰不会造成序列化数据丢失问题。序列化读取: ArrayList
在反序列化时会根据实际元素数量动态调整容量,而不是依赖于序列化时保存的容量信息,避免了内存浪费。并且它逐个读取并填充元素,确保反序列化后的对象与其最初状态一致。
Fail-Fast 机制
经过前文我们了解到,ArrayList
是线程不安全的集合。当我们在对ArrayList
进行迭代遍历时,如果有另一个线程修改了集合(如添加或删除元素),并且修改次数超过了迭代器创建时的预期值,就会触发 Fail-Fast 机制,抛出并发修改异常(ConcurrentModificationException
),防止迭代继续执行。这样做的目的是为了避免在不确定的数据状态下进行迭代,保证数据的一致性和可靠性。
什么是 Fail-Fast
Fail-Fast 机制是一种错误检测机制,用于在并发修改ArrayList
时检测到不一致的情况。当检测到不一致时,ArrayList
会抛出一个ConcurrentModificationException
异常。这种机制的主要目的是确保在遍历ArrayList
时,列表的结构保持稳定,避免并发修改带来的未知结果。
Fail-Fast 机制原理
先来看一个触发 Fail-Fast 机制的示例代码:
public class ConcurrentModificationExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
// 创建并启动一个修改列表的线程
Thread modifierThread = new Thread(() -> {
list.add("D");
});
modifierThread.start();
// 使用 Iterator 遍历列表
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
try {
Thread.sleep(100L); // 模拟延迟
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/*for (String item : list) {
System.out.println(item);
try {
Thread.sleep(100L); // 模拟延迟
} catch (InterruptedException e) {
e.printStackTrace();
}
}*/
}
}
运行结果:
A
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
at java.util.ArrayList$Itr.next(ArrayList.java:861)
at com.johnny.ConcurrentModificationExample.main(ConcurrentModificationExample.java:21)
上述示例中,对集合进行迭代遍历时,同时开启了另一个线程对集合进行了元素添加的操作,就会触发 Fail-Fast 机制。
使用增强
for
循环进行遍历:使用增强
for
循环同样会触发 Fail-Fast 机制,因为增强for
本身是一个语法糖,通过反编译源码可以看出,其本质也是基于Iterator
实现的。
接下来,我们直接通过迭代器源码来分析ArrayList
的 Fail-Fast 机制原理,迭代器源码比较简单,这里只贴出关键部分进行说明:
private class Itr implements Iterator<E> {
// 下一个要返回元素的索引
int cursor; // index of next element to return
// 上一次返回的元素索引;如果未返回过则为 -1
int lastRet = -1; // index of last element returned; -1 if no such
/**
* 前边源码分析时,ArrayList 的很多核心方法如扩容、添加、删除等都记录了 modCount 变量的值,
* 用于保存当前集合结构被修改的次数。
* 这里将集合已经保存的修改次数存储到 expectedModCount 变量中,方便后续在集合迭代过程中
* 检测集合是否被修改,如被修改则触发 Fail-Fast。
* Fail-Fast 的关键就在与检查 modCount 是否与 expectedModCount 的值相匹配。
*/
int expectedModCount = modCount;
/**
* 此方法就是 Fail-Fast 机制的核心,确保在每次迭代操作前后都检查结构修改。
* 方法中比较 modCount 和 expectedModCount 的值,如果不同则抛出 ConcurrentModificationException。
*/
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
总结:Fail-Fast 机制是通过在ArrayList
内部维护一个 modCount 变量来实现的。该变量记录了对ArrayList
的结构进行修改的次数(如add()
、remove()
、clear()
等)。在每次迭代开始时,迭代器内部会将 modCount 的值保存在一个局部变量 expectedModCount 中。然后,在迭代过程中,每次调用迭代器方法时都会再次检查 modCount 和 expectedModCount 是否相等,如果不相等,就说明有其他线程对ArrayList
进行了修改,就会立即抛出ConcurrentModificationException
异常。
Fail-Fast 机制的局限性
Fail-Fast 机制通过 modCount 和 expectedModCount 来检测集合是否被结构性修改。虽然这种机制能够帮助我们尽早发现并发修改问题,但它也有一些局限性:
竞态条件(Race Condition):在某些情况下,两个或多个线程可能会同时尝试修改集合,而 Fail-Fast 机制可能无法及时检测到这些并发修改。例如,如果一个线程刚刚完成了 checkForComodification()
方法调用,这时另一个线程紧接着进行了集合结构修改,但这是在第一个线程检查 modCount 是否发生变化之后进行的,那么第一个线程可能不会抛出异常。非结构性修改:Fail-Fast 机制只能检测到结构性修改(如添加、删除元素),但不能检测到元素内容的变化。例如,如果你有一个 ArrayList<StringBuilder>
,并且多个线程修改同一个StringBuilder
对象的内容,Fail-Fast 机制将无法检测到这种变化,因为集合本身的结构没有改变。
解决 Fail-Fast 机制的方案
为了更好地处理并发修改问题,可以考虑以下几种方法:
外部同步
使用synchronized
关键字或显式锁来保护对ArrayList
的访问,确保同一时间只有一个线程可以访问列表。
public class SynchronizedExample {
private final List<String> list = new ArrayList<>();
private final Object lock = new Object();
public void addElement(String element) {
synchronized (lock) {
list.add(element);
}
}
public void iterate() {
synchronized (lock) {
for (String item : list) {
System.out.println(item);
}
}
}
}
使用线程安全的替代方案
使用Collections.synchronizedList()
或CopyOnWriteArrayList
等线程安全的集合类。CopyOnWriteArrayList
特别适合读多写少的场景,因为它在每次修改时都会创建新的副本,从而避免了锁定开销。
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteArrayListExample {
private final List<String> list = new CopyOnWriteArrayList<>();
public void addElement(String element) {
list.add(element);
}
public void iterate() {
for (String item : list) {
System.out.println(item);
}
}
}
结语
好啦,讲到这里关于ArrayList
集合的相关内容我们就分析完了。在实际开发中,选择合适的数据结构和并发控制策略是构建高性能应用程序的关键。在这两篇文章中,我们分析了ArrayList
的核心源码,了解了其工作原理。希望通过本文的分析,大家能够更好地理解何时以及如何使用ArrayList
。
您的支持就是对我最大的鼓励!如果本文对您有帮助,请记得点赞、分享、在看哦~~~谢谢!