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

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

就是說(shuō),
這是 JDK 的一個(gè) bug,并且到 OpenJDK 19.0.2 還沒(méi)有解決的一個(gè)問(wèn)題。bug 單位于,
這是因?yàn)楫?dāng)我們使用 Collections 的方法 synchronizedList 獲取同步容器的時(shí)候(代碼如下),
public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
它會(huì)根據(jù)被包裝的容器是否實(shí)現(xiàn)了 RandomAccess 接口來(lái)判斷使用 SynchronizedRandomAccessList 還是 SynchronizedList 進(jìn)行包裝。RandomAccess 的意思是是否可以在任意位置訪問(wèn)列表的元素,顯然 ArrayList 實(shí)現(xiàn)了這個(gè)接口。所以,當(dāng)我們使用同步容器進(jìn)行包裝的時(shí)候,返回的是 SynchronizedRandomAccessList 這個(gè)類而不是 SynchronizedList 的實(shí)例.
對(duì) SynchronizedRandomAccessList,它有一個(gè) writeReplace() 方法
private Object writeReplace() {
return new SynchronizedList<>(list);
}
這個(gè)方法是用來(lái)兼容 1.4 之前版本的序列化的,所以,當(dāng)對(duì) SynchronizedRandomAccessList 執(zhí)行序列化的時(shí)候會(huì)先調(diào)用 writeReplace() 方法,并將被包裝的 list 對(duì)象傳入,然后使用該方法返回的對(duì)象進(jìn)行序列化而不是原始對(duì)象。
對(duì)于 SynchronizedRandomAccessList,它是 SynchronizedList 的子類,它們對(duì)私有鎖的實(shí)現(xiàn)機(jī)制是相同的,即,兩者都是對(duì)自身的實(shí)例 (也就是 this)進(jìn)行加鎖。所以,兩者持有的 ArrayList 是同一實(shí)例,但是加鎖的卻是不同的對(duì)象。也就是說(shuō),序列化過(guò)程中加鎖的對(duì)象是 writeReplace() 方法創(chuàng)建的 SynchronizedList 的實(shí)例,其他線程修改數(shù)據(jù)時(shí)加鎖的是 SynchronizedRandomAccessList 的實(shí)例。
驗(yàn)證的方式比較簡(jiǎn)單,在 writeObject() 出打斷點(diǎn)獲取 this 對(duì)象和最初的同步容器返回結(jié)果做一個(gè)對(duì)比即可。
總結(jié)
一個(gè)略坑的問(wèn)題,問(wèn)題解決比較簡(jiǎn)單,但是分析過(guò)程有些曲折,主要是被“鎖在序列化過(guò)程被釋放了”這個(gè)想法誤導(dǎo)。而實(shí)際上之所以出現(xiàn)這個(gè)問(wèn)題是因?yàn)榧渔i的是不同的對(duì)象。此外,還有一個(gè)原因是,序列化過(guò)程許多操作是反射執(zhí)行的,比如 writeReplace() 和 writeObject() 這些方法。如果對(duì) JDK 的序列化過(guò)程不了解,很難想到這兩個(gè) private 的方法。
從這個(gè)例子中可以得出的另一個(gè)結(jié)論就是,同步容器和并發(fā)容器實(shí)現(xiàn)邏輯不同,看來(lái)在有些情形下兩者起到的效果還是有區(qū)別的。序列化可能是一個(gè)極端的例子,但是下次序列化一個(gè)列表的時(shí)候是否應(yīng)該考慮到 JDK 的這個(gè) bug 呢?
以上就是JDK序列化Bug難題解決示例詳解的詳細(xì)內(nèi)容,更多關(guān)于JDK序列化Bug難題解決的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Java規(guī)則引擎easy-rules詳細(xì)介紹
本文主要介紹了Java規(guī)則引擎easy-rules詳細(xì)介紹,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-01-01
詳解XML,Object,Json轉(zhuǎn)換與Xstream的使用
這篇文章主要介紹了詳解XML,Object,Json轉(zhuǎn)換與Xstream的使用的相關(guān)資料,需要的朋友可以參考下2017-02-02
Java使用easyExcel導(dǎo)出數(shù)據(jù)及單元格多張圖片
除了平時(shí)簡(jiǎn)單的數(shù)據(jù)導(dǎo)出需求外,我們也經(jīng)常會(huì)遇到一些有固定格式或者模板要求的數(shù)據(jù)導(dǎo)出,下面這篇文章主要給大家介紹了關(guān)于Java使用easyExcel導(dǎo)出數(shù)據(jù)及單元格多張圖片的相關(guān)資料,需要的朋友可以參考下2023-05-05
struts2單個(gè)文件上傳的兩種實(shí)現(xiàn)方式
這篇文章主要介紹了struts2單個(gè)文件上傳的兩種實(shí)現(xiàn)方式,有需要的朋友可以參考一下2014-01-01
drools規(guī)則動(dòng)態(tài)化實(shí)踐解析
這篇文章主要為大家介紹了drools規(guī)則動(dòng)態(tài)化實(shí)踐解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02
SpringBoot單機(jī)限流的實(shí)現(xiàn)
在系統(tǒng)運(yùn)維中, 有時(shí)候?yàn)榱吮苊庥脩舻膼阂馑⒔涌? 會(huì)加入一定規(guī)則的限流,本文主要介紹了SpringBoot單機(jī)限流的實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-08-08
springboot應(yīng)用中靜態(tài)資源訪問(wèn)與接口請(qǐng)求沖突問(wèn)題解決
這篇文章主要介紹了springboot應(yīng)用中靜態(tài)資源訪問(wèn)與接口請(qǐng)求沖突,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-06-06

