1、布景

最近查看运用的溃散记载的时分遇到了一个跟 Java 序列化相关的溃散,

截屏2023-01-26 19.09.08.png

从溃散的仓库来看,整个调用仓库里没有咱们自己的代码信息。溃散的起点是 Android 系统主动存储 Fragment 的状态,也便是将数据序列化并写入 Bundle 时。终究呈现问题的代码则坐落 ArrayList 的 writeObject() 办法。

这儿顺带阐明一下,一般咱们在运用序列化的时分只需要让自己的类完成 Serializable 接口即可,最多便是为自己的类增加一个名为 SerialVersionUID 的静态字段以标志序列化的版别号。可是,实际上序列化的进程是能够自定义的,也便是经过 writeObject()readObject() 完成。这两个办法看上去或许比较古怪,因为他们既不存在于 Object 类,也不存在于 Serializable 接口。所以,对它们没有覆写一说,并且仍是 private 的。从上述仓库也能够看出,调用这两个办法是经过反射的形式调用的。

2、剖析

从仓库看出来是序列化进程中报错,并且是因为 Fragment 状态主动保存进程中报错,报错的方位不在咱们的代码中,无法也不应该运用 hook 的办法处理。

再从报错信息看,是多线程修正导致的,也便是因为 ArrayList 并不是线程安全的,所以,假如在调用序列化的进程中其他线程对 ArrayList 做了修正,那么此刻就会抛出 ConcurrentModificationException 异常。

可是! 再进一步看,为了处理 ArrayList 在多线程环境中不安全的问题,我这儿是用了同步容器进行包装。从仓库也能够看出,仓库中包含如下一行代码,

Collections$SynchronizedCollection.writeObject(Collections.java:2125)

这阐明,整个序列化的操作是在同步代码块中履行的。而就在履行进程中,其他线程完成了对 ArrayList 的修正。

再看一下报错的 ArrayList 的代码,

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount; // 1
    s.defaultWriteObject();
    // Write out size as capacity for behavioural compatibility with clone()
    s.writeInt(size);
    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }
    if (modCount != expectedModCount) { // 2
        throw new ConcurrentModificationException();
    }
}

也便是说,在 writeObject 这个办法履行 1 和 2 之间的代码的时分,容器被修正了。

可是,该办法的调用是坐落同步容器的同步代码块中的,这儿呈现同步错误,我首要想到的是如下几个原因:

  1. 同步容器的同步锁没有掩盖一切的办法:根本不或许,标准 JDK 应该仍是谨慎的 …
  2. 外部经过反射直接调用了同步容器内的真实数据:一般不会有这种骚操作
  3. 履行序列化进程的进程跳过了锁:虽然是反射调用,可是代码逻辑的履行是在代码块内部的
  4. 履行序列化办法的进程中释放了锁

3、复现

带着上述问题,首要仍是先复现该问题。

该异常仍是比较简单复现,

