JDK序列化Bug難題解決示例詳解
1、背景
最近查看應(yīng)用的崩潰記錄的時候遇到了一個跟 Java 序列化相關(guān)的崩潰,

從崩潰的堆棧來看,整個調(diào)用堆棧里沒有我們自己的代碼信息。崩潰的起點是 Android 系統(tǒng)自動存儲 Fragment 的狀態(tài),也就是將數(shù)據(jù)序列化并寫入 Bundle 時。最終出現(xiàn)問題的代碼則位于 ArrayList 的 writeObject() 方法。
這里順帶說明一下,一般我們在使用序列化的時候只需要讓自己的類實現(xiàn) Serializable 接口即可,最多就是為自己的類增加一個名為 SerialVersionUID 的靜態(tài)字段以標(biāo)志序列化的版本號。但是,實際上序列化的過程是可以自定義的,也就是通過 writeObject() 和 readObject() 實現(xiàn)。這兩個方法看上去可能比較古怪,因為他們既不存在于 Object 類,也不存在于 Serializable 接口。所以,對它們沒有覆寫一說,并且還是 private 的。從上述堆棧也可以看出,調(diào)用這兩個方法是通過反射的形式調(diào)用的。
2、分析
從堆棧看出來是序列化過程中報錯,并且是因為 Fragment 狀態(tài)自動保存過程中報錯,報錯的位置不在我們的代碼中,無法也不應(yīng)該使用 hook 的方式解決。
再從報錯信息看,是多線程修改導(dǎo)致的,也就是因為 ArrayList 并不是線程安全的,所以,如果在調(diào)用序列化的過程中其他線程對 ArrayList 做了修改,那么此時就會拋出 ConcurrentModificationException 異常。
但是! 再進(jìn)一步看,為了解決 ArrayList 在多線程環(huán)境中不安全的問題,我這里是用了同步容器進(jìn)行包裝。從堆棧也可以看出,堆棧中包含如下一行代碼,
Collections$SynchronizedCollection.writeObject(Collections.java:2125)
這說明,整個序列化的操作是在同步代碼塊中執(zhí)行的。而就在執(zhí)行過程中,其他線程完成了對 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 這個方法執(zhí)行 1 和 2 之間的代碼的時候,容器被修改了。
但是,該方法的調(diào)用是位于同步容器的同步代碼塊中的,這里出現(xiàn)同步錯誤,我首先想到的是如下幾個原因:
- 同步容器的同步鎖沒有覆蓋所有的方法:基本不可能,標(biāo)準(zhǔn) JDK 應(yīng)該還是嚴(yán)謹(jǐn)?shù)?...
- 外部通過反射直接調(diào)用了同步容器內(nèi)的真實數(shù)據(jù):一般不會有這種騷操作
- 執(zhí)行序列化過程的過程跳過了鎖:雖然是反射調(diào)用,但是代碼邏輯的執(zhí)行是在代碼塊內(nèi)部的
- 執(zhí)行序列化方法的過程中釋放了鎖
3、復(fù)現(xiàn)
帶著上述問題,首先還是先復(fù)現(xiàn)該問題。
該異常還是比較容易復(fù)現(xiàn),
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));
}
}
這里創(chuàng)建了一個容量為 20 的線程池,遍歷 100 次循環(huán),每次往線程池添加一個序列化的任務(wù)以及 19 個修改列表的操作。
按照上述操作,基本 100% 復(fù)現(xiàn)這個問題。
4、解決
如果只是從堆??矗@個問題非常“詭異”,它看上去是在執(zhí)行序列化的過程中把線程的鎖釋放了。所以,為了找到問題的原因我做了幾個測試。
當(dāng)然,我首先想到的是解決并發(fā)修改的問題,除了使用同步容器,另外一種方式是使用并發(fā)容器。ArrayList 對應(yīng)的并發(fā)容器是 CopyOnWriteArrayList。換了該容器之后可以修復(fù)這個問題。
此外,我用自定義同步鎖的形式在序列化操作的外部對整個序列化過程進(jìn)行同步,這種方式也可以解決上述問題。
不過,雖然解決了這個問題,此時還存在一個疑問就是序列化過程中鎖是如何“丟”了的。為了更好地分析問題,我 Copy 了一份 JDK 的 SynchronizedList 的源碼,并使用 Copy 的代碼復(fù)現(xiàn)上述問題,試了很多次也沒有出現(xiàn)。所以,這成了“看上去一樣的代碼,但是執(zhí)行起來結(jié)果不同”。感覺非常“詭異”。 ??
最后,我把這個問題放到了 StackOverflow 上面。國外的一個開發(fā)者解答了這個問題,

