你真的了解 ArrayList 吗?(下)

文摘   2024-12-16 09:00   山东  

引言

书接上文,在你真的了解 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[].classcapacity);
        // 确保有足够的空间存储所有反序列化后的元素
        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 机制的方案

为了更好地处理并发修改问题,可以考虑以下几种方法:

  1. 外部同步

使用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);
            }
        }
    }
}
  1. 使用线程安全的替代方案

使用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

您的支持就是对我最大的鼓励!如果本文对您有帮助,请记得点赞分享在看哦~~~谢谢!

Java驿站
这里是【Java驿站】,一个Java编程学习与交流平台。
 最新文章