private static final int TOTAL_TEST_LOOP = 100;
private static final int TOTAL_THREAD_COUNT = 20;
private static volatile int writeTaskNo = 0;
private static final List<String> list = Collections.synchronizedList(new ArrayList<>());
private static final Executor executor = Executors.newFixedThreadPool(TOTAL_THREAD_COUNT);
public static void main(String...args) throws IOException {
    for (int i = 0; i < TOTAL_TEST_LOOP; i++) {
        executor.execute(new WriteListTask());
        for (int j=0; j<TOTAL_THREAD_COUNT-1; j++) {
            executor.execute(new ChangeListTask());
        }
    }
}
private static final class ChangeListTask implements Runnable {
    @Override
    public void run() {
        list.add("hello");
        System.out.println("change list job done");
    }
}
private static final class WriteListTask implements Runnable {
    @Override
    public void run() {
        File file = new File("temp");
        OutputStream os = null;
        ObjectOutputStream oos = null;
        try {
            os = new FileOutputStream(file);
            oos = new ObjectOutputStream(os);
            oos.writeObject(list);
            oos.flush();
            os.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                oos.close();
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        System.out.println(String.format("write [%d] list job done", ++writeTaskNo));
    }
}

这儿创建了一个容量为 20 的线程池,遍历 100 次循环,每次往线程池增加一个序列化的使命以及 19 个修正列表的操作。

依照上述操作,根本 100% 复现这个问题。

4、处理

假如只是从仓库看,这个问题十分“怪异”,它看上去是在履行序列化的进程中把线程的锁释放了。所以,为了找到问题的原因我做了几个测验。

当然,我首要想到的是处理并发修正的问题,除了运用同步容器,另外一种办法是运用并发容器。ArrayList 对应的并发容器是 CopyOnWriteArrayList换了该容器之后能够修复这个问题。

此外,我用自定义同步锁的形式在序列化操作的外部对整个序列化进程进行同步,这种办法也能够处理上述问题

不过,虽然处理了这个问题,此刻还存在一个疑问便是序列化进程中锁是如何“丢”了的。为了更好地剖析问题,我 Copy 了一份 JDK 的 SynchronizedList 的源码,并运用 Copy 的代码复现上述问题,试了很屡次也没有呈现。所以,这成了“看上去相同的代码,可是履行起来成果不同”。感觉十分“怪异”。 😓

最终,我把这个问题放到了 StackOverflow 上面。国外的一个开发者回答了这个问题,

截屏2023-01-26 22.14.56.png

便是说,

这是 JDK 的一个 bug,并且到 OpenJDK 19.0.2 还没有处理的一个问题。bug 单坐落,

bugs.openjdk.org/browse/JDK-…

这是因为当咱们运用 Collections 的办法 synchronizedList 获取同步容器的时分(代码如下),

public static <T> List<T> synchronizedList(List<T> list) {
    return (list instanceof RandomAccess ?
            new SynchronizedRandomAccessList<>(list) :
            new SynchronizedList<>(list));
}

它会根据被包装的容器是否完成了 RandomAccess 接口来判别运用 SynchronizedRandomAccessList 仍是 SynchronizedList 进行包装。RandomAccess 的意思是是否能够在任意方位访问列表的元素,显然 ArrayList 完成了这个接口。所以,当咱们运用同步容器进行包装的时分,回来的是 SynchronizedRandomAccessList 这个类而不是 SynchronizedList 的实例.

SynchronizedRandomAccessList,它有一个 writeReplace() 办法

private Object writeReplace() {
    return new SynchronizedList<>(list);
}

这个办法是用来兼容 1.4 之前版别的序列化的,所以,当对 SynchronizedRandomAccessList 履行序列化的时分会先调用 writeReplace() 办法,并将被包装的 list 目标传入,然后运用该办法回来的目标进行序列化而不是原始目标。

对于 SynchronizedRandomAccessList,它是 SynchronizedList 的子类,它们对私有锁的完成机制是相同的,即,两者都是对自身的实例 (也便是 this)进行加锁。所以,两者持有的 ArrayList 是同一实例,可是加锁的却是不同的目标。也便是说,序列化进程中加锁的目标是 writeReplace() 办法创建的 SynchronizedList 的实例,其他线程修正数据时加锁的是 SynchronizedRandomAccessList 的实例。

验证的办法比较简单,在 writeObject() 出打断点获取 this 目标和最初的同步容器回来成果做一个比照即可。

总结

一个略坑的问题,问题处理比较简单,可是剖析进程有些曲折,主要是被“锁在序列化进程被释放了”这个主意误导。而实际上之所以呈现这个问题是因为加锁的是不同的目标。此外,还有一个原因是,序列化进程许多操作是反射履行的,比如 writeReplace()writeObject() 这些办法。假如对 JDK 的序列化进程不了解,很难想到这两个 private 的办法。

从这个例子中能够得出的另一个结论便是,同步容器和并发容器完成逻辑不同,看来在有些景象下两者起到的作用仍是有区别的。序列化或许是一个极端的例子,可是下次序列化一个列表的时分是否应该考虑到 JDK 的这个 bug 呢?