就是說,
這是 JDK 的一個 bug,并且到 OpenJDK 19.0.2 還沒有解決的一個問題。bug 單位于,
這是因為當(dāng)我們使用 Collections 的方法 synchronizedList 獲取同步容器的時候(代碼如下),
public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
它會根據(jù)被包裝的容器是否實現(xiàn)了 RandomAccess 接口來判斷使用 SynchronizedRandomAccessList 還是 SynchronizedList 進(jìn)行包裝。RandomAccess 的意思是是否可以在任意位置訪問列表的元素,顯然 ArrayList 實現(xiàn)了這個接口。所以,當(dāng)我們使用同步容器進(jìn)行包裝的時候,返回的是 SynchronizedRandomAccessList 這個類而不是 SynchronizedList 的實例.
對 SynchronizedRandomAccessList,它有一個 writeReplace() 方法
private Object writeReplace() {
return new SynchronizedList<>(list);
}
這個方法是用來兼容 1.4 之前版本的序列化的,所以,當(dāng)對 SynchronizedRandomAccessList 執(zhí)行序列化的時候會先調(diào)用 writeReplace() 方法,并將被包裝的 list 對象傳入,然后使用該方法返回的對象進(jìn)行序列化而不是原始對象。
對于 SynchronizedRandomAccessList,它是 SynchronizedList 的子類,它們對私有鎖的實現(xiàn)機(jī)制是相同的,即,兩者都是對自身的實例 (也就是 this)進(jìn)行加鎖。所以,兩者持有的 ArrayList 是同一實例,但是加鎖的卻是不同的對象。也就是說,序列化過程中加鎖的對象是 writeReplace() 方法創(chuàng)建的 SynchronizedList 的實例,其他線程修改數(shù)據(jù)時加鎖的是 SynchronizedRandomAccessList 的實例。
驗證的方式比較簡單,在 writeObject() 出打斷點獲取 this 對象和最初的同步容器返回結(jié)果做一個對比即可。
總結(jié)
一個略坑的問題,問題解決比較簡單,但是分析過程有些曲折,主要是被“鎖在序列化過程被釋放了”這個想法誤導(dǎo)。而實際上之所以出現(xiàn)這個問題是因為加鎖的是不同的對象。此外,還有一個原因是,序列化過程許多操作是反射執(zhí)行的,比如 writeReplace() 和 writeObject() 這些方法。如果對 JDK 的序列化過程不了解,很難想到這兩個 private 的方法。
從這個例子中可以得出的另一個結(jié)論就是,同步容器和并發(fā)容器實現(xiàn)邏輯不同,看來在有些情形下兩者起到的效果還是有區(qū)別的。序列化可能是一個極端的例子,但是下次序列化一個列表的時候是否應(yīng)該考慮到 JDK 的這個 bug 呢?
以上就是JDK序列化Bug難題解決示例詳解的詳細(xì)內(nèi)容,更多關(guān)于JDK序列化Bug難題解決的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Spring MVC整合Shiro權(quán)限控制的方法
這篇文章主要介紹了Spring MVC整合Shiro權(quán)限控制,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-05-05
使用Sentinel自定義返回和實現(xiàn)區(qū)分來源方式
這篇文章主要介紹了使用Sentinel自定義返回和實現(xiàn)區(qū)分來源方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2025-04-04
WebSocket+Vue+SpringBoot實現(xiàn)語音通話的使用示例
本文主要介紹了WebSocket+Vue+SpringBoot實現(xiàn)語音通話的使用示例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-11-11
Spring\SpringBoot配置連接數(shù)據(jù)庫的方法
最近在學(xué)習(xí)SpringBoot,第一步就是要配置數(shù)據(jù)庫,本文詳細(xì)的介紹了Spring\SpringBoot配置連接數(shù)據(jù)庫的方法,有需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-06-